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 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-01 10:28:41 +05:30
co-authored by Claude Opus 5
commit f6a54d6f93
46 changed files with 5287 additions and 0 deletions
+24
View File
@@ -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
+17
View File
@@ -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
+18
View File
@@ -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
+17
View File
@@ -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
+18
View File
@@ -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
+11
View File
@@ -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
+32
View File
@@ -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/
+17
View File
@@ -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"]
+20
View File
@@ -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.
+120
View File
@@ -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=<at-least-32-random-characters>
DB_HOST=<managed-postgres-host>
DB_PORT=5432
DB_NAME=maskan_crm
DB_USER=<dedicated-user>
DB_PASSWORD=<secret>
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.
+40
View File
@@ -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
+61
View File
@@ -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()
+25
View File
@@ -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"}
+1
View File
@@ -0,0 +1 @@
@@ -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 ###
+2
View File
@@ -0,0 +1,2 @@
"""Maskan CRM API package."""
+2
View File
@@ -0,0 +1,2 @@
"""Versioned API routers."""
+143
View File
@@ -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)
+71
View File
@@ -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),
)
+119
View File
@@ -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),
)
+298
View File
@@ -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)
+111
View File
@@ -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],
)
+380
View File
@@ -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
+309
View File
@@ -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)
+13
View File
@@ -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)
+317
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
"""Core configuration, database, and security helpers."""
+107
View File
@@ -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
+36
View File
@@ -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()
+146
View File
@@ -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"))]
+107
View File
@@ -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)
+560
View File
@@ -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,
)
+411
View File
@@ -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
+249
View File
@@ -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")
+68
View File
@@ -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:
+157
View File
@@ -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"
}
}
}
}
+42
View File
@@ -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"
}
}
+42
View File
@@ -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"]
+57
View File
@@ -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);
+130
View File
@@ -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
+51
View File
@@ -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
+30
View File
@@ -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()
+83
View File
@@ -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
+121
View File
@@ -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"]
+138
View File
@@ -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"
+148
View File
@@ -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"] == []