T01: core infrastructure + auth + multi-tenant + RLS

- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
leocrm-bot
2026-06-29 00:10:10 +02:00
parent 3cc0b2e1b4
commit 7a7daf8100
137 changed files with 3866 additions and 10195 deletions
+4 -6
View File
@@ -1,8 +1,6 @@
AUTH_SECRET=test-secret-with-at-least-thirty-two-characters-for-development
DATABASE_URL=sqlite+aiosqlite:///./dev.db
JWT_ALGORITHM=HS256
JWT_EXPIRY_HOURS=24
BCRYPT_ROUNDS=4
CORS_ORIGINS=http://localhost:5500,http://localhost:8000
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
REDIS_URL=redis://localhost:6379/0
ENVIRONMENT=development
LOG_LEVEL=INFO
BCRYPT_ROUNDS=12
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
+33 -25
View File
@@ -1,34 +1,42 @@
# CRM System v1.0 - Environment Variables Template
# Copy this file to .env and fill in real values
# .env is gitignored, NEVER commit it
# LeoCRM v1.0 - Environment Variables Template
# === REQUIRED ===
# JWT signing secret. MUST be at least 32 characters.
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(48))"
# In production, this is auto-injected by Coolify as SERVICE_BASE64_64.
AUTH_SECRET=replace-me-with-a-secure-random-string-at-least-32-chars-long
DATABASE_URL=postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm
REDIS_URL=redis://localhost:6379/0
# === OPTIONAL (with defaults) ===
# Database URL. Driver-agnostic (aiosqlite or asyncpg).
# Dev default: SQLite file ./dev.db
# Prod example: postgresql+asyncpg://user:pass@host:5432/dbname
DATABASE_URL=sqlite+aiosqlite:///./dev.db
# JWT settings
JWT_ALGORITHM=HS256
JWT_EXPIRY_HOURS=24
# Password hashing rounds (bcrypt)
BCRYPT_ROUNDS=12
# CORS allowed origins (comma-separated, NO wildcards)
# Dev: http://localhost:5500,http://localhost:8000
# Prod: https://crm.media-on.de
CORS_ORIGINS=http://localhost:5500,http://localhost:8000
# Environment: development | production
# Environment: development | production | testing
ENVIRONMENT=development
# Log level: DEBUG | INFO | WARNING | ERROR
LOG_LEVEL=INFO
# Database pool
DB_POOL_SIZE=10
DB_MAX_OVERFLOW=20
DB_ECHO=false
# Session settings
SESSION_TTL_SECONDS=28800
SESSION_COOKIE_NAME=leocrm_session
SESSION_COOKIE_SECURE=false
SESSION_COOKIE_SAMESITE=strict
SESSION_COOKIE_HTTPONLY=true
# Password hashing
BCRYPT_ROUNDS=12
PASSWORD_RESET_EXPIRY_HOURS=1
# CORS allowed origins (comma-separated, NO wildcards)
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
# Rate limiting
RATE_LIMIT_LOGIN_MAX=5
RATE_LIMIT_LOGIN_WINDOW=900
RATE_LIMIT_RESET_MAX=3
RATE_LIMIT_RESET_WINDOW=3600
RATE_LIMIT_RESET_CONFIRM_MAX=5
RATE_LIMIT_RESET_CONFIRM_WINDOW=3600
RATE_LIMIT_GENERAL_MAX=60
RATE_LIMIT_GENERAL_WINDOW=60
+8 -41
View File
@@ -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())
+1 -5
View File
@@ -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)}
-103
View File
@@ -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")
+198
View File
@@ -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)
-285
View File
@@ -1,285 +0,0 @@
"""business_entities
Revision ID: 0002
Revises: 0001
Create Date: 2026-06-03
Adds 8 new tables for the Phase 4b business logic:
- accounts, contacts, deals, activities (with polymorphic parent FKs)
- notes, tags, tag_links (polymorphic parent, no DB FK)
- deal_stage_history (audit trail of stage transitions)
All tables include org_id for multi-tenant isolation (R-1) and
deleted_at for soft-delete (R-1 default filter).
"""
from __future__ import annotations
from collections.abc import Sequence
from typing import Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "0002"
down_revision: Union[str, None] = "0001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# === accounts ===
op.create_table(
"accounts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("website", sa.String(length=512), nullable=True),
sa.Column("industry", sa.String(length=32), nullable=True),
sa.Column("size", sa.String(length=32), nullable=True),
sa.Column("address", sa.JSON(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_accounts_org_id"),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_accounts_owner_id"),
)
op.create_index("ix_accounts_name", "accounts", ["name"])
op.create_index("ix_accounts_industry", "accounts", ["industry"])
op.create_index("ix_accounts_org_id", "accounts", ["org_id"])
op.create_index("ix_accounts_owner_id", "accounts", ["owner_id"])
op.create_index("ix_accounts_created_at", "accounts", ["created_at"])
op.create_index("ix_accounts_deleted_at", "accounts", ["deleted_at"])
# === contacts ===
op.create_table(
"contacts",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("first_name", sa.String(length=128), nullable=False),
sa.Column("last_name", sa.String(length=128), nullable=False),
sa.Column("email", sa.String(length=255), nullable=True),
sa.Column("phone", sa.String(length=64), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_contacts_org_id"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_contacts_account_id"),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_contacts_owner_id"),
)
op.create_index("ix_contacts_last_name", "contacts", ["last_name"])
op.create_index("ix_contacts_email", "contacts", ["email"])
op.create_index("ix_contacts_org_id", "contacts", ["org_id"])
op.create_index("ix_contacts_account_id", "contacts", ["account_id"])
op.create_index("ix_contacts_owner_id", "contacts", ["owner_id"])
op.create_index("ix_contacts_created_at", "contacts", ["created_at"])
op.create_index("ix_contacts_deleted_at", "contacts", ["deleted_at"])
# === deals ===
op.create_table(
"deals",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("value", sa.Numeric(12, 2), nullable=False, server_default="0"),
sa.Column("currency", sa.String(length=3), nullable=False, server_default="EUR"),
sa.Column("stage", sa.String(length=32), nullable=False, server_default="lead"),
sa.Column("close_date", sa.Date(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("won_lost_reason", sa.String(length=512), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_deals_org_id"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_deals_account_id"),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_deals_owner_id"),
)
op.create_index("ix_deals_stage", "deals", ["stage"])
op.create_index("ix_deals_org_id", "deals", ["org_id"])
op.create_index("ix_deals_account_id", "deals", ["account_id"])
op.create_index("ix_deals_owner_id", "deals", ["owner_id"])
op.create_index("ix_deals_created_at", "deals", ["created_at"])
op.create_index("ix_deals_deleted_at", "deals", ["deleted_at"])
# === activities ===
op.create_table(
"activities",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("type", sa.String(length=32), nullable=False),
sa.Column("subject", sa.String(length=255), nullable=False),
sa.Column("body", sa.Text(), nullable=True),
sa.Column("due_date", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("contact_id", sa.Integer(), nullable=True),
sa.Column("deal_id", sa.Integer(), nullable=True),
sa.Column("owner_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_activities_org_id"),
sa.ForeignKeyConstraint(["account_id"], ["accounts.id"], name="fk_activities_account_id"),
sa.ForeignKeyConstraint(["contact_id"], ["contacts.id"], name="fk_activities_contact_id"),
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], name="fk_activities_deal_id"),
sa.ForeignKeyConstraint(["owner_id"], ["users.id"], name="fk_activities_owner_id"),
)
op.create_index("ix_activities_type", "activities", ["type"])
op.create_index("ix_activities_due_date", "activities", ["due_date"])
op.create_index("ix_activities_org_id", "activities", ["org_id"])
op.create_index("ix_activities_account_id", "activities", ["account_id"])
op.create_index("ix_activities_contact_id", "activities", ["contact_id"])
op.create_index("ix_activities_deal_id", "activities", ["deal_id"])
op.create_index("ix_activities_owner_id", "activities", ["owner_id"])
op.create_index("ix_activities_created_at", "activities", ["created_at"])
op.create_index("ix_activities_deleted_at", "activities", ["deleted_at"])
# === notes ===
op.create_table(
"notes",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("author_id", sa.Integer(), nullable=False),
sa.Column("parent_type", sa.String(length=32), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_notes_org_id"),
sa.ForeignKeyConstraint(["author_id"], ["users.id"], name="fk_notes_author_id"),
)
op.create_index("ix_notes_parent_type", "notes", ["parent_type"])
op.create_index("ix_notes_parent_id", "notes", ["parent_id"])
op.create_index("ix_notes_author_id", "notes", ["author_id"])
op.create_index("ix_notes_org_id", "notes", ["org_id"])
op.create_index("ix_notes_created_at", "notes", ["created_at"])
op.create_index("ix_notes_deleted_at", "notes", ["deleted_at"])
# === tags ===
op.create_table(
"tags",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("color", sa.String(length=7), nullable=False, server_default="#3B82F6"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tags_org_id"),
)
op.create_index("ix_tags_name", "tags", ["name"])
op.create_index("ix_tags_org_id", "tags", ["org_id"])
op.create_index("ix_tags_created_at", "tags", ["created_at"])
op.create_index("ix_tags_deleted_at", "tags", ["deleted_at"])
# === tag_links ===
op.create_table(
"tag_links",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("tag_id", sa.Integer(), nullable=False),
sa.Column("parent_type", sa.String(length=32), nullable=False),
sa.Column("parent_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_tag_links_org_id"),
sa.ForeignKeyConstraint(["tag_id"], ["tags.id"], ondelete="CASCADE", name="fk_tag_links_tag_id"),
sa.UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),
)
op.create_index("ix_tag_links_tag_id", "tag_links", ["tag_id"])
op.create_index("ix_tag_links_parent_type", "tag_links", ["parent_type"])
op.create_index("ix_tag_links_parent_id", "tag_links", ["parent_id"])
op.create_index("ix_tag_links_org_id", "tag_links", ["org_id"])
op.create_index("ix_tag_links_created_at", "tag_links", ["created_at"])
op.create_index("ix_tag_links_deleted_at", "tag_links", ["deleted_at"])
# === deal_stage_history ===
op.create_table(
"deal_stage_history",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("org_id", sa.Integer(), nullable=False),
sa.Column("deal_id", sa.Integer(), nullable=False),
sa.Column("from_stage", sa.String(length=32), nullable=True),
sa.Column("to_stage", sa.String(length=32), nullable=False),
sa.Column("changed_by", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["org_id"], ["orgs.id"], ondelete="CASCADE", name="fk_dsh_org_id"),
sa.ForeignKeyConstraint(["deal_id"], ["deals.id"], ondelete="CASCADE", name="fk_dsh_deal_id"),
sa.ForeignKeyConstraint(["changed_by"], ["users.id"], name="fk_dsh_changed_by"),
)
op.create_index("ix_dsh_deal_id", "deal_stage_history", ["deal_id"])
op.create_index("ix_dsh_org_id", "deal_stage_history", ["org_id"])
op.create_index("ix_dsh_created_at", "deal_stage_history", ["created_at"])
def downgrade() -> None:
op.drop_index("ix_dsh_created_at", table_name="deal_stage_history")
op.drop_index("ix_dsh_org_id", table_name="deal_stage_history")
op.drop_index("ix_dsh_deal_id", table_name="deal_stage_history")
op.drop_table("deal_stage_history")
op.drop_index("ix_tag_links_deleted_at", table_name="tag_links")
op.drop_index("ix_tag_links_created_at", table_name="tag_links")
op.drop_index("ix_tag_links_org_id", table_name="tag_links")
op.drop_index("ix_tag_links_parent_id", table_name="tag_links")
op.drop_index("ix_tag_links_parent_type", table_name="tag_links")
op.drop_index("ix_tag_links_tag_id", table_name="tag_links")
op.drop_table("tag_links")
op.drop_index("ix_tags_deleted_at", table_name="tags")
op.drop_index("ix_tags_created_at", table_name="tags")
op.drop_index("ix_tags_org_id", table_name="tags")
op.drop_index("ix_tags_name", table_name="tags")
op.drop_table("tags")
op.drop_index("ix_notes_deleted_at", table_name="notes")
op.drop_index("ix_notes_created_at", table_name="notes")
op.drop_index("ix_notes_org_id", table_name="notes")
op.drop_index("ix_notes_author_id", table_name="notes")
op.drop_index("ix_notes_parent_id", table_name="notes")
op.drop_index("ix_notes_parent_type", table_name="notes")
op.drop_table("notes")
op.drop_index("ix_activities_deleted_at", table_name="activities")
op.drop_index("ix_activities_created_at", table_name="activities")
op.drop_index("ix_activities_owner_id", table_name="activities")
op.drop_index("ix_activities_deal_id", table_name="activities")
op.drop_index("ix_activities_contact_id", table_name="activities")
op.drop_index("ix_activities_account_id", table_name="activities")
op.drop_index("ix_activities_org_id", table_name="activities")
op.drop_index("ix_activities_due_date", table_name="activities")
op.drop_index("ix_activities_type", table_name="activities")
op.drop_table("activities")
op.drop_index("ix_deals_deleted_at", table_name="deals")
op.drop_index("ix_deals_created_at", table_name="deals")
op.drop_index("ix_deals_owner_id", table_name="deals")
op.drop_index("ix_deals_account_id", table_name="deals")
op.drop_index("ix_deals_org_id", table_name="deals")
op.drop_index("ix_deals_stage", table_name="deals")
op.drop_table("deals")
op.drop_index("ix_contacts_deleted_at", table_name="contacts")
op.drop_index("ix_contacts_created_at", table_name="contacts")
op.drop_index("ix_contacts_owner_id", table_name="contacts")
op.drop_index("ix_contacts_account_id", table_name="contacts")
op.drop_index("ix_contacts_org_id", table_name="contacts")
op.drop_index("ix_contacts_email", table_name="contacts")
op.drop_index("ix_contacts_last_name", table_name="contacts")
op.drop_table("contacts")
op.drop_index("ix_accounts_deleted_at", table_name="accounts")
op.drop_index("ix_accounts_created_at", table_name="accounts")
op.drop_index("ix_accounts_owner_id", table_name="accounts")
op.drop_index("ix_accounts_org_id", table_name="accounts")
op.drop_index("ix_accounts_industry", table_name="accounts")
op.drop_index("ix_accounts_name", table_name="accounts")
op.drop_table("accounts")
+1 -3
View File
@@ -1,3 +1 @@
"""CRM System Application Package."""
__version__ = "1.0.0"
"""LeoCRM backend application package."""
+1 -1
View File
@@ -1 +1 @@
"""API routers package."""
"""API package."""
+1 -1
View File
@@ -1 +1 @@
"""API v1 routers."""
"""API v1 package."""
-129
View File
@@ -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"]
-164
View File
@@ -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"]
-154
View File
@@ -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()
-135
View File
@@ -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"]
-47
View File
@@ -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"]
-185
View File
@@ -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"]
-69
View File
@@ -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__,
}
-131
View File
@@ -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"]
-106
View File
@@ -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"]
-147
View File
@@ -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)
+65
View File
@@ -0,0 +1,65 @@
"""Application configuration via Pydantic Settings."""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Environment
environment: Literal["development", "production", "testing"] = "development"
log_level: str = "INFO"
# Database
database_url: str = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test"
db_pool_size: int = 10
db_max_overflow: int = 20
db_echo: bool = False
# Redis
redis_url: str = "redis://localhost:6379/0"
session_ttl_seconds: int = 28800 # 8 hours
# Auth
bcrypt_rounds: int = 12
session_cookie_name: str = "leocrm_session"
session_cookie_secure: bool = False # True in production behind HTTPS
session_cookie_samesite: str = "strict"
session_cookie_httponly: bool = True
password_reset_expiry_hours: int = 1
# CORS
cors_origins: str = "http://localhost:5173,http://localhost:3000"
# Rate Limiting
rate_limit_login_max: int = 5
rate_limit_login_window: int = 900 # 15 min
rate_limit_reset_max: int = 3
rate_limit_reset_window: int = 3600 # 1 hour
rate_limit_reset_confirm_max: int = 5
rate_limit_reset_confirm_window: int = 3600 # 1 hour
rate_limit_general_max: int = 60
rate_limit_general_window: int = 60 # 1 min
@property
def cors_origin_list(self) -> list[str]:
"""Parse comma-separated CORS origins into a list."""
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
"""Get cached settings instance."""
return Settings()
+1 -1
View File
@@ -1 +1 @@
"""Core module: configuration, database, security, dependencies."""
"""Core infrastructure package."""
+54
View File
@@ -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
+169
View File
@@ -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
+50
View File
@@ -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)
-86
View File
@@ -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]
-69
View File
@@ -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()
+134
View File
@@ -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
-64
View File
@@ -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
+41
View File
@@ -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
+42
View File
@@ -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,
}
+37
View File
@@ -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)
+123
View File
@@ -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,
}
+47
View File
@@ -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"
-99
View File
@@ -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
+46
View File
@@ -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
+24
View File
@@ -0,0 +1,24 @@
"""Tenant-scoping: ORM auto-filter and tenant context management."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import event as sa_event
from sqlalchemy.orm import Session, with_loader_criteria
from sqlalchemy.sql import Select
from app.core.db import TenantMixin
def apply_tenant_filter(query: Select, tenant_id: uuid.UUID) -> Select:
"""Apply tenant_id filter to a SELECT query."""
# This is used explicitly when needed
return query.where(TenantMixin.tenant_id == tenant_id)
async def set_rls_context(session, tenant_id: uuid.UUID | str) -> None:
"""Set PostgreSQL RLS session variable."""
from app.core.db import set_tenant_context
await set_tenant_context(session, tenant_id)
+96
View File
@@ -0,0 +1,96 @@
"""FastAPI dependencies: auth, db, tenant context, RBAC."""
from __future__ import annotations
import uuid
from typing import Any
import redis.asyncio as aioredis
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis, get_session_data
from app.core.db import get_db, set_tenant_context
from app.models.user import User
async def get_redis_dep() -> aioredis.Redis:
"""FastAPI dependency for Redis client."""
return get_redis()
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis_dep),
) -> dict[str, Any]:
"""Get the current authenticated user from session cookie.
Returns session data dict with user_id, tenant_id, email, name, role.
"""
settings = get_settings()
session_id = request.cookies.get(settings.session_cookie_name)
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Not authenticated", "code": "not_authenticated"},
)
session_data = await get_session_data(redis, session_id)
if session_data is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
)
if not session_data.get("is_active", True):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "User account deactivated", "code": "user_inactive"},
)
# Set RLS tenant context
tenant_id = uuid.UUID(session_data["tenant_id"])
await set_tenant_context(db, tenant_id)
return session_data
async def require_admin(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require admin role."""
if current_user.get("role") != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Admin access required", "code": "forbidden"},
)
return current_user
async def require_write(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require write permission (admin or editor)."""
role = current_user.get("role", "viewer")
if role not in ("admin", "editor"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"detail": "Write access required", "code": "forbidden"},
)
return current_user
async def get_tenant_id(
current_user: dict[str, Any] = Depends(get_current_user),
) -> uuid.UUID:
"""Extract tenant_id from current user session."""
return uuid.UUID(current_user["tenant_id"])
async def get_current_user_id(
current_user: dict[str, Any] = Depends(get_current_user),
) -> uuid.UUID:
"""Extract user_id from current user session."""
return uuid.UUID(current_user["user_id"])
+21 -174
View File
@@ -1,200 +1,47 @@
"""FastAPI application entry point.
Wires up:
- CORS middleware (whitelist, NO wildcard)
- Security headers middleware (CSP, X-Frame-Options, X-Content-Type-Options, HSTS in prod)
- Global exception handlers (HTTPException, RequestValidationError, SQLAlchemyError)
- Startup/shutdown events (DB connection check, log level)
- Versioned API router mounts under /api/v1
- Root-level /health endpoint for Coolify healthcheck
"""
"""FastAPI application - LeoCRM backend."""
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from starlette.exceptions import HTTPException as StarletteHTTPException
from app import __version__
from app.api.v1 import (
accounts,
activities,
auth,
contacts,
dashboard,
deals,
health,
notes,
tags,
users,
)
from app.core.config import get_settings
from app.core.db import AsyncSessionLocal, dispose_engine
logger = logging.getLogger(__name__)
# === Lifespan ===
from app.config import get_settings
from app.core.middleware import CSRFMiddleware
from app.core.db import close_engine
from app.routes import auth, users, roles, tenants, health, notifications, companies
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown hooks."""
settings = get_settings()
logging.basicConfig(level=settings.LOG_LEVEL)
logger.info(
"Starting CRM System v%s in %s mode",
__version__,
settings.ENVIRONMENT,
)
# Verify DB connection on startup
try:
async with AsyncSessionLocal() as session:
await session.execute(text("SELECT 1"))
logger.info("Database connection OK (%s)", settings.DATABASE_URL.split("://", 1)[0])
except Exception as e:
logger.error("Database connection FAILED on startup: %s", e)
# Don't crash — let /health report the issue so Coolify can restart
"""Application lifespan: startup and shutdown."""
yield
# Shutdown
logger.info("Shutting down CRM System")
await dispose_engine()
# === App ===
await close_engine()
def create_app() -> FastAPI:
"""Application factory (allows easy testing override)."""
"""Create and configure the FastAPI application."""
settings = get_settings()
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
app = FastAPI(
title="CRM System",
version=__version__,
docs_url="/docs",
redoc_url="/redoc",
openapi_url="/openapi.json",
lifespan=lifespan,
)
# === CORS ===
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
max_age=3600,
)
app.add_middleware(CSRFMiddleware)
# === Security Headers ===
@app.middleware("http")
async def security_headers_middleware(request: Request, call_next):
"""Inject CSP, X-Frame-Options, X-Content-Type-Options, and HSTS headers."""
response = await call_next(request)
settings_local = get_settings()
if settings_local.is_production:
# Prod: strict CSP (no unsafe-inline for scripts; TODO v1.1 add nonce)
csp = (
"default-src 'self'; "
"script-src 'self' https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"object-src 'none';"
)
else:
# Dev: allow inline scripts for fast iteration (Alpine.js x-data blocks)
csp = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; "
"object-src 'none';"
)
response.headers["Content-Security-Policy"] = csp
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
if settings_local.is_production:
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
# === Exception Handlers ===
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""Format HTTPException responses consistently."""
# Distinguish token-expired for FR-1.7 acceptance criterion
if exc.status_code == 401:
detail = exc.detail
if detail == "Could not validate credentials":
# Token invalid or expired — callers can detect this
return JSONResponse(
status_code=exc.status_code,
content={"detail": "token_expired_or_invalid"},
headers=exc.headers,
)
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
headers=exc.headers,
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Format Pydantic validation errors consistently."""
from fastapi.encoders import jsonable_encoder
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"detail": jsonable_encoder(exc.errors())},
)
@app.exception_handler(SQLAlchemyError)
async def sqlalchemy_exception_handler(request: Request, exc: SQLAlchemyError) -> JSONResponse:
"""Log DB errors and return a 500 without leaking internals."""
logger.exception("Database error on %s %s", request.method, request.url)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": "Database error"},
)
# === Routers ===
# Health is mounted at both /health (root) and /api/v1/health (versioned)
app.include_router(health.router)
app.include_router(auth.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
# Phase 4b business routers
app.include_router(accounts.router, prefix="/api/v1")
app.include_router(contacts.router, prefix="/api/v1")
app.include_router(deals.router, prefix="/api/v1")
app.include_router(activities.router, prefix="/api/v1")
app.include_router(notes.router, prefix="/api/v1")
app.include_router(tags.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
# === Static Files (Frontend) ===
# Mount app/webui/ on root AFTER all routers, so /api/v1/* still matches first.
# StaticFiles mit html=True servt index.html on `/` und alle .html files direkt.
webui_dir = Path(__file__).parent / "webui"
if webui_dir.exists():
app.mount("/", StaticFiles(directory=str(webui_dir), html=True), name="webui")
else:
logger.warning("webui directory not found at %s — frontend not served", webui_dir)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(roles.router)
app.include_router(tenants.router)
app.include_router(notifications.router)
app.include_router(companies.router)
return app
+12 -33
View File
@@ -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",
]
-78
View File
@@ -1,78 +0,0 @@
"""Account model: companies/organizations that the CRM tracks."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING, Any
from sqlalchemy import JSON, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.activity import Activity
from app.models.contact import Contact
from app.models.deal import Deal
from app.models.note import Note
from app.models.tag_link import TagLink
from app.models.user import User
class Industry(str, Enum):
"""Industry classification for accounts."""
startup = "startup"
sme = "sme"
midmarket = "midmarket"
enterprise = "enterprise"
other = "other"
class AccountSize(str, Enum):
"""Company size category."""
startup = "startup"
sme = "sme"
midmarket = "midmarket"
enterprise = "enterprise"
class Account(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "accounts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
website: Mapped[str | None] = mapped_column(String(512), nullable=True)
industry: Mapped[Industry | None] = mapped_column(String(32), nullable=True, index=True)
size: Mapped[AccountSize | None] = mapped_column(String(32), nullable=True)
address: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
# Relationships
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
contacts: Mapped[list[Contact]] = relationship(
"Contact", back_populates="account", lazy="selectin"
)
deals: Mapped[list[Deal]] = relationship("Deal", back_populates="account", lazy="selectin")
activities: Mapped[list[Activity]] = relationship(
"Activity", back_populates="account", lazy="selectin"
)
notes: Mapped[list[Note]] = relationship(
"Note",
primaryjoin="and_(Account.id==foreign(Note.parent_id), Note.parent_type=='account')",
viewonly=True,
lazy="selectin",
)
tags: Mapped[list[TagLink]] = relationship(
"TagLink",
primaryjoin="and_(Account.id==foreign(TagLink.parent_id), TagLink.parent_type=='account')",
viewonly=True,
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Account id={self.id} name={self.name!r}>"
__all__ = ["Account", "AccountSize", "Industry"]
-65
View File
@@ -1,65 +0,0 @@
"""Activity model: calls, meetings, emails, tasks, notes tied to any entity."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.account import Account
from app.models.contact import Contact
from app.models.deal import Deal
from app.models.user import User
class ActivityType(str, Enum):
"""Type of activity."""
call = "call"
meeting = "meeting"
email = "email"
task = "task"
note = "note"
class Activity(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "activities"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[ActivityType] = mapped_column(String(32), nullable=False, index=True)
subject: Mapped[str] = mapped_column(String(255), nullable=False)
body: Mapped[str | None] = mapped_column(Text, nullable=True)
due_date: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id"), nullable=True, index=True
)
contact_id: Mapped[int | None] = mapped_column(
ForeignKey("contacts.id"), nullable=True, index=True
)
deal_id: Mapped[int | None] = mapped_column(ForeignKey("deals.id"), nullable=True, index=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
# Relationships
account: Mapped[Account | None] = relationship(
"Account", back_populates="activities", lazy="selectin"
)
contact: Mapped[Contact | None] = relationship(
"Contact", back_populates="activities", lazy="selectin"
)
deal: Mapped[Deal | None] = relationship("Deal", back_populates="activities", lazy="selectin")
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
def __repr__(self) -> str:
return f"<Activity id={self.id} type={self.type} subject={self.subject!r}>"
__all__ = ["Activity", "ActivityType"]
+52
View File
@@ -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()
)
+53
View File
@@ -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)
-51
View File
@@ -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"]
+40
View File
@@ -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
)
-58
View File
@@ -1,58 +0,0 @@
"""Contact model: people at accounts (or standalone contacts)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.account import Account
from app.models.activity import Activity
from app.models.note import Note
from app.models.tag_link import TagLink
from app.models.user import User
class Contact(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "contacts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
first_name: Mapped[str] = mapped_column(String(128), nullable=False)
last_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
phone: Mapped[str | None] = mapped_column(String(64), nullable=True)
account_id: Mapped[int | None] = mapped_column(
ForeignKey("accounts.id"), nullable=True, index=True
)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
# Relationships
account: Mapped[Account | None] = relationship(
"Account", back_populates="contacts", lazy="selectin"
)
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
activities: Mapped[list[Activity]] = relationship(
"Activity", back_populates="contact", lazy="selectin"
)
notes: Mapped[list[Note]] = relationship(
"Note",
primaryjoin="and_(Contact.id==foreign(Note.parent_id), Note.parent_type=='contact')",
viewonly=True,
lazy="selectin",
)
tags: Mapped[list[TagLink]] = relationship(
"TagLink",
primaryjoin="and_(Contact.id==foreign(TagLink.parent_id), TagLink.parent_type=='contact')",
viewonly=True,
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Contact id={self.id} {self.first_name} {self.last_name!r}>"
__all__ = ["Contact"]
-86
View File
@@ -1,86 +0,0 @@
"""Deal model: sales opportunities tied to an account."""
from __future__ import annotations
from datetime import date
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.account import Account
from app.models.activity import Activity
from app.models.deal_stage_history import DealStageHistory
from app.models.note import Note
from app.models.tag_link import TagLink
from app.models.user import User
class DealStage(str, Enum):
"""Sales pipeline stage for a deal."""
lead = "lead"
qualified = "qualified"
proposal = "proposal"
negotiation = "negotiation"
won = "won"
lost = "lost"
class Deal(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "deals"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
currency: Mapped[str] = mapped_column(
String(3), nullable=False, default="EUR", server_default="EUR"
)
stage: Mapped[DealStage] = mapped_column(
String(32),
nullable=False,
default=DealStage.lead,
server_default=DealStage.lead.value,
index=True,
)
close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
account_id: Mapped[int] = mapped_column(ForeignKey("accounts.id"), nullable=False, index=True)
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
won_lost_reason: Mapped[str | None] = mapped_column(String(512), nullable=True)
# Relationships
account: Mapped[Account] = relationship("Account", back_populates="deals", lazy="selectin")
owner: Mapped[User] = relationship("User", foreign_keys=[owner_id], lazy="joined")
stage_history: Mapped[list[DealStageHistory]] = relationship(
"DealStageHistory",
back_populates="deal",
cascade="all, delete-orphan",
lazy="selectin",
order_by="DealStageHistory.created_at",
)
activities: Mapped[list[Activity]] = relationship(
"Activity", back_populates="deal", lazy="selectin"
)
notes: Mapped[list[Note]] = relationship(
"Note",
primaryjoin="and_(Deal.id==foreign(Note.parent_id), Note.parent_type=='deal')",
viewonly=True,
lazy="selectin",
)
tags: Mapped[list[TagLink]] = relationship(
"TagLink",
primaryjoin="and_(Deal.id==foreign(TagLink.parent_id), TagLink.parent_type=='deal')",
viewonly=True,
lazy="selectin",
)
def __repr__(self) -> str:
return f"<Deal id={self.id} title={self.title!r} stage={self.stage}>"
__all__ = ["Deal", "DealStage"]
-36
View File
@@ -1,36 +0,0 @@
"""DealStageHistory model: audit trail of stage transitions for a deal."""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, TimestampMixin
from app.models.deal import DealStage
if TYPE_CHECKING:
from app.models.deal import Deal
from app.models.user import User
class DealStageHistory(Base, TimestampMixin, OrgScopedMixin):
"""Records each deal stage transition. Append-only — no soft-delete."""
__tablename__ = "deal_stage_history"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
deal_id: Mapped[int] = mapped_column(ForeignKey("deals.id"), nullable=False, index=True)
from_stage: Mapped[DealStage | None] = mapped_column(String(32), nullable=True)
to_stage: Mapped[DealStage] = mapped_column(String(32), nullable=False)
changed_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
deal: Mapped[Deal] = relationship("Deal", back_populates="stage_history", lazy="joined")
changer: Mapped[User] = relationship("User", foreign_keys=[changed_by], lazy="joined")
def __repr__(self) -> str:
return f"<DealStageHistory id={self.id} deal={self.deal_id} {self.from_stage}->{self.to_stage}>"
__all__ = ["DealStageHistory"]
-42
View File
@@ -1,42 +0,0 @@
"""Note model: free-text notes attached polymorphically to accounts/contacts/deals."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
class NoteParentType(str, Enum):
"""Polymorphic parent type for notes (no DB FK, validated in service layer)."""
account = "account"
contact = "contact"
deal = "deal"
class Note(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "notes"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
body: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True)
parent_type: Mapped[NoteParentType] = mapped_column(String(32), nullable=False, index=True)
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
# Service layer (note_service.create_note) validates existence.
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
author: Mapped[User] = relationship("User", foreign_keys=[author_id], lazy="joined")
def __repr__(self) -> str:
return f"<Note id={self.id} parent={self.parent_type}:{self.parent_id}>"
__all__ = ["Note", "NoteParentType"]
+35
View File
@@ -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()
)
-34
View File
@@ -1,34 +0,0 @@
"""Organization model."""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, TimestampMixin
if TYPE_CHECKING:
from app.models.user import User
class Org(Base, TimestampMixin):
__tablename__ = "orgs"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
logo_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
default_currency: Mapped[str] = mapped_column(
String(3), nullable=False, default="EUR", server_default="EUR"
)
# Relationship to users (defined here to resolve circular import)
users: Mapped[list[User]] = relationship(
back_populates="org",
lazy="selectin",
cascade="all, delete-orphan",
)
def __repr__(self) -> str:
return f"<Org id={self.id} name={self.name!r}>"
+29
View File
@@ -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()
)
+30
View File
@@ -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()
)
-34
View File
@@ -1,34 +0,0 @@
"""Tag model: labels that can be linked to accounts/contacts/deals."""
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.tag_link import TagLink
class Tag(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
color: Mapped[str] = mapped_column(
String(7), nullable=False, default="#3B82F6", server_default="#3B82F6"
)
# Relationships
links: Mapped[list[TagLink]] = relationship(
"TagLink", back_populates="tag", cascade="all, delete-orphan", lazy="selectin"
)
def __repr__(self) -> str:
return f"<Tag id={self.id} name={self.name!r}>"
__all__ = ["Tag"]
-44
View File
@@ -1,44 +0,0 @@
"""TagLink model: polymorphic many-to-many between tags and accounts/contacts/deals."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.tag import Tag
class TagLinkParentType(str, Enum):
"""Polymorphic parent type for tag links (no DB FK, validated in service layer)."""
account = "account"
contact = "contact"
deal = "deal"
class TagLink(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "tag_links"
__table_args__ = (UniqueConstraint("tag_id", "parent_type", "parent_id", name="uq_tag_link"),)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), nullable=False, index=True)
parent_type: Mapped[TagLinkParentType] = mapped_column(String(32), nullable=False, index=True)
# parent_id is intentionally NOT a DB FK because of polymorphic parent.
# Service layer (tag_service.link_tag) validates existence.
parent_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
tag: Mapped[Tag] = relationship("Tag", back_populates="links", lazy="joined")
def __repr__(self) -> str:
return (
f"<TagLink id={self.id} tag={self.tag_id} parent={self.parent_type}:{self.parent_id}>"
)
__all__ = ["TagLink", "TagLinkParentType"]
+30
View File
@@ -0,0 +1,30 @@
"""Tenant model."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import String, DateTime, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
class Tenant(Base):
"""Tenant entity — top-level organisational unit."""
__tablename__ = "tenants"
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+34 -33
View File
@@ -1,48 +1,49 @@
"""User model with role enum and soft-delete."""
"""User and UserTenant models."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import Boolean, String, UniqueConstraint
from sqlalchemy import String, Boolean, DateTime, ForeignKey, func, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID as PGUUID, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base, OrgScopedMixin, SoftDeleteMixin, TimestampMixin
if TYPE_CHECKING:
from app.models.org import Org
from app.core.db import Base, TenantMixin
class UserRole(str, Enum):
"""User role for RBAC."""
class User(Base, TenantMixin):
"""User entity — belongs to a tenant, can be member of multiple tenants."""
admin = "admin"
sales_manager = "sales_manager"
sales_rep = "sales_rep"
class User(Base, TimestampMixin, SoftDeleteMixin, OrgScopedMixin):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("org_id", "email", name="uq_users_org_email"),)
__table_args__ = (
UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),
)
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(
String(32),
nullable=False,
default=UserRole.sales_rep,
server_default=UserRole.sales_rep.value,
)
avatar_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
email_notifications: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default="1"
)
role: Mapped[str] = mapped_column(String(50), nullable=False, default="viewer")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
preferences: Mapped[dict[str, Any]] = mapped_column(JSONB, default=dict, nullable=False)
# Relationship back to org (string reference avoids circular import at runtime)
org: Mapped[Org] = relationship(back_populates="users", lazy="joined")
def __repr__(self) -> str:
return f"<User id={self.id} email={self.email!r} role={self.role}>"
class UserTenant(Base):
"""N:M association — user membership in tenants."""
__tablename__ = "user_tenants"
user_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
tenant_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("tenants.id", ondelete="CASCADE"), primary_key=True
)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+1
View File
@@ -0,0 +1 @@
"""Routes package."""
+218
View File
@@ -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."}
+210
View File
@@ -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
}
+13
View File
@@ -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"}
+69
View File
@@ -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}
+98
View File
@@ -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)
+75
View File
@@ -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"}
+168
View File
@@ -0,0 +1,168 @@
"""User management routes."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.audit import log_audit
from app.core.db import get_db
from app.core.notifications import create_notification
from app.deps import get_current_user, require_admin, get_tenant_id, get_current_user_id
from app.schemas.user import UserCreate, UserUpdate
from app.services.user_service import user_service
router = APIRouter(prefix="/api/v1/users", tags=["users"])
@router.get("")
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
search: str | None = Query(None),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""List users (admin only, paginated)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
return await user_service.list_users(db, tenant_id, page, page_size, search)
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_user(
body: UserCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Create a new user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
user = await user_service.create_user(
db, tenant_id, body.email, body.name, body.password, body.role, body.is_active,
)
# Audit log
await log_audit(
db, tenant_id, user_id, "create", "user", user.id,
changes={"email": body.email, "name": body.name, "role": body.role},
)
# Notification
await create_notification(
db, tenant_id, user.id, "info",
"Account created",
f"Your account has been created by {current_user['name']}.",
)
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
@router.get("/{user_id}")
async def get_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
):
"""Get a single user."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
@router.patch("/{user_id}")
async def update_user(
user_id: str,
body: UserUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Update a user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
acting_user_id = uuid.UUID(current_user["user_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
changes: dict[str, Any] = {}
if body.name is not None:
changes["name"] = body.name
if body.role is not None:
changes["role"] = body.role
if body.is_active is not None:
changes["is_active"] = body.is_active
user = await user_service.update_user(
db, tenant_id, uid, body.name, body.role, body.is_active,
)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(db, tenant_id, acting_user_id, "update", "user", uid, changes=changes)
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"role": user.role,
"is_active": user.is_active,
"tenant_id": str(user.tenant_id),
}
@router.delete("/{user_id}")
async def delete_user(
user_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Delete a user (admin only)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
acting_user_id = uuid.UUID(current_user["user_id"])
try:
uid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(400, detail={"detail": "Invalid user_id", "code": "invalid_id"})
# Get user snapshot for audit before deletion
user = await user_service.get_user(db, tenant_id, uid)
if user is None:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
success = await user_service.delete_user(db, tenant_id, uid)
if not success:
raise HTTPException(404, detail={"detail": "User not found", "code": "not_found"})
await log_audit(
db, tenant_id, acting_user_id, "delete", "user", uid,
changes={"email": user.email, "name": user.name},
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+1 -86
View File
@@ -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."""
-58
View File
@@ -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"]
-65
View File
@@ -1,65 +0,0 @@
"""Pydantic schemas for Activity endpoints."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.models.activity import ActivityType
class ActivityBase(BaseModel):
type: ActivityType
subject: str = Field(..., min_length=1, max_length=255)
body: str | None = None
due_date: datetime | None = None
account_id: int | None = None
contact_id: int | None = None
deal_id: int | None = None
@model_validator(mode="after")
def _check_at_least_one_parent(self) -> ActivityBase:
if self.account_id is None and self.contact_id is None and self.deal_id is None:
raise ValueError("At least one of account_id, contact_id, deal_id must be set")
return self
class ActivityCreate(ActivityBase):
pass
class ActivityUpdate(BaseModel):
type: ActivityType | None = None
subject: str | None = Field(None, min_length=1, max_length=255)
body: str | None = None
due_date: datetime | None = None
account_id: int | None = None
contact_id: int | None = None
deal_id: int | None = None
class ActivityOut(ActivityBase):
model_config = ConfigDict(from_attributes=True)
id: int
org_id: int
owner_id: int
completed_at: datetime | None = None
created_at: datetime
updated_at: datetime
deleted_at: datetime | None = None
class ActivityCompleteRequest(BaseModel):
"""Body for PATCH /api/v1/activities/{id}/complete."""
outcome: str | None = Field(None, max_length=2048)
__all__ = [
"ActivityCompleteRequest",
"ActivityCreate",
"ActivityOut",
"ActivityUpdate",
]
+18 -41
View File
@@ -1,59 +1,36 @@
"""Pydantic schemas for authentication endpoints."""
"""Auth schemas."""
from __future__ import annotations
from pydantic import BaseModel, EmailStr, Field
from app.models.user import UserRole
class UserRegisterRequest(BaseModel):
"""Request body for POST /api/v1/auth/register (bootstrap)."""
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
name: str = Field(..., min_length=1, max_length=255)
role: UserRole = UserRole.sales_rep
password: str = Field(..., min_length=1)
class UserLoginRequest(BaseModel):
"""Request body for POST /api/v1/auth/login (form-data or JSON)."""
class PasswordResetRequest(BaseModel):
email: EmailStr
password: str = Field(..., min_length=1, max_length=128)
class TokenResponse(BaseModel):
"""Response body for successful auth (register/login/refresh)."""
access_token: str
token_type: str = "bearer"
expires_in: int # seconds
class PasswordResetConfirm(BaseModel):
token: str = Field(..., min_length=1)
new_password: str = Field(..., min_length=8)
class LogoutResponse(BaseModel):
"""Response body for POST /api/v1/auth/logout.
The token is deleted client-side; this endpoint exists for consistency and
future server-side blacklisting.
"""
message: str = "logged out"
class SwitchTenantRequest(BaseModel):
tenant_id: str = Field(..., min_length=1)
class RegisterResponse(BaseModel):
"""Response body for successful registration.
Returns the user info (without password) plus an access token.
"""
user: UserOut
access_token: str
token_type: str = "bearer"
expires_in: int
class AuthResponse(BaseModel):
user_id: str
email: str
name: str
role: str
tenant_id: str
tenant_name: str | None = None
# Late import to avoid circular dependency
from app.schemas.user import UserOut # noqa: E402
RegisterResponse.model_rebuild()
class MessageResponse(BaseModel):
message: str
+24 -18
View File
@@ -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
+23
View File
@@ -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
-45
View File
@@ -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"]
-39
View File
@@ -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"]
-72
View File
@@ -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",
]
-42
View File
@@ -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"]
+26
View File
@@ -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]
-56
View File
@@ -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"]
+20
View File
@@ -0,0 +1,20 @@
"""Tenant schemas."""
from __future__ import annotations
from pydantic import BaseModel, Field
class TenantCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
slug: str = Field(..., min_length=1, max_length=100)
class TenantResponse(BaseModel):
id: str
name: str
slug: str
class TenantUserAssign(BaseModel):
user_id: str = Field(..., min_length=1)
+20 -38
View File
@@ -1,55 +1,37 @@
"""Pydantic schemas for User endpoints."""
"""User schemas."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from app.models.user import UserRole
from pydantic import BaseModel, EmailStr, Field
class UserOut(BaseModel):
"""Response schema for a user (never includes password_hash)."""
model_config = ConfigDict(from_attributes=True)
id: int
class UserCreate(BaseModel):
email: EmailStr
name: str
role: UserRole
org_id: int
avatar_url: str | None = None
email_notifications: bool = True
created_at: datetime
name: str = Field(..., min_length=1, max_length=200)
password: str = Field(..., min_length=8)
role: str = Field(default="viewer")
is_active: bool = True
class UserUpdate(BaseModel):
"""Request body for PATCH /api/v1/users/{id} (admin or self)."""
name: str | None = Field(None, min_length=1, max_length=255)
avatar_url: str | None = Field(None, max_length=1024)
email_notifications: bool | None = None
role: UserRole | None = None # admin-only
name: str | None = Field(None, min_length=1, max_length=200)
role: str | None = None
is_active: bool | None = None
class UserCreateRequest(BaseModel):
"""Request body for POST /api/v1/users (admin-only)."""
email: EmailStr
password: str = Field(..., min_length=8, max_length=128)
name: str = Field(..., min_length=1, max_length=255)
role: UserRole = UserRole.sales_rep
class UserResponse(BaseModel):
id: str
email: str
name: str
role: str
is_active: bool
tenant_id: str
class UserListResponse(BaseModel):
"""Response schema for paginated user list."""
items: list[UserOut]
class PaginatedUsers(BaseModel):
items: list[UserResponse]
total: int
page: int
page_size: int
# Re-export for late import in auth.py
__all__ = ["UserCreateRequest", "UserListResponse", "UserOut", "UserUpdate"]
+1 -8
View File
@@ -1,8 +1 @@
"""Service layer for the CRM system.
Submodules are imported directly by routers via
`from app.services.<name> import <func>` to avoid circular-import issues
during application startup.
"""
__all__: list[str] = []
"""Service layer package."""
-83
View File
@@ -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"]
-101
View File
@@ -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",
]
-183
View File
@@ -1,183 +0,0 @@
"""Activity service: create, get, list, update, complete, soft-delete."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.activity import Activity, ActivityType
from app.schemas.activity import ActivityCompleteRequest, ActivityCreate, ActivityUpdate
from app.services._base import OrgScopedQuery
class ActivityNotFound(Exception):
"""Raised when an activity lookup fails."""
class NoParentException(Exception):
"""Raised when an activity is created without any parent (account/contact/deal)."""
async def _validate_parents(
db: AsyncSession,
*,
org_id: int,
account_id: int | None,
contact_id: int | None,
deal_id: int | None,
) -> None:
"""Ensure at least one parent is set and each provided id exists in org."""
if account_id is None and contact_id is None and deal_id is None:
raise NoParentException(
"Activity must reference at least one of: account_id, contact_id, deal_id"
)
if account_id is not None:
from app.models.account import Account
if await OrgScopedQuery(Account, db, org_id=org_id).get(account_id) is None:
raise NoParentException(f"Account {account_id} not found in this org")
if contact_id is not None:
from app.models.contact import Contact
if await OrgScopedQuery(Contact, db, org_id=org_id).get(contact_id) is None:
raise NoParentException(f"Contact {contact_id} not found in this org")
if deal_id is not None:
from app.models.deal import Deal
if await OrgScopedQuery(Deal, db, org_id=org_id).get(deal_id) is None:
raise NoParentException(f"Deal {deal_id} not found in this org")
async def create_activity(
db: AsyncSession,
payload: ActivityCreate,
*,
org_id: int,
owner_id: int,
) -> Activity:
"""Create a new activity. Validates parents (at least one required)."""
await _validate_parents(
db,
org_id=org_id,
account_id=payload.account_id,
contact_id=payload.contact_id,
deal_id=payload.deal_id,
)
activity = Activity(
org_id=org_id,
type=payload.type,
subject=payload.subject,
body=payload.body,
due_date=payload.due_date,
account_id=payload.account_id,
contact_id=payload.contact_id,
deal_id=payload.deal_id,
owner_id=owner_id,
)
db.add(activity)
await db.commit()
await db.refresh(activity)
return activity
async def get_activity(db: AsyncSession, activity_id: int, *, org_id: int) -> Activity | None:
"""Fetch a single activity by id."""
q = OrgScopedQuery(Activity, db, org_id=org_id)
return await q.get(activity_id)
async def list_activities(
db: AsyncSession,
*,
org_id: int,
skip: int = 0,
limit: int = 20,
type: ActivityType | None = None,
owner_id: int | None = None,
overdue: bool | None = None,
completed: bool | None = None,
) -> list[Activity]:
"""List activities with optional filters."""
scoped = OrgScopedQuery(Activity, db, org_id=org_id)
base = await scoped.list(
skip=skip,
limit=limit,
order_by=Activity.id,
type=type.value if hasattr(type, "value") else type,
owner_id=owner_id,
)
now = datetime.now(UTC).replace(tzinfo=None) # SQLite returns naive datetimes
if overdue is True:
base = [
a
for a in base
if a.due_date is not None and a.due_date < now and a.completed_at is None
]
if completed is True:
base = [a for a in base if a.completed_at is not None]
elif completed is False:
base = [a for a in base if a.completed_at is None]
return base
async def update_activity(
db: AsyncSession, activity: Activity, payload: ActivityUpdate
) -> Activity:
"""Apply partial updates to an activity."""
data = payload.model_dump(exclude_unset=True)
# If parents change, re-validate
if any(k in data for k in ("account_id", "contact_id", "deal_id")):
new_acc = data.get("account_id", activity.account_id)
new_con = data.get("contact_id", activity.contact_id)
new_deal = data.get("deal_id", activity.deal_id)
await _validate_parents(
db,
org_id=activity.org_id,
account_id=new_acc,
contact_id=new_con,
deal_id=new_deal,
)
for field, value in data.items():
if value is not None:
setattr(activity, field, value)
await db.commit()
await db.refresh(activity)
return activity
async def complete_activity(
db: AsyncSession,
activity: Activity,
payload: ActivityCompleteRequest,
) -> Activity:
"""Mark activity as completed; set completed_at and optionally outcome."""
activity.completed_at = datetime.now(UTC)
if payload.outcome is not None:
# We store outcome in body (free-form text) for v1
existing = activity.body or ""
outcome_line = f"\n[Outcome] {payload.outcome}"
activity.body = (existing + outcome_line).strip()
await db.commit()
await db.refresh(activity)
return activity
async def soft_delete_activity(db: AsyncSession, activity: Activity) -> Activity:
"""Soft-delete an activity."""
activity.deleted_at = datetime.now(UTC)
await db.commit()
await db.refresh(activity)
return activity
__all__ = [
"ActivityNotFound",
"NoParentException",
"complete_activity",
"create_activity",
"get_activity",
"list_activities",
"soft_delete_activity",
"update_activity",
]
+248 -87
View File
@@ -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()
-128
View File
@@ -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",
]
-61
View File
@@ -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"]
-186
View File
@@ -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",
]
-135
View File
@@ -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",
]
+100
View File
@@ -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()
-147
View File
@@ -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",
]
+96
View File
@@ -0,0 +1,96 @@
"""Tenant management service."""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.tenant import Tenant
from app.models.user import User, UserTenant
class TenantService:
"""Handles tenant CRUD and user-tenant membership."""
async def list_tenants_for_user(
self,
db: AsyncSession,
user_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List all tenants a user belongs to."""
q = (
select(Tenant, UserTenant.is_default)
.join(UserTenant, UserTenant.tenant_id == Tenant.id)
.where(UserTenant.user_id == user_id)
)
result = await db.execute(q)
rows = result.all()
return [
{
"id": str(t.id),
"name": t.name,
"slug": t.slug,
"is_default": is_default,
}
for t, is_default in rows
]
async def create_tenant(
self,
db: AsyncSession,
name: str,
slug: str,
) -> Tenant:
"""Create a new tenant."""
tenant = Tenant(name=name, slug=slug)
db.add(tenant)
await db.flush()
return tenant
async def get_tenant(self, db: AsyncSession, tenant_id: uuid.UUID) -> Tenant | None:
"""Get a tenant by ID."""
q = select(Tenant).where(Tenant.id == tenant_id)
result = await db.execute(q)
return result.scalar_one_or_none()
async def list_tenant_users(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
) -> list[dict[str, Any]]:
"""List users in a tenant."""
q = select(User).where(User.tenant_id == tenant_id)
result = await db.execute(q)
users = result.scalars().all()
return [
{
"id": str(u.id),
"email": u.email,
"name": u.name,
"role": u.role,
"is_active": u.is_active,
}
for u in users
]
async def assign_user_to_tenant(
self,
db: AsyncSession,
tenant_id: uuid.UUID,
user_id: uuid.UUID,
) -> UserTenant:
"""Assign a user to a tenant."""
ut = UserTenant(
user_id=user_id,
tenant_id=tenant_id,
is_default=False,
)
db.add(ut)
await db.flush()
return ut
tenant_service = TenantService()
+143 -99
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Utilities package."""
-1
View File
@@ -1 +0,0 @@
"""Static frontend assets (Phase 4c fills this)."""
-29
View File
@@ -1,29 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>404 Seite nicht gefunden</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/app.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
</head>
<body class="min-h-screen flex items-center justify-center bg-slate-50 p-4">
<div class="crm-card text-center max-w-md" x-data="{}">
<h1 class="text-6xl font-bold text-slate-900 mb-2">404</h1>
<p class="text-xl text-slate-700 mb-2">Seite nicht gefunden</p>
<p class="text-sm text-slate-500 mb-6">
Die angeforderte URL existiert nicht oder du bist nicht berechtigt.
</p>
<a href="/dashboard.html" class="btn-primary inline-block mb-2">Zum Dashboard</a>
<a href="/app.html" class="btn-secondary inline-block">Oder zur App-Startseite</a>
<p class="text-xs text-slate-400 mt-6">
Falls du eine bestimmte Page suchst: Accounts, Contacts, Pipeline oder Activities.
</p>
</div>
</body>
</html>
-350
View File
@@ -1,350 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CRM Account Detail</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/app.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script type="module" src="/js/store.js"></script>
<script type="module" src="/js/components/notifications.js"></script>
</head>
<body class="min-h-screen bg-slate-50">
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
<template x-for="n in items" :key="n.id">
<div class="crm-toast" :class="'toast-' + n.type"
@click="dismiss(n.id)" x-text="n.message"></div>
</template>
</div>
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
<aside class="crm-sidebar">
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
<a href="/app.html" class="!text-white !border-0">CRM</a>
</div>
<nav class="mt-2">
<a href="/app.html">Dashboard</a>
<a href="/accounts.html" class="active">Accounts</a>
<a href="/contacts.html">Contacts</a>
<a href="/pipeline.html">Pipeline</a>
<a href="/activities.html">Activities</a>
<a href="/settings-profile.html">Profil</a>
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
</nav>
</aside>
<div class="flex-1 flex flex-col">
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
<h1 class="text-xl font-semibold text-slate-800">Account-Detail</h1>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-600">
<strong class="text-slate-900"
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
</span>
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
</div>
</header>
<main class="flex-1 p-6" x-data="accountDetail()" x-init="init()">
<!-- Loading -->
<div x-show="loading" class="crm-card text-center py-12">
<div class="crm-spinner mx-auto"></div>
<p class="text-slate-500 mt-2">Lade Account …</p>
</div>
<div x-show="!loading && account">
<!-- Header -->
<div class="crm-card mb-4">
<div class="flex items-start justify-between flex-wrap gap-2">
<div>
<h2 class="text-2xl font-bold text-slate-900" x-text="account?.name"></h2>
<p class="text-slate-500 text-sm mt-1">
<span class="crm-badge badge-blue" x-text="account?.industry || ''"></span>
<span class="crm-badge badge-gray ml-1" x-text="account?.size || ''"></span>
<span class="ml-2">Owner #<span x-text="account?.owner_id"></span></span>
</p>
<p class="text-blue-600 underline mt-2" x-show="account?.website">
<a :href="account?.website" target="_blank" x-text="account?.website"></a>
</p>
</div>
<div class="flex gap-2">
<button @click="openEdit()" class="btn-secondary">Bearbeiten</button>
<button @click="confirmDelete()" class="btn-danger">Löschen</button>
</div>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-200 mb-4" role="tablist">
<button @click="tab='info'" :class="tabBtn('info')">Info</button>
<button @click="loadContacts()" :class="tabBtn('contacts')">Contacts</button>
<button @click="loadDeals()" :class="tabBtn('deals')">Deals</button>
<button @click="loadActivities()" :class="tabBtn('activities')">Activities</button>
<button @click="loadNotes()" :class="tabBtn('notes')">Notes</button>
</div>
<!-- Info -->
<div x-show="tab === 'info'" class="crm-card">
<dl class="grid grid-cols-2 gap-4 text-sm">
<div><dt class="text-slate-500">Name</dt><dd class="font-medium" x-text="account?.name"></dd></div>
<div><dt class="text-slate-500">Branche</dt><dd x-text="account?.industry || ''"></dd></div>
<div><dt class="text-slate-500">Größe</dt><dd x-text="account?.size || ''"></dd></div>
<div><dt class="text-slate-500">Website</dt><dd x-text="account?.website || ''"></dd></div>
<div><dt class="text-slate-500">Org-ID</dt><dd x-text="account?.org_id"></dd></div>
<div><dt class="text-slate-500">Owner</dt><dd x-text="account?.owner_id"></dd></div>
<div><dt class="text-slate-500">Erstellt</dt><dd x-text="formatDateTime(account?.created_at)"></dd></div>
<div><dt class="text-slate-500">Aktualisiert</dt><dd x-text="formatDateTime(account?.updated_at)"></dd></div>
</dl>
</div>
<!-- Contacts -->
<div x-show="tab === 'contacts'" class="crm-card">
<div x-show="loadingSub" class="text-center py-4">
<div class="crm-spinner mx-auto"></div>
</div>
<table class="crm-table" x-show="!loadingSub">
<thead><tr><th>Name</th><th>Email</th><th>Phone</th></tr></thead>
<tbody>
<template x-for="c in subLists.contacts" :key="c.id">
<tr class="cursor-pointer"
@click="window.location.href = '/contacts-detail.html?id=' + c.id">
<td x-text="(c.first_name || '') + ' ' + (c.last_name || '')"></td>
<td x-text="c.email || ''"></td>
<td x-text="c.phone || ''"></td>
</tr>
</template>
<tr x-show="!loadingSub && subLists.contacts.length === 0">
<td colspan="3" class="text-center text-slate-500 py-3">Keine Contacts.</td>
</tr>
</tbody>
</table>
</div>
<!-- Deals -->
<div x-show="tab === 'deals'" class="crm-card">
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
<table class="crm-table" x-show="!loadingSub">
<thead><tr><th>Title</th><th>Stage</th><th>Value</th><th>Close</th></tr></thead>
<tbody>
<template x-for="d in subLists.deals" :key="d.id">
<tr>
<td x-text="d.title"></td>
<td><span class="crm-badge badge-blue" x-text="d.stage"></span></td>
<td x-text="formatCurrency(d.value)"></td>
<td x-text="d.close_date || ''"></td>
</tr>
</template>
<tr x-show="!loadingSub && subLists.deals.length === 0">
<td colspan="4" class="text-center text-slate-500 py-3">Keine Deals.</td>
</tr>
</tbody>
</table>
</div>
<!-- Activities -->
<div x-show="tab === 'activities'" class="crm-card">
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
<ul class="divide-y divide-slate-100" x-show="!loadingSub">
<template x-for="a in subLists.activities" :key="a.id">
<li class="py-2 flex items-start gap-3">
<span class="crm-badge badge-blue" x-text="a.type"></span>
<div class="flex-1">
<p class="text-sm font-medium" x-text="a.subject"></p>
<p class="text-xs text-slate-500" x-text="a.body || ''"></p>
</div>
</li>
</template>
<li x-show="!loadingSub && subLists.activities.length === 0"
class="text-center text-slate-500 py-3 text-sm">Keine Activities.</li>
</ul>
</div>
<!-- Notes -->
<div x-show="tab === 'notes'" class="crm-card">
<div x-show="loadingSub" class="text-center py-4"><div class="crm-spinner mx-auto"></div></div>
<ul class="space-y-2" x-show="!loadingSub">
<template x-for="n in subLists.notes" :key="n.id">
<li class="border-l-4 border-blue-500 pl-3 py-1">
<p class="text-sm" x-text="n.body"></p>
<p class="text-xs text-slate-400" x-text="formatDateTime(n.created_at)"></p>
</li>
</template>
<li x-show="!loadingSub && subLists.notes.length === 0"
class="text-center text-slate-500 py-3 text-sm">Keine Notes.</li>
</ul>
</div>
</div>
<!-- Edit modal -->
<div x-show="showEdit" x-cloak class="crm-modal-backdrop" @click.self="closeEdit()">
<div class="crm-modal p-6">
<h2 class="text-lg font-semibold mb-4">Account bearbeiten</h2>
<form @submit.prevent="submitEdit()">
<div class="mb-3">
<label class="crm-label">Name *</label>
<input type="text" required x-model="editForm.name" class="crm-input" />
</div>
<div class="mb-3">
<label class="crm-label">Website</label>
<input type="text" x-model="editForm.website" class="crm-input" />
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="crm-label">Branche</label>
<input type="text" x-model="editForm.industry" class="crm-input" />
</div>
<div>
<label class="crm-label">Größe</label>
<input type="text" x-model="editForm.size" class="crm-input" />
</div>
</div>
<div x-show="editError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
x-text="editError"></div>
<div class="flex gap-2 justify-end">
<button type="button" @click="closeEdit()" class="btn-secondary">Abbrechen</button>
<button type="submit" :disabled="saving" class="btn-primary"
x-text="saving ? 'Speichere …' : 'Speichern'"></button>
</div>
</form>
</div>
</div>
</main>
</div>
</div>
<script>
function accountDetail() {
return {
id: null,
account: null,
loading: true,
tab: 'info',
subLists: { contacts: [], deals: [], activities: [], notes: [] },
loadingSub: false,
showEdit: false,
editForm: { name: '', website: '', industry: '', size: '' },
editError: '',
saving: false,
async init() {
const params = new URLSearchParams(window.location.search);
this.id = params.get('id');
if (!this.id) { window.location.href = '/accounts.html'; return; }
await this.load();
},
async load() {
this.loading = true;
try {
const { api } = await import('/js/api.js');
this.account = await api.get(`/accounts/${this.id}`);
this.editForm = {
name: this.account.name || '',
website: this.account.website || '',
industry: this.account.industry || '',
size: this.account.size || '',
};
} catch (err) {
Alpine.store('notifications').error(err.message || 'Fehler beim Laden.');
} finally { this.loading = false; }
},
async loadContacts() {
this.tab = 'contacts';
this.loadingSub = true;
try {
const { api } = await import('/js/api.js');
const r = await api.get(`/contacts/?account_id=${this.id}&limit=200`);
this.subLists.contacts = Array.isArray(r) ? r : (r.items || []);
} finally { this.loadingSub = false; }
},
async loadDeals() {
this.tab = 'deals';
this.loadingSub = true;
try {
const { api } = await import('/js/api.js');
const r = await api.get(`/deals/?account_id=${this.id}&limit=200`);
// Backend may not have account_id filter; client-filter as fallback
const list = Array.isArray(r) ? r : (r.items || []);
this.subLists.deals = list.filter(d => String(d.account_id) === String(this.id));
} finally { this.loadingSub = false; }
},
async loadActivities() {
this.tab = 'activities';
this.loadingSub = true;
try {
const { api } = await import('/js/api.js');
const r = await api.get(`/activities/?account_id=${this.id}&limit=200`);
this.subLists.activities = Array.isArray(r) ? r : (r.items || []);
} finally { this.loadingSub = false; }
},
async loadNotes() {
this.tab = 'notes';
this.loadingSub = true;
try {
const { api } = await import('/js/api.js');
const r = await api.get(`/notes/?parent_type=account&parent_id=${this.id}`);
this.subLists.notes = Array.isArray(r) ? r : (r.items || []);
} catch (err) {
// /notes may not be paginated; show empty on error
this.subLists.notes = [];
} finally { this.loadingSub = false; }
},
tabBtn(t) {
return this.tab === t
? 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-blue-600 text-blue-600'
: 'flex-1 py-2 px-4 text-sm font-medium border-b-2 border-transparent text-slate-500 hover:text-slate-700';
},
openEdit() { this.showEdit = true; this.editError = ''; },
closeEdit() { this.showEdit = false; },
async submitEdit() {
this.saving = true;
this.editError = '';
try {
const { api } = await import('/js/api.js');
const payload = { name: this.editForm.name };
if (this.editForm.website) payload.website = this.editForm.website;
if (this.editForm.industry) payload.industry = this.editForm.industry;
if (this.editForm.size) payload.size = this.editForm.size;
await api.patch(`/accounts/${this.id}`, payload);
Alpine.store('notifications').success('Account aktualisiert.');
this.showEdit = false;
await this.load();
} catch (err) {
this.editError = err.message || 'Fehler beim Speichern.';
} finally { this.saving = false; }
},
async confirmDelete() {
if (!confirm('Account wirklich löschen (Soft-Delete)?')) return;
try {
const { api } = await import('/js/api.js');
await api.del(`/accounts/${this.id}`);
Alpine.store('notifications').success('Account gelöscht.');
window.location.href = '/accounts.html';
} catch (err) {
Alpine.store('notifications').error(err.message || 'Löschen fehlgeschlagen.');
}
},
formatCurrency(v) {
return Number(v || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
},
formatDateTime(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString('de-DE'); } catch { return iso; }
},
};
}
</script>
</body>
</html>
-189
View File
@@ -1,189 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CRM Accounts</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/app.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script type="module" src="/js/store.js"></script>
<script type="module" src="/js/components/notifications.js"></script>
<script type="module" src="/components/account-list.js"></script>
</head>
<body class="min-h-screen bg-slate-50">
<!-- Toast container -->
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
<template x-for="n in items" :key="n.id">
<div class="crm-toast" :class="'toast-' + n.type"
@click="dismiss(n.id)" x-text="n.message"></div>
</template>
</div>
<!-- Layout shell -->
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
<aside class="crm-sidebar">
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
<a href="/app.html" class="!text-white !border-0">CRM</a>
</div>
<nav class="mt-2">
<a href="/app.html">Dashboard</a>
<a href="/accounts.html" class="active">Accounts</a>
<a href="/contacts.html">Contacts</a>
<a href="/pipeline.html">Pipeline</a>
<a href="/activities.html">Activities</a>
<a href="/settings-profile.html">Profil</a>
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
</nav>
</aside>
<div class="flex-1 flex flex-col">
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
<h1 class="text-xl font-semibold text-slate-800">Accounts</h1>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-600">
<strong class="text-slate-900"
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
</span>
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
</div>
</header>
<main class="flex-1 p-6" x-data="accountList()" x-init="init()">
<!-- Toolbar -->
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
<div class="flex-1 min-w-[12rem]">
<label class="crm-label" for="q">Suche (Name)</label>
<input id="q" type="text" x-model="q" @keyup.enter="search()"
class="crm-input" placeholder="z.B. Acme Corp" />
</div>
<div>
<label class="crm-label" for="industry">Branche</label>
<select id="industry" x-model="industry" class="crm-input">
<option value="">Alle</option>
<template x-for="i in industries" :key="i">
<option :value="i" x-text="i"></option>
</template>
</select>
</div>
<div>
<label class="crm-label" for="size">Größe</label>
<select id="size" x-model="size" class="crm-input">
<option value="">Alle</option>
<template x-for="s in sizes" :key="s">
<option :value="s" x-text="s"></option>
</template>
</select>
</div>
<button @click="search()" class="btn-primary">Suchen</button>
<button @click="openCreate()" class="btn-primary ml-auto">+ New Account</button>
</div>
<!-- Loading -->
<div x-show="loading" class="crm-card text-center py-8">
<div class="crm-spinner mx-auto"></div>
<p class="text-slate-500 mt-2 text-sm">Lade Accounts …</p>
</div>
<!-- Table -->
<div x-show="!loading" class="overflow-x-auto">
<table class="crm-table">
<thead>
<tr>
<th>Name</th>
<th>Branche</th>
<th>Größe</th>
<th>Website</th>
<th>Owner</th>
</tr>
</thead>
<tbody>
<template x-for="a in accounts" :key="a.id">
<tr class="cursor-pointer"
@click="window.location.href = '/accounts-detail.html?id=' + a.id">
<td class="font-medium" x-text="a.name"></td>
<td><span class="crm-badge badge-blue" x-text="a.industry || ''"></span></td>
<td x-text="a.size || ''"></td>
<td class="text-blue-600 underline" x-text="a.website || ''"></td>
<td x-text="a.owner_id"></td>
</tr>
</template>
<tr x-show="!loading && accounts.length === 0">
<td colspan="5" class="text-center text-slate-500 py-6">
Keine Accounts gefunden.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
x-show="!loading">
<span x-text="pageInfo"></span>
<div class="flex gap-2">
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
</div>
</div>
<!-- Create modal -->
<div x-show="showCreate" x-cloak class="crm-modal-backdrop"
@click.self="closeCreate()">
<div class="crm-modal p-6">
<h2 class="text-lg font-semibold mb-4">Neuer Account</h2>
<form @submit.prevent="submitCreate()">
<div class="mb-3">
<label class="crm-label">Name *</label>
<input type="text" required minlength="1" maxlength="255"
x-model="createForm.name" class="crm-input" />
</div>
<div class="mb-3">
<label class="crm-label">Website</label>
<input type="text" maxlength="512"
x-model="createForm.website" class="crm-input" />
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="crm-label">Branche</label>
<select x-model="createForm.industry" class="crm-input">
<option value="">(keine)</option>
<template x-for="i in industries" :key="i">
<option :value="i" x-text="i"></option>
</template>
</select>
</div>
<div>
<label class="crm-label">Größe</label>
<select x-model="createForm.size" class="crm-input">
<option value="">(keine)</option>
<template x-for="s in sizes" :key="s">
<option :value="s" x-text="s"></option>
</template>
</select>
</div>
</div>
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
x-text="createError"></div>
<div class="flex gap-2 justify-end">
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
<button type="submit" :disabled="creating" class="btn-primary">
<span x-text="creating ? 'Speichere …' : 'Anlegen'"></span>
</button>
</div>
</form>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
-220
View File
@@ -1,220 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CRM Activities</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/app.css" />
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script type="module" src="/js/store.js"></script>
<script type="module" src="/js/components/notifications.js"></script>
<script type="module" src="/components/activity-list.js"></script>
</head>
<body class="min-h-screen bg-slate-50">
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
<template x-for="n in items" :key="n.id">
<div class="crm-toast" :class="'toast-' + n.type"
@click="dismiss(n.id)" x-text="n.message"></div>
</template>
</div>
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
<aside class="crm-sidebar">
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
<a href="/app.html" class="!text-white !border-0">CRM</a>
</div>
<nav class="mt-2">
<a href="/app.html">Dashboard</a>
<a href="/accounts.html">Accounts</a>
<a href="/contacts.html">Contacts</a>
<a href="/pipeline.html">Pipeline</a>
<a href="/activities.html" class="active">Activities</a>
<a href="/settings-profile.html">Profil</a>
<a href="/settings-users.html" x-show="$store.auth.isAdmin">Users (Admin)</a>
<a href="/settings-org.html" x-show="$store.auth.isAdmin">Org (Admin)</a>
</nav>
</aside>
<div class="flex-1 flex flex-col">
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
<h1 class="text-xl font-semibold text-slate-800">Activities</h1>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-600">
<strong class="text-slate-900"
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
</span>
<button class="btn-secondary" @click="$store.auth.logout()">Logout</button>
</div>
</header>
<main class="flex-1 p-6" x-data="activityList()" x-init="init()">
<!-- Toolbar -->
<div class="crm-card mb-4 flex flex-wrap items-end gap-3">
<div>
<label class="crm-label">Typ</label>
<select x-model="type" class="crm-input">
<option value="">Alle</option>
<template x-for="t in types" :key="t">
<option :value="t" x-text="t"></option>
</template>
</select>
</div>
<div>
<label class="crm-label">Overdue</label>
<select x-model="overdue" class="crm-input">
<option value="">Alle</option>
<option value="true">Nur Overdue</option>
<option value="false">Nicht Overdue</option>
</select>
</div>
<div>
<label class="crm-label">Status</label>
<select x-model="completed" class="crm-input">
<option value="">Alle</option>
<option value="false">Offen</option>
<option value="true">Erledigt</option>
</select>
</div>
<button @click="applyFilters()" class="btn-primary">Filtern</button>
<button @click="openCreate()" class="btn-primary ml-auto">+ New Activity</button>
</div>
<!-- Loading -->
<div x-show="loading" class="crm-card text-center py-8">
<div class="crm-spinner mx-auto"></div>
<p class="text-slate-500 mt-2 text-sm">Lade Activities …</p>
</div>
<!-- Table -->
<div x-show="!loading" class="overflow-x-auto">
<table class="crm-table">
<thead>
<tr>
<th>Typ</th>
<th>Subject</th>
<th>Fällig</th>
<th>Owner</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<template x-for="a in activities" :key="a.id">
<tr class="cursor-pointer"
:class="{ 'row-overdue': isOverdue(a) }"
@click="openDetail(a)">
<td><span class="crm-badge" :class="typeBadge(a.type)" x-text="a.type"></span></td>
<td class="font-medium" x-text="a.subject"></td>
<td x-text="formatDateTime(a.due_date)"></td>
<td x-text="a.owner_id"></td>
<td>
<span x-show="isCompleted(a)" class="crm-badge badge-green">Erledigt</span>
<span x-show="!isCompleted(a) && isOverdue(a)" class="crm-badge badge-red">Overdue</span>
<span x-show="!isCompleted(a) && !isOverdue(a)" class="crm-badge badge-yellow">Offen</span>
</td>
</tr>
</template>
<tr x-show="!loading && activities.length === 0">
<td colspan="5" class="text-center text-slate-500 py-6">
Keine Activities gefunden.
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div class="flex items-center justify-between mt-4 text-sm text-slate-600"
x-show="!loading">
<span x-text="pageInfo"></span>
<div class="flex gap-2">
<button @click="prevPage()" :disabled="skip === 0" class="btn-secondary">← Zurück</button>
<button @click="nextPage()" :disabled="skip + limit >= total" class="btn-secondary">Weiter →</button>
</div>
</div>
<!-- Create modal -->
<div x-show="showCreate" x-cloak class="crm-modal-backdrop" @click.self="closeCreate()">
<div class="crm-modal p-6">
<h2 class="text-lg font-semibold mb-4">Neue Activity</h2>
<form @submit.prevent="submitCreate()">
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="crm-label">Typ *</label>
<select x-model="createForm.type" class="crm-input" required>
<template x-for="t in types" :key="t">
<option :value="t" x-text="t"></option>
</template>
</select>
</div>
<div>
<label class="crm-label">Fällig am</label>
<input type="datetime-local"
x-model="createForm.due_date" class="crm-input" />
</div>
</div>
<div class="mb-3">
<label class="crm-label">Subject *</label>
<input type="text" required maxlength="255"
x-model="createForm.subject" class="crm-input" />
</div>
<div class="mb-3">
<label class="crm-label">Body</label>
<textarea x-model="createForm.body" class="crm-input" rows="2"></textarea>
</div>
<div class="text-xs text-slate-500 mb-3">
Polymorph-Parent: mindestens eine ID angeben.
</div>
<div class="grid grid-cols-3 gap-2 mb-3">
<div>
<label class="crm-label">Account-ID</label>
<input type="number" min="1" x-model="createForm.account_id" class="crm-input" />
</div>
<div>
<label class="crm-label">Contact-ID</label>
<input type="number" min="1" x-model="createForm.contact_id" class="crm-input" />
</div>
<div>
<label class="crm-label">Deal-ID</label>
<input type="number" min="1" x-model="createForm.deal_id" class="crm-input" />
</div>
</div>
<div x-show="createError" class="mb-3 p-2 bg-red-50 text-red-700 text-sm rounded"
x-text="createError"></div>
<div class="flex gap-2 justify-end">
<button type="button" @click="closeCreate()" class="btn-secondary">Abbrechen</button>
<button type="submit" :disabled="creating" class="btn-primary"
x-text="creating ? 'Speichere …' : 'Anlegen'"></button>
</div>
</form>
</div>
</div>
<!-- Detail modal -->
<div x-show="showDetail" x-cloak class="crm-modal-backdrop" @click.self="closeDetail()">
<div class="crm-modal p-6" x-show="selected">
<h2 class="text-lg font-semibold mb-2" x-text="selected?.subject"></h2>
<p class="text-xs text-slate-500 mb-3">
<span class="crm-badge" :class="typeBadge(selected?.type)" x-text="selected?.type"></span>
<span class="ml-2">Fällig: <span x-text="formatDateTime(selected?.due_date)"></span></span>
</p>
<p class="text-sm text-slate-700 mb-4" x-text="selected?.body || '(kein Body)'"></p>
<div class="flex gap-2 justify-end">
<button type="button" @click="closeDetail()" class="btn-secondary">Schließen</button>
<button x-show="!selected?.completed_at" @click="completeSelected()"
:disabled="completing" class="btn-primary"
x-text="completing ? 'Speichere …' : 'Als erledigt markieren'"></button>
</div>
</div>
</div>
</main>
</div>
</div>
</body>
</html>
-145
View File
@@ -1,145 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CRM Dashboard</title>
<!-- Tailwind CSS via CDN (Dev) -->
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/css/app.css" />
<!-- Alpine.js -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"></script>
<!-- App stores + dashboard component -->
<script type="module" src="/js/store.js"></script>
<script type="module" src="/js/components/notifications.js"></script>
<script type="module" src="/components/app-shell.js"></script>
<script type="module" src="/components/dashboard-kpis.js"></script>
</head>
<body class="min-h-screen bg-slate-50">
<!-- === TOAST CONTAINER === -->
<div x-data="toastContainer()" class="crm-toast-container" x-cloak>
<template x-for="n in items" :key="n.id">
<div class="crm-toast" :class="'toast-' + n.type"
@click="dismiss(n.id)" x-text="n.message"></div>
</template>
</div>
<!-- === LAYOUT SHELL === -->
<!-- Auth-Gate + appShell: store.js's init() redirects to /index.html when no JWT. -->
<div x-data="{ init: () => Alpine.store('auth').init() }" x-init="init()" class="flex min-h-screen">
<div x-data="appShell('dashboard')" x-cloak class="flex min-h-screen flex-1">
<!-- === SIDEBAR === -->
<aside class="crm-sidebar">
<div class="px-6 py-4 text-white text-lg font-bold border-b border-slate-700">
<a href="/app.html" class="!text-white !border-0">CRM</a>
</div>
<nav class="mt-2">
<a href="/app.html" :class="{ active: active === 'dashboard' }">Dashboard</a>
<a href="/accounts.html" :class="{ active: active === 'accounts' }">Accounts</a>
<a href="/contacts.html" :class="{ active: active === 'contacts' }">Contacts</a>
<a href="/pipeline.html" :class="{ active: active === 'pipeline' }">Pipeline</a>
<a href="/activities.html" :class="{ active: active === 'activities' }">Activities</a>
<a href="/settings-profile.html":class="{ active: active === 'settings-profile' }">Profil</a>
<a href="/settings-users.html" :class="{ active: active === 'settings-users' }"
x-show="$store.auth.isAdmin">Users (Admin)</a>
<a href="/settings-org.html" :class="{ active: active === 'settings-org' }"
x-show="$store.auth.isAdmin">Org (Admin)</a>
</nav>
</aside>
<!-- === MAIN AREA === -->
<div class="flex-1 flex flex-col">
<!-- Top navigation -->
<header class="bg-white border-b border-slate-200 px-6 py-3 flex items-center justify-between">
<h1 class="text-xl font-semibold text-slate-800" x-text="title"></h1>
<div class="flex items-center gap-3 text-sm">
<span class="text-slate-600">
Angemeldet als
<strong class="text-slate-900"
x-text="$store.auth.user ? $store.auth.user.name : '…'"></strong>
</span>
<button class="btn-secondary" @click="logout()">Logout</button>
</div>
</header>
<!-- === DASHBOARD CONTENT (default landing) === -->
<main class="flex-1 p-6" x-data="dashboardKpis()" x-init="init()">
<!-- Loading -->
<div x-show="loading" class="crm-card text-center py-8">
<div class="crm-spinner mx-auto"></div>
<p class="text-slate-500 mt-2 text-sm">Lade Dashboard …</p>
</div>
<!-- Error -->
<div x-show="error" class="crm-card text-red-700 bg-red-50">
<span x-text="error"></span>
</div>
<div x-show="!loading && !error">
<!-- KPI Grid -->
<h2 class="text-lg font-semibold mb-3">Kennzahlen</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="crm-card">
<p class="text-xs text-slate-500">Open Deals</p>
<p class="text-2xl font-bold text-slate-900" x-text="kpis.open_deals ?? ''"></p>
</div>
<div class="crm-card">
<p class="text-xs text-slate-500">Pipeline Value</p>
<p class="text-2xl font-bold text-slate-900"
x-text="formatCurrency(kpis.pipeline_value)"></p>
</div>
<div class="crm-card">
<p class="text-xs text-slate-500">Won this Month</p>
<p class="text-2xl font-bold text-slate-900" x-text="kpis.won_this_month ?? ''"></p>
</div>
<div class="crm-card">
<p class="text-xs text-slate-500">Conversion Rate</p>
<p class="text-2xl font-bold text-slate-900"
x-text="formatPercent(kpis.conversion_rate)"></p>
</div>
</div>
<!-- Quick actions -->
<h2 class="text-lg font-semibold mb-3">Schnellaktionen</h2>
<div class="flex gap-2 mb-6">
<a href="/accounts.html" class="btn-primary">+ New Account</a>
<a href="/contacts.html" class="btn-primary">+ New Contact</a>
<a href="/pipeline.html" class="btn-primary">+ New Deal</a>
</div>
<!-- Activity feed -->
<h2 class="text-lg font-semibold mb-3">Letzte Aktivitäten</h2>
<div class="crm-card">
<template x-for="a in feed" :key="a.id">
<div class="py-2 border-b border-slate-100 last:border-0">
<div class="flex justify-between items-center">
<p class="text-sm">
<span class="crm-badge" :class="badgeColor(a.type)" x-text="a.type"></span>
<span class="ml-2 font-medium text-slate-800" x-text="a.subject"></span>
</p>
<span class="text-xs text-slate-400" x-text="formatRelative(a.created_at)"></span>
</div>
<p class="text-xs text-slate-500 mt-1" x-text="a.body || ''"></p>
</div>
</template>
<p x-show="feed.length === 0" class="text-sm text-slate-500 text-center py-4">
Keine Aktivitäten.
</p>
</div>
</div>
</main>
</div>
</div>
</div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More