commit f6a54d6f9348933d2f6aa215a76ce50af1005e71 Author: AFFAANh Date: Sat Aug 1 10:28:41 2026 +0530 Initial commit: Maskan CRM backend Independent FastAPI backend for Maskan CRM. Owns contacts, organizations, leads, pipelines, activities, products, quotes, users, permissions, audit records and first-party integration credentials, with Alembic migrations against PostgreSQL. Co-Authored-By: Claude Opus 5 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6e8753d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +.git +.gitignore +.venv +.venv311 +venv +__pycache__ +*.py[cod] +*.egg-info +.pytest_cache +.ruff_cache +.mypy_cache +.coverage +htmlcov +dist +build +node_modules +.env +.env.* +!.env.example +tests +*.db +*.sqlite +*.sqlite3 +*.log diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..9e6f737 --- /dev/null +++ b/.env.development.example @@ -0,0 +1,17 @@ +MASKAN_CRM_ENV=development +MASKAN_CRM_MODE=suite +MASKAN_CRM_CORS_ORIGINS=https://crm-dev.example.com +MASKAN_CRM_JWT_SECRET=replace-with-a-development-secret-at-least-32-characters + +DB_HOST=development-postgres.example.internal +DB_PORT=5432 +DB_NAME=maskan_crm +DB_USER=maskan_crm +DB_PASSWORD=replace-with-development-database-password +DB_SSLMODE=require + +MASKAN_CRM_BOOTSTRAP_WORKSPACE=maskan +MASKAN_CRM_BOOTSTRAP_COMPANY=Maskan Technologies +MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL=owner@example.com +MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD=replace-with-development-owner-password +MASKAN_CRM_SEED_DEMO=true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cb0d8fb --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +MASKAN_CRM_ENV=development +MASKAN_CRM_MODE=standalone +MASKAN_CRM_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174 +MASKAN_CRM_JWT_SECRET=replace-with-a-random-value-at-least-32-characters +MASKAN_CRM_ACCESS_TOKEN_MINUTES=30 + +DB_HOST=127.0.0.1 +DB_PORT=5433 +DB_NAME=maskan_crm +DB_USER=postgres +DB_PASSWORD=postgres +DB_SSLMODE=disable + +MASKAN_CRM_BOOTSTRAP_WORKSPACE=maskan +MASKAN_CRM_BOOTSTRAP_COMPANY=Maskan Technologies +MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL=owner@example.com +MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD=replace-before-first-run +MASKAN_CRM_SEED_DEMO=true diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..6a67c4d --- /dev/null +++ b/.env.local.example @@ -0,0 +1,17 @@ +MASKAN_CRM_ENV=development +MASKAN_CRM_MODE=standalone +MASKAN_CRM_CORS_ORIGINS=http://127.0.0.1:5174,http://localhost:5174 +MASKAN_CRM_JWT_SECRET=local-development-secret-change-me-123456789 + +DB_HOST=127.0.0.1 +DB_PORT=5433 +DB_NAME=maskan_crm +DB_USER=postgres +DB_PASSWORD=postgres +DB_SSLMODE=disable + +MASKAN_CRM_BOOTSTRAP_WORKSPACE=maskan +MASKAN_CRM_BOOTSTRAP_COMPANY=Maskan Technologies +MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL=owner@example.com +MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD=change-this-before-first-run +MASKAN_CRM_SEED_DEMO=true diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..2959e10 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,18 @@ +MASKAN_CRM_ENV=production +MASKAN_CRM_MODE=standalone +MASKAN_CRM_CORS_ORIGINS=https://crm.example.com +MASKAN_CRM_JWT_SECRET=replace-from-your-production-secret-manager +MASKAN_CRM_ACCESS_TOKEN_MINUTES=30 + +DB_HOST=production-postgres.example.internal +DB_PORT=5432 +DB_NAME=maskan_crm +DB_USER=maskan_crm +DB_PASSWORD=replace-from-your-production-secret-manager +DB_SSLMODE=require + +MASKAN_CRM_BOOTSTRAP_WORKSPACE=maskan +MASKAN_CRM_BOOTSTRAP_COMPANY=Maskan Technologies +MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL=owner@example.com +MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD=replace-from-your-production-secret-manager +MASKAN_CRM_SEED_DEMO=false diff --git a/.env.testing.example b/.env.testing.example new file mode 100644 index 0000000..19d39c4 --- /dev/null +++ b/.env.testing.example @@ -0,0 +1,11 @@ +MASKAN_CRM_ENV=testing +MASKAN_CRM_MODE=standalone +MASKAN_CRM_CORS_ORIGINS=http://127.0.0.1:5174 +MASKAN_CRM_JWT_SECRET=automated-test-secret-at-least-32-characters +DB_HOST=127.0.0.1 +DB_PORT=5433 +DB_NAME=maskan_crm_test +DB_USER=postgres +DB_PASSWORD=postgres +DB_SSLMODE=disable +MASKAN_CRM_SEED_DEMO=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c02218b --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +.venv/ +.venv311/ +venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.coverage +htmlcov/ +dist/ +build/ + +node_modules/ + +.env +.env.* +!.env.example +!.env.local.example +!.env.development.example +!.env.testing.example +!.env.production.example + +*.db +*.sqlite +*.sqlite3 +*.log + +.DS_Store +.idea/ +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a80045e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY pyproject.toml alembic.ini ./ +COPY alembic ./alembic +COPY app ./app + +RUN pip install --no-cache-dir . + +EXPOSE 8091 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8091"] + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8099bd3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MaskanX Proprietary License + +Copyright (c) 2026 MaskanX. All rights reserved. + +This software and associated documentation files (the "Software") are +proprietary to MaskanX. + +You may use, copy, modify, deploy, and distribute the Software only with prior +written permission from MaskanX or under a separate written agreement signed +by MaskanX. + +No rights are granted to sublicense, sell, lease, rent, publish, host for third +parties, or otherwise make the Software available to others except where +expressly permitted in writing by MaskanX. + +The Software is provided "as is", without warranty of any kind, express or +implied, including but not limited to the warranties of merchantability, +fitness for a particular purpose, and noninfringement. In no event shall +MaskanX be liable for any claim, damages, or other liability arising from use +of the Software. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bd30245 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# Maskan CRM Backend + +Independent FastAPI API for Maskan CRM. This repository owns CRM contacts, +organizations, leads, pipelines, activities, products, quotes, users, +permissions, audit records, and first-party integration credentials. + +## Runtime + +- Python 3.11 to 3.13 +- FastAPI and SQLAlchemy +- Alembic migrations +- PostgreSQL 16 for every non-test environment +- Node.js 20+ only as a consistent command runner for DevOps + +The API never shares a database or database credentials with MaskanX. + +## Local Setup + +```powershell +Copy-Item .env.local.example .env.local +npm install +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -e ".[dev]" +npm run docker:up +npm run local:seed +``` + +`docker:up` starts the API and PostgreSQL. To run the API directly with reload, +start only PostgreSQL and use: + +```powershell +npm run local:migrate +npm run local:seed +npm run local +``` + +### Which PostgreSQL are you pointing at? + +`.env.local.example` ships with `DB_PORT=5433`, the port Docker Compose +publishes for this repository's own `maskan-crm-postgres` container. That is +correct when you run `npm run docker:up`. + +If you instead use a PostgreSQL you already run locally (usually port `5432`), +set `DB_PORT=5432` and `DB_PASSWORD` to that server's password, and create the +database first: + +```sql +CREATE DATABASE maskan_crm; +``` + +Pointing at a port where nothing is listening fails with +`psycopg.errors.ConnectionTimeout: connection timeout expired` during +`local:migrate` or `local:seed`, and the API returns 500s on `/auth/login` +(which the browser then reports as a CORS error, because the error response +carries no CORS headers). + +Open: + +- API: `http://127.0.0.1:8091` +- API docs: `http://127.0.0.1:8091/api/docs` +- Readiness: `http://127.0.0.1:8091/health/ready` + +## Lifecycle Commands + +The requested environment command names are available using this backend's +native Python tooling. Alembic replaces Sequelize because this is a FastAPI +service. + +| Command | Purpose | +| --- | --- | +| `npm start` | Start using `.env` or injected environment variables | +| `npm run build` | Build the Python wheel into `dist/` | +| `npm run local` | Start with `.env.local` and reload | +| `npm run local:migrate` | Apply local Alembic migrations | +| `npm run local:migrate:undo` | Undo the newest local migration | +| `npm run local:migrate:undo:all` | Roll back all local migrations | +| `npm run local:seed` | Seed the bootstrap workspace and optional demo data | +| `npm run local:seed:undo` | Remove the local bootstrap workspace | +| `npm run local:reset` | Recreate and seed the local schema | +| `npm run dev*` | Equivalent commands using `.env.development` | +| `npm test` | Run unit/API tests using `.env.testing` | +| `npm run test:migrate` | Apply migrations using `.env.testing` | +| `npm run test:seed` | Seed using `.env.testing` | +| `npm run test:reset` | Recreate the test schema | +| `npm run prod` | Start with production validation enabled | +| `npm run prod:migrate` | Apply production migrations | +| `npm run prod:seed` | Seed production without demo records | + +## Production + +Supply secrets through the deployment platform, not committed files: + +```text +MASKAN_CRM_ENV=production +MASKAN_CRM_MODE=standalone +MASKAN_CRM_CORS_ORIGINS=https://crm.example.com +MASKAN_CRM_JWT_SECRET= +DB_HOST= +DB_PORT=5432 +DB_NAME=maskan_crm +DB_USER= +DB_PASSWORD= +DB_SSLMODE=require +``` + +Run `npm run prod:migrate` as a release step, then run `npm run prod`. + +## MaskanX Integration + +MaskanX connects to `/api/v1/integrations` with a revocable `mcrm_` service key +and idempotency keys. This reliable REST path is the normal product +integration. MCP tools are an optional agent interface and do not replace the +service-to-service API. + +## Database Policy + +PostgreSQL is the configured database for local, development, testing +migrations, staging, and production. A small in-memory SQLite fixture is used +inside isolated unit tests only and is rejected as an application runtime +database outside the explicit `testing` environment. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..cb436d3 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,40 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os +sqlalchemy.url = postgresql+psycopg://postgres:postgres@127.0.0.1:5433/maskan_crm + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S + diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..1b26a7f --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,61 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import get_settings +from app.core.database import Base +from app import models # noqa: F401 + +config = context.config +# Alembic stores options in a ConfigParser, which applies %-interpolation to +# values. A URL-encoded password (for example "%40" for "@") would otherwise +# raise "invalid interpolation syntax", so escape percent signs on the way in. +# get_main_option/get_section return the original URL once unescaped. +config.set_main_option( + "sqlalchemy.url", + get_settings().database_url.replace("%", "%%"), +) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() + diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..8453245 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} + diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/alembic/versions/eca4681c9f66_initial_crm_schema.py b/alembic/versions/eca4681c9f66_initial_crm_schema.py new file mode 100644 index 0000000..6dec7b5 --- /dev/null +++ b/alembic/versions/eca4681c9f66_initial_crm_schema.py @@ -0,0 +1,416 @@ +"""initial_crm_schema + +Revision ID: eca4681c9f66 +Revises: +Create Date: 2026-07-29 12:34:04.058619 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'eca4681c9f66' +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: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('crm_tenants', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('slug', sa.String(length=80), nullable=False), + sa.Column('name', sa.String(length=160), nullable=False), + sa.Column('mode', sa.String(length=24), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('settings', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('slug') + ) + op.create_table('crm_external_links', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('provider', sa.String(length=80), nullable=False), + sa.Column('entity_type', sa.String(length=80), nullable=False), + sa.Column('entity_id', sa.String(length=36), nullable=False), + sa.Column('external_id', sa.String(length=255), nullable=False), + sa.Column('metadata_json', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'provider', 'entity_type', 'external_id', name='uq_crm_external_links_provider_entity') + ) + op.create_index('ix_crm_external_links_local', 'crm_external_links', ['tenant_id', 'entity_type', 'entity_id'], unique=False) + op.create_table('crm_idempotency_records', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('scope', sa.String(length=100), nullable=False), + sa.Column('idempotency_key', sa.String(length=255), nullable=False), + sa.Column('response_code', sa.Integer(), nullable=False), + sa.Column('response_body', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'scope', 'idempotency_key', name='uq_crm_idempotency_scope_key') + ) + op.create_table('crm_integration_credentials', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('key_prefix', sa.String(length=20), nullable=False), + sa.Column('key_hash', sa.String(length=64), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('key_hash'), + sa.UniqueConstraint('tenant_id', 'name', name='uq_crm_integration_key_name') + ) + op.create_index(op.f('ix_crm_integration_credentials_tenant_id'), 'crm_integration_credentials', ['tenant_id'], unique=False) + op.create_table('crm_integration_events', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('topic', sa.String(length=140), nullable=False), + sa.Column('payload', sa.JSON(), nullable=False), + sa.Column('status', sa.String(length=24), nullable=False), + sa.Column('attempts', sa.Integer(), nullable=False), + sa.Column('available_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_crm_integration_events_delivery', 'crm_integration_events', ['status', 'available_at'], unique=False) + op.create_table('crm_lead_sources', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'name', name='uq_crm_lead_sources_tenant_name') + ) + op.create_index(op.f('ix_crm_lead_sources_tenant_id'), 'crm_lead_sources', ['tenant_id'], unique=False) + op.create_table('crm_lead_types', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'name', name='uq_crm_lead_types_tenant_name') + ) + op.create_index(op.f('ix_crm_lead_types_tenant_id'), 'crm_lead_types', ['tenant_id'], unique=False) + op.create_table('crm_pipelines', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=140), nullable=False), + sa.Column('is_default', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'name', name='uq_crm_pipelines_tenant_name') + ) + op.create_index(op.f('ix_crm_pipelines_tenant_id'), 'crm_pipelines', ['tenant_id'], unique=False) + op.create_table('crm_products', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('sku', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('quantity', sa.Integer(), nullable=False), + sa.Column('price', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'sku', name='uq_crm_products_tenant_sku') + ) + op.create_index(op.f('ix_crm_products_tenant_id'), 'crm_products', ['tenant_id'], unique=False) + op.create_index('ix_crm_products_tenant_name', 'crm_products', ['tenant_id', 'name'], unique=False) + op.create_table('crm_users', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('full_name', sa.String(length=160), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=24), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('last_login_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'email', name='uq_crm_users_tenant_email') + ) + op.create_index(op.f('ix_crm_users_tenant_id'), 'crm_users', ['tenant_id'], unique=False) + op.create_index('ix_crm_users_tenant_role', 'crm_users', ['tenant_id', 'role'], unique=False) + op.create_table('crm_audit_events', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('actor_id', sa.String(length=36), nullable=True), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('entity_type', sa.String(length=80), nullable=False), + sa.Column('entity_id', sa.String(length=80), nullable=False), + sa.Column('payload', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['actor_id'], ['crm_users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('ix_crm_audit_tenant_created', 'crm_audit_events', ['tenant_id', 'created_at'], unique=False) + op.create_index('ix_crm_audit_tenant_entity', 'crm_audit_events', ['tenant_id', 'entity_type', 'entity_id'], unique=False) + op.create_table('crm_organizations', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('legal_name', sa.String(length=240), nullable=True), + sa.Column('website', sa.String(length=500), nullable=True), + sa.Column('primary_email', sa.String(length=255), nullable=True), + sa.Column('phone', sa.String(length=80), nullable=True), + sa.Column('industry', sa.String(length=120), nullable=True), + sa.Column('address', sa.JSON(), nullable=False), + sa.Column('attributes', sa.JSON(), nullable=False), + sa.Column('owner_id', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['owner_id'], ['crm_users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_crm_organizations_tenant_id'), 'crm_organizations', ['tenant_id'], unique=False) + op.create_index('ix_crm_organizations_tenant_name', 'crm_organizations', ['tenant_id', 'name'], unique=False) + op.create_table('crm_stages', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('pipeline_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=140), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('probability', sa.Integer(), nullable=False), + sa.Column('color', sa.String(length=16), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['pipeline_id'], ['crm_pipelines.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('pipeline_id', 'name', name='uq_crm_stages_pipeline_name') + ) + op.create_index('ix_crm_stages_pipeline_position', 'crm_stages', ['pipeline_id', 'position'], unique=False) + op.create_index(op.f('ix_crm_stages_tenant_id'), 'crm_stages', ['tenant_id'], unique=False) + op.create_table('crm_contacts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('first_name', sa.String(length=100), nullable=False), + sa.Column('last_name', sa.String(length=100), nullable=False), + sa.Column('job_title', sa.String(length=160), nullable=True), + sa.Column('primary_email', sa.String(length=255), nullable=True), + sa.Column('emails', sa.JSON(), nullable=False), + sa.Column('phones', sa.JSON(), nullable=False), + sa.Column('lifecycle_stage', sa.String(length=40), nullable=False), + sa.Column('lead_source', sa.String(length=120), nullable=True), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('organization_id', sa.String(length=36), nullable=True), + sa.Column('owner_id', sa.String(length=36), nullable=True), + sa.Column('attributes', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['organization_id'], ['crm_organizations.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['owner_id'], ['crm_users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'primary_email', name='uq_crm_contacts_tenant_primary_email') + ) + op.create_index(op.f('ix_crm_contacts_organization_id'), 'crm_contacts', ['organization_id'], unique=False) + op.create_index(op.f('ix_crm_contacts_tenant_id'), 'crm_contacts', ['tenant_id'], unique=False) + op.create_index('ix_crm_contacts_tenant_name', 'crm_contacts', ['tenant_id', 'last_name', 'first_name'], unique=False) + op.create_index('ix_crm_contacts_tenant_score', 'crm_contacts', ['tenant_id', 'score'], unique=False) + op.create_table('crm_leads', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('title', sa.String(length=240), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('value', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('status', sa.String(length=24), nullable=False), + sa.Column('score', sa.Integer(), nullable=False), + sa.Column('position', sa.Integer(), nullable=False), + sa.Column('lost_reason', sa.Text(), nullable=True), + sa.Column('expected_close_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('closed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('contact_id', sa.String(length=36), nullable=True), + sa.Column('organization_id', sa.String(length=36), nullable=True), + sa.Column('owner_id', sa.String(length=36), nullable=True), + sa.Column('pipeline_id', sa.String(length=36), nullable=False), + sa.Column('stage_id', sa.String(length=36), nullable=False), + sa.Column('source_id', sa.String(length=36), nullable=True), + sa.Column('type_id', sa.String(length=36), nullable=True), + sa.Column('attributes', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['contact_id'], ['crm_contacts.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['organization_id'], ['crm_organizations.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['owner_id'], ['crm_users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['pipeline_id'], ['crm_pipelines.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['source_id'], ['crm_lead_sources.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['stage_id'], ['crm_stages.id'], ondelete='RESTRICT'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['type_id'], ['crm_lead_types.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_crm_leads_contact_id'), 'crm_leads', ['contact_id'], unique=False) + op.create_index(op.f('ix_crm_leads_organization_id'), 'crm_leads', ['organization_id'], unique=False) + op.create_index(op.f('ix_crm_leads_tenant_id'), 'crm_leads', ['tenant_id'], unique=False) + op.create_index('ix_crm_leads_tenant_owner', 'crm_leads', ['tenant_id', 'owner_id'], unique=False) + op.create_index('ix_crm_leads_tenant_stage', 'crm_leads', ['tenant_id', 'stage_id', 'position'], unique=False) + op.create_index('ix_crm_leads_tenant_status', 'crm_leads', ['tenant_id', 'status'], unique=False) + op.create_table('crm_activities', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('activity_type', sa.String(length=32), nullable=False), + sa.Column('title', sa.String(length=240), nullable=False), + sa.Column('details', sa.Text(), nullable=True), + sa.Column('starts_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('ends_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('due_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('is_done', sa.Boolean(), nullable=False), + sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('owner_id', sa.String(length=36), nullable=True), + sa.Column('contact_id', sa.String(length=36), nullable=True), + sa.Column('organization_id', sa.String(length=36), nullable=True), + sa.Column('lead_id', sa.String(length=36), nullable=True), + sa.Column('additional', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['contact_id'], ['crm_contacts.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['lead_id'], ['crm_leads.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['organization_id'], ['crm_organizations.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['owner_id'], ['crm_users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_crm_activities_contact_id'), 'crm_activities', ['contact_id'], unique=False) + op.create_index(op.f('ix_crm_activities_lead_id'), 'crm_activities', ['lead_id'], unique=False) + op.create_index(op.f('ix_crm_activities_organization_id'), 'crm_activities', ['organization_id'], unique=False) + op.create_index('ix_crm_activities_tenant_due', 'crm_activities', ['tenant_id', 'is_done', 'due_at'], unique=False) + op.create_index(op.f('ix_crm_activities_tenant_id'), 'crm_activities', ['tenant_id'], unique=False) + op.create_table('crm_quotes', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('number', sa.String(length=80), nullable=False), + sa.Column('subject', sa.String(length=240), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=32), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('billing_address', sa.JSON(), nullable=False), + sa.Column('shipping_address', sa.JSON(), nullable=False), + sa.Column('discount_amount', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('tax_amount', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('adjustment_amount', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('subtotal', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('grand_total', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('contact_id', sa.String(length=36), nullable=True), + sa.Column('organization_id', sa.String(length=36), nullable=True), + sa.Column('lead_id', sa.String(length=36), nullable=True), + sa.Column('owner_id', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['contact_id'], ['crm_contacts.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['lead_id'], ['crm_leads.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['organization_id'], ['crm_organizations.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['owner_id'], ['crm_users.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('tenant_id', 'number', name='uq_crm_quotes_tenant_number') + ) + op.create_index(op.f('ix_crm_quotes_tenant_id'), 'crm_quotes', ['tenant_id'], unique=False) + op.create_index('ix_crm_quotes_tenant_status', 'crm_quotes', ['tenant_id', 'status'], unique=False) + op.create_table('crm_quote_items', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('quote_id', sa.String(length=36), nullable=False), + sa.Column('product_id', sa.String(length=36), nullable=True), + sa.Column('name', sa.String(length=200), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('quantity', sa.Numeric(precision=12, scale=2), nullable=False), + sa.Column('unit_price', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('tax_rate', sa.Numeric(precision=6, scale=3), nullable=False), + sa.Column('line_total', sa.Numeric(precision=14, scale=2), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['product_id'], ['crm_products.id'], ondelete='SET NULL'), + sa.ForeignKeyConstraint(['quote_id'], ['crm_quotes.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['crm_tenants.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_crm_quote_items_tenant_id'), 'crm_quote_items', ['tenant_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_crm_quote_items_tenant_id'), table_name='crm_quote_items') + op.drop_table('crm_quote_items') + op.drop_index('ix_crm_quotes_tenant_status', table_name='crm_quotes') + op.drop_index(op.f('ix_crm_quotes_tenant_id'), table_name='crm_quotes') + op.drop_table('crm_quotes') + op.drop_index(op.f('ix_crm_activities_tenant_id'), table_name='crm_activities') + op.drop_index('ix_crm_activities_tenant_due', table_name='crm_activities') + op.drop_index(op.f('ix_crm_activities_organization_id'), table_name='crm_activities') + op.drop_index(op.f('ix_crm_activities_lead_id'), table_name='crm_activities') + op.drop_index(op.f('ix_crm_activities_contact_id'), table_name='crm_activities') + op.drop_table('crm_activities') + op.drop_index('ix_crm_leads_tenant_status', table_name='crm_leads') + op.drop_index('ix_crm_leads_tenant_stage', table_name='crm_leads') + op.drop_index('ix_crm_leads_tenant_owner', table_name='crm_leads') + op.drop_index(op.f('ix_crm_leads_tenant_id'), table_name='crm_leads') + op.drop_index(op.f('ix_crm_leads_organization_id'), table_name='crm_leads') + op.drop_index(op.f('ix_crm_leads_contact_id'), table_name='crm_leads') + op.drop_table('crm_leads') + op.drop_index('ix_crm_contacts_tenant_score', table_name='crm_contacts') + op.drop_index('ix_crm_contacts_tenant_name', table_name='crm_contacts') + op.drop_index(op.f('ix_crm_contacts_tenant_id'), table_name='crm_contacts') + op.drop_index(op.f('ix_crm_contacts_organization_id'), table_name='crm_contacts') + op.drop_table('crm_contacts') + op.drop_index(op.f('ix_crm_stages_tenant_id'), table_name='crm_stages') + op.drop_index('ix_crm_stages_pipeline_position', table_name='crm_stages') + op.drop_table('crm_stages') + op.drop_index('ix_crm_organizations_tenant_name', table_name='crm_organizations') + op.drop_index(op.f('ix_crm_organizations_tenant_id'), table_name='crm_organizations') + op.drop_table('crm_organizations') + op.drop_index('ix_crm_audit_tenant_entity', table_name='crm_audit_events') + op.drop_index('ix_crm_audit_tenant_created', table_name='crm_audit_events') + op.drop_table('crm_audit_events') + op.drop_index('ix_crm_users_tenant_role', table_name='crm_users') + op.drop_index(op.f('ix_crm_users_tenant_id'), table_name='crm_users') + op.drop_table('crm_users') + op.drop_index('ix_crm_products_tenant_name', table_name='crm_products') + op.drop_index(op.f('ix_crm_products_tenant_id'), table_name='crm_products') + op.drop_table('crm_products') + op.drop_index(op.f('ix_crm_pipelines_tenant_id'), table_name='crm_pipelines') + op.drop_table('crm_pipelines') + op.drop_index(op.f('ix_crm_lead_types_tenant_id'), table_name='crm_lead_types') + op.drop_table('crm_lead_types') + op.drop_index(op.f('ix_crm_lead_sources_tenant_id'), table_name='crm_lead_sources') + op.drop_table('crm_lead_sources') + op.drop_index('ix_crm_integration_events_delivery', table_name='crm_integration_events') + op.drop_table('crm_integration_events') + op.drop_index(op.f('ix_crm_integration_credentials_tenant_id'), table_name='crm_integration_credentials') + op.drop_table('crm_integration_credentials') + op.drop_table('crm_idempotency_records') + op.drop_index('ix_crm_external_links_local', table_name='crm_external_links') + op.drop_table('crm_external_links') + op.drop_table('crm_tenants') + # ### end Alembic commands ### + diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..38806a8 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,2 @@ +"""Maskan CRM API package.""" + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..ada2065 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,2 @@ +"""Versioned API routers.""" + diff --git a/app/api/activities.py b/app/api/activities.py new file mode 100644 index 0000000..e04b89f --- /dev/null +++ b/app/api/activities.py @@ -0,0 +1,143 @@ +from datetime import UTC, datetime + +from fastapi import APIRouter, Query, Response, status +from sqlalchemy import func, select + +from app.core.security import Admin, CurrentUser, Database, Writer +from app.models import Activity, Contact, Lead, Organization, User +from app.schemas import ActivityCreate, ActivityOut, ActivityUpdate, Page +from app.services import ( + activity_load_options, + activity_to_out, + add_audit, + apply_updates, + model_or_404, + verify_optional_reference, +) + +router = APIRouter(tags=["Activities"]) + + +def _validate_references( + db: Database, + tenant_id: str, + values: dict[str, object], +) -> None: + verify_optional_reference(db, User, values.get("owner_id"), tenant_id) + verify_optional_reference(db, Contact, values.get("contact_id"), tenant_id) + verify_optional_reference(db, Organization, values.get("organization_id"), tenant_id) + verify_optional_reference(db, Lead, values.get("lead_id"), tenant_id) + + +@router.get("/activities", response_model=Page[ActivityOut]) +def list_activities( + user: CurrentUser, + db: Database, + is_done: bool | None = None, + lead_id: str | None = None, + contact_id: str | None = None, + organization_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), +) -> Page[ActivityOut]: + filters = [Activity.tenant_id == user.tenant_id] + if is_done is not None: + filters.append(Activity.is_done == is_done) + if lead_id: + filters.append(Activity.lead_id == lead_id) + if contact_id: + filters.append(Activity.contact_id == contact_id) + if organization_id: + filters.append(Activity.organization_id == organization_id) + total = db.scalar(select(func.count(Activity.id)).where(*filters)) or 0 + records = db.scalars( + select(Activity) + .where(*filters) + .options(*activity_load_options()) + .order_by(Activity.is_done, Activity.due_at, Activity.created_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size), + ).all() + return Page( + items=[activity_to_out(item) for item in records], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post( + "/activities", + response_model=ActivityOut, + status_code=status.HTTP_201_CREATED, +) +def create_activity( + payload: ActivityCreate, + user: Writer, + db: Database, +) -> ActivityOut: + values = payload.model_dump() + _validate_references(db, user.tenant_id, values) + if values["is_done"]: + values["completed_at"] = datetime.now(UTC) + activity = Activity(tenant_id=user.tenant_id, **values) + db.add(activity) + db.flush() + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="activity.created", + entity_type="activity", + entity_id=activity.id, + ) + db.commit() + activity = db.scalar( + select(Activity) + .where(Activity.id == activity.id) + .options(*activity_load_options()), + ) + return activity_to_out(activity) + + +@router.patch("/activities/{activity_id}", response_model=ActivityOut) +def update_activity( + activity_id: str, + payload: ActivityUpdate, + user: Writer, + db: Database, +) -> ActivityOut: + activity = model_or_404(db, Activity, activity_id, user.tenant_id) + values = payload.model_dump(exclude_unset=True) + _validate_references(db, user.tenant_id, values) + if "is_done" in values: + values["completed_at"] = datetime.now(UTC) if values["is_done"] else None + apply_updates(activity, values) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="activity.updated", + entity_type="activity", + entity_id=activity.id, + payload={"fields": sorted(values)}, + ) + db.commit() + activity = db.scalar( + select(Activity) + .where(Activity.id == activity.id) + .options(*activity_load_options()), + ) + return activity_to_out(activity) + + +@router.delete("/activities/{activity_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_activity( + activity_id: str, + user: Admin, + db: Database, +) -> Response: + activity = model_or_404(db, Activity, activity_id, user.tenant_id) + db.delete(activity) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..a1aaa2c --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,71 @@ +from datetime import UTC, datetime + +from fastapi import APIRouter, HTTPException, status +from sqlalchemy import func, select + +from app.core.config import get_settings +from app.core.security import CurrentUser, Database, create_access_token, verify_password +from app.models import Tenant, User +from app.schemas import LoginRequest, SessionResponse, TenantOut, TokenResponse, UserOut + +router = APIRouter(prefix="/auth", tags=["Authentication"]) + + +@router.post("/login", response_model=TokenResponse) +def login(payload: LoginRequest, db: Database) -> TokenResponse: + tenant = db.scalar( + select(Tenant).where( + func.lower(Tenant.slug) == payload.workspace.lower(), + Tenant.is_active.is_(True), + ), + ) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="The workspace, email, or password is incorrect.", + ) + + user = db.scalar( + select(User).where( + User.tenant_id == tenant.id, + func.lower(User.email) == payload.email.lower(), + User.is_active.is_(True), + ), + ) + if user is None or not verify_password(payload.password, user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="The workspace, email, or password is incorrect.", + ) + + user.last_login_at = datetime.now(UTC) + db.commit() + + settings = get_settings() + return TokenResponse( + access_token=create_access_token(user), + expires_in=settings.access_token_minutes * 60, + user=UserOut.model_validate(user), + tenant=TenantOut.model_validate(tenant), + ) + + +@router.get("/me", response_model=SessionResponse) +def current_session(user: CurrentUser, db: Database) -> SessionResponse: + tenant = db.scalar( + select(Tenant).where( + Tenant.id == user.tenant_id, + Tenant.is_active.is_(True), + ), + ) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="The current workspace is unavailable.", + ) + + return SessionResponse( + user=UserOut.model_validate(user), + tenant=TenantOut.model_validate(tenant), + ) + diff --git a/app/api/catalog.py b/app/api/catalog.py new file mode 100644 index 0000000..756fa69 --- /dev/null +++ b/app/api/catalog.py @@ -0,0 +1,119 @@ +from fastapi import APIRouter, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError + +from app.core.security import CurrentUser, Database, Writer +from app.models import Contact, Lead, Organization, Product, Quote, User +from app.schemas import Page, ProductCreate, ProductOut, QuoteCreate, QuoteOut +from app.services import add_audit, model_or_404, verify_optional_reference + +router = APIRouter(tags=["Catalog and Quotes"]) + + +@router.get("/products", response_model=Page[ProductOut]) +def list_products( + user: CurrentUser, + db: Database, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), +) -> Page[ProductOut]: + filters = [Product.tenant_id == user.tenant_id] + total = db.scalar(select(func.count(Product.id)).where(*filters)) or 0 + records = db.scalars( + select(Product) + .where(*filters) + .order_by(Product.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size), + ).all() + return Page( + items=[ProductOut.model_validate(item) for item in records], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("/products", response_model=ProductOut, status_code=status.HTTP_201_CREATED) +def create_product(payload: ProductCreate, user: Writer, db: Database) -> ProductOut: + product = Product(tenant_id=user.tenant_id, **payload.model_dump()) + db.add(product) + try: + db.flush() + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="product.created", + entity_type="product", + entity_id=product.id, + ) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A product with this SKU already exists.", + ) from exc + db.refresh(product) + return ProductOut.model_validate(product) + + +@router.get("/quotes", response_model=Page[QuoteOut]) +def list_quotes( + user: CurrentUser, + db: Database, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), +) -> Page[QuoteOut]: + filters = [Quote.tenant_id == user.tenant_id] + total = db.scalar(select(func.count(Quote.id)).where(*filters)) or 0 + records = db.scalars( + select(Quote) + .where(*filters) + .order_by(Quote.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size), + ).all() + return Page( + items=[QuoteOut.model_validate(item) for item in records], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("/quotes", response_model=QuoteOut, status_code=status.HTTP_201_CREATED) +def create_quote(payload: QuoteCreate, user: Writer, db: Database) -> QuoteOut: + verify_optional_reference(db, Contact, payload.contact_id, user.tenant_id) + verify_optional_reference(db, Organization, payload.organization_id, user.tenant_id) + verify_optional_reference(db, Lead, payload.lead_id, user.tenant_id) + verify_optional_reference(db, User, payload.owner_id, user.tenant_id) + quote = Quote(tenant_id=user.tenant_id, **payload.model_dump()) + db.add(quote) + try: + db.flush() + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="quote.created", + entity_type="quote", + entity_id=quote.id, + ) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A quote with this number already exists.", + ) from exc + db.refresh(quote) + return QuoteOut.model_validate(quote) + + +@router.get("/products/{product_id}", response_model=ProductOut) +def get_product(product_id: str, user: CurrentUser, db: Database) -> ProductOut: + return ProductOut.model_validate( + model_or_404(db, Product, product_id, user.tenant_id), + ) diff --git a/app/api/contacts.py b/app/api/contacts.py new file mode 100644 index 0000000..b468770 --- /dev/null +++ b/app/api/contacts.py @@ -0,0 +1,298 @@ +from fastapi import APIRouter, HTTPException, Query, Response, status +from sqlalchemy import func, or_, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + +from app.core.security import Admin, CurrentUser, Database, Writer +from app.models import Contact, Organization, User +from app.schemas import ( + ContactCreate, + ContactOut, + ContactUpdate, + OrganizationCreate, + OrganizationOut, + OrganizationUpdate, + Page, +) +from app.services import ( + add_audit, + add_event, + apply_updates, + contact_to_out, + model_or_404, + verify_optional_reference, +) + +router = APIRouter(tags=["Contacts"]) + + +def _commit_or_conflict(db: Database, message: str) -> None: + try: + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) from exc + + +@router.get("/contacts", response_model=Page[ContactOut]) +def list_contacts( + user: CurrentUser, + db: Database, + search: str | None = Query(default=None, max_length=200), + organization_id: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), +) -> Page[ContactOut]: + filters = [Contact.tenant_id == user.tenant_id] + if organization_id: + filters.append(Contact.organization_id == organization_id) + if search: + pattern = f"%{search.strip().lower()}%" + filters.append( + or_( + func.lower(Contact.first_name).like(pattern), + func.lower(Contact.last_name).like(pattern), + func.lower(Contact.primary_email).like(pattern), + ), + ) + + total = db.scalar(select(func.count(Contact.id)).where(*filters)) or 0 + contacts = db.scalars( + select(Contact) + .where(*filters) + .options(selectinload(Contact.organization), selectinload(Contact.owner)) + .order_by(Contact.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size), + ).all() + + return Page( + items=[contact_to_out(contact) for contact in contacts], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("/contacts", response_model=ContactOut, status_code=status.HTTP_201_CREATED) +def create_contact(payload: ContactCreate, user: Writer, db: Database) -> ContactOut: + verify_optional_reference( + db, + Organization, + payload.organization_id, + user.tenant_id, + ) + verify_optional_reference(db, User, payload.owner_id, user.tenant_id) + contact = Contact(tenant_id=user.tenant_id, **payload.model_dump()) + db.add(contact) + try: + db.flush() + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="contact.created", + entity_type="contact", + entity_id=contact.id, + ) + add_event( + db, + tenant_id=user.tenant_id, + topic="crm.contact.created", + payload={"contact_id": contact.id}, + ) + db.commit() + except IntegrityError as exc: + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A contact with this primary email already exists.", + ) from exc + db.refresh(contact) + contact = db.scalar( + select(Contact) + .where(Contact.id == contact.id) + .options(selectinload(Contact.organization), selectinload(Contact.owner)), + ) + return contact_to_out(contact) + + +@router.get("/contacts/{contact_id}", response_model=ContactOut) +def get_contact(contact_id: str, user: CurrentUser, db: Database) -> ContactOut: + contact = db.scalar( + select(Contact) + .where(Contact.id == contact_id, Contact.tenant_id == user.tenant_id) + .options(selectinload(Contact.organization), selectinload(Contact.owner)), + ) + if contact is None: + raise HTTPException(status_code=404, detail="The requested record was not found.") + return contact_to_out(contact) + + +@router.patch("/contacts/{contact_id}", response_model=ContactOut) +def update_contact( + contact_id: str, + payload: ContactUpdate, + user: Writer, + db: Database, +) -> ContactOut: + contact = model_or_404(db, Contact, contact_id, user.tenant_id) + values = payload.model_dump(exclude_unset=True) + verify_optional_reference( + db, + Organization, + values.get("organization_id"), + user.tenant_id, + ) + verify_optional_reference(db, User, values.get("owner_id"), user.tenant_id) + apply_updates(contact, values) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="contact.updated", + entity_type="contact", + entity_id=contact.id, + payload={"fields": sorted(values)}, + ) + add_event( + db, + tenant_id=user.tenant_id, + topic="crm.contact.updated", + payload={"contact_id": contact.id, "fields": sorted(values)}, + ) + _commit_or_conflict(db, "A contact with this primary email already exists.") + contact = db.scalar( + select(Contact) + .where(Contact.id == contact_id) + .options(selectinload(Contact.organization), selectinload(Contact.owner)), + ) + return contact_to_out(contact) + + +@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_contact( + contact_id: str, + user: Admin, + db: Database, +) -> Response: + contact = model_or_404(db, Contact, contact_id, user.tenant_id) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="contact.deleted", + entity_type="contact", + entity_id=contact.id, + ) + db.delete(contact) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/organizations", response_model=Page[OrganizationOut]) +def list_organizations( + user: CurrentUser, + db: Database, + search: str | None = Query(default=None, max_length=200), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1, le=100), +) -> Page[OrganizationOut]: + filters = [Organization.tenant_id == user.tenant_id] + if search: + filters.append( + func.lower(Organization.name).like(f"%{search.strip().lower()}%"), + ) + + total = db.scalar(select(func.count(Organization.id)).where(*filters)) or 0 + records = db.scalars( + select(Organization) + .where(*filters) + .order_by(Organization.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size), + ).all() + return Page( + items=[OrganizationOut.model_validate(item) for item in records], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post( + "/organizations", + response_model=OrganizationOut, + status_code=status.HTTP_201_CREATED, +) +def create_organization( + payload: OrganizationCreate, + user: Writer, + db: Database, +) -> OrganizationOut: + verify_optional_reference(db, User, payload.owner_id, user.tenant_id) + organization = Organization(tenant_id=user.tenant_id, **payload.model_dump()) + db.add(organization) + db.flush() + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="organization.created", + entity_type="organization", + entity_id=organization.id, + ) + add_event( + db, + tenant_id=user.tenant_id, + topic="crm.organization.created", + payload={"organization_id": organization.id}, + ) + db.commit() + db.refresh(organization) + return OrganizationOut.model_validate(organization) + + +@router.get("/organizations/{organization_id}", response_model=OrganizationOut) +def get_organization( + organization_id: str, + user: CurrentUser, + db: Database, +) -> OrganizationOut: + organization = model_or_404( + db, + Organization, + organization_id, + user.tenant_id, + ) + return OrganizationOut.model_validate(organization) + + +@router.patch("/organizations/{organization_id}", response_model=OrganizationOut) +def update_organization( + organization_id: str, + payload: OrganizationUpdate, + user: Writer, + db: Database, +) -> OrganizationOut: + organization = model_or_404( + db, + Organization, + organization_id, + user.tenant_id, + ) + values = payload.model_dump(exclude_unset=True) + verify_optional_reference(db, User, values.get("owner_id"), user.tenant_id) + apply_updates(organization, values) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="organization.updated", + entity_type="organization", + entity_id=organization.id, + payload={"fields": sorted(values)}, + ) + db.commit() + db.refresh(organization) + return OrganizationOut.model_validate(organization) diff --git a/app/api/dashboard.py b/app/api/dashboard.py new file mode 100644 index 0000000..331e5ee --- /dev/null +++ b/app/api/dashboard.py @@ -0,0 +1,111 @@ +from datetime import UTC, datetime +from decimal import Decimal + +from fastapi import APIRouter +from sqlalchemy import func, select + +from app.core.security import CurrentUser, Database +from app.models import Activity, Contact, Lead, LeadSource, Stage +from app.schemas import ( + DashboardResponse, + DashboardSourceMetric, + DashboardStageMetric, +) +from app.services import activity_load_options, activity_to_out, decimal_or_zero + +router = APIRouter(tags=["Dashboard"]) + + +@router.get("/dashboard", response_model=DashboardResponse) +def get_dashboard(user: CurrentUser, db: Database) -> DashboardResponse: + tenant_filter = Lead.tenant_id == user.tenant_id + total_revenue = db.scalar( + select(func.sum(Lead.value)).where(tenant_filter, Lead.status == "won"), + ) + open_pipeline_value = db.scalar( + select(func.sum(Lead.value)).where(tenant_filter, Lead.status == "open"), + ) + total_leads = db.scalar(select(func.count(Lead.id)).where(tenant_filter)) or 0 + open_leads = db.scalar( + select(func.count(Lead.id)).where(tenant_filter, Lead.status == "open"), + ) or 0 + won_leads = db.scalar( + select(func.count(Lead.id)).where(tenant_filter, Lead.status == "won"), + ) or 0 + lost_leads = db.scalar( + select(func.count(Lead.id)).where(tenant_filter, Lead.status == "lost"), + ) or 0 + total_contacts = db.scalar( + select(func.count(Contact.id)).where(Contact.tenant_id == user.tenant_id), + ) or 0 + overdue_activities = db.scalar( + select(func.count(Activity.id)).where( + Activity.tenant_id == user.tenant_id, + Activity.is_done.is_(False), + Activity.due_at < datetime.now(UTC), + ), + ) or 0 + + stage_rows = db.execute( + select( + Stage.id, + Stage.name, + Stage.color, + func.count(Lead.id), + func.coalesce(func.sum(Lead.value), 0), + ) + .outerjoin( + Lead, + (Lead.stage_id == Stage.id) & (Lead.tenant_id == user.tenant_id), + ) + .where(Stage.tenant_id == user.tenant_id) + .group_by(Stage.id, Stage.name, Stage.color, Stage.position) + .order_by(Stage.position), + ).all() + + source_rows = db.execute( + select( + func.coalesce(LeadSource.name, "Direct"), + func.count(Lead.id), + ) + .select_from(Lead) + .outerjoin(LeadSource, Lead.source_id == LeadSource.id) + .where(Lead.tenant_id == user.tenant_id) + .group_by(LeadSource.name) + .order_by(func.count(Lead.id).desc()), + ).all() + + recent = db.scalars( + select(Activity) + .where(Activity.tenant_id == user.tenant_id) + .options(*activity_load_options()) + .order_by(Activity.created_at.desc()) + .limit(5), + ).all() + + return DashboardResponse( + total_revenue=decimal_or_zero(total_revenue), + open_pipeline_value=decimal_or_zero(open_pipeline_value), + total_leads=total_leads, + open_leads=open_leads, + won_leads=won_leads, + lost_leads=lost_leads, + total_contacts=total_contacts, + overdue_activities=overdue_activities, + stages=[ + DashboardStageMetric( + stage_id=row[0], + stage_name=row[1], + color=row[2], + lead_count=row[3], + total_value=Decimal(row[4]), + ) + for row in stage_rows + ], + sources=[ + DashboardSourceMetric(source=row[0], lead_count=row[1]) + for row in source_rows + ], + recent_activities=[activity_to_out(item) for item in recent], + ) + diff --git a/app/api/integrations.py b/app/api/integrations.py new file mode 100644 index 0000000..cfd9eb6 --- /dev/null +++ b/app/api/integrations.py @@ -0,0 +1,380 @@ +from datetime import UTC, datetime +from secrets import token_urlsafe +from typing import Annotated + +from fastapi import APIRouter, Header, HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + +from app.core.config import get_settings +from app.core.security import Admin, Database, hash_service_key +from app.models import ( + Contact, + ExternalLink, + IdempotencyRecord, + IntegrationCredential, + Lead, + LeadSource, + Organization, + Pipeline, + Tenant, +) +from app.schemas import ( + IntegrationCredentialOut, + IntegrationKeyCreate, + IntegrationKeyResponse, + IntegrationLeadRequest, + IntegrationLeadResponse, + IntegrationStatusResponse, +) +from app.services import add_audit, add_event, next_lead_position + +router = APIRouter(prefix="/integrations", tags=["Integrations"]) + + +@router.get( + "/credentials", + response_model=list[IntegrationCredentialOut], +) +def list_integration_keys( + user: Admin, + db: Database, +) -> list[IntegrationCredential]: + return list( + db.scalars( + select(IntegrationCredential) + .where(IntegrationCredential.tenant_id == user.tenant_id) + .order_by(IntegrationCredential.created_at.desc()), + ).all(), + ) + + +@router.post( + "/credentials", + response_model=IntegrationKeyResponse, + status_code=status.HTTP_201_CREATED, +) +def create_integration_key( + payload: IntegrationKeyCreate, + user: Admin, + db: Database, +) -> IntegrationKeyResponse: + raw_key = f"mcrm_{token_urlsafe(36)}" + credential = IntegrationCredential( + tenant_id=user.tenant_id, + name=payload.name, + key_prefix=raw_key[:12], + key_hash=hash_service_key(raw_key), + ) + db.add(credential) + try: + db.flush() + except IntegrityError as exc: + # Credential names are unique per tenant. Reusing one is a client + # mistake, not a server fault, so report it as a conflict instead of + # letting the constraint surface as a 500. + db.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=( + f"An integration credential named '{payload.name}' already " + "exists. Choose a different name, or delete the existing one." + ), + ) from exc + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="integration_credential.created", + entity_type="integration_credential", + entity_id=credential.id, + ) + db.commit() + db.refresh(credential) + return IntegrationKeyResponse( + id=credential.id, + name=credential.name, + key=raw_key, + key_prefix=credential.key_prefix, + created_at=credential.created_at, + ) + + +@router.delete( + "/credentials/{credential_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +def revoke_integration_key( + credential_id: str, + user: Admin, + db: Database, +) -> None: + credential = db.scalar( + select(IntegrationCredential).where( + IntegrationCredential.id == credential_id, + IntegrationCredential.tenant_id == user.tenant_id, + ), + ) + if credential is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The integration credential was not found.", + ) + if credential.is_active: + credential.is_active = False + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="integration_credential.revoked", + entity_type="integration_credential", + entity_id=credential.id, + ) + db.commit() + + +def _authenticate_integration(db: Database, raw_key: str | None) -> IntegrationCredential: + if not raw_key: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="An integration key is required.", + ) + credential = db.scalar( + select(IntegrationCredential).where( + IntegrationCredential.key_hash == hash_service_key(raw_key), + IntegrationCredential.is_active.is_(True), + ), + ) + if credential is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="The integration key is invalid.", + ) + credential.last_used_at = datetime.now(UTC) + return credential + + +@router.get("/status", response_model=IntegrationStatusResponse) +def integration_status( + db: Database, + integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None, +) -> IntegrationStatusResponse: + credential = _authenticate_integration(db, integration_key) + tenant = db.scalar( + select(Tenant).where(Tenant.id == credential.tenant_id), + ) + if tenant is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="The integration workspace is unavailable.", + ) + + settings = get_settings() + response = IntegrationStatusResponse( + status="ready", + product=settings.app_name, + mode=settings.mode, + tenant_id=tenant.id, + tenant_name=tenant.name, + workspace=tenant.slug, + credential_id=credential.id, + credential_name=credential.name, + key_prefix=credential.key_prefix, + ) + db.commit() + return response + + +@router.post("/leads", response_model=IntegrationLeadResponse) +def ingest_lead( + payload: IntegrationLeadRequest, + db: Database, + integration_key: Annotated[str | None, Header(alias="X-Integration-Key")] = None, + idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None, +) -> IntegrationLeadResponse: + credential = _authenticate_integration(db, integration_key) + if not idempotency_key: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Idempotency-Key is required.", + ) + + previous = db.scalar( + select(IdempotencyRecord).where( + IdempotencyRecord.tenant_id == credential.tenant_id, + IdempotencyRecord.scope == "integration.lead", + IdempotencyRecord.idempotency_key == idempotency_key, + ), + ) + if previous: + return IntegrationLeadResponse.model_validate(previous.response_body) + + existing_link = db.scalar( + select(ExternalLink).where( + ExternalLink.tenant_id == credential.tenant_id, + ExternalLink.provider == payload.provider, + ExternalLink.entity_type == "lead", + ExternalLink.external_id == payload.external_id, + ), + ) + if existing_link: + existing_lead = db.scalar( + select(Lead) + .where( + Lead.id == existing_link.entity_id, + Lead.tenant_id == credential.tenant_id, + ) + .options(selectinload(Lead.contact)), + ) + if existing_lead: + result = IntegrationLeadResponse( + contact_id=existing_lead.contact_id or "", + lead_id=existing_lead.id, + created=False, + ) + db.add( + IdempotencyRecord( + tenant_id=credential.tenant_id, + scope="integration.lead", + idempotency_key=idempotency_key, + response_code=200, + response_body=result.model_dump(), + ), + ) + db.commit() + return result + + organization = None + if payload.company_name: + organization = db.scalar( + select(Organization).where( + Organization.tenant_id == credential.tenant_id, + func.lower(Organization.name) == payload.company_name.lower(), + ), + ) + if organization is None: + organization = Organization( + tenant_id=credential.tenant_id, + name=payload.company_name, + ) + db.add(organization) + db.flush() + + contact = None + if payload.email: + contact = db.scalar( + select(Contact).where( + Contact.tenant_id == credential.tenant_id, + func.lower(Contact.primary_email) == payload.email.lower(), + ), + ) + if contact is None: + phones = ( + [{"label": "work", "value": payload.phone, "primary": True}] + if payload.phone + else [] + ) + emails = ( + [{"label": "work", "value": str(payload.email), "primary": True}] + if payload.email + else [] + ) + contact = Contact( + tenant_id=credential.tenant_id, + first_name=payload.first_name, + last_name=payload.last_name, + job_title=payload.job_title, + primary_email=str(payload.email) if payload.email else None, + emails=emails, + phones=phones, + lead_source=payload.source, + score=payload.score, + organization_id=organization.id if organization else None, + attributes={"campaign": payload.campaign, **payload.metadata}, + ) + db.add(contact) + db.flush() + + pipeline = db.scalar( + select(Pipeline) + .where( + Pipeline.tenant_id == credential.tenant_id, + Pipeline.is_default.is_(True), + ) + .options(selectinload(Pipeline.stages)), + ) + if pipeline is None or not pipeline.stages: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="The CRM workspace does not have a default pipeline.", + ) + + source = db.scalar( + select(LeadSource).where( + LeadSource.tenant_id == credential.tenant_id, + func.lower(LeadSource.name) == payload.source.lower(), + ), + ) + if source is None: + source = LeadSource(tenant_id=credential.tenant_id, name=payload.source) + db.add(source) + db.flush() + + first_stage = sorted(pipeline.stages, key=lambda item: item.position)[0] + lead = Lead( + tenant_id=credential.tenant_id, + title=payload.lead_title or f"{contact.name} opportunity", + description="Lead received through MaskanX integration.", + score=payload.score, + contact_id=contact.id, + organization_id=organization.id if organization else None, + pipeline_id=pipeline.id, + stage_id=first_stage.id, + source_id=source.id, + position=next_lead_position(db, credential.tenant_id, first_stage.id), + attributes={"campaign": payload.campaign, **payload.metadata}, + ) + db.add(lead) + db.flush() + db.add( + ExternalLink( + tenant_id=credential.tenant_id, + provider=payload.provider, + entity_type="lead", + entity_id=lead.id, + external_id=payload.external_id, + metadata_json=payload.metadata, + ), + ) + add_audit( + db, + actor=None, + tenant_id=credential.tenant_id, + action="lead.ingested", + entity_type="lead", + entity_id=lead.id, + payload={"provider": payload.provider, "external_id": payload.external_id}, + ) + add_event( + db, + tenant_id=credential.tenant_id, + topic="crm.lead.ingested", + payload={"lead_id": lead.id, "provider": payload.provider}, + ) + result = IntegrationLeadResponse( + contact_id=contact.id, + lead_id=lead.id, + created=True, + ) + db.add( + IdempotencyRecord( + tenant_id=credential.tenant_id, + scope="integration.lead", + idempotency_key=idempotency_key, + response_code=200, + response_body=result.model_dump(), + ), + ) + db.commit() + return result diff --git a/app/api/leads.py b/app/api/leads.py new file mode 100644 index 0000000..5e2ac73 --- /dev/null +++ b/app/api/leads.py @@ -0,0 +1,309 @@ + +from fastapi import APIRouter, HTTPException, Query, Response, status +from sqlalchemy import func, or_, select +from sqlalchemy.orm import selectinload + +from app.core.security import Admin, CurrentUser, Database, Writer +from app.models import ( + Lead, + LeadSource, + LeadType, + Pipeline, + Stage, + User, +) +from app.schemas import ( + LeadCreate, + LeadMove, + LeadOut, + LeadUpdate, + Page, + PipelineOut, + ReferenceItem, + StageOut, + UserOut, +) +from app.services import ( + add_audit, + add_event, + apply_updates, + lead_load_options, + lead_to_out, + model_or_404, + next_lead_position, + set_lead_status, + validate_lead_references, + verify_optional_reference, +) + +router = APIRouter(tags=["Leads"]) + + +@router.get("/pipelines", response_model=list[PipelineOut]) +def list_pipelines(user: CurrentUser, db: Database) -> list[PipelineOut]: + pipelines = db.scalars( + select(Pipeline) + .where(Pipeline.tenant_id == user.tenant_id) + .options(selectinload(Pipeline.stages)) + .order_by(Pipeline.name), + ).all() + return [ + PipelineOut( + id=pipeline.id, + name=pipeline.name, + is_default=pipeline.is_default, + stages=[StageOut.model_validate(stage) for stage in pipeline.stages], + ) + for pipeline in pipelines + ] + + +@router.get("/lead-sources", response_model=list[ReferenceItem]) +def list_sources(user: CurrentUser, db: Database) -> list[ReferenceItem]: + records = db.scalars( + select(LeadSource) + .where(LeadSource.tenant_id == user.tenant_id) + .order_by(LeadSource.name), + ).all() + return [ReferenceItem.model_validate(item) for item in records] + + +@router.get("/lead-types", response_model=list[ReferenceItem]) +def list_types(user: CurrentUser, db: Database) -> list[ReferenceItem]: + records = db.scalars( + select(LeadType) + .where(LeadType.tenant_id == user.tenant_id) + .order_by(LeadType.name), + ).all() + return [ReferenceItem.model_validate(item) for item in records] + + +@router.get("/users", response_model=list[UserOut]) +def list_users(user: CurrentUser, db: Database) -> list[UserOut]: + records = db.scalars( + select(User) + .where(User.tenant_id == user.tenant_id, User.is_active.is_(True)) + .order_by(User.full_name), + ).all() + return [UserOut.model_validate(item) for item in records] + + +@router.get("/leads", response_model=Page[LeadOut]) +def list_leads( + user: CurrentUser, + db: Database, + search: str | None = Query(default=None, max_length=200), + pipeline_id: str | None = None, + stage_id: str | None = None, + contact_id: str | None = None, + organization_id: str | None = None, + status_value: str | None = Query(default=None, alias="status"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=100, ge=1, le=250), +) -> Page[LeadOut]: + filters = [Lead.tenant_id == user.tenant_id] + if pipeline_id: + filters.append(Lead.pipeline_id == pipeline_id) + if stage_id: + filters.append(Lead.stage_id == stage_id) + if contact_id: + filters.append(Lead.contact_id == contact_id) + if organization_id: + filters.append(Lead.organization_id == organization_id) + if status_value: + filters.append(Lead.status == status_value) + if search: + pattern = f"%{search.strip().lower()}%" + filters.append( + or_( + func.lower(Lead.title).like(pattern), + func.lower(Lead.description).like(pattern), + ), + ) + + total = db.scalar(select(func.count(Lead.id)).where(*filters)) or 0 + leads = db.scalars( + select(Lead) + .where(*filters) + .options(*lead_load_options()) + .order_by(Lead.stage_id, Lead.position, Lead.updated_at.desc()) + .offset((page - 1) * page_size) + .limit(page_size), + ).all() + return Page( + items=[lead_to_out(lead) for lead in leads], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("/leads", response_model=LeadOut, status_code=status.HTTP_201_CREATED) +def create_lead(payload: LeadCreate, user: Writer, db: Database) -> LeadOut: + validate_lead_references( + db, + tenant_id=user.tenant_id, + pipeline_id=payload.pipeline_id, + stage_id=payload.stage_id, + contact_id=payload.contact_id, + organization_id=payload.organization_id, + owner_id=payload.owner_id, + ) + verify_optional_reference(db, LeadSource, payload.source_id, user.tenant_id) + verify_optional_reference(db, LeadType, payload.type_id, user.tenant_id) + values = payload.model_dump() + values["position"] = next_lead_position(db, user.tenant_id, payload.stage_id) + lead = Lead(tenant_id=user.tenant_id, **values) + db.add(lead) + db.flush() + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="lead.created", + entity_type="lead", + entity_id=lead.id, + ) + add_event( + db, + tenant_id=user.tenant_id, + topic="crm.lead.created", + payload={"lead_id": lead.id}, + ) + db.commit() + lead = db.scalar( + select(Lead).where(Lead.id == lead.id).options(*lead_load_options()), + ) + return lead_to_out(lead) + + +@router.get("/leads/{lead_id}", response_model=LeadOut) +def get_lead(lead_id: str, user: CurrentUser, db: Database) -> LeadOut: + lead = db.scalar( + select(Lead) + .where(Lead.id == lead_id, Lead.tenant_id == user.tenant_id) + .options(*lead_load_options()), + ) + if lead is None: + raise HTTPException(status_code=404, detail="The requested record was not found.") + return lead_to_out(lead) + + +@router.patch("/leads/{lead_id}", response_model=LeadOut) +def update_lead( + lead_id: str, + payload: LeadUpdate, + user: Writer, + db: Database, +) -> LeadOut: + lead = model_or_404(db, Lead, lead_id, user.tenant_id) + values = payload.model_dump(exclude_unset=True) + target_pipeline = values.get("pipeline_id", lead.pipeline_id) + target_stage = values.get("stage_id", lead.stage_id) + validate_lead_references( + db, + tenant_id=user.tenant_id, + pipeline_id=target_pipeline, + stage_id=target_stage, + contact_id=values.get("contact_id", lead.contact_id), + organization_id=values.get("organization_id", lead.organization_id), + owner_id=values.get("owner_id", lead.owner_id), + ) + verify_optional_reference( + db, + LeadSource, + values.get("source_id", lead.source_id), + user.tenant_id, + ) + verify_optional_reference( + db, + LeadType, + values.get("type_id", lead.type_id), + user.tenant_id, + ) + status_value = values.pop("status", None) + lost_reason = values.pop("lost_reason", lead.lost_reason) + if target_stage != lead.stage_id: + values["position"] = next_lead_position( + db, + user.tenant_id, + target_stage, + ) + apply_updates(lead, values) + if status_value is not None: + set_lead_status(lead, status_value, lost_reason) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="lead.updated", + entity_type="lead", + entity_id=lead.id, + payload={"fields": sorted(payload.model_fields_set)}, + ) + add_event( + db, + tenant_id=user.tenant_id, + topic="crm.lead.updated", + payload={"lead_id": lead.id, "fields": sorted(payload.model_fields_set)}, + ) + db.commit() + lead = db.scalar( + select(Lead).where(Lead.id == lead.id).options(*lead_load_options()), + ) + return lead_to_out(lead) + + +@router.post("/leads/{lead_id}/move", response_model=LeadOut) +def move_lead( + lead_id: str, + payload: LeadMove, + user: Writer, + db: Database, +) -> LeadOut: + lead = model_or_404(db, Lead, lead_id, user.tenant_id) + stage = model_or_404(db, Stage, payload.stage_id, user.tenant_id) + old_stage_id = lead.stage_id + lead.stage_id = stage.id + lead.pipeline_id = stage.pipeline_id + lead.position = payload.position or next_lead_position( + db, + user.tenant_id, + stage.id, + ) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="lead.moved", + entity_type="lead", + entity_id=lead.id, + payload={"from_stage_id": old_stage_id, "to_stage_id": stage.id}, + ) + add_event( + db, + tenant_id=user.tenant_id, + topic="crm.lead.stage_changed", + payload={"lead_id": lead.id, "stage_id": stage.id}, + ) + db.commit() + lead = db.scalar( + select(Lead).where(Lead.id == lead.id).options(*lead_load_options()), + ) + return lead_to_out(lead) + + +@router.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_lead(lead_id: str, user: Admin, db: Database) -> Response: + lead = model_or_404(db, Lead, lead_id, user.tenant_id) + add_audit( + db, + actor=user, + tenant_id=user.tenant_id, + action="lead.deleted", + entity_type="lead", + entity_id=lead.id, + ) + db.delete(lead) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/app/api/router.py b/app/api/router.py new file mode 100644 index 0000000..ae6dc62 --- /dev/null +++ b/app/api/router.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from app.api import activities, auth, catalog, contacts, dashboard, integrations, leads + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(auth.router) +api_router.include_router(dashboard.router) +api_router.include_router(contacts.router) +api_router.include_router(leads.router) +api_router.include_router(activities.router) +api_router.include_router(catalog.router) +api_router.include_router(integrations.router) + diff --git a/app/cli.py b/app/cli.py new file mode 100644 index 0000000..67f3074 --- /dev/null +++ b/app/cli.py @@ -0,0 +1,317 @@ +import argparse +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +from sqlalchemy import delete, select +from sqlalchemy.orm import selectinload + +from app.core.config import get_settings +from app.core.database import SessionLocal +from app.core.security import hash_password +from app.models import ( + Activity, + Contact, + Lead, + LeadSource, + LeadType, + Organization, + Pipeline, + Product, + Quote, + Stage, + Tenant, + User, +) + + +def get_or_create(db, model, defaults: dict, **lookup): + instance = db.scalar(select(model).filter_by(**lookup)) + if instance: + return instance + instance = model(**lookup, **defaults) + db.add(instance) + db.flush() + return instance + + +def seed(with_demo: bool) -> None: + settings = get_settings() + if ( + settings.environment.lower() == "production" + and settings.bootstrap_admin_password == "change-this-before-first-run" + ): + raise RuntimeError( + "Set MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD before production seeding.", + ) + + with SessionLocal() as db: + tenant = get_or_create( + db, + Tenant, + { + "name": settings.bootstrap_company, + "mode": settings.mode, + "settings": {"currency": "USD", "timezone": "Asia/Kolkata"}, + }, + slug=settings.bootstrap_workspace, + ) + owner = db.scalar( + select(User).where( + User.tenant_id == tenant.id, + User.email == settings.bootstrap_admin_email.lower(), + ), + ) + if owner is None: + owner = User( + tenant_id=tenant.id, + email=settings.bootstrap_admin_email.lower(), + full_name="Maskan CRM Owner", + password_hash=hash_password(settings.bootstrap_admin_password), + role="owner", + ) + db.add(owner) + db.flush() + + pipeline = db.scalar( + select(Pipeline) + .where(Pipeline.tenant_id == tenant.id, Pipeline.is_default.is_(True)) + .options(selectinload(Pipeline.stages)), + ) + if pipeline is None: + pipeline = Pipeline( + tenant_id=tenant.id, + name="Sales Pipeline", + is_default=True, + ) + db.add(pipeline) + db.flush() + + stage_specs = [ + ("New", 1, 10, "#64748b"), + ("Qualified", 2, 30, "#2563eb"), + ("Proposal", 3, 60, "#7c3aed"), + ("Negotiation", 4, 80, "#d97706"), + ("Won", 5, 100, "#059669"), + ] + stages = {} + for name, position, probability, color in stage_specs: + stages[name] = get_or_create( + db, + Stage, + { + "tenant_id": tenant.id, + "position": position, + "probability": probability, + "color": color, + }, + pipeline_id=pipeline.id, + name=name, + ) + + sources = {} + for name in ["MaskanX", "LinkedIn", "Meta Ads", "Website", "Referral"]: + sources[name] = get_or_create( + db, + LeadSource, + {"tenant_id": tenant.id}, + name=name, + ) + + lead_types = {} + for name in ["New Business", "Expansion", "Renewal"]: + lead_types[name] = get_or_create( + db, + LeadType, + {"tenant_id": tenant.id}, + name=name, + ) + + if with_demo: + seed_demo_data( + db, + tenant=tenant, + owner=owner, + pipeline=pipeline, + stages=stages, + sources=sources, + lead_types=lead_types, + ) + + db.commit() + print(f"Seed complete for workspace '{tenant.slug}'.") + + +def undo_seed() -> None: + settings = get_settings() + if settings.environment.lower() == "production": + raise RuntimeError("Seed undo is disabled in production.") + + with SessionLocal() as db: + result = db.execute( + delete(Tenant).where(Tenant.slug == settings.bootstrap_workspace), + ) + db.commit() + if result.rowcount: + print( + f"Seed data removed for workspace " + f"'{settings.bootstrap_workspace}'.", + ) + else: + print("No bootstrap seed data was found.") + + +def seed_demo_data( + db, + *, + tenant: Tenant, + owner: User, + pipeline: Pipeline, + stages: dict[str, Stage], + sources: dict[str, LeadSource], + lead_types: dict[str, LeadType], +) -> None: + company = get_or_create( + db, + Organization, + { + "tenant_id": tenant.id, + "industry": "Software", + "website": "https://example.com", + "owner_id": owner.id, + }, + name="Northstar Labs", + ) + contact = db.scalar( + select(Contact).where( + Contact.tenant_id == tenant.id, + Contact.primary_email == "maya@example.com", + ), + ) + if contact is None: + contact = Contact( + tenant_id=tenant.id, + first_name="Maya", + last_name="Shah", + job_title="Chief Technology Officer", + primary_email="maya@example.com", + emails=[{"label": "work", "value": "maya@example.com", "primary": True}], + phones=[{"label": "work", "value": "+91 90000 00000", "primary": True}], + lifecycle_stage="opportunity", + lead_source="LinkedIn", + score=82, + organization_id=company.id, + owner_id=owner.id, + ) + db.add(contact) + db.flush() + + lead_specs = [ + ("AI workflow modernization", "Qualified", "25000", "LinkedIn", 82), + ("Customer support automation", "Proposal", "18000", "Website", 76), + ("Custom SaaS build", "New", "42000", "Meta Ads", 61), + ] + for index, (title, stage_name, value, source_name, score) in enumerate(lead_specs): + existing = db.scalar( + select(Lead).where( + Lead.tenant_id == tenant.id, + Lead.title == title, + ), + ) + if existing is None: + db.add( + Lead( + tenant_id=tenant.id, + title=title, + description="Demo opportunity for local development.", + value=Decimal(value), + currency="USD", + score=score, + position=index + 1, + contact_id=contact.id, + organization_id=company.id, + owner_id=owner.id, + pipeline_id=pipeline.id, + stage_id=stages[stage_name].id, + source_id=sources[source_name].id, + type_id=lead_types["New Business"].id, + ), + ) + + if db.scalar( + select(Activity).where( + Activity.tenant_id == tenant.id, + Activity.title == "Discovery call with Northstar Labs", + ), + ) is None: + db.add( + Activity( + tenant_id=tenant.id, + activity_type="call", + title="Discovery call with Northstar Labs", + details="Review automation priorities and success metrics.", + starts_at=datetime.now(UTC) + timedelta(days=1), + ends_at=datetime.now(UTC) + timedelta(days=1, minutes=30), + due_at=datetime.now(UTC) + timedelta(days=1), + owner_id=owner.id, + contact_id=contact.id, + organization_id=company.id, + ), + ) + + if db.scalar( + select(Product).where( + Product.tenant_id == tenant.id, + Product.sku == "AI-AUTOMATION", + ), + ) is None: + db.add( + Product( + tenant_id=tenant.id, + sku="AI-AUTOMATION", + name="AI Automation Implementation", + description="Discovery, design, implementation, and rollout.", + quantity=100, + price=Decimal("15000"), + currency="USD", + ), + ) + + if db.scalar( + select(Quote).where( + Quote.tenant_id == tenant.id, + Quote.number == "Q-1001", + ), + ) is None: + db.add( + Quote( + tenant_id=tenant.id, + number="Q-1001", + subject="AI workflow modernization proposal", + status="draft", + currency="USD", + subtotal=Decimal("25000"), + grand_total=Decimal("25000"), + contact_id=contact.id, + organization_id=company.id, + owner_id=owner.id, + expires_at=datetime.now(UTC) + timedelta(days=30), + ), + ) + + +def main() -> None: + parser = argparse.ArgumentParser(prog="maskan-crm") + subparsers = parser.add_subparsers(dest="command", required=True) + seed_parser = subparsers.add_parser("seed") + seed_parser.add_argument("--without-demo", action="store_true") + subparsers.add_parser("seed-undo") + args = parser.parse_args() + + if args.command == "seed": + seed(with_demo=not args.without_demo and get_settings().seed_demo) + elif args.command == "seed-undo": + undo_seed() + + +if __name__ == "__main__": + main() diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..9de50ff --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1,2 @@ +"""Core configuration, database, and security helpers.""" + diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..89f969a --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,107 @@ +from functools import lru_cache +from urllib.parse import quote_plus + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + app_name: str = "Maskan CRM" + environment: str = Field(default="development", alias="MASKAN_CRM_ENV") + mode: str = Field(default="standalone", alias="MASKAN_CRM_MODE") + cors_origins_raw: str = Field( + default="http://127.0.0.1:5174,http://localhost:5174", + alias="MASKAN_CRM_CORS_ORIGINS", + ) + jwt_secret: str = Field( + default="development-only-change-me-at-least-32-characters", + alias="MASKAN_CRM_JWT_SECRET", + ) + access_token_minutes: int = Field( + default=30, + alias="MASKAN_CRM_ACCESS_TOKEN_MINUTES", + ) + jwt_issuer: str = "maskan-crm" + jwt_audience: str = "maskan-crm-api" + + database_url_override: str | None = Field(default=None, alias="DATABASE_URL") + db_host: str = Field(default="127.0.0.1", alias="DB_HOST") + db_port: int = Field(default=5433, alias="DB_PORT") + db_name: str = Field(default="maskan_crm", alias="DB_NAME") + db_user: str = Field(default="postgres", alias="DB_USER") + db_password: str = Field(default="postgres", alias="DB_PASSWORD") + db_sslmode: str = Field(default="disable", alias="DB_SSLMODE") + + bootstrap_workspace: str = Field( + default="maskan", + alias="MASKAN_CRM_BOOTSTRAP_WORKSPACE", + ) + bootstrap_company: str = Field( + default="Maskan Technologies", + alias="MASKAN_CRM_BOOTSTRAP_COMPANY", + ) + bootstrap_admin_email: str = Field( + default="owner@example.com", + alias="MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL", + ) + bootstrap_admin_password: str = Field( + default="change-this-before-first-run", + alias="MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD", + ) + seed_demo: bool = Field(default=True, alias="MASKAN_CRM_SEED_DEMO") + + @property + def cors_origins(self) -> list[str]: + return [item.strip() for item in self.cors_origins_raw.split(",") if item.strip()] + + @property + def database_url(self) -> str: + if self.database_url_override: + return self.database_url_override + + user = quote_plus(self.db_user) + password = quote_plus(self.db_password) + return ( + f"postgresql+psycopg://{user}:{password}@{self.db_host}:" + f"{self.db_port}/{self.db_name}?sslmode={self.db_sslmode}" + ) + + def validate_production(self) -> None: + if self.environment.lower() != "production": + self.validate_database_backend() + return + + if self.jwt_secret in { + "development-only-change-me-at-least-32-characters", + "replace-me", + }: + raise RuntimeError("MASKAN_CRM_JWT_SECRET must be set for production.") + + if len(self.jwt_secret) < 32: + raise RuntimeError("MASKAN_CRM_JWT_SECRET must contain at least 32 characters.") + + self.validate_database_backend() + + def validate_database_backend(self) -> None: + """Allow SQLite only in explicit automated-test environments.""" + if not self.database_url.lower().startswith("sqlite"): + return + + if self.environment.lower() not in {"test", "testing"}: + raise RuntimeError( + "Maskan CRM requires PostgreSQL outside automated tests. " + "Set DB_HOST/DB_PORT/DB_NAME/DB_USER/DB_PASSWORD or DATABASE_URL.", + ) + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + settings.validate_production() + return settings diff --git a/app/core/database.py b/app/core/database.py new file mode 100644 index 0000000..5826ac4 --- /dev/null +++ b/app/core/database.py @@ -0,0 +1,36 @@ +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from app.core.config import get_settings + + +class Base(DeclarativeBase): + pass + + +def _engine_options(url: str) -> dict[str, object]: + if url.startswith("sqlite"): + return {"connect_args": {"check_same_thread": False}} + + return { + "pool_pre_ping": True, + "pool_size": 10, + "max_overflow": 20, + "pool_recycle": 1800, + } + + +settings = get_settings() +engine = create_engine(settings.database_url, **_engine_options(settings.database_url)) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +def get_db() -> Generator[Session, None, None]: + database = SessionLocal() + try: + yield database + finally: + database.close() + diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..a6d5a46 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,146 @@ +import base64 +import hashlib +import hmac +import os +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Annotated + +import jwt +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from jwt import InvalidTokenError +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.core.database import get_db +from app.models import User + +PBKDF2_ITERATIONS = 600_000 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + +ROLE_LEVELS = { + "viewer": 10, + "member": 20, + "manager": 30, + "admin": 40, + "owner": 50, +} + + +def hash_password(password: str) -> str: + salt = os.urandom(16) + digest = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + PBKDF2_ITERATIONS, + ) + return "pbkdf2_sha256${}${}${}".format( + PBKDF2_ITERATIONS, + base64.urlsafe_b64encode(salt).decode("ascii"), + base64.urlsafe_b64encode(digest).decode("ascii"), + ) + + +def verify_password(password: str, encoded: str) -> bool: + try: + algorithm, iterations, salt_value, digest_value = encoded.split("$", 3) + if algorithm != "pbkdf2_sha256": + return False + salt = base64.urlsafe_b64decode(salt_value.encode("ascii")) + expected = base64.urlsafe_b64decode(digest_value.encode("ascii")) + actual = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt, + int(iterations), + ) + return hmac.compare_digest(actual, expected) + except (ValueError, TypeError): + return False + + +def create_access_token(user: User) -> str: + settings = get_settings() + now = datetime.now(UTC) + expires_at = now + timedelta(minutes=settings.access_token_minutes) + payload = { + "sub": user.id, + "tenant_id": user.tenant_id, + "role": user.role, + "iss": settings.jwt_issuer, + "aud": settings.jwt_audience, + "iat": now, + "exp": expires_at, + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def hash_service_key(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def get_current_user( + token: Annotated[str, Depends(oauth2_scheme)], + db: Annotated[Session, Depends(get_db)], +) -> User: + settings = get_settings() + credentials_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authentication is required.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=["HS256"], + audience=settings.jwt_audience, + issuer=settings.jwt_issuer, + ) + user_id = payload.get("sub") + tenant_id = payload.get("tenant_id") + except InvalidTokenError as exc: + raise credentials_error from exc + + if not user_id or not tenant_id: + raise credentials_error + + user = db.scalar( + select(User).where( + User.id == user_id, + User.tenant_id == tenant_id, + User.is_active.is_(True), + ), + ) + if user is None: + raise credentials_error + + return user + + +CurrentUser = Annotated[User, Depends(get_current_user)] +Database = Annotated[Session, Depends(get_db)] + + +def require_role(minimum_role: str) -> Callable[[CurrentUser], User]: + minimum_level = ROLE_LEVELS[minimum_role] + + def dependency(user: CurrentUser) -> User: + if ROLE_LEVELS.get(user.role, 0) < minimum_level: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You do not have permission to perform this action.", + ) + return user + + return dependency + + +Writer = Annotated[User, Depends(require_role("member"))] +Manager = Annotated[User, Depends(require_role("manager"))] +Admin = Annotated[User, Depends(require_role("admin"))] + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..13c2a67 --- /dev/null +++ b/app/main.py @@ -0,0 +1,107 @@ +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, Request +from fastapi.exceptions import RequestValidationError +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy import text + +from app.api.router import api_router +from app.core.config import get_settings +from app.core.database import SessionLocal + +settings = get_settings() +app = FastAPI( + title="Maskan CRM API", + version="0.1.0", + docs_url="/api/docs", + openapi_url="/api/openapi.json", +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +def error_response( + *, + request: Request, + status_code: int, + code: str, + message: str, + details: object | None = None, +) -> JSONResponse: + request_id = getattr(request.state, "request_id", str(uuid4())) + return JSONResponse( + status_code=status_code, + content={ + "error": { + "code": code, + "message": message, + "details": details, + "request_id": request_id, + }, + }, + headers={"X-Request-ID": request_id}, + ) + + +@app.middleware("http") +async def request_id_middleware(request: Request, call_next): + request.state.request_id = request.headers.get("X-Request-ID") or str(uuid4()) + response = await call_next(request) + response.headers["X-Request-ID"] = request.state.request_id + return response + + +@app.exception_handler(HTTPException) +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + return error_response( + request=request, + status_code=exc.status_code, + code=f"HTTP_{exc.status_code}", + message=str(exc.detail), + ) + + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler( + request: Request, + exc: RequestValidationError, +) -> JSONResponse: + return error_response( + request=request, + status_code=422, + code="VALIDATION_ERROR", + message="One or more fields are invalid.", + details=exc.errors(), + ) + + +@app.get("/health/live", tags=["Health"]) +def live() -> dict[str, str]: + return {"status": "ok", "service": "maskan-crm-api"} + + +@app.get("/health/ready", tags=["Health"]) +def ready() -> dict[str, str]: + with SessionLocal() as db: + db.execute(text("SELECT 1")) + return {"status": "ready", "database": "connected"} + + +@app.get("/api/v1/status", tags=["Health"]) +def status() -> dict[str, str]: + return { + "product": "Maskan CRM", + "mode": settings.mode, + "environment": settings.environment, + } + + +app.include_router(api_router) + diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..1a89599 --- /dev/null +++ b/app/models.py @@ -0,0 +1,560 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any +from uuid import uuid4 + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + ForeignKey, + Index, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.core.database import Base + + +def new_id() -> str: + return str(uuid4()) + + +def now_utc() -> datetime: + return datetime.now(UTC) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=now_utc, + nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=now_utc, + onupdate=now_utc, + nullable=False, + ) + + +class Tenant(Base, TimestampMixin): + __tablename__ = "crm_tenants" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + slug: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + name: Mapped[str] = mapped_column(String(160), nullable=False) + mode: Mapped[str] = mapped_column(String(24), default="standalone", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class User(Base, TimestampMixin): + __tablename__ = "crm_users" + __table_args__ = ( + UniqueConstraint("tenant_id", "email", name="uq_crm_users_tenant_email"), + Index("ix_crm_users_tenant_role", "tenant_id", "role"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + email: Mapped[str] = mapped_column(String(255), nullable=False) + full_name: Mapped[str] = mapped_column(String(160), nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + role: Mapped[str] = mapped_column(String(24), default="member", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + tenant: Mapped[Tenant] = relationship() + + +class Organization(Base, TimestampMixin): + __tablename__ = "crm_organizations" + __table_args__ = ( + Index("ix_crm_organizations_tenant_name", "tenant_id", "name"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + legal_name: Mapped[str | None] = mapped_column(String(240)) + website: Mapped[str | None] = mapped_column(String(500)) + primary_email: Mapped[str | None] = mapped_column(String(255)) + phone: Mapped[str | None] = mapped_column(String(80)) + industry: Mapped[str | None] = mapped_column(String(120)) + address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_users.id", ondelete="SET NULL"), + ) + + owner: Mapped[User | None] = relationship() + contacts: Mapped[list[Contact]] = relationship(back_populates="organization") + + +class Contact(Base, TimestampMixin): + __tablename__ = "crm_contacts" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "primary_email", + name="uq_crm_contacts_tenant_primary_email", + ), + Index("ix_crm_contacts_tenant_name", "tenant_id", "last_name", "first_name"), + Index("ix_crm_contacts_tenant_score", "tenant_id", "score"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + first_name: Mapped[str] = mapped_column(String(100), nullable=False) + last_name: Mapped[str] = mapped_column(String(100), default="", nullable=False) + job_title: Mapped[str | None] = mapped_column(String(160)) + primary_email: Mapped[str | None] = mapped_column(String(255)) + emails: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False) + phones: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, nullable=False) + lifecycle_stage: Mapped[str] = mapped_column(String(40), default="lead", nullable=False) + lead_source: Mapped[str | None] = mapped_column(String(120)) + score: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_organizations.id", ondelete="SET NULL"), + index=True, + ) + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_users.id", ondelete="SET NULL"), + ) + attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + organization: Mapped[Organization | None] = relationship(back_populates="contacts") + owner: Mapped[User | None] = relationship() + + @property + def name(self) -> str: + return f"{self.first_name} {self.last_name}".strip() + + +class Pipeline(Base, TimestampMixin): + __tablename__ = "crm_pipelines" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_crm_pipelines_tenant_name"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(140), nullable=False) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + stages: Mapped[list[Stage]] = relationship( + back_populates="pipeline", + cascade="all, delete-orphan", + order_by="Stage.position", + ) + + +class Stage(Base, TimestampMixin): + __tablename__ = "crm_stages" + __table_args__ = ( + UniqueConstraint( + "pipeline_id", + "name", + name="uq_crm_stages_pipeline_name", + ), + Index("ix_crm_stages_pipeline_position", "pipeline_id", "position"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + pipeline_id: Mapped[str] = mapped_column( + ForeignKey("crm_pipelines.id", ondelete="CASCADE"), + nullable=False, + ) + name: Mapped[str] = mapped_column(String(140), nullable=False) + position: Mapped[int] = mapped_column(Integer, nullable=False) + probability: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + color: Mapped[str] = mapped_column(String(16), default="#64748b", nullable=False) + + pipeline: Mapped[Pipeline] = relationship(back_populates="stages") + + +class LeadSource(Base, TimestampMixin): + __tablename__ = "crm_lead_sources" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_crm_lead_sources_tenant_name"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + + +class LeadType(Base, TimestampMixin): + __tablename__ = "crm_lead_types" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_crm_lead_types_tenant_name"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + + +class Lead(Base, TimestampMixin): + __tablename__ = "crm_leads" + __table_args__ = ( + Index("ix_crm_leads_tenant_stage", "tenant_id", "stage_id", "position"), + Index("ix_crm_leads_tenant_status", "tenant_id", "status"), + Index("ix_crm_leads_tenant_owner", "tenant_id", "owner_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + title: Mapped[str] = mapped_column(String(240), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + value: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False) + status: Mapped[str] = mapped_column(String(24), default="open", nullable=False) + score: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + position: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + lost_reason: Mapped[str | None] = mapped_column(Text) + expected_close_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + closed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + contact_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_contacts.id", ondelete="SET NULL"), + index=True, + ) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_organizations.id", ondelete="SET NULL"), + index=True, + ) + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_users.id", ondelete="SET NULL"), + ) + pipeline_id: Mapped[str] = mapped_column( + ForeignKey("crm_pipelines.id", ondelete="RESTRICT"), + nullable=False, + ) + stage_id: Mapped[str] = mapped_column( + ForeignKey("crm_stages.id", ondelete="RESTRICT"), + nullable=False, + ) + source_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_lead_sources.id", ondelete="SET NULL"), + ) + type_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_lead_types.id", ondelete="SET NULL"), + ) + attributes: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + contact: Mapped[Contact | None] = relationship() + organization: Mapped[Organization | None] = relationship() + owner: Mapped[User | None] = relationship() + pipeline: Mapped[Pipeline] = relationship() + stage: Mapped[Stage] = relationship() + source: Mapped[LeadSource | None] = relationship() + lead_type: Mapped[LeadType | None] = relationship() + + +class Activity(Base, TimestampMixin): + __tablename__ = "crm_activities" + __table_args__ = ( + Index("ix_crm_activities_tenant_due", "tenant_id", "is_done", "due_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + activity_type: Mapped[str] = mapped_column(String(32), nullable=False) + title: Mapped[str] = mapped_column(String(240), nullable=False) + details: Mapped[str | None] = mapped_column(Text) + starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + is_done: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_users.id", ondelete="SET NULL"), + ) + contact_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_contacts.id", ondelete="CASCADE"), + index=True, + ) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_organizations.id", ondelete="CASCADE"), + index=True, + ) + lead_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_leads.id", ondelete="CASCADE"), + index=True, + ) + additional: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + owner: Mapped[User | None] = relationship() + contact: Mapped[Contact | None] = relationship() + organization: Mapped[Organization | None] = relationship() + lead: Mapped[Lead | None] = relationship() + + +class Product(Base, TimestampMixin): + __tablename__ = "crm_products" + __table_args__ = ( + UniqueConstraint("tenant_id", "sku", name="uq_crm_products_tenant_sku"), + Index("ix_crm_products_tenant_name", "tenant_id", "name"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + sku: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + quantity: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + price: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + +class Quote(Base, TimestampMixin): + __tablename__ = "crm_quotes" + __table_args__ = ( + UniqueConstraint("tenant_id", "number", name="uq_crm_quotes_tenant_number"), + Index("ix_crm_quotes_tenant_status", "tenant_id", "status"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + number: Mapped[str] = mapped_column(String(80), nullable=False) + subject: Mapped[str] = mapped_column(String(240), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(32), default="draft", nullable=False) + currency: Mapped[str] = mapped_column(String(3), default="USD", nullable=False) + billing_address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + shipping_address: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + discount_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + default=0, + nullable=False, + ) + tax_amount: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + adjustment_amount: Mapped[Decimal] = mapped_column( + Numeric(14, 2), + default=0, + nullable=False, + ) + subtotal: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + grand_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + contact_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_contacts.id", ondelete="SET NULL"), + ) + organization_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_organizations.id", ondelete="SET NULL"), + ) + lead_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_leads.id", ondelete="SET NULL"), + ) + owner_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_users.id", ondelete="SET NULL"), + ) + + items: Mapped[list[QuoteItem]] = relationship( + back_populates="quote", + cascade="all, delete-orphan", + ) + + +class QuoteItem(Base, TimestampMixin): + __tablename__ = "crm_quote_items" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + quote_id: Mapped[str] = mapped_column( + ForeignKey("crm_quotes.id", ondelete="CASCADE"), + nullable=False, + ) + product_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_products.id", ondelete="SET NULL"), + ) + name: Mapped[str] = mapped_column(String(200), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + quantity: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=1, nullable=False) + unit_price: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + tax_rate: Mapped[Decimal] = mapped_column(Numeric(6, 3), default=0, nullable=False) + line_total: Mapped[Decimal] = mapped_column(Numeric(14, 2), default=0, nullable=False) + + quote: Mapped[Quote] = relationship(back_populates="items") + + +class AuditEvent(Base): + __tablename__ = "crm_audit_events" + __table_args__ = ( + Index("ix_crm_audit_tenant_entity", "tenant_id", "entity_type", "entity_id"), + Index("ix_crm_audit_tenant_created", "tenant_id", "created_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + ) + actor_id: Mapped[str | None] = mapped_column( + ForeignKey("crm_users.id", ondelete="SET NULL"), + ) + action: Mapped[str] = mapped_column(String(100), nullable=False) + entity_type: Mapped[str] = mapped_column(String(80), nullable=False) + entity_id: Mapped[str] = mapped_column(String(80), nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=now_utc, + nullable=False, + ) + + +class IntegrationEvent(Base): + __tablename__ = "crm_integration_events" + __table_args__ = ( + Index("ix_crm_integration_events_delivery", "status", "available_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + ) + topic: Mapped[str] = mapped_column(String(140), nullable=False) + payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + status: Mapped[str] = mapped_column(String(24), default="pending", nullable=False) + attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + available_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=now_utc, + nullable=False, + ) + delivered_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=now_utc, + nullable=False, + ) + + +class IntegrationCredential(Base, TimestampMixin): + __tablename__ = "crm_integration_credentials" + __table_args__ = ( + UniqueConstraint("tenant_id", "name", name="uq_crm_integration_key_name"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + name: Mapped[str] = mapped_column(String(120), nullable=False) + key_prefix: Mapped[str] = mapped_column(String(20), nullable=False) + key_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ExternalLink(Base, TimestampMixin): + __tablename__ = "crm_external_links" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "provider", + "entity_type", + "external_id", + name="uq_crm_external_links_provider_entity", + ), + Index("ix_crm_external_links_local", "tenant_id", "entity_type", "entity_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + ) + provider: Mapped[str] = mapped_column(String(80), nullable=False) + entity_type: Mapped[str] = mapped_column(String(80), nullable=False) + entity_id: Mapped[str] = mapped_column(String(36), nullable=False) + external_id: Mapped[str] = mapped_column(String(255), nullable=False) + metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + + +class IdempotencyRecord(Base): + __tablename__ = "crm_idempotency_records" + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "scope", + "idempotency_key", + name="uq_crm_idempotency_scope_key", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_id) + tenant_id: Mapped[str] = mapped_column( + ForeignKey("crm_tenants.id", ondelete="CASCADE"), + nullable=False, + ) + scope: Mapped[str] = mapped_column(String(100), nullable=False) + idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False) + response_code: Mapped[int] = mapped_column(Integer, nullable=False) + response_body: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=now_utc, + nullable=False, + ) + diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..9ba1ad4 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any, Generic, TypeVar + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +T = TypeVar("T") + + +class ApiModel(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class Page(ApiModel, Generic[T]): + items: list[T] + total: int + page: int + page_size: int + + +class LoginRequest(ApiModel): + workspace: str = Field(min_length=2, max_length=80) + email: EmailStr + password: str = Field(min_length=8, max_length=200) + + +class UserOut(ApiModel): + id: str + tenant_id: str + email: EmailStr + full_name: str + role: str + + +class TenantOut(ApiModel): + id: str + slug: str + name: str + mode: str + + +class TokenResponse(ApiModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user: UserOut + tenant: TenantOut + + +class SessionResponse(ApiModel): + user: UserOut + tenant: TenantOut + + +class OrganizationBase(ApiModel): + name: str = Field(min_length=1, max_length=200) + legal_name: str | None = Field(default=None, max_length=240) + website: str | None = Field(default=None, max_length=500) + primary_email: EmailStr | None = None + phone: str | None = Field(default=None, max_length=80) + industry: str | None = Field(default=None, max_length=120) + address: dict[str, Any] = Field(default_factory=dict) + attributes: dict[str, Any] = Field(default_factory=dict) + owner_id: str | None = None + + +class OrganizationCreate(OrganizationBase): + pass + + +class OrganizationUpdate(ApiModel): + name: str | None = Field(default=None, min_length=1, max_length=200) + legal_name: str | None = Field(default=None, max_length=240) + website: str | None = Field(default=None, max_length=500) + primary_email: EmailStr | None = None + phone: str | None = Field(default=None, max_length=80) + industry: str | None = Field(default=None, max_length=120) + address: dict[str, Any] | None = None + attributes: dict[str, Any] | None = None + owner_id: str | None = None + + +class OrganizationOut(OrganizationBase): + id: str + tenant_id: str + created_at: datetime + updated_at: datetime + + +class ContactBase(ApiModel): + first_name: str = Field(min_length=1, max_length=100) + last_name: str = Field(default="", max_length=100) + job_title: str | None = Field(default=None, max_length=160) + primary_email: EmailStr | None = None + emails: list[dict[str, Any]] = Field(default_factory=list) + phones: list[dict[str, Any]] = Field(default_factory=list) + lifecycle_stage: str = Field(default="lead", max_length=40) + lead_source: str | None = Field(default=None, max_length=120) + score: int = Field(default=0, ge=0, le=100) + organization_id: str | None = None + owner_id: str | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + +class ContactCreate(ContactBase): + pass + + +class ContactUpdate(ApiModel): + first_name: str | None = Field(default=None, min_length=1, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + job_title: str | None = Field(default=None, max_length=160) + primary_email: EmailStr | None = None + emails: list[dict[str, Any]] | None = None + phones: list[dict[str, Any]] | None = None + lifecycle_stage: str | None = Field(default=None, max_length=40) + lead_source: str | None = Field(default=None, max_length=120) + score: int | None = Field(default=None, ge=0, le=100) + organization_id: str | None = None + owner_id: str | None = None + attributes: dict[str, Any] | None = None + + +class ContactOut(ContactBase): + id: str + tenant_id: str + name: str + organization_name: str | None = None + owner_name: str | None = None + created_at: datetime + updated_at: datetime + + +class StageOut(ApiModel): + id: str + pipeline_id: str + name: str + position: int + probability: int + color: str + + +class PipelineOut(ApiModel): + id: str + name: str + is_default: bool + stages: list[StageOut] + + +class LeadBase(ApiModel): + title: str = Field(min_length=1, max_length=240) + description: str | None = None + value: Decimal = Field(default=Decimal("0"), ge=0) + currency: str = Field(default="USD", min_length=3, max_length=3) + score: int = Field(default=0, ge=0, le=100) + expected_close_at: datetime | None = None + contact_id: str | None = None + organization_id: str | None = None + owner_id: str | None = None + pipeline_id: str + stage_id: str + source_id: str | None = None + type_id: str | None = None + attributes: dict[str, Any] = Field(default_factory=dict) + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + return value.upper() + + +class LeadCreate(LeadBase): + pass + + +class LeadUpdate(ApiModel): + title: str | None = Field(default=None, min_length=1, max_length=240) + description: str | None = None + value: Decimal | None = Field(default=None, ge=0) + currency: str | None = Field(default=None, min_length=3, max_length=3) + status: str | None = Field(default=None, pattern="^(open|won|lost)$") + score: int | None = Field(default=None, ge=0, le=100) + lost_reason: str | None = None + expected_close_at: datetime | None = None + contact_id: str | None = None + organization_id: str | None = None + owner_id: str | None = None + pipeline_id: str | None = None + stage_id: str | None = None + source_id: str | None = None + type_id: str | None = None + attributes: dict[str, Any] | None = None + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str | None) -> str | None: + return value.upper() if value else value + + +class LeadMove(ApiModel): + stage_id: str + position: int = Field(default=0, ge=0) + + +class LeadOut(LeadBase): + id: str + status: str + position: int + lost_reason: str | None + closed_at: datetime | None + contact_name: str | None = None + organization_name: str | None = None + owner_name: str | None = None + pipeline_name: str + stage_name: str + stage_color: str + source_name: str | None = None + type_name: str | None = None + created_at: datetime + updated_at: datetime + + +class ActivityBase(ApiModel): + activity_type: str = Field( + pattern="^(task|call|meeting|note|email|lunch)$", + ) + title: str = Field(min_length=1, max_length=240) + details: str | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None + due_at: datetime | None = None + is_done: bool = False + owner_id: str | None = None + contact_id: str | None = None + organization_id: str | None = None + lead_id: str | None = None + additional: dict[str, Any] = Field(default_factory=dict) + + +class ActivityCreate(ActivityBase): + pass + + +class ActivityUpdate(ApiModel): + activity_type: str | None = Field( + default=None, + pattern="^(task|call|meeting|note|email|lunch)$", + ) + title: str | None = Field(default=None, min_length=1, max_length=240) + details: str | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None + due_at: datetime | None = None + is_done: bool | None = None + owner_id: str | None = None + contact_id: str | None = None + organization_id: str | None = None + lead_id: str | None = None + additional: dict[str, Any] | None = None + + +class ActivityOut(ActivityBase): + id: str + completed_at: datetime | None + owner_name: str | None = None + contact_name: str | None = None + organization_name: str | None = None + lead_title: str | None = None + created_at: datetime + updated_at: datetime + + +class ProductBase(ApiModel): + sku: str = Field(min_length=1, max_length=100) + name: str = Field(min_length=1, max_length=200) + description: str | None = None + quantity: int = Field(default=0, ge=0) + price: Decimal = Field(default=Decimal("0"), ge=0) + currency: str = Field(default="USD", min_length=3, max_length=3) + is_active: bool = True + + +class ProductCreate(ProductBase): + pass + + +class ProductOut(ProductBase): + id: str + created_at: datetime + updated_at: datetime + + +class QuoteOut(ApiModel): + id: str + number: str + subject: str + status: str + currency: str + subtotal: Decimal + grand_total: Decimal + expires_at: datetime | None + created_at: datetime + updated_at: datetime + + +class QuoteCreate(ApiModel): + number: str = Field(min_length=1, max_length=80) + subject: str = Field(min_length=1, max_length=240) + description: str | None = None + status: str = Field(default="draft", pattern="^(draft|sent|accepted|rejected|expired)$") + currency: str = Field(default="USD", min_length=3, max_length=3) + contact_id: str | None = None + organization_id: str | None = None + lead_id: str | None = None + owner_id: str | None = None + expires_at: datetime | None = None + + @field_validator("currency") + @classmethod + def normalize_currency(cls, value: str) -> str: + return value.upper() + + +class ReferenceItem(ApiModel): + id: str + name: str + + +class DashboardStageMetric(ApiModel): + stage_id: str + stage_name: str + color: str + lead_count: int + total_value: Decimal + + +class DashboardSourceMetric(ApiModel): + source: str + lead_count: int + + +class DashboardResponse(ApiModel): + total_revenue: Decimal + open_pipeline_value: Decimal + total_leads: int + open_leads: int + won_leads: int + lost_leads: int + total_contacts: int + overdue_activities: int + stages: list[DashboardStageMetric] + sources: list[DashboardSourceMetric] + recent_activities: list[ActivityOut] + + +class IntegrationLeadRequest(ApiModel): + provider: str = Field(min_length=2, max_length=80) + external_id: str = Field(min_length=1, max_length=255) + first_name: str = Field(min_length=1, max_length=100) + last_name: str = Field(default="", max_length=100) + email: EmailStr | None = None + phone: str | None = Field(default=None, max_length=80) + company_name: str | None = Field(default=None, max_length=200) + job_title: str | None = Field(default=None, max_length=160) + lead_title: str | None = Field(default=None, max_length=240) + source: str = Field(default="MaskanX", max_length=120) + score: int = Field(default=0, ge=0, le=100) + campaign: dict[str, Any] = Field(default_factory=dict) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class IntegrationLeadResponse(ApiModel): + contact_id: str + lead_id: str + created: bool + + +class IntegrationKeyCreate(ApiModel): + name: str = Field(min_length=2, max_length=120) + + +class IntegrationKeyResponse(ApiModel): + id: str + name: str + key: str + key_prefix: str + created_at: datetime + + +class IntegrationCredentialOut(ApiModel): + id: str + name: str + key_prefix: str + is_active: bool + last_used_at: datetime | None + created_at: datetime + updated_at: datetime + + +class IntegrationStatusResponse(ApiModel): + status: str + product: str + mode: str + tenant_id: str + tenant_name: str + workspace: str + credential_id: str + credential_name: str + key_prefix: str diff --git a/app/services.py b/app/services.py new file mode 100644 index 0000000..2a42c37 --- /dev/null +++ b/app/services.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import Any + +from fastapi import HTTPException, status +from sqlalchemy import func, select +from sqlalchemy.orm import Session, selectinload + +from app.models import ( + Activity, + AuditEvent, + Contact, + IntegrationEvent, + Lead, + Organization, + Pipeline, + Stage, + User, +) +from app.schemas import ActivityOut, ContactOut, LeadOut + + +def model_or_404( + db: Session, + model: type[Any], + entity_id: str, + tenant_id: str, +) -> Any: + entity = db.scalar( + select(model).where( + model.id == entity_id, + model.tenant_id == tenant_id, + ), + ) + if entity is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="The requested record was not found.", + ) + return entity + + +def verify_optional_reference( + db: Session, + model: type[Any], + entity_id: str | None, + tenant_id: str, +) -> None: + if entity_id is not None: + model_or_404(db, model, entity_id, tenant_id) + + +def add_audit( + db: Session, + *, + actor: User | None, + tenant_id: str, + action: str, + entity_type: str, + entity_id: str, + payload: dict[str, Any] | None = None, +) -> None: + db.add( + AuditEvent( + tenant_id=tenant_id, + actor_id=actor.id if actor else None, + action=action, + entity_type=entity_type, + entity_id=entity_id, + payload=payload or {}, + ), + ) + + +def add_event( + db: Session, + *, + tenant_id: str, + topic: str, + payload: dict[str, Any], +) -> None: + db.add( + IntegrationEvent( + tenant_id=tenant_id, + topic=topic, + payload=payload, + ), + ) + + +def apply_updates(entity: Any, values: dict[str, Any]) -> None: + for field, value in values.items(): + setattr(entity, field, value) + + +def contact_to_out(contact: Contact) -> ContactOut: + return ContactOut( + id=contact.id, + tenant_id=contact.tenant_id, + first_name=contact.first_name, + last_name=contact.last_name, + name=contact.name, + job_title=contact.job_title, + primary_email=contact.primary_email, + emails=contact.emails, + phones=contact.phones, + lifecycle_stage=contact.lifecycle_stage, + lead_source=contact.lead_source, + score=contact.score, + organization_id=contact.organization_id, + organization_name=contact.organization.name if contact.organization else None, + owner_id=contact.owner_id, + owner_name=contact.owner.full_name if contact.owner else None, + attributes=contact.attributes, + created_at=contact.created_at, + updated_at=contact.updated_at, + ) + + +def lead_to_out(lead: Lead) -> LeadOut: + return LeadOut( + id=lead.id, + title=lead.title, + description=lead.description, + value=lead.value, + currency=lead.currency, + status=lead.status, + score=lead.score, + position=lead.position, + lost_reason=lead.lost_reason, + expected_close_at=lead.expected_close_at, + closed_at=lead.closed_at, + contact_id=lead.contact_id, + contact_name=lead.contact.name if lead.contact else None, + organization_id=lead.organization_id, + organization_name=lead.organization.name if lead.organization else None, + owner_id=lead.owner_id, + owner_name=lead.owner.full_name if lead.owner else None, + pipeline_id=lead.pipeline_id, + pipeline_name=lead.pipeline.name, + stage_id=lead.stage_id, + stage_name=lead.stage.name, + stage_color=lead.stage.color, + source_id=lead.source_id, + source_name=lead.source.name if lead.source else None, + type_id=lead.type_id, + type_name=lead.lead_type.name if lead.lead_type else None, + attributes=lead.attributes, + created_at=lead.created_at, + updated_at=lead.updated_at, + ) + + +def activity_to_out(activity: Activity) -> ActivityOut: + return ActivityOut( + id=activity.id, + activity_type=activity.activity_type, + title=activity.title, + details=activity.details, + starts_at=activity.starts_at, + ends_at=activity.ends_at, + due_at=activity.due_at, + is_done=activity.is_done, + completed_at=activity.completed_at, + owner_id=activity.owner_id, + owner_name=activity.owner.full_name if activity.owner else None, + contact_id=activity.contact_id, + contact_name=activity.contact.name if activity.contact else None, + organization_id=activity.organization_id, + organization_name=( + activity.organization.name if activity.organization else None + ), + lead_id=activity.lead_id, + lead_title=activity.lead.title if activity.lead else None, + additional=activity.additional, + created_at=activity.created_at, + updated_at=activity.updated_at, + ) + + +def lead_load_options() -> tuple[Any, ...]: + return ( + selectinload(Lead.contact), + selectinload(Lead.organization), + selectinload(Lead.owner), + selectinload(Lead.pipeline), + selectinload(Lead.stage), + selectinload(Lead.source), + selectinload(Lead.lead_type), + ) + + +def activity_load_options() -> tuple[Any, ...]: + return ( + selectinload(Activity.owner), + selectinload(Activity.contact), + selectinload(Activity.organization), + selectinload(Activity.lead), + ) + + +def validate_lead_references( + db: Session, + *, + tenant_id: str, + pipeline_id: str, + stage_id: str, + contact_id: str | None = None, + organization_id: str | None = None, + owner_id: str | None = None, +) -> None: + pipeline = model_or_404(db, Pipeline, pipeline_id, tenant_id) + stage = model_or_404(db, Stage, stage_id, tenant_id) + if stage.pipeline_id != pipeline.id: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="The selected stage does not belong to the selected pipeline.", + ) + verify_optional_reference(db, Contact, contact_id, tenant_id) + verify_optional_reference(db, Organization, organization_id, tenant_id) + verify_optional_reference(db, User, owner_id, tenant_id) + + +def set_lead_status(lead: Lead, status_value: str, lost_reason: str | None) -> None: + if status_value == "lost" and not lost_reason: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail="A lost reason is required when a lead is marked lost.", + ) + + lead.status = status_value + lead.lost_reason = lost_reason if status_value == "lost" else None + lead.closed_at = datetime.now(UTC) if status_value in {"won", "lost"} else None + + +def next_lead_position(db: Session, tenant_id: str, stage_id: str) -> int: + maximum = db.scalar( + select(func.max(Lead.position)).where( + Lead.tenant_id == tenant_id, + Lead.stage_id == stage_id, + ), + ) + return int(maximum or 0) + 1 + + +def decimal_or_zero(value: Decimal | None) -> Decimal: + return value or Decimal("0") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ae9b99a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,68 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME:-maskan_crm} + POSTGRES_USER: ${DB_USER:-postgres} + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + ports: + - "${POSTGRES_PUBLIC_PORT:-5433}:5432" + volumes: + - maskan-crm-postgres:/var/lib/postgresql/data + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-maskan_crm}", + ] + interval: 5s + timeout: 5s + retries: 20 + + api: + build: + context: . + restart: unless-stopped + environment: + MASKAN_CRM_ENV: ${MASKAN_CRM_ENV:-development} + MASKAN_CRM_MODE: ${MASKAN_CRM_MODE:-standalone} + MASKAN_CRM_CORS_ORIGINS: ${MASKAN_CRM_CORS_ORIGINS:-http://127.0.0.1:5174,http://localhost:5174} + MASKAN_CRM_JWT_SECRET: ${MASKAN_CRM_JWT_SECRET:-local-development-secret-change-me-123456789} + MASKAN_CRM_ACCESS_TOKEN_MINUTES: ${MASKAN_CRM_ACCESS_TOKEN_MINUTES:-30} + DB_HOST: postgres + DB_PORT: 5432 + DB_NAME: ${DB_NAME:-maskan_crm} + DB_USER: ${DB_USER:-postgres} + DB_PASSWORD: ${DB_PASSWORD:-postgres} + DB_SSLMODE: disable + MASKAN_CRM_BOOTSTRAP_WORKSPACE: ${MASKAN_CRM_BOOTSTRAP_WORKSPACE:-maskan} + MASKAN_CRM_BOOTSTRAP_COMPANY: ${MASKAN_CRM_BOOTSTRAP_COMPANY:-Maskan Technologies} + MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL: ${MASKAN_CRM_BOOTSTRAP_ADMIN_EMAIL:-owner@example.com} + MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD: ${MASKAN_CRM_BOOTSTRAP_ADMIN_PASSWORD:-change-this-before-first-run} + MASKAN_CRM_SEED_DEMO: ${MASKAN_CRM_SEED_DEMO:-true} + command: + [ + "sh", + "-c", + "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8091", + ] + ports: + - "${MASKAN_CRM_API_PORT:-8091}:8091" + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8091/health/ready')", + ] + interval: 10s + timeout: 5s + retries: 12 + +volumes: + maskan-crm-postgres: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d9b0855 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,157 @@ +{ + "name": "maskan-crm-backend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maskan-crm-backend", + "version": "0.1.0", + "dependencies": { + "dotenv": "^16.6.1" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "dotenv-cli": "^8.0.0" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", + "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..36c49ce --- /dev/null +++ b/package.json @@ -0,0 +1,42 @@ +{ + "name": "maskan-crm-backend", + "version": "0.1.0", + "private": true, + "description": "Independent FastAPI backend for Maskan CRM.", + "scripts": { + "start": "node -r dotenv/config scripts/run-python.cjs -m uvicorn app.main:app --host 0.0.0.0 --port 8091", + "build": "node scripts/run-python.cjs -m pip wheel . --no-deps --wheel-dir dist", + "local": "dotenv -e .env.local -- node scripts/run-python.cjs -m uvicorn app.main:app --host 127.0.0.1 --port 8091 --reload", + "local:migrate": "dotenv -e .env.local -- node scripts/run-python.cjs -m alembic upgrade head", + "local:migrate:undo": "dotenv -e .env.local -- node scripts/run-python.cjs -m alembic downgrade -1", + "local:migrate:undo:all": "dotenv -e .env.local -- node scripts/run-python.cjs -m alembic downgrade base", + "local:seed": "dotenv -e .env.local -- node scripts/run-python.cjs -m app.cli seed", + "local:seed:undo": "dotenv -e .env.local -- node scripts/run-python.cjs -m app.cli seed-undo", + "local:reset": "npm run local:migrate:undo:all && npm run local:migrate && npm run local:seed", + "dev": "dotenv -e .env.development -- node scripts/run-python.cjs -m uvicorn app.main:app --host 0.0.0.0 --port 8091 --reload", + "dev:migrate": "dotenv -e .env.development -- node scripts/run-python.cjs -m alembic upgrade head", + "dev:migrate:undo": "dotenv -e .env.development -- node scripts/run-python.cjs -m alembic downgrade -1", + "dev:migrate:undo:all": "dotenv -e .env.development -- node scripts/run-python.cjs -m alembic downgrade base", + "dev:seed": "dotenv -e .env.development -- node scripts/run-python.cjs -m app.cli seed", + "dev:seed:undo": "dotenv -e .env.development -- node scripts/run-python.cjs -m app.cli seed-undo", + "dev:reset": "npm run dev:migrate:undo:all && npm run dev:migrate && npm run dev:seed", + "test": "dotenv -e .env.testing -- node scripts/run-python.cjs -m pytest", + "test:migrate": "dotenv -e .env.testing -- node scripts/run-python.cjs -m alembic upgrade head", + "test:seed": "dotenv -e .env.testing -- node scripts/run-python.cjs -m app.cli seed", + "test:reset": "dotenv -e .env.testing -- node scripts/run-python.cjs -m alembic downgrade base && dotenv -e .env.testing -- node scripts/run-python.cjs -m alembic upgrade head", + "prod": "cross-env MASKAN_CRM_ENV=production node scripts/run-python.cjs -m uvicorn app.main:app --host 0.0.0.0 --port 8091", + "prod:migrate": "cross-env MASKAN_CRM_ENV=production node scripts/run-python.cjs -m alembic upgrade head", + "prod:seed": "cross-env MASKAN_CRM_ENV=production node scripts/run-python.cjs -m app.cli seed --without-demo", + "lint": "node scripts/run-python.cjs -m ruff check app tests", + "docker:up": "docker compose up --build", + "docker:down": "docker compose down", + "docker:reset": "docker compose down -v && docker compose up --build" + }, + "dependencies": { + "dotenv": "^16.6.1" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "dotenv-cli": "^8.0.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4379c0e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "maskan-crm-api" +version = "0.1.0" +description = "FastAPI backend for Maskan CRM" +requires-python = ">=3.11,<3.14" +dependencies = [ + "alembic>=1.14.0", + "email-validator>=2.2.0", + "fastapi>=0.115.0", + "psycopg[binary]>=3.2.0", + "pydantic-settings>=2.7.0", + "PyJWT>=2.10.0", + "sqlalchemy>=2.0.36", + "uvicorn[standard]>=0.34.0", +] + +[project.optional-dependencies] +dev = [ + "httpx>=0.28.0", + "pytest>=8.3.0", + "pytest-cov>=6.0.0", + "ruff>=0.9.0", +] + +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +include = ["app*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B"] + diff --git a/scripts/run-python.cjs b/scripts/run-python.cjs new file mode 100644 index 0000000..b002416 --- /dev/null +++ b/scripts/run-python.cjs @@ -0,0 +1,57 @@ +const { existsSync } = require("node:fs"); +const { join, resolve } = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const root = resolve(__dirname, ".."); + +function canRun(command) { + const result = spawnSync(command, ["--version"], { + cwd: root, + shell: false, + stdio: "ignore", + }); + + return result.status === 0; +} + +const configured = process.env.MASKAN_CRM_PYTHON; +const localCandidates = + process.platform === "win32" + ? [ + join(root, ".venv", "Scripts", "python.exe"), + join(root, "..", ".venv311", "Scripts", "python.exe"), + ] + : [ + join(root, ".venv", "bin", "python"), + join(root, "..", ".venv311", "bin", "python"), + ]; + +const candidates = [ + configured, + ...localCandidates.filter(existsSync), + process.platform === "win32" ? "python" : "python3", + "python", +].filter(Boolean); + +const python = candidates.find(canRun); + +if (!python) { + console.error( + "Python was not found. Create .venv or set MASKAN_CRM_PYTHON.", + ); + process.exit(1); +} + +const result = spawnSync(python, process.argv.slice(2), { + cwd: root, + env: process.env, + shell: false, + stdio: "inherit", +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f94396d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,130 @@ +from collections.abc import Generator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, event, select +from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.pool import StaticPool + +from app.core.database import Base, get_db +from app.core.security import hash_password +from app.main import app +from app.models import LeadSource, LeadType, Pipeline, Stage, Tenant, User + +TEST_PASSWORD = "StrongPassword123!" +engine = create_engine( + "sqlite+pysqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSession = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +@event.listens_for(engine, "connect") +def enable_sqlite_foreign_keys(connection, _record) -> None: + cursor = connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +@pytest.fixture() +def db() -> Generator[Session, None, None]: + Base.metadata.create_all(bind=engine) + database = TestingSession() + try: + seed_test_workspace(database) + yield database + finally: + database.close() + Base.metadata.drop_all(bind=engine) + + +def seed_test_workspace(db: Session) -> None: + tenant = Tenant(slug="maskan", name="Maskan Technologies") + db.add(tenant) + db.flush() + owner = User( + tenant_id=tenant.id, + email="owner@example.com", + full_name="Test Owner", + password_hash=hash_password(TEST_PASSWORD), + role="owner", + ) + viewer = User( + tenant_id=tenant.id, + email="viewer@example.com", + full_name="Test Viewer", + password_hash=hash_password(TEST_PASSWORD), + role="viewer", + ) + db.add_all([owner, viewer]) + pipeline = Pipeline(tenant_id=tenant.id, name="Sales Pipeline", is_default=True) + db.add(pipeline) + db.flush() + db.add_all( + [ + Stage( + tenant_id=tenant.id, + pipeline_id=pipeline.id, + name="New", + position=1, + probability=10, + color="#64748b", + ), + Stage( + tenant_id=tenant.id, + pipeline_id=pipeline.id, + name="Qualified", + position=2, + probability=40, + color="#2563eb", + ), + LeadSource(tenant_id=tenant.id, name="LinkedIn"), + LeadType(tenant_id=tenant.id, name="New Business"), + ], + ) + db.commit() + + +@pytest.fixture() +def client(db: Session) -> Generator[TestClient, None, None]: + def override_db(): + yield db + + app.dependency_overrides[get_db] = override_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +def login(client: TestClient, email: str = "owner@example.com") -> dict[str, str]: + response = client.post( + "/api/v1/auth/login", + json={ + "workspace": "maskan", + "email": email, + "password": TEST_PASSWORD, + }, + ) + assert response.status_code == 200, response.text + return {"Authorization": f"Bearer {response.json()['access_token']}"} + + +@pytest.fixture() +def auth_headers(client: TestClient) -> dict[str, str]: + return login(client) + + +@pytest.fixture() +def viewer_headers(client: TestClient) -> dict[str, str]: + return login(client, "viewer@example.com") + + +@pytest.fixture() +def pipeline_ids(db: Session) -> tuple[str, str, str]: + pipeline = db.scalar(select(Pipeline).where(Pipeline.is_default.is_(True))) + stages = db.scalars( + select(Stage).where(Stage.pipeline_id == pipeline.id).order_by(Stage.position), + ).all() + return pipeline.id, stages[0].id, stages[1].id + diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..8313a00 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,51 @@ +from fastapi.testclient import TestClient + +from tests.conftest import TEST_PASSWORD + + +def test_login_and_current_session(client: TestClient) -> None: + response = client.post( + "/api/v1/auth/login", + json={ + "workspace": "maskan", + "email": "owner@example.com", + "password": TEST_PASSWORD, + }, + ) + assert response.status_code == 200 + payload = response.json() + assert payload["user"]["role"] == "owner" + assert payload["tenant"]["slug"] == "maskan" + + session = client.get( + "/api/v1/auth/me", + headers={"Authorization": f"Bearer {payload['access_token']}"}, + ) + assert session.status_code == 200 + assert session.json()["tenant"]["name"] == "Maskan Technologies" + + +def test_login_failure_is_generic(client: TestClient) -> None: + response = client.post( + "/api/v1/auth/login", + json={ + "workspace": "maskan", + "email": "owner@example.com", + "password": "incorrect-password", + }, + ) + assert response.status_code == 401 + assert "workspace, email, or password" in response.json()["error"]["message"] + + +def test_viewer_cannot_create_contact( + client: TestClient, + viewer_headers: dict[str, str], +) -> None: + response = client.post( + "/api/v1/contacts", + headers=viewer_headers, + json={"first_name": "Read", "last_name": "Only"}, + ) + assert response.status_code == 403 + diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..1064073 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,30 @@ +import pytest + +from app.core.config import Settings + + +def test_postgres_is_the_default_runtime_database() -> None: + settings = Settings(_env_file=None) + + assert settings.database_url.startswith("postgresql+psycopg://") + + +def test_sqlite_is_rejected_outside_tests() -> None: + settings = Settings( + _env_file=None, + MASKAN_CRM_ENV="development", + DATABASE_URL="sqlite+pysqlite:///crm.db", + ) + + with pytest.raises(RuntimeError, match="requires PostgreSQL"): + settings.validate_database_backend() + + +def test_sqlite_is_allowed_for_automated_tests_only() -> None: + settings = Settings( + _env_file=None, + MASKAN_CRM_ENV="testing", + DATABASE_URL="sqlite+pysqlite://", + ) + + settings.validate_database_backend() diff --git a/tests/test_contacts.py b/tests/test_contacts.py new file mode 100644 index 0000000..81839d6 --- /dev/null +++ b/tests/test_contacts.py @@ -0,0 +1,83 @@ +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import Contact, Tenant + + +def test_contact_create_search_and_update( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + created = client.post( + "/api/v1/contacts", + headers=auth_headers, + json={ + "first_name": "Asha", + "last_name": "Rao", + "primary_email": "asha@example.com", + "score": 74, + "phones": [ + {"label": "work", "value": "+91 90000 12345", "primary": True}, + ], + }, + ) + assert created.status_code == 201, created.text + contact_id = created.json()["id"] + assert created.json()["name"] == "Asha Rao" + + listed = client.get( + "/api/v1/contacts?search=asha", + headers=auth_headers, + ) + assert listed.status_code == 200 + assert listed.json()["total"] == 1 + + updated = client.patch( + f"/api/v1/contacts/{contact_id}", + headers=auth_headers, + json={"lifecycle_stage": "opportunity", "score": 88}, + ) + assert updated.status_code == 200 + assert updated.json()["score"] == 88 + + +def test_duplicate_primary_email_is_rejected( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + payload = { + "first_name": "Asha", + "last_name": "Rao", + "primary_email": "duplicate@example.com", + } + assert client.post("/api/v1/contacts", headers=auth_headers, json=payload).status_code == 201 + duplicate = client.post( + "/api/v1/contacts", + headers=auth_headers, + json={**payload, "first_name": "Another"}, + ) + assert duplicate.status_code == 409 + + +def test_cross_tenant_contact_returns_not_found( + client: TestClient, + auth_headers: dict[str, str], + db: Session, +) -> None: + other_tenant = Tenant(slug="other", name="Other Workspace") + db.add(other_tenant) + db.flush() + private_contact = Contact( + tenant_id=other_tenant.id, + first_name="Private", + last_name="Contact", + ) + db.add(private_contact) + db.commit() + + response = client.get( + f"/api/v1/contacts/{private_contact.id}", + headers=auth_headers, + ) + assert response.status_code == 404 + diff --git a/tests/test_integrations.py b/tests/test_integrations.py new file mode 100644 index 0000000..e9d0612 --- /dev/null +++ b/tests/test_integrations.py @@ -0,0 +1,121 @@ +from fastapi.testclient import TestClient + + +def test_integration_credentials_can_be_listed_and_revoked( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + created = client.post( + "/api/v1/integrations/credentials", + headers=auth_headers, + json={"name": "MaskanX production"}, + ) + assert created.status_code == 201, created.text + credential_id = created.json()["id"] + service_key = created.json()["key"] + + listed = client.get( + "/api/v1/integrations/credentials", + headers=auth_headers, + ) + assert listed.status_code == 200, listed.text + assert listed.json()[0]["id"] == credential_id + assert "key" not in listed.json()[0] + + ready = client.get( + "/api/v1/integrations/status", + headers={"X-Integration-Key": service_key}, + ) + assert ready.status_code == 200, ready.text + assert ready.json()["status"] == "ready" + assert ready.json()["workspace"] == "maskan" + + revoked = client.delete( + f"/api/v1/integrations/credentials/{credential_id}", + headers=auth_headers, + ) + assert revoked.status_code == 204, revoked.text + + rejected = client.get( + "/api/v1/integrations/status", + headers={"X-Integration-Key": service_key}, + ) + assert rejected.status_code == 401 + + +def test_integration_lead_ingestion_is_idempotent( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + credential = client.post( + "/api/v1/integrations/credentials", + headers=auth_headers, + json={"name": "MaskanX test"}, + ) + assert credential.status_code == 201, credential.text + service_key = credential.json()["key"] + assert service_key.startswith("mcrm_") + + request_headers = { + "X-Integration-Key": service_key, + "Idempotency-Key": "meta-lead-123", + } + payload = { + "provider": "meta", + "external_id": "lead-123", + "first_name": "Asha", + "last_name": "Rao", + "email": "asha@example.com", + "company_name": "Northstar Systems", + "lead_title": "AI automation enquiry", + "source": "Meta Ads", + "score": 76, + "campaign": { + "campaign_id": "campaign-1", + "ad_id": "ad-4", + "form_id": "form-2", + }, + } + + first = client.post( + "/api/v1/integrations/leads", + headers=request_headers, + json=payload, + ) + assert first.status_code == 200, first.text + assert first.json()["created"] is True + + retry = client.post( + "/api/v1/integrations/leads", + headers=request_headers, + json=payload, + ) + assert retry.status_code == 200, retry.text + assert retry.json() == first.json() + + contacts = client.get("/api/v1/contacts", headers=auth_headers) + leads = client.get("/api/v1/leads", headers=auth_headers) + assert contacts.json()["total"] == 1 + assert leads.json()["total"] == 1 + + +def test_integration_lead_requires_idempotency_key( + client: TestClient, + auth_headers: dict[str, str], +) -> None: + credential = client.post( + "/api/v1/integrations/credentials", + headers=auth_headers, + json={"name": "MaskanX test"}, + ) + response = client.post( + "/api/v1/integrations/leads", + headers={"X-Integration-Key": credential.json()["key"]}, + json={ + "provider": "meta", + "external_id": "lead-456", + "first_name": "Maya", + }, + ) + assert response.status_code == 400 + assert "Idempotency-Key" in response.json()["error"]["message"] diff --git a/tests/test_leads.py b/tests/test_leads.py new file mode 100644 index 0000000..b4c60a5 --- /dev/null +++ b/tests/test_leads.py @@ -0,0 +1,138 @@ +from fastapi.testclient import TestClient + + +def test_lead_create_move_and_dashboard( + client: TestClient, + auth_headers: dict[str, str], + pipeline_ids: tuple[str, str, str], +) -> None: + pipeline_id, new_stage_id, qualified_stage_id = pipeline_ids + created = client.post( + "/api/v1/leads", + headers=auth_headers, + json={ + "title": "Automation discovery", + "description": "Qualified inbound opportunity", + "value": "12500.00", + "currency": "USD", + "score": 71, + "pipeline_id": pipeline_id, + "stage_id": new_stage_id, + }, + ) + assert created.status_code == 201, created.text + lead_id = created.json()["id"] + assert created.json()["stage_name"] == "New" + + moved = client.post( + f"/api/v1/leads/{lead_id}/move", + headers=auth_headers, + json={"stage_id": qualified_stage_id, "position": 1}, + ) + assert moved.status_code == 200 + assert moved.json()["stage_name"] == "Qualified" + + dashboard = client.get("/api/v1/dashboard", headers=auth_headers) + assert dashboard.status_code == 200 + assert dashboard.json()["total_leads"] == 1 + assert dashboard.json()["open_pipeline_value"] == "12500.00" + + +def test_lost_lead_requires_reason( + client: TestClient, + auth_headers: dict[str, str], + pipeline_ids: tuple[str, str, str], +) -> None: + pipeline_id, stage_id, _ = pipeline_ids + created = client.post( + "/api/v1/leads", + headers=auth_headers, + json={ + "title": "Opportunity", + "pipeline_id": pipeline_id, + "stage_id": stage_id, + }, + ) + lead_id = created.json()["id"] + response = client.patch( + f"/api/v1/leads/{lead_id}", + headers=auth_headers, + json={"status": "lost"}, + ) + assert response.status_code == 422 + + +def test_lead_stage_edit_repositions_and_status_transitions_persist( + client: TestClient, + auth_headers: dict[str, str], + pipeline_ids: tuple[str, str, str], +) -> None: + pipeline_id, new_stage_id, qualified_stage_id = pipeline_ids + existing_qualified = client.post( + "/api/v1/leads", + headers=auth_headers, + json={ + "title": "Existing qualified opportunity", + "pipeline_id": pipeline_id, + "stage_id": qualified_stage_id, + }, + ) + assert existing_qualified.status_code == 201 + + created = client.post( + "/api/v1/leads", + headers=auth_headers, + json={ + "title": "Lifecycle opportunity", + "pipeline_id": pipeline_id, + "stage_id": new_stage_id, + }, + ) + assert created.status_code == 201 + lead_id = created.json()["id"] + + moved_by_edit = client.patch( + f"/api/v1/leads/{lead_id}", + headers=auth_headers, + json={"stage_id": qualified_stage_id}, + ) + assert moved_by_edit.status_code == 200 + assert moved_by_edit.json()["stage_id"] == qualified_stage_id + assert moved_by_edit.json()["position"] == 2 + + won = client.patch( + f"/api/v1/leads/{lead_id}", + headers=auth_headers, + json={"status": "won"}, + ) + assert won.status_code == 200 + assert won.json()["status"] == "won" + assert won.json()["closed_at"] is not None + assert won.json()["lost_reason"] is None + + reopened = client.patch( + f"/api/v1/leads/{lead_id}", + headers=auth_headers, + json={"status": "open"}, + ) + assert reopened.status_code == 200 + assert reopened.json()["status"] == "open" + assert reopened.json()["closed_at"] is None + + lost = client.patch( + f"/api/v1/leads/{lead_id}", + headers=auth_headers, + json={"status": "lost", "lost_reason": "Timing changed"}, + ) + assert lost.status_code == 200 + assert lost.json()["status"] == "lost" + assert lost.json()["lost_reason"] == "Timing changed" + assert lost.json()["closed_at"] is not None + + fetched = client.get( + f"/api/v1/leads/{lead_id}", + headers=auth_headers, + ) + assert fetched.status_code == 200 + assert fetched.json()["status"] == "lost" + assert fetched.json()["lost_reason"] == "Timing changed" diff --git a/tests/test_record_relationships.py b/tests/test_record_relationships.py new file mode 100644 index 0000000..2afc5a6 --- /dev/null +++ b/tests/test_record_relationships.py @@ -0,0 +1,148 @@ +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from app.models import Activity, Contact, Tenant + + +def test_record_relation_filters_return_only_linked_records( + client: TestClient, + auth_headers: dict[str, str], + pipeline_ids: tuple[str, str, str], +) -> None: + pipeline_id, stage_id, _ = pipeline_ids + organization = client.post( + "/api/v1/organizations", + headers=auth_headers, + json={"name": "Northstar Labs"}, + ) + assert organization.status_code == 201 + organization_id = organization.json()["id"] + + linked_contact = client.post( + "/api/v1/contacts", + headers=auth_headers, + json={ + "first_name": "Maya", + "last_name": "Shah", + "organization_id": organization_id, + }, + ) + assert linked_contact.status_code == 201 + contact_id = linked_contact.json()["id"] + + other_contact = client.post( + "/api/v1/contacts", + headers=auth_headers, + json={"first_name": "Other", "last_name": "Person"}, + ) + assert other_contact.status_code == 201 + + linked_lead = client.post( + "/api/v1/leads", + headers=auth_headers, + json={ + "title": "Northstar automation", + "pipeline_id": pipeline_id, + "stage_id": stage_id, + "contact_id": contact_id, + "organization_id": organization_id, + }, + ) + assert linked_lead.status_code == 201 + lead_id = linked_lead.json()["id"] + assert client.post( + "/api/v1/leads", + headers=auth_headers, + json={ + "title": "Unrelated opportunity", + "pipeline_id": pipeline_id, + "stage_id": stage_id, + "contact_id": other_contact.json()["id"], + }, + ).status_code == 201 + + linked_activity = client.post( + "/api/v1/activities", + headers=auth_headers, + json={ + "activity_type": "call", + "title": "Discovery call", + "contact_id": contact_id, + "organization_id": organization_id, + "lead_id": lead_id, + }, + ) + assert linked_activity.status_code == 201 + assert client.post( + "/api/v1/activities", + headers=auth_headers, + json={ + "activity_type": "note", + "title": "Unrelated note", + "contact_id": other_contact.json()["id"], + }, + ).status_code == 201 + + contact_leads = client.get( + f"/api/v1/leads?contact_id={contact_id}", + headers=auth_headers, + ) + assert contact_leads.status_code == 200 + assert contact_leads.json()["total"] == 1 + assert contact_leads.json()["items"][0]["id"] == lead_id + + organization_leads = client.get( + f"/api/v1/leads?organization_id={organization_id}", + headers=auth_headers, + ) + assert organization_leads.status_code == 200 + assert organization_leads.json()["total"] == 1 + + contact_activities = client.get( + f"/api/v1/activities?contact_id={contact_id}", + headers=auth_headers, + ) + assert contact_activities.status_code == 200 + assert contact_activities.json()["total"] == 1 + assert contact_activities.json()["items"][0]["title"] == "Discovery call" + + organization_activities = client.get( + f"/api/v1/activities?organization_id={organization_id}", + headers=auth_headers, + ) + assert organization_activities.status_code == 200 + assert organization_activities.json()["total"] == 1 + + +def test_relation_filter_cannot_cross_tenant_boundary( + client: TestClient, + auth_headers: dict[str, str], + db: Session, +) -> None: + other_tenant = Tenant(slug="private", name="Private Workspace") + db.add(other_tenant) + db.flush() + private_contact = Contact( + tenant_id=other_tenant.id, + first_name="Private", + last_name="Contact", + ) + db.add(private_contact) + db.flush() + db.add( + Activity( + tenant_id=other_tenant.id, + activity_type="note", + title="Private activity", + contact_id=private_contact.id, + ), + ) + db.commit() + + response = client.get( + f"/api/v1/activities?contact_id={private_contact.id}", + headers=auth_headers, + ) + assert response.status_code == 200 + assert response.json()["total"] == 0 + assert response.json()["items"] == []