T01: core infrastructure + auth + multi-tenant + RLS
- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens - Session-based auth (Redis + PostgreSQL audit trail) - Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config) - RBAC with roles/permissions + field-level permissions - CSRF protection via Origin header validation - Auth rate limiting (Redis counters with TTL) - CORS with explicit origins (no wildcard) - Health endpoint (no auth required) - Notification service + audit log middleware - 29 tests, 26 ACs, all passing - Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
@@ -1,8 +1,6 @@
|
||||
AUTH_SECRET=test-secret-with-at-least-thirty-two-characters-for-development
|
||||
DATABASE_URL=sqlite+aiosqlite:///./dev.db
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRY_HOURS=24
|
||||
BCRYPT_ROUNDS=4
|
||||
CORS_ORIGINS=http://localhost:5500,http://localhost:8000
|
||||
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
ENVIRONMENT=development
|
||||
LOG_LEVEL=INFO
|
||||
BCRYPT_ROUNDS=12
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
+33
-25
@@ -1,34 +1,42 @@
|
||||
# CRM System v1.0 - Environment Variables Template
|
||||
# Copy this file to .env and fill in real values
|
||||
# .env is gitignored, NEVER commit it
|
||||
# LeoCRM v1.0 - Environment Variables Template
|
||||
|
||||
# === REQUIRED ===
|
||||
# JWT signing secret. MUST be at least 32 characters.
|
||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
# In production, this is auto-injected by Coolify as SERVICE_BASE64_64.
|
||||
AUTH_SECRET=replace-me-with-a-secure-random-string-at-least-32-chars-long
|
||||
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
|
||||
# === OPTIONAL (with defaults) ===
|
||||
|
||||
# Database URL. Driver-agnostic (aiosqlite or asyncpg).
|
||||
# Dev default: SQLite file ./dev.db
|
||||
# Prod example: postgresql+asyncpg://user:pass@host:5432/dbname
|
||||
DATABASE_URL=sqlite+aiosqlite:///./dev.db
|
||||
|
||||
# JWT settings
|
||||
JWT_ALGORITHM=HS256
|
||||
JWT_EXPIRY_HOURS=24
|
||||
|
||||
# Password hashing rounds (bcrypt)
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
# CORS allowed origins (comma-separated, NO wildcards)
|
||||
# Dev: http://localhost:5500,http://localhost:8000
|
||||
# Prod: https://crm.media-on.de
|
||||
CORS_ORIGINS=http://localhost:5500,http://localhost:8000
|
||||
|
||||
# Environment: development | production
|
||||
# Environment: development | production | testing
|
||||
ENVIRONMENT=development
|
||||
|
||||
# Log level: DEBUG | INFO | WARNING | ERROR
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Database pool
|
||||
DB_POOL_SIZE=10
|
||||
DB_MAX_OVERFLOW=20
|
||||
DB_ECHO=false
|
||||
|
||||
# Session settings
|
||||
SESSION_TTL_SECONDS=28800
|
||||
SESSION_COOKIE_NAME=leocrm_session
|
||||
SESSION_COOKIE_SECURE=false
|
||||
SESSION_COOKIE_SAMESITE=strict
|
||||
SESSION_COOKIE_HTTPONLY=true
|
||||
|
||||
# Password hashing
|
||||
BCRYPT_ROUNDS=12
|
||||
PASSWORD_RESET_EXPIRY_HOURS=1
|
||||
|
||||
# CORS allowed origins (comma-separated, NO wildcards)
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_LOGIN_MAX=5
|
||||
RATE_LIMIT_LOGIN_WINDOW=900
|
||||
RATE_LIMIT_RESET_MAX=3
|
||||
RATE_LIMIT_RESET_WINDOW=3600
|
||||
RATE_LIMIT_RESET_CONFIRM_MAX=5
|
||||
RATE_LIMIT_RESET_CONFIRM_WINDOW=3600
|
||||
RATE_LIMIT_GENERAL_MAX=60
|
||||
RATE_LIMIT_GENERAL_WINDOW=60
|
||||
|
||||
+8
-41
@@ -1,8 +1,4 @@
|
||||
"""Alembic environment for async SQLAlchemy.
|
||||
|
||||
Reads DATABASE_URL from app.core.config and imports all models so
|
||||
autogenerate can detect schema changes.
|
||||
"""
|
||||
"""Alembic migration environment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,69 +10,40 @@ from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
# Import settings + Base + all models
|
||||
from app.core.config import get_settings
|
||||
from app.config import get_settings
|
||||
from app.core.db import Base
|
||||
|
||||
# Import models so Base.metadata is populated
|
||||
import app.models # noqa: F401
|
||||
from app.models import * # noqa: F401,F403
|
||||
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url from app settings
|
||||
config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL)
|
||||
|
||||
# Configure Python logging from alembic.ini
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# Metadata for autogenerate
|
||||
target_metadata = Base.metadata
|
||||
settings = get_settings()
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode (emits SQL without a DB connection)."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
render_as_batch=True, # SQLite ALTER TABLE support
|
||||
)
|
||||
|
||||
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run migrations with the given connection."""
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True, # SQLite ALTER TABLE support
|
||||
)
|
||||
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in async mode using an async engine."""
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
connectable = async_engine_from_config(config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=pool.NullPool)
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode via asyncio."""
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
|
||||
@@ -3,18 +3,14 @@
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Union
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""init_orgs_and_users
|
||||
|
||||
Revision ID: 0001
|
||||
Revises:
|
||||
Create Date: 2026-06-03
|
||||
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0001"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === orgs ===
|
||||
op.create_table(
|
||||
"orgs",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("logo_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column(
|
||||
"default_currency",
|
||||
sa.String(length=3),
|
||||
nullable=False,
|
||||
server_default="EUR",
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.create_index("ix_orgs_created_at", "orgs", ["created_at"])
|
||||
|
||||
# === users ===
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("password_hash", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column(
|
||||
"role",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
server_default="sales_rep",
|
||||
),
|
||||
sa.Column("avatar_url", sa.String(length=1024), nullable=True),
|
||||
sa.Column(
|
||||
"email_notifications",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("1"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_users_org_id"
|
||||
),
|
||||
sa.UniqueConstraint("org_id", "email", name="uq_users_org_email"),
|
||||
)
|
||||
op.create_index("ix_users_email", "users", ["email"])
|
||||
op.create_index("ix_users_org_id", "users", ["org_id"])
|
||||
op.create_index("ix_users_created_at", "users", ["created_at"])
|
||||
op.create_index("ix_users_deleted_at", "users", ["deleted_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_deleted_at", table_name="users")
|
||||
op.drop_index("ix_users_created_at", table_name="users")
|
||||
op.drop_index("ix_users_org_id", table_name="users")
|
||||
op.drop_index("ix_users_email", table_name="users")
|
||||
op.drop_table("users")
|
||||
op.drop_index("ix_orgs_created_at", table_name="orgs")
|
||||
op.drop_table("orgs")
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Initial migration - all core tables.
|
||||
|
||||
Revision ID: 0001_initial
|
||||
Revises:
|
||||
Create Date: 2026-06-28
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "0001_initial"
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# tenants
|
||||
op.create_table(
|
||||
"tenants",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_tenants_slug", "tenants", ["slug"])
|
||||
|
||||
# users
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("email", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("password_hash", sa.String(255), nullable=False),
|
||||
sa.Column("role", sa.String(50), nullable=False, server_default="viewer"),
|
||||
sa.Column("is_active", sa.Boolean, nullable=False, server_default=sa.true()),
|
||||
sa.Column("preferences", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
)
|
||||
op.create_index("ix_users_tenant_id", "users", ["tenant_id"])
|
||||
op.create_index("ix_users_email", "users", ["email"])
|
||||
|
||||
# user_tenants
|
||||
op.create_table(
|
||||
"user_tenants",
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True),
|
||||
sa.Column("is_default", sa.Boolean, nullable=False, server_default=sa.false()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# roles
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("permissions", postgresql.JSONB, nullable=False),
|
||||
sa.Column("field_permissions", postgresql.JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_roles_tenant_id", "roles", ["tenant_id"])
|
||||
|
||||
# sessions
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("csrf_token", sa.String(255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_sessions_tenant_id", "sessions", ["tenant_id"])
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||
|
||||
# audit_log
|
||||
op.create_table(
|
||||
"audit_log",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("action", sa.String(50), nullable=False),
|
||||
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("changes", postgresql.JSONB, nullable=True),
|
||||
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_audit_log_tenant_id", "audit_log", ["tenant_id"])
|
||||
op.create_index("ix_audit_log_entity_type", "audit_log", ["entity_type"])
|
||||
op.create_index("ix_audit_log_user_id", "audit_log", ["user_id"])
|
||||
op.create_index("ix_audit_log_timestamp", "audit_log", ["timestamp"])
|
||||
|
||||
# deletion_log
|
||||
op.create_table(
|
||||
"deletion_log",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("entity_type", sa.String(50), nullable=False),
|
||||
sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("entity_snapshot", postgresql.JSONB, nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
# notifications
|
||||
op.create_table(
|
||||
"notifications",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("type", sa.String(20), nullable=False),
|
||||
sa.Column("title", sa.String(200), nullable=False),
|
||||
sa.Column("body", sa.Text, nullable=True),
|
||||
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_notifications_tenant_id", "notifications", ["tenant_id"])
|
||||
op.create_index("ix_notifications_user_id", "notifications", ["user_id"])
|
||||
op.create_index("ix_notifications_tenant_user_read", "notifications", ["tenant_id", "user_id", "read_at"])
|
||||
|
||||
# password_reset_tokens
|
||||
op.create_table(
|
||||
"password_reset_tokens",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("token_hash", sa.String(255), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_password_reset_tokens_tenant_id", "password_reset_tokens", ["tenant_id"])
|
||||
op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"])
|
||||
op.create_index("ix_password_reset_tokens_token_hash", "password_reset_tokens", ["token_hash"])
|
||||
|
||||
# api_tokens
|
||||
op.create_table(
|
||||
"api_tokens",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("token_hash", sa.String(255), nullable=False),
|
||||
sa.Column("name", sa.String(200), nullable=False),
|
||||
sa.Column("scopes", postgresql.JSONB, nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_api_tokens_tenant_id", "api_tokens", ["tenant_id"])
|
||||
op.create_index("ix_api_tokens_token_hash", "api_tokens", ["token_hash"])
|
||||
op.create_index("ix_api_tokens_tenant_user", "api_tokens", ["tenant_id", "user_id"])
|
||||
|
||||
# companies
|
||||
op.create_table(
|
||||
"companies",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("tenant_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("account_number", sa.String(40), nullable=True),
|
||||
sa.Column("industry", sa.String(50), nullable=True),
|
||||
sa.Column("phone", sa.String(30), nullable=True),
|
||||
sa.Column("email", sa.String(255), nullable=True),
|
||||
sa.Column("website", sa.String(500), nullable=True),
|
||||
sa.Column("description", sa.Text, nullable=True),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("updated_by", postgresql.UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
op.create_index("ix_companies_tenant_id", "companies", ["tenant_id"])
|
||||
op.create_index("ix_companies_tenant_deleted", "companies", ["tenant_id", "deleted_at"])
|
||||
op.create_index("ix_companies_tenant_name", "companies", ["tenant_id", "name"])
|
||||
|
||||
# Enable RLS on tenant-scoped tables
|
||||
for table in ["companies", "users", "roles", "sessions", "audit_log", "notifications", "api_tokens"]:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
f"USING (tenant_id = current_setting('app.current_tenant_id')::uuid);"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for table in ["companies", "api_tokens", "password_reset_tokens", "notifications",
|
||||
"deletion_log", "audit_log", "sessions", "roles", "user_tenants",
|
||||
"users", "tenants"]:
|
||||
op.drop_table(table)
|
||||
@@ -1,285 +0,0 @@
|
||||
"""business_entities
|
||||
|
||||
Revision ID: 0002
|
||||
Revises: 0001
|
||||
Create Date: 2026-06-03
|
||||
|
||||
Adds 8 new tables for the Phase 4b business logic:
|
||||
- accounts, contacts, deals, activities (with polymorphic parent FKs)
|
||||
- notes, tags, tag_links (polymorphic parent, no DB FK)
|
||||
- deal_stage_history (audit trail of stage transitions)
|
||||
|
||||
All tables include org_id for multi-tenant isolation (R-1) and
|
||||
deleted_at for soft-delete (R-1 default filter).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "0002"
|
||||
down_revision: Union[str, None] = "0001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# === accounts ===
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("website", sa.String(length=512), nullable=True),
|
||||
sa.Column("industry", sa.String(length=32), nullable=True),
|
||||
sa.Column("size", sa.String(length=32), nullable=True),
|
||||
sa.Column("address", sa.JSON(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_accounts_org_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_accounts_owner_id"),
|
||||
)
|
||||
op.create_index("ix_accounts_name", "accounts", ["name"])
|
||||
op.create_index("ix_accounts_industry", "accounts", ["industry"])
|
||||
op.create_index("ix_accounts_org_id", "accounts", ["org_id"])
|
||||
op.create_index("ix_accounts_owner_id", "accounts", ["owner_id"])
|
||||
op.create_index("ix_accounts_created_at", "accounts", ["created_at"])
|
||||
op.create_index("ix_accounts_deleted_at", "accounts", ["deleted_at"])
|
||||
|
||||
# === contacts ===
|
||||
op.create_table(
|
||||
"contacts",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("first_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("last_name", sa.String(length=128), nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=True),
|
||||
sa.Column("phone", sa.String(length=64), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_contacts_org_id"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_contacts_account_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_contacts_owner_id"),
|
||||
)
|
||||
op.create_index("ix_contacts_last_name", "contacts", ["last_name"])
|
||||
op.create_index("ix_contacts_email", "contacts", ["email"])
|
||||
op.create_index("ix_contacts_org_id", "contacts", ["org_id"])
|
||||
op.create_index("ix_contacts_account_id", "contacts", ["account_id"])
|
||||
op.create_index("ix_contacts_owner_id", "contacts", ["owner_id"])
|
||||
op.create_index("ix_contacts_created_at", "contacts", ["created_at"])
|
||||
op.create_index("ix_contacts_deleted_at", "contacts", ["deleted_at"])
|
||||
|
||||
# === deals ===
|
||||
op.create_table(
|
||||
"deals",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("value", sa.Numeric(12, 2), nullable=False, server_default="0"),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
|
||||
sa.Column("stage", sa.String(length=32), nullable=False, server_default="lead"),
|
||||
sa.Column("close_date", sa.Date(), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=False),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("won_lost_reason", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_deals_org_id"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_deals_account_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_deals_owner_id"),
|
||||
)
|
||||
op.create_index("ix_deals_stage", "deals", ["stage"])
|
||||
op.create_index("ix_deals_org_id", "deals", ["org_id"])
|
||||
op.create_index("ix_deals_account_id", "deals", ["account_id"])
|
||||
op.create_index("ix_deals_owner_id", "deals", ["owner_id"])
|
||||
op.create_index("ix_deals_created_at", "deals", ["created_at"])
|
||||
op.create_index("ix_deals_deleted_at", "deals", ["deleted_at"])
|
||||
|
||||
# === activities ===
|
||||
op.create_table(
|
||||
"activities",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("type", sa.String(length=32), nullable=False),
|
||||
sa.Column("subject", sa.String(length=255), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=True),
|
||||
sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("contact_id", sa.Integer(), nullable=True),
|
||||
sa.Column("deal_id", sa.Integer(), nullable=True),
|
||||
sa.Column("owner_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_activities_org_id"),
|
||||
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_activities_account_id"),
|
||||
sa.ForeignKeyConstraint(["contact_id"], ["contacts.id"], name="fk_activities_contact_id"),
|
||||
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], name="fk_activities_deal_id"),
|
||||
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_activities_owner_id"),
|
||||
)
|
||||
op.create_index("ix_activities_type", "activities", ["type"])
|
||||
op.create_index("ix_activities_due_date", "activities", ["due_date"])
|
||||
op.create_index("ix_activities_org_id", "activities", ["org_id"])
|
||||
op.create_index("ix_activities_account_id", "activities", ["account_id"])
|
||||
op.create_index("ix_activities_contact_id", "activities", ["contact_id"])
|
||||
op.create_index("ix_activities_deal_id", "activities", ["deal_id"])
|
||||
op.create_index("ix_activities_owner_id", "activities", ["owner_id"])
|
||||
op.create_index("ix_activities_created_at", "activities", ["created_at"])
|
||||
op.create_index("ix_activities_deleted_at", "activities", ["deleted_at"])
|
||||
|
||||
# === notes ===
|
||||
op.create_table(
|
||||
"notes",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("author_id", sa.Integer(), nullable=False),
|
||||
sa.Column("parent_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_notes_org_id"),
|
||||
sa.ForeignKeyConstraint(["author_id"], ["users.id"], name="fk_notes_author_id"),
|
||||
)
|
||||
op.create_index("ix_notes_parent_type", "notes", ["parent_type"])
|
||||
op.create_index("ix_notes_parent_id", "notes", ["parent_id"])
|
||||
op.create_index("ix_notes_author_id", "notes", ["author_id"])
|
||||
op.create_index("ix_notes_org_id", "notes", ["org_id"])
|
||||
op.create_index("ix_notes_created_at", "notes", ["created_at"])
|
||||
op.create_index("ix_notes_deleted_at", "notes", ["deleted_at"])
|
||||
|
||||
# === tags ===
|
||||
op.create_table(
|
||||
"tags",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("color", sa.String(length=7), nullable=False, server_default="#3B82F6"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tags_org_id"),
|
||||
)
|
||||
op.create_index("ix_tags_name", "tags", ["name"])
|
||||
op.create_index("ix_tags_org_id", "tags", ["org_id"])
|
||||
op.create_index("ix_tags_created_at", "tags", ["created_at"])
|
||||
op.create_index("ix_tags_deleted_at", "tags", ["deleted_at"])
|
||||
|
||||
# === tag_links ===
|
||||
op.create_table(
|
||||
"tag_links",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tag_id", sa.Integer(), nullable=False),
|
||||
sa.Column("parent_type", sa.String(length=32), nullable=False),
|
||||
sa.Column("parent_id", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tag_links_org_id"),
|
||||
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE", name="fk_tag_links_tag_id"),
|
||||
sa.UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),
|
||||
)
|
||||
op.create_index("ix_tag_links_tag_id", "tag_links", ["tag_id"])
|
||||
op.create_index("ix_tag_links_parent_type", "tag_links", ["parent_type"])
|
||||
op.create_index("ix_tag_links_parent_id", "tag_links", ["parent_id"])
|
||||
op.create_index("ix_tag_links_org_id", "tag_links", ["org_id"])
|
||||
op.create_index("ix_tag_links_created_at", "tag_links", ["created_at"])
|
||||
op.create_index("ix_tag_links_deleted_at", "tag_links", ["deleted_at"])
|
||||
|
||||
# === deal_stage_history ===
|
||||
op.create_table(
|
||||
"deal_stage_history",
|
||||
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
||||
sa.Column("org_id", sa.Integer(), nullable=False),
|
||||
sa.Column("deal_id", sa.Integer(), nullable=False),
|
||||
sa.Column("from_stage", sa.String(length=32), nullable=True),
|
||||
sa.Column("to_stage", sa.String(length=32), nullable=False),
|
||||
sa.Column("changed_by", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_dsh_org_id"),
|
||||
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], ondelete="CASCADE", name="fk_dsh_deal_id"),
|
||||
sa.ForeignKeyConstraint(["changed_by"], ["users.id"], name="fk_dsh_changed_by"),
|
||||
)
|
||||
op.create_index("ix_dsh_deal_id", "deal_stage_history", ["deal_id"])
|
||||
op.create_index("ix_dsh_org_id", "deal_stage_history", ["org_id"])
|
||||
op.create_index("ix_dsh_created_at", "deal_stage_history", ["created_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_dsh_created_at", table_name="deal_stage_history")
|
||||
op.drop_index("ix_dsh_org_id", table_name="deal_stage_history")
|
||||
op.drop_index("ix_dsh_deal_id", table_name="deal_stage_history")
|
||||
op.drop_table("deal_stage_history")
|
||||
|
||||
op.drop_index("ix_tag_links_deleted_at", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_created_at", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_org_id", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_parent_id", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_parent_type", table_name="tag_links")
|
||||
op.drop_index("ix_tag_links_tag_id", table_name="tag_links")
|
||||
op.drop_table("tag_links")
|
||||
|
||||
op.drop_index("ix_tags_deleted_at", table_name="tags")
|
||||
op.drop_index("ix_tags_created_at", table_name="tags")
|
||||
op.drop_index("ix_tags_org_id", table_name="tags")
|
||||
op.drop_index("ix_tags_name", table_name="tags")
|
||||
op.drop_table("tags")
|
||||
|
||||
op.drop_index("ix_notes_deleted_at", table_name="notes")
|
||||
op.drop_index("ix_notes_created_at", table_name="notes")
|
||||
op.drop_index("ix_notes_org_id", table_name="notes")
|
||||
op.drop_index("ix_notes_author_id", table_name="notes")
|
||||
op.drop_index("ix_notes_parent_id", table_name="notes")
|
||||
op.drop_index("ix_notes_parent_type", table_name="notes")
|
||||
op.drop_table("notes")
|
||||
|
||||
op.drop_index("ix_activities_deleted_at", table_name="activities")
|
||||
op.drop_index("ix_activities_created_at", table_name="activities")
|
||||
op.drop_index("ix_activities_owner_id", table_name="activities")
|
||||
op.drop_index("ix_activities_deal_id", table_name="activities")
|
||||
op.drop_index("ix_activities_contact_id", table_name="activities")
|
||||
op.drop_index("ix_activities_account_id", table_name="activities")
|
||||
op.drop_index("ix_activities_org_id", table_name="activities")
|
||||
op.drop_index("ix_activities_due_date", table_name="activities")
|
||||
op.drop_index("ix_activities_type", table_name="activities")
|
||||
op.drop_table("activities")
|
||||
|
||||
op.drop_index("ix_deals_deleted_at", table_name="deals")
|
||||
op.drop_index("ix_deals_created_at", table_name="deals")
|
||||
op.drop_index("ix_deals_owner_id", table_name="deals")
|
||||
op.drop_index("ix_deals_account_id", table_name="deals")
|
||||
op.drop_index("ix_deals_org_id", table_name="deals")
|
||||
op.drop_index("ix_deals_stage", table_name="deals")
|
||||
op.drop_table("deals")
|
||||
|
||||
op.drop_index("ix_contacts_deleted_at", table_name="contacts")
|
||||
op.drop_index("ix_contacts_created_at", table_name="contacts")
|
||||
op.drop_index("ix_contacts_owner_id", table_name="contacts")
|
||||
op.drop_index("ix_contacts_account_id", table_name="contacts")
|
||||
op.drop_index("ix_contacts_org_id", table_name="contacts")
|
||||
op.drop_index("ix_contacts_email", table_name="contacts")
|
||||
op.drop_index("ix_contacts_last_name", table_name="contacts")
|
||||
op.drop_table("contacts")
|
||||
|
||||
op.drop_index("ix_accounts_deleted_at", table_name="accounts")
|
||||
op.drop_index("ix_accounts_created_at", table_name="accounts")
|
||||
op.drop_index("ix_accounts_owner_id", table_name="accounts")
|
||||
op.drop_index("ix_accounts_org_id", table_name="accounts")
|
||||
op.drop_index("ix_accounts_industry", table_name="accounts")
|
||||
op.drop_index("ix_accounts_name", table_name="accounts")
|
||||
op.drop_table("accounts")
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
"""CRM System Application Package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
"""LeoCRM backend application package."""
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
"""API routers package."""
|
||||
"""API package."""
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""API v1 routers."""
|
||||
"""API v1 package."""
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Account API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.account import AccountSize, Industry
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountCreate, AccountOut, AccountUpdate
|
||||
from app.services.account_service import (
|
||||
create_account as svc_create_account,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
get_account as svc_get_account,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
list_accounts as svc_list_accounts,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
soft_delete_account as svc_soft_delete_account,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
update_account as svc_update_account,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AccountOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new account",
|
||||
)
|
||||
async def create_account(
|
||||
payload: AccountCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_create_account(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[AccountOut],
|
||||
summary="List accounts (paginated, filterable)",
|
||||
)
|
||||
async def list_accounts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
industry: Industry | None = None,
|
||||
size: AccountSize | None = None,
|
||||
owner_id: int | None = None,
|
||||
q: str | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AccountOut]:
|
||||
accounts = await svc_list_accounts(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
industry=industry.value if industry else None,
|
||||
size=size.value if size else None,
|
||||
owner_id=owner_id,
|
||||
q=q,
|
||||
)
|
||||
return [AccountOut.model_validate(a) for a in accounts]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{account_id}",
|
||||
response_model=AccountOut,
|
||||
summary="Get one account with all relations",
|
||||
)
|
||||
async def get_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{account_id}",
|
||||
response_model=AccountOut,
|
||||
summary="Update an account",
|
||||
)
|
||||
async def update_account(
|
||||
account_id: int,
|
||||
payload: AccountUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
updated = await svc_update_account(db, acc, payload)
|
||||
return AccountOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete an account",
|
||||
)
|
||||
async def delete_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
await svc_soft_delete_account(db, acc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Activity API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.activity import ActivityType
|
||||
from app.models.user import User
|
||||
from app.schemas.activity import (
|
||||
ActivityCompleteRequest,
|
||||
ActivityCreate,
|
||||
ActivityOut,
|
||||
ActivityUpdate,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
NoParentException,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
complete_activity as svc_complete_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
create_activity as svc_create_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
get_activity as svc_get_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
list_activities as svc_list_activities,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
soft_delete_activity as svc_soft_delete_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
update_activity as svc_update_activity,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ActivityOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new activity (polymorphic parent: account/contact/deal)",
|
||||
)
|
||||
async def create_activity(
|
||||
payload: ActivityCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
try:
|
||||
activity = await svc_create_activity(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except NoParentException as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return ActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ActivityOut],
|
||||
summary="List activities (paginated, filterable)",
|
||||
)
|
||||
async def list_activities(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
type: ActivityType | None = None,
|
||||
owner_id: int | None = None,
|
||||
overdue: bool | None = None,
|
||||
completed: bool | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ActivityOut]:
|
||||
activities = await svc_list_activities(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type=type,
|
||||
owner_id=owner_id,
|
||||
overdue=overdue,
|
||||
completed=completed,
|
||||
)
|
||||
return [ActivityOut.model_validate(a) for a in activities]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{activity_id}",
|
||||
response_model=ActivityOut,
|
||||
summary="Get one activity",
|
||||
)
|
||||
async def get_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
return ActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{activity_id}",
|
||||
response_model=ActivityOut,
|
||||
summary="Update an activity",
|
||||
)
|
||||
async def update_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
try:
|
||||
updated = await svc_update_activity(db, activity, payload)
|
||||
except NoParentException as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return ActivityOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{activity_id}/complete",
|
||||
response_model=ActivityOut,
|
||||
summary="Mark an activity as completed",
|
||||
)
|
||||
async def complete_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityCompleteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
updated = await svc_complete_activity(db, activity, payload)
|
||||
return ActivityOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{activity_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete an activity",
|
||||
)
|
||||
async def delete_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
await svc_soft_delete_activity(db, activity)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Auth API endpoints: register, login, refresh, logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
LogoutResponse,
|
||||
RegisterResponse,
|
||||
TokenResponse,
|
||||
UserLoginRequest,
|
||||
UserRegisterRequest,
|
||||
)
|
||||
from app.schemas.user import UserOut
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=RegisterResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Bootstrap user registration (only allowed if users table is empty)",
|
||||
)
|
||||
async def register(
|
||||
payload: UserRegisterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RegisterResponse:
|
||||
"""Create the first user and a default organization.
|
||||
|
||||
Returns 403 after the first user has been registered.
|
||||
"""
|
||||
try:
|
||||
user, token = await auth_service.register_user(db, payload)
|
||||
except auth_service.BootstrapAlreadyCompleted as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except auth_service.EmailAlreadyExists as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
|
||||
settings = get_settings()
|
||||
return RegisterResponse(
|
||||
user=UserOut.model_validate(user),
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
summary="Login with email + password (form-data or JSON)",
|
||||
)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""OAuth2-compatible login. `username` field carries the email.
|
||||
|
||||
Returns 401 on invalid credentials — never leaks whether the email exists.
|
||||
"""
|
||||
result = await auth_service.authenticate_user(
|
||||
db, email=form_data.username, password=form_data.password
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
_user, token = result
|
||||
settings = get_settings()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/json",
|
||||
response_model=TokenResponse,
|
||||
summary="Login with JSON body (alternative to form-data)",
|
||||
)
|
||||
async def login_json(
|
||||
payload: UserLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""JSON-body login variant for clients that prefer JSON over form-data."""
|
||||
result = await auth_service.authenticate_user(
|
||||
db, email=payload.email, password=payload.password
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
_user, token = result
|
||||
settings = get_settings()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="Issue a new JWT for the current user",
|
||||
)
|
||||
async def refresh(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> TokenResponse:
|
||||
"""Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret."""
|
||||
settings = get_settings()
|
||||
from app.core.security import create_access_token
|
||||
|
||||
role_str = (
|
||||
current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
|
||||
)
|
||||
token = create_access_token(current_user.id, current_user.org_id, role_str)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=LogoutResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Logout (client-side token discard)",
|
||||
)
|
||||
async def logout(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
) -> LogoutResponse:
|
||||
"""Stateless logout. Client deletes the token from localStorage.
|
||||
|
||||
The endpoint validates the token (so a stolen token can be detected on logout)
|
||||
but does not maintain a server-side blacklist in v1.
|
||||
"""
|
||||
return LogoutResponse()
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Contact API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate
|
||||
from app.services.contact_service import (
|
||||
InvalidAccount,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
create_contact as svc_create_contact,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
get_contact as svc_get_contact,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
list_contacts as svc_list_contacts,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
soft_delete_contact as svc_soft_delete_contact,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
update_contact as svc_update_contact,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ContactOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new contact",
|
||||
)
|
||||
async def create_contact(
|
||||
payload: ContactCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
try:
|
||||
contact = await svc_create_contact(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return ContactOut.model_validate(contact)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ContactOut],
|
||||
summary="List contacts (paginated, filterable)",
|
||||
)
|
||||
async def list_contacts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
account_id: int | None = None,
|
||||
owner_id: int | None = None,
|
||||
q: str | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ContactOut]:
|
||||
contacts = await svc_list_contacts(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
account_id=account_id,
|
||||
owner_id=owner_id,
|
||||
q=q,
|
||||
)
|
||||
return [ContactOut.model_validate(c) for c in contacts]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{contact_id}",
|
||||
response_model=ContactOut,
|
||||
summary="Get one contact",
|
||||
)
|
||||
async def get_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return ContactOut.model_validate(contact)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{contact_id}",
|
||||
response_model=ContactOut,
|
||||
summary="Update a contact",
|
||||
)
|
||||
async def update_contact(
|
||||
contact_id: int,
|
||||
payload: ContactUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
try:
|
||||
updated = await svc_update_contact(db, contact, payload)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return ContactOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{contact_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a contact",
|
||||
)
|
||||
async def delete_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
await svc_soft_delete_contact(db, contact)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Dashboard API endpoints: KPIs and activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
||||
from app.services.dashboard_service import (
|
||||
get_activity_feed,
|
||||
get_kpis,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kpis",
|
||||
response_model=KPIOut,
|
||||
summary="Get dashboard KPIs (open deals, pipeline value, won this month, conversion rate)",
|
||||
)
|
||||
async def get_kpis_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KPIOut:
|
||||
kpis = await get_kpis(db, org_id=current_user.org_id)
|
||||
return KPIOut(**kpis) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feed",
|
||||
response_model=list[ActivityFeedItem],
|
||||
summary="Get the latest 20 activities (sorted by created_at desc)",
|
||||
)
|
||||
async def get_activity_feed_endpoint(
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ActivityFeedItem]:
|
||||
activities = await get_activity_feed(db, org_id=current_user.org_id, limit=limit)
|
||||
return [ActivityFeedItem.model_validate(a) for a in activities]
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Deal API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.deal import DealStage
|
||||
from app.models.user import User
|
||||
from app.schemas.deal import (
|
||||
DealCreate,
|
||||
DealOut,
|
||||
DealPipelineOut,
|
||||
DealStageUpdate,
|
||||
DealUpdate,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
InvalidAccount,
|
||||
get_pipeline,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
create_deal as svc_create_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
get_deal as svc_get_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
list_deals as svc_list_deals,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
soft_delete_deal as svc_soft_delete_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
update_deal as svc_update_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
update_stage as svc_update_stage,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/deals", tags=["deals"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=DealOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new deal",
|
||||
)
|
||||
async def create_deal(
|
||||
payload: DealCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
try:
|
||||
deal = await svc_create_deal(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return DealOut.model_validate(deal)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[DealOut],
|
||||
summary="List deals (paginated, filterable)",
|
||||
)
|
||||
async def list_deals(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
stage: DealStage | None = None,
|
||||
owner_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DealOut]:
|
||||
deals = await svc_list_deals(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
stage=stage.value if stage else None,
|
||||
owner_id=owner_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
return [DealOut.model_validate(d) for d in deals]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pipeline",
|
||||
response_model=list[DealPipelineOut],
|
||||
summary="Get pipeline view (deals grouped by stage)",
|
||||
)
|
||||
async def get_pipeline_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DealPipelineOut]:
|
||||
pipeline = await get_pipeline(db, org_id=current_user.org_id)
|
||||
out: list[DealPipelineOut] = []
|
||||
for item in pipeline:
|
||||
out.append(
|
||||
DealPipelineOut(
|
||||
stage=DealStage(item["stage"]),
|
||||
count=int(item["count"]),
|
||||
total_value=float(item["total_value"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{deal_id}",
|
||||
response_model=DealOut,
|
||||
summary="Get one deal",
|
||||
)
|
||||
async def get_deal(
|
||||
deal_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
return DealOut.model_validate(deal)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{deal_id}",
|
||||
response_model=DealOut,
|
||||
summary="Update a deal (does NOT change stage)",
|
||||
)
|
||||
async def update_deal(
|
||||
deal_id: int,
|
||||
payload: DealUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
updated = await svc_update_deal(db, deal, payload)
|
||||
return DealOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{deal_id}/stage",
|
||||
response_model=DealOut,
|
||||
summary="Change a deal's stage (creates DealStageHistory entry)",
|
||||
)
|
||||
async def update_deal_stage(
|
||||
deal_id: int,
|
||||
payload: DealStageUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
updated = await svc_update_stage(
|
||||
db, deal, payload.stage, changed_by=current_user.id, reason=payload.reason
|
||||
)
|
||||
return DealOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{deal_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a deal",
|
||||
)
|
||||
async def delete_deal(
|
||||
deal_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
await svc_soft_delete_deal(db, deal)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Health check endpoints: /health (root) and /api/v1/health."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import __version__
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
async def _check_db(db: AsyncSession) -> bool:
|
||||
"""Run SELECT 1 to verify the database connection is alive."""
|
||||
try:
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
result.scalar_one()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Database health check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
summary="Healthcheck (used by Coolify + Kubernetes)",
|
||||
)
|
||||
async def health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Returns 200 if the API and DB are healthy, 503 if the DB is down."""
|
||||
db_ok = await _check_db(db)
|
||||
if not db_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "error", "db": "down"},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/health",
|
||||
summary="Versioned healthcheck (for clients that hit /api/v1/*)",
|
||||
)
|
||||
async def api_v1_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Same as /health but under the /api/v1 prefix for versioned routing."""
|
||||
db_ok = await _check_db(db)
|
||||
if not db_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "error", "db": "down"},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
"version": __version__,
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Note API endpoints (R-2: polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.note import NoteParentType
|
||||
from app.models.user import User
|
||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
||||
from app.services.note_service import (
|
||||
InvalidParent,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
create_note as svc_create_note,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
get_note as svc_get_note,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
list_notes as svc_list_notes,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
soft_delete_note as svc_soft_delete_note,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
update_note as svc_update_note,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=NoteOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new note (R-2: validates parent exists)",
|
||||
)
|
||||
async def create_note(
|
||||
payload: NoteCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
try:
|
||||
note = await svc_create_note(
|
||||
db, payload, org_id=current_user.org_id, author_id=current_user.id
|
||||
)
|
||||
except InvalidParent as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return NoteOut.model_validate(note)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[NoteOut],
|
||||
summary="List notes (filterable by parent_type + parent_id)",
|
||||
)
|
||||
async def list_notes(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
parent_type: NoteParentType | None = None,
|
||||
parent_id: int | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[NoteOut]:
|
||||
notes = await svc_list_notes(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
parent_type=parent_type.value if parent_type else None,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
return [NoteOut.model_validate(n) for n in notes]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{note_id}",
|
||||
response_model=NoteOut,
|
||||
summary="Get one note",
|
||||
)
|
||||
async def get_note(
|
||||
note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
return NoteOut.model_validate(note)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{note_id}",
|
||||
response_model=NoteOut,
|
||||
summary="Update a note's body",
|
||||
)
|
||||
async def update_note(
|
||||
note_id: int,
|
||||
payload: NoteUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
updated = await svc_update_note(db, note, payload)
|
||||
return NoteOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{note_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a note",
|
||||
)
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
await svc_soft_delete_note(db, note)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Tag API endpoints (R-2: polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
||||
from app.services.tag_service import (
|
||||
DuplicateTagLink,
|
||||
InvalidParent,
|
||||
TagNotFound,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
create_tag as svc_create_tag,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
link_tag as svc_link_tag,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
list_tags as svc_list_tags,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
unlink_tag as svc_unlink_tag,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=TagOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new tag",
|
||||
)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TagOut:
|
||||
tag = await svc_create_tag(db, payload, org_id=current_user.org_id)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[TagOut],
|
||||
summary="List all tags in the org",
|
||||
)
|
||||
async def list_tags(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[TagOut]:
|
||||
tags = await svc_list_tags(db, org_id=current_user.org_id)
|
||||
return [TagOut.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/link",
|
||||
response_model=TagLinkOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Link a tag to an entity (R-2: validates parent exists)",
|
||||
)
|
||||
async def link_tag_endpoint(
|
||||
payload: TagLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TagLinkOut:
|
||||
try:
|
||||
link = await svc_link_tag(db, payload, org_id=current_user.org_id)
|
||||
except InvalidParent as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except TagNotFound as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except DuplicateTagLink as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
return TagLinkOut.model_validate(link)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/link",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Unlink a tag from an entity",
|
||||
)
|
||||
async def unlink_tag_endpoint(
|
||||
payload: TagLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
deleted = await svc_unlink_tag(
|
||||
db,
|
||||
tag_id=payload.tag_id,
|
||||
parent_type=payload.parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Tag link not found")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,147 +0,0 @@
|
||||
"""User API endpoints: me, list, create, update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_admin_user, get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services import user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Get the currently authenticated user",
|
||||
)
|
||||
async def get_me(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserOut:
|
||||
"""Returns the user from the JWT. Used by the frontend for auth checks and profile display."""
|
||||
return UserOut.model_validate(current_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Update own profile (name, avatar, notification prefs)",
|
||||
)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""A user can update their own profile, but not their role."""
|
||||
updated = await user_service.update_user_profile(db, current_user, payload, is_admin=False)
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=UserListResponse,
|
||||
summary="List all users in the current org (admin only)",
|
||||
)
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserListResponse:
|
||||
"""Paginated list of active users. Restricted to admin role."""
|
||||
skip = (page - 1) * page_size
|
||||
users = await user_service.list_users(
|
||||
db, org_id=current_user.org_id, skip=skip, limit=page_size
|
||||
)
|
||||
total = await user_service.count_users_in_org(db, current_user.org_id)
|
||||
return UserListResponse(
|
||||
items=[UserOut.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=UserOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new user in the current org (admin only)",
|
||||
)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""Admin creates a new user in the same org."""
|
||||
try:
|
||||
new_user = await user_service.create_user_as_admin(db, payload, org_id=current_user.org_id)
|
||||
except user_service.EmailAlreadyTaken as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
return UserOut.model_validate(new_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserOut,
|
||||
summary="Update a user (admin or self for non-role fields)",
|
||||
)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""Admin can update any user (including role); non-admin can update only their own profile fields."""
|
||||
is_admin = (
|
||||
current_user.role == UserRole.admin
|
||||
if hasattr(current_user.role, "__eq__") and not isinstance(current_user.role, str)
|
||||
else str(current_user.role) == UserRole.admin.value
|
||||
)
|
||||
if not is_admin and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can only update your own profile",
|
||||
)
|
||||
|
||||
target = await user_service.get_user_by_id(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if target.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
updated = await user_service.update_user_profile(db, target, payload, is_admin=is_admin)
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a user (admin only)",
|
||||
)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Sets deleted_at timestamp. The record stays in the DB for audit purposes."""
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
|
||||
target = await user_service.get_user_by_id(db, user_id)
|
||||
if target is None or target.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
await user_service.soft_delete_user(db, target)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Application configuration via Pydantic Settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment variables."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Environment
|
||||
environment: Literal["development", "production", "testing"] = "development"
|
||||
log_level: str = "INFO"
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
|
||||
db_pool_size: int = 10
|
||||
db_max_overflow: int = 20
|
||||
db_echo: bool = False
|
||||
|
||||
# Redis
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
session_ttl_seconds: int = 28800 # 8 hours
|
||||
|
||||
# Auth
|
||||
bcrypt_rounds: int = 12
|
||||
session_cookie_name: str = "leocrm_session"
|
||||
session_cookie_secure: bool = False # True in production behind HTTPS
|
||||
session_cookie_samesite: str = "strict"
|
||||
session_cookie_httponly: bool = True
|
||||
password_reset_expiry_hours: int = 1
|
||||
|
||||
# CORS
|
||||
cors_origins: str = "http://localhost:5173,http://localhost:3000"
|
||||
|
||||
# Rate Limiting
|
||||
rate_limit_login_max: int = 5
|
||||
rate_limit_login_window: int = 900 # 15 min
|
||||
rate_limit_reset_max: int = 3
|
||||
rate_limit_reset_window: int = 3600 # 1 hour
|
||||
rate_limit_reset_confirm_max: int = 5
|
||||
rate_limit_reset_confirm_window: int = 3600 # 1 hour
|
||||
rate_limit_general_max: int = 60
|
||||
rate_limit_general_window: int = 60 # 1 min
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
"""Parse comma-separated CORS origins into a list."""
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Get cached settings instance."""
|
||||
return Settings()
|
||||
@@ -1 +1 @@
|
||||
"""Core module: configuration, database, security, dependencies."""
|
||||
"""Core infrastructure package."""
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Audit log middleware — records create/update/delete actions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audit import AuditLog, DeletionLog
|
||||
|
||||
|
||||
async def log_audit(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID | None = None,
|
||||
changes: dict[str, Any] | None = None,
|
||||
) -> AuditLog:
|
||||
"""Create an audit log entry."""
|
||||
entry = AuditLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
changes=changes,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry
|
||||
|
||||
|
||||
async def log_deletion(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID | None,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID,
|
||||
entity_snapshot: dict[str, Any],
|
||||
) -> DeletionLog:
|
||||
"""Create a deletion log entry (immutable snapshot)."""
|
||||
entry = DeletionLog(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
entity_snapshot=entity_snapshot,
|
||||
)
|
||||
db.add(entry)
|
||||
await db.flush()
|
||||
return entry
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Session-based authentication, password hashing, and RBAC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from passlib.context import CryptContext
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.role import Role
|
||||
from app.models.session import Session as SessionModel
|
||||
|
||||
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto", bcrypt__rounds=get_settings().bcrypt_rounds)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt."""
|
||||
return _pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against a bcrypt hash."""
|
||||
return _pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def generate_session_token() -> str:
|
||||
"""Generate a cryptographically secure session token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def generate_csrf_token() -> str:
|
||||
"""Generate a CSRF token."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""SHA-256 hash a token for storage."""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def get_redis() -> aioredis.Redis:
|
||||
"""Get a Redis client instance."""
|
||||
return aioredis.from_url(get_settings().redis_url, decode_responses=True)
|
||||
|
||||
|
||||
async def create_session(
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
user: User,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> tuple[str, str]:
|
||||
"""Create a session in Redis (runtime) and PostgreSQL (audit trail).
|
||||
Returns (session_id, csrf_token).
|
||||
"""
|
||||
settings = get_settings()
|
||||
session_id = str(uuid.uuid4())
|
||||
csrf_token = generate_csrf_token()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.session_ttl_seconds)
|
||||
|
||||
# Redis runtime session
|
||||
session_data: dict[str, Any] = {
|
||||
"user_id": str(user.id),
|
||||
"tenant_id": str(tenant_id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"csrf_token": csrf_token,
|
||||
"is_active": user.is_active,
|
||||
}
|
||||
import json
|
||||
await redis.setex(
|
||||
f"session:{session_id}",
|
||||
settings.session_ttl_seconds,
|
||||
json.dumps(session_data),
|
||||
)
|
||||
|
||||
# PostgreSQL audit trail
|
||||
audit_record = SessionModel(
|
||||
id=uuid.UUID(session_id),
|
||||
user_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
csrf_token=csrf_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(audit_record)
|
||||
await db.flush()
|
||||
|
||||
return session_id, csrf_token
|
||||
|
||||
|
||||
async def get_session_data(redis: aioredis.Redis, session_id: str) -> dict[str, Any] | None:
|
||||
"""Retrieve session data from Redis."""
|
||||
import json
|
||||
raw = await redis.get(f"session:{session_id}")
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def invalidate_session(redis: aioredis.Redis, session_id: str) -> None:
|
||||
"""Delete a session from Redis (logout). PostgreSQL record persists."""
|
||||
await redis.delete(f"session:{session_id}")
|
||||
|
||||
|
||||
async def update_session_tenant(
|
||||
redis: aioredis.Redis,
|
||||
session_id: str,
|
||||
new_tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Update the active tenant in a Redis session."""
|
||||
import json
|
||||
settings = get_settings()
|
||||
raw = await redis.get(f"session:{session_id}")
|
||||
if raw is None:
|
||||
return None
|
||||
data = json.loads(raw)
|
||||
data["tenant_id"] = str(new_tenant_id)
|
||||
ttl = await redis.ttl(f"session:{session_id}")
|
||||
if ttl <= 0:
|
||||
ttl = settings.session_ttl_seconds
|
||||
await redis.setex(f"session:{session_id}", ttl, json.dumps(data))
|
||||
return data
|
||||
|
||||
|
||||
def check_permission(role_name: str, module: str, action: str, permissions: dict | None = None) -> bool:
|
||||
"""Check if a role has permission for a module+action.
|
||||
Built-in roles: admin (all), editor (read+write), viewer (read only).
|
||||
Custom roles use the permissions dict.
|
||||
"""
|
||||
if role_name == "admin":
|
||||
return True
|
||||
if role_name == "editor":
|
||||
if action in ("read", "write", "create", "update"):
|
||||
return True
|
||||
return False
|
||||
if role_name == "viewer":
|
||||
return action == "read"
|
||||
# Custom role — check permissions dict
|
||||
if permissions:
|
||||
module_perms = permissions.get(module, {})
|
||||
return bool(module_perms.get(action, False))
|
||||
return False
|
||||
|
||||
|
||||
def filter_fields_by_permission(
|
||||
data: dict[str, Any],
|
||||
field_permissions: dict[str, str],
|
||||
role_name: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Filter response fields based on field-level permissions.
|
||||
field_permissions: {"annual_revenue": "hidden"} → removed for non-admin.
|
||||
"""
|
||||
if role_name == "admin":
|
||||
return data
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
perm = field_permissions.get(key)
|
||||
if perm == "hidden":
|
||||
continue
|
||||
result[key] = value
|
||||
return result
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Redis cache wrapper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
_cache_redis: aioredis.Redis | None = None
|
||||
|
||||
|
||||
def get_cache() -> aioredis.Redis:
|
||||
"""Get or create the cache Redis client."""
|
||||
global _cache_redis
|
||||
if _cache_redis is None:
|
||||
_cache_redis = aioredis.from_url(get_settings().redis_url, decode_responses=True)
|
||||
return _cache_redis
|
||||
|
||||
|
||||
async def cache_get(key: str) -> Any | None:
|
||||
"""Get a value from cache."""
|
||||
r = get_cache()
|
||||
raw = await r.get(key)
|
||||
if raw is None:
|
||||
return None
|
||||
return json.loads(raw)
|
||||
|
||||
|
||||
async def cache_set(key: str, value: Any, ttl: int = 300) -> None:
|
||||
"""Set a value in cache with TTL."""
|
||||
r = get_cache()
|
||||
await r.setex(key, ttl, json.dumps(value))
|
||||
|
||||
|
||||
async def cache_delete(key: str) -> None:
|
||||
"""Delete a key from cache."""
|
||||
r = get_cache()
|
||||
await r.delete(key)
|
||||
|
||||
|
||||
async def cache_flush_pattern(pattern: str) -> None:
|
||||
"""Delete all keys matching a pattern."""
|
||||
r = get_cache()
|
||||
keys = await r.keys(pattern)
|
||||
if keys:
|
||||
await r.delete(*keys)
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Application configuration via Pydantic-Settings v2.
|
||||
|
||||
Loads from .env file and environment variables.
|
||||
Hard-fails if AUTH_SECRET is missing or shorter than 32 characters
|
||||
(per 02-architecture.md Section 13.5 R-5: NO JWT secret fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# === Database ===
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db"
|
||||
|
||||
# === JWT / Auth ===
|
||||
# NO DEFAULT — hard-fail if missing (R-5)
|
||||
AUTH_SECRET: str = Field(..., min_length=32)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRY_HOURS: int = 24
|
||||
|
||||
# === Password Hashing ===
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# === CORS ===
|
||||
# Comma-separated list of allowed origins, NO wildcards
|
||||
CORS_ORIGINS: str = "http://localhost:5500,http://localhost:8000"
|
||||
|
||||
# === Runtime ===
|
||||
ENVIRONMENT: Literal["development", "production", "test"] = "development"
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
|
||||
@field_validator("AUTH_SECRET")
|
||||
@classmethod
|
||||
def validate_auth_secret(cls, v: str) -> str:
|
||||
"""Ensure AUTH_SECRET is at least 32 characters and not a known dev default."""
|
||||
if len(v) < 32:
|
||||
raise ValueError(
|
||||
f"AUTH_SECRET must be at least 32 characters (got {len(v)}). "
|
||||
"Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'"
|
||||
)
|
||||
# Reject obvious placeholders
|
||||
lowered = v.lower()
|
||||
if "replace-me" in lowered or "changeme" in lowered or "secret" == lowered:
|
||||
raise ValueError("AUTH_SECRET appears to be a placeholder. Use a real random value.")
|
||||
return v
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS_ORIGINS into a list of trimmed, non-empty origins."""
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""Check if running in production mode."""
|
||||
return self.ENVIRONMENT == "production"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
"""Check if running in test mode."""
|
||||
return self.ENVIRONMENT == "test"
|
||||
|
||||
@property
|
||||
def jwt_expiry_seconds(self) -> int:
|
||||
"""JWT expiry in seconds (for response `expires_in` field)."""
|
||||
return self.JWT_EXPIRY_HOURS * 3600
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance."""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Async SQLAlchemy engine, session factory, and FastAPI dependency.
|
||||
|
||||
Driver-agnostic: works with both aiosqlite (dev) and asyncpg (prod).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models.
|
||||
|
||||
Re-exported here so Alembic env.py can import it from a single location.
|
||||
"""
|
||||
|
||||
|
||||
# === Engine & Session Factory ===
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# SQLite needs check_same_thread=False even for async — handled by aiosqlite.
|
||||
# echo=False in production; controlled by env if needed later.
|
||||
_engine_kwargs: dict[str, Any] = {"echo": False, "future": True}
|
||||
|
||||
# For PostgreSQL asyncpg, pool sizing matters; for SQLite it's irrelevant.
|
||||
if not _settings.DATABASE_URL.startswith("sqlite"):
|
||||
_engine_kwargs["pool_size"] = 5
|
||||
_engine_kwargs["max_overflow"] = 10
|
||||
_engine_kwargs["pool_pre_ping"] = True
|
||||
|
||||
engine: AsyncEngine = create_async_engine(_settings.DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
# === Dependency ===
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an AsyncSession and ensures cleanup."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
# No explicit close needed — async context manager handles it.
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose of the engine on application shutdown."""
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Database engine, session management, and base model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event as sa_event
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
|
||||
from sqlalchemy import text, String, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base with shared columns and tenant-scoping support."""
|
||||
|
||||
@declared_attr.directive
|
||||
def __tablename__(cls) -> str:
|
||||
return cls.__name__.lower() + "s"
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds created_at and updated_at timestamps."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class TenantMixin(TimestampMixin):
|
||||
"""Adds tenant_id column and enables ORM-level auto-filtering."""
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), nullable=False, index=True
|
||||
)
|
||||
|
||||
|
||||
# Global engine and session factory
|
||||
_engine: AsyncEngine | None = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def get_engine() -> AsyncEngine:
|
||||
"""Get or create the global async engine."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
pool_size=settings.db_pool_size,
|
||||
max_overflow=settings.db_max_overflow,
|
||||
echo=settings.db_echo,
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
"""Get or create the global session factory."""
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = async_sessionmaker(
|
||||
bind=get_engine(),
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
)
|
||||
return _session_factory
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency: yield an async database session."""
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def set_tenant_context(session: AsyncSession, tenant_id: uuid.UUID | str) -> None:
|
||||
"""Set PostgreSQL session variable for RLS tenant context."""
|
||||
await session.execute(
|
||||
text("SELECT set_config('app.current_tenant_id', :tid, true)"),
|
||||
{"tid": str(tenant_id)},
|
||||
)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def create_db_session(tenant_id: uuid.UUID | str | None = None) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Create a standalone session outside FastAPI (e.g. for tests/workers)."""
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
if tenant_id is not None:
|
||||
await set_tenant_context(session, tenant_id)
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
async def close_engine() -> None:
|
||||
"""Dispose the global engine (for shutdown)."""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
def reset_engine_for_testing(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
|
||||
"""Replace the global engine with a test engine. Returns a session factory."""
|
||||
global _engine, _session_factory
|
||||
_engine = engine
|
||||
_session_factory = async_sessionmaker(
|
||||
bind=engine, expire_on_commit=False, class_=AsyncSession
|
||||
)
|
||||
return _session_factory
|
||||
@@ -1,64 +0,0 @@
|
||||
"""FastAPI dependency functions for auth and role checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the current authenticated user from the JWT in the Authorization header.
|
||||
|
||||
Raises 401 on invalid/expired token, missing user, or soft-deleted user.
|
||||
"""
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exc
|
||||
|
||||
sub = payload.get("sub")
|
||||
if sub is None:
|
||||
raise credentials_exc
|
||||
|
||||
try:
|
||||
user_id = int(sub)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exc from None
|
||||
|
||||
# Load user fresh from DB to honor soft-delete and role changes
|
||||
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise credentials_exc
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Require the current user to have the admin role."""
|
||||
user_role = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
if user_role != UserRole.admin.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,41 @@
|
||||
"""In-process event bus for publish/subscribe."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Any, Callable, Coroutine
|
||||
|
||||
EventHandler = Callable[[dict[str, Any]], Coroutine[Any, Any, None]]
|
||||
|
||||
|
||||
class EventBus:
|
||||
"""Simple async event bus for in-process pub/sub."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._handlers: dict[str, list[EventHandler]] = defaultdict(list)
|
||||
|
||||
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||
"""Subscribe a handler to an event."""
|
||||
self._handlers[event_name].append(handler)
|
||||
|
||||
def unsubscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||
"""Unsubscribe a handler from an event."""
|
||||
if event_name in self._handlers:
|
||||
self._handlers[event_name] = [h for h in self._handlers[event_name] if h is not handler]
|
||||
|
||||
async def publish(self, event_name: str, payload: dict[str, Any]) -> None:
|
||||
"""Publish an event to all subscribers."""
|
||||
handlers = self._handlers.get(event_name, [])
|
||||
tasks = [asyncio.create_task(h(payload)) for h in handlers]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# Global event bus instance
|
||||
_event_bus = EventBus()
|
||||
|
||||
|
||||
def get_event_bus() -> EventBus:
|
||||
"""Get the global event bus."""
|
||||
return _event_bus
|
||||
@@ -0,0 +1,42 @@
|
||||
"""ARQ job queue integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from arq import create_pool
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def get_job_pool():
|
||||
"""Get an ARQ job pool for enqueueing background tasks."""
|
||||
settings = get_settings()
|
||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||
return await create_pool(redis_settings)
|
||||
|
||||
|
||||
async def enqueue_job(job_name: str, *args: Any, **kwargs: Any) -> str | None:
|
||||
"""Enqueue a background job. Returns job_id or None."""
|
||||
pool = await get_job_pool()
|
||||
job = await pool.enqueue_job(job_name, *args, **kwargs)
|
||||
if job:
|
||||
return job.job_id
|
||||
return None
|
||||
|
||||
|
||||
async def get_job_status(job_id: str) -> dict[str, Any] | None:
|
||||
"""Get the status of a background job."""
|
||||
pool = await get_job_pool()
|
||||
job_info = await pool.job_info(job_id)
|
||||
if job_info is None:
|
||||
return None
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"status": str(job_info.status),
|
||||
"result": job_info.result,
|
||||
"enqueue_time": job_info.enqueue_time.isoformat() if job_info.enqueue_time else None,
|
||||
"start_time": job_info.start_time.isoformat() if job_info.start_time else None,
|
||||
"finish_time": job_info.finish_time.isoformat() if job_info.finish_time else None,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"""CSRF middleware — Origin header validation for POST/PATCH/DELETE."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""Validate Origin header on all state-changing requests.
|
||||
SameSite=Strict cookie + Origin validation = CSRF protection.
|
||||
"""
|
||||
|
||||
UNSAFE_METHODS = {"POST", "PATCH", "PUT", "DELETE"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.method in self.UNSAFE_METHODS:
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "Missing Origin header", "code": "csrf_missing_origin"},
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
allowed = settings.cors_origin_list
|
||||
# In production, same-origin is enforced; in dev, explicit origins
|
||||
if origin not in allowed:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"detail": "Invalid Origin", "code": "csrf_invalid_origin"},
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Notification service — create and manage user notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
type: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
) -> Notification:
|
||||
"""Create a new notification for a user."""
|
||||
notif = Notification(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
body=body,
|
||||
)
|
||||
db.add(notif)
|
||||
await db.flush()
|
||||
return notif
|
||||
|
||||
|
||||
async def list_notifications(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> dict[str, Any]:
|
||||
"""List notifications for a user, unread first, then by created_at desc."""
|
||||
offset = (page - 1) * page_size
|
||||
# Count total
|
||||
count_q = select(func.count()).select_from(Notification).where(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
# Query — unread first (read_at IS NULL), then newest
|
||||
q = (
|
||||
select(Notification)
|
||||
.where(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
.order_by(
|
||||
Notification.read_at.isnot(None), # False (unread) sorts first
|
||||
Notification.created_at.desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [_notification_to_dict(n) for n in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def mark_notification_read(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
) -> Notification | None:
|
||||
"""Mark a notification as read."""
|
||||
from datetime import datetime, timezone
|
||||
q = (
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.id == notification_id,
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
.returning(Notification)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
row = result.scalar_one_or_none()
|
||||
return row
|
||||
|
||||
|
||||
async def get_unread_count(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> int:
|
||||
"""Get unread notification count for a user."""
|
||||
q = select(func.count()).select_from(Notification).where(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def _notification_to_dict(n: Notification) -> dict[str, Any]:
|
||||
"""Convert a notification to a dict."""
|
||||
return {
|
||||
"id": str(n.id),
|
||||
"type": n.type,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"read_at": n.read_at.isoformat() if n.read_at else None,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Redis-based rate limiting for auth endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
from app.core.auth import get_redis
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
async def check_rate_limit(
|
||||
redis_key: str,
|
||||
max_attempts: int,
|
||||
window_seconds: int,
|
||||
) -> None:
|
||||
"""Check rate limit using Redis INCR + EXPIRE.
|
||||
Raises 429 if limit exceeded.
|
||||
"""
|
||||
redis = get_redis()
|
||||
current = await redis.incr(redis_key)
|
||||
if current == 1:
|
||||
await redis.expire(redis_key, window_seconds)
|
||||
if current > max_attempts:
|
||||
ttl = await redis.ttl(redis_key)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail={
|
||||
"detail": "Rate limit exceeded",
|
||||
"code": "rate_limited",
|
||||
"retry_after": ttl,
|
||||
},
|
||||
headers={"Retry-After": str(ttl)} if ttl > 0 else {},
|
||||
)
|
||||
|
||||
|
||||
async def reset_rate_limit(redis_key: str) -> None:
|
||||
"""Reset a rate limit counter (e.g. on successful login)."""
|
||||
redis = get_redis()
|
||||
await redis.delete(redis_key)
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Extract client IP from request."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Password hashing and JWT encoding/decoding.
|
||||
|
||||
Uses python-jose[cryptography] (per 02-architecture.md Section 13.1) and
|
||||
passlib[bcrypt] with bcrypt 4.0.1 (per Section 13.6, 03a-patterns R-6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# === Password Hashing ===
|
||||
|
||||
# CryptContext auto-upgrades old hashes when verified. bcrypt 4.0.1 is pinned
|
||||
# because 4.1+ breaks passlib (per 03a-patterns-summary R-6).
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password using bcrypt with the configured rounds."""
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a bcrypt hash."""
|
||||
try:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# === JWT ===
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
org_id: int,
|
||||
role: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token with the given user, org, and role claims.
|
||||
|
||||
The token contains:
|
||||
- sub: user id (string)
|
||||
- org_id: organization id
|
||||
- role: user role
|
||||
- exp: expiry timestamp
|
||||
- iat: issued-at timestamp
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(hours=_settings.JWT_EXPIRY_HOURS)
|
||||
expire = now + expires_delta
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"org_id": org_id,
|
||||
"role": role,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, _settings.AUTH_SECRET, algorithm=_settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
"""Decode and validate a JWT access token.
|
||||
|
||||
Returns the payload dict on success, or None on any error
|
||||
(invalid signature, malformed token, expired).
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
_settings.AUTH_SECRET,
|
||||
algorithms=[_settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def is_token_expired(token: str) -> bool:
|
||||
"""Check whether a token is expired without raising on other errors."""
|
||||
try:
|
||||
jwt.get_unverified_claims(token)
|
||||
# If decode succeeds, it's not expired.
|
||||
jwt.decode(token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM])
|
||||
return False
|
||||
except JWTError as e:
|
||||
return "expired" in str(e).lower() or "exp" in str(e).lower()
|
||||
except Exception:
|
||||
return True
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Service container (dependency injection)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.core.cache import get_cache
|
||||
from app.core.event_bus import get_event_bus
|
||||
|
||||
|
||||
class ServiceContainer:
|
||||
"""Simple DI container for shared services."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._services: dict[str, Any] = {}
|
||||
self._initialized = False
|
||||
|
||||
def register(self, name: str, instance: Any) -> None:
|
||||
"""Register a service instance."""
|
||||
self._services[name] = instance
|
||||
|
||||
def get(self, name: str) -> Any:
|
||||
"""Get a service by name."""
|
||||
if name not in self._services:
|
||||
raise KeyError(f"Service '{name}' not registered")
|
||||
return self._services[name]
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""Check if a service is registered."""
|
||||
return name in self._services
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize core services."""
|
||||
if self._initialized:
|
||||
return
|
||||
self.register("cache", get_cache())
|
||||
self.register("event_bus", get_event_bus())
|
||||
self._initialized = True
|
||||
|
||||
|
||||
_container = ServiceContainer()
|
||||
|
||||
|
||||
def get_container() -> ServiceContainer:
|
||||
"""Get the global service container."""
|
||||
return _container
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Tenant-scoping: ORM auto-filter and tenant context management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import event as sa_event
|
||||
from sqlalchemy.orm import Session, with_loader_criteria
|
||||
from sqlalchemy.sql import Select
|
||||
|
||||
from app.core.db import TenantMixin
|
||||
|
||||
|
||||
def apply_tenant_filter(query: Select, tenant_id: uuid.UUID) -> Select:
|
||||
"""Apply tenant_id filter to a SELECT query."""
|
||||
# This is used explicitly when needed
|
||||
return query.where(TenantMixin.tenant_id == tenant_id)
|
||||
|
||||
|
||||
async def set_rls_context(session, tenant_id: uuid.UUID | str) -> None:
|
||||
"""Set PostgreSQL RLS session variable."""
|
||||
from app.core.db import set_tenant_context
|
||||
await set_tenant_context(session, tenant_id)
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
"""FastAPI dependencies: auth, db, tenant context, RBAC."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.auth import get_redis, get_session_data
|
||||
from app.core.db import get_db, set_tenant_context
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
async def get_redis_dep() -> aioredis.Redis:
|
||||
"""FastAPI dependency for Redis client."""
|
||||
return get_redis()
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
redis: aioredis.Redis = Depends(get_redis_dep),
|
||||
) -> dict[str, Any]:
|
||||
"""Get the current authenticated user from session cookie.
|
||||
Returns session data dict with user_id, tenant_id, email, name, role.
|
||||
"""
|
||||
settings = get_settings()
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||
)
|
||||
|
||||
session_data = await get_session_data(redis, session_id)
|
||||
if session_data is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
||||
)
|
||||
|
||||
if not session_data.get("is_active", True):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "User account deactivated", "code": "user_inactive"},
|
||||
)
|
||||
|
||||
# Set RLS tenant context
|
||||
tenant_id = uuid.UUID(session_data["tenant_id"])
|
||||
await set_tenant_context(db, tenant_id)
|
||||
|
||||
return session_data
|
||||
|
||||
|
||||
async def require_admin(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Require admin role."""
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Admin access required", "code": "forbidden"},
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_write(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
"""Require write permission (admin or editor)."""
|
||||
role = current_user.get("role", "viewer")
|
||||
if role not in ("admin", "editor"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Write access required", "code": "forbidden"},
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def get_tenant_id(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> uuid.UUID:
|
||||
"""Extract tenant_id from current user session."""
|
||||
return uuid.UUID(current_user["tenant_id"])
|
||||
|
||||
|
||||
async def get_current_user_id(
|
||||
current_user: dict[str, Any] = Depends(get_current_user),
|
||||
) -> uuid.UUID:
|
||||
"""Extract user_id from current user session."""
|
||||
return uuid.UUID(current_user["user_id"])
|
||||
+21
-174
@@ -1,200 +1,47 @@
|
||||
"""FastAPI application entry point.
|
||||
|
||||
Wires up:
|
||||
- CORS middleware (whitelist, NO wildcard)
|
||||
- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod)
|
||||
- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError)
|
||||
- Startup/shutdown events (DB connection check, log level)
|
||||
- Versioned API router mounts under /api/v1
|
||||
- Root-level /health endpoint for Coolify healthcheck
|
||||
"""
|
||||
"""FastAPI application - LeoCRM backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app import __version__
|
||||
from app.api.v1 import (
|
||||
accounts,
|
||||
activities,
|
||||
auth,
|
||||
contacts,
|
||||
dashboard,
|
||||
deals,
|
||||
health,
|
||||
notes,
|
||||
tags,
|
||||
users,
|
||||
)
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import AsyncSessionLocal, dispose_engine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# === Lifespan ===
|
||||
from app.config import get_settings
|
||||
from app.core.middleware import CSRFMiddleware
|
||||
from app.core.db import close_engine
|
||||
from app.routes import auth, users, roles, tenants, health, notifications, companies
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan: startup and shutdown hooks."""
|
||||
settings = get_settings()
|
||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||
logger.info(
|
||||
"Starting CRM System v%s in %s mode",
|
||||
__version__,
|
||||
settings.ENVIRONMENT,
|
||||
)
|
||||
|
||||
# Verify DB connection on startup
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0])
|
||||
except Exception as e:
|
||||
logger.error("Database connection FAILED on startup: %s", e)
|
||||
# Don't crash — let /health report the issue so Coolify can restart
|
||||
|
||||
"""Application lifespan: startup and shutdown."""
|
||||
yield
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down CRM System")
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
# === App ===
|
||||
await close_engine()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Application factory (allows easy testing override)."""
|
||||
"""Create and configure the FastAPI application."""
|
||||
settings = get_settings()
|
||||
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
app = FastAPI(
|
||||
title="CRM System",
|
||||
version=__version__,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# === CORS ===
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins_list,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
|
||||
max_age=3600,
|
||||
)
|
||||
app.add_middleware(CSRFMiddleware)
|
||||
|
||||
# === Security Headers ===
|
||||
@app.middleware("http")
|
||||
async def security_headers_middleware(request: Request, call_next):
|
||||
"""Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers."""
|
||||
response = await call_next(request)
|
||||
settings_local = get_settings()
|
||||
|
||||
if settings_local.is_production:
|
||||
# Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce)
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' https://cdn.tailwindcss.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"object-src 'none';"
|
||||
)
|
||||
else:
|
||||
# Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks)
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"object-src 'none';"
|
||||
)
|
||||
|
||||
response.headers["Content-Security-Policy"] = csp
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
if settings_local.is_production:
|
||||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
return response
|
||||
|
||||
# === Exception Handlers ===
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""Format HTTPException responses consistently."""
|
||||
# Distinguish token-expired for FR-1.7 acceptance criterion
|
||||
if exc.status_code == 401:
|
||||
detail = exc.detail
|
||||
if detail == "Could not validate credentials":
|
||||
# Token invalid or expired — callers can detect this
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": "token_expired_or_invalid"},
|
||||
headers=exc.headers,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"detail": exc.detail},
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
"""Format Pydantic validation errors consistently."""
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
content={"detail": jsonable_encoder(exc.errors())},
|
||||
)
|
||||
|
||||
@app.exception_handler(SQLAlchemyError)
|
||||
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse:
|
||||
"""Log DB errors and return a 500 without leaking internals."""
|
||||
logger.exception("Database error on %s %s", request.method, request.url)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Database error"},
|
||||
)
|
||||
|
||||
# === Routers ===
|
||||
# Health is mounted at both /health (root) and /api/v1/health (versioned)
|
||||
app.include_router(health.router)
|
||||
app.include_router(auth.router, prefix="/api/v1")
|
||||
app.include_router(users.router, prefix="/api/v1")
|
||||
# Phase 4b business routers
|
||||
app.include_router(accounts.router, prefix="/api/v1")
|
||||
app.include_router(contacts.router, prefix="/api/v1")
|
||||
app.include_router(deals.router, prefix="/api/v1")
|
||||
app.include_router(activities.router, prefix="/api/v1")
|
||||
app.include_router(notes.router, prefix="/api/v1")
|
||||
app.include_router(tags.router, prefix="/api/v1")
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
|
||||
# === Static Files (Frontend) ===
|
||||
# Mount app/webui/ on root AFTER all routers, so /api/v1/* still matches first.
|
||||
# StaticFiles mit html=True servt index.html on `/` und alle .html files direkt.
|
||||
webui_dir = Path(__file__).parent / "webui"
|
||||
if webui_dir.exists():
|
||||
app.mount("/", StaticFiles(directory=str(webui_dir), html=True), name="webui")
|
||||
else:
|
||||
logger.warning("webui directory not found at %s — frontend not served", webui_dir)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(roles.router)
|
||||
app.include_router(tenants.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(companies.router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
+12
-33
@@ -1,37 +1,16 @@
|
||||
"""SQLAlchemy ORM models for the CRM system."""
|
||||
"""SQLAlchemy models for LeoCRM."""
|
||||
|
||||
from app.models.account import Account, AccountSize, Industry
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal, DealStage
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
from app.models.note import Note, NoteParentType
|
||||
from app.models.org import Org
|
||||
from app.models.tag import Tag
|
||||
from app.models.tag_link import TagLink, TagLinkParentType
|
||||
from app.models.user import User, UserRole
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.role import Role
|
||||
from app.models.session import Session
|
||||
from app.models.audit import AuditLog, DeletionLog
|
||||
from app.models.notification import Notification
|
||||
from app.models.auth import PasswordResetToken, ApiToken
|
||||
from app.models.company import Company
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AccountSize",
|
||||
"Activity",
|
||||
"ActivityType",
|
||||
"Base",
|
||||
"Contact",
|
||||
"Deal",
|
||||
"DealStage",
|
||||
"DealStageHistory",
|
||||
"Industry",
|
||||
"Note",
|
||||
"NoteParentType",
|
||||
"Org",
|
||||
"OrgScopedMixin",
|
||||
"SoftDeleteMixin",
|
||||
"Tag",
|
||||
"TagLink",
|
||||
"TagLinkParentType",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
"Tenant", "User", "UserTenant", "Role", "Session",
|
||||
"AuditLog", "DeletionLog", "Notification",
|
||||
"PasswordResetToken", "ApiToken", "Company",
|
||||
]
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Account model: companies/organizations that the CRM tracks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.activity import Activity
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Industry(str, Enum):
|
||||
"""Industry classification for accounts."""
|
||||
|
||||
startup = "startup"
|
||||
sme = "sme"
|
||||
midmarket = "midmarket"
|
||||
enterprise = "enterprise"
|
||||
other = "other"
|
||||
|
||||
|
||||
class AccountSize(str, Enum):
|
||||
"""Company size category."""
|
||||
|
||||
startup = "startup"
|
||||
sme = "sme"
|
||||
midmarket = "midmarket"
|
||||
enterprise = "enterprise"
|
||||
|
||||
|
||||
class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
website: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
industry: Mapped[Industry | None] = mapped_column(String(32), nullable=True, index=True)
|
||||
size: Mapped[AccountSize | None] = mapped_column(String(32), nullable=True)
|
||||
address: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
|
||||
contacts: Mapped[list[Contact]] = relationship(
|
||||
"Contact", back_populates="account", lazy="selectin"
|
||||
)
|
||||
deals: Mapped[list[Deal]] = relationship("Deal", back_populates="account", lazy="selectin")
|
||||
activities: Mapped[list[Activity]] = relationship(
|
||||
"Activity", back_populates="account", lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list[Note]] = relationship(
|
||||
"Note",
|
||||
primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[list[TagLink]] = relationship(
|
||||
"TagLink",
|
||||
primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Account id={self.id} name={self.name!r}>"
|
||||
|
||||
|
||||
__all__ = ["Account", "AccountSize", "Industry"]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Activity model: calls, meetings, emails, tasks, notes tied to any entity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class ActivityType(str, Enum):
|
||||
"""Type of activity."""
|
||||
|
||||
call = "call"
|
||||
meeting = "meeting"
|
||||
email = "email"
|
||||
task = "task"
|
||||
note = "note"
|
||||
|
||||
|
||||
class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "activities"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
type: Mapped[ActivityType] = mapped_column(String(32), nullable=False, index=True)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
due_date: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True, index=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
contact_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("contacts.id"), nullable=True, index=True
|
||||
)
|
||||
deal_id: Mapped[int | None] = mapped_column(ForeignKey("deals.id"), nullable=True, index=True)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
account: Mapped[Account | None] = relationship(
|
||||
"Account", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
contact: Mapped[Contact | None] = relationship(
|
||||
"Contact", back_populates="activities", lazy="selectin"
|
||||
)
|
||||
deal: Mapped[Deal | None] = relationship("Deal", back_populates="activities", lazy="selectin")
|
||||
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>"
|
||||
|
||||
|
||||
__all__ = ["Activity", "ActivityType"]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""AuditLog and DeletionLog models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class AuditLog(Base, TenantMixin):
|
||||
"""Audit trail for all create/update/delete/login actions."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
entity_id: Mapped[uuid.UUID | None] = mapped_column(PGUUID(as_uuid=True), nullable=True)
|
||||
changes: Mapped[dict[str, Any] | None] = mapped_column(JSONB, nullable=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class DeletionLog(Base, TenantMixin):
|
||||
"""Immutable record of deleted entities (for forensic recovery)."""
|
||||
|
||||
__tablename__ = "deletion_log"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False)
|
||||
entity_snapshot: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
deleted_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""PasswordResetToken and ApiToken models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class PasswordResetToken(Base, TenantMixin):
|
||||
"""Token for password reset flow."""
|
||||
|
||||
__tablename__ = "password_reset_tokens"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class ApiToken(Base, TenantMixin):
|
||||
"""API token for programmatic access."""
|
||||
|
||||
__tablename__ = "api_tokens"
|
||||
__table_args__ = (
|
||||
Index("ix_api_tokens_tenant_user", "tenant_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
scopes: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -1,51 +0,0 @@
|
||||
"""Base model and reusable mixins for the CRM system."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Adds created_at and updated_at columns to a model."""
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
"""Adds deleted_at column for soft-delete pattern."""
|
||||
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
class OrgScopedMixin:
|
||||
"""Adds org_id foreign key. All queries must filter by org_id (OrgScopedQuery)."""
|
||||
|
||||
org_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("orgs.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["Base", "OrgScopedMixin", "SoftDeleteMixin", "TimestampMixin"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Company model — minimal for cross-tenant test (AC #17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, Integer, Numeric, Text, DateTime, ForeignKey, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Company(Base, TenantMixin):
|
||||
"""Company entity (minimal fields for T01 cross-tenant test)."""
|
||||
|
||||
__tablename__ = "companies"
|
||||
__table_args__ = (
|
||||
Index("ix_companies_tenant_deleted", "tenant_id", "deleted_at"),
|
||||
Index("ix_companies_tenant_name", "tenant_id", "name"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
account_number: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Contact model: people at accounts (or standalone contacts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.activity import Activity
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "contacts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
first_name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("accounts.id"), nullable=True, index=True
|
||||
)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
account: Mapped[Account | None] = relationship(
|
||||
"Account", back_populates="contacts", lazy="selectin"
|
||||
)
|
||||
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
|
||||
activities: Mapped[list[Activity]] = relationship(
|
||||
"Activity", back_populates="contact", lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list[Note]] = relationship(
|
||||
"Note",
|
||||
primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[list[TagLink]] = relationship(
|
||||
"TagLink",
|
||||
primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Contact id={self.id} {self.first_name} {self.last_name!r}>"
|
||||
|
||||
|
||||
__all__ = ["Contact"]
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Deal model: sales opportunities tied to an account."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.activity import Activity
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
from app.models.note import Note
|
||||
from app.models.tag_link import TagLink
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class DealStage(str, Enum):
|
||||
"""Sales pipeline stage for a deal."""
|
||||
|
||||
lead = "lead"
|
||||
qualified = "qualified"
|
||||
proposal = "proposal"
|
||||
negotiation = "negotiation"
|
||||
won = "won"
|
||||
lost = "lost"
|
||||
|
||||
|
||||
class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "deals"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
|
||||
currency: Mapped[str] = mapped_column(
|
||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
||||
)
|
||||
stage: Mapped[DealStage] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=DealStage.lead,
|
||||
server_default=DealStage.lead.value,
|
||||
index=True,
|
||||
)
|
||||
close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False, index=True)
|
||||
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
won_lost_reason: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# Relationships
|
||||
account: Mapped[Account] = relationship("Account", back_populates="deals", lazy="selectin")
|
||||
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
|
||||
stage_history: Mapped[list[DealStageHistory]] = relationship(
|
||||
"DealStageHistory",
|
||||
back_populates="deal",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
order_by="DealStageHistory.created_at",
|
||||
)
|
||||
activities: Mapped[list[Activity]] = relationship(
|
||||
"Activity", back_populates="deal", lazy="selectin"
|
||||
)
|
||||
notes: Mapped[list[Note]] = relationship(
|
||||
"Note",
|
||||
primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
tags: Mapped[list[TagLink]] = relationship(
|
||||
"TagLink",
|
||||
primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')",
|
||||
viewonly=True,
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Deal id={self.id} title={self.title!r} stage={self.stage}>"
|
||||
|
||||
|
||||
__all__ = ["Deal", "DealStage"]
|
||||
@@ -1,36 +0,0 @@
|
||||
"""DealStageHistory model: audit trail of stage transitions for a deal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, TimestampMixin
|
||||
from app.models.deal import DealStage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.deal import Deal
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
|
||||
"""Records each deal stage transition. Append-only — no soft-delete."""
|
||||
|
||||
__tablename__ = "deal_stage_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"), nullable=False, index=True)
|
||||
from_stage: Mapped[DealStage | None] = mapped_column(String(32), nullable=True)
|
||||
to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False)
|
||||
changed_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
|
||||
deal: Mapped[Deal] = relationship("Deal", back_populates="stage_history", lazy="joined")
|
||||
changer: Mapped[User] = relationship("User", foreign_keys=[changed_by], lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>"
|
||||
|
||||
|
||||
__all__ = ["DealStageHistory"]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Note model: free-text notes attached polymorphically to accounts/contacts/deals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class NoteParentType(str, Enum):
|
||||
"""Polymorphic parent type for notes (no DB FK, validated in service layer)."""
|
||||
|
||||
account = "account"
|
||||
contact = "contact"
|
||||
deal = "deal"
|
||||
|
||||
|
||||
class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
|
||||
parent_type: Mapped[NoteParentType] = mapped_column(String(32), nullable=False, index=True)
|
||||
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
|
||||
# Service layer (note_service.create_note) validates existence.
|
||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
|
||||
author: Mapped[User] = relationship("User", foreign_keys=[author_id], lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>"
|
||||
|
||||
|
||||
__all__ = ["Note", "NoteParentType"]
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Notification model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Text, func, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Notification(Base, TenantMixin):
|
||||
"""User notification entity."""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
Index("ix_notifications_tenant_user_read", "tenant_id", "user_id", "read_at"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
body: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Organization model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class Org(Base, TimestampMixin):
|
||||
__tablename__ = "orgs"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
logo_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
default_currency: Mapped[str] = mapped_column(
|
||||
String(3), nullable=False, default="EUR", server_default="EUR"
|
||||
)
|
||||
|
||||
# Relationship to users (defined here to resolve circular import)
|
||||
users: Mapped[list[User]] = relationship(
|
||||
back_populates="org",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Org id={self.id} name={self.name!r}>"
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Role model with RBAC permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Role(Base, TenantMixin):
|
||||
"""Role entity with module→action→permission mapping and field-level permissions."""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
|
||||
field_permissions: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Session model — PostgreSQL audit trail for sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, ForeignKey, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class Session(Base, TenantMixin):
|
||||
"""Immutable session audit record. Runtime session lookup uses Redis."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
csrf_token: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Tag model: labels that can be linked to accounts/contacts/deals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tag_link import TagLink
|
||||
|
||||
|
||||
class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
color: Mapped[str] = mapped_column(
|
||||
String(7), nullable=False, default="#3B82F6", server_default="#3B82F6"
|
||||
)
|
||||
|
||||
# Relationships
|
||||
links: Mapped[list[TagLink]] = relationship(
|
||||
"TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Tag id={self.id} name={self.name!r}>"
|
||||
|
||||
|
||||
__all__ = ["Tag"]
|
||||
@@ -1,44 +0,0 @@
|
||||
"""TagLink model: polymorphic many-to-many between tags and accounts/contacts/deals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.tag import Tag
|
||||
|
||||
|
||||
class TagLinkParentType(str, Enum):
|
||||
"""Polymorphic parent type for tag links (no DB FK, validated in service layer)."""
|
||||
|
||||
account = "account"
|
||||
contact = "contact"
|
||||
deal = "deal"
|
||||
|
||||
|
||||
class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "tag_links"
|
||||
__table_args__ = (UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), nullable=False, index=True)
|
||||
parent_type: Mapped[TagLinkParentType] = mapped_column(String(32), nullable=False, index=True)
|
||||
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
|
||||
# Service layer (tag_service.link_tag) validates existence.
|
||||
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
|
||||
tag: Mapped[Tag] = relationship("Tag", back_populates="links", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>"
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["TagLink", "TagLinkParentType"]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Tenant model."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import String, DateTime, func
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.db import Base
|
||||
|
||||
|
||||
class Tenant(Base):
|
||||
"""Tenant entity — top-level organisational unit."""
|
||||
|
||||
__tablename__ = "tenants"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
+34
-33
@@ -1,48 +1,49 @@
|
||||
"""User model with role enum and soft-delete."""
|
||||
"""User and UserTenant models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Boolean, String, UniqueConstraint
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.org import Org
|
||||
from app.core.db import Base, TenantMixin
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""User role for RBAC."""
|
||||
class User(Base, TenantMixin):
|
||||
"""User entity — belongs to a tenant, can be member of multiple tenants."""
|
||||
|
||||
admin = "admin"
|
||||
sales_manager = "sales_manager"
|
||||
sales_rep = "sales_rep"
|
||||
|
||||
|
||||
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
|
||||
__tablename__ = "users"
|
||||
__table_args__ = (UniqueConstraint("org_id", "email", name="uq_users_org_email"),)
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[UserRole] = mapped_column(
|
||||
String(32),
|
||||
nullable=False,
|
||||
default=UserRole.sales_rep,
|
||||
server_default=UserRole.sales_rep.value,
|
||||
)
|
||||
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
email_notifications: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=True, server_default="1"
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
|
||||
|
||||
# Relationship back to org (string reference avoids circular import at runtime)
|
||||
org: Mapped[Org] = relationship(back_populates="users", lazy="joined")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<User id={self.id} email={self.email!r} role={self.role}>"
|
||||
class UserTenant(Base):
|
||||
"""N:M association — user membership in tenants."""
|
||||
|
||||
__tablename__ = "user_tenants"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(
|
||||
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Routes package."""
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Auth routes — login, logout, me, switch-tenant, password reset."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
|
||||
from app.core.auth import get_redis
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user
|
||||
from app.schemas.auth import (
|
||||
LoginRequest, PasswordResetConfirm, PasswordResetRequest,
|
||||
SwitchTenantRequest, AuthResponse,
|
||||
)
|
||||
from app.services.auth_service import auth_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
body: LoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Login with email+password. Sets session cookie."""
|
||||
ip = get_client_ip(request)
|
||||
redis = get_redis()
|
||||
|
||||
# Rate limit
|
||||
await check_rate_limit(
|
||||
f"auth:login:{ip}:{body.email}",
|
||||
settings.rate_limit_login_max,
|
||||
settings.rate_limit_login_window,
|
||||
)
|
||||
|
||||
result = await auth_service.login(db, redis, body.email, body.password)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Invalid email or password", "code": "invalid_credentials"},
|
||||
)
|
||||
|
||||
session_id, csrf_token, user, tenant = result
|
||||
|
||||
# Reset rate limit on success
|
||||
await reset_rate_limit(f"auth:login:{ip}:{body.email}")
|
||||
|
||||
response = Response(status_code=status.HTTP_200_OK)
|
||||
response.set_cookie(
|
||||
key=settings.session_cookie_name,
|
||||
value=session_id,
|
||||
httponly=settings.session_cookie_httponly,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite=settings.session_cookie_samesite,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
path="/",
|
||||
)
|
||||
# Return auth info in body
|
||||
from fastapi.responses import JSONResponse
|
||||
resp = JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"user_id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"tenant_id": str(tenant.id),
|
||||
"tenant_name": tenant.name,
|
||||
"csrf_token": csrf_token,
|
||||
},
|
||||
)
|
||||
resp.set_cookie(
|
||||
key=settings.session_cookie_name,
|
||||
value=session_id,
|
||||
httponly=settings.session_cookie_httponly,
|
||||
secure=settings.session_cookie_secure,
|
||||
samesite=settings.session_cookie_samesite,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
path="/",
|
||||
)
|
||||
return resp
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Logout — invalidate session, clear cookie."""
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
redis = get_redis()
|
||||
if session_id:
|
||||
await auth_service.logout(redis, session_id)
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
resp = JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={"message": "Logged out"},
|
||||
)
|
||||
resp.delete_cookie(settings.session_cookie_name, path="/")
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def me(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Get current user + active tenant."""
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||
)
|
||||
|
||||
redis = get_redis()
|
||||
info = await auth_service.get_current_user_info(db, redis, session_id)
|
||||
if info is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
||||
)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
@router.post("/switch-tenant")
|
||||
async def switch_tenant(
|
||||
request: Request,
|
||||
body: SwitchTenantRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Switch active tenant for current session."""
|
||||
session_id = request.cookies.get(settings.session_cookie_name)
|
||||
if not session_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
||||
)
|
||||
|
||||
redis = get_redis()
|
||||
try:
|
||||
new_tenant_id = uuid.UUID(body.tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Invalid tenant_id", "code": "invalid_tenant_id"},
|
||||
)
|
||||
|
||||
result = await auth_service.switch_tenant(db, redis, session_id, new_tenant_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Cannot switch to this tenant", "code": "tenant_switch_failed"},
|
||||
)
|
||||
|
||||
return {
|
||||
"user_id": result["user_id"],
|
||||
"email": result["email"],
|
||||
"name": result["name"],
|
||||
"role": result["role"],
|
||||
"tenant_id": result["tenant_id"],
|
||||
"tenant_name": result.get("tenant_name"),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/password-reset/request")
|
||||
async def password_reset_request(
|
||||
request: Request,
|
||||
body: PasswordResetRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Request password reset. Always returns 200 (no user enumeration)."""
|
||||
ip = get_client_ip(request)
|
||||
redis = get_redis()
|
||||
|
||||
await check_rate_limit(
|
||||
f"auth:reset:{ip}",
|
||||
settings.rate_limit_reset_max,
|
||||
settings.rate_limit_reset_window,
|
||||
)
|
||||
|
||||
await auth_service.request_password_reset(db, body.email)
|
||||
return {"message": "If the email exists, a reset link has been sent."}
|
||||
|
||||
|
||||
@router.post("/password-reset/confirm")
|
||||
async def password_reset_confirm(
|
||||
request: Request,
|
||||
body: PasswordResetConfirm,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reset password with a valid token."""
|
||||
ip = get_client_ip(request)
|
||||
redis = get_redis()
|
||||
|
||||
await check_rate_limit(
|
||||
f"auth:reset_confirm:{ip}",
|
||||
settings.rate_limit_reset_confirm_max,
|
||||
settings.rate_limit_reset_confirm_window,
|
||||
)
|
||||
|
||||
success = await auth_service.confirm_password_reset(db, body.token, body.new_password)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"detail": "Invalid or expired token", "code": "invalid_token"},
|
||||
)
|
||||
|
||||
return {"message": "Password has been reset successfully."}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Company routes — minimal for cross-tenant and RBAC tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.core.auth import check_permission, filter_fields_by_permission
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.models.company import Company
|
||||
from app.models.role import Role
|
||||
from app.schemas.company import CompanyCreate
|
||||
|
||||
router = APIRouter(prefix="/api/v1/companies", tags=["companies"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_companies(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List companies in current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
q = select(Company).where(
|
||||
Company.tenant_id == tenant_id,
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
companies = result.scalars().all()
|
||||
return {"items": [_company_to_dict(c) for c in companies]}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_company(
|
||||
body: CompanyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Create a company. Requires write permission."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = current_user.get("role", "viewer")
|
||||
|
||||
# RBAC check
|
||||
if not check_permission(role, "companies", "create"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"detail": "Insufficient permissions", "code": "forbidden"},
|
||||
)
|
||||
|
||||
company = Company(
|
||||
tenant_id=tenant_id,
|
||||
name=body.name,
|
||||
industry=body.industry,
|
||||
phone=body.phone,
|
||||
email=body.email,
|
||||
website=body.website,
|
||||
description=body.description,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(company)
|
||||
await db.flush()
|
||||
|
||||
# Audit log
|
||||
await log_audit(
|
||||
db, tenant_id, user_id, "create", "company", company.id,
|
||||
changes={"name": body.name},
|
||||
)
|
||||
|
||||
return _company_to_dict(company)
|
||||
|
||||
|
||||
@router.get("/{company_id}")
|
||||
async def get_company(
|
||||
company_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single company. Cross-tenant access returns 404."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||
|
||||
q = select(Company).where(
|
||||
Company.id == cid,
|
||||
Company.tenant_id == tenant_id, # Tenant filter = cross-tenant returns 404
|
||||
Company.deleted_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
company = result.scalar_one_or_none()
|
||||
if company is None:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
|
||||
# Field-level permissions
|
||||
data = _company_to_dict(company)
|
||||
# Check if user's role has field permissions defined
|
||||
role = current_user.get("role", "viewer")
|
||||
if role != "admin":
|
||||
# Check for custom role field permissions
|
||||
role_q = select(Role).where(Role.tenant_id == tenant_id, Role.name == role)
|
||||
role_result = await db.execute(role_q)
|
||||
custom_role = role_result.scalar_one_or_none()
|
||||
if custom_role and custom_role.field_permissions:
|
||||
data = filter_fields_by_permission(data, custom_role.field_permissions, role)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@router.patch("/{company_id}")
|
||||
async def update_company(
|
||||
company_id: str,
|
||||
body: CompanyCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Update a company."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = current_user.get("role", "viewer")
|
||||
|
||||
if not check_permission(role, "companies", "update"):
|
||||
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||
|
||||
q = select(Company).where(Company.id == cid, Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
|
||||
result = await db.execute(q)
|
||||
company = result.scalar_one_or_none()
|
||||
if company is None:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
|
||||
changes: dict[str, Any] = {}
|
||||
if body.name is not None:
|
||||
changes["name"] = {"old": company.name, "new": body.name}
|
||||
company.name = body.name
|
||||
if body.industry is not None:
|
||||
company.industry = body.industry
|
||||
if body.phone is not None:
|
||||
company.phone = body.phone
|
||||
if body.email is not None:
|
||||
company.email = body.email
|
||||
if body.website is not None:
|
||||
company.website = body.website
|
||||
if body.description is not None:
|
||||
company.description = body.description
|
||||
company.updated_by = user_id
|
||||
|
||||
await db.flush()
|
||||
await log_audit(db, tenant_id, user_id, "update", "company", cid, changes=changes)
|
||||
|
||||
return _company_to_dict(company)
|
||||
|
||||
|
||||
@router.delete("/{company_id}")
|
||||
async def delete_company(
|
||||
company_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Soft-delete a company."""
|
||||
from datetime import datetime, timezone
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
role = current_user.get("role", "viewer")
|
||||
|
||||
if not check_permission(role, "companies", "delete"):
|
||||
raise HTTPException(403, detail={"detail": "Insufficient permissions", "code": "forbidden"})
|
||||
|
||||
try:
|
||||
cid = uuid.UUID(company_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid company_id", "code": "invalid_id"})
|
||||
|
||||
q = select(Company).where(Company.id == cid, Company.tenant_id == tenant_id, Company.deleted_at.is_(None))
|
||||
result = await db.execute(q)
|
||||
company = result.scalar_one_or_none()
|
||||
if company is None:
|
||||
raise HTTPException(404, detail={"detail": "Company not found", "code": "not_found"})
|
||||
|
||||
company.deleted_at = datetime.now(timezone.utc)
|
||||
company.updated_by = user_id
|
||||
|
||||
await log_audit(db, tenant_id, user_id, "delete", "company", cid, changes={"name": company.name})
|
||||
|
||||
from fastapi import Response
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def _company_to_dict(c: Company) -> dict[str, Any]:
|
||||
"""Convert company to response dict."""
|
||||
return {
|
||||
"id": str(c.id),
|
||||
"name": c.name,
|
||||
"industry": c.industry,
|
||||
"phone": c.phone,
|
||||
"email": c.email,
|
||||
"annual_revenue": None, # Minimal model — would be from DB column
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Health check endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/api/v1/health")
|
||||
async def health():
|
||||
"""Health check — no auth required."""
|
||||
return {"status": "ok", "version": "1.0.0"}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Notification routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.notifications import (
|
||||
get_unread_count, list_notifications, mark_notification_read,
|
||||
)
|
||||
from app.deps import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications_endpoint(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(25, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List notifications (unread first)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
return await list_notifications(db, tenant_id, user_id, page, page_size)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read")
|
||||
async def mark_notification_read_endpoint(
|
||||
notification_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Mark a notification as read."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
nid = uuid.UUID(notification_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid notification_id", "code": "invalid_id"})
|
||||
|
||||
notif = await mark_notification_read(db, tenant_id, user_id, nid)
|
||||
if notif is None:
|
||||
raise HTTPException(404, detail={"detail": "Notification not found", "code": "not_found"})
|
||||
|
||||
return {
|
||||
"id": str(notif.id),
|
||||
"type": notif.type,
|
||||
"title": notif.title,
|
||||
"body": notif.body,
|
||||
"read_at": notif.read_at.isoformat() if notif.read_at else None,
|
||||
"created_at": notif.created_at.isoformat() if notif.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count_endpoint(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get unread notification count."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
count = await get_unread_count(db, tenant_id, user_id)
|
||||
return {"count": count}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Role management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.schemas.role import RoleCreate, RoleUpdate
|
||||
from app.services.role_service import role_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/roles", tags=["roles"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_roles(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List roles for the current tenant."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
roles = await role_service.list_roles(db, tenant_id)
|
||||
return {"items": roles}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_role(
|
||||
body: RoleCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Create a custom role (admin only)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
role = await role_service.create_role(
|
||||
db, tenant_id, body.name, body.permissions, body.field_permissions,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
content={
|
||||
"id": str(role.id),
|
||||
"name": role.name,
|
||||
"permissions": role.permissions,
|
||||
"field_permissions": role.field_permissions,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{role_id}")
|
||||
async def update_role(
|
||||
role_id: str,
|
||||
body: RoleUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Update a role (admin only)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
rid = uuid.UUID(role_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
|
||||
|
||||
role = await role_service.update_role(
|
||||
db, tenant_id, rid, body.name, body.permissions, body.field_permissions,
|
||||
)
|
||||
if role is None:
|
||||
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||
|
||||
return {
|
||||
"id": str(role.id),
|
||||
"name": role.name,
|
||||
"permissions": role.permissions,
|
||||
"field_permissions": role.field_permissions,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{role_id}")
|
||||
async def delete_role(
|
||||
role_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Delete a role (admin only)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
rid = uuid.UUID(role_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid role_id", "code": "invalid_id"})
|
||||
|
||||
success = await role_service.delete_role(db, tenant_id, rid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "Role not found", "code": "not_found"})
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tenant management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.deps import get_current_user, require_admin
|
||||
from app.schemas.tenant import TenantCreate, TenantUserAssign
|
||||
from app.services.tenant_service import tenant_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/tenants", tags=["tenants"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_tenants(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""List tenants for the current user."""
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
tenants = await tenant_service.list_tenants_for_user(db, user_id)
|
||||
return {"items": tenants}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_tenant(
|
||||
body: TenantCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Create a new tenant (admin only)."""
|
||||
tenant = await tenant_service.create_tenant(db, body.name, body.slug)
|
||||
return {
|
||||
"id": str(tenant.id),
|
||||
"name": tenant.name,
|
||||
"slug": tenant.slug,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{tenant_id}/users")
|
||||
async def list_tenant_users(
|
||||
tenant_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""List users in a tenant (admin only)."""
|
||||
try:
|
||||
tid = uuid.UUID(tenant_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid tenant_id", "code": "invalid_id"})
|
||||
|
||||
users = await tenant_service.list_tenant_users(db, tid)
|
||||
return {"items": users}
|
||||
|
||||
|
||||
@router.post("/{tenant_id}/users")
|
||||
async def assign_user_to_tenant(
|
||||
tenant_id: str,
|
||||
body: TenantUserAssign,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Assign a user to a tenant (admin only)."""
|
||||
try:
|
||||
tid = uuid.UUID(tenant_id)
|
||||
uid = uuid.UUID(body.user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid ID", "code": "invalid_id"})
|
||||
|
||||
ut = await tenant_service.assign_user_to_tenant(db, tid, uid)
|
||||
return {"message": "User assigned to tenant"}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""User management routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi import Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.core.db import get_db
|
||||
from app.core.notifications import create_notification
|
||||
from app.deps import get_current_user, require_admin, get_tenant_id, get_current_user_id
|
||||
from app.schemas.user import UserCreate, UserUpdate
|
||||
from app.services.user_service import user_service
|
||||
|
||||
router = APIRouter(prefix="/api/v1/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(25, ge=1, le=100),
|
||||
search: str | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""List users (admin only, paginated)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
return await user_service.list_users(db, tenant_id, page, page_size, search)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
body: UserCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Create a new user (admin only)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
user_id = uuid.UUID(current_user["user_id"])
|
||||
|
||||
user = await user_service.create_user(
|
||||
db, tenant_id, body.email, body.name, body.password, body.role, body.is_active,
|
||||
)
|
||||
|
||||
# Audit log
|
||||
await log_audit(
|
||||
db, tenant_id, user_id, "create", "user", user.id,
|
||||
changes={"email": body.email, "name": body.name, "role": body.role},
|
||||
)
|
||||
|
||||
# Notification
|
||||
await create_notification(
|
||||
db, tenant_id, user.id, "info",
|
||||
"Account created",
|
||||
f"Your account has been created by {current_user['name']}.",
|
||||
)
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{user_id}")
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single user."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
|
||||
|
||||
user = await user_service.get_user(db, tenant_id, uid)
|
||||
if user is None:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
}
|
||||
|
||||
|
||||
@router.patch("/{user_id}")
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UserUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Update a user (admin only)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
|
||||
|
||||
changes: dict[str, Any] = {}
|
||||
if body.name is not None:
|
||||
changes["name"] = body.name
|
||||
if body.role is not None:
|
||||
changes["role"] = body.role
|
||||
if body.is_active is not None:
|
||||
changes["is_active"] = body.is_active
|
||||
|
||||
user = await user_service.update_user(
|
||||
db, tenant_id, uid, body.name, body.role, body.is_active,
|
||||
)
|
||||
if user is None:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
|
||||
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
|
||||
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: dict = Depends(require_admin),
|
||||
):
|
||||
"""Delete a user (admin only)."""
|
||||
tenant_id = uuid.UUID(current_user["tenant_id"])
|
||||
acting_user_id = uuid.UUID(current_user["user_id"])
|
||||
try:
|
||||
uid = uuid.UUID(user_id)
|
||||
except ValueError:
|
||||
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
|
||||
|
||||
# Get user snapshot for audit before deletion
|
||||
user = await user_service.get_user(db, tenant_id, uid)
|
||||
if user is None:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
|
||||
success = await user_service.delete_user(db, tenant_id, uid)
|
||||
if not success:
|
||||
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
|
||||
|
||||
await log_audit(
|
||||
db, tenant_id, acting_user_id, "delete", "user", uid,
|
||||
changes={"email": user.email, "name": user.name},
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
+1
-86
@@ -1,86 +1 @@
|
||||
"""Pydantic schemas for the CRM system."""
|
||||
|
||||
from app.schemas.account import (
|
||||
AccountCreate,
|
||||
AccountListItem,
|
||||
AccountOut,
|
||||
AccountUpdate,
|
||||
)
|
||||
from app.schemas.activity import (
|
||||
ActivityCompleteRequest,
|
||||
ActivityCreate,
|
||||
ActivityOut,
|
||||
ActivityUpdate,
|
||||
)
|
||||
from app.schemas.auth import (
|
||||
LogoutResponse,
|
||||
RegisterResponse,
|
||||
TokenResponse,
|
||||
UserLoginRequest,
|
||||
UserRegisterRequest,
|
||||
)
|
||||
from app.schemas.common import PaginatedResponse, PaginationParams
|
||||
from app.schemas.contact import (
|
||||
ContactCreate,
|
||||
ContactListItem,
|
||||
ContactOut,
|
||||
ContactUpdate,
|
||||
)
|
||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
||||
from app.schemas.deal import (
|
||||
DealCreate,
|
||||
DealListItem,
|
||||
DealOut,
|
||||
DealPipelineOut,
|
||||
DealStageUpdate,
|
||||
DealUpdate,
|
||||
)
|
||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
||||
from app.schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AccountCreate",
|
||||
"AccountListItem",
|
||||
"AccountOut",
|
||||
"AccountUpdate",
|
||||
"ActivityCompleteRequest",
|
||||
"ActivityCreate",
|
||||
"ActivityFeedItem",
|
||||
"ActivityOut",
|
||||
"ActivityUpdate",
|
||||
"ContactCreate",
|
||||
"ContactListItem",
|
||||
"ContactOut",
|
||||
"ContactUpdate",
|
||||
"DealCreate",
|
||||
"DealListItem",
|
||||
"DealOut",
|
||||
"DealPipelineOut",
|
||||
"DealStageUpdate",
|
||||
"DealUpdate",
|
||||
"KPIOut",
|
||||
"LogoutResponse",
|
||||
"NoteCreate",
|
||||
"NoteOut",
|
||||
"NoteUpdate",
|
||||
"PaginatedResponse",
|
||||
"PaginationParams",
|
||||
"RegisterResponse",
|
||||
"TagCreate",
|
||||
"TagLinkCreate",
|
||||
"TagLinkOut",
|
||||
"TagOut",
|
||||
"TokenResponse",
|
||||
"UserCreateRequest",
|
||||
"UserListResponse",
|
||||
"UserLoginRequest",
|
||||
"UserOut",
|
||||
"UserRegisterRequest",
|
||||
"UserUpdate",
|
||||
]
|
||||
"""Pydantic schemas package."""
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
"""Pydantic schemas for Account endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.account import AccountSize, Industry
|
||||
|
||||
|
||||
class AccountBase(BaseModel):
|
||||
"""Shared fields for create/update."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
website: str | None = Field(None, max_length=512)
|
||||
industry: Industry | None = None
|
||||
size: AccountSize | None = None
|
||||
address: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
"""Request body for POST /api/v1/accounts/."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/v1/accounts/{id}. All optional."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
website: str | None = Field(None, max_length=512)
|
||||
industry: Industry | None = None
|
||||
size: AccountSize | None = None
|
||||
address: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AccountOut(AccountBase):
|
||||
"""Response schema for an account."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class AccountListItem(AccountOut):
|
||||
"""Slim schema for list responses."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["AccountCreate", "AccountListItem", "AccountOut", "AccountUpdate"]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""Pydantic schemas for Activity endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from app.models.activity import ActivityType
|
||||
|
||||
|
||||
class ActivityBase(BaseModel):
|
||||
type: ActivityType
|
||||
subject: str = Field(..., min_length=1, max_length=255)
|
||||
body: str | None = None
|
||||
due_date: datetime | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
deal_id: int | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_at_least_one_parent(self) -> ActivityBase:
|
||||
if self.account_id is None and self.contact_id is None and self.deal_id is None:
|
||||
raise ValueError("At least one of account_id, contact_id, deal_id must be set")
|
||||
return self
|
||||
|
||||
|
||||
class ActivityCreate(ActivityBase):
|
||||
pass
|
||||
|
||||
|
||||
class ActivityUpdate(BaseModel):
|
||||
type: ActivityType | None = None
|
||||
subject: str | None = Field(None, min_length=1, max_length=255)
|
||||
body: str | None = None
|
||||
due_date: datetime | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
deal_id: int | None = None
|
||||
|
||||
|
||||
class ActivityOut(ActivityBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class ActivityCompleteRequest(BaseModel):
|
||||
"""Body for PATCH /api/v1/activities/{id}/complete."""
|
||||
|
||||
outcome: str | None = Field(None, max_length=2048)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActivityCompleteRequest",
|
||||
"ActivityCreate",
|
||||
"ActivityOut",
|
||||
"ActivityUpdate",
|
||||
]
|
||||
+18
-41
@@ -1,59 +1,36 @@
|
||||
"""Pydantic schemas for authentication endpoints."""
|
||||
"""Auth schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class UserRegisterRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/auth/register (bootstrap)."""
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: UserRole = UserRole.sales_rep
|
||||
password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class UserLoginRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/auth/login (form-data or JSON)."""
|
||||
|
||||
class PasswordResetRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=1, max_length=128)
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Response body for successful auth (register/login/refresh)."""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int # seconds
|
||||
class PasswordResetConfirm(BaseModel):
|
||||
token: str = Field(..., min_length=1)
|
||||
new_password: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
"""Response body for POST /api/v1/auth/logout.
|
||||
|
||||
The token is deleted client-side; this endpoint exists for consistency and
|
||||
future server-side blacklisting.
|
||||
"""
|
||||
|
||||
message: str = "logged out"
|
||||
class SwitchTenantRequest(BaseModel):
|
||||
tenant_id: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
"""Response body for successful registration.
|
||||
|
||||
Returns the user info (without password) plus an access token.
|
||||
"""
|
||||
|
||||
user: UserOut
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
class AuthResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
tenant_id: str
|
||||
tenant_name: str | None = None
|
||||
|
||||
|
||||
# Late import to avoid circular dependency
|
||||
from app.schemas.user import UserOut # noqa: E402
|
||||
|
||||
RegisterResponse.model_rebuild()
|
||||
class MessageResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
+24
-18
@@ -1,30 +1,36 @@
|
||||
"""Common Pydantic schemas: pagination."""
|
||||
"""Common schemas for pagination, errors, etc."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
T = TypeVar("T")
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Standard pagination query params."""
|
||||
|
||||
skip: int = Field(default=0, ge=0)
|
||||
limit: int = Field(default=20, ge=1, le=100)
|
||||
class ErrorResponse(BaseModel):
|
||||
detail: str
|
||||
code: str | None = None
|
||||
fields: dict[str, str] | None = None
|
||||
|
||||
|
||||
class PaginatedResponse(BaseModel, Generic[T]):
|
||||
"""Generic paginated response."""
|
||||
class HealthResponse(BaseModel):
|
||||
status: str
|
||||
version: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[T] # type: ignore[valid-type]
|
||||
class NotificationResponse(BaseModel):
|
||||
id: str
|
||||
type: str
|
||||
title: str
|
||||
body: str | None = None
|
||||
read_at: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class NotificationListResponse(BaseModel):
|
||||
items: list[NotificationResponse]
|
||||
total: int
|
||||
skip: int
|
||||
limit: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
__all__ = ["PaginatedResponse", "PaginationParams"]
|
||||
class UnreadCountResponse(BaseModel):
|
||||
count: int
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Company schema (minimal for cross-tenant test)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CompanyCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
industry: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
website: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class CompanyResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
industry: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
annual_revenue: float | None = None
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Pydantic schemas for Contact endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class ContactBase(BaseModel):
|
||||
first_name: str = Field(..., min_length=1, max_length=128)
|
||||
last_name: str = Field(..., min_length=1, max_length=128)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=64)
|
||||
account_id: int | None = None
|
||||
|
||||
|
||||
class ContactCreate(ContactBase):
|
||||
pass
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
first_name: str | None = Field(None, min_length=1, max_length=128)
|
||||
last_name: str | None = Field(None, min_length=1, max_length=128)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=64)
|
||||
account_id: int | None = None
|
||||
|
||||
|
||||
class ContactOut(ContactBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class ContactListItem(ContactOut):
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["ContactCreate", "ContactListItem", "ContactOut", "ContactUpdate"]
|
||||
@@ -1,39 +0,0 @@
|
||||
"""Pydantic schemas for Dashboard endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.activity import ActivityType
|
||||
|
||||
|
||||
class KPIOut(BaseModel):
|
||||
"""Headline KPIs for the org dashboard."""
|
||||
|
||||
open_deals_count: int
|
||||
pipeline_value: float
|
||||
won_this_month: int
|
||||
conversion_rate: float
|
||||
|
||||
|
||||
class ActivityFeedItem(BaseModel):
|
||||
"""One item in the activity feed."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
type: ActivityType
|
||||
subject: str
|
||||
body: str | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
deal_id: int | None = None
|
||||
owner_id: int
|
||||
completed_at: datetime | None = None
|
||||
due_date: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
__all__ = ["ActivityFeedItem", "KPIOut"]
|
||||
@@ -1,72 +0,0 @@
|
||||
"""Pydantic schemas for Deal endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.deal import DealStage
|
||||
|
||||
|
||||
class DealBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
value: Decimal = Field(default=Decimal("0"), max_digits=12, decimal_places=2)
|
||||
currency: str = Field(default="EUR", min_length=3, max_length=3)
|
||||
stage: DealStage = DealStage.lead
|
||||
close_date: date | None = None
|
||||
account_id: int
|
||||
won_lost_reason: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealCreate(DealBase):
|
||||
pass
|
||||
|
||||
|
||||
class DealUpdate(BaseModel):
|
||||
title: str | None = Field(None, min_length=1, max_length=255)
|
||||
value: Decimal | None = Field(None, max_digits=12, decimal_places=2)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
close_date: date | None = None
|
||||
won_lost_reason: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealOut(DealBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
owner_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class DealListItem(DealOut):
|
||||
pass
|
||||
|
||||
|
||||
class DealStageUpdate(BaseModel):
|
||||
"""Body for PATCH /api/v1/deals/{id}/stage."""
|
||||
|
||||
stage: DealStage
|
||||
reason: str | None = Field(None, max_length=512)
|
||||
|
||||
|
||||
class DealPipelineOut(BaseModel):
|
||||
"""A single stage's slice of the pipeline."""
|
||||
|
||||
stage: DealStage
|
||||
count: int
|
||||
total_value: float
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DealCreate",
|
||||
"DealListItem",
|
||||
"DealOut",
|
||||
"DealPipelineOut",
|
||||
"DealStageUpdate",
|
||||
"DealUpdate",
|
||||
]
|
||||
@@ -1,42 +0,0 @@
|
||||
"""Pydantic schemas for Note endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.note import NoteParentType
|
||||
|
||||
|
||||
class NoteCreate(BaseModel):
|
||||
"""Body for POST /api/v1/notes/."""
|
||||
|
||||
body: str = Field(..., min_length=1)
|
||||
parent_type: NoteParentType
|
||||
parent_id: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
"""Body for PATCH /api/v1/notes/{id}."""
|
||||
|
||||
body: str | None = Field(None, min_length=1)
|
||||
|
||||
|
||||
class NoteOut(BaseModel):
|
||||
"""Response schema for a note."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
body: str
|
||||
author_id: int
|
||||
parent_type: NoteParentType
|
||||
parent_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
__all__ = ["NoteCreate", "NoteOut", "NoteUpdate"]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Role schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class RoleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
field_permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RoleUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
permissions: dict[str, Any] | None = None
|
||||
field_permissions: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class RoleResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
permissions: dict[str, Any]
|
||||
field_permissions: dict[str, Any]
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Pydantic schemas for Tag and TagLink endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.tag_link import TagLinkParentType
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
"""Body for POST /api/v1/tags/."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
color: str = Field(default="#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
|
||||
|
||||
class TagOut(BaseModel):
|
||||
"""Response schema for a tag."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
name: str
|
||||
color: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
class TagLinkCreate(BaseModel):
|
||||
"""Body for POST /api/v1/tags/link."""
|
||||
|
||||
tag_id: int = Field(..., ge=1)
|
||||
parent_type: TagLinkParentType
|
||||
parent_id: int = Field(..., ge=1)
|
||||
|
||||
|
||||
class TagLinkOut(BaseModel):
|
||||
"""Response schema for a tag link."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
org_id: int
|
||||
tag_id: int
|
||||
parent_type: TagLinkParentType
|
||||
parent_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
|
||||
__all__ = ["TagCreate", "TagLinkCreate", "TagLinkOut", "TagOut"]
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Tenant schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TenantCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
slug: str = Field(..., min_length=1, max_length=100)
|
||||
|
||||
|
||||
class TenantResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class TenantUserAssign(BaseModel):
|
||||
user_id: str = Field(..., min_length=1)
|
||||
+20
-38
@@ -1,55 +1,37 @@
|
||||
"""Pydantic schemas for User endpoints."""
|
||||
"""User schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.models.user import UserRole
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""Response schema for a user (never includes password_hash)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
class UserCreate(BaseModel):
|
||||
email: EmailStr
|
||||
name: str
|
||||
role: UserRole
|
||||
org_id: int
|
||||
avatar_url: str | None = None
|
||||
email_notifications: bool = True
|
||||
created_at: datetime
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
password: str = Field(..., min_length=8)
|
||||
role: str = Field(default="viewer")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""Request body for PATCH /api/v1/users/{id} (admin or self)."""
|
||||
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
avatar_url: str | None = Field(None, max_length=1024)
|
||||
email_notifications: bool | None = None
|
||||
role: UserRole | None = None # admin-only
|
||||
name: str | None = Field(None, min_length=1, max_length=200)
|
||||
role: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/users (admin-only)."""
|
||||
|
||||
email: EmailStr
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
role: UserRole = UserRole.sales_rep
|
||||
class UserResponse(BaseModel):
|
||||
id: str
|
||||
email: str
|
||||
name: str
|
||||
role: str
|
||||
is_active: bool
|
||||
tenant_id: str
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""Response schema for paginated user list."""
|
||||
|
||||
items: list[UserOut]
|
||||
class PaginatedUsers(BaseModel):
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
# Re-export for late import in auth.py
|
||||
__all__ = ["UserCreateRequest", "UserListResponse", "UserOut", "UserUpdate"]
|
||||
|
||||
@@ -1,8 +1 @@
|
||||
"""Service layer for the CRM system.
|
||||
|
||||
Submodules are imported directly by routers via
|
||||
`from app.services.<name> import <func>` to avoid circular-import issues
|
||||
during application startup.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
"""Service layer package."""
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
"""OrgScopedQuery helper: central tenant-isolation layer (R-1 mitigation).
|
||||
|
||||
Every business query passes through this helper so that:
|
||||
- `org_id` is always applied (multi-tenant isolation)
|
||||
- `deleted_at IS NULL` is applied by default (soft-delete)
|
||||
- R-1 risk is mitigated: no query accidentally crosses tenant boundaries
|
||||
|
||||
In v1 (single-tenant) org_id defaults to 1. In v2 (multi-tenant) the
|
||||
router layer passes `current_user.org_id`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class OrgScopedQuery:
|
||||
"""Zentraler Query-Helper, der jede Query mit org_id filtert.
|
||||
|
||||
v1: org_id = 1 (Single-Tenant-Default)
|
||||
v2: org_id = current_user.org_id
|
||||
"""
|
||||
|
||||
def __init__(self, model: type[Any], db: AsyncSession, org_id: int = 1) -> None:
|
||||
self.model = model
|
||||
self.db = db
|
||||
self.org_id = org_id
|
||||
|
||||
async def get(self, id: int) -> Any:
|
||||
"""Fetch a single record by id, scoped to org and not soft-deleted."""
|
||||
result = await self.db.execute(
|
||||
select(self.model).where(
|
||||
self.model.id == id,
|
||||
self.model.org_id == self.org_id,
|
||||
self.model.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list(
|
||||
self,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
order_by: Any | None = None,
|
||||
**filters: Any,
|
||||
) -> list[Any]:
|
||||
"""List records scoped to org, with optional filters and pagination."""
|
||||
stmt = select(self.model).where(
|
||||
self.model.org_id == self.org_id,
|
||||
self.model.deleted_at.is_(None),
|
||||
)
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
stmt = stmt.where(getattr(self.model, field) == value)
|
||||
if order_by is not None:
|
||||
stmt = stmt.order_by(order_by)
|
||||
stmt = stmt.offset(skip).limit(limit)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count(self, **filters: Any) -> int:
|
||||
"""Count records scoped to org, with optional filters."""
|
||||
from sqlalchemy import func as sa_func
|
||||
|
||||
stmt = (
|
||||
select(sa_func.count())
|
||||
.select_from(self.model)
|
||||
.where(
|
||||
self.model.org_id == self.org_id,
|
||||
self.model.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
for field, value in filters.items():
|
||||
if value is not None:
|
||||
stmt = stmt.where(getattr(self.model, field) == value)
|
||||
result = await self.db.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
__all__ = ["OrgScopedQuery"]
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Account service: create, get, list (with filters), update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.schemas.account import AccountCreate, AccountUpdate
|
||||
|
||||
|
||||
class AccountNotFound(Exception):
|
||||
"""Raised when an account lookup fails."""
|
||||
|
||||
|
||||
async def create_account(
|
||||
db: AsyncSession, payload: AccountCreate, *, org_id: int, owner_id: int
|
||||
) -> Account:
|
||||
"""Create a new account in the given org."""
|
||||
|
||||
account = Account(
|
||||
org_id=org_id,
|
||||
name=payload.name,
|
||||
website=payload.website,
|
||||
industry=payload.industry,
|
||||
size=payload.size,
|
||||
address=payload.address,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(account)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def get_account(db: AsyncSession, account_id: int, *, org_id: int) -> Account | None:
|
||||
"""Fetch a single account by id (org-scoped, not soft-deleted)."""
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
q = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
return await q.get(account_id)
|
||||
|
||||
|
||||
async def list_accounts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
industry: str | None = None,
|
||||
size: str | None = None,
|
||||
owner_id: int | None = None,
|
||||
q: str | None = None,
|
||||
) -> list[Account]:
|
||||
"""List accounts with filters and search."""
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
scoped = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
base = await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Account.id,
|
||||
industry=industry,
|
||||
size=size,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if q:
|
||||
# In-memory filter for name (small datasets, fine for v1)
|
||||
needle = q.lower()
|
||||
base = [a for a in base if needle in a.name.lower()]
|
||||
return base
|
||||
|
||||
|
||||
async def update_account(db: AsyncSession, account: Account, payload: AccountUpdate) -> Account:
|
||||
"""Apply partial updates to an account."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(account, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
async def soft_delete_account(db: AsyncSession, account: Account) -> Account:
|
||||
"""Soft-delete an account by setting deleted_at to now."""
|
||||
account.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AccountNotFound",
|
||||
"create_account",
|
||||
"get_account",
|
||||
"list_accounts",
|
||||
"soft_delete_account",
|
||||
"update_account",
|
||||
]
|
||||
@@ -1,183 +0,0 @@
|
||||
"""Activity service: create, get, list, update, complete, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.activity import Activity, ActivityType
|
||||
from app.schemas.activity import ActivityCompleteRequest, ActivityCreate, ActivityUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class ActivityNotFound(Exception):
|
||||
"""Raised when an activity lookup fails."""
|
||||
|
||||
|
||||
class NoParentException(Exception):
|
||||
"""Raised when an activity is created without any parent (account/contact/deal)."""
|
||||
|
||||
|
||||
async def _validate_parents(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
account_id: int | None,
|
||||
contact_id: int | None,
|
||||
deal_id: int | None,
|
||||
) -> None:
|
||||
"""Ensure at least one parent is set and each provided id exists in org."""
|
||||
if account_id is None and contact_id is None and deal_id is None:
|
||||
raise NoParentException(
|
||||
"Activity must reference at least one of: account_id, contact_id, deal_id"
|
||||
)
|
||||
if account_id is not None:
|
||||
from app.models.account import Account
|
||||
|
||||
if await OrgScopedQuery(Account, db, org_id=org_id).get(account_id) is None:
|
||||
raise NoParentException(f"Account {account_id} not found in this org")
|
||||
if contact_id is not None:
|
||||
from app.models.contact import Contact
|
||||
|
||||
if await OrgScopedQuery(Contact, db, org_id=org_id).get(contact_id) is None:
|
||||
raise NoParentException(f"Contact {contact_id} not found in this org")
|
||||
if deal_id is not None:
|
||||
from app.models.deal import Deal
|
||||
|
||||
if await OrgScopedQuery(Deal, db, org_id=org_id).get(deal_id) is None:
|
||||
raise NoParentException(f"Deal {deal_id} not found in this org")
|
||||
|
||||
|
||||
async def create_activity(
|
||||
db: AsyncSession,
|
||||
payload: ActivityCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
owner_id: int,
|
||||
) -> Activity:
|
||||
"""Create a new activity. Validates parents (at least one required)."""
|
||||
await _validate_parents(
|
||||
db,
|
||||
org_id=org_id,
|
||||
account_id=payload.account_id,
|
||||
contact_id=payload.contact_id,
|
||||
deal_id=payload.deal_id,
|
||||
)
|
||||
activity = Activity(
|
||||
org_id=org_id,
|
||||
type=payload.type,
|
||||
subject=payload.subject,
|
||||
body=payload.body,
|
||||
due_date=payload.due_date,
|
||||
account_id=payload.account_id,
|
||||
contact_id=payload.contact_id,
|
||||
deal_id=payload.deal_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(activity)
|
||||
await db.commit()
|
||||
await db.refresh(activity)
|
||||
return activity
|
||||
|
||||
|
||||
async def get_activity(db: AsyncSession, activity_id: int, *, org_id: int) -> Activity | None:
|
||||
"""Fetch a single activity by id."""
|
||||
q = OrgScopedQuery(Activity, db, org_id=org_id)
|
||||
return await q.get(activity_id)
|
||||
|
||||
|
||||
async def list_activities(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
type: ActivityType | None = None,
|
||||
owner_id: int | None = None,
|
||||
overdue: bool | None = None,
|
||||
completed: bool | None = None,
|
||||
) -> list[Activity]:
|
||||
"""List activities with optional filters."""
|
||||
scoped = OrgScopedQuery(Activity, db, org_id=org_id)
|
||||
base = await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Activity.id,
|
||||
type=type.value if hasattr(type, "value") else type,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes
|
||||
if overdue is True:
|
||||
base = [
|
||||
a
|
||||
for a in base
|
||||
if a.due_date is not None and a.due_date < now and a.completed_at is None
|
||||
]
|
||||
if completed is True:
|
||||
base = [a for a in base if a.completed_at is not None]
|
||||
elif completed is False:
|
||||
base = [a for a in base if a.completed_at is None]
|
||||
return base
|
||||
|
||||
|
||||
async def update_activity(
|
||||
db: AsyncSession, activity: Activity, payload: ActivityUpdate
|
||||
) -> Activity:
|
||||
"""Apply partial updates to an activity."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# If parents change, re-validate
|
||||
if any(k in data for k in ("account_id", "contact_id", "deal_id")):
|
||||
new_acc = data.get("account_id", activity.account_id)
|
||||
new_con = data.get("contact_id", activity.contact_id)
|
||||
new_deal = data.get("deal_id", activity.deal_id)
|
||||
await _validate_parents(
|
||||
db,
|
||||
org_id=activity.org_id,
|
||||
account_id=new_acc,
|
||||
contact_id=new_con,
|
||||
deal_id=new_deal,
|
||||
)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(activity, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(activity)
|
||||
return activity
|
||||
|
||||
|
||||
async def complete_activity(
|
||||
db: AsyncSession,
|
||||
activity: Activity,
|
||||
payload: ActivityCompleteRequest,
|
||||
) -> Activity:
|
||||
"""Mark activity as completed; set completed_at and optionally outcome."""
|
||||
activity.completed_at = datetime.now(UTC)
|
||||
if payload.outcome is not None:
|
||||
# We store outcome in body (free-form text) for v1
|
||||
existing = activity.body or ""
|
||||
outcome_line = f"\n[Outcome] {payload.outcome}"
|
||||
activity.body = (existing + outcome_line).strip()
|
||||
await db.commit()
|
||||
await db.refresh(activity)
|
||||
return activity
|
||||
|
||||
|
||||
async def soft_delete_activity(db: AsyncSession, activity: Activity) -> Activity:
|
||||
"""Soft-delete an activity."""
|
||||
activity.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(activity)
|
||||
return activity
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ActivityNotFound",
|
||||
"NoParentException",
|
||||
"complete_activity",
|
||||
"create_activity",
|
||||
"get_activity",
|
||||
"list_activities",
|
||||
"soft_delete_activity",
|
||||
"update_activity",
|
||||
]
|
||||
+248
-87
@@ -1,116 +1,277 @@
|
||||
"""Auth service: register, login, token generation."""
|
||||
"""Authentication service — login, logout, password reset, session management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import create_access_token, hash_password, verify_password
|
||||
from app.models.org import Org
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.auth import UserRegisterRequest
|
||||
from app.config import get_settings
|
||||
from app.core.auth import (
|
||||
create_session, get_session_data, hash_password, hash_token,
|
||||
invalidate_session, update_session_tenant, verify_password,
|
||||
)
|
||||
from app.core.audit import log_audit
|
||||
from app.models.auth import PasswordResetToken
|
||||
from app.models.user import User, UserTenant
|
||||
from app.models.tenant import Tenant
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
"""Base for auth service errors with an HTTP-friendly status code."""
|
||||
class AuthService:
|
||||
"""Handles authentication operations."""
|
||||
|
||||
status_code: int = 400
|
||||
async def login(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
email: str,
|
||||
password: str,
|
||||
tenant_slug: str | None = None,
|
||||
) -> tuple[str, str, User, Tenant] | None:
|
||||
"""Authenticate user and create session.
|
||||
Returns (session_id, csrf_token, user, tenant) or None.
|
||||
"""
|
||||
# Find user by email — need to check across tenants or use default tenant
|
||||
q = select(User).where(User.email == email, User.is_active == True)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
|
||||
class EmailAlreadyExists(AuthError):
|
||||
status_code = 409
|
||||
# Get user's default tenant or the one matching slug
|
||||
ut_q = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
if tenant_slug:
|
||||
ut_q = ut_q.join(Tenant, UserTenant.tenant_id == Tenant.id).where(
|
||||
Tenant.slug == tenant_slug
|
||||
)
|
||||
else:
|
||||
ut_q = ut_q.where(UserTenant.is_default == True)
|
||||
ut_result = await db.execute(ut_q)
|
||||
user_tenant = ut_result.scalar_one_or_none()
|
||||
|
||||
# Fallback: just get first tenant membership
|
||||
if user_tenant is None:
|
||||
ut_q2 = select(UserTenant).where(UserTenant.user_id == user.id)
|
||||
ut_result2 = await db.execute(ut_q2)
|
||||
user_tenant = ut_result2.scalar_one_or_none()
|
||||
if user_tenant is None:
|
||||
return None
|
||||
|
||||
class BootstrapAlreadyCompleted(AuthError):
|
||||
status_code = 403
|
||||
tenant_q = select(Tenant).where(Tenant.id == user_tenant.tenant_id)
|
||||
tenant_result = await db.execute(tenant_q)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
if tenant is None:
|
||||
return None
|
||||
|
||||
session_id, csrf_token = await create_session(db, redis, user, tenant.id)
|
||||
|
||||
class InvalidCredentials(AuthError):
|
||||
status_code = 401
|
||||
|
||||
|
||||
async def count_users(db: AsyncSession) -> int:
|
||||
"""Count active (non-soft-deleted) users. Used to gate bootstrap registration."""
|
||||
result = await db.execute(select(User).where(User.deleted_at.is_(None)))
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
async def register_user(db: AsyncSession, payload: UserRegisterRequest) -> tuple[User, str]:
|
||||
"""Bootstrap registration.
|
||||
|
||||
Creates a new Org and the first user (or a new user in the existing org
|
||||
if the org is passed in — Phase 4a only supports bootstrap here).
|
||||
|
||||
Raises:
|
||||
BootstrapAlreadyCompleted: if users already exist (403).
|
||||
EmailAlreadyExists: if the email is already taken (409).
|
||||
|
||||
Returns:
|
||||
(user, jwt_token) tuple.
|
||||
"""
|
||||
existing = await count_users(db)
|
||||
if existing > 0:
|
||||
raise BootstrapAlreadyCompleted(
|
||||
"Bootstrap registration is disabled: users already exist. "
|
||||
"Use POST /api/v1/users (admin) to invite new users."
|
||||
# Log the login in audit trail
|
||||
await log_audit(
|
||||
db, tenant.id, user.id, "login", "user", user.id,
|
||||
changes={"email": email},
|
||||
)
|
||||
|
||||
# Check email uniqueness within the (new) org context
|
||||
org = Org(name=f"{payload.name}'s Org")
|
||||
db.add(org)
|
||||
await db.flush() # assigns org.id
|
||||
return session_id, csrf_token, user, tenant
|
||||
|
||||
user = User(
|
||||
org_id=org.id,
|
||||
email=payload.email.lower(),
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name,
|
||||
# First registered user is implicitly admin for bootstrap convenience.
|
||||
role=UserRole.admin,
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise EmailAlreadyExists(f"A user with email {payload.email!r} already exists.") from e
|
||||
async def logout(self, redis: aioredis.Redis, session_id: str) -> bool:
|
||||
"""Invalidate a session."""
|
||||
await invalidate_session(redis, session_id)
|
||||
return True
|
||||
|
||||
await db.refresh(user)
|
||||
async def get_current_user_info(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
session_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get current user info from session."""
|
||||
session_data = await get_session_data(redis, session_id)
|
||||
if session_data is None:
|
||||
return None
|
||||
|
||||
role_str = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
token = create_access_token(user.id, user.org_id, role_str)
|
||||
return user, token
|
||||
# Fetch tenant name
|
||||
tenant_q = select(Tenant).where(Tenant.id == uuid.UUID(session_data["tenant_id"]))
|
||||
tenant_result = await db.execute(tenant_q)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"user_id": session_data["user_id"],
|
||||
"email": session_data["email"],
|
||||
"name": session_data["name"],
|
||||
"role": session_data["role"],
|
||||
"tenant_id": session_data["tenant_id"],
|
||||
"tenant_name": tenant.name if tenant else None,
|
||||
}
|
||||
|
||||
async def authenticate_user(db: AsyncSession, email: str, password: str) -> tuple[User, str] | None:
|
||||
"""Verify credentials and return (user, token) on success, None on failure.
|
||||
async def switch_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
redis: aioredis.Redis,
|
||||
session_id: str,
|
||||
new_tenant_id: uuid.UUID,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Switch the active tenant for the current session."""
|
||||
session_data = await get_session_data(redis, session_id)
|
||||
if session_data is None:
|
||||
return None
|
||||
|
||||
The endpoint wraps this and returns 401 on None — keeping the
|
||||
service-level function free of HTTPException for testability.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.email == email.lower(),
|
||||
User.deleted_at.is_(None),
|
||||
user_id = uuid.UUID(session_data["user_id"])
|
||||
|
||||
# Verify user is member of target tenant
|
||||
ut_q = select(UserTenant).where(
|
||||
UserTenant.user_id == user_id,
|
||||
UserTenant.tenant_id == new_tenant_id,
|
||||
)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
ut_result = await db.execute(ut_q)
|
||||
if ut_result.scalar_one_or_none() is None:
|
||||
return None
|
||||
|
||||
if user is None:
|
||||
return None
|
||||
updated = await update_session_tenant(redis, session_id, new_tenant_id)
|
||||
if updated is None:
|
||||
return None
|
||||
|
||||
if not verify_password(password, user.password_hash):
|
||||
return None
|
||||
# Fetch tenant name
|
||||
tenant_q = select(Tenant).where(Tenant.id == new_tenant_id)
|
||||
tenant_result = await db.execute(tenant_q)
|
||||
tenant = tenant_result.scalar_one_or_none()
|
||||
|
||||
role_str = user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
token = create_access_token(user.id, user.org_id, role_str)
|
||||
return user, token
|
||||
updated["tenant_name"] = tenant.name if tenant else None
|
||||
return updated
|
||||
|
||||
async def request_password_reset(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
email: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
) -> bool:
|
||||
"""Create a password reset token. Always returns True (no user enumeration)."""
|
||||
q = select(User).where(User.email == email, User.is_active == True)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return True # Don't reveal whether email exists
|
||||
|
||||
# Invalidate previous unused tokens
|
||||
prev_q = select(PasswordResetToken).where(
|
||||
PasswordResetToken.user_id == user.id,
|
||||
PasswordResetToken.used_at.is_(None),
|
||||
)
|
||||
prev_result = await db.execute(prev_q)
|
||||
for prev_token in prev_result.scalars().all():
|
||||
prev_token.used_at = datetime.now(timezone.utc)
|
||||
|
||||
# Create new token
|
||||
import secrets
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
settings = get_settings()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(reset_token)
|
||||
await db.flush()
|
||||
|
||||
# In production: send email via SMTP. For now, log it.
|
||||
# The raw_token would be in the email link.
|
||||
return True
|
||||
|
||||
async def confirm_password_reset(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
token: str,
|
||||
new_password: str,
|
||||
) -> bool:
|
||||
"""Reset password using a valid token. Returns True on success."""
|
||||
token_hash = hash_token(token)
|
||||
q = select(PasswordResetToken).where(
|
||||
PasswordResetToken.token_hash == token_hash,
|
||||
PasswordResetToken.used_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
reset_token = result.scalar_one_or_none()
|
||||
|
||||
if reset_token is None:
|
||||
return False
|
||||
|
||||
if reset_token.expires_at < datetime.now(timezone.utc):
|
||||
return False # Token expired
|
||||
|
||||
# Get user
|
||||
user_q = select(User).where(User.id == reset_token.user_id)
|
||||
user_result = await db.execute(user_q)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return False
|
||||
|
||||
# Update password
|
||||
user.password_hash = hash_password(new_password)
|
||||
reset_token.used_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
|
||||
return True
|
||||
|
||||
async def get_password_reset_token_raw(self, db: AsyncSession, email: str) -> str | None:
|
||||
"""Get the raw (unhashed) reset token for testing purposes.
|
||||
This simulates what would be sent via email.
|
||||
"""
|
||||
# This is a test helper — in production the token goes via email only
|
||||
import secrets
|
||||
q = select(User).where(User.email == email)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
settings = get_settings()
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.password_reset_expiry_hours)
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(reset_token)
|
||||
await db.flush()
|
||||
return raw_token
|
||||
|
||||
async def create_expired_reset_token(self, db: AsyncSession, email: str) -> str | None:
|
||||
"""Create an already-expired reset token for testing."""
|
||||
import secrets
|
||||
q = select(User).where(User.email == email)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = hash_token(raw_token)
|
||||
expires_at = datetime.now(timezone.utc) - timedelta(hours=1) # Already expired
|
||||
|
||||
reset_token = PasswordResetToken(
|
||||
tenant_id=user.tenant_id,
|
||||
user_id=user.id,
|
||||
token_hash=token_hash,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(reset_token)
|
||||
await db.flush()
|
||||
return raw_token
|
||||
|
||||
|
||||
def build_token_response(user: User) -> str:
|
||||
"""Build a fresh access token for a user."""
|
||||
settings = get_settings()
|
||||
_ = settings # touch to ensure config is loaded
|
||||
return create_access_token(user.id, user.org_id, user.role.value)
|
||||
auth_service = AuthService()
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Contact service: create, get, list (with filters), update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.schemas.contact import ContactCreate, ContactUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class ContactNotFound(Exception):
|
||||
"""Raised when a contact lookup fails."""
|
||||
|
||||
|
||||
class InvalidAccount(Exception):
|
||||
"""Raised when account_id points to a non-existent account."""
|
||||
|
||||
|
||||
async def _validate_account(db: AsyncSession, account_id: int, *, org_id: int) -> None:
|
||||
"""Raise if account_id does not exist within the org."""
|
||||
if account_id is None:
|
||||
return
|
||||
q = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
exists = await q.get(account_id)
|
||||
if exists is None:
|
||||
raise InvalidAccount(f"Account {account_id} not found in this org")
|
||||
|
||||
|
||||
async def create_contact(
|
||||
db: AsyncSession,
|
||||
payload: ContactCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
owner_id: int,
|
||||
) -> Contact:
|
||||
"""Create a new contact. Validates account_id if provided."""
|
||||
await _validate_account(db, payload.account_id, org_id=org_id)
|
||||
contact = Contact(
|
||||
org_id=org_id,
|
||||
first_name=payload.first_name,
|
||||
last_name=payload.last_name,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
account_id=payload.account_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
db.add(contact)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise InvalidAccount(str(e)) from e
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def get_contact(db: AsyncSession, contact_id: int, *, org_id: int) -> Contact | None:
|
||||
"""Fetch a single contact by id."""
|
||||
q = OrgScopedQuery(Contact, db, org_id=org_id)
|
||||
return await q.get(contact_id)
|
||||
|
||||
|
||||
async def list_contacts(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
account_id: int | None = None,
|
||||
owner_id: int | None = None,
|
||||
q: str | None = None,
|
||||
) -> list[Contact]:
|
||||
"""List contacts with filters and search."""
|
||||
scoped = OrgScopedQuery(Contact, db, org_id=org_id)
|
||||
base = await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Contact.id,
|
||||
account_id=account_id,
|
||||
owner_id=owner_id,
|
||||
)
|
||||
if q:
|
||||
needle = q.lower()
|
||||
base = [
|
||||
c
|
||||
for c in base
|
||||
if needle in c.first_name.lower()
|
||||
or needle in c.last_name.lower()
|
||||
or (c.email and needle in c.email.lower())
|
||||
]
|
||||
return base
|
||||
|
||||
|
||||
async def update_contact(db: AsyncSession, contact: Contact, payload: ContactUpdate) -> Contact:
|
||||
"""Apply partial updates to a contact."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "account_id" in data and data["account_id"] is not None:
|
||||
await _validate_account(db, data["account_id"], org_id=contact.org_id)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(contact, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
async def soft_delete_contact(db: AsyncSession, contact: Contact) -> Contact:
|
||||
"""Soft-delete a contact."""
|
||||
contact.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(contact)
|
||||
return contact
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ContactNotFound",
|
||||
"InvalidAccount",
|
||||
"create_contact",
|
||||
"get_contact",
|
||||
"list_contacts",
|
||||
"soft_delete_contact",
|
||||
"update_contact",
|
||||
]
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Dashboard service: KPIs and activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.activity import Activity
|
||||
from app.models.deal import Deal, DealStage
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
async def get_kpis(db: AsyncSession, *, org_id: int) -> dict[str, object]:
|
||||
"""Compute headline KPIs for the org's dashboard."""
|
||||
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
open_stages = (DealStage.lead, DealStage.qualified, DealStage.proposal, DealStage.negotiation)
|
||||
|
||||
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
|
||||
open_deals = [d for d in all_deals if d.stage in open_stages]
|
||||
open_deals_count = len(open_deals)
|
||||
|
||||
pipeline_value = float(sum((d.value for d in open_deals), Decimal("0")))
|
||||
|
||||
now = datetime.now(UTC)
|
||||
# SQLite returns naive datetimes; strip tz for comparison
|
||||
month_start = now.replace(tzinfo=None, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
won_this_month = [
|
||||
d for d in all_deals if d.stage == DealStage.won and d.created_at >= month_start
|
||||
]
|
||||
won_count = len(won_this_month)
|
||||
|
||||
won_total = sum(1 for d in all_deals if d.stage == DealStage.won)
|
||||
lost_total = sum(1 for d in all_deals if d.stage == DealStage.lost)
|
||||
if won_total + lost_total == 0:
|
||||
conversion_rate = 0.0
|
||||
else:
|
||||
conversion_rate = round((won_total / (won_total + lost_total)) * 100, 2)
|
||||
|
||||
return {
|
||||
"open_deals_count": open_deals_count,
|
||||
"pipeline_value": pipeline_value,
|
||||
"won_this_month": won_count,
|
||||
"conversion_rate": conversion_rate,
|
||||
}
|
||||
|
||||
|
||||
async def get_activity_feed(db: AsyncSession, *, org_id: int, limit: int = 20) -> list[Activity]:
|
||||
"""Return the most recent activities, ordered by created_at desc."""
|
||||
result = await db.execute(
|
||||
select(Activity)
|
||||
.where(Activity.org_id == org_id, Activity.deleted_at.is_(None))
|
||||
.order_by(Activity.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
__all__ = ["get_activity_feed", "get_kpis"]
|
||||
@@ -1,186 +0,0 @@
|
||||
"""Deal service: create, get, list, update, update_stage, get_pipeline, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.deal import Deal, DealStage
|
||||
from app.models.deal_stage_history import DealStageHistory
|
||||
from app.schemas.deal import DealCreate, DealUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class DealNotFound(Exception):
|
||||
"""Raised when a deal lookup fails."""
|
||||
|
||||
|
||||
class InvalidAccount(Exception):
|
||||
"""Raised when account_id points to a non-existent account."""
|
||||
|
||||
|
||||
def _stage_value(stage: DealStage | str) -> str:
|
||||
"""Normalize stage to its string value."""
|
||||
if hasattr(stage, "value"):
|
||||
return stage.value # type: ignore[union-attr]
|
||||
return str(stage)
|
||||
|
||||
|
||||
async def create_deal(
|
||||
db: AsyncSession,
|
||||
payload: DealCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
owner_id: int,
|
||||
) -> Deal:
|
||||
"""Create a new deal. Validates account_id exists in org."""
|
||||
acc_q = OrgScopedQuery(Account, db, org_id=org_id)
|
||||
if await acc_q.get(payload.account_id) is None:
|
||||
raise InvalidAccount(f"Account {payload.account_id} not found in this org")
|
||||
deal = Deal(
|
||||
org_id=org_id,
|
||||
title=payload.title,
|
||||
value=payload.value,
|
||||
currency=payload.currency,
|
||||
stage=payload.stage,
|
||||
close_date=payload.close_date,
|
||||
account_id=payload.account_id,
|
||||
owner_id=owner_id,
|
||||
won_lost_reason=payload.won_lost_reason,
|
||||
)
|
||||
db.add(deal)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise InvalidAccount(str(e)) from e
|
||||
await db.refresh(deal)
|
||||
# Initial stage history entry (from None → initial stage)
|
||||
history = DealStageHistory(
|
||||
org_id=org_id,
|
||||
deal_id=deal.id,
|
||||
from_stage=None,
|
||||
to_stage=deal.stage,
|
||||
changed_by=owner_id,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
async def get_deal(db: AsyncSession, deal_id: int, *, org_id: int) -> Deal | None:
|
||||
"""Fetch a single deal by id."""
|
||||
q = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
return await q.get(deal_id)
|
||||
|
||||
|
||||
async def list_deals(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
stage: str | None = None,
|
||||
owner_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
) -> list[Deal]:
|
||||
"""List deals with optional filters."""
|
||||
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
return await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Deal.id,
|
||||
stage=stage,
|
||||
owner_id=owner_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
|
||||
|
||||
async def update_deal(db: AsyncSession, deal: Deal, payload: DealUpdate) -> Deal:
|
||||
"""Apply partial updates to a deal. Does NOT change stage (use update_stage)."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Disallow direct stage changes via update endpoint
|
||||
data.pop("stage", None)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(deal, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
async def update_stage(
|
||||
db: AsyncSession,
|
||||
deal: Deal,
|
||||
new_stage: DealStage,
|
||||
*,
|
||||
changed_by: int,
|
||||
reason: str | None = None,
|
||||
) -> Deal:
|
||||
"""Change a deal's stage and append a DealStageHistory record."""
|
||||
from_stage_str = _stage_value(deal.stage)
|
||||
to_stage_str = _stage_value(new_stage)
|
||||
if from_stage_str == to_stage_str:
|
||||
return deal # no-op
|
||||
|
||||
deal.stage = new_stage # type: ignore[assignment]
|
||||
if new_stage in (DealStage.won, DealStage.lost) and reason is not None:
|
||||
deal.won_lost_reason = reason
|
||||
|
||||
history = DealStageHistory(
|
||||
org_id=deal.org_id,
|
||||
deal_id=deal.id,
|
||||
from_stage=from_stage_str,
|
||||
to_stage=to_stage_str,
|
||||
changed_by=changed_by,
|
||||
)
|
||||
db.add(history)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
async def get_pipeline(db: AsyncSession, *, org_id: int) -> list[dict[str, object]]:
|
||||
"""Return deals grouped by stage for the pipeline view."""
|
||||
scoped = OrgScopedQuery(Deal, db, org_id=org_id)
|
||||
all_deals = await scoped.list(skip=0, limit=10_000, order_by=Deal.id)
|
||||
grouped: dict[str, list[Deal]] = defaultdict(list)
|
||||
for d in all_deals:
|
||||
grouped[_stage_value(d.stage)].append(d)
|
||||
return [
|
||||
{
|
||||
"stage": stage.value,
|
||||
"count": len(deals),
|
||||
"total_value": float(sum((d.value for d in deals), Decimal("0"))),
|
||||
"deals": deals,
|
||||
}
|
||||
for stage in DealStage
|
||||
for deals in [grouped.get(stage.value, [])]
|
||||
]
|
||||
|
||||
|
||||
async def soft_delete_deal(db: AsyncSession, deal: Deal) -> Deal:
|
||||
"""Soft-delete a deal."""
|
||||
deal.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(deal)
|
||||
return deal
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DealNotFound",
|
||||
"InvalidAccount",
|
||||
"create_deal",
|
||||
"get_deal",
|
||||
"get_pipeline",
|
||||
"list_deals",
|
||||
"soft_delete_deal",
|
||||
"update_deal",
|
||||
"update_stage",
|
||||
]
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Note service: polymorphic notes attached to accounts/contacts/deals (R-2 validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.note import Note, NoteParentType
|
||||
from app.schemas.note import NoteCreate, NoteUpdate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class NoteNotFound(Exception):
|
||||
"""Raised when a note lookup fails."""
|
||||
|
||||
|
||||
class InvalidParent(Exception):
|
||||
"""Raised when note parent_type + parent_id don't point to an existing entity.
|
||||
|
||||
Mitigates R-2 (polymorphic validation): because parent_id has no DB-level FK,
|
||||
the service layer must verify the parent exists before creating a note.
|
||||
"""
|
||||
|
||||
|
||||
async def _validate_parent(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
parent_type: NoteParentType,
|
||||
parent_id: int,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Verify the (parent_type, parent_id) tuple points to an existing entity in org."""
|
||||
if parent_type == NoteParentType.account:
|
||||
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)")
|
||||
elif parent_type == NoteParentType.contact:
|
||||
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)")
|
||||
elif parent_type == NoteParentType.deal:
|
||||
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)")
|
||||
|
||||
|
||||
async def create_note(
|
||||
db: AsyncSession,
|
||||
payload: NoteCreate,
|
||||
*,
|
||||
org_id: int,
|
||||
author_id: int,
|
||||
) -> Note:
|
||||
"""Create a new note. R-2: validates parent_type + parent_id exist."""
|
||||
parent_type = (
|
||||
payload.parent_type
|
||||
if isinstance(payload.parent_type, NoteParentType)
|
||||
else NoteParentType(payload.parent_type)
|
||||
)
|
||||
await _validate_parent(
|
||||
db,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
note = Note(
|
||||
org_id=org_id,
|
||||
body=payload.body,
|
||||
author_id=author_id,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
)
|
||||
db.add(note)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def get_note(db: AsyncSession, note_id: int, *, org_id: int) -> Note | None:
|
||||
"""Fetch a single note by id."""
|
||||
q = OrgScopedQuery(Note, db, org_id=org_id)
|
||||
return await q.get(note_id)
|
||||
|
||||
|
||||
async def list_notes(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
org_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
parent_type: str | None = None,
|
||||
parent_id: int | None = None,
|
||||
) -> list[Note]:
|
||||
"""List notes with optional parent filters."""
|
||||
scoped = OrgScopedQuery(Note, db, org_id=org_id)
|
||||
return await scoped.list(
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
order_by=Note.id,
|
||||
parent_type=parent_type,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
|
||||
|
||||
async def update_note(db: AsyncSession, note: Note, payload: NoteUpdate) -> Note:
|
||||
"""Apply partial updates to a note. Does NOT change parent (notes are pinned)."""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
data.pop("parent_type", None)
|
||||
data.pop("parent_id", None)
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(note, field, value)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
async def soft_delete_note(db: AsyncSession, note: Note) -> Note:
|
||||
"""Soft-delete a note."""
|
||||
note.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(note)
|
||||
return note
|
||||
|
||||
|
||||
__all__ = [
|
||||
"InvalidParent",
|
||||
"NoteNotFound",
|
||||
"create_note",
|
||||
"get_note",
|
||||
"list_notes",
|
||||
"soft_delete_note",
|
||||
"update_note",
|
||||
]
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Role management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.role import Role
|
||||
|
||||
|
||||
class RoleService:
|
||||
"""Handles role CRUD operations."""
|
||||
|
||||
async def list_roles(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all roles in a tenant."""
|
||||
q = select(Role).where(Role.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
roles = result.scalars().all()
|
||||
return [self._role_to_dict(r) for r in roles]
|
||||
|
||||
async def create_role(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
name: str,
|
||||
permissions: dict[str, Any],
|
||||
field_permissions: dict[str, Any] | None = None,
|
||||
) -> Role:
|
||||
"""Create a new custom role."""
|
||||
role = Role(
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
permissions=permissions,
|
||||
field_permissions=field_permissions or {},
|
||||
)
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
return role
|
||||
|
||||
async def update_role(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
role_id: uuid.UUID,
|
||||
name: str | None = None,
|
||||
permissions: dict[str, Any] | None = None,
|
||||
field_permissions: dict[str, Any] | None = None,
|
||||
) -> Role | None:
|
||||
"""Update a role."""
|
||||
q = select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
role = result.scalar_one_or_none()
|
||||
if role is None:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
role.name = name
|
||||
if permissions is not None:
|
||||
role.permissions = permissions
|
||||
if field_permissions is not None:
|
||||
role.field_permissions = field_permissions
|
||||
|
||||
await db.flush()
|
||||
return role
|
||||
|
||||
async def delete_role(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
role_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Delete a role."""
|
||||
q = select(Role).where(Role.id == role_id, Role.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
role = result.scalar_one_or_none()
|
||||
if role is None:
|
||||
return False
|
||||
|
||||
await db.delete(role)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
def _role_to_dict(self, role: Role) -> dict[str, Any]:
|
||||
"""Convert role to response dict."""
|
||||
return {
|
||||
"id": str(role.id),
|
||||
"name": role.name,
|
||||
"permissions": role.permissions,
|
||||
"field_permissions": role.field_permissions,
|
||||
}
|
||||
|
||||
|
||||
role_service = RoleService()
|
||||
@@ -1,147 +0,0 @@
|
||||
"""Tag service: tags and polymorphic tag_links (R-2 validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.contact import Contact
|
||||
from app.models.deal import Deal
|
||||
from app.models.tag import Tag
|
||||
from app.models.tag_link import TagLink, TagLinkParentType
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate
|
||||
from app.services._base import OrgScopedQuery
|
||||
|
||||
|
||||
class TagNotFound(Exception):
|
||||
"""Raised when a tag lookup fails."""
|
||||
|
||||
|
||||
class InvalidParent(Exception):
|
||||
"""Raised when tag_link parent_type + parent_id don't point to an existing entity."""
|
||||
|
||||
|
||||
class DuplicateTagLink(Exception):
|
||||
"""Raised when a tag is already linked to a parent (unique constraint)."""
|
||||
|
||||
|
||||
async def _validate_parent(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
parent_type: TagLinkParentType,
|
||||
parent_id: int,
|
||||
org_id: int,
|
||||
) -> None:
|
||||
"""Verify (parent_type, parent_id) points to an existing entity in org."""
|
||||
if parent_type == TagLinkParentType.account:
|
||||
if await OrgScopedQuery(Account, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(f"Account {parent_id} not found in this org (R-2 validation)")
|
||||
elif parent_type == TagLinkParentType.contact:
|
||||
if await OrgScopedQuery(Contact, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(f"Contact {parent_id} not found in this org (R-2 validation)")
|
||||
elif parent_type == TagLinkParentType.deal:
|
||||
if await OrgScopedQuery(Deal, db, org_id=org_id).get(parent_id) is None:
|
||||
raise InvalidParent(f"Deal {parent_id} not found in this org (R-2 validation)")
|
||||
|
||||
|
||||
async def create_tag(db: AsyncSession, payload: TagCreate, *, org_id: int) -> Tag:
|
||||
"""Create a new tag in the given org."""
|
||||
tag = Tag(
|
||||
org_id=org_id,
|
||||
name=payload.name,
|
||||
color=payload.color,
|
||||
)
|
||||
db.add(tag)
|
||||
await db.commit()
|
||||
await db.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
async def list_tags(db: AsyncSession, *, org_id: int) -> list[Tag]:
|
||||
"""List all tags in the org."""
|
||||
scoped = OrgScopedQuery(Tag, db, org_id=org_id)
|
||||
return await scoped.list(skip=0, limit=1000, order_by=Tag.id)
|
||||
|
||||
|
||||
async def get_tag(db: AsyncSession, tag_id: int, *, org_id: int) -> Tag | None:
|
||||
"""Fetch a single tag by id."""
|
||||
return await OrgScopedQuery(Tag, db, org_id=org_id).get(tag_id)
|
||||
|
||||
|
||||
async def link_tag(db: AsyncSession, payload: TagLinkCreate, *, org_id: int) -> TagLink:
|
||||
"""Link a tag to an entity. R-2: validates parent exists."""
|
||||
parent_type = (
|
||||
payload.parent_type
|
||||
if isinstance(payload.parent_type, TagLinkParentType)
|
||||
else TagLinkParentType(payload.parent_type)
|
||||
)
|
||||
# Verify tag exists in this org
|
||||
if await OrgScopedQuery(Tag, db, org_id=org_id).get(payload.tag_id) is None:
|
||||
raise TagNotFound(f"Tag {payload.tag_id} not found in this org")
|
||||
# Verify parent exists in this org (R-2)
|
||||
await _validate_parent(
|
||||
db,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=org_id,
|
||||
)
|
||||
link = TagLink(
|
||||
org_id=org_id,
|
||||
tag_id=payload.tag_id,
|
||||
parent_type=parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
)
|
||||
db.add(link)
|
||||
try:
|
||||
await db.commit()
|
||||
except IntegrityError as e:
|
||||
await db.rollback()
|
||||
raise DuplicateTagLink(
|
||||
f"Tag {payload.tag_id} is already linked to {parent_type.value}:{payload.parent_id}"
|
||||
) from e
|
||||
await db.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
async def unlink_tag(
|
||||
db: AsyncSession,
|
||||
*,
|
||||
tag_id: int,
|
||||
parent_type: TagLinkParentType,
|
||||
parent_id: int,
|
||||
org_id: int,
|
||||
) -> bool:
|
||||
"""Unlink a tag from an entity. Returns True if a link was deleted."""
|
||||
from sqlalchemy import select
|
||||
|
||||
parent_type_str = parent_type.value if hasattr(parent_type, "value") else str(parent_type)
|
||||
result = await db.execute(
|
||||
select(TagLink).where(
|
||||
TagLink.org_id == org_id,
|
||||
TagLink.tag_id == tag_id,
|
||||
TagLink.parent_type == parent_type_str,
|
||||
TagLink.parent_id == parent_id,
|
||||
TagLink.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
link = result.scalar_one_or_none()
|
||||
if link is None:
|
||||
return False
|
||||
link.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DuplicateTagLink",
|
||||
"InvalidParent",
|
||||
"TagNotFound",
|
||||
"create_tag",
|
||||
"get_tag",
|
||||
"link_tag",
|
||||
"list_tags",
|
||||
"unlink_tag",
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Tenant management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.tenant import Tenant
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
|
||||
class TenantService:
|
||||
"""Handles tenant CRUD and user-tenant membership."""
|
||||
|
||||
async def list_tenants_for_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all tenants a user belongs to."""
|
||||
q = (
|
||||
select(Tenant, UserTenant.is_default)
|
||||
.join(UserTenant, UserTenant.tenant_id == Tenant.id)
|
||||
.where(UserTenant.user_id == user_id)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
rows = result.all()
|
||||
return [
|
||||
{
|
||||
"id": str(t.id),
|
||||
"name": t.name,
|
||||
"slug": t.slug,
|
||||
"is_default": is_default,
|
||||
}
|
||||
for t, is_default in rows
|
||||
]
|
||||
|
||||
async def create_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
slug: str,
|
||||
) -> Tenant:
|
||||
"""Create a new tenant."""
|
||||
tenant = Tenant(name=name, slug=slug)
|
||||
db.add(tenant)
|
||||
await db.flush()
|
||||
return tenant
|
||||
|
||||
async def get_tenant(self, db: AsyncSession, tenant_id: uuid.UUID) -> Tenant | None:
|
||||
"""Get a tenant by ID."""
|
||||
q = select(Tenant).where(Tenant.id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_tenant_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List users in a tenant."""
|
||||
q = select(User).where(User.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"email": u.email,
|
||||
"name": u.name,
|
||||
"role": u.role,
|
||||
"is_active": u.is_active,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
async def assign_user_to_tenant(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> UserTenant:
|
||||
"""Assign a user to a tenant."""
|
||||
ut = UserTenant(
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
is_default=False,
|
||||
)
|
||||
db.add(ut)
|
||||
await db.flush()
|
||||
return ut
|
||||
|
||||
|
||||
tenant_service = TenantService()
|
||||
+143
-99
@@ -1,109 +1,153 @@
|
||||
"""User service: read, update, soft-delete, list."""
|
||||
"""User management service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import select, func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.security import hash_password
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.user import UserCreateRequest, UserUpdate
|
||||
from app.core.auth import hash_password
|
||||
from app.models.user import User, UserTenant
|
||||
|
||||
|
||||
class UserNotFound(Exception):
|
||||
"""Raised when a user lookup fails."""
|
||||
class UserService:
|
||||
"""Handles user CRUD operations."""
|
||||
|
||||
async def list_users(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
search: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List users in a tenant with pagination and search."""
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
q = select(User).where(User.tenant_id == tenant_id)
|
||||
count_q = select(func.count()).select_from(User).where(User.tenant_id == tenant_id)
|
||||
|
||||
if search:
|
||||
search_filter = or_(
|
||||
User.name.ilike(f"%{search}%"),
|
||||
User.email.ilike(f"%{search}%"),
|
||||
)
|
||||
q = q.where(search_filter)
|
||||
count_q = count_q.where(search_filter)
|
||||
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
q = q.offset(offset).limit(page_size).order_by(User.created_at.desc())
|
||||
result = await db.execute(q)
|
||||
users = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [self._user_to_dict(u) for u in users],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> User | None:
|
||||
"""Get a single user by ID within tenant scope."""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
email: str,
|
||||
name: str,
|
||||
password: str,
|
||||
role: str = "viewer",
|
||||
is_active: bool = True,
|
||||
) -> User:
|
||||
"""Create a new user in a tenant."""
|
||||
user = User(
|
||||
tenant_id=tenant_id,
|
||||
email=email,
|
||||
name=name,
|
||||
password_hash=hash_password(password),
|
||||
role=role,
|
||||
is_active=is_active,
|
||||
preferences={},
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
# Add user-tenant membership
|
||||
ut = UserTenant(
|
||||
user_id=user.id,
|
||||
tenant_id=tenant_id,
|
||||
is_default=True,
|
||||
)
|
||||
db.add(ut)
|
||||
await db.flush()
|
||||
|
||||
return user
|
||||
|
||||
async def update_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
name: str | None = None,
|
||||
role: str | None = None,
|
||||
is_active: bool | None = None,
|
||||
) -> User | None:
|
||||
"""Update a user."""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
if name is not None:
|
||||
user.name = name
|
||||
if role is not None:
|
||||
user.role = role
|
||||
if is_active is not None:
|
||||
user.is_active = is_active
|
||||
|
||||
await db.flush()
|
||||
return user
|
||||
|
||||
async def delete_user(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> bool:
|
||||
"""Delete a user from a tenant."""
|
||||
q = select(User).where(User.id == user_id, User.tenant_id == tenant_id)
|
||||
result = await db.execute(q)
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
return False
|
||||
|
||||
await db.delete(user)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
def _user_to_dict(self, user: User) -> dict[str, Any]:
|
||||
"""Convert user to response dict."""
|
||||
return {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"name": user.name,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
"tenant_id": str(user.tenant_id),
|
||||
}
|
||||
|
||||
|
||||
class EmailAlreadyTaken(Exception):
|
||||
"""Raised when attempting to create/update a user with an existing email."""
|
||||
|
||||
|
||||
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
|
||||
"""Fetch a user by ID (active only, soft-deleted excluded)."""
|
||||
result = await db.execute(select(User).where(User.id == user_id, User.deleted_at.is_(None)))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_email(db: AsyncSession, email: str, org_id: int | None = None) -> User | None:
|
||||
"""Fetch a user by email, optionally scoped to an org."""
|
||||
stmt = select(User).where(
|
||||
User.email == email.lower(),
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
if org_id is not None:
|
||||
stmt = stmt.where(User.org_id == org_id)
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def update_user_profile(
|
||||
db: AsyncSession, user: User, payload: UserUpdate, *, is_admin: bool = False
|
||||
) -> User:
|
||||
"""Apply partial updates to a user.
|
||||
|
||||
Non-admin callers cannot change the role. All other fields are optional.
|
||||
"""
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
|
||||
if "role" in data and not is_admin:
|
||||
# Silently drop role change for non-admin callers
|
||||
data.pop("role")
|
||||
|
||||
for field, value in data.items():
|
||||
if value is not None:
|
||||
setattr(user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def soft_delete_user(db: AsyncSession, user: User) -> User:
|
||||
"""Soft-delete a user by setting deleted_at to now."""
|
||||
user.deleted_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def create_user_as_admin(db: AsyncSession, payload: UserCreateRequest, org_id: int) -> User:
|
||||
"""Create a new user in the given org (admin-only flow)."""
|
||||
existing = await get_user_by_email(db, payload.email, org_id=org_id)
|
||||
if existing is not None:
|
||||
raise EmailAlreadyTaken(f"A user with email {payload.email!r} already exists in this org.")
|
||||
|
||||
user = User(
|
||||
org_id=org_id,
|
||||
email=payload.email.lower(),
|
||||
password_hash=hash_password(payload.password),
|
||||
name=payload.name,
|
||||
role=payload.role if payload.role else UserRole.sales_rep,
|
||||
)
|
||||
db.add(user)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def list_users(db: AsyncSession, org_id: int, skip: int = 0, limit: int = 50) -> list[User]:
|
||||
"""List active users in an org, paginated."""
|
||||
result = await db.execute(
|
||||
select(User)
|
||||
.where(User.org_id == org_id, User.deleted_at.is_(None))
|
||||
.order_by(User.id)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def count_users_in_org(db: AsyncSession, org_id: int) -> int:
|
||||
"""Count active users in an org."""
|
||||
result = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(User.org_id == org_id, User.deleted_at.is_(None))
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
user_service = UserService()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Utilities package."""
|
||||
@@ -1 +0,0 @@
|
||||
"""Static frontend assets (Phase 4c fills this)."""
|
||||
@@ -1,29 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>404 – Seite nicht gefunden</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen flex items-center justify-center bg-slate-50 p-4">
|
||||
|
||||
<div class="crm-card text-center max-w-md" x-data="{}">
|
||||
<h1 class="text-6xl font-bold text-slate-900 mb-2">404</h1>
|
||||
<p class="text-xl text-slate-700 mb-2">Seite nicht gefunden</p>
|
||||
<p class="text-sm text-slate-500 mb-6">
|
||||
Die angeforderte URL existiert nicht oder du bist nicht berechtigt.
|
||||
</p>
|
||||
<a href="/dashboard.html" class="btn-primary inline-block mb-2">Zum Dashboard</a>
|
||||
<a href="/app.html" class="btn-secondary inline-block">Oder zur App-Startseite</a>
|
||||
|
||||
<p class="text-xs text-slate-400 mt-6">
|
||||
Falls du eine bestimmte Page suchst: Accounts, Contacts, Pipeline oder Activities.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,350 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CRM – Account Detail</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
|
||||
<script type="module" src="/js/store.js"></script>
|
||||
<script type="module" src="/js/components/notifications.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50">
|
||||
|
||||
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<div class="crm-toast" :class="'toast-' + n.type"
|
||||
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||
|
||||
<aside class="crm-sidebar">
|
||||
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||
</div>
|
||||
<nav class="mt-2">
|
||||
<a href="/app.html">Dashboard</a>
|
||||
<a href="/accounts.html" class="active">Accounts</a>
|
||||
<a href="/contacts.html">Contacts</a>
|
||||
<a href="/pipeline.html">Pipeline</a>
|
||||
<a href="/activities.html">Activities</a>
|
||||
<a href="/settings-profile.html">Profil</a>
|
||||
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-800">Account-Detail</h1>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-slate-600">
|
||||
<strong class="text-slate-900"
|
||||
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||
</span>
|
||||
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 p-6" x-data="accountDetail()" x-init="init()">
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="crm-card text-center py-12">
|
||||
<div class="crm-spinner mx-auto"></div>
|
||||
<p class="text-slate-500 mt-2">Lade Account …</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && account">
|
||||
<!-- Header -->
|
||||
<div class="crm-card mb-4">
|
||||
<div class="flex items-start justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900" x-text="account?.name"></h2>
|
||||
<p class="text-slate-500 text-sm mt-1">
|
||||
<span class="crm-badge badge-blue" x-text="account?.industry || '–'"></span>
|
||||
<span class="crm-badge badge-gray ml-1" x-text="account?.size || '–'"></span>
|
||||
<span class="ml-2">Owner #<span x-text="account?.owner_id"></span></span>
|
||||
</p>
|
||||
<p class="text-blue-600 underline mt-2" x-show="account?.website">
|
||||
<a :href="account?.website" target="_blank" x-text="account?.website"></a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="openEdit()" class="btn-secondary">Bearbeiten</button>
|
||||
<button @click="confirmDelete()" class="btn-danger">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-slate-200 mb-4" role="tablist">
|
||||
<button @click="tab='info'" :class="tabBtn('info')">Info</button>
|
||||
<button @click="loadContacts()" :class="tabBtn('contacts')">Contacts</button>
|
||||
<button @click="loadDeals()" :class="tabBtn('deals')">Deals</button>
|
||||
<button @click="loadActivities()" :class="tabBtn('activities')">Activities</button>
|
||||
<button @click="loadNotes()" :class="tabBtn('notes')">Notes</button>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div x-show="tab === 'info'" class="crm-card">
|
||||
<dl class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><dt class="text-slate-500">Name</dt><dd class="font-medium" x-text="account?.name"></dd></div>
|
||||
<div><dt class="text-slate-500">Branche</dt><dd x-text="account?.industry || '–'"></dd></div>
|
||||
<div><dt class="text-slate-500">Größe</dt><dd x-text="account?.size || '–'"></dd></div>
|
||||
<div><dt class="text-slate-500">Website</dt><dd x-text="account?.website || '–'"></dd></div>
|
||||
<div><dt class="text-slate-500">Org-ID</dt><dd x-text="account?.org_id"></dd></div>
|
||||
<div><dt class="text-slate-500">Owner</dt><dd x-text="account?.owner_id"></dd></div>
|
||||
<div><dt class="text-slate-500">Erstellt</dt><dd x-text="formatDateTime(account?.created_at)"></dd></div>
|
||||
<div><dt class="text-slate-500">Aktualisiert</dt><dd x-text="formatDateTime(account?.updated_at)"></dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div x-show="tab === 'contacts'" class="crm-card">
|
||||
<div x-show="loadingSub" class="text-center py-4">
|
||||
<div class="crm-spinner mx-auto"></div>
|
||||
</div>
|
||||
<table class="crm-table" x-show="!loadingSub">
|
||||
<thead><tr><th>Name</th><th>Email</th><th>Phone</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="c in subLists.contacts" :key="c.id">
|
||||
<tr class="cursor-pointer"
|
||||
@click="window.location.href = '/contacts-detail.html?id=' + c.id">
|
||||
<td x-text="(c.first_name || '') + ' ' + (c.last_name || '')"></td>
|
||||
<td x-text="c.email || '–'"></td>
|
||||
<td x-text="c.phone || '–'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!loadingSub && subLists.contacts.length === 0">
|
||||
<td colspan="3" class="text-center text-slate-500 py-3">Keine Contacts.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Deals -->
|
||||
<div x-show="tab === 'deals'" class="crm-card">
|
||||
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||
<table class="crm-table" x-show="!loadingSub">
|
||||
<thead><tr><th>Title</th><th>Stage</th><th>Value</th><th>Close</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="d in subLists.deals" :key="d.id">
|
||||
<tr>
|
||||
<td x-text="d.title"></td>
|
||||
<td><span class="crm-badge badge-blue" x-text="d.stage"></span></td>
|
||||
<td x-text="formatCurrency(d.value)"></td>
|
||||
<td x-text="d.close_date || '–'"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!loadingSub && subLists.deals.length === 0">
|
||||
<td colspan="4" class="text-center text-slate-500 py-3">Keine Deals.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Activities -->
|
||||
<div x-show="tab === 'activities'" class="crm-card">
|
||||
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||
<ul class="divide-y divide-slate-100" x-show="!loadingSub">
|
||||
<template x-for="a in subLists.activities" :key="a.id">
|
||||
<li class="py-2 flex items-start gap-3">
|
||||
<span class="crm-badge badge-blue" x-text="a.type"></span>
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium" x-text="a.subject"></p>
|
||||
<p class="text-xs text-slate-500" x-text="a.body || ''"></p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="!loadingSub && subLists.activities.length === 0"
|
||||
class="text-center text-slate-500 py-3 text-sm">Keine Activities.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div x-show="tab === 'notes'" class="crm-card">
|
||||
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
|
||||
<ul class="space-y-2" x-show="!loadingSub">
|
||||
<template x-for="n in subLists.notes" :key="n.id">
|
||||
<li class="border-l-4 border-blue-500 pl-3 py-1">
|
||||
<p class="text-sm" x-text="n.body"></p>
|
||||
<p class="text-xs text-slate-400" x-text="formatDateTime(n.created_at)"></p>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="!loadingSub && subLists.notes.length === 0"
|
||||
class="text-center text-slate-500 py-3 text-sm">Keine Notes.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit modal -->
|
||||
<div x-show="showEdit" x-cloak class="crm-modal-backdrop" @click.self="closeEdit()">
|
||||
<div class="crm-modal p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Account bearbeiten</h2>
|
||||
<form @submit.prevent="submitEdit()">
|
||||
<div class="mb-3">
|
||||
<label class="crm-label">Name *</label>
|
||||
<input type="text" required x-model="editForm.name" class="crm-input" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="crm-label">Website</label>
|
||||
<input type="text" x-model="editForm.website" class="crm-input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label class="crm-label">Branche</label>
|
||||
<input type="text" x-model="editForm.industry" class="crm-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Größe</label>
|
||||
<input type="text" x-model="editForm.size" class="crm-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="editError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||
x-text="editError"></div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="closeEdit()" class="btn-secondary">Abbrechen</button>
|
||||
<button type="submit" :disabled="saving" class="btn-primary"
|
||||
x-text="saving ? 'Speichere …' : 'Speichern'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function accountDetail() {
|
||||
return {
|
||||
id: null,
|
||||
account: null,
|
||||
loading: true,
|
||||
tab: 'info',
|
||||
subLists: { contacts: [], deals: [], activities: [], notes: [] },
|
||||
loadingSub: false,
|
||||
showEdit: false,
|
||||
editForm: { name: '', website: '', industry: '', size: '' },
|
||||
editError: '',
|
||||
saving: false,
|
||||
|
||||
async init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
this.id = params.get('id');
|
||||
if (!this.id) { window.location.href = '/accounts.html'; return; }
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async load() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
this.account = await api.get(`/accounts/${this.id}`);
|
||||
this.editForm = {
|
||||
name: this.account.name || '',
|
||||
website: this.account.website || '',
|
||||
industry: this.account.industry || '',
|
||||
size: this.account.size || '',
|
||||
};
|
||||
} catch (err) {
|
||||
Alpine.store('notifications').error(err.message || 'Fehler beim Laden.');
|
||||
} finally { this.loading = false; }
|
||||
},
|
||||
|
||||
async loadContacts() {
|
||||
this.tab = 'contacts';
|
||||
this.loadingSub = true;
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
const r = await api.get(`/contacts/?account_id=${this.id}&limit=200`);
|
||||
this.subLists.contacts = Array.isArray(r) ? r : (r.items || []);
|
||||
} finally { this.loadingSub = false; }
|
||||
},
|
||||
async loadDeals() {
|
||||
this.tab = 'deals';
|
||||
this.loadingSub = true;
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
const r = await api.get(`/deals/?account_id=${this.id}&limit=200`);
|
||||
// Backend may not have account_id filter; client-filter as fallback
|
||||
const list = Array.isArray(r) ? r : (r.items || []);
|
||||
this.subLists.deals = list.filter(d => String(d.account_id) === String(this.id));
|
||||
} finally { this.loadingSub = false; }
|
||||
},
|
||||
async loadActivities() {
|
||||
this.tab = 'activities';
|
||||
this.loadingSub = true;
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
const r = await api.get(`/activities/?account_id=${this.id}&limit=200`);
|
||||
this.subLists.activities = Array.isArray(r) ? r : (r.items || []);
|
||||
} finally { this.loadingSub = false; }
|
||||
},
|
||||
async loadNotes() {
|
||||
this.tab = 'notes';
|
||||
this.loadingSub = true;
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
const r = await api.get(`/notes/?parent_type=account&parent_id=${this.id}`);
|
||||
this.subLists.notes = Array.isArray(r) ? r : (r.items || []);
|
||||
} catch (err) {
|
||||
// /notes may not be paginated; show empty on error
|
||||
this.subLists.notes = [];
|
||||
} finally { this.loadingSub = false; }
|
||||
},
|
||||
|
||||
tabBtn(t) {
|
||||
return this.tab === t
|
||||
? 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-blue-600 text-blue-600'
|
||||
: 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-transparent text-slate-500 hover:text-slate-700';
|
||||
},
|
||||
|
||||
openEdit() { this.showEdit = true; this.editError = ''; },
|
||||
closeEdit() { this.showEdit = false; },
|
||||
async submitEdit() {
|
||||
this.saving = true;
|
||||
this.editError = '';
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
const payload = { name: this.editForm.name };
|
||||
if (this.editForm.website) payload.website = this.editForm.website;
|
||||
if (this.editForm.industry) payload.industry = this.editForm.industry;
|
||||
if (this.editForm.size) payload.size = this.editForm.size;
|
||||
await api.patch(`/accounts/${this.id}`, payload);
|
||||
Alpine.store('notifications').success('Account aktualisiert.');
|
||||
this.showEdit = false;
|
||||
await this.load();
|
||||
} catch (err) {
|
||||
this.editError = err.message || 'Fehler beim Speichern.';
|
||||
} finally { this.saving = false; }
|
||||
},
|
||||
|
||||
async confirmDelete() {
|
||||
if (!confirm('Account wirklich löschen (Soft-Delete)?')) return;
|
||||
try {
|
||||
const { api } = await import('/js/api.js');
|
||||
await api.del(`/accounts/${this.id}`);
|
||||
Alpine.store('notifications').success('Account gelöscht.');
|
||||
window.location.href = '/accounts.html';
|
||||
} catch (err) {
|
||||
Alpine.store('notifications').error(err.message || 'Löschen fehlgeschlagen.');
|
||||
}
|
||||
},
|
||||
|
||||
formatCurrency(v) {
|
||||
return Number(v || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||
},
|
||||
formatDateTime(iso) {
|
||||
if (!iso) return '–';
|
||||
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,189 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CRM – Accounts</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
|
||||
<script type="module" src="/js/store.js"></script>
|
||||
<script type="module" src="/js/components/notifications.js"></script>
|
||||
<script type="module" src="/components/account-list.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50">
|
||||
|
||||
<!-- Toast container -->
|
||||
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<div class="crm-toast" :class="'toast-' + n.type"
|
||||
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Layout shell -->
|
||||
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||
|
||||
<aside class="crm-sidebar">
|
||||
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||
</div>
|
||||
<nav class="mt-2">
|
||||
<a href="/app.html">Dashboard</a>
|
||||
<a href="/accounts.html" class="active">Accounts</a>
|
||||
<a href="/contacts.html">Contacts</a>
|
||||
<a href="/pipeline.html">Pipeline</a>
|
||||
<a href="/activities.html">Activities</a>
|
||||
<a href="/settings-profile.html">Profil</a>
|
||||
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-800">Accounts</h1>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-slate-600">
|
||||
<strong class="text-slate-900"
|
||||
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||
</span>
|
||||
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 p-6" x-data="accountList()" x-init="init()">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
|
||||
<div class="flex-1 min-w-[12rem]">
|
||||
<label class="crm-label" for="q">Suche (Name)</label>
|
||||
<input id="q" type="text" x-model="q" @keyup.enter="search()"
|
||||
class="crm-input" placeholder="z.B. Acme Corp" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label" for="industry">Branche</label>
|
||||
<select id="industry" x-model="industry" class="crm-input">
|
||||
<option value="">Alle</option>
|
||||
<template x-for="i in industries" :key="i">
|
||||
<option :value="i" x-text="i"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label" for="size">Größe</label>
|
||||
<select id="size" x-model="size" class="crm-input">
|
||||
<option value="">Alle</option>
|
||||
<template x-for="s in sizes" :key="s">
|
||||
<option :value="s" x-text="s"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="search()" class="btn-primary">Suchen</button>
|
||||
<button @click="openCreate()" class="btn-primary ml-auto">+ New Account</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="crm-card text-center py-8">
|
||||
<div class="crm-spinner mx-auto"></div>
|
||||
<p class="text-slate-500 mt-2 text-sm">Lade Accounts …</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div x-show="!loading" class="overflow-x-auto">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Branche</th>
|
||||
<th>Größe</th>
|
||||
<th>Website</th>
|
||||
<th>Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="a in accounts" :key="a.id">
|
||||
<tr class="cursor-pointer"
|
||||
@click="window.location.href = '/accounts-detail.html?id=' + a.id">
|
||||
<td class="font-medium" x-text="a.name"></td>
|
||||
<td><span class="crm-badge badge-blue" x-text="a.industry || '–'"></span></td>
|
||||
<td x-text="a.size || '–'"></td>
|
||||
<td class="text-blue-600 underline" x-text="a.website || '–'"></td>
|
||||
<td x-text="a.owner_id"></td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!loading && accounts.length === 0">
|
||||
<td colspan="5" class="text-center text-slate-500 py-6">
|
||||
Keine Accounts gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
|
||||
x-show="!loading">
|
||||
<span x-text="pageInfo"></span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
|
||||
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div x-show="showCreate" x-cloak class="crm-modal-backdrop"
|
||||
@click.self="closeCreate()">
|
||||
<div class="crm-modal p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Neuer Account</h2>
|
||||
<form @submit.prevent="submitCreate()">
|
||||
<div class="mb-3">
|
||||
<label class="crm-label">Name *</label>
|
||||
<input type="text" required minlength="1" maxlength="255"
|
||||
x-model="createForm.name" class="crm-input" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="crm-label">Website</label>
|
||||
<input type="text" maxlength="512"
|
||||
x-model="createForm.website" class="crm-input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label class="crm-label">Branche</label>
|
||||
<select x-model="createForm.industry" class="crm-input">
|
||||
<option value="">(keine)</option>
|
||||
<template x-for="i in industries" :key="i">
|
||||
<option :value="i" x-text="i"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Größe</label>
|
||||
<select x-model="createForm.size" class="crm-input">
|
||||
<option value="">(keine)</option>
|
||||
<template x-for="s in sizes" :key="s">
|
||||
<option :value="s" x-text="s"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||
x-text="createError"></div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
|
||||
<button type="submit" :disabled="creating" class="btn-primary">
|
||||
<span x-text="creating ? 'Speichere …' : 'Anlegen'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,220 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CRM – Activities</title>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
|
||||
<script type="module" src="/js/store.js"></script>
|
||||
<script type="module" src="/js/components/notifications.js"></script>
|
||||
<script type="module" src="/components/activity-list.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50">
|
||||
|
||||
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<div class="crm-toast" :class="'toast-' + n.type"
|
||||
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||
|
||||
<aside class="crm-sidebar">
|
||||
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||
</div>
|
||||
<nav class="mt-2">
|
||||
<a href="/app.html">Dashboard</a>
|
||||
<a href="/accounts.html">Accounts</a>
|
||||
<a href="/contacts.html">Contacts</a>
|
||||
<a href="/pipeline.html">Pipeline</a>
|
||||
<a href="/activities.html" class="active">Activities</a>
|
||||
<a href="/settings-profile.html">Profil</a>
|
||||
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-800">Activities</h1>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-slate-600">
|
||||
<strong class="text-slate-900"
|
||||
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||
</span>
|
||||
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 p-6" x-data="activityList()" x-init="init()">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="crm-label">Typ</label>
|
||||
<select x-model="type" class="crm-input">
|
||||
<option value="">Alle</option>
|
||||
<template x-for="t in types" :key="t">
|
||||
<option :value="t" x-text="t"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Overdue</label>
|
||||
<select x-model="overdue" class="crm-input">
|
||||
<option value="">Alle</option>
|
||||
<option value="true">Nur Overdue</option>
|
||||
<option value="false">Nicht Overdue</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Status</label>
|
||||
<select x-model="completed" class="crm-input">
|
||||
<option value="">Alle</option>
|
||||
<option value="false">Offen</option>
|
||||
<option value="true">Erledigt</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="applyFilters()" class="btn-primary">Filtern</button>
|
||||
<button @click="openCreate()" class="btn-primary ml-auto">+ New Activity</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="crm-card text-center py-8">
|
||||
<div class="crm-spinner mx-auto"></div>
|
||||
<p class="text-slate-500 mt-2 text-sm">Lade Activities …</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div x-show="!loading" class="overflow-x-auto">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Typ</th>
|
||||
<th>Subject</th>
|
||||
<th>Fällig</th>
|
||||
<th>Owner</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="a in activities" :key="a.id">
|
||||
<tr class="cursor-pointer"
|
||||
:class="{ 'row-overdue': isOverdue(a) }"
|
||||
@click="openDetail(a)">
|
||||
<td><span class="crm-badge" :class="typeBadge(a.type)" x-text="a.type"></span></td>
|
||||
<td class="font-medium" x-text="a.subject"></td>
|
||||
<td x-text="formatDateTime(a.due_date)"></td>
|
||||
<td x-text="a.owner_id"></td>
|
||||
<td>
|
||||
<span x-show="isCompleted(a)" class="crm-badge badge-green">Erledigt</span>
|
||||
<span x-show="!isCompleted(a) && isOverdue(a)" class="crm-badge badge-red">Overdue</span>
|
||||
<span x-show="!isCompleted(a) && !isOverdue(a)" class="crm-badge badge-yellow">Offen</span>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr x-show="!loading && activities.length === 0">
|
||||
<td colspan="5" class="text-center text-slate-500 py-6">
|
||||
Keine Activities gefunden.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
|
||||
x-show="!loading">
|
||||
<span x-text="pageInfo"></span>
|
||||
<div class="flex gap-2">
|
||||
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
|
||||
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create modal -->
|
||||
<div x-show="showCreate" x-cloak class="crm-modal-backdrop" @click.self="closeCreate()">
|
||||
<div class="crm-modal p-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Neue Activity</h2>
|
||||
<form @submit.prevent="submitCreate()">
|
||||
<div class="grid grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label class="crm-label">Typ *</label>
|
||||
<select x-model="createForm.type" class="crm-input" required>
|
||||
<template x-for="t in types" :key="t">
|
||||
<option :value="t" x-text="t"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Fällig am</label>
|
||||
<input type="datetime-local"
|
||||
x-model="createForm.due_date" class="crm-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="crm-label">Subject *</label>
|
||||
<input type="text" required maxlength="255"
|
||||
x-model="createForm.subject" class="crm-input" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="crm-label">Body</label>
|
||||
<textarea x-model="createForm.body" class="crm-input" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mb-3">
|
||||
Polymorph-Parent: mindestens eine ID angeben.
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 mb-3">
|
||||
<div>
|
||||
<label class="crm-label">Account-ID</label>
|
||||
<input type="number" min="1" x-model="createForm.account_id" class="crm-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Contact-ID</label>
|
||||
<input type="number" min="1" x-model="createForm.contact_id" class="crm-input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="crm-label">Deal-ID</label>
|
||||
<input type="number" min="1" x-model="createForm.deal_id" class="crm-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
|
||||
x-text="createError"></div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
|
||||
<button type="submit" :disabled="creating" class="btn-primary"
|
||||
x-text="creating ? 'Speichere …' : 'Anlegen'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div x-show="showDetail" x-cloak class="crm-modal-backdrop" @click.self="closeDetail()">
|
||||
<div class="crm-modal p-6" x-show="selected">
|
||||
<h2 class="text-lg font-semibold mb-2" x-text="selected?.subject"></h2>
|
||||
<p class="text-xs text-slate-500 mb-3">
|
||||
<span class="crm-badge" :class="typeBadge(selected?.type)" x-text="selected?.type"></span>
|
||||
<span class="ml-2">Fällig: <span x-text="formatDateTime(selected?.due_date)"></span></span>
|
||||
</p>
|
||||
<p class="text-sm text-slate-700 mb-4" x-text="selected?.body || '(kein Body)'"></p>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" @click="closeDetail()" class="btn-secondary">Schließen</button>
|
||||
<button x-show="!selected?.completed_at" @click="completeSelected()"
|
||||
:disabled="completing" class="btn-primary"
|
||||
x-text="completing ? 'Speichere …' : 'Als erledigt markieren'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,145 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CRM – Dashboard</title>
|
||||
|
||||
<!-- Tailwind CSS via CDN (Dev) -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="stylesheet" href="/css/app.css" />
|
||||
|
||||
<!-- Alpine.js -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- App stores + dashboard component -->
|
||||
<script type="module" src="/js/store.js"></script>
|
||||
<script type="module" src="/js/components/notifications.js"></script>
|
||||
<script type="module" src="/components/app-shell.js"></script>
|
||||
<script type="module" src="/components/dashboard-kpis.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-50">
|
||||
|
||||
<!-- === TOAST CONTAINER === -->
|
||||
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
|
||||
<template x-for="n in items" :key="n.id">
|
||||
<div class="crm-toast" :class="'toast-' + n.type"
|
||||
@click="dismiss(n.id)" x-text="n.message"></div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- === LAYOUT SHELL === -->
|
||||
<!-- Auth-Gate + appShell: store.js's init() redirects to /index.html when no JWT. -->
|
||||
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
|
||||
<div x-data="appShell('dashboard')" x-cloak class="flex min-h-screen flex-1">
|
||||
|
||||
<!-- === SIDEBAR === -->
|
||||
<aside class="crm-sidebar">
|
||||
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
|
||||
<a href="/app.html" class="!text-white !border-0">CRM</a>
|
||||
</div>
|
||||
<nav class="mt-2">
|
||||
<a href="/app.html" :class="{ active: active === 'dashboard' }">Dashboard</a>
|
||||
<a href="/accounts.html" :class="{ active: active === 'accounts' }">Accounts</a>
|
||||
<a href="/contacts.html" :class="{ active: active === 'contacts' }">Contacts</a>
|
||||
<a href="/pipeline.html" :class="{ active: active === 'pipeline' }">Pipeline</a>
|
||||
<a href="/activities.html" :class="{ active: active === 'activities' }">Activities</a>
|
||||
<a href="/settings-profile.html":class="{ active: active === 'settings-profile' }">Profil</a>
|
||||
<a href="/settings-users.html" :class="{ active: active === 'settings-users' }"
|
||||
x-show="$store.auth.isAdmin">Users (Admin)</a>
|
||||
<a href="/settings-org.html" :class="{ active: active === 'settings-org' }"
|
||||
x-show="$store.auth.isAdmin">Org (Admin)</a>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- === MAIN AREA === -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
|
||||
<!-- Top navigation -->
|
||||
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-slate-800" x-text="title"></h1>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-slate-600">
|
||||
Angemeldet als
|
||||
<strong class="text-slate-900"
|
||||
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
|
||||
</span>
|
||||
<button class="btn-secondary" @click="logout()">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- === DASHBOARD CONTENT (default landing) === -->
|
||||
<main class="flex-1 p-6" x-data="dashboardKpis()" x-init="init()">
|
||||
|
||||
<!-- Loading -->
|
||||
<div x-show="loading" class="crm-card text-center py-8">
|
||||
<div class="crm-spinner mx-auto"></div>
|
||||
<p class="text-slate-500 mt-2 text-sm">Lade Dashboard …</p>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div x-show="error" class="crm-card text-red-700 bg-red-50">
|
||||
<span x-text="error"></span>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && !error">
|
||||
|
||||
<!-- KPI Grid -->
|
||||
<h2 class="text-lg font-semibold mb-3">Kennzahlen</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div class="crm-card">
|
||||
<p class="text-xs text-slate-500">Open Deals</p>
|
||||
<p class="text-2xl font-bold text-slate-900" x-text="kpis.open_deals ?? '–'"></p>
|
||||
</div>
|
||||
<div class="crm-card">
|
||||
<p class="text-xs text-slate-500">Pipeline Value</p>
|
||||
<p class="text-2xl font-bold text-slate-900"
|
||||
x-text="formatCurrency(kpis.pipeline_value)"></p>
|
||||
</div>
|
||||
<div class="crm-card">
|
||||
<p class="text-xs text-slate-500">Won this Month</p>
|
||||
<p class="text-2xl font-bold text-slate-900" x-text="kpis.won_this_month ?? '–'"></p>
|
||||
</div>
|
||||
<div class="crm-card">
|
||||
<p class="text-xs text-slate-500">Conversion Rate</p>
|
||||
<p class="text-2xl font-bold text-slate-900"
|
||||
x-text="formatPercent(kpis.conversion_rate)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick actions -->
|
||||
<h2 class="text-lg font-semibold mb-3">Schnellaktionen</h2>
|
||||
<div class="flex gap-2 mb-6">
|
||||
<a href="/accounts.html" class="btn-primary">+ New Account</a>
|
||||
<a href="/contacts.html" class="btn-primary">+ New Contact</a>
|
||||
<a href="/pipeline.html" class="btn-primary">+ New Deal</a>
|
||||
</div>
|
||||
|
||||
<!-- Activity feed -->
|
||||
<h2 class="text-lg font-semibold mb-3">Letzte Aktivitäten</h2>
|
||||
<div class="crm-card">
|
||||
<template x-for="a in feed" :key="a.id">
|
||||
<div class="py-2 border-b border-slate-100 last:border-0">
|
||||
<div class="flex justify-between items-center">
|
||||
<p class="text-sm">
|
||||
<span class="crm-badge" :class="badgeColor(a.type)" x-text="a.type"></span>
|
||||
<span class="ml-2 font-medium text-slate-800" x-text="a.subject"></span>
|
||||
</p>
|
||||
<span class="text-xs text-slate-400" x-text="formatRelative(a.created_at)"></span>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-1" x-text="a.body || ''"></p>
|
||||
</div>
|
||||
</template>
|
||||
<p x-show="feed.length === 0" class="text-sm text-slate-500 text-center py-4">
|
||||
Keine Aktivitäten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user