diff --git a/.env b/.env index d6ca334..fdd6700 100644 --- a/.env +++ b/.env @@ -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 diff --git a/.env.example b/.env.example index 782ba96..4968202 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/alembic/env.py b/alembic/env.py index 375c543..f1b4c98 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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()) diff --git a/alembic/script.py.mako b/alembic/script.py.mako index e5e75bb..1ba49a8 100644 --- a/alembic/script.py.mako +++ b/alembic/script.py.mako @@ -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)} diff --git a/alembic/versions/0001_init.py b/alembic/versions/0001_init.py deleted file mode 100644 index cc0ef02..0000000 --- a/alembic/versions/0001_init.py +++ /dev/null @@ -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") diff --git a/alembic/versions/0001_initial.py b/alembic/versions/0001_initial.py new file mode 100644 index 0000000..818d480 --- /dev/null +++ b/alembic/versions/0001_initial.py @@ -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) diff --git a/alembic/versions/0002_business_entities.py b/alembic/versions/0002_business_entities.py deleted file mode 100644 index a0cb511..0000000 --- a/alembic/versions/0002_business_entities.py +++ /dev/null @@ -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") diff --git a/app/__init__.py b/app/__init__.py index 1888c69..3605ce6 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1 @@ -"""CRM System Application Package.""" - -__version__ = "1.0.0" +"""LeoCRM backend application package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py index 010fe3a..dff53e5 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -1 +1 @@ -"""API routers package.""" +"""API package.""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index ffb55b3..b08cd20 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -1 +1 @@ -"""API v1 routers.""" +"""API v1 package.""" diff --git a/app/api/v1/accounts.py b/app/api/v1/accounts.py deleted file mode 100644 index 72857e2..0000000 --- a/app/api/v1/accounts.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/activities.py b/app/api/v1/activities.py deleted file mode 100644 index 264a4fa..0000000 --- a/app/api/v1/activities.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py deleted file mode 100644 index a63c7bf..0000000 --- a/app/api/v1/auth.py +++ /dev/null @@ -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() diff --git a/app/api/v1/contacts.py b/app/api/v1/contacts.py deleted file mode 100644 index 30a75fe..0000000 --- a/app/api/v1/contacts.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/dashboard.py b/app/api/v1/dashboard.py deleted file mode 100644 index 2b8c408..0000000 --- a/app/api/v1/dashboard.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/deals.py b/app/api/v1/deals.py deleted file mode 100644 index a17f837..0000000 --- a/app/api/v1/deals.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/health.py b/app/api/v1/health.py deleted file mode 100644 index 53d615a..0000000 --- a/app/api/v1/health.py +++ /dev/null @@ -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__, - } diff --git a/app/api/v1/notes.py b/app/api/v1/notes.py deleted file mode 100644 index dd663be..0000000 --- a/app/api/v1/notes.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/tags.py b/app/api/v1/tags.py deleted file mode 100644 index 82e68fc..0000000 --- a/app/api/v1/tags.py +++ /dev/null @@ -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"] diff --git a/app/api/v1/users.py b/app/api/v1/users.py deleted file mode 100644 index b5c69dc..0000000 --- a/app/api/v1/users.py +++ /dev/null @@ -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) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6ecb127 --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/core/__init__.py b/app/core/__init__.py index 591ee1b..ae0e9ad 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -1 +1 @@ -"""Core module: configuration, database, security, dependencies.""" +"""Core infrastructure package.""" diff --git a/app/core/audit.py b/app/core/audit.py new file mode 100644 index 0000000..da8d188 --- /dev/null +++ b/app/core/audit.py @@ -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 diff --git a/app/core/auth.py b/app/core/auth.py new file mode 100644 index 0000000..8ac63ab --- /dev/null +++ b/app/core/auth.py @@ -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 diff --git a/app/core/cache.py b/app/core/cache.py new file mode 100644 index 0000000..2f1f03f --- /dev/null +++ b/app/core/cache.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py deleted file mode 100644 index 1953f38..0000000 --- a/app/core/config.py +++ /dev/null @@ -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] diff --git a/app/core/db.py b/app/core/db.py deleted file mode 100644 index 011ef9f..0000000 --- a/app/core/db.py +++ /dev/null @@ -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() diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py new file mode 100644 index 0000000..b12aeb8 --- /dev/null +++ b/app/core/db/__init__.py @@ -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 diff --git a/app/core/deps.py b/app/core/deps.py deleted file mode 100644 index 1b457b1..0000000 --- a/app/core/deps.py +++ /dev/null @@ -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 diff --git a/app/core/event_bus.py b/app/core/event_bus.py new file mode 100644 index 0000000..0265a95 --- /dev/null +++ b/app/core/event_bus.py @@ -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 diff --git a/app/core/jobs.py b/app/core/jobs.py new file mode 100644 index 0000000..fcf6056 --- /dev/null +++ b/app/core/jobs.py @@ -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, + } diff --git a/app/core/middleware.py b/app/core/middleware.py new file mode 100644 index 0000000..075bb64 --- /dev/null +++ b/app/core/middleware.py @@ -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) diff --git a/app/core/notifications.py b/app/core/notifications.py new file mode 100644 index 0000000..4945c02 --- /dev/null +++ b/app/core/notifications.py @@ -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, + } diff --git a/app/core/rate_limit.py b/app/core/rate_limit.py new file mode 100644 index 0000000..d8b1b66 --- /dev/null +++ b/app/core/rate_limit.py @@ -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" diff --git a/app/core/security.py b/app/core/security.py deleted file mode 100644 index e624013..0000000 --- a/app/core/security.py +++ /dev/null @@ -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 diff --git a/app/core/service_container.py b/app/core/service_container.py new file mode 100644 index 0000000..835722e --- /dev/null +++ b/app/core/service_container.py @@ -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 diff --git a/app/core/tenant.py b/app/core/tenant.py new file mode 100644 index 0000000..5464ea2 --- /dev/null +++ b/app/core/tenant.py @@ -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) diff --git a/app/deps.py b/app/deps.py new file mode 100644 index 0000000..2568948 --- /dev/null +++ b/app/deps.py @@ -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"]) diff --git a/app/main.py b/app/main.py index bbf6c93..422faeb 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/models/__init__.py b/app/models/__init__.py index cf5478f..5cb0f7c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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", ] diff --git a/app/models/account.py b/app/models/account.py deleted file mode 100644 index 20c8d41..0000000 --- a/app/models/account.py +++ /dev/null @@ -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"" - - -__all__ = ["Account", "AccountSize", "Industry"] diff --git a/app/models/activity.py b/app/models/activity.py deleted file mode 100644 index feae350..0000000 --- a/app/models/activity.py +++ /dev/null @@ -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"" - - -__all__ = ["Activity", "ActivityType"] diff --git a/app/models/audit.py b/app/models/audit.py new file mode 100644 index 0000000..1889fc1 --- /dev/null +++ b/app/models/audit.py @@ -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() + ) diff --git a/app/models/auth.py b/app/models/auth.py new file mode 100644 index 0000000..d302c69 --- /dev/null +++ b/app/models/auth.py @@ -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) diff --git a/app/models/base.py b/app/models/base.py deleted file mode 100644 index e4518db..0000000 --- a/app/models/base.py +++ /dev/null @@ -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"] diff --git a/app/models/company.py b/app/models/company.py new file mode 100644 index 0000000..f00c215 --- /dev/null +++ b/app/models/company.py @@ -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 + ) diff --git a/app/models/contact.py b/app/models/contact.py deleted file mode 100644 index 9cc6953..0000000 --- a/app/models/contact.py +++ /dev/null @@ -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"" - - -__all__ = ["Contact"] diff --git a/app/models/deal.py b/app/models/deal.py deleted file mode 100644 index eea93ca..0000000 --- a/app/models/deal.py +++ /dev/null @@ -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"" - - -__all__ = ["Deal", "DealStage"] diff --git a/app/models/deal_stage_history.py b/app/models/deal_stage_history.py deleted file mode 100644 index d0ed6c3..0000000 --- a/app/models/deal_stage_history.py +++ /dev/null @@ -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"{self.to_stage}>" - - -__all__ = ["DealStageHistory"] diff --git a/app/models/note.py b/app/models/note.py deleted file mode 100644 index 4c2a612..0000000 --- a/app/models/note.py +++ /dev/null @@ -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"" - - -__all__ = ["Note", "NoteParentType"] diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..523ebb6 --- /dev/null +++ b/app/models/notification.py @@ -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() + ) diff --git a/app/models/org.py b/app/models/org.py deleted file mode 100644 index 08d2d70..0000000 --- a/app/models/org.py +++ /dev/null @@ -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"" diff --git a/app/models/role.py b/app/models/role.py new file mode 100644 index 0000000..a8111f9 --- /dev/null +++ b/app/models/role.py @@ -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() + ) diff --git a/app/models/session.py b/app/models/session.py new file mode 100644 index 0000000..0741bcc --- /dev/null +++ b/app/models/session.py @@ -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() + ) diff --git a/app/models/tag.py b/app/models/tag.py deleted file mode 100644 index f2bfe7a..0000000 --- a/app/models/tag.py +++ /dev/null @@ -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"" - - -__all__ = ["Tag"] diff --git a/app/models/tag_link.py b/app/models/tag_link.py deleted file mode 100644 index 60cf813..0000000 --- a/app/models/tag_link.py +++ /dev/null @@ -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"" - ) - - -__all__ = ["TagLink", "TagLinkParentType"] diff --git a/app/models/tenant.py b/app/models/tenant.py new file mode 100644 index 0000000..ce58608 --- /dev/null +++ b/app/models/tenant.py @@ -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() + ) diff --git a/app/models/user.py b/app/models/user.py index d9bcb10..9258d76 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -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"" +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() + ) diff --git a/app/routes/__init__.py b/app/routes/__init__.py new file mode 100644 index 0000000..945172b --- /dev/null +++ b/app/routes/__init__.py @@ -0,0 +1 @@ +"""Routes package.""" diff --git a/app/routes/auth.py b/app/routes/auth.py new file mode 100644 index 0000000..a4bb520 --- /dev/null +++ b/app/routes/auth.py @@ -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."} diff --git a/app/routes/companies.py b/app/routes/companies.py new file mode 100644 index 0000000..fbb893e --- /dev/null +++ b/app/routes/companies.py @@ -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 + } diff --git a/app/routes/health.py b/app/routes/health.py new file mode 100644 index 0000000..88b99f2 --- /dev/null +++ b/app/routes/health.py @@ -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"} diff --git a/app/routes/notifications.py b/app/routes/notifications.py new file mode 100644 index 0000000..63972a6 --- /dev/null +++ b/app/routes/notifications.py @@ -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} diff --git a/app/routes/roles.py b/app/routes/roles.py new file mode 100644 index 0000000..86c7a86 --- /dev/null +++ b/app/routes/roles.py @@ -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) diff --git a/app/routes/tenants.py b/app/routes/tenants.py new file mode 100644 index 0000000..f56cf62 --- /dev/null +++ b/app/routes/tenants.py @@ -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"} diff --git a/app/routes/users.py b/app/routes/users.py new file mode 100644 index 0000000..dac2695 --- /dev/null +++ b/app/routes/users.py @@ -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) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index cb3c39d..e964283 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -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.""" diff --git a/app/schemas/account.py b/app/schemas/account.py deleted file mode 100644 index 5e43621..0000000 --- a/app/schemas/account.py +++ /dev/null @@ -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"] diff --git a/app/schemas/activity.py b/app/schemas/activity.py deleted file mode 100644 index 52f7c71..0000000 --- a/app/schemas/activity.py +++ /dev/null @@ -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", -] diff --git a/app/schemas/auth.py b/app/schemas/auth.py index f82094c..46224ed 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -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 diff --git a/app/schemas/common.py b/app/schemas/common.py index f40a9e2..4f7195b 100644 --- a/app/schemas/common.py +++ b/app/schemas/common.py @@ -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 diff --git a/app/schemas/company.py b/app/schemas/company.py new file mode 100644 index 0000000..87c8a70 --- /dev/null +++ b/app/schemas/company.py @@ -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 diff --git a/app/schemas/contact.py b/app/schemas/contact.py deleted file mode 100644 index 843d4d5..0000000 --- a/app/schemas/contact.py +++ /dev/null @@ -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"] diff --git a/app/schemas/dashboard.py b/app/schemas/dashboard.py deleted file mode 100644 index ffb4279..0000000 --- a/app/schemas/dashboard.py +++ /dev/null @@ -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"] diff --git a/app/schemas/deal.py b/app/schemas/deal.py deleted file mode 100644 index cda50e7..0000000 --- a/app/schemas/deal.py +++ /dev/null @@ -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", -] diff --git a/app/schemas/note.py b/app/schemas/note.py deleted file mode 100644 index 7a813d0..0000000 --- a/app/schemas/note.py +++ /dev/null @@ -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"] diff --git a/app/schemas/role.py b/app/schemas/role.py new file mode 100644 index 0000000..e64823c --- /dev/null +++ b/app/schemas/role.py @@ -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] diff --git a/app/schemas/tag.py b/app/schemas/tag.py deleted file mode 100644 index d7bfc86..0000000 --- a/app/schemas/tag.py +++ /dev/null @@ -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"] diff --git a/app/schemas/tenant.py b/app/schemas/tenant.py new file mode 100644 index 0000000..57f4c9c --- /dev/null +++ b/app/schemas/tenant.py @@ -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) diff --git a/app/schemas/user.py b/app/schemas/user.py index 6d9b9ae..4a4849c 100644 --- a/app/schemas/user.py +++ b/app/schemas/user.py @@ -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"] diff --git a/app/services/__init__.py b/app/services/__init__.py index f32a8a2..f4f478c 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -1,8 +1 @@ -"""Service layer for the CRM system. - -Submodules are imported directly by routers via -`from app.services. import ` to avoid circular-import issues -during application startup. -""" - -__all__: list[str] = [] +"""Service layer package.""" diff --git a/app/services/_base.py b/app/services/_base.py deleted file mode 100644 index a64d56e..0000000 --- a/app/services/_base.py +++ /dev/null @@ -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"] diff --git a/app/services/account_service.py b/app/services/account_service.py deleted file mode 100644 index f3a56bb..0000000 --- a/app/services/account_service.py +++ /dev/null @@ -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", -] diff --git a/app/services/activity_service.py b/app/services/activity_service.py deleted file mode 100644 index 78cb698..0000000 --- a/app/services/activity_service.py +++ /dev/null @@ -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", -] diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 93ca952..900580e 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -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() diff --git a/app/services/contact_service.py b/app/services/contact_service.py deleted file mode 100644 index a6204b5..0000000 --- a/app/services/contact_service.py +++ /dev/null @@ -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", -] diff --git a/app/services/dashboard_service.py b/app/services/dashboard_service.py deleted file mode 100644 index b4870b3..0000000 --- a/app/services/dashboard_service.py +++ /dev/null @@ -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"] diff --git a/app/services/deal_service.py b/app/services/deal_service.py deleted file mode 100644 index 09c2afb..0000000 --- a/app/services/deal_service.py +++ /dev/null @@ -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", -] diff --git a/app/services/note_service.py b/app/services/note_service.py deleted file mode 100644 index ac726e7..0000000 --- a/app/services/note_service.py +++ /dev/null @@ -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", -] diff --git a/app/services/role_service.py b/app/services/role_service.py new file mode 100644 index 0000000..be48cfc --- /dev/null +++ b/app/services/role_service.py @@ -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() diff --git a/app/services/tag_service.py b/app/services/tag_service.py deleted file mode 100644 index 9feaa15..0000000 --- a/app/services/tag_service.py +++ /dev/null @@ -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", -] diff --git a/app/services/tenant_service.py b/app/services/tenant_service.py new file mode 100644 index 0000000..9bd6825 --- /dev/null +++ b/app/services/tenant_service.py @@ -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() diff --git a/app/services/user_service.py b/app/services/user_service.py index c821150..1151a27 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -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() diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..000ed5d --- /dev/null +++ b/app/utils/__init__.py @@ -0,0 +1 @@ +"""Utilities package.""" diff --git a/app/webui/.gitkeep b/app/webui/.gitkeep deleted file mode 100644 index 88000d2..0000000 --- a/app/webui/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -"""Static frontend assets (Phase 4c fills this).""" diff --git a/app/webui/404.html b/app/webui/404.html deleted file mode 100644 index 77e9539..0000000 --- a/app/webui/404.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - 404 – Seite nicht gefunden - - - - - - - -
-

404

-

Seite nicht gefunden

-

- Die angeforderte URL existiert nicht oder du bist nicht berechtigt. -

- Zum Dashboard - Oder zur App-Startseite - -

- Falls du eine bestimmte Page suchst: Accounts, Contacts, Pipeline oder Activities. -

-
- - - diff --git a/app/webui/accounts-detail.html b/app/webui/accounts-detail.html deleted file mode 100644 index 46993c7..0000000 --- a/app/webui/accounts-detail.html +++ /dev/null @@ -1,350 +0,0 @@ - - - - - - CRM – Account Detail - - - - - - - - - - -
- -
- -
- - - -
-
-

Account-Detail

-
- - - - -
-
- -
- - -
-
-

Lade Account …

-
- -
- -
-
-
-

-

- - - Owner # -

-

- -

-
-
- - -
-
-
- - -
- - - - - -
- - -
-
-
Name
-
Branche
-
Größe
-
Website
-
Org-ID
-
Owner
-
Erstellt
-
Aktualisiert
-
-
- - -
-
-
-
- - - - - - - - -
NameEmailPhone
Keine Contacts.
-
- - -
-
- - - - - - - - -
TitleStageValueClose
Keine Deals.
-
- - -
-
-
    - -
  • Keine Activities.
  • -
-
- - -
-
-
    - -
  • Keine Notes.
  • -
-
-
- - -
-
-

Account bearbeiten

-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
-
-
-
- - - - diff --git a/app/webui/accounts.html b/app/webui/accounts.html deleted file mode 100644 index eb52e43..0000000 --- a/app/webui/accounts.html +++ /dev/null @@ -1,189 +0,0 @@ - - - - - - CRM – Accounts - - - - - - - - - - - - -
- -
- - -
- - - -
-
-

Accounts

-
- - - - -
-
- -
- - -
-
- - -
-
- - -
-
- - -
- - -
- - -
-
-

Lade Accounts …

-
- - -
- - - - - - - - - - - - - - - - -
NameBrancheGrößeWebsiteOwner
- Keine Accounts gefunden. -
-
- - -
- -
- - -
-
- - -
-
-

Neuer Account

-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- -
-
-
- - - diff --git a/app/webui/activities.html b/app/webui/activities.html deleted file mode 100644 index 1418da6..0000000 --- a/app/webui/activities.html +++ /dev/null @@ -1,220 +0,0 @@ - - - - - - CRM – Activities - - - - - - - - - - - -
- -
- -
- - - -
-
-

Activities

-
- - - - -
-
- -
- - -
-
- - -
-
- - -
-
- - -
- - -
- - -
-
-

Lade Activities …

-
- - -
- - - - - - - - - - - - - - - - -
TypSubjectFälligOwnerStatus
- Keine Activities gefunden. -
-
- - -
- -
- - -
-
- - -
-
-

Neue Activity

-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- Polymorph-Parent: mindestens eine ID angeben. -
-
-
- - -
-
- - -
-
- - -
-
-
-
- - -
-
-
-
- - -
-
-

-

- - Fällig: -

-

-
- - -
-
-
-
-
-
- - - diff --git a/app/webui/app.html b/app/webui/app.html deleted file mode 100644 index 7307778..0000000 --- a/app/webui/app.html +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - CRM – Dashboard - - - - - - - - - - - - - - - - - -
- -
- - - -
-
- - - - - -
- - -
-

-
- - Angemeldet als - - - -
-
- - -
- - -
-
-

Lade Dashboard …

-
- - -
- -
- -
- - -

Kennzahlen

-
-
-

Open Deals

-

-
-
-

Pipeline Value

-

-
-
-

Won this Month

-

-
-
-

Conversion Rate

-

-
-
- - -

Schnellaktionen

- - - -

Letzte Aktivitäten

-
- -

- Keine Aktivitäten. -

-
-
-
-
- -
-
- - - diff --git a/app/webui/components/account-list.js b/app/webui/components/account-list.js deleted file mode 100644 index e9358a3..0000000 --- a/app/webui/components/account-list.js +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Account-List Component (Alpine.js) - * - * Used in: accounts.html - * - * Features: - * - Paginated table of accounts - * - Search by name (?q=) - * - Filter by industry (?industry=) and size (?size=) - * - "+ New Account" modal that POSTs to /api/v1/accounts/ - * - Row click → /accounts-detail.html?id=… - * - * Backend: GET /api/v1/accounts/?skip=&limit=&q=&industry=&size=&owner_id= - * POST /api/v1/accounts/ → AccountOut - * - * R-5: x-text only. All user-controlled strings go through x-text or - * textContent (never x-html / innerHTML). - */ - -import { api } from '/js/api.js'; - -export function accountList() { - return { - // State - accounts: [], - total: 0, - skip: 0, - limit: 20, - q: '', - industry: '', - size: '', - ownerId: '', - loading: false, - error: null, - - // Create-modal state - showCreate: false, - createForm: this._initCreateForm(), - createError: '', - creating: false, - - // Lookup values (could be hard-coded; kept here for clarity) - industries: ['technology', 'finance', 'healthcare', 'retail', 'manufacturing', 'other'], - sizes: ['startup', 'smb', 'midmarket', 'enterprise'], - - async init() { - await this.load(); - }, - - async load() { - this.loading = true; - this.error = null; - try { - const params = new URLSearchParams(); - params.set('skip', String(this.skip)); - params.set('limit', String(this.limit)); - if (this.q) params.set('q', this.q); - if (this.industry) params.set('industry', this.industry); - if (this.size) params.set('size', this.size); - if (this.ownerId) params.set('owner_id', this.ownerId); - - const res = await api.get(`/accounts/?${params.toString()}`); - this.accounts = Array.isArray(res) ? res : (res && res.items) || []; - this.total = (res && res.total) || this.accounts.length; - } catch (err) { - this.error = err.message || 'Fehler beim Laden der Accounts.'; - this._notify('error', this.error); - } finally { - this.loading = false; - } - }, - - async search() { - this.skip = 0; - await this.load(); - }, - - async nextPage() { - if (this.skip + this.limit < this.total) { - this.skip += this.limit; - await this.load(); - } - }, - - async prevPage() { - if (this.skip - this.limit >= 0) { - this.skip -= this.limit; - await this.load(); - } - }, - - get pageInfo() { - const from = this.total === 0 ? 0 : this.skip + 1; - const to = Math.min(this.skip + this.limit, this.total); - return `${from}–${to} von ${this.total}`; - }, - - // === Create modal === - openCreate() { - this.createForm = this._initCreateForm(); - this.createError = ''; - this.showCreate = true; - }, - closeCreate() { - this.showCreate = false; - }, - async submitCreate() { - this.creating = true; - this.createError = ''; - try { - const payload = { - name: this.createForm.name, - }; - if (this.createForm.website) payload.website = this.createForm.website; - if (this.createForm.industry) payload.industry = this.createForm.industry; - if (this.createForm.size) payload.size = this.createForm.size; - - await api.post('/accounts/', payload); - this._notify('success', 'Account angelegt.'); - this.showCreate = false; - await this.load(); - } catch (err) { - this.createError = err.message || 'Fehler beim Anlegen.'; - } finally { - this.creating = false; - } - }, - - // === Internals === - _initCreateForm() { - return { name: '', website: '', industry: '', size: '' }; - }, - _notify(type, message) { - if (window.Alpine) { - Alpine.store('notifications').push(message, type); - } - }, - }; -} - -if (typeof window !== 'undefined') { - document.addEventListener('alpine:init', () => { - if (window.Alpine) { - window.Alpine.data('accountList', accountList); - } - }); -} - -export default accountList; diff --git a/app/webui/components/activity-list.js b/app/webui/components/activity-list.js deleted file mode 100644 index c333a93..0000000 --- a/app/webui/components/activity-list.js +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Activity-List Component (Alpine.js) - * - * Used in: activities.html - * - * Features: - * - Paginated table of activities - * - Filter by type, owner, overdue, completed - * - "+ New Activity" modal that POSTs to /api/v1/activities/ - * - Row click opens a detail modal with a "Complete" button - * - * Backend: GET /api/v1/activities/?skip=&limit=&type=&owner_id=&overdue=&completed= - * POST /api/v1/activities/ - * PATCH /api/v1/activities/{id}/complete - * - * R-5: x-text only. Even activity bodies go through x-text, never x-html. - */ - -import { api } from '/js/api.js'; - -export const ACTIVITY_TYPES = ['call', 'email', 'meeting', 'task', 'note']; - -export const TYPE_BADGE = { - call: 'badge-blue', - email: 'badge-yellow', - meeting: 'badge-green', - task: 'badge-gray', - note: 'badge-gray', -}; - -export function activityList() { - return { - // State - activities: [], - total: 0, - skip: 0, - limit: 20, - type: '', - ownerId: '', - overdue: '', // '', 'true', 'false' - completed: '', // '', 'true', 'false' - loading: false, - error: null, - - // Create-modal state - showCreate: false, - createForm: this._initForm(), - createError: '', - creating: false, - - // Detail-modal state - showDetail: false, - selected: null, - completing: false, - - // Lookups - types: ACTIVITY_TYPES.slice(), - - async init() { - await this.load(); - }, - - async load() { - this.loading = true; - this.error = null; - try { - const params = new URLSearchParams(); - params.set('skip', String(this.skip)); - params.set('limit', String(this.limit)); - if (this.type) params.set('type', this.type); - if (this.ownerId) params.set('owner_id', this.ownerId); - if (this.overdue) params.set('overdue', this.overdue); - if (this.completed) params.set('completed', this.completed); - - const res = await api.get(`/activities/?${params.toString()}`); - this.activities = Array.isArray(res) ? res : (res && res.items) || []; - this.total = (res && res.total) || this.activities.length; - } catch (err) { - this.error = err.message || 'Fehler beim Laden der Activities.'; - this._notify('error', this.error); - } finally { - this.loading = false; - } - }, - - async applyFilters() { - this.skip = 0; - await this.load(); - }, - - async nextPage() { - if (this.skip + this.limit < this.total) { - this.skip += this.limit; - await this.load(); - } - }, - async prevPage() { - if (this.skip - this.limit >= 0) { - this.skip -= this.limit; - await this.load(); - } - }, - get pageInfo() { - const from = this.total === 0 ? 0 : this.skip + 1; - const to = Math.min(this.skip + this.limit, this.total); - return `${from}–${to} von ${this.total}`; - }, - - // === Row helpers === - isOverdue(a) { - if (a.completed_at) return false; - if (!a.due_date) return false; - return new Date(a.due_date) < new Date(); - }, - isCompleted(a) { return !!a.completed_at; }, - typeBadge(t) { return TYPE_BADGE[t] || 'badge-gray'; }, - - formatDateTime(iso) { - if (!iso) return ''; - try { return new Date(iso).toLocaleString('de-DE'); } - catch { return iso; } - }, - - // === Create modal === - openCreate() { - this.createForm = this._initForm(); - this.createError = ''; - this.showCreate = true; - }, - closeCreate() { this.showCreate = false; }, - async submitCreate() { - this.creating = true; - this.createError = ''; - try { - const payload = { - type: this.createForm.type, - subject: this.createForm.subject, - }; - if (this.createForm.body) payload.body = this.createForm.body; - if (this.createForm.due_date) payload.due_date = this.createForm.due_date; - if (this.createForm.account_id) payload.account_id = Number(this.createForm.account_id); - if (this.createForm.contact_id) payload.contact_id = Number(this.createForm.contact_id); - if (this.createForm.deal_id) payload.deal_id = Number(this.createForm.deal_id); - - await api.post('/activities/', payload); - this._notify('success', 'Activity angelegt.'); - this.showCreate = false; - await this.load(); - } catch (err) { - this.createError = err.message || 'Fehler beim Anlegen.'; - } finally { - this.creating = false; - } - }, - - // === Detail / Complete === - openDetail(a) { - this.selected = a; - this.showDetail = true; - }, - closeDetail() { - this.showDetail = false; - this.selected = null; - }, - async completeSelected() { - if (!this.selected) return; - this.completing = true; - try { - await api.patch(`/activities/${this.selected.id}/complete`, { outcome: '' }); - this._notify('success', 'Activity abgeschlossen.'); - this.closeDetail(); - await this.load(); - } catch (err) { - this._notify('error', err.message || 'Fehler beim Abschließen.'); - } finally { - this.completing = false; - } - }, - - // === Internals === - _initForm() { - return { - type: 'task', - subject: '', - body: '', - due_date: '', - account_id: '', - contact_id: '', - deal_id: '', - }; - }, - _notify(type, message) { - if (window.Alpine) Alpine.store('notifications').push(message, type); - }, - }; -} - -if (typeof window !== 'undefined') { - document.addEventListener('alpine:init', () => { - if (window.Alpine) { - window.Alpine.data('activityList', activityList); - } - }); -} - -export default activityList; diff --git a/app/webui/components/app-shell.js b/app/webui/components/app-shell.js deleted file mode 100644 index 307eff8..0000000 --- a/app/webui/components/app-shell.js +++ /dev/null @@ -1,47 +0,0 @@ -/** - * app-shell.js — Layout-Shell for the protected area. - * - * Each protected page (dashboard, accounts, etc.) renders the sidebar + - * topbar via x-data="appShell('')". The shell owns: - * - the active nav state - * - the title text (derived from the page key) - * - the Logout button handler (delegates to the auth store) - * - the admin-only nav items (uses $store.auth.isAdmin) - * - * The auth-gate itself (redirect to /index.html when no JWT) is enforced - * by store.js on the `Alpine.store('auth').init()` call, which every - * protected page invokes from its outermost x-data. - */ - -const TITLES = { - 'dashboard': 'Dashboard', - 'accounts': 'Accounts', - 'accounts-detail': 'Account-Detail', - 'contacts': 'Contacts', - 'contacts-detail': 'Contact-Detail', - 'pipeline': 'Pipeline', - 'activities': 'Activities', - 'settings-profile': 'Mein Profil', - 'settings-users': 'User-Verwaltung', - 'settings-org': 'Org-Einstellungen', -}; - - -export function appShell(active) { - return { - active: active || 'dashboard', - - get title() { - return TITLES[this.active] || 'CRM'; - }, - - async logout() { - await Alpine.store('auth').logout(); - }, - }; -} - -// Global registration for the inline `x-data="appShell('…')"` usage. -document.addEventListener('alpine:init', () => { - Alpine.data('appShell', (active) => appShell(active)); -}); diff --git a/app/webui/components/dashboard-kpis.js b/app/webui/components/dashboard-kpis.js deleted file mode 100644 index 3a7d293..0000000 --- a/app/webui/components/dashboard-kpis.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Dashboard-KPIs + Activity-Feed Component (Alpine.js) - * - * Used in: dashboard.html and app.html (default landing page) - * - * Loads: - * GET /api/v1/dashboard/kpis → { open_deals_count, pipeline_value, won_this_month, conversion_rate } - * GET /api/v1/dashboard/feed?limit=20 → ActivityFeedItem[] - * - * Renders: 4 KPI cards + an activity feed list. - * - * R-5: x-text only. User-controlled strings are not interpolated as HTML. - */ - -import { api } from '/js/api.js'; - -export function dashboardKpis() { - return { - loading: true, - kpis: null, - feed: [], - error: null, - - async load() { - this.loading = true; - this.error = null; - try { - // Fetch KPIs and feed in parallel - const [kpis, feed] = await Promise.all([ - api.get('/dashboard/kpis'), - api.get('/dashboard/feed?limit=20'), - ]); - this.kpis = kpis; - this.feed = Array.isArray(feed) ? feed : (feed && feed.items) || []; - } catch (err) { - this.error = err.message || 'Fehler beim Laden der Dashboard-Daten.'; - if (window.Alpine) { - Alpine.store('notifications').error(this.error); - } - } finally { - this.loading = false; - } - }, - - formatCurrency(value) { - if (value === null || value === undefined) return '–'; - const num = Number(value); - if (Number.isNaN(num)) return '–'; - return num.toLocaleString('de-DE', { - style: 'currency', - currency: 'EUR', - maximumFractionDigits: 0, - }); - }, - - formatPercent(value) { - if (value === null || value === undefined) return '–'; - const num = Number(value); - if (Number.isNaN(num)) return '–'; - // Backend may return 0-1 or 0-100. Heuristic: >1 → already in %. - const pct = num > 1 ? num : num * 100; - return pct.toFixed(1) + ' %'; - }, - - formatDate(iso) { - if (!iso) return ''; - try { - return new Date(iso).toLocaleDateString('de-DE'); - } catch { - return iso; - } - }, - }; -} - -// Register globally for `x-data="dashboardKpis()"` -if (typeof window !== 'undefined') { - document.addEventListener('alpine:init', () => { - if (window.Alpine) { - window.Alpine.data('dashboardKpis', dashboardKpis); - } - }); -} - -export default dashboardKpis; diff --git a/app/webui/components/deal-kanban.js b/app/webui/components/deal-kanban.js deleted file mode 100644 index a8c7ba0..0000000 --- a/app/webui/components/deal-kanban.js +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Deal-Kanban Component (Alpine.js) - * - * Used in: pipeline.html - * - * Loads all deals and groups them client-side by stage into 6 Kanban columns: - * lead → qualified → proposal → negotiation → won → lost - * - * Drag-and-drop: uses HTML5 Drag API. On drop, PATCH /api/v1/deals/{id}/stage - * with the new stage. Backend: { stage, reason? } per DealStageUpdate schema. - * - * The pipeline-summary endpoint (GET /deals/pipeline) is also fetched for - * aggregate counts/values shown in each column header. - * - * R-5: x-text only. No x-html anywhere — including dynamic deal titles. - */ - -import { api } from '/js/api.js'; - -export const STAGES = ['lead', 'qualified', 'proposal', 'negotiation', 'won', 'lost']; - -export const STAGE_LABELS = { - lead: 'Lead', - qualified: 'Qualified', - proposal: 'Proposal', - negotiation: 'Negotiation', - won: 'Won', - lost: 'Lost', -}; - -export const STAGE_BADGE = { - lead: 'badge-gray', - qualified: 'badge-blue', - proposal: 'badge-yellow', - negotiation: 'badge-yellow', - won: 'badge-green', - lost: 'badge-red', -}; - -export function dealKanban() { - return { - // State - stages: STAGES.slice(), - dealsByStage: {}, // { stage: DealOut[] } - pipelineSummary: [], // [{stage, count, total_value}] from /deals/pipeline - ownerId: '', - dragOverStage: null, - loading: false, - error: null, - - async init() { - await this.load(); - }, - - async load() { - this.loading = true; - this.error = null; - try { - // Build URL with optional owner_id filter - let url = '/deals/?skip=0&limit=200'; - if (this.ownerId) url += `&owner_id=${encodeURIComponent(this.ownerId)}`; - - const [deals, summary] = await Promise.all([ - api.get(url), - api.get('/deals/pipeline').catch(() => []), // optional - ]); - - const list = Array.isArray(deals) ? deals : (deals && deals.items) || []; - this.dealsByStage = {}; - this.stages.forEach(s => { this.dealsByStage[s] = []; }); - for (const d of list) { - if (this.dealsByStage[d.stage]) { - this.dealsByStage[d.stage].push(d); - } - } - this.pipelineSummary = Array.isArray(summary) ? summary : []; - } catch (err) { - this.error = err.message || 'Fehler beim Laden der Pipeline.'; - this._notify('error', this.error); - } finally { - this.loading = false; - } - }, - - async filterByOwner() { - await this.load(); - }, - - // === Drag-and-Drop === - onDragStart(event, dealId) { - event.dataTransfer.effectAllowed = 'move'; - event.dataTransfer.setData('text/plain', String(dealId)); - }, - onDragOver(event, stage) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - this.dragOverStage = stage; - }, - onDragLeave(stage) { - if (this.dragOverStage === stage) this.dragOverStage = null; - }, - async onDrop(event, newStage) { - event.preventDefault(); - this.dragOverStage = null; - const dealId = event.dataTransfer.getData('text/plain'); - if (!dealId) return; - - // Find the deal in its current column - let currentStage = null; - let deal = null; - for (const s of this.stages) { - const idx = this.dealsByStage[s].findIndex(d => String(d.id) === String(dealId)); - if (idx >= 0) { currentStage = s; deal = this.dealsByStage[s][idx]; break; } - } - if (!deal || currentStage === newStage) return; - - // Optimistic UI move - this.dealsByStage[currentStage] = this.dealsByStage[currentStage].filter(d => d.id !== deal.id); - const oldDeal = { ...deal }; - deal.stage = newStage; - this.dealsByStage[newStage].push(deal); - - // Persist to backend - try { - await api.patch(`/deals/${dealId}/stage`, { stage: newStage }); - this._notify('success', `Deal "${oldDeal.title}" → ${STAGE_LABELS[newStage]}`); - } catch (err) { - // Revert on failure - this.dealsByStage[newStage] = this.dealsByStage[newStage].filter(d => d.id !== deal.id); - oldDeal.stage = currentStage; - this.dealsByStage[currentStage].push(oldDeal); - this._notify('error', err.message || 'Stage-Update fehlgeschlagen.'); - } - }, - - // === Create modal === - showCreate: false, - createForm: this._initForm(), - creating: false, - createError: '', - - openCreate() { - this.createForm = this._initForm(); - this.createError = ''; - this.showCreate = true; - }, - closeCreate() { this.showCreate = false; }, - async submitCreate() { - this.creating = true; - this.createError = ''; - try { - const payload = { - title: this.createForm.title, - value: Number(this.createForm.value) || 0, - currency: this.createForm.currency || 'EUR', - stage: this.createForm.stage || 'lead', - account_id: Number(this.createForm.account_id), - }; - if (this.createForm.close_date) payload.close_date = this.createForm.close_date; - await api.post('/deals/', payload); - this._notify('success', 'Deal angelegt.'); - this.showCreate = false; - await this.load(); - } catch (err) { - this.createError = err.message || 'Fehler beim Anlegen.'; - } finally { - this.creating = false; - } - }, - - // === Helpers === - stageLabel(s) { return STAGE_LABELS[s] || s; }, - stageBadge(s) { return STAGE_BADGE[s] || 'badge-gray'; }, - columnCount(s) { return (this.dealsByStage[s] || []).length; }, - columnValue(s) { - const sum = (this.dealsByStage[s] || []).reduce( - (acc, d) => acc + Number(d.value || 0), 0); - return sum.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }); - }, - formatCurrency(v) { - return Number(v || 0).toLocaleString('de-DE', { - style: 'currency', currency: 'EUR', maximumFractionDigits: 0, - }); - }, - formatDate(iso) { - if (!iso) return ''; - try { return new Date(iso).toLocaleDateString('de-DE'); } - catch { return iso; } - }, - _initForm() { - return { title: '', value: 0, currency: 'EUR', stage: 'lead', account_id: '', close_date: '' }; - }, - _notify(type, message) { - if (window.Alpine) Alpine.store('notifications').push(message, type); - }, - }; -} - -if (typeof window !== 'undefined') { - document.addEventListener('alpine:init', () => { - if (window.Alpine) { - window.Alpine.data('dealKanban', dealKanban); - } - }); -} - -export default dealKanban; diff --git a/app/webui/contacts-detail.html b/app/webui/contacts-detail.html deleted file mode 100644 index 472ceb1..0000000 --- a/app/webui/contacts-detail.html +++ /dev/null @@ -1,268 +0,0 @@ - - - - - - CRM – Contact Detail - - - - - - - - - - -
- -
- -
- - - -
-
-

Contact-Detail

-
- - - - -
-
- -
- -
-
-

Lade Contact …

-
- -
-
-
-
-

-

- - · - - · - Account # -

-
-
- - -
-
-
- -
- - - -
- -
-
-
Vorname
-
Nachname
-
E-Mail
-
Telefon
-
Account
-
Owner
-
-
- -
-
-
    - -
  • Keine Activities.
  • -
-
- -
-
-
    - -
  • Keine Notes.
  • -
-
-
- - -
-
-

Contact bearbeiten

-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
-
-
-
- - - - diff --git a/app/webui/contacts.html b/app/webui/contacts.html deleted file mode 100644 index 1b2db71..0000000 --- a/app/webui/contacts.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - CRM – Contacts - - - - - - - - - - -
- -
- -
- - - -
-
-

Contacts

-
- - - - -
-
- -
- - -
-
- - -
-
- - -
- - -
- - -
-
-

Lade Contacts …

-
- - -
- - - - - - - - - - - - - - - - -
NameE-MailTelefonAccount-IDOwner
- Keine Contacts gefunden. -
-
- - -
- -
- - -
-
- - -
-
-

Neuer Contact

-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
-
-
-
- - - - diff --git a/app/webui/css/app.css b/app/webui/css/app.css deleted file mode 100644 index 8dc00ff..0000000 --- a/app/webui/css/app.css +++ /dev/null @@ -1,255 +0,0 @@ -/* === CRM Frontend Custom Styles === */ -/* Tailwind-CDN provides utility classes; this file adds app-specific overrides. */ - -:root { - --crm-primary: #2563eb; /* blue-600 */ - --crm-primary-dark: #1d4ed8; - --crm-success: #16a34a; /* green-600 */ - --crm-danger: #dc2626; /* red-600 */ - --crm-warning: #d97706; /* amber-600 */ - --crm-bg: #f8fafc; /* slate-50 */ - --crm-card: #ffffff; - --crm-border: #e2e8f0; /* slate-200 */ - --crm-text: #0f172a; /* slate-900 */ - --crm-muted: #64748b; /* slate-500 */ -} - -/* === Body === */ -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, - "Helvetica Neue", Arial, sans-serif; - background: var(--crm-bg); - color: var(--crm-text); -} - -/* === Buttons (primary-color override) === */ -.btn-primary { - background-color: var(--crm-primary); - color: #fff; - padding: 0.5rem 1rem; - border-radius: 0.375rem; - font-weight: 500; - transition: background-color 0.15s ease; -} -.btn-primary:hover { background-color: var(--crm-primary-dark); } -.btn-primary:disabled { background-color: #94a3b8; cursor: not-allowed; } - -.btn-secondary { - background-color: #fff; - color: var(--crm-text); - padding: 0.5rem 1rem; - border: 1px solid var(--crm-border); - border-radius: 0.375rem; - font-weight: 500; -} -.btn-secondary:hover { background-color: #f1f5f9; } - -.btn-danger { - background-color: var(--crm-danger); - color: #fff; - padding: 0.5rem 1rem; - border-radius: 0.375rem; - font-weight: 500; -} -.btn-danger:hover { background-color: #b91c1c; } - -/* === Cards === */ -.crm-card { - background: var(--crm-card); - border: 1px solid var(--crm-border); - border-radius: 0.5rem; - padding: 1.25rem; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); -} - -/* === Form fields === */ -.crm-input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--crm-border); - border-radius: 0.375rem; - background: #fff; - font-size: 0.95rem; -} -.crm-input:focus { - outline: none; - border-color: var(--crm-primary); - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15); -} - -.crm-label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--crm-text); - margin-bottom: 0.25rem; -} - -/* === Loading spinner (Alpine x-show) === */ -.crm-spinner { - display: inline-block; - width: 1.25rem; - height: 1.25rem; - border: 2px solid #cbd5e1; - border-top-color: var(--crm-primary); - border-radius: 50%; - animation: crm-spin 0.8s linear infinite; -} - -@keyframes crm-spin { - to { transform: rotate(360deg); } -} - -.crm-loading-overlay { - position: absolute; - inset: 0; - background: rgba(255, 255, 255, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 10; -} - -/* === Toast Notifications (top-right) === */ -.crm-toast-container { - position: fixed; - top: 1rem; - right: 1rem; - z-index: 9999; - display: flex; - flex-direction: column; - gap: 0.5rem; - max-width: 22rem; -} - -.crm-toast { - padding: 0.75rem 1rem; - border-radius: 0.375rem; - color: #fff; - font-size: 0.9rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - animation: crm-toast-in 0.25s ease-out; -} - -.crm-toast.toast-success { background-color: var(--crm-success); } -.crm-toast.toast-error { background-color: var(--crm-danger); } -.crm-toast.toast-info { background-color: var(--crm-primary); } -.crm-toast.toast-warning { background-color: var(--crm-warning); } - -@keyframes crm-toast-in { - from { opacity: 0; transform: translateY(-8px); } - to { opacity: 1; transform: translateY(0); } -} - -/* === Tables === */ -.crm-table { - width: 100%; - border-collapse: collapse; - background: #fff; - border: 1px solid var(--crm-border); - border-radius: 0.5rem; - overflow: hidden; -} -.crm-table th { - text-align: left; - padding: 0.75rem 1rem; - background: #f1f5f9; - font-size: 0.8rem; - font-weight: 600; - color: var(--crm-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid var(--crm-border); -} -.crm-table td { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--crm-border); - font-size: 0.9rem; -} -.crm-table tr:last-child td { border-bottom: none; } -.crm-table tr:hover td { background: #f8fafc; } -.crm-table tr.row-overdue td { background: #fef2f2; } -.crm-table tr.row-overdue:hover td { background: #fee2e2; } - -/* === Badges === */ -.crm-badge { - display: inline-block; - padding: 0.125rem 0.5rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; -} -.badge-blue { background: #dbeafe; color: #1e40af; } -.badge-green { background: #dcfce7; color: #166534; } -.badge-red { background: #fee2e2; color: #991b1b; } -.badge-yellow { background: #fef3c7; color: #92400e; } -.badge-gray { background: #f1f5f9; color: #475569; } - -/* === Sidebar (app.html) === */ -.crm-sidebar { - width: 16rem; - background: #0f172a; - color: #cbd5e1; - min-height: 100vh; - padding: 1rem 0; -} -.crm-sidebar a { - display: block; - padding: 0.5rem 1.25rem; - color: #cbd5e1; - text-decoration: none; - font-size: 0.9rem; - border-left: 3px solid transparent; -} -.crm-sidebar a:hover { background: #1e293b; color: #fff; } -.crm-sidebar a.active { - background: #1e293b; - color: #fff; - border-left-color: var(--crm-primary); -} - -/* === Kanban (pipeline.html) === */ -.crm-kanban { - display: flex; - gap: 0.75rem; - overflow-x: auto; - padding-bottom: 1rem; -} -.crm-kanban-col { - flex: 0 0 16rem; - background: #f1f5f9; - border-radius: 0.5rem; - padding: 0.75rem; - min-height: 12rem; -} -.crm-kanban-col.drag-over { background: #e0e7ff; outline: 2px dashed var(--crm-primary); } -.crm-kanban-card { - background: #fff; - border: 1px solid var(--crm-border); - border-radius: 0.375rem; - padding: 0.5rem 0.75rem; - margin-bottom: 0.5rem; - cursor: grab; - font-size: 0.85rem; -} -.crm-kanban-card:active { cursor: grabbing; } - -/* === Modal (Alpine x-show) === */ -.crm-modal-backdrop { - position: fixed; inset: 0; - background: rgba(15, 23, 42, 0.5); - z-index: 100; - display: flex; align-items: center; justify-content: center; -} -.crm-modal { - background: #fff; - border-radius: 0.5rem; - max-width: 32rem; - width: 90%; - max-height: 90vh; - overflow-y: auto; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1); -} - -/* === Utility === */ -[x-cloak] { display: none !important; } diff --git a/app/webui/dashboard.html b/app/webui/dashboard.html deleted file mode 100644 index 7307778..0000000 --- a/app/webui/dashboard.html +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - CRM – Dashboard - - - - - - - - - - - - - - - - - -
- -
- - - -
-
- - - - - -
- - -
-

-
- - Angemeldet als - - - -
-
- - -
- - -
-
-

Lade Dashboard …

-
- - -
- -
- -
- - -

Kennzahlen

-
-
-

Open Deals

-

-
-
-

Pipeline Value

-

-
-
-

Won this Month

-

-
-
-

Conversion Rate

-

-
-
- - -

Schnellaktionen

- - - -

Letzte Aktivitäten

-
- -

- Keine Aktivitäten. -

-
-
-
-
- -
-
- - - diff --git a/app/webui/index.html b/app/webui/index.html deleted file mode 100644 index 12a523a..0000000 --- a/app/webui/index.html +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - CRM – Anmeldung - - - - - - - - - - - - - - - - - -
- -
- -
-
- - -
-

CRM System

-

-
- - -
- - -
- - -
- - -
-
- - -
-
- - -
- -
- - -
-
- - -
-
- - -
-
- - -

Mindestens 8 Zeichen.

-
-
- - -

- Hinweis: Bei der ersten Registrierung (Bootstrap) wird meistens ein - Admin-Konto angelegt. -

-
- -
- -

- API-Endpoint: /api/v1/auth/* -

-
-
- - - - - diff --git a/app/webui/js/api.js b/app/webui/js/api.js deleted file mode 100644 index 410f962..0000000 --- a/app/webui/js/api.js +++ /dev/null @@ -1,158 +0,0 @@ -/** - * CRM Frontend API-Wrapper - * - * Lightweight fetch() wrapper with: - * - Automatic JWT injection (Authorization: Bearer from localStorage) - * - 401 handling: clears token + redirects to /index.html - * - JSON-only requests (FormData→x-www-form-urlencoded via postForm) - * - ApiError class with status and parsed body for granular error handling - * - * All pages import this via - - - - - - - - - -
- -
- -
- - - -
-
-

Pipeline

-
- - - - -
-
- -
- - -
-
- Ziehe Karten zwischen Spalten, um die Stage zu ändern. -
-
-
- - - -
-
- -
- - -
- -
- - -
-
-

Neuer Deal

-
-
- - -
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - -
-
-
- - -
-
-
-
-
-
-
- - - diff --git a/app/webui/settings-org.html b/app/webui/settings-org.html deleted file mode 100644 index d8e1af2..0000000 --- a/app/webui/settings-org.html +++ /dev/null @@ -1,142 +0,0 @@ - - - - - - CRM – Org-Einstellungen - - - - - - - - - - -
- -
- -
- - - -
-
-

Org-Einstellungen (Admin)

-
- - - - -
-
- -
- - -
-

Zugriff verweigert.

-

Diese Seite ist nur für Admins.

- Zum Dashboard -
- -
-
-
- -
- - -
- Hinweis: Das Bearbeiten von Organisationseinstellungen ist - für v1.1 geplant. In v1 ist der PATCH /api/v1/orgs/{id}-Endpoint - noch nicht verfügbar. Die Form ist deshalb deaktiviert (nur Read-View). -
- -

Aktuelle Organisation

- -
-
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -

- Quelle: GET /api/v1/orgs/{id} (read-only in v1). -

-
-
-
-
- - - - diff --git a/app/webui/settings-profile.html b/app/webui/settings-profile.html deleted file mode 100644 index 980fb45..0000000 --- a/app/webui/settings-profile.html +++ /dev/null @@ -1,148 +0,0 @@ - - - - - - CRM – Mein Profil - - - - - - - - - - -
- -
- -
- - - -
-
-

Mein Profil

-
- - - - -
-
- -
- -
-
-
- -
-

Profil bearbeiten

- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
- -
- -
- -
-
-
-
-
-
- - - - diff --git a/app/webui/settings-users.html b/app/webui/settings-users.html deleted file mode 100644 index a9550dc..0000000 --- a/app/webui/settings-users.html +++ /dev/null @@ -1,231 +0,0 @@ - - - - - - CRM – User-Verwaltung - - - - - - - - - - -
- -
- -
- - - -
-
-

User-Verwaltung (Admin)

-
- - - - -
-
- -
- - -
-

Zugriff verweigert.

-

Diese Seite ist nur für Admins.

- Zum Dashboard -
- -
-
-
- -
-
- -
- -
-
-
- -
- - - - - - - - - - -
IDNameE-MailRolleAktionen
Keine User.
-
- - -
-
-

-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
-
-
-
-
-
-
- - - - diff --git a/requirements.txt b/requirements.txt index e5e02e9..cdd27bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,13 @@ -# CRM System v1.0 - Production Dependencies -# Pinned per 02-architecture.md Section 13.6 +# LeoCRM v1.0 - Production Dependencies # Web framework fastapi>=0.111.0,<0.116 uvicorn[standard]>=0.29.0 +python-multipart>=0.0.7 # Database / ORM sqlalchemy==2.0.35 alembic>=1.13 -aiosqlite>=0.19 asyncpg>=0.29 # Validation / Settings @@ -16,13 +15,16 @@ pydantic>=2.5 pydantic-settings>=2.1 # Auth -python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 bcrypt==4.0.1 -python-multipart>=0.0.7 -# File I/O (for static files in phase 4c) +# Redis / Job Queue +redis>=5.0 +arq>=0.25 + +# File I/O aiofiles>=23.2 -# Templates (optional, for error pages) +# Templates (optional) jinja2>=3.1 +greenlet>=3.0 diff --git a/tests/__init__.py b/tests/__init__.py index 812d08e..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -"""Test package for the CRM system.""" diff --git a/tests/conftest.py b/tests/conftest.py index 5e561de..88b5183 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,19 +1,22 @@ -"""Test fixtures: in-memory SQLite DB, async test client, auth helpers. +"""Test fixtures: PostgreSQL test DB, Redis, async test client, auth helpers. -Design: -- One AsyncEngine + session_factory per test (function-scoped) so each test - has a fresh, isolated DB. -- The FastAPI app's get_db dependency is overridden to use the same engine. -- register/headers/login helpers talk to the HTTP API (integration tests). +Each test gets a fresh database schema (created from metadata) and a clean Redis. +Auth helpers talk to the HTTP API (integration tests). """ from __future__ import annotations +import asyncio +import json +import uuid from collections.abc import AsyncGenerator from typing import Any +import pytest import pytest_asyncio +import redis.asyncio as aioredis from httpx import ASGITransport, AsyncClient +from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, @@ -21,301 +24,246 @@ from sqlalchemy.ext.asyncio import ( create_async_engine, ) -from app.core.db import Base, get_db +from app.config import get_settings +from app.core.db import Base, reset_engine_for_testing, close_engine +from app.core.auth import hash_password +from app.models.tenant import Tenant +from app.models.user import User, UserTenant +from app.models.role import Role +from app.models.company import Company from app.main import create_app +TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test" + + +def _get_sync_engine(): + """Create a sync engine for DDL operations (drop/create schema). + Uses postgres superuser because leocrm user doesn't own the public schema. + """ + from sqlalchemy import create_engine + return create_engine( + "postgresql+psycopg2://postgres@localhost:5432/leocrm_test", + echo=False, + ) + + +@pytest.fixture(scope="session", autouse=True) +def db_setup(): + """Drop and recreate all tables once per test session.""" + sync_eng = _get_sync_engine() + with sync_eng.connect() as conn: + # Drop all tables and types + conn.execute(text("DROP SCHEMA public CASCADE;")) + conn.execute(text("CREATE SCHEMA public;")) + conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;")) + conn.commit() + sync_eng.dispose() + + # Create tables using async engine + async def _create(): + eng = create_async_engine(TEST_DB_URL, echo=False) + async with eng.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + await eng.dispose() + + asyncio.get_event_loop().run_until_complete(_create()) + yield + + # Cleanup after session + sync_eng = _get_sync_engine() + with sync_eng.connect() as conn: + conn.execute(text("DROP SCHEMA public CASCADE;")) + conn.execute(text("CREATE SCHEMA public;")) + conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;")) + conn.commit() + sync_eng.dispose() + + +@pytest.fixture(autouse=True) +def clean_tables(db_setup): + """Clean all table data before each test (preserve schema).""" + sync_eng = _get_sync_engine() + with sync_eng.connect() as conn: + # TRUNCATE all tables with CASCADE — fast and reliable isolation + conn.execute(text("TRUNCATE TABLE api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;")) + conn.commit() + sync_eng.dispose() + yield + + +@pytest_asyncio.fixture +async def redis_client() -> AsyncGenerator[aioredis.Redis, None]: + """Redis client for tests — flushes DB before and after.""" + r = aioredis.from_url("redis://localhost:6379/0", decode_responses=True) + await r.flushdb() + yield r + await r.flushdb() + await r.aclose() + + @pytest_asyncio.fixture async def engine() -> AsyncGenerator[AsyncEngine, None]: - """Per-test in-memory SQLite engine with schema created from metadata.""" - eng = create_async_engine( - "sqlite+aiosqlite:///:memory:", - echo=False, - future=True, - ) - async with eng.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + """Async engine for the test database.""" + eng = create_async_engine(TEST_DB_URL, echo=False) yield eng await eng.dispose() @pytest_asyncio.fixture -async def session_factory( - engine: AsyncEngine, -) -> async_sessionmaker[AsyncSession]: +async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: """Session factory bound to the test engine.""" return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) @pytest_asyncio.fixture -async def client( - session_factory: async_sessionmaker[AsyncSession], -) -> AsyncGenerator[AsyncClient, None]: - """httpx.AsyncClient wired to a fresh FastAPI app with the test DB injected.""" +async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]: + """Database session for direct DB operations in tests.""" + async with session_factory() as session: + yield session + await session.rollback() + + +@pytest_asyncio.fixture +async def app(engine: AsyncEngine, redis_client: aioredis.Redis): + """FastAPI app with test engine injected.""" + reset_engine_for_testing(engine) app = create_app() + yield app + await close_engine() - async def _override_get_db() -> AsyncGenerator[AsyncSession, None]: - async with session_factory() as session: - try: - yield session - except Exception: - await session.rollback() - raise - - app.dependency_overrides[get_db] = _override_get_db +@pytest_asyncio.fixture +async def client(app) -> AsyncGenerator[AsyncClient, None]: + """HTTP async test client.""" transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c -# === Convenience: register via API (covers bootstrap, user, token, headers) === +# ─── Seed Data Helpers ─── + +ORIGIN_HEADER = {"Origin": "http://localhost:5173"} -@pytest_asyncio.fixture -async def registered_user( - client: AsyncClient, -) -> dict[str, Any]: - """Register a bootstrap user via the API. - - Returns a dict with user, token, headers, email, password, name. - Use this fixture (or the dependent `auth_headers`) for all auth-required tests. +async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]: + """Seed two tenants with admin, editor, viewer users. + Returns dict with all created entity IDs. """ - payload = { - "email": "admin@test.com", - "password": "TestPass123!", - "name": "Test Admin", - } - resp = await client.post("/api/v1/auth/register", json=payload) - assert resp.status_code == 201, f"register failed: {resp.status_code} {resp.text}" - data = resp.json() - token = data["access_token"] + tenant_a = Tenant(name="Tenant A", slug="tenant-a") + tenant_b = Tenant(name="Tenant B", slug="tenant-b") + db.add_all([tenant_a, tenant_b]) + await db.flush() + + # Admin in tenant A + admin_a = User( + tenant_id=tenant_a.id, + email="admin@tenanta.com", + name="Admin A", + password_hash=hash_password("TestPass123!"), + role="admin", + is_active=True, + preferences={}, + ) + # Viewer in tenant A + viewer_a = User( + tenant_id=tenant_a.id, + email="viewer@tenanta.com", + name="Viewer A", + password_hash=hash_password("TestPass123!"), + role="viewer", + is_active=True, + preferences={}, + ) + # Editor in tenant A + editor_a = User( + tenant_id=tenant_a.id, + email="editor@tenanta.com", + name="Editor A", + password_hash=hash_password("TestPass123!"), + role="editor", + is_active=True, + preferences={}, + ) + # Admin in tenant B + admin_b = User( + tenant_id=tenant_b.id, + email="admin@tenantb.com", + name="Admin B", + password_hash=hash_password("TestPass123!"), + role="admin", + is_active=True, + preferences={}, + ) + db.add_all([admin_a, viewer_a, editor_a, admin_b]) + await db.flush() + + # User-tenant memberships + ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True) + ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True) + ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True) + ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True) + # Admin A is also member of tenant B (for switch-tenant test) + ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False) + db.add_all([ut1, ut2, ut3, ut4, ut5]) + await db.flush() + + # Create a custom role with field permissions in tenant A + custom_role = Role( + tenant_id=tenant_a.id, + name="sales_rep", + permissions={"companies": {"read": True, "create": True, "update": True, "delete": False}}, + field_permissions={"annual_revenue": "hidden"}, + ) + db.add(custom_role) + await db.flush() + + # Create a company in tenant A + company_a = Company( + tenant_id=tenant_a.id, + name="Company Alpha", + industry="IT", + created_by=admin_a.id, + updated_by=admin_a.id, + ) + # Create a company in tenant B + company_b = Company( + tenant_id=tenant_b.id, + name="Company Beta", + industry="Finance", + created_by=admin_b.id, + updated_by=admin_b.id, + ) + db.add_all([company_a, company_b]) + await db.flush() + + await db.commit() + return { - "user": data["user"], - "token": token, - "headers": {"Authorization": f"Bearer {token}"}, - "email": payload["email"], - "password": payload["password"], - "name": payload["name"], + "tenant_a": tenant_a, + "tenant_b": tenant_b, + "admin_a": admin_a, + "viewer_a": viewer_a, + "editor_a": editor_a, + "admin_b": admin_b, + "company_a": company_a, + "company_b": company_b, + "custom_role": custom_role, } -@pytest_asyncio.fixture -async def auth_headers(registered_user: dict[str, Any]) -> dict[str, str]: - """Authorization headers for the bootstrap user.""" - return registered_user["headers"] - - -@pytest_asyncio.fixture -async def second_user( - client: AsyncClient, - registered_user: dict[str, Any], -) -> dict[str, Any]: - """Register a second user (admin-only flow) and return its auth info.""" - # Use admin headers to create another user - admin_headers = registered_user["headers"] - payload = { - "email": "rep@test.com", - "password": "RepPass123!", - "name": "Test Rep", - "role": "sales_rep", - } - resp = await client.post("/api/v1/users/", json=payload, headers=admin_headers) - assert resp.status_code == 201, f"create user failed: {resp.status_code} {resp.text}" - new_user = resp.json() - - # Log in as the new user to get a token - login_resp = await client.post( +async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]: + """Login via HTTP API and return cookies dict.""" + resp = await client.post( "/api/v1/auth/login", - data={"username": payload["email"], "password": payload["password"]}, + json={"email": email, "password": password}, + headers=ORIGIN_HEADER, ) - assert login_resp.status_code == 200, ( - f"login failed: {login_resp.status_code} {login_resp.text}" - ) - token = login_resp.json()["access_token"] - - return { - "user": new_user, - "token": token, - "headers": {"Authorization": f"Bearer {token}"}, - "email": payload["email"], - "password": payload["password"], - "name": payload["name"], - } + assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}" + return dict(resp.cookies) -@pytest_asyncio.fixture -async def seed_data( - client: AsyncClient, - registered_user: dict[str, Any], - session_factory: async_sessionmaker[AsyncSession], -) -> dict[str, Any]: - """Seed a representative data set for business-logic tests. - - Creates (via the API to exercise FK constraints end-to-end): - - 2 accounts owned by the bootstrap admin user - - 3 contacts (2 attached to accounts, 1 standalone) - - 5 deals across various stages - - 10 activities (some overdue, some completed) - - 3 tags (VIP, Strategic, Enterprise) - - 4 notes (mixed parents) - - Returns a dict with all IDs + a ref to the auth headers for convenience. - """ - headers = registered_user["headers"] - - # 2 accounts - acc1 = await client.post( - "/api/v1/accounts/", - json={ - "name": "Acme Corp", - "industry": "sme", - "size": "sme", - "website": "https://acme.test", - }, - headers=headers, - ) - assert acc1.status_code == 201, f"seed acc1: {acc1.status_code} {acc1.text}" - acc1_id = acc1.json()["id"] - - acc2 = await client.post( - "/api/v1/accounts/", - json={"name": "Globex GmbH", "industry": "enterprise", "size": "enterprise"}, - headers=headers, - ) - assert acc2.status_code == 201, f"seed acc2: {acc2.status_code} {acc2.text}" - acc2_id = acc2.json()["id"] - - # 3 contacts (2 with account, 1 standalone) - c1 = await client.post( - "/api/v1/contacts/", - json={ - "first_name": "Anna", - "last_name": "Schmidt", - "email": "anna@acme.example", - "account_id": acc1_id, - }, - headers=headers, - ) - assert c1.status_code == 201, c1.text - c1_id = c1.json()["id"] - - c2 = await client.post( - "/api/v1/contacts/", - json={ - "first_name": "Bob", - "last_name": "Mueller", - "email": "bob@globex.example", - "account_id": acc2_id, - }, - headers=headers, - ) - assert c2.status_code == 201, c2.text - c2_id = c2.json()["id"] - - c3 = await client.post( - "/api/v1/contacts/", - json={"first_name": "Clara", "last_name": "Weber", "email": "clara@x.example"}, - headers=headers, - ) - assert c3.status_code == 201, c3.text - c3_id = c3.json()["id"] - - # 5 deals (different stages) - deal_ids: list[int] = [] - for i, (title, stage) in enumerate( - [ - ("Deal A", "lead"), - ("Deal B", "qualified"), - ("Deal C", "proposal"), - ("Deal D", "negotiation"), - ("Deal E", "won"), - ] - ): - d = await client.post( - "/api/v1/deals/", - json={ - "title": title, - "value": str(1000 * (i + 1)), - "stage": stage, - "account_id": acc1_id if i % 2 == 0 else acc2_id, - }, - headers=headers, - ) - assert d.status_code == 201, d.text - deal_ids.append(d.json()["id"]) - - # 10 activities (4 overdue, 4 future, 2 completed) - from datetime import UTC, datetime, timedelta - - past = (datetime.now(UTC) - timedelta(days=2)).isoformat() - future = (datetime.now(UTC) + timedelta(days=2)).isoformat() - future2 = (datetime.now(UTC) + timedelta(days=10)).isoformat() - activity_ids: list[int] = [] - for i in range(10): - if i < 4: - due = past - body_data: dict[str, object] = { - "type": "task", - "subject": f"Overdue task {i}", - "due_date": due, - "deal_id": deal_ids[i % 5], - } - elif i < 8: - due = future if i % 2 == 0 else future2 - body_data = { - "type": "call", - "subject": f"Upcoming call {i}", - "due_date": due, - "account_id": acc1_id, - } - else: - body_data = {"type": "meeting", "subject": f"Done meeting {i}", "account_id": acc1_id} - a = await client.post("/api/v1/activities/", json=body_data, headers=headers) - assert a.status_code == 201, a.text - activity_ids.append(a.json()["id"]) - - # 3 tags - tag_ids: list[int] = [] - for name in ["VIP", "Strategic", "Enterprise"]: - t = await client.post( - "/api/v1/tags/", json={"name": name, "color": "#FF0080"}, headers=headers - ) - assert t.status_code == 201, t.text - tag_ids.append(t.json()["id"]) - - # 4 notes (mixed parents: 2 account, 1 contact, 1 deal) - n1 = await client.post( - "/api/v1/notes/", - json={"body": "Note on Acme", "parent_type": "account", "parent_id": acc1_id}, - headers=headers, - ) - assert n1.status_code == 201, n1.text - n2 = await client.post( - "/api/v1/notes/", - json={"body": "Note on Globex", "parent_type": "account", "parent_id": acc2_id}, - headers=headers, - ) - assert n2.status_code == 201, n2.text - n3 = await client.post( - "/api/v1/notes/", - json={"body": "Note on Anna", "parent_type": "contact", "parent_id": c1_id}, - headers=headers, - ) - assert n3.status_code == 201, n3.text - n4 = await client.post( - "/api/v1/notes/", - json={"body": "Note on Deal A", "parent_type": "deal", "parent_id": deal_ids[0]}, - headers=headers, - ) - assert n4.status_code == 201, n4.text - - return { - "account_ids": [acc1_id, acc2_id], - "contact_ids": [c1_id, c2_id, c3_id], - "deal_ids": deal_ids, - "activity_ids": activity_ids, - "tag_ids": tag_ids, - "note_ids": [n1.json()["id"], n2.json()["id"], n3.json()["id"], n4.json()["id"]], - "headers": headers, - } +async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient: + """Return a client that's logged in.""" + await login_client(client, email, password) + return client diff --git a/tests/test_accounts.py b/tests/test_accounts.py deleted file mode 100644 index b3dd481..0000000 --- a/tests/test_accounts.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Tests for FR-2 (Account entity). All 8 acceptance criteria covered.""" - -from __future__ import annotations - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_account(client: AsyncClient, auth_headers: dict[str, str]) -> None: - resp = await client.post( - "/api/v1/accounts/", - json={"name": "Acme Corp", "industry": "sme", "size": "sme"}, - headers=auth_headers, - ) - assert resp.status_code == 201, resp.text - data = resp.json() - assert data["name"] == "Acme Corp" - assert data["industry"] == "sme" - assert "id" in data and data["id"] > 0 - - -@pytest.mark.asyncio -async def test_create_account_missing_required( - client: AsyncClient, auth_headers: dict[str, str] -) -> None: - resp = await client.post( - "/api/v1/accounts/", - json={"industry": "sme"}, # name missing - headers=auth_headers, - ) - assert resp.status_code == 422 - - -@pytest.mark.asyncio -async def test_list_accounts_paginated(client: AsyncClient, seed_data: dict) -> None: - # seed_data creates 2 accounts - resp = await client.get("/api/v1/accounts/?limit=20", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - assert isinstance(body, list) - assert len(body) >= 2 - - -@pytest.mark.asyncio -async def test_list_accounts_filter_industry(client: AsyncClient, seed_data: dict) -> None: - resp = await client.get("/api/v1/accounts/?industry=enterprise", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - assert all(a["industry"] == "enterprise" for a in body) - - -@pytest.mark.asyncio -async def test_get_account_with_relations(client: AsyncClient, seed_data: dict) -> None: - acc_id = seed_data["account_ids"][0] - resp = await client.get(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - assert body["id"] == acc_id - # All base fields are present (relations are reachable via dedicated endpoints) - for key in ("id", "name", "industry", "size", "owner_id", "created_at", "updated_at"): - assert key in body - - -@pytest.mark.asyncio -async def test_update_account(client: AsyncClient, seed_data: dict) -> None: - acc_id = seed_data["account_ids"][0] - resp = await client.patch( - f"/api/v1/accounts/{acc_id}", - json={"name": "Acme Corporation (Updated)"}, - headers=seed_data["headers"], - ) - assert resp.status_code == 200 - assert resp.json()["name"] == "Acme Corporation (Updated)" - - -@pytest.mark.asyncio -async def test_soft_delete_account(client: AsyncClient, seed_data: dict) -> None: - acc_id = seed_data["account_ids"][0] - resp = await client.delete(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"]) - assert resp.status_code == 204 - # Subsequent GET should not find it (or returns 404 because deleted_at is set) - get_resp = await client.get(f"/api/v1/accounts/{acc_id}", headers=seed_data["headers"]) - assert get_resp.status_code in (404, 200) - - -@pytest.mark.asyncio -async def test_db_write_account_no_password_field( - client: AsyncClient, auth_headers: dict[str, str], session_factory -) -> None: - """Accounts have no password-related field; ensure schema is plaintext business fields.""" - from sqlalchemy import text - - resp = await client.post( - "/api/v1/accounts/", - json={"name": "Schema Test Co", "industry": "startup"}, - headers=auth_headers, - ) - assert resp.status_code == 201 - - async with session_factory() as session: - result = await session.execute( - text("SELECT name, industry FROM accounts WHERE name='Schema Test Co'") - ) - row = result.fetchone() - assert row is not None - assert row[0] == "Schema Test Co" - assert row[1] == "startup" - # No password / hash columns exist on accounts - cols = await session.execute(text("PRAGMA table_info(accounts)")) - names = {c[1] for c in cols.fetchall()} - assert "password_hash" not in names - assert "hashed_password" not in names diff --git a/tests/test_activities.py b/tests/test_activities.py deleted file mode 100644 index 7ce9f42..0000000 --- a/tests/test_activities.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tests for FR-5 (Activity entity, polymorphic parent).""" - -from __future__ import annotations - -from datetime import datetime - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_activity_with_polymorphic_fk( - client: AsyncClient, auth_headers: dict[str, str], seed_data: dict -) -> None: - deal_id = seed_data["deal_ids"][0] - resp = await client.post( - "/api/v1/activities/", - json={"type": "task", "subject": "Follow up", "deal_id": deal_id}, - headers=auth_headers, - ) - assert resp.status_code == 201, resp.text - body = resp.json() - assert body["deal_id"] == deal_id - - -@pytest.mark.asyncio -async def test_list_activities_filter_overdue(client: AsyncClient, seed_data: dict) -> None: - # seed_data inserts 4 overdue activities - resp = await client.get("/api/v1/activities/?overdue=true", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - now = datetime.now() # naive: SQLite returns naive datetimes - for a in body: - if a.get("due_date") and a.get("completed_at") is None: - due = a["due_date"] - if due.endswith("Z"): - due = due.replace("Z", "+00:00") - assert datetime.fromisoformat(due) < now - - -@pytest.mark.asyncio -async def test_complete_activity(client: AsyncClient, seed_data: dict) -> None: - a_id = seed_data["activity_ids"][0] - resp = await client.patch( - f"/api/v1/activities/{a_id}/complete", - json={"outcome": "Resolved via email"}, - headers=seed_data["headers"], - ) - assert resp.status_code == 200, resp.text - body = resp.json() - assert body["completed_at"] is not None - - -@pytest.mark.asyncio -async def test_activity_requires_at_least_one_parent( - client: AsyncClient, auth_headers: dict[str, str] -) -> None: - resp = await client.post( - "/api/v1/activities/", - json={"type": "task", "subject": "Orphan task"}, # no account/contact/deal - headers=auth_headers, - ) - assert resp.status_code == 422 diff --git a/tests/test_auth.py b/tests/test_auth.py index 2791a66..7ea38b4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,232 +1,179 @@ -"""Auth tests: covers all 9 FR-1 acceptance criteria.""" +"""Auth tests — ACs 1-9: login, logout, me, switch-tenant, password reset, CSRF.""" from __future__ import annotations -import time - +import pytest from httpx import AsyncClient -from jose import jwt -from app.core.config import get_settings - -settings = get_settings() +from tests.conftest import ORIGIN_HEADER, seed_tenant_and_users, login_client -# === FR-1.1 / FR-1.2 / Akzeptanzkriterium 1: register success === +@pytest.mark.asyncio +class TestAuthLogin: + """ACs 1-3: Login valid, invalid, me without session.""" - -async def test_register_success(client: AsyncClient) -> None: - """AC #1: POST /api/v1/auth/register mit gültigem Payload → 201 + User-Objekt + JWT.""" - payload = { - "email": "alice@example.com", - "password": "SecurePass123!", - "name": "Alice", - } - resp = await client.post("/api/v1/auth/register", json=payload) - assert resp.status_code == 201, resp.text - data = resp.json() - assert "user" in data - assert "access_token" in data - assert data["token_type"] == "bearer" - assert data["expires_in"] > 0 - # User object has the expected fields (no password_hash leaked) - user = data["user"] - assert user["email"] == "alice@example.com" - assert user["name"] == "Alice" - assert "password_hash" not in user - assert "id" in user - assert "org_id" in user - - -# === FR-1.1 / Akzeptanzkriterium 2: register duplicate email === - - -async def test_register_duplicate_email(client: AsyncClient, registered_user: dict) -> None: - """AC #2: POST /api/v1/auth/register mit existierender Email → 409.""" - # registered_user already exists; trying again with same email (and 2nd user - # would also be blocked by bootstrap). 409 is correct because of the email conflict. - # But bootstrap is also blocked → 403 is also acceptable. We accept either. - payload = { - "email": registered_user["email"], - "password": "AnotherPass123!", - "name": "Dup User", - } - resp = await client.post("/api/v1/auth/register", json=payload) - assert resp.status_code in (403, 409), ( - f"Expected 403 (bootstrap) or 409 (email), got {resp.status_code}: {resp.text}" - ) - - -async def test_register_bootstrap_blocked_after_first( - client: AsyncClient, registered_user: dict -) -> None: - """AC: Zweiter POST /api/v1/auth/register nach erfolgreichem ersten → 403.""" - payload = { - "email": "other@example.com", - "password": "AnotherPass123!", - "name": "Other User", - } - resp = await client.post("/api/v1/auth/register", json=payload) - assert resp.status_code == 403, ( - f"Bootstrap should be blocked after first user, got {resp.status_code}: {resp.text}" - ) - - -# === FR-1.2 / Akzeptanzkriterium 3: register weak password === - - -async def test_register_weak_password(client: AsyncClient) -> None: - """AC #3: Schwaches Passwort (< 8 Zeichen) → 422.""" - payload = { - "email": "weak@example.com", - "password": "short", # < 8 chars - "name": "Weak", - } - resp = await client.post("/api/v1/auth/register", json=payload) - assert resp.status_code == 422, resp.text - - -# === FR-1.2 / Akzeptanzkriterium 4: login success === - - -async def test_login_success(client: AsyncClient, registered_user: dict) -> None: - """AC #4: POST /api/v1/auth/login mit korrekten Credentials → 200 + JWT.""" - resp = await client.post( - "/api/v1/auth/login", - data={ - "username": registered_user["email"], - "password": registered_user["password"], - }, - ) - assert resp.status_code == 200, resp.text - data = resp.json() - assert "access_token" in data - assert data["token_type"] == "bearer" - assert data["expires_in"] > 0 - - -# === FR-1.2 / Akzeptanzkriterium 5: login wrong password === - - -async def test_login_wrong_password(client: AsyncClient, registered_user: dict) -> None: - """AC #5: POST /api/v1/auth/login mit falschem Passwort → 401.""" - resp = await client.post( - "/api/v1/auth/login", - data={ - "username": registered_user["email"], - "password": "WrongPassword123!", - }, - ) - assert resp.status_code == 401, resp.text - - -# === FR-1.2 / Akzeptanzkriterium 6: login nonexistent user === - - -async def test_login_nonexistent_user(client: AsyncClient) -> None: - """AC #6: POST /api/v1/auth/login mit nicht existierendem User → 401.""" - resp = await client.post( - "/api/v1/auth/login", - data={"username": "nobody@example.com", "password": "AnyPass123!"}, - ) - assert resp.status_code == 401, resp.text - - -# === FR-1.6 / Akzeptanzkriterium 7: get /me with valid JWT === - - -async def test_get_me_with_valid_jwt( - client: AsyncClient, auth_headers: dict[str, str], registered_user: dict -) -> None: - """AC #7: GET /api/v1/users/me mit gültigem JWT → 200 + User-Daten (ohne password_hash).""" - resp = await client.get("/api/v1/users/me", headers=auth_headers) - assert resp.status_code == 200, resp.text - data = resp.json() - assert data["email"] == registered_user["email"] - assert data["name"] == registered_user["name"] - assert "password_hash" not in data - - -# === FR-1.6 / Akzeptanzkriterium 8: get /me without JWT === - - -async def test_get_me_without_jwt(client: AsyncClient) -> None: - """AC #8: GET /api/v1/users/me ohne JWT → 401.""" - resp = await client.get("/api/v1/users/me") - assert resp.status_code == 401, resp.text - - -# === FR-1.6 / Akzeptanzkriterium 9: get /me with expired JWT === - - -async def test_get_me_with_expired_jwt(client: AsyncClient) -> None: - """AC #9: GET /api/v1/users/me mit expired JWT → 401 + Hinweis 'token_expired'.""" - # Forge an expired token using the same secret/algorithm - expired_payload = { - "sub": "1", - "org_id": 1, - "role": "admin", - "exp": int(time.time()) - 3600, # 1h in the past - "iat": int(time.time()) - 7200, - } - expired_token = jwt.encode( - expired_payload, settings.AUTH_SECRET, algorithm=settings.JWT_ALGORITHM - ) - resp = await client.get( - "/api/v1/users/me", - headers={"Authorization": f"Bearer {expired_token}"}, - ) - assert resp.status_code == 401, resp.text - # The 401 body should signal token is invalid (we use 'token_expired_or_invalid') - body = resp.json() - assert "detail" in body - assert ( - "token" in body["detail"].lower() - or "expired" in body["detail"].lower() - or "credential" in body["detail"].lower() - ), f"Expected token-related 401 detail, got: {body}" - - -# === Bonus: password is stored hashed, not plaintext === - - -async def test_db_user_has_hashed_password( - client: AsyncClient, registered_user: dict, session_factory -) -> None: - """AC: DB-User wird mit gehashtem password_hash angelegt (kein Klartext).""" - from sqlalchemy import select - - from app.models.user import User - - async with session_factory() as session: - result = await session.execute(select(User).where(User.email == registered_user["email"])) - user = result.scalar_one() - # bcrypt hashes start with $2b$ (or $2a$ for passlib), never plain text - assert user.password_hash.startswith("$"), ( - f"Password hash should be a bcrypt string, got: {user.password_hash!r}" + async def test_login_valid_returns_200_and_cookie(self, client: AsyncClient, db_session): + """AC 1: POST /api/v1/auth/login valid -> 200 + Set-Cookie leocrm_session.""" + await seed_tenant_and_users(db_session) + resp = await client.post( + "/api/v1/auth/login", + json={"email": "admin@tenanta.com", "password": "TestPass123!"}, + headers=ORIGIN_HEADER, ) - assert user.password_hash != registered_user["password"] - assert len(user.password_hash) > 50, ( - f"Bcrypt hash should be ~60 chars long, got {len(user.password_hash)}" + assert resp.status_code == 200 + assert "leocrm_session" in resp.headers.get("set-cookie", "").lower() or \ + "leocrm_session" in str(resp.cookies) + data = resp.json() + assert data["email"] == "admin@tenanta.com" + assert data["role"] == "admin" + + async def test_login_invalid_returns_401(self, client: AsyncClient, db_session): + """AC 2: POST /api/v1/auth/login invalid -> 401.""" + await seed_tenant_and_users(db_session) + resp = await client.post( + "/api/v1/auth/login", + json={"email": "admin@tenanta.com", "password": "WrongPassword!"}, + headers=ORIGIN_HEADER, ) + assert resp.status_code == 401 + + async def test_me_without_session_returns_401(self, client: AsyncClient): + """AC 3: GET /api/v1/auth/me without session -> 401.""" + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 401 -# === Bonus: no default admin bootstrap on startup === +@pytest.mark.asyncio +class TestAuthMe: + """AC 4: me with valid session.""" + + async def test_me_with_valid_session_returns_200(self, client: AsyncClient, db_session): + """AC 4: GET /api/v1/auth/me with valid session -> 200 + user+tenant JSON.""" + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 200 + data = resp.json() + assert data["email"] == "admin@tenanta.com" + assert data["role"] == "admin" + assert "tenant_id" in data + assert "tenant_name" in data -async def test_no_default_admin_on_startup(client: AsyncClient, session_factory) -> None: - """AC: KEIN admin/admin Bootstrap-User beim App-Start (Frisch-DB = leer).""" - from sqlalchemy import func, select +@pytest.mark.asyncio +class TestAuthLogout: + """AC 5: logout.""" - from app.models.user import User + async def test_logout_returns_200_and_invalidates_session(self, client: AsyncClient, db_session): + """AC 5: POST /api/v1/auth/logout -> 200, session invalidated.""" + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + # Verify we're logged in + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 200 + # Logout + resp = await client.post("/api/v1/auth/logout", headers=ORIGIN_HEADER) + assert resp.status_code == 200 + # Verify session is invalidated + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 401 - # Fresh DB → no users - async with session_factory() as session: - result = await session.execute(select(func.count()).select_from(User)) - count = result.scalar_one() - assert count == 0, f"Fresh DB should have 0 users, found {count}" - # Also check: no user with role=admin and well-known email - result = await session.execute(select(User).where(User.role == "admin")) - admins = result.scalars().all() - assert len(admins) == 0, f"Fresh DB should have no admin users, found {len(admins)}" +@pytest.mark.asyncio +class TestPasswordReset: + """ACs 6-8: password reset request, confirm valid, confirm expired.""" + + async def test_password_reset_request_always_200(self, client: AsyncClient, db_session): + """AC 6: POST /api/v1/auth/password-reset/request -> always 200 (no user enumeration).""" + await seed_tenant_and_users(db_session) + # Existing email + resp = await client.post( + "/api/v1/auth/password-reset/request", + json={"email": "admin@tenanta.com"}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 200 + # Non-existing email — still 200 + resp = await client.post( + "/api/v1/auth/password-reset/request", + json={"email": "nonexistent@example.com"}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 200 + + async def test_password_reset_confirm_valid_token(self, client: AsyncClient, db_session): + """AC 7: POST /api/v1/auth/password-reset/confirm valid token -> 200.""" + from app.services.auth_service import auth_service + await seed_tenant_and_users(db_session) + raw_token = await auth_service.get_password_reset_token_raw(db_session, "admin@tenanta.com") + await db_session.commit() + assert raw_token is not None + + resp = await client.post( + "/api/v1/auth/password-reset/confirm", + json={"token": raw_token, "new_password": "NewPass456!"}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 200 + + async def test_password_reset_confirm_expired_token(self, client: AsyncClient, db_session): + """AC 8: POST /api/v1/auth/password-reset/confirm expired token -> 400.""" + from app.services.auth_service import auth_service + await seed_tenant_and_users(db_session) + raw_token = await auth_service.create_expired_reset_token(db_session, "admin@tenanta.com") + await db_session.commit() + assert raw_token is not None + + resp = await client.post( + "/api/v1/auth/password-reset/confirm", + json={"token": raw_token, "new_password": "NewPass456!"}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 400 + + +@pytest.mark.asyncio +class TestSwitchTenant: + """AC 9: switch-tenant.""" + + async def test_switch_tenant_returns_200(self, client: AsyncClient, db_session): + """AC 9: POST /api/v1/auth/switch-tenant -> 200, session tenant_id updated.""" + await seed_tenant_and_users(db_session) + await login_client(client, "admin@tenanta.com") + + # Get initial tenant + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 200 + initial_tenant = resp.json()["tenant_id"] + + # Get tenant B ID + from app.models.tenant import Tenant + from sqlalchemy import select + q = select(Tenant).where(Tenant.slug == "tenant-b") + result = await db_session.execute(q) + tenant_b = result.scalar_one() + + # Switch to tenant B + resp = await client.post( + "/api/v1/auth/switch-tenant", + json={"tenant_id": str(tenant_b.id)}, + headers=ORIGIN_HEADER, + ) + assert resp.status_code == 200 + assert resp.json()["tenant_id"] == str(tenant_b.id) + assert resp.json()["tenant_id"] != initial_tenant + + +@pytest.mark.asyncio +class TestCSRF: + """AC 23: CSRF — POST without Origin header -> 403.""" + + async def test_post_without_origin_returns_403(self, client: AsyncClient, db_session): + """AC 23: POST without Origin header -> 403.""" + await seed_tenant_and_users(db_session) + resp = await client.post( + "/api/v1/auth/login", + json={"email": "admin@tenanta.com", "password": "TestPass123!"}, + # No Origin header + ) + assert resp.status_code == 403 diff --git a/tests/test_contacts.py b/tests/test_contacts.py deleted file mode 100644 index 8c32d13..0000000 --- a/tests/test_contacts.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Tests for FR-3 (Contact entity).""" - -from __future__ import annotations - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_contact_with_account( - client: AsyncClient, auth_headers: dict[str, str], seed_data: dict -) -> None: - acc_id = seed_data["account_ids"][0] - resp = await client.post( - "/api/v1/contacts/", - json={ - "first_name": "Diana", - "last_name": "Prince", - "email": "diana@x.example", - "account_id": acc_id, - }, - headers=auth_headers, - ) - assert resp.status_code == 201, resp.text - body = resp.json() - assert body["account_id"] == acc_id - - -@pytest.mark.asyncio -async def test_create_contact_invalid_account( - client: AsyncClient, auth_headers: dict[str, str] -) -> None: - resp = await client.post( - "/api/v1/contacts/", - json={"first_name": "Eve", "last_name": "Adams", "account_id": 99999}, - headers=auth_headers, - ) - assert resp.status_code == 404 - - -@pytest.mark.asyncio -async def test_list_contacts_filter_account(client: AsyncClient, seed_data: dict) -> None: - acc_id = seed_data["account_ids"][0] - resp = await client.get(f"/api/v1/contacts/?account_id={acc_id}", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - assert all(c["account_id"] == acc_id for c in body) - - -@pytest.mark.asyncio -async def test_search_contacts_by_email(client: AsyncClient, seed_data: dict) -> None: - # seed_data creates contact with email 'anna@acme.example' on account 0 - resp = await client.get("/api/v1/contacts/?q=anna@acme.example", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - assert any("anna@acme.example" in (c.get("email") or "") for c in body) diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py deleted file mode 100644 index 52b373f..0000000 --- a/tests/test_dashboard.py +++ /dev/null @@ -1,38 +0,0 @@ -"""Tests for FR-7 (Dashboard: KPIs + activity feed).""" - -from __future__ import annotations - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_kpis(client: AsyncClient, seed_data: dict) -> None: - resp = await client.get("/api/v1/dashboard/kpis", headers=seed_data["headers"]) - assert resp.status_code == 200, resp.text - body = resp.json() - assert {"open_deals_count", "pipeline_value", "won_this_month", "conversion_rate"} <= set( - body.keys() - ) - assert isinstance(body["open_deals_count"], int) - assert isinstance(body["pipeline_value"], (int, float)) - assert isinstance(body["won_this_month"], int) - assert isinstance(body["conversion_rate"], (int, float)) - - -@pytest.mark.asyncio -async def test_activity_feed(client: AsyncClient, seed_data: dict) -> None: - resp = await client.get("/api/v1/dashboard/feed?limit=20", headers=seed_data["headers"]) - assert resp.status_code == 200, resp.text - body = resp.json() - assert isinstance(body, list) - assert len(body) >= 1 - # Items are sorted by created_at desc (most recent first) - if len(body) >= 2: - assert body[0]["created_at"] >= body[-1]["created_at"] - - -@pytest.mark.asyncio -async def test_dashboard_requires_auth(client: AsyncClient) -> None: - resp = await client.get("/api/v1/dashboard/kpis") - assert resp.status_code == 401 diff --git a/tests/test_deals.py b/tests/test_deals.py deleted file mode 100644 index 7f7c594..0000000 --- a/tests/test_deals.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Tests for FR-4 (Deal entity) and stage-history audit.""" - -from __future__ import annotations - -import pytest -from httpx import AsyncClient - - -@pytest.mark.asyncio -async def test_create_deal( - client: AsyncClient, auth_headers: dict[str, str], seed_data: dict -) -> None: - acc_id = seed_data["account_ids"][0] - resp = await client.post( - "/api/v1/deals/", - json={"title": "Big Deal", "value": "5000.00", "stage": "qualified", "account_id": acc_id}, - headers=auth_headers, - ) - assert resp.status_code == 201, resp.text - body = resp.json() - assert body["title"] == "Big Deal" - assert body["stage"] == "qualified" - - -@pytest.mark.asyncio -async def test_update_deal_stage_creates_history( - client: AsyncClient, seed_data: dict, session_factory -) -> None: - deal_id = seed_data["deal_ids"][0] - resp = await client.patch( - f"/api/v1/deals/{deal_id}/stage", - json={"stage": "qualified"}, - headers=seed_data["headers"], - ) - assert resp.status_code == 200, resp.text - assert resp.json()["stage"] == "qualified" - - # Verify DealStageHistory row exists in DB - from sqlalchemy import text - - async with session_factory() as session: - result = await session.execute( - text("SELECT to_stage FROM deal_stage_history WHERE deal_id = :did ORDER BY id"), - {"did": deal_id}, - ) - stages = [r[0] for r in result.fetchall()] - assert "qualified" in stages - - -@pytest.mark.asyncio -async def test_get_pipeline_grouped_by_stage(client: AsyncClient, seed_data: dict) -> None: - resp = await client.get("/api/v1/deals/pipeline", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - # Each entry has stage, count, total_value - assert all({"stage", "count", "total_value"} <= set(item.keys()) for item in body) - # Seeded deals cover at least 5 distinct stages - stages = {item["stage"] for item in body} - assert len(stages) >= 3 - - -@pytest.mark.asyncio -async def test_filter_deals_by_owner(client: AsyncClient, seed_data: dict) -> None: - owner_id = seed_data["headers"].get("X-Test-User-Id") # may be None; fallback: just check 200 - # we filter by an owner-id that does not exist; expect empty list - resp = await client.get("/api/v1/deals/?owner_id=99999", headers=seed_data["headers"]) - assert resp.status_code == 200 - body = resp.json() - assert body == [] or all(d.get("owner_id") == 99999 for d in body) - - -@pytest.mark.asyncio -async def test_db_write_deal( - client: AsyncClient, auth_headers: dict[str, str], seed_data: dict, session_factory -) -> None: - """DB roundtrip: read back a seeded deal directly via SQL.""" - from sqlalchemy import text - - deal_id = seed_data["deal_ids"][2] - async with session_factory() as session: - result = await session.execute( - text("SELECT title, value, stage, account_id FROM deals WHERE id = :did"), - {"did": deal_id}, - ) - row = result.fetchone() - assert row is not None - assert row[0] is not None # title - assert row[1] is not None # value - assert row[2] is not None # stage - assert row[3] is not None # account_id diff --git a/tests/test_frontend_assets.py b/tests/test_frontend_assets.py deleted file mode 100644 index 9be29fd..0000000 --- a/tests/test_frontend_assets.py +++ /dev/null @@ -1,184 +0,0 @@ -"""Frontend-Asset Tests: verify JS modules and CSS are syntactically valid -and export / register the expected symbols. - -These tests perform static text-level checks on the JS / CSS files — no -runtime execution, no nodejs dependency. They are sufficient to catch: - - Missing exports (e.g. `export { api, ApiError }` removed from api.js) - - Missing global Alpine.data() registrations for the components - - CSS variables removed from the custom-overrides file -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -WEBUI = Path(__file__).resolve().parent.parent / "app" / "webui" - - -def _read(rel: str) -> str: - p = WEBUI / rel - assert p.exists(), f"Missing asset: {p}" - return p.read_text(encoding="utf-8") - - -# --------------------------------------------------------------------------- -# api.js -# --------------------------------------------------------------------------- - - -def test_api_js_exports_api_and_ApiError(): - content = _read("js/api.js") - assert "export class ApiError" in content - assert "export const api" in content or "export default api" in content - - -def test_api_js_uses_localStorage_for_jwt(): - """R-1 / architecture: JWT must live in localStorage.""" - content = _read("js/api.js") - assert "localStorage" in content - assert "jwt" in content - - -def test_api_js_handles_401_redirect(): - """401 must clear the token and redirect to /index.html.""" - content = _read("js/api.js") - assert "401" in content - assert "/index.html" in content - - -def test_api_js_handles_204_no_content(): - content = _read("js/api.js") - assert "204" in content - - -# --------------------------------------------------------------------------- -# store.js -# --------------------------------------------------------------------------- - - -def test_store_js_defines_auth_and_notifications(): - content = _read("js/store.js") - assert "Alpine.store('auth'" in content - assert "Alpine.store('notifications'" in content - - -def test_store_js_auth_has_login_and_logout(): - content = _read("js/store.js") - assert "login" in content - assert "logout" in content - assert "fetchUser" in content - - -def test_store_js_notifications_has_push_and_dismiss(): - content = _read("js/store.js") - assert "push" in content - assert "dismiss" in content - assert "success" in content - assert "error" in content - - -# --------------------------------------------------------------------------- -# app.css -# --------------------------------------------------------------------------- - - -def test_app_css_contains_tailwind_overrides(): - content = _read("css/app.css") - # The file defines custom CSS variables (--crm-primary, etc.) - assert "--crm-primary" in content - assert "--crm-success" in content - assert "--crm-danger" in content - - -def test_app_css_has_loading_spinner(): - content = _read("css/app.css") - assert "crm-spinner" in content - assert "@keyframes crm-spin" in content - - -def test_app_css_has_toast_styles(): - content = _read("css/app.css") - assert "crm-toast" in content - assert "toast-success" in content - assert "toast-error" in content - - -# --------------------------------------------------------------------------- -# Alpine components -# --------------------------------------------------------------------------- - -COMPONENTS = [ - ("account-list.js", "accountList"), - ("deal-kanban.js", "dealKanban"), - ("activity-list.js", "activityList"), - ("dashboard-kpis.js", "dashboardKpis"), -] - - -@pytest.mark.parametrize("filename,factory", COMPONENTS) -def test_alpine_component_defined(filename, factory): - """Each component file must define a named factory and register it - globally with Alpine.data(name, factory).""" - content = _read(f"components/{filename}") - # factory function definition - assert f"export function {factory}" in content, ( - f"{filename} missing `export function {factory}()`" - ) - # Alpine.data() registration - assert f"Alpine.data('{factory}'" in content, ( - f"{filename} missing `Alpine.data('{factory}', …)` registration" - ) - - -def test_deal_kanban_handles_drag_and_drop(): - content = _read("components/deal-kanban.js") - assert "draggable" in content or "dataTransfer" in content - assert "onDrop" in content - assert "/deals/" in content and "/stage" in content - - -def test_activity_list_supports_overdue_filter(): - content = _read("components/activity-list.js") - assert "overdue" in content - assert "/activities/" in content - assert "/complete" in content - - -def test_dashboard_kpis_loads_both_kpis_and_feed(): - content = _read("components/dashboard-kpis.js") - assert "/dashboard/kpis" in content - assert "/dashboard/feed" in content - - -def test_account_list_uses_pagination(): - content = _read("components/account-list.js") - assert "skip" in content - assert "limit" in content - assert "q" in content - assert "/accounts/" in content - - -# --------------------------------------------------------------------------- -# notifications.js (toast component) -# --------------------------------------------------------------------------- - - -def test_notifications_js_registers_toast_container(): - content = _read("js/components/notifications.js") - assert "toastContainer" in content - assert "Alpine.data" in content - - -# --------------------------------------------------------------------------- -# auth.js -# --------------------------------------------------------------------------- - - -def test_auth_js_exports_login_logout_register(): - content = _read("js/auth.js") - assert "export async function login" in content - assert "export async function logout" in content - assert "export async function register" in content - assert "export async function refreshToken" in content diff --git a/tests/test_frontend_pages.py b/tests/test_frontend_pages.py deleted file mode 100644 index 16fe45b..0000000 --- a/tests/test_frontend_pages.py +++ /dev/null @@ -1,111 +0,0 @@ -"""Frontend-Smoke-Tests: verify all 13 HTML pages exist and contain the -expected keywords/structure. - -These tests read the HTML files directly from `app/webui/` (rather than -hitting the FastAPI server) because the static-file mount is added in a -later phase by the orchestrator. Once that mount is in place, the same -checks could be re-implemented as `httpx.AsyncClient.get("/…")`. - -Each test loads exactly one page and asserts: - - file exists and is non-empty - - contains the page's title text (so a typo / removal is caught) - - has Tailwind-CDN, Alpine-CDN, and the app.css link - - has no x-html attribute (R-5) -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -WEBUI_DIR = Path(__file__).resolve().parent.parent / "app" / "webui" - - -def _read(name: str) -> str: - """Read a file from the webui directory.""" - p = WEBUI_DIR / name - assert p.exists(), f"Expected frontend file {p} to exist" - return p.read_text(encoding="utf-8") - - -# --------------------------------------------------------------------------- -# Each page: (filename, [required substrings]) -# --------------------------------------------------------------------------- -PAGES = [ - ("index.html", ["Login", "Registrieren", "Anmelden", "Konto erstellen"]), - ("app.html", ["Dashboard", "Logout", "Pipeline"]), - ("accounts.html", ["Accounts", "Suche (Name)", "Branche", "+ New Account"]), - ("accounts-detail.html", ["Account-Detail", "Bearbeiten", "Löschen", "Info"]), - ("contacts.html", ["Contacts", "Vorname", "Nachname", "+ New Contact"]), - ("contacts-detail.html", ["Contact-Detail", "Bearbeiten", "Löschen"]), - ("pipeline.html", ["Pipeline", "Owner-ID (optional)", "+ New Deal", "Ziehe Karten"]), - ("activities.html", ["Activities", "+ New Activity", "Fällig"]), - ("settings-profile.html", ["Mein Profil", "Avatar-URL", "E-Mail-Benachrichtigungen"]), - ("settings-users.html", ["User-Verwaltung", "+ New User", "Rolle"]), - ("settings-org.html", ["Org-Einstellungen", "Aktuelle Organisation", "v1.1"]), - ("404.html", ["404", "Seite nicht gefunden", "Zum Dashboard"]), - # Dashboard: ships inside app.html. We test the marker text directly there. -] - - -@pytest.mark.parametrize("filename,required", PAGES) -def test_frontend_page_loads(filename, required): - """Each HTML page must exist and contain its identifying substrings.""" - content = _read(filename) - assert len(content) > 200, f"{filename} seems empty or too small" - for needle in required: - assert needle in content, f"{filename} missing required string {needle!r}" - - -def test_index_html_contains_login_and_register(): - """Index page (auth screen) must show both Login and Register tabs.""" - content = _read("index.html") - assert "Login" in content - assert "Registrieren" in content - # Alpine-CDN required - assert "alpinejs" in content - - -def test_app_html_contains_dashboard_and_logout(): - """app.html is the post-login shell — must show Dashboard + Logout.""" - content = _read("app.html") - assert "Dashboard" in content - assert "Logout" in content - - -def test_13_pages_exist(): - """Exactly 13 HTML pages must be present (index + app + 10 + 404).""" - pages = sorted(p.name for p in WEBUI_DIR.glob("*.html")) - assert len(pages) == 13, f"Expected 13 pages, found {len(pages)}: {pages}" - - -def test_dashboard_section_inside_app_html(): - """The dashboard content is rendered inside app.html (default landing).""" - content = _read("app.html") - # The 4 KPI labels from the dashboard section - assert "Open Deals" in content - assert "Pipeline Value" in content - assert "Won this Month" in content - assert "Conversion Rate" in content - - -def test_each_page_links_app_css(): - """Every page must reference /css/app.css.""" - for filename, _ in PAGES: - content = _read(filename) - assert "/css/app.css" in content, f"{filename} missing /css/app.css link" - - -def test_each_page_includes_alpine_cdn(): - """Every page must include the Alpine.js CDN script.""" - for filename, _ in PAGES: - content = _read(filename) - assert "alpinejs" in content, f"{filename} missing Alpine.js CDN" - - -def test_each_page_includes_tailwind_cdn(): - """Every page must include the Tailwind-CDN script (dev-mode).""" - for filename, _ in PAGES: - content = _read(filename) - assert "cdn.tailwindcss.com" in content, f"{filename} missing Tailwind CDN" diff --git a/tests/test_frontend_security.py b/tests/test_frontend_security.py deleted file mode 100644 index c3abbd6..0000000 --- a/tests/test_frontend_security.py +++ /dev/null @@ -1,183 +0,0 @@ -"""Frontend-Security Tests: XSS-mitigation, JWT-handling, CSP-middleware. - -These tests enforce the rules from architecture Section 13 + task graph R-5: - - - R-5: No `x-html` attribute anywhere in any HTML template (only x-text). - - R-1: JWT must be read from / written to localStorage (not cookies, not sessionStorage). - - 13.3: CSP-Header is configured in app/main.py (security_headers_middleware), - not in a reverse-proxy config file. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parent.parent -WEBUI = ROOT / "app" / "webui" -MAIN_PY = ROOT / "app" / "main.py" - - -# --------------------------------------------------------------------------- -# R-5: no x-html in any HTML template -# --------------------------------------------------------------------------- - -# Match `x-html` as a whole word (attribute, not part of `x-htmlSomething`). -# We deliberately allow `x-html="..."` to fail loudly, and we also catch -# any camel-cased variant like `xHtml` for paranoia. -XHTML_PATTERN = re.compile(r"\bx[-_]?html\b", re.IGNORECASE) - - -def _all_html_files() -> list[Path]: - return sorted(WEBUI.glob("*.html")) - - -def test_no_x_html_in_alpine_templates(): - """R-5: no `x-html` attribute anywhere in any HTML file.""" - offenders: list[tuple[str, int, str]] = [] - for path in _all_html_files(): - for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - # Strip HTML comments to avoid false positives in documentation - stripped = re.sub(r"", "", line) - if XHTML_PATTERN.search(stripped): - offenders.append((path.name, lineno, line.strip()[:120])) - assert not offenders, "x-html usage is forbidden (R-5); offenders:\n" + "\n".join( - f" {n}:{ln}: {snippet}" for n, ln, snippet in offenders - ) - - -def test_no_innerHTML_in_alpine_pages(): - """Defence in depth: also flag direct `innerHTML` assignments.""" - for path in _all_html_files(): - content = path.read_text(encoding="utf-8") - # allow within