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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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"]
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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"}
|
||||||
|
|
||||||
@@ -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 ###
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Maskan CRM API package."""
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Versioned API routers."""
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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],
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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()
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Core configuration, database, and security helpers."""
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
@@ -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:
|
||||||
Generated
+157
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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"]
|
||||||
@@ -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"
|
||||||
@@ -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"] == []
|
||||||
Reference in New Issue
Block a user