Sync platform docs, infra, and module services with Accounting integration.
Include Loyalty/Communication/Sports Center backends and registry updates alongside production nginx and compose wiring. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
067b499193
commit
e41ecfad4c
18
.env.example
18
.env.example
@ -90,6 +90,24 @@ CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/c
|
||||
CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db
|
||||
CRM_SERVICE_NAME=crm-service
|
||||
|
||||
# ---- Loyalty Service ----
|
||||
LOYALTY_SERVICE_URL=http://loyalty-service:8004
|
||||
LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db
|
||||
LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db
|
||||
LOYALTY_SERVICE_NAME=loyalty-service
|
||||
|
||||
# ---- Communication Service (Phase 8) ----
|
||||
COMMUNICATION_SERVICE_URL=http://communication-service:8005
|
||||
COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db
|
||||
COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db
|
||||
COMMUNICATION_SERVICE_NAME=communication-service
|
||||
|
||||
# ---- Sports Center Service (Phase 9.0) ----
|
||||
SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006
|
||||
SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db
|
||||
SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db
|
||||
SPORTS_CENTER_SERVICE_NAME=sports-center-service
|
||||
|
||||
# ---- OTP / Payamak SMS (Core Service) ----
|
||||
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
||||
PAYAMAK_USERNAME=9155105404
|
||||
|
||||
152
README.md
152
README.md
@ -1,74 +1,78 @@
|
||||
# TorbatYar SuperApp SaaS Platform
|
||||
|
||||
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
|
||||
|
||||
> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
|
||||
|
||||
## Documentation (source of truth)
|
||||
|
||||
شروع از [`docs/README.md`](./docs/README.md).
|
||||
|
||||
| Document | Responsibility |
|
||||
| --- | --- |
|
||||
| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview |
|
||||
| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference |
|
||||
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
||||
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
||||
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||
| [`docs/next-steps.md`](./docs/next-steps.md) | Immediate next milestone |
|
||||
| [`docs/roadmap.md`](./docs/roadmap.md) | Future roadmap |
|
||||
| [`docs/deployment/`](./docs/deployment/) | Deployment runbooks |
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
- **Mandatory Frontend/Backend separation** — `backend/` and `frontend/` are independent apps.
|
||||
- **Database-per-service** — no cross-DB queries ([ADR-001](./docs/architecture/adr/ADR-001.md)).
|
||||
- **Inter-service communication** — REST, Webhook, Async Event, Outbox/Inbox only.
|
||||
- **Multi-tenancy** — business tables carry `tenant_id`.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
TorbatYar/
|
||||
├── backend/
|
||||
│ ├── core-service/ # FastAPI Core Platform
|
||||
│ ├── shared-lib/ # Shared backend library
|
||||
│ └── services/ # Identity, Accounting, CRM (+ future modules)
|
||||
├── frontend/ # Next.js
|
||||
├── docs/ # Documentation architecture (canonical)
|
||||
├── infrastructure/ # Nginx, Keycloak, deploy env samples
|
||||
├── scripts/ # Ops / verification scripts
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Core API:** http://localhost:8000/docs
|
||||
- **Identity API:** http://localhost:8001/docs
|
||||
- **Accounting API:** http://localhost:8002/docs
|
||||
- **CRM API:** http://localhost:8003/docs
|
||||
- **Keycloak:** http://localhost:8080
|
||||
|
||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||
|
||||
## Tests (Backend)
|
||||
|
||||
```bash
|
||||
cd backend/core-service
|
||||
pytest -q
|
||||
```
|
||||
|
||||
## Current status
|
||||
|
||||
See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md).
|
||||
# TorbatYar SuperApp SaaS Platform
|
||||
|
||||
پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
|
||||
|
||||
> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
|
||||
|
||||
## Documentation (source of truth)
|
||||
|
||||
شروع از [`docs/README.md`](./docs/README.md).
|
||||
|
||||
| Document | Responsibility |
|
||||
| --- | --- |
|
||||
| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview |
|
||||
| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference |
|
||||
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
||||
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
||||
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) |
|
||||
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||
| [`docs/next-steps.md`](./docs/next-steps.md) | Immediate next milestone |
|
||||
| [`docs/roadmap.md`](./docs/roadmap.md) | Future roadmap |
|
||||
| [`docs/deployment/`](./docs/deployment/) | Deployment runbooks |
|
||||
|
||||
## Architecture at a glance
|
||||
|
||||
- **Mandatory Frontend/Backend separation** — `backend/` and `frontend/` are independent apps.
|
||||
- **Database-per-service** — no cross-DB queries ([ADR-001](./docs/architecture/adr/ADR-001.md)).
|
||||
- **Inter-service communication** — REST, Webhook, Async Event, Outbox/Inbox only.
|
||||
- **Multi-tenancy** — business tables carry `tenant_id`.
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
TorbatYar/
|
||||
├── backend/
|
||||
│ ├── core-service/ # FastAPI Core Platform
|
||||
│ ├── shared-lib/ # Shared backend library
|
||||
│ └── services/ # Identity, Accounting, CRM, Loyalty, Communication, Sports Center (+ future)
|
||||
├── frontend/ # Next.js
|
||||
├── docs/ # Documentation architecture (canonical)
|
||||
├── infrastructure/ # Nginx, Keycloak, deploy env samples
|
||||
├── scripts/ # Ops / verification scripts
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Core API:** http://localhost:8000/docs
|
||||
- **Identity API:** http://localhost:8001/docs
|
||||
- **Accounting API:** http://localhost:8002/docs
|
||||
- **CRM API:** http://localhost:8003/docs
|
||||
- **Loyalty API:** http://localhost:8004/docs
|
||||
- **Communication API:** http://localhost:8005/docs
|
||||
- **Sports Center API:** http://localhost:8006/docs
|
||||
- **Keycloak:** http://localhost:8080
|
||||
|
||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||
|
||||
## Tests (Backend)
|
||||
|
||||
```bash
|
||||
cd backend/core-service
|
||||
pytest -q
|
||||
```
|
||||
|
||||
## Current status
|
||||
|
||||
See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md).
|
||||
|
||||
22
backend/services/communication/Dockerfile.dev
Normal file
22
backend/services/communication/Dockerfile.dev
Normal file
@ -0,0 +1,22 @@
|
||||
# Dev image: dependencies only — code mounted with uvicorn --reload
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/shared-lib/ /shared-lib/
|
||||
COPY backend/services/communication/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
EXPOSE 8005
|
||||
24
backend/services/communication/README.md
Normal file
24
backend/services/communication/README.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Communication Service
|
||||
|
||||
Independent **Enterprise Communication Platform** for TorbatYar SuperApp.
|
||||
|
||||
- Database: `communication_db` (sole owner)
|
||||
- Port: `8005`
|
||||
- Version: `0.8.10.1`
|
||||
- Permission prefix: `communication.*`
|
||||
|
||||
## Boundaries
|
||||
|
||||
This service is shared infrastructure. It does **not** belong to CRM, Loyalty, Restaurant, or any business module.
|
||||
|
||||
Consumers interact only via HTTP APIs and events. No module may call external SMS/email/push providers directly.
|
||||
|
||||
## Channels
|
||||
|
||||
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future.
|
||||
|
||||
**Initially active:** SMS (mock + Payamak adapters).
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/communication-phase-8.md`.
|
||||
41
backend/services/communication/alembic.ini
Normal file
41
backend/services/communication/alembic.ini
Normal file
@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[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
|
||||
42
backend/services/communication/alembic/env.py
Normal file
42
backend/services/communication/alembic/env.py
Normal file
@ -0,0 +1,42 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
context.configure(
|
||||
url=settings.database_url_sync,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
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)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@ -0,0 +1,19 @@
|
||||
"""Initial Communication schema — Phase 8.0–8.10."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.drop_all(bind=bind)
|
||||
@ -0,0 +1,21 @@
|
||||
"""Phase 8 validation hardening — indexes already in models; revision for upgrade path."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0002_validation_hardening"
|
||||
down_revision = "0001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# create_all is idempotent for new tables/indexes in greenfield;
|
||||
# for existing DBs this ensures metadata indexes from foundation models exist.
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Non-destructive: keep data; indexes remain (safe for shared validation revision).
|
||||
pass
|
||||
1
backend/services/communication/app/__init__.py
Normal file
1
backend/services/communication/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.8.10.1"
|
||||
39
backend/services/communication/app/api/deps.py
Normal file
39
backend/services/communication/app/api/deps.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Common API dependencies."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from shared.exceptions import TenantNotResolvedError
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
from shared.tenant import STATE_TENANT_ID
|
||||
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"get_pagination",
|
||||
"require_tenant",
|
||||
"get_current_user",
|
||||
"AsyncSession",
|
||||
"CurrentUser",
|
||||
]
|
||||
|
||||
|
||||
def get_pagination(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=500),
|
||||
) -> PaginationParams:
|
||||
return PaginationParams(page=page, page_size=page_size)
|
||||
|
||||
|
||||
def require_tenant(request: Request) -> UUID:
|
||||
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
|
||||
if tenant_id is None:
|
||||
raise TenantNotResolvedError(
|
||||
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
|
||||
)
|
||||
return tenant_id
|
||||
40
backend/services/communication/app/api/permissions.py
Normal file
40
backend/services/communication/app/api/permissions.py
Normal file
@ -0,0 +1,40 @@
|
||||
"""Permission enforcement helpers for Communication APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_current_user
|
||||
from shared.exceptions import ForbiddenError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"})
|
||||
|
||||
|
||||
def user_has_permission(user: CurrentUser, permission: str) -> bool:
|
||||
if any(role in _ADMIN_ROLES for role in user.roles):
|
||||
return True
|
||||
if "communication.manage" in user.roles:
|
||||
return True
|
||||
return permission in user.roles
|
||||
|
||||
|
||||
def require_permissions(*permissions: str) -> Callable:
|
||||
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return user
|
||||
if any(user_has_permission(user, perm) for perm in permissions):
|
||||
return user
|
||||
raise ForbiddenError(
|
||||
"دسترسی مجاز نیست",
|
||||
error_code="permission_denied",
|
||||
details={"required": list(permissions)},
|
||||
)
|
||||
|
||||
return _dependency
|
||||
12
backend/services/communication/app/api/v1/__init__.py
Normal file
12
backend/services/communication/app/api/v1/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import contacts, messages, monitoring, otp, providers, templates, webhooks
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(providers.router, prefix="/providers", tags=["providers"])
|
||||
api_router.include_router(templates.router, prefix="/templates", tags=["templates"])
|
||||
api_router.include_router(contacts.router, prefix="/contacts", tags=["contacts"])
|
||||
api_router.include_router(messages.router, prefix="/messages", tags=["messages"])
|
||||
api_router.include_router(otp.router, prefix="/otp", tags=["otp"])
|
||||
api_router.include_router(webhooks.router, prefix="/webhooks", tags=["webhooks"])
|
||||
api_router.include_router(monitoring.router, prefix="/monitoring", tags=["monitoring"])
|
||||
103
backend/services/communication/app/api/v1/contacts.py
Normal file
103
backend/services/communication/app/api/v1/contacts.py
Normal file
@ -0,0 +1,103 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.deps import AsyncSession, CurrentUser, get_current_user, get_db, require_tenant
|
||||
from app.schemas.common import (
|
||||
ContactCreate,
|
||||
ContactOut,
|
||||
ContactSourceCreate,
|
||||
ContactSourceOut,
|
||||
)
|
||||
from app.services.contact_service import ContactService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CSVImportBody(BaseModel):
|
||||
csv_text: str
|
||||
|
||||
|
||||
class ResolveBody(BaseModel):
|
||||
source_id: UUID | None = None
|
||||
query: dict | None = None
|
||||
channel: str = "sms"
|
||||
|
||||
|
||||
@router.get("/manual", response_model=list[ContactOut])
|
||||
async def list_manual_contacts(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await ContactService(session).list_manual(tenant_id)
|
||||
|
||||
|
||||
@router.post("/manual", response_model=ContactOut, status_code=201)
|
||||
async def create_manual_contact(
|
||||
body: ContactCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await ContactService(session).create_manual(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/manual/import-csv", response_model=list[ContactOut], status_code=201)
|
||||
async def import_csv(
|
||||
body: CSVImportBody,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await ContactService(session).import_csv(
|
||||
tenant_id, body.csv_text, actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sources", response_model=list[ContactSourceOut])
|
||||
async def list_sources(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await ContactService(session).list_sources(tenant_id)
|
||||
|
||||
|
||||
@router.post("/sources", response_model=ContactSourceOut, status_code=201)
|
||||
async def create_source(
|
||||
body: ContactSourceCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await ContactService(session).create_source(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/resolve")
|
||||
async def resolve_contacts(
|
||||
body: ResolveBody,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
resolved = await ContactService(session).resolve(
|
||||
tenant_id,
|
||||
source_id=body.source_id,
|
||||
query=body.query,
|
||||
channel=body.channel,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"address": r.address,
|
||||
"display_name": r.display_name,
|
||||
"external_ref": r.external_ref,
|
||||
"source_type": r.source_type,
|
||||
}
|
||||
for r in resolved
|
||||
]
|
||||
57
backend/services/communication/app/api/v1/health.py
Normal file
57
backend/services/communication/app/api/v1/health.py
Normal file
@ -0,0 +1,57 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import __version__
|
||||
from app.api.deps import get_db
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine
|
||||
from app.providers import supported_channels
|
||||
from app.services.monitoring_service import CapabilityService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
"""Liveness probe — does not touch the database."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"channels": [c["channel"] for c in supported_channels() if c["status"] == "active"],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/health/ready")
|
||||
async def readiness_check(session: AsyncSession = Depends(get_db)):
|
||||
"""Readiness probe — verifies database connectivity."""
|
||||
try:
|
||||
await session.execute(select(1))
|
||||
db_ok = True
|
||||
except Exception: # noqa: BLE001
|
||||
db_ok = False
|
||||
status = "ok" if db_ok else "degraded"
|
||||
return {
|
||||
"status": status,
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"database": "up" if db_ok else "down",
|
||||
"engine": str(engine.url).split("@")[-1] if engine.url else "unknown",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/capabilities")
|
||||
async def capabilities():
|
||||
return CapabilityService().capabilities()
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def metrics_root():
|
||||
"""Service-level metrics summary (no tenant). Use /api/v1/monitoring/metrics for tenant metrics."""
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"hint": "GET /api/v1/monitoring/metrics with X-Tenant-ID for tenant metrics",
|
||||
"features": CapabilityService().capabilities()["features"],
|
||||
}
|
||||
121
backend/services/communication/app/api/v1/messages.py
Normal file
121
backend/services/communication/app/api/v1/messages.py
Normal file
@ -0,0 +1,121 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.deps import (
|
||||
AsyncSession,
|
||||
CurrentUser,
|
||||
get_db,
|
||||
get_pagination,
|
||||
require_tenant,
|
||||
)
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
MESSAGES_CANCEL,
|
||||
MESSAGES_SEND,
|
||||
MESSAGES_VIEW,
|
||||
QUEUE_MANAGE,
|
||||
QUEUE_VIEW,
|
||||
)
|
||||
from app.schemas.common import DeliveryEventOut, MessageOut, MessageSendRequest, QueueItemOut
|
||||
from app.services.message_service import MessageService
|
||||
from app.services.queue_engine import QueueEngine
|
||||
from shared.pagination import PaginationParams
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/send", response_model=list[MessageOut], status_code=201)
|
||||
async def send_message(
|
||||
body: MessageSendRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(MESSAGES_SEND)),
|
||||
):
|
||||
return await MessageService(session).send(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[MessageOut])
|
||||
async def list_messages(
|
||||
status: str | None = Query(default=None),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
_: CurrentUser = Depends(require_permissions(MESSAGES_VIEW)),
|
||||
):
|
||||
return await MessageService(session).list(
|
||||
tenant_id,
|
||||
status=status,
|
||||
offset=pagination.offset,
|
||||
limit=pagination.limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def message_stats(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(MESSAGES_VIEW)),
|
||||
):
|
||||
return await MessageService(session).stats(tenant_id)
|
||||
|
||||
|
||||
@router.get("/{message_id}", response_model=MessageOut)
|
||||
async def get_message(
|
||||
message_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(MESSAGES_VIEW)),
|
||||
):
|
||||
return await MessageService(session).get(tenant_id, message_id)
|
||||
|
||||
|
||||
@router.get("/{message_id}/timeline", response_model=list[DeliveryEventOut])
|
||||
async def message_timeline(
|
||||
message_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(MESSAGES_VIEW)),
|
||||
):
|
||||
return await MessageService(session).timeline(tenant_id, message_id)
|
||||
|
||||
|
||||
@router.post("/{message_id}/cancel", response_model=MessageOut)
|
||||
async def cancel_message(
|
||||
message_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(MESSAGES_CANCEL)),
|
||||
):
|
||||
return await MessageService(session).cancel(tenant_id, message_id)
|
||||
|
||||
|
||||
@router.post("/queue/process")
|
||||
async def process_queue(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(QUEUE_MANAGE)),
|
||||
limit: int = Query(default=20, ge=1, le=200),
|
||||
):
|
||||
processed = await QueueEngine(session).process_due(tenant_id, limit=limit)
|
||||
return {"processed": processed}
|
||||
|
||||
|
||||
@router.get("/queue/dead-letters", response_model=list[QueueItemOut])
|
||||
async def dead_letters(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(QUEUE_VIEW)),
|
||||
):
|
||||
return await QueueEngine(session).list_dead_letters(tenant_id)
|
||||
|
||||
|
||||
@router.get("/queue/stats")
|
||||
async def queue_stats(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(QUEUE_VIEW)),
|
||||
):
|
||||
return await QueueEngine(session).stats(tenant_id)
|
||||
29
backend/services/communication/app/api/v1/monitoring.py
Normal file
29
backend/services/communication/app/api/v1/monitoring.py
Normal file
@ -0,0 +1,29 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import AsyncSession, CurrentUser, get_current_user, get_db, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import MONITORING_VIEW
|
||||
from app.schemas.common import MonitoringStatsOut
|
||||
from app.services.monitoring_service import MonitoringService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=MonitoringStatsOut)
|
||||
async def monitoring_stats(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(MONITORING_VIEW)),
|
||||
):
|
||||
return await MonitoringService(session).stats(tenant_id)
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
async def monitoring_metrics(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(MONITORING_VIEW)),
|
||||
):
|
||||
return await MonitoringService(session).metrics(tenant_id)
|
||||
42
backend/services/communication/app/api/v1/otp.py
Normal file
42
backend/services/communication/app/api/v1/otp.py
Normal file
@ -0,0 +1,42 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import AsyncSession, CurrentUser, get_db, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import OTP_REQUEST, OTP_VERIFY
|
||||
from app.schemas.common import OTPRequest, OTPRequestOut, OTPVerifyOut, OTPVerifyRequest
|
||||
from app.services.otp_service import OTPService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/request", response_model=OTPRequestOut, status_code=201)
|
||||
async def request_otp(
|
||||
body: OTPRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(OTP_REQUEST)),
|
||||
):
|
||||
challenge, debug_code = await OTPService(session).request(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
return OTPRequestOut(
|
||||
challenge_id=challenge.id,
|
||||
destination=challenge.destination,
|
||||
channel=challenge.channel,
|
||||
expires_at=challenge.expires_at,
|
||||
message_id=challenge.message_id,
|
||||
debug_code=debug_code,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify", response_model=OTPVerifyOut)
|
||||
async def verify_otp(
|
||||
body: OTPVerifyRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(OTP_VERIFY)),
|
||||
):
|
||||
result = await OTPService(session).verify(tenant_id, body.model_dump())
|
||||
return OTPVerifyOut(**result)
|
||||
127
backend/services/communication/app/api/v1/providers.py
Normal file
127
backend/services/communication/app/api/v1/providers.py
Normal file
@ -0,0 +1,127 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import AsyncSession, CurrentUser, get_db, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import PROVIDERS_MANAGE, PROVIDERS_VIEW, SENDERS_MANAGE, SENDERS_VIEW
|
||||
from app.schemas.common import (
|
||||
ProviderCreate,
|
||||
ProviderOut,
|
||||
ProviderStatusOut,
|
||||
ProviderUpdate,
|
||||
SenderCreate,
|
||||
SenderOut,
|
||||
)
|
||||
from app.services.monitoring_service import MonitoringService
|
||||
from app.services.provider_service import ProviderService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProviderOut])
|
||||
async def list_providers(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(PROVIDERS_VIEW)),
|
||||
):
|
||||
return await ProviderService(session).list(tenant_id)
|
||||
|
||||
|
||||
@router.post("", response_model=ProviderOut, status_code=201)
|
||||
async def create_provider(
|
||||
body: ProviderCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PROVIDERS_MANAGE)),
|
||||
):
|
||||
return await ProviderService(session).create(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=list[ProviderStatusOut])
|
||||
async def providers_status(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(PROVIDERS_VIEW)),
|
||||
):
|
||||
return await MonitoringService(session).providers_status(tenant_id)
|
||||
|
||||
|
||||
@router.get("/{provider_id}", response_model=ProviderOut)
|
||||
async def get_provider(
|
||||
provider_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(PROVIDERS_VIEW)),
|
||||
):
|
||||
return await ProviderService(session).get(tenant_id, provider_id)
|
||||
|
||||
|
||||
@router.patch("/{provider_id}", response_model=ProviderOut)
|
||||
async def update_provider(
|
||||
provider_id: UUID,
|
||||
body: ProviderUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PROVIDERS_MANAGE)),
|
||||
):
|
||||
return await ProviderService(session).update(
|
||||
tenant_id,
|
||||
provider_id,
|
||||
body.model_dump(exclude_unset=True),
|
||||
actor_id=user.user_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{provider_id}", status_code=204)
|
||||
async def delete_provider(
|
||||
provider_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PROVIDERS_MANAGE)),
|
||||
):
|
||||
await ProviderService(session).delete(tenant_id, provider_id, actor_id=user.user_id)
|
||||
|
||||
|
||||
@router.get("/{provider_id}/balance")
|
||||
async def provider_balance(
|
||||
provider_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(PROVIDERS_VIEW)),
|
||||
):
|
||||
balance = await ProviderService(session).get_balance(tenant_id, provider_id)
|
||||
return {"provider_id": provider_id, "balance": balance}
|
||||
|
||||
|
||||
@router.post("/{provider_id}/health")
|
||||
async def provider_health(
|
||||
provider_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(PROVIDERS_VIEW)),
|
||||
):
|
||||
return await ProviderService(session).health_probe(tenant_id, provider_id)
|
||||
|
||||
|
||||
@router.post("/senders", response_model=SenderOut, status_code=201)
|
||||
async def create_sender(
|
||||
body: SenderCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SENDERS_MANAGE)),
|
||||
):
|
||||
return await ProviderService(session).create_sender(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/senders/list", response_model=list[SenderOut])
|
||||
async def list_senders(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(require_permissions(SENDERS_VIEW)),
|
||||
):
|
||||
return await ProviderService(session).list_senders(tenant_id)
|
||||
96
backend/services/communication/app/api/v1/templates.py
Normal file
96
backend/services/communication/app/api/v1/templates.py
Normal file
@ -0,0 +1,96 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import AsyncSession, CurrentUser, get_current_user, get_db, require_tenant
|
||||
from app.schemas.common import (
|
||||
TemplateCreate,
|
||||
TemplateOut,
|
||||
TemplatePreviewRequest,
|
||||
TemplatePreviewResponse,
|
||||
)
|
||||
from app.services.template_service import TemplateService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[TemplateOut])
|
||||
async def list_templates(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await TemplateService(session).list(tenant_id)
|
||||
|
||||
|
||||
@router.post("", response_model=TemplateOut, status_code=201)
|
||||
async def create_template(
|
||||
body: TemplateCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await TemplateService(session).create(
|
||||
tenant_id, body.model_dump(), actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{template_id}", response_model=TemplateOut)
|
||||
async def get_template(
|
||||
template_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await TemplateService(session).get(tenant_id, template_id)
|
||||
|
||||
|
||||
@router.post("/{template_id}/submit", response_model=TemplateOut)
|
||||
async def submit_template(
|
||||
template_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await TemplateService(session).submit_for_approval(
|
||||
tenant_id, template_id, actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{template_id}/approve", response_model=TemplateOut)
|
||||
async def approve_template(
|
||||
template_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
return await TemplateService(session).approve(
|
||||
tenant_id, template_id, actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{template_id}/reject", response_model=TemplateOut)
|
||||
async def reject_template(
|
||||
template_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
reason: str | None = None,
|
||||
):
|
||||
return await TemplateService(session).reject(
|
||||
tenant_id, template_id, reason=reason, actor_id=user.user_id
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{template_id}/preview", response_model=TemplatePreviewResponse)
|
||||
async def preview_template(
|
||||
template_id: UUID,
|
||||
body: TemplatePreviewRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
result = await TemplateService(session).preview(
|
||||
tenant_id, template_id, body.variables
|
||||
)
|
||||
return TemplatePreviewResponse(**result)
|
||||
22
backend/services/communication/app/api/v1/webhooks.py
Normal file
22
backend/services/communication/app/api/v1/webhooks.py
Normal file
@ -0,0 +1,22 @@
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Header
|
||||
|
||||
from app.api.deps import AsyncSession, get_db, require_tenant
|
||||
from app.schemas.common import WebhookIn, WebhookOut
|
||||
from app.services.webhook_service import WebhookService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/incoming", response_model=WebhookOut, status_code=201)
|
||||
async def incoming_webhook(
|
||||
body: WebhookIn,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
x_webhook_secret: str | None = Header(default=None),
|
||||
):
|
||||
data = body.model_dump()
|
||||
return await WebhookService(session).receive(
|
||||
tenant_id, data, webhook_secret=x_webhook_secret
|
||||
)
|
||||
89
backend/services/communication/app/core/config.py
Normal file
89
backend/services/communication/app/core/config.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""تنظیمات Communication Service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
service_name: str = Field(
|
||||
default="communication-service",
|
||||
validation_alias="COMMUNICATION_SERVICE_NAME",
|
||||
)
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/communication_db",
|
||||
validation_alias="COMMUNICATION_DATABASE_URL",
|
||||
)
|
||||
database_url_sync: str = Field(
|
||||
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/communication_db",
|
||||
validation_alias="COMMUNICATION_DATABASE_URL_SYNC",
|
||||
)
|
||||
|
||||
core_service_url: str = Field(
|
||||
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
|
||||
)
|
||||
|
||||
keycloak_enabled: bool = True
|
||||
keycloak_server_url: str = Field(
|
||||
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
|
||||
)
|
||||
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
|
||||
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_audience: str = "account"
|
||||
jwt_verify_signature: bool = True
|
||||
auth_required: bool = True
|
||||
|
||||
cors_origins: str = Field(
|
||||
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||
validation_alias="CORS_ORIGINS",
|
||||
)
|
||||
|
||||
otp_default_ttl_seconds: int = Field(default=120, validation_alias="COMM_OTP_TTL_SECONDS")
|
||||
otp_max_attempts: int = Field(default=5, validation_alias="COMM_OTP_MAX_ATTEMPTS")
|
||||
otp_rate_limit_per_minute: int = Field(
|
||||
default=3, validation_alias="COMM_OTP_RATE_LIMIT_PER_MINUTE"
|
||||
)
|
||||
default_max_retries: int = Field(default=3, validation_alias="COMM_DEFAULT_MAX_RETRIES")
|
||||
circuit_breaker_failure_threshold: int = Field(
|
||||
default=5, validation_alias="COMM_CIRCUIT_FAILURE_THRESHOLD"
|
||||
)
|
||||
circuit_breaker_reset_seconds: int = Field(
|
||||
default=60, validation_alias="COMM_CIRCUIT_RESET_SECONDS"
|
||||
)
|
||||
|
||||
@property
|
||||
def keycloak_public_base(self) -> str:
|
||||
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
|
||||
|
||||
@property
|
||||
def keycloak_public_realm_url(self) -> str:
|
||||
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
21
backend/services/communication/app/core/database.py
Normal file
21
backend/services/communication/app/core/database.py
Normal file
@ -0,0 +1,21 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
14
backend/services/communication/app/core/logging.py
Normal file
14
backend/services/communication/app/core/logging.py
Normal file
@ -0,0 +1,14 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
49
backend/services/communication/app/core/security.py
Normal file
49
backend/services/communication/app/core/security.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""JWT authentication dependencies for Communication service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.auth import JWTSettings, JWTValidator
|
||||
from shared.exceptions import UnauthorizedError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_jwt_validator() -> JWTValidator:
|
||||
return JWTValidator(
|
||||
JWTSettings(
|
||||
keycloak_enabled=settings.keycloak_enabled,
|
||||
keycloak_server_url=settings.keycloak_server_url,
|
||||
keycloak_realm=settings.keycloak_realm,
|
||||
jwt_algorithm=settings.jwt_algorithm,
|
||||
jwt_audience=settings.jwt_audience,
|
||||
jwt_verify_signature=settings.jwt_verify_signature,
|
||||
jwt_issuer=settings.keycloak_public_realm_url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser | None:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
return None
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
63
backend/services/communication/app/events/publisher.py
Normal file
63
backend/services/communication/app/events/publisher.py
Normal file
@ -0,0 +1,63 @@
|
||||
"""Communication event publisher — EventEnvelope contracts; no bus consumers yet."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from shared.events import EventEnvelope
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.types import CommunicationEventType
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: CommunicationEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope: ...
|
||||
|
||||
|
||||
class InMemoryEventPublisher:
|
||||
"""Records published envelopes for tests and local verification."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.published: list[EventEnvelope] = []
|
||||
|
||||
def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: CommunicationEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope:
|
||||
envelope = EventEnvelope(
|
||||
event_id=uuid4(),
|
||||
event_type=event_type.value,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=str(aggregate_id),
|
||||
tenant_id=tenant_id,
|
||||
source_service=settings.service_name,
|
||||
payload=payload or {},
|
||||
)
|
||||
self.published.append(envelope)
|
||||
return envelope
|
||||
|
||||
|
||||
_default_publisher = InMemoryEventPublisher()
|
||||
|
||||
|
||||
def get_event_publisher() -> InMemoryEventPublisher:
|
||||
return _default_publisher
|
||||
|
||||
|
||||
def reset_event_publisher() -> InMemoryEventPublisher:
|
||||
global _default_publisher
|
||||
_default_publisher = InMemoryEventPublisher()
|
||||
return _default_publisher
|
||||
23
backend/services/communication/app/events/types.py
Normal file
23
backend/services/communication/app/events/types.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Communication event types."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class CommunicationEventType(str, enum.Enum):
|
||||
MESSAGE_QUEUED = "communication.message.queued"
|
||||
MESSAGE_SENT = "communication.message.sent"
|
||||
MESSAGE_DELIVERED = "communication.message.delivered"
|
||||
MESSAGE_FAILED = "communication.message.failed"
|
||||
MESSAGE_CANCELLED = "communication.message.cancelled"
|
||||
MESSAGE_EXPIRED = "communication.message.expired"
|
||||
PROVIDER_FAILOVER = "communication.provider.failover"
|
||||
PROVIDER_CIRCUIT_OPENED = "communication.provider.circuit_opened"
|
||||
PROVIDER_CIRCUIT_CLOSED = "communication.provider.circuit_closed"
|
||||
TEMPLATE_APPROVED = "communication.template.approved"
|
||||
TEMPLATE_REJECTED = "communication.template.rejected"
|
||||
OTP_GENERATED = "communication.otp.generated"
|
||||
OTP_VERIFIED = "communication.otp.verified"
|
||||
OTP_FAILED = "communication.otp.failed"
|
||||
QUEUE_DEAD_LETTER = "communication.queue.dead_letter"
|
||||
WEBHOOK_RECEIVED = "communication.webhook.received"
|
||||
69
backend/services/communication/app/main.py
Normal file
69
backend/services/communication/app/main.py
Normal file
@ -0,0 +1,69 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app import __version__
|
||||
from app.api.v1 import api_router
|
||||
from app.api.v1 import health
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.middlewares.tenant import TenantHeaderMiddleware
|
||||
from shared.exceptions import AppError
|
||||
from shared.responses import ErrorDetail, ErrorResponse
|
||||
|
||||
configure_logging(settings.log_level)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("service_starting", extra={"service": settings.service_name})
|
||||
yield
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="Communication Service",
|
||||
version=__version__,
|
||||
description="Enterprise Communication Platform — Phase 8 (independent shared infrastructure)",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origin_list,
|
||||
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
|
||||
allow_credentials=False,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.add_middleware(TenantHeaderMiddleware)
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(
|
||||
code=exc.error_code, message=exc.message, details=exc.details
|
||||
)
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=ErrorResponse(
|
||||
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
app.include_router(health.router)
|
||||
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
24
backend/services/communication/app/middlewares/tenant.py
Normal file
24
backend/services/communication/app/middlewares/tenant.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Tenant header resolution middleware."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
|
||||
|
||||
|
||||
class TenantHeaderMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
setattr(request.state, STATE_TENANT_ID, None)
|
||||
raw = request.headers.get(HEADER_TENANT_ID)
|
||||
if raw:
|
||||
try:
|
||||
setattr(request.state, STATE_TENANT_ID, UUID(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return await call_next(request)
|
||||
30
backend/services/communication/app/models/__init__.py
Normal file
30
backend/services/communication/app/models/__init__.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Import all models for Alembic metadata discovery."""
|
||||
from app.models.foundation import (
|
||||
CommunicationAuditLog,
|
||||
ContactSource,
|
||||
DeliveryEvent,
|
||||
ManualContact,
|
||||
Message,
|
||||
MessageTemplate,
|
||||
OTPChallenge,
|
||||
ProviderConfig,
|
||||
ProviderLog,
|
||||
QueueItem,
|
||||
SenderNumber,
|
||||
WebhookReceipt,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProviderConfig",
|
||||
"SenderNumber",
|
||||
"MessageTemplate",
|
||||
"ManualContact",
|
||||
"ContactSource",
|
||||
"Message",
|
||||
"QueueItem",
|
||||
"DeliveryEvent",
|
||||
"ProviderLog",
|
||||
"OTPChallenge",
|
||||
"WebhookReceipt",
|
||||
"CommunicationAuditLog",
|
||||
]
|
||||
47
backend/services/communication/app/models/base.py
Normal file
47
backend/services/communication/app/models/base.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Shared model mixins."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class TenantMixin:
|
||||
"""Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded."""
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
|
||||
class ActorAuditMixin:
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
397
backend/services/communication/app/models/foundation.py
Normal file
397
backend/services/communication/app/models/foundation.py
Normal file
@ -0,0 +1,397 @@
|
||||
"""Communication platform domain models — Phase 8."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class ProviderConfig(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Tenant-specific communication provider credentials and routing metadata."""
|
||||
|
||||
__tablename__ = "provider_configs"
|
||||
__table_args__ = (
|
||||
Index("ix_provider_configs_tenant_channel", "tenant_id", "channel"),
|
||||
Index("ix_provider_configs_tenant_priority", "tenant_id", "priority"),
|
||||
)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
provider_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
channel: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="active")
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=100)
|
||||
credentials: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
balance: Mapped[Decimal | None] = mapped_column(Numeric(18, 4), nullable=True)
|
||||
rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
max_retries: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
circuit_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="closed"
|
||||
)
|
||||
circuit_opened_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_health_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class SenderNumber(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "sender_numbers"
|
||||
__table_args__ = (
|
||||
Index("ix_sender_numbers_tenant_channel", "tenant_id", "channel"),
|
||||
# App layer enforces uniqueness among non-deleted rows; DB unique is tenant+channel+value.
|
||||
UniqueConstraint(
|
||||
"tenant_id", "channel", "value", name="uq_sender_numbers_tenant_channel_value"
|
||||
),
|
||||
)
|
||||
|
||||
channel: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
value: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class MessageTemplate(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "message_templates"
|
||||
__table_args__ = (
|
||||
Index("ix_message_templates_tenant_key", "tenant_id", "template_key"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"template_key",
|
||||
"locale",
|
||||
"version",
|
||||
name="uq_message_templates_key_locale_version",
|
||||
),
|
||||
)
|
||||
|
||||
template_key: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
channel: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
locale: Mapped[str] = mapped_column(String(16), nullable=False, default="fa")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="draft")
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
subject: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
variables: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
approved_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
approved_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
rejection_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class ManualContact(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Contacts owned by Communication only — no business-module data duplication."""
|
||||
|
||||
__tablename__ = "manual_contacts"
|
||||
__table_args__ = (
|
||||
Index("ix_manual_contacts_tenant_phone", "tenant_id", "phone"),
|
||||
Index("ix_manual_contacts_tenant_email", "tenant_id", "email"),
|
||||
)
|
||||
|
||||
display_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
push_token: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class ContactSource(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Dynamic contact source configuration — resolves contacts via API, never copies data."""
|
||||
|
||||
__tablename__ = "contact_sources"
|
||||
__table_args__ = (
|
||||
Index("ix_contact_sources_tenant_type", "tenant_id", "source_type"),
|
||||
)
|
||||
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
source_type: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
endpoint_path: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
auth_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
query_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
field_mapping: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
|
||||
|
||||
class Message(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "messages"
|
||||
__table_args__ = (
|
||||
Index("ix_messages_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_messages_tenant_channel", "tenant_id", "channel"),
|
||||
Index("ix_messages_tenant_created", "tenant_id", "created_at"),
|
||||
Index("ix_messages_tenant_correlation", "tenant_id", "correlation_id"),
|
||||
Index("ix_messages_tenant_provider_msg", "tenant_id", "provider_message_id"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"correlation_id",
|
||||
"to_address",
|
||||
name="uq_messages_tenant_correlation_to",
|
||||
),
|
||||
)
|
||||
|
||||
channel: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="queued")
|
||||
to_address: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
from_address: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
subject: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
template_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
template_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
template_variables: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
provider_message_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
delivered_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
failed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
error_code: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
retry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
correlation_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
source_module: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
|
||||
|
||||
class QueueItem(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Append-only queue row — soft delete intentionally omitted."""
|
||||
|
||||
__tablename__ = "queue_items"
|
||||
__table_args__ = (
|
||||
Index("ix_queue_items_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_queue_items_priority_available", "priority", "available_at"),
|
||||
)
|
||||
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, index=True)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending")
|
||||
priority: Mapped[str] = mapped_column(String(20), nullable=False, default="normal")
|
||||
attempt: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||
available_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
batch_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
is_dead_letter: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class DeliveryEvent(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Immutable delivery timeline events for a message."""
|
||||
|
||||
__tablename__ = "delivery_events"
|
||||
__table_args__ = (
|
||||
Index("ix_delivery_events_message", "tenant_id", "message_id", "created_at"),
|
||||
)
|
||||
|
||||
message_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class ProviderLog(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Append-only provider I/O log — soft delete intentionally omitted."""
|
||||
|
||||
__tablename__ = "provider_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_provider_logs_tenant_provider", "tenant_id", "provider_config_id"),
|
||||
Index("ix_provider_logs_tenant_created", "tenant_id", "created_at"),
|
||||
)
|
||||
|
||||
provider_config_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
response_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class OTPChallenge(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Business/platform OTP challenges — independent from Core auth OTP."""
|
||||
|
||||
__tablename__ = "otp_challenges"
|
||||
__table_args__ = (
|
||||
Index("ix_otp_challenges_tenant_destination", "tenant_id", "destination"),
|
||||
Index("ix_otp_challenges_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
|
||||
channel: Mapped[str] = mapped_column(String(40), nullable=False, default="sms")
|
||||
destination: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
code_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(40), nullable=False, default="pending")
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
max_attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=5)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
purpose: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class WebhookReceipt(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Append-only inbound webhook receipt — soft delete intentionally omitted."""
|
||||
|
||||
__tablename__ = "webhook_receipts"
|
||||
__table_args__ = (
|
||||
Index("ix_webhook_receipts_tenant_provider", "tenant_id", "provider_kind"),
|
||||
Index(
|
||||
"ix_webhook_receipts_tenant_external",
|
||||
"tenant_id",
|
||||
"provider_kind",
|
||||
"external_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"provider_kind",
|
||||
"external_id",
|
||||
name="uq_webhook_receipts_tenant_provider_external",
|
||||
),
|
||||
)
|
||||
|
||||
provider_kind: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
provider_config_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
event_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
external_id: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
message_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
signature_valid: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
processed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
processing_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class CommunicationAuditLog(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
"""Append-only audit trail — soft delete intentionally omitted."""
|
||||
|
||||
__tablename__ = "communication_audit_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_comm_audit_tenant_entity", "tenant_id", "entity_type", "entity_id"),
|
||||
)
|
||||
|
||||
entity_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
entity_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
actor_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
137
backend/services/communication/app/models/types.py
Normal file
137
backend/services/communication/app/models/types.py
Normal file
@ -0,0 +1,137 @@
|
||||
"""Communication domain enums and dialect-safe GUID type."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.types import CHAR, TypeDecorator
|
||||
|
||||
|
||||
class GUID(TypeDecorator):
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if dialect.name == "postgresql":
|
||||
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
|
||||
|
||||
class ChannelType(str, enum.Enum):
|
||||
SMS = "sms"
|
||||
EMAIL = "email"
|
||||
PUSH = "push"
|
||||
WHATSAPP = "whatsapp"
|
||||
TELEGRAM = "telegram"
|
||||
RUBIKA = "rubika"
|
||||
VOICE = "voice"
|
||||
FUTURE = "future"
|
||||
|
||||
|
||||
class ProviderKind(str, enum.Enum):
|
||||
PAYAMAK = "payamak"
|
||||
MOCK = "mock"
|
||||
SMTP = "smtp"
|
||||
FCM = "fcm"
|
||||
WHATSAPP_CLOUD = "whatsapp_cloud"
|
||||
TELEGRAM_BOT = "telegram_bot"
|
||||
RUBIKA_BOT = "rubika_bot"
|
||||
VOICE_GATEWAY = "voice_gateway"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
class ProviderLifecycleStatus(str, enum.Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
DEGRADED = "degraded"
|
||||
CIRCUIT_OPEN = "circuit_open"
|
||||
|
||||
|
||||
class CircuitState(str, enum.Enum):
|
||||
CLOSED = "closed"
|
||||
OPEN = "open"
|
||||
HALF_OPEN = "half_open"
|
||||
|
||||
|
||||
class MessageStatus(str, enum.Enum):
|
||||
QUEUED = "queued"
|
||||
SENT = "sent"
|
||||
DELIVERED = "delivered"
|
||||
FAILED = "failed"
|
||||
EXPIRED = "expired"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class TemplateStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
APPROVED = "approved"
|
||||
REJECTED = "rejected"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class ContactSourceType(str, enum.Enum):
|
||||
MANUAL = "manual"
|
||||
CSV = "csv"
|
||||
CRM = "crm"
|
||||
LOYALTY = "loyalty"
|
||||
SPORTS = "sports"
|
||||
RESTAURANT = "restaurant"
|
||||
MARKETPLACE = "marketplace"
|
||||
ACCOUNTING = "accounting"
|
||||
WEBSITE = "website"
|
||||
EXTERNAL_REST = "external_rest"
|
||||
FUTURE = "future"
|
||||
|
||||
|
||||
class QueuePriority(str, enum.Enum):
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class QueueItemStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
DEAD_LETTER = "dead_letter"
|
||||
CANCELLED = "cancelled"
|
||||
SCHEDULED = "scheduled"
|
||||
|
||||
|
||||
class OTPStatus(str, enum.Enum):
|
||||
PENDING = "pending"
|
||||
VERIFIED = "verified"
|
||||
EXPIRED = "expired"
|
||||
LOCKED = "locked"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
SEND = "send"
|
||||
DELIVER = "deliver"
|
||||
FAIL = "fail"
|
||||
RETRY = "retry"
|
||||
FAILOVER = "failover"
|
||||
VERIFY = "verify"
|
||||
WEBHOOK = "webhook"
|
||||
@ -0,0 +1,64 @@
|
||||
"""Communication permission definitions."""
|
||||
|
||||
COMMUNICATION_VIEW = "communication.view"
|
||||
COMMUNICATION_MANAGE = "communication.manage"
|
||||
|
||||
PROVIDERS_VIEW = "communication.providers.view"
|
||||
PROVIDERS_MANAGE = "communication.providers.manage"
|
||||
SENDERS_VIEW = "communication.senders.view"
|
||||
SENDERS_MANAGE = "communication.senders.manage"
|
||||
TEMPLATES_VIEW = "communication.templates.view"
|
||||
TEMPLATES_MANAGE = "communication.templates.manage"
|
||||
TEMPLATES_APPROVE = "communication.templates.approve"
|
||||
CONTACTS_VIEW = "communication.contacts.view"
|
||||
CONTACTS_MANAGE = "communication.contacts.manage"
|
||||
SOURCES_VIEW = "communication.sources.view"
|
||||
SOURCES_MANAGE = "communication.sources.manage"
|
||||
MESSAGES_VIEW = "communication.messages.view"
|
||||
MESSAGES_SEND = "communication.messages.send"
|
||||
MESSAGES_CANCEL = "communication.messages.cancel"
|
||||
QUEUE_VIEW = "communication.queue.view"
|
||||
QUEUE_MANAGE = "communication.queue.manage"
|
||||
OTP_REQUEST = "communication.otp.request"
|
||||
OTP_VERIFY = "communication.otp.verify"
|
||||
WEBHOOKS_MANAGE = "communication.webhooks.manage"
|
||||
MONITORING_VIEW = "communication.monitoring.view"
|
||||
|
||||
ALL_PERMISSIONS = [
|
||||
COMMUNICATION_VIEW,
|
||||
COMMUNICATION_MANAGE,
|
||||
PROVIDERS_VIEW,
|
||||
PROVIDERS_MANAGE,
|
||||
SENDERS_VIEW,
|
||||
SENDERS_MANAGE,
|
||||
TEMPLATES_VIEW,
|
||||
TEMPLATES_MANAGE,
|
||||
TEMPLATES_APPROVE,
|
||||
CONTACTS_VIEW,
|
||||
CONTACTS_MANAGE,
|
||||
SOURCES_VIEW,
|
||||
SOURCES_MANAGE,
|
||||
MESSAGES_VIEW,
|
||||
MESSAGES_SEND,
|
||||
MESSAGES_CANCEL,
|
||||
QUEUE_VIEW,
|
||||
QUEUE_MANAGE,
|
||||
OTP_REQUEST,
|
||||
OTP_VERIFY,
|
||||
WEBHOOKS_MANAGE,
|
||||
MONITORING_VIEW,
|
||||
]
|
||||
|
||||
PERMISSION_PREFIXES = [
|
||||
"communication.",
|
||||
"communication.providers.",
|
||||
"communication.senders.",
|
||||
"communication.templates.",
|
||||
"communication.contacts.",
|
||||
"communication.sources.",
|
||||
"communication.messages.",
|
||||
"communication.queue.",
|
||||
"communication.otp.",
|
||||
"communication.webhooks.",
|
||||
"communication.monitoring.",
|
||||
]
|
||||
74
backend/services/communication/app/providers/__init__.py
Normal file
74
backend/services/communication/app/providers/__init__.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Provider registry — maps provider_kind to channel adapters."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.providers.contracts import ChannelProvider
|
||||
from app.providers.mock_sms import MockSMSProvider, StubFutureChannelProvider
|
||||
from app.providers.payamak import PayamakSMSProvider
|
||||
|
||||
_REGISTRY: dict[str, ChannelProvider] = {}
|
||||
|
||||
|
||||
def _bootstrap() -> None:
|
||||
if _REGISTRY:
|
||||
return
|
||||
mock = MockSMSProvider()
|
||||
payamak = PayamakSMSProvider()
|
||||
_REGISTRY["mock"] = mock
|
||||
_REGISTRY["payamak"] = payamak
|
||||
for kind, channel in (
|
||||
("smtp", "email"),
|
||||
("fcm", "push"),
|
||||
("whatsapp_cloud", "whatsapp"),
|
||||
("telegram_bot", "telegram"),
|
||||
("rubika_bot", "rubika"),
|
||||
("voice_gateway", "voice"),
|
||||
("custom", "future"),
|
||||
):
|
||||
_REGISTRY[kind] = StubFutureChannelProvider(kind, channel)
|
||||
|
||||
|
||||
def get_provider(provider_kind: str) -> ChannelProvider:
|
||||
_bootstrap()
|
||||
if provider_kind not in _REGISTRY:
|
||||
raise KeyError(f"Unknown provider kind: {provider_kind}")
|
||||
return _REGISTRY[provider_kind]
|
||||
|
||||
|
||||
def list_registered_provider_kinds() -> list[str]:
|
||||
_bootstrap()
|
||||
return sorted(_REGISTRY.keys())
|
||||
|
||||
|
||||
def reset_provider_registry_for_tests(mock: MockSMSProvider | None = None) -> MockSMSProvider:
|
||||
"""Replace mock instance so tests can inspect send calls."""
|
||||
global _REGISTRY
|
||||
_REGISTRY = {}
|
||||
instance = mock or MockSMSProvider()
|
||||
_REGISTRY["mock"] = instance
|
||||
_REGISTRY["payamak"] = PayamakSMSProvider()
|
||||
for kind, channel in (
|
||||
("smtp", "email"),
|
||||
("fcm", "push"),
|
||||
("whatsapp_cloud", "whatsapp"),
|
||||
("telegram_bot", "telegram"),
|
||||
("rubika_bot", "rubika"),
|
||||
("voice_gateway", "voice"),
|
||||
("custom", "future"),
|
||||
):
|
||||
_REGISTRY[kind] = StubFutureChannelProvider(kind, channel)
|
||||
return instance
|
||||
|
||||
|
||||
def supported_channels() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"channel": "sms", "status": "active", "providers": ["mock", "payamak"]},
|
||||
{"channel": "email", "status": "planned", "providers": ["smtp"]},
|
||||
{"channel": "push", "status": "planned", "providers": ["fcm"]},
|
||||
{"channel": "whatsapp", "status": "planned", "providers": ["whatsapp_cloud"]},
|
||||
{"channel": "telegram", "status": "planned", "providers": ["telegram_bot"]},
|
||||
{"channel": "rubika", "status": "planned", "providers": ["rubika_bot"]},
|
||||
{"channel": "voice", "status": "planned", "providers": ["voice_gateway"]},
|
||||
{"channel": "future", "status": "extensible", "providers": ["custom"]},
|
||||
]
|
||||
53
backend/services/communication/app/providers/contracts.py
Normal file
53
backend/services/communication/app/providers/contracts.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Channel provider contracts — all external delivery goes through these adapters."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendRequest:
|
||||
tenant_id: UUID
|
||||
channel: str
|
||||
to_address: str
|
||||
body: str
|
||||
from_address: str | None = None
|
||||
subject: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SendResult:
|
||||
success: bool
|
||||
provider_message_id: str | None = None
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
raw_response: dict[str, Any] | None = None
|
||||
latency_ms: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class BalanceResult:
|
||||
balance: float | None
|
||||
currency: str | None = None
|
||||
raw: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderHealth:
|
||||
healthy: bool
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class ChannelProvider(Protocol):
|
||||
"""Protocol every channel adapter must implement."""
|
||||
|
||||
provider_kind: str
|
||||
channel: str
|
||||
|
||||
async def send(self, request: SendRequest, credentials: dict[str, Any]) -> SendResult: ...
|
||||
|
||||
async def get_balance(self, credentials: dict[str, Any]) -> BalanceResult: ...
|
||||
|
||||
async def health_check(self, credentials: dict[str, Any]) -> ProviderHealth: ...
|
||||
68
backend/services/communication/app/providers/mock_sms.py
Normal file
68
backend/services/communication/app/providers/mock_sms.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Mock SMS provider for tests and local development."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from app.providers.contracts import (
|
||||
BalanceResult,
|
||||
ProviderHealth,
|
||||
SendRequest,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
|
||||
class MockSMSProvider:
|
||||
provider_kind = "mock"
|
||||
channel = "sms"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[SendRequest] = []
|
||||
self.fail_next: bool = False
|
||||
self.fail_message: str = "mock_failure"
|
||||
|
||||
async def send(self, request: SendRequest, credentials: dict[str, Any]) -> SendResult:
|
||||
self.sent.append(request)
|
||||
if self.fail_next or credentials.get("force_fail"):
|
||||
self.fail_next = False
|
||||
return SendResult(
|
||||
success=False,
|
||||
error_code="mock_failure",
|
||||
error_message=self.fail_message,
|
||||
latency_ms=1,
|
||||
)
|
||||
return SendResult(
|
||||
success=True,
|
||||
provider_message_id=f"mock-{uuid4()}",
|
||||
raw_response={"status": "ok"},
|
||||
latency_ms=1,
|
||||
)
|
||||
|
||||
async def get_balance(self, credentials: dict[str, Any]) -> BalanceResult:
|
||||
return BalanceResult(balance=float(credentials.get("balance", 1000)), currency="IRR")
|
||||
|
||||
async def health_check(self, credentials: dict[str, Any]) -> ProviderHealth:
|
||||
if credentials.get("unhealthy"):
|
||||
return ProviderHealth(healthy=False, detail="forced unhealthy")
|
||||
return ProviderHealth(healthy=True, detail="ok")
|
||||
|
||||
|
||||
class StubFutureChannelProvider:
|
||||
"""Placeholder for email/push/whatsapp/telegram/rubika/voice until implemented."""
|
||||
|
||||
def __init__(self, provider_kind: str, channel: str) -> None:
|
||||
self.provider_kind = provider_kind
|
||||
self.channel = channel
|
||||
|
||||
async def send(self, request: SendRequest, credentials: dict[str, Any]) -> SendResult:
|
||||
return SendResult(
|
||||
success=False,
|
||||
error_code="channel_not_implemented",
|
||||
error_message=f"Channel {self.channel} is registered but not yet implemented",
|
||||
)
|
||||
|
||||
async def get_balance(self, credentials: dict[str, Any]) -> BalanceResult:
|
||||
return BalanceResult(balance=None)
|
||||
|
||||
async def health_check(self, credentials: dict[str, Any]) -> ProviderHealth:
|
||||
return ProviderHealth(healthy=False, detail="not implemented")
|
||||
85
backend/services/communication/app/providers/payamak.py
Normal file
85
backend/services/communication/app/providers/payamak.py
Normal file
@ -0,0 +1,85 @@
|
||||
"""Payamak SMS provider adapter — only used inside Communication service."""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
from app.providers.contracts import (
|
||||
BalanceResult,
|
||||
ProviderHealth,
|
||||
SendRequest,
|
||||
SendResult,
|
||||
)
|
||||
|
||||
|
||||
class PayamakSMSProvider:
|
||||
provider_kind = "payamak"
|
||||
channel = "sms"
|
||||
|
||||
async def send(self, request: SendRequest, credentials: dict[str, Any]) -> SendResult:
|
||||
username = credentials.get("username") or credentials.get("api_key")
|
||||
password = credentials.get("password") or credentials.get("api_key")
|
||||
from_number = request.from_address or credentials.get("from")
|
||||
if not username or not password:
|
||||
# Dev-safe path: simulate send when credentials absent
|
||||
return SendResult(
|
||||
success=True,
|
||||
provider_message_id=f"payamak-dev-{uuid4()}",
|
||||
raw_response={"mode": "dev_simulated"},
|
||||
latency_ms=0,
|
||||
)
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.post(
|
||||
credentials.get(
|
||||
"endpoint",
|
||||
"https://api.payamak-panel.com/post/Send.asmx/SendSimpleSMS",
|
||||
),
|
||||
data={
|
||||
"username": username,
|
||||
"password": password,
|
||||
"to": request.to_address,
|
||||
"from": from_number or "",
|
||||
"text": request.body,
|
||||
"isflash": "false",
|
||||
},
|
||||
)
|
||||
latency = int((time.perf_counter() - started) * 1000)
|
||||
if response.status_code >= 400:
|
||||
return SendResult(
|
||||
success=False,
|
||||
error_code="payamak_http_error",
|
||||
error_message=response.text[:500],
|
||||
latency_ms=latency,
|
||||
raw_response={"status_code": response.status_code},
|
||||
)
|
||||
return SendResult(
|
||||
success=True,
|
||||
provider_message_id=response.text.strip()[:200] or f"payamak-{uuid4()}",
|
||||
raw_response={"body": response.text[:500]},
|
||||
latency_ms=latency,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — provider boundary
|
||||
latency = int((time.perf_counter() - started) * 1000)
|
||||
return SendResult(
|
||||
success=False,
|
||||
error_code="payamak_exception",
|
||||
error_message=str(exc),
|
||||
latency_ms=latency,
|
||||
)
|
||||
|
||||
async def get_balance(self, credentials: dict[str, Any]) -> BalanceResult:
|
||||
raw = credentials.get("cached_balance")
|
||||
if raw is not None:
|
||||
return BalanceResult(balance=float(raw), currency="IRR")
|
||||
return BalanceResult(balance=None, currency="IRR")
|
||||
|
||||
async def health_check(self, credentials: dict[str, Any]) -> ProviderHealth:
|
||||
if credentials.get("username") or credentials.get("api_key"):
|
||||
return ProviderHealth(healthy=True, detail="credentials present")
|
||||
return ProviderHealth(healthy=True, detail="dev mode without credentials")
|
||||
66
backend/services/communication/app/repositories/base.py
Normal file
66
backend/services/communication/app/repositories/base.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Tenant-aware base repository with soft-delete helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generic, Sequence, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class TenantBaseRepository(Generic[ModelT]):
|
||||
model: type[ModelT]
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
def _not_deleted_clause(self):
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
return self.model.is_deleted.is_(False) # type: ignore[attr-defined]
|
||||
return True
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
|
||||
clauses = [
|
||||
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||
]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def add(self, entity: ModelT) -> ModelT:
|
||||
self.session.add(entity)
|
||||
await self.session.flush()
|
||||
return entity
|
||||
|
||||
async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None:
|
||||
entity.is_deleted = True # type: ignore[attr-defined]
|
||||
entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined]
|
||||
if deleted_by is not None and hasattr(entity, "deleted_by"):
|
||||
entity.deleted_by = deleted_by # type: ignore[attr-defined]
|
||||
await self.session.flush()
|
||||
|
||||
async def list_by_tenant(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[ModelT]:
|
||||
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(self.model).where(*clauses).offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(func.count()).select_from(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
328
backend/services/communication/app/repositories/foundation.py
Normal file
328
backend/services/communication/app/repositories/foundation.py
Normal file
@ -0,0 +1,328 @@
|
||||
"""Communication repositories."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import asc, desc, func, or_, select
|
||||
|
||||
from app.models.foundation import (
|
||||
CommunicationAuditLog,
|
||||
ContactSource,
|
||||
DeliveryEvent,
|
||||
ManualContact,
|
||||
Message,
|
||||
MessageTemplate,
|
||||
OTPChallenge,
|
||||
ProviderConfig,
|
||||
ProviderLog,
|
||||
QueueItem,
|
||||
SenderNumber,
|
||||
WebhookReceipt,
|
||||
)
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
|
||||
|
||||
class ProviderConfigRepository(TenantBaseRepository[ProviderConfig]):
|
||||
model = ProviderConfig
|
||||
|
||||
async def list_by_channel(
|
||||
self, tenant_id: UUID, channel: str, *, active_only: bool = True
|
||||
) -> Sequence[ProviderConfig]:
|
||||
clauses = [
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.channel == channel,
|
||||
self.model.is_deleted.is_(False),
|
||||
]
|
||||
if active_only:
|
||||
clauses.append(self.model.status.in_(["active", "degraded"]))
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(*clauses)
|
||||
.order_by(asc(self.model.priority), asc(self.model.created_at))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def list_all_status(self, tenant_id: UUID) -> Sequence[ProviderConfig]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(asc(self.model.channel), asc(self.model.priority))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class SenderNumberRepository(TenantBaseRepository[SenderNumber]):
|
||||
model = SenderNumber
|
||||
|
||||
async def get_default(self, tenant_id: UUID, channel: str) -> SenderNumber | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.channel == channel,
|
||||
self.model.is_default.is_(True),
|
||||
self.model.is_active.is_(True),
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class MessageTemplateRepository(TenantBaseRepository[MessageTemplate]):
|
||||
model = MessageTemplate
|
||||
|
||||
async def get_by_key(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
template_key: str,
|
||||
*,
|
||||
locale: str = "fa",
|
||||
approved_only: bool = True,
|
||||
) -> MessageTemplate | None:
|
||||
clauses = [
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.template_key == template_key,
|
||||
self.model.locale == locale,
|
||||
self.model.is_deleted.is_(False),
|
||||
]
|
||||
if approved_only:
|
||||
clauses.append(self.model.status == "approved")
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(*clauses)
|
||||
.order_by(desc(self.model.version))
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_versions(
|
||||
self, tenant_id: UUID, template_key: str, locale: str = "fa"
|
||||
) -> Sequence[MessageTemplate]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.template_key == template_key,
|
||||
self.model.locale == locale,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(desc(self.model.version))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ManualContactRepository(TenantBaseRepository[ManualContact]):
|
||||
model = ManualContact
|
||||
|
||||
|
||||
class ContactSourceRepository(TenantBaseRepository[ContactSource]):
|
||||
model = ContactSource
|
||||
|
||||
async def list_active(self, tenant_id: UUID) -> Sequence[ContactSource]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_active.is_(True),
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class MessageRepository(TenantBaseRepository[Message]):
|
||||
model = Message
|
||||
|
||||
async def get_by_correlation(
|
||||
self, tenant_id: UUID, correlation_id: str
|
||||
) -> Sequence[Message]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.correlation_id == correlation_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(asc(self.model.created_at))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def list_by_status(
|
||||
self, tenant_id: UUID, status: str, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[Message]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.status == status,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(desc(self.model.created_at))
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_by_status(self, tenant_id: UUID) -> dict[str, int]:
|
||||
stmt = (
|
||||
select(self.model.status, func.count())
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.group_by(self.model.status)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
|
||||
class QueueItemRepository(TenantBaseRepository[QueueItem]):
|
||||
model = QueueItem
|
||||
|
||||
async def enqueue(self, item: QueueItem) -> QueueItem:
|
||||
return await self.add(item)
|
||||
|
||||
async def claim_next(
|
||||
self,
|
||||
tenant_id: UUID | None = None,
|
||||
*,
|
||||
limit: int = 10,
|
||||
increment_attempt: bool = True,
|
||||
) -> Sequence[QueueItem]:
|
||||
now = datetime.now(timezone.utc)
|
||||
clauses = [
|
||||
self.model.status.in_(["pending", "scheduled"]),
|
||||
self.model.is_dead_letter.is_(False),
|
||||
]
|
||||
if tenant_id is not None:
|
||||
clauses.append(self.model.tenant_id == tenant_id)
|
||||
from sqlalchemy import case
|
||||
|
||||
priority_case = case(
|
||||
(self.model.priority == "urgent", 1),
|
||||
(self.model.priority == "high", 2),
|
||||
(self.model.priority == "normal", 3),
|
||||
else_=4,
|
||||
)
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(*clauses)
|
||||
.order_by(asc(priority_case), asc(self.model.available_at))
|
||||
.limit(limit * 3)
|
||||
)
|
||||
bind = self.session.get_bind()
|
||||
if bind is not None and bind.dialect.name == "postgresql":
|
||||
stmt = stmt.with_for_update(skip_locked=True)
|
||||
result = await self.session.execute(stmt)
|
||||
candidates = list(result.scalars().all())
|
||||
items: list[QueueItem] = []
|
||||
for item in candidates:
|
||||
available = item.available_at
|
||||
if available.tzinfo is None:
|
||||
available = available.replace(tzinfo=timezone.utc)
|
||||
if available > now:
|
||||
continue
|
||||
item.status = "processing"
|
||||
item.locked_at = now
|
||||
if increment_attempt:
|
||||
item.attempt += 1
|
||||
items.append(item)
|
||||
if len(items) >= limit:
|
||||
break
|
||||
await self.session.flush()
|
||||
return items
|
||||
|
||||
async def count_by_status(self, tenant_id: UUID) -> dict[str, int]:
|
||||
stmt = (
|
||||
select(self.model.status, func.count())
|
||||
.where(self.model.tenant_id == tenant_id)
|
||||
.group_by(self.model.status)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return {row[0]: int(row[1]) for row in result.all()}
|
||||
|
||||
async def list_dead_letters(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[QueueItem]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
or_(
|
||||
self.model.is_dead_letter.is_(True),
|
||||
self.model.status == "dead_letter",
|
||||
),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class DeliveryEventRepository(TenantBaseRepository[DeliveryEvent]):
|
||||
model = DeliveryEvent
|
||||
|
||||
async def list_for_message(
|
||||
self, tenant_id: UUID, message_id: UUID
|
||||
) -> Sequence[DeliveryEvent]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.message_id == message_id,
|
||||
)
|
||||
.order_by(asc(self.model.created_at))
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ProviderLogRepository(TenantBaseRepository[ProviderLog]):
|
||||
model = ProviderLog
|
||||
|
||||
|
||||
class OTPChallengeRepository(TenantBaseRepository[OTPChallenge]):
|
||||
model = OTPChallenge
|
||||
|
||||
async def get_latest_pending(
|
||||
self, tenant_id: UUID, destination: str
|
||||
) -> OTPChallenge | None:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.destination == destination,
|
||||
self.model.status == "pending",
|
||||
)
|
||||
.order_by(desc(self.model.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def count_recent(
|
||||
self, tenant_id: UUID, destination: str, since: datetime
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.destination == destination,
|
||||
self.model.created_at >= since,
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class WebhookReceiptRepository(TenantBaseRepository[WebhookReceipt]):
|
||||
model = WebhookReceipt
|
||||
|
||||
|
||||
class AuditLogRepository(TenantBaseRepository[CommunicationAuditLog]):
|
||||
model = CommunicationAuditLog
|
||||
311
backend/services/communication/app/schemas/common.py
Normal file
311
backend/services/communication/app/schemas/common.py
Normal file
@ -0,0 +1,311 @@
|
||||
"""Pydantic schemas for Communication APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ORMModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class ProviderCreate(BaseModel):
|
||||
name: str
|
||||
provider_kind: str
|
||||
channel: str
|
||||
priority: int = 100
|
||||
credentials: dict[str, Any] | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
balance: Decimal | None = None
|
||||
rate_limit_per_minute: int | None = None
|
||||
max_retries: int = 3
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class ProviderUpdate(BaseModel):
|
||||
name: str | None = None
|
||||
priority: int | None = None
|
||||
credentials: dict[str, Any] | None = None
|
||||
settings: dict[str, Any] | None = None
|
||||
balance: Decimal | None = None
|
||||
rate_limit_per_minute: int | None = None
|
||||
max_retries: int | None = None
|
||||
status: str | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class ProviderOut(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
name: str
|
||||
provider_kind: str
|
||||
channel: str
|
||||
status: str
|
||||
priority: int
|
||||
balance: Decimal | None = None
|
||||
rate_limit_per_minute: int | None = None
|
||||
max_retries: int
|
||||
circuit_state: str
|
||||
consecutive_failures: int
|
||||
last_health_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
is_default: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SenderCreate(BaseModel):
|
||||
channel: str
|
||||
value: str
|
||||
label: str | None = None
|
||||
provider_config_id: UUID | None = None
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class SenderOut(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
channel: str
|
||||
value: str
|
||||
label: str | None = None
|
||||
provider_config_id: UUID | None = None
|
||||
is_default: bool
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
template_key: str
|
||||
name: str
|
||||
channel: str
|
||||
locale: str = "fa"
|
||||
body: str
|
||||
subject: str | None = None
|
||||
variables: list[str] | None = None
|
||||
|
||||
|
||||
class TemplateOut(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
template_key: str
|
||||
name: str
|
||||
channel: str
|
||||
locale: str
|
||||
version: int
|
||||
status: str
|
||||
body: str
|
||||
subject: str | None = None
|
||||
variables: list[str] | None = None
|
||||
approved_by: str | None = None
|
||||
approved_at: datetime | None = None
|
||||
rejection_reason: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class TemplatePreviewRequest(BaseModel):
|
||||
variables: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class TemplatePreviewResponse(BaseModel):
|
||||
rendered: str
|
||||
subject: str | None = None
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
display_name: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
push_token: str | None = None
|
||||
metadata_json: dict[str, Any] | None = None
|
||||
tags: list[str] | None = None
|
||||
|
||||
|
||||
class ContactOut(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
display_name: str | None = None
|
||||
phone: str | None = None
|
||||
email: str | None = None
|
||||
push_token: str | None = None
|
||||
metadata_json: dict[str, Any] | None = None
|
||||
tags: list[str] | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ContactSourceCreate(BaseModel):
|
||||
name: str
|
||||
source_type: str
|
||||
base_url: str | None = None
|
||||
endpoint_path: str | None = None
|
||||
auth_config: dict[str, Any] | None = None
|
||||
query_mapping: dict[str, Any] | None = None
|
||||
field_mapping: dict[str, Any] | None = None
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class ContactSourceOut(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
name: str
|
||||
source_type: str
|
||||
base_url: str | None = None
|
||||
endpoint_path: str | None = None
|
||||
field_mapping: dict[str, Any] | None = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class MessageSendRequest(BaseModel):
|
||||
channel: str = "sms"
|
||||
to_address: str | None = None
|
||||
from_address: str | None = None
|
||||
body: str | None = None
|
||||
subject: str | None = None
|
||||
template_key: str | None = None
|
||||
template_id: UUID | None = None
|
||||
template_variables: dict[str, Any] | None = None
|
||||
priority: str = "normal"
|
||||
scheduled_at: datetime | None = None
|
||||
correlation_id: str | None = None
|
||||
source_module: str | None = None
|
||||
metadata_json: dict[str, Any] | None = None
|
||||
contact_source_id: UUID | None = None
|
||||
contact_query: dict[str, Any] | None = None
|
||||
batch: bool = False
|
||||
process_immediately: bool = True
|
||||
|
||||
|
||||
class MessageOut(ORMModel):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
channel: str
|
||||
status: str
|
||||
to_address: str
|
||||
from_address: str | None = None
|
||||
subject: str | None = None
|
||||
body: str
|
||||
template_key: str | None = None
|
||||
provider_config_id: UUID | None = None
|
||||
provider_message_id: str | None = None
|
||||
priority: str
|
||||
scheduled_at: datetime | None = None
|
||||
sent_at: datetime | None = None
|
||||
delivered_at: datetime | None = None
|
||||
failed_at: datetime | None = None
|
||||
error_code: str | None = None
|
||||
error_message: str | None = None
|
||||
retry_count: int
|
||||
correlation_id: str | None = None
|
||||
source_module: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class DeliveryEventOut(ORMModel):
|
||||
id: UUID
|
||||
message_id: UUID
|
||||
status: str
|
||||
provider_config_id: UUID | None = None
|
||||
detail: str | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class QueueItemOut(ORMModel):
|
||||
id: UUID
|
||||
message_id: UUID
|
||||
status: str
|
||||
priority: str
|
||||
attempt: int
|
||||
max_attempts: int
|
||||
available_at: datetime
|
||||
is_dead_letter: bool
|
||||
last_error: str | None = None
|
||||
batch_id: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class OTPRequest(BaseModel):
|
||||
destination: str
|
||||
channel: str = "sms"
|
||||
purpose: str | None = None
|
||||
ttl_seconds: int | None = None
|
||||
code_length: int = 6
|
||||
|
||||
|
||||
class OTPRequestOut(BaseModel):
|
||||
challenge_id: UUID
|
||||
destination: str
|
||||
channel: str
|
||||
expires_at: datetime
|
||||
message_id: UUID | None = None
|
||||
# Returned only in non-production for tests/dev
|
||||
debug_code: str | None = None
|
||||
|
||||
|
||||
class OTPVerifyRequest(BaseModel):
|
||||
destination: str
|
||||
code: str
|
||||
challenge_id: UUID | None = None
|
||||
|
||||
|
||||
class OTPVerifyOut(BaseModel):
|
||||
verified: bool
|
||||
challenge_id: UUID | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class WebhookIn(BaseModel):
|
||||
provider_kind: str
|
||||
event_type: str
|
||||
external_id: str | None = None
|
||||
message_id: UUID | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
signature: str | None = None
|
||||
|
||||
|
||||
class WebhookOut(ORMModel):
|
||||
id: UUID
|
||||
provider_kind: str
|
||||
event_type: str
|
||||
external_id: str | None = None
|
||||
message_id: UUID | None = None
|
||||
signature_valid: bool
|
||||
processed: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CapabilitiesOut(BaseModel):
|
||||
service: str
|
||||
version: str
|
||||
channels: list[dict[str, Any]]
|
||||
features: list[str]
|
||||
independent: bool = True
|
||||
requires_other_modules: bool = False
|
||||
|
||||
|
||||
class ProviderStatusOut(BaseModel):
|
||||
id: UUID
|
||||
name: str
|
||||
provider_kind: str
|
||||
channel: str
|
||||
status: str
|
||||
circuit_state: str
|
||||
consecutive_failures: int
|
||||
balance: Decimal | None = None
|
||||
rate_limit_per_minute: int | None = None
|
||||
last_health_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
healthy: bool
|
||||
|
||||
|
||||
class MonitoringStatsOut(BaseModel):
|
||||
messages_by_status: dict[str, int]
|
||||
queue_by_status: dict[str, int]
|
||||
providers: list[ProviderStatusOut]
|
||||
rate_limit_deferrals: int = 0
|
||||
checked_at: str | None = None
|
||||
37
backend/services/communication/app/services/audit_service.py
Normal file
37
backend/services/communication/app/services/audit_service.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Audit helper for Communication platform."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.foundation import CommunicationAuditLog
|
||||
from app.repositories.foundation import AuditLogRepository
|
||||
|
||||
|
||||
class AuditService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.repo = AuditLogRepository(session)
|
||||
|
||||
async def log(
|
||||
self,
|
||||
*,
|
||||
tenant_id: UUID,
|
||||
entity_type: str,
|
||||
entity_id: UUID | str,
|
||||
action: str,
|
||||
actor_id: str | None = None,
|
||||
changes: dict[str, Any] | None = None,
|
||||
detail: str | None = None,
|
||||
) -> CommunicationAuditLog:
|
||||
entry = CommunicationAuditLog(
|
||||
tenant_id=tenant_id,
|
||||
entity_type=entity_type,
|
||||
entity_id=str(entity_id),
|
||||
action=action,
|
||||
actor_id=actor_id,
|
||||
changes=changes,
|
||||
detail=detail,
|
||||
)
|
||||
return await self.repo.add(entry)
|
||||
218
backend/services/communication/app/services/contact_service.py
Normal file
218
backend/services/communication/app/services/contact_service.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""Manual contacts and dynamic contact source resolution — no business data duplication."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.foundation import ContactSource, ManualContact
|
||||
from app.repositories.foundation import ContactSourceRepository, ManualContactRepository
|
||||
from app.validators import validate_contact, validate_contact_source
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
|
||||
|
||||
class ResolvedContact:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
address: str,
|
||||
channel_hint: str | None = None,
|
||||
display_name: str | None = None,
|
||||
external_ref: str | None = None,
|
||||
source_type: str = "manual",
|
||||
) -> None:
|
||||
self.address = address
|
||||
self.channel_hint = channel_hint
|
||||
self.display_name = display_name
|
||||
self.external_ref = external_ref
|
||||
self.source_type = source_type
|
||||
|
||||
|
||||
class ContactService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.contacts = ManualContactRepository(session)
|
||||
self.sources = ContactSourceRepository(session)
|
||||
|
||||
async def create_manual(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> ManualContact:
|
||||
validate_contact(data)
|
||||
entity = ManualContact(
|
||||
tenant_id=tenant_id,
|
||||
display_name=data.get("display_name"),
|
||||
phone=data.get("phone"),
|
||||
email=data.get("email"),
|
||||
push_token=data.get("push_token"),
|
||||
metadata_json=data.get("metadata_json"),
|
||||
tags=data.get("tags"),
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.contacts.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_manual(self, tenant_id: UUID) -> list[ManualContact]:
|
||||
return list(await self.contacts.list_by_tenant(tenant_id, limit=500))
|
||||
|
||||
async def import_csv(
|
||||
self, tenant_id: UUID, csv_text: str, *, actor_id: str | None = None
|
||||
) -> list[ManualContact]:
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
created: list[ManualContact] = []
|
||||
for row in reader:
|
||||
payload = {
|
||||
"display_name": row.get("display_name") or row.get("name"),
|
||||
"phone": row.get("phone") or row.get("mobile"),
|
||||
"email": row.get("email"),
|
||||
"push_token": row.get("push_token"),
|
||||
}
|
||||
validate_contact(payload)
|
||||
entity = ManualContact(
|
||||
tenant_id=tenant_id,
|
||||
display_name=payload.get("display_name"),
|
||||
phone=payload.get("phone"),
|
||||
email=payload.get("email"),
|
||||
push_token=payload.get("push_token"),
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.contacts.add(entity)
|
||||
created.append(entity)
|
||||
await self.session.commit()
|
||||
return created
|
||||
|
||||
async def create_source(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> ContactSource:
|
||||
validate_contact_source(data)
|
||||
entity = ContactSource(
|
||||
tenant_id=tenant_id,
|
||||
name=data["name"],
|
||||
source_type=data["source_type"],
|
||||
base_url=data.get("base_url"),
|
||||
endpoint_path=data.get("endpoint_path"),
|
||||
auth_config=data.get("auth_config"),
|
||||
query_mapping=data.get("query_mapping"),
|
||||
field_mapping=data.get("field_mapping"),
|
||||
is_active=data.get("is_active", True),
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.sources.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_sources(self, tenant_id: UUID) -> list[ContactSource]:
|
||||
return list(await self.sources.list_by_tenant(tenant_id))
|
||||
|
||||
async def resolve(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
source_id: UUID | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
channel: str = "sms",
|
||||
) -> list[ResolvedContact]:
|
||||
"""Resolve contacts dynamically — never persists remote business records."""
|
||||
query = query or {}
|
||||
if source_id is None:
|
||||
# Manual store resolution by phone/email filters
|
||||
manuals = await self.list_manual(tenant_id)
|
||||
results: list[ResolvedContact] = []
|
||||
for c in manuals:
|
||||
address = c.phone if channel == "sms" else (c.email or c.phone)
|
||||
if not address:
|
||||
continue
|
||||
if query.get("phone") and c.phone != query["phone"]:
|
||||
continue
|
||||
if query.get("email") and c.email != query["email"]:
|
||||
continue
|
||||
results.append(
|
||||
ResolvedContact(
|
||||
address=address,
|
||||
display_name=c.display_name,
|
||||
source_type="manual",
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
source = await self.sources.get(tenant_id, source_id)
|
||||
if source is None or not source.is_active:
|
||||
raise NotFoundError("Contact source not found")
|
||||
|
||||
if source.source_type in ("manual", "csv"):
|
||||
return await self.resolve(tenant_id, query=query, channel=channel)
|
||||
|
||||
return await self._resolve_remote(source, query=query, channel=channel)
|
||||
|
||||
async def _resolve_remote(
|
||||
self,
|
||||
source: ContactSource,
|
||||
*,
|
||||
query: dict[str, Any],
|
||||
channel: str,
|
||||
) -> list[ResolvedContact]:
|
||||
if not source.base_url:
|
||||
raise AppError(
|
||||
"Contact source missing base_url",
|
||||
status_code=422,
|
||||
error_code="invalid_source",
|
||||
)
|
||||
path = source.endpoint_path or ""
|
||||
url = source.base_url.rstrip("/") + "/" + path.lstrip("/")
|
||||
headers: dict[str, str] = {}
|
||||
auth = source.auth_config or {}
|
||||
if auth.get("bearer"):
|
||||
headers["Authorization"] = f"Bearer {auth['bearer']}"
|
||||
if auth.get("api_key"):
|
||||
headers[auth.get("api_key_header", "X-API-Key")] = auth["api_key"]
|
||||
params = dict(source.query_mapping or {})
|
||||
params.update(query)
|
||||
|
||||
# In tests, callers can inject a stub via settings-like auth_config mock_contacts
|
||||
if auth.get("mock_contacts") is not None:
|
||||
rows = auth["mock_contacts"]
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(url, params=params, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise AppError(
|
||||
f"Failed to resolve contacts from {source.source_type}",
|
||||
status_code=502,
|
||||
error_code="contact_source_error",
|
||||
details={"error": str(exc)},
|
||||
) from exc
|
||||
rows = payload if isinstance(payload, list) else payload.get("items", [])
|
||||
|
||||
mapping = source.field_mapping or {}
|
||||
phone_field = mapping.get("phone", "phone")
|
||||
email_field = mapping.get("email", "email")
|
||||
name_field = mapping.get("display_name", "display_name")
|
||||
id_field = mapping.get("id", "id")
|
||||
results: list[ResolvedContact] = []
|
||||
for row in rows:
|
||||
address = row.get(phone_field) if channel == "sms" else row.get(email_field)
|
||||
if channel == "sms" and not address:
|
||||
address = row.get(phone_field)
|
||||
if not address:
|
||||
continue
|
||||
results.append(
|
||||
ResolvedContact(
|
||||
address=str(address),
|
||||
display_name=str(row.get(name_field) or "") or None,
|
||||
external_ref=str(row.get(id_field)) if row.get(id_field) else None,
|
||||
source_type=source.source_type,
|
||||
)
|
||||
)
|
||||
return results
|
||||
152
backend/services/communication/app/services/message_service.py
Normal file
152
backend/services/communication/app/services/message_service.py
Normal file
@ -0,0 +1,152 @@
|
||||
"""Outbound message orchestration — never raises into business callers as hard stop.
|
||||
|
||||
Business modules call this service via API; failures are tracked as message status,
|
||||
so calling systems can continue their transactions independently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.foundation import Message
|
||||
from app.models.types import MessageStatus
|
||||
from app.repositories.foundation import DeliveryEventRepository, MessageRepository
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.contact_service import ContactService
|
||||
from app.services.queue_engine import QueueEngine
|
||||
from app.services.router import CommunicationRouter
|
||||
from app.services.template_service import TemplateService
|
||||
from app.validators import validate_message_send
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
class MessageService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.messages = MessageRepository(session)
|
||||
self.delivery = DeliveryEventRepository(session)
|
||||
self.templates = TemplateService(session)
|
||||
self.contacts = ContactService(session)
|
||||
self.queue = QueueEngine(session)
|
||||
self.router = CommunicationRouter(session)
|
||||
self.audit = AuditService(session)
|
||||
|
||||
async def send(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> list[Message]:
|
||||
validate_message_send(data)
|
||||
|
||||
correlation_id = data.get("correlation_id")
|
||||
existing_by_dest: dict[str, Message] = {}
|
||||
if correlation_id:
|
||||
for row in await self.messages.get_by_correlation(tenant_id, correlation_id):
|
||||
existing_by_dest[row.to_address] = row
|
||||
|
||||
destinations: list[str] = []
|
||||
if data.get("to_address"):
|
||||
destinations.append(data["to_address"])
|
||||
if data.get("contact_source_id") or data.get("batch"):
|
||||
resolved = await self.contacts.resolve(
|
||||
tenant_id,
|
||||
source_id=data.get("contact_source_id"),
|
||||
query=data.get("contact_query"),
|
||||
channel=data["channel"],
|
||||
)
|
||||
destinations.extend(r.address for r in resolved)
|
||||
seen: set[str] = set()
|
||||
unique: list[str] = []
|
||||
for d in destinations:
|
||||
if d not in seen:
|
||||
seen.add(d)
|
||||
unique.append(d)
|
||||
if not unique:
|
||||
raise NotFoundError("No destinations resolved")
|
||||
|
||||
# Full idempotent hit — all destinations already exist for this correlation
|
||||
if correlation_id and unique and all(d in existing_by_dest for d in unique):
|
||||
return [existing_by_dest[d] for d in unique]
|
||||
|
||||
body, subject, template = await self.templates.resolve_body(
|
||||
tenant_id,
|
||||
template_id=data.get("template_id"),
|
||||
template_key=data.get("template_key"),
|
||||
variables=data.get("template_variables"),
|
||||
fallback_body=data.get("body"),
|
||||
fallback_subject=data.get("subject"),
|
||||
)
|
||||
|
||||
batch_id = QueueEngine.new_batch_id() if len(unique) > 1 else None
|
||||
created: list[Message] = []
|
||||
for address in unique:
|
||||
if correlation_id and address in existing_by_dest:
|
||||
created.append(existing_by_dest[address])
|
||||
continue
|
||||
message = Message(
|
||||
tenant_id=tenant_id,
|
||||
channel=data["channel"],
|
||||
status=MessageStatus.QUEUED.value,
|
||||
to_address=address,
|
||||
from_address=data.get("from_address"),
|
||||
subject=subject,
|
||||
body=body,
|
||||
template_id=template.id if template else data.get("template_id"),
|
||||
template_key=template.template_key if template else data.get("template_key"),
|
||||
template_variables=data.get("template_variables"),
|
||||
priority=data.get("priority", "normal"),
|
||||
scheduled_at=data.get("scheduled_at"),
|
||||
correlation_id=correlation_id,
|
||||
source_module=data.get("source_module"),
|
||||
metadata_json=data.get("metadata_json"),
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.messages.add(message)
|
||||
await self.queue.enqueue(message, batch_id=batch_id)
|
||||
await self.audit.log(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="message",
|
||||
entity_id=message.id,
|
||||
action="send",
|
||||
actor_id=actor_id,
|
||||
detail=f"queued to {address}",
|
||||
)
|
||||
created.append(message)
|
||||
|
||||
await self.session.commit()
|
||||
for m in created:
|
||||
await self.session.refresh(m)
|
||||
|
||||
if data.get("process_immediately", True) and not data.get("scheduled_at"):
|
||||
await self.queue.process_due(tenant_id, limit=max(20, len(created)))
|
||||
for m in created:
|
||||
await self.session.refresh(m)
|
||||
return created
|
||||
|
||||
async def get(self, tenant_id: UUID, message_id: UUID) -> Message:
|
||||
entity = await self.messages.get(tenant_id, message_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("Message not found")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, status: str | None = None, offset: int = 0, limit: int = 20
|
||||
) -> list[Message]:
|
||||
if status:
|
||||
return list(
|
||||
await self.messages.list_by_status(
|
||||
tenant_id, status, offset=offset, limit=limit
|
||||
)
|
||||
)
|
||||
return list(await self.messages.list_by_tenant(tenant_id, offset=offset, limit=limit))
|
||||
|
||||
async def timeline(self, tenant_id: UUID, message_id: UUID):
|
||||
await self.get(tenant_id, message_id)
|
||||
return list(await self.delivery.list_for_message(tenant_id, message_id))
|
||||
|
||||
async def cancel(self, tenant_id: UUID, message_id: UUID) -> Message:
|
||||
return await self.router.cancel(tenant_id, message_id)
|
||||
|
||||
async def stats(self, tenant_id: UUID) -> dict[str, int]:
|
||||
return await self.messages.count_by_status(tenant_id)
|
||||
@ -0,0 +1,177 @@
|
||||
"""Health, capability, and monitoring facades."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import __version__
|
||||
from app.core.config import settings
|
||||
from app.core.database import engine
|
||||
from app.models.foundation import ProviderLog
|
||||
from app.providers import list_registered_provider_kinds, supported_channels
|
||||
from app.repositories.foundation import (
|
||||
MessageRepository,
|
||||
ProviderConfigRepository,
|
||||
QueueItemRepository,
|
||||
)
|
||||
from app.services.queue_engine import get_rate_limiter
|
||||
|
||||
|
||||
class CapabilityService:
|
||||
def capabilities(self) -> dict[str, Any]:
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"channels": supported_channels(),
|
||||
"features": [
|
||||
"provider_framework",
|
||||
"communication_router",
|
||||
"sms",
|
||||
"template_engine",
|
||||
"dynamic_contact_sources",
|
||||
"queue_engine",
|
||||
"delivery_tracking",
|
||||
"otp",
|
||||
"webhooks",
|
||||
"failover",
|
||||
"circuit_breaker",
|
||||
"rate_limiting",
|
||||
"idempotency",
|
||||
"tenant_isolation",
|
||||
"audit_logging",
|
||||
"metrics",
|
||||
],
|
||||
"independent": True,
|
||||
"requires_other_modules": False,
|
||||
"registered_provider_kinds": list_registered_provider_kinds(),
|
||||
"contact_source_types": [
|
||||
"manual",
|
||||
"csv",
|
||||
"crm",
|
||||
"loyalty",
|
||||
"sports",
|
||||
"restaurant",
|
||||
"marketplace",
|
||||
"accounting",
|
||||
"website",
|
||||
"external_rest",
|
||||
"future",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class MonitoringService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.messages = MessageRepository(session)
|
||||
self.queue = QueueItemRepository(session)
|
||||
self.providers = ProviderConfigRepository(session)
|
||||
|
||||
async def stats(self, tenant_id: UUID) -> dict[str, Any]:
|
||||
providers = await self.providers.list_all_status(tenant_id)
|
||||
provider_rows = []
|
||||
for p in providers:
|
||||
healthy = p.circuit_state != "open" and p.status in ("active", "degraded")
|
||||
provider_rows.append(
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"provider_kind": p.provider_kind,
|
||||
"channel": p.channel,
|
||||
"status": p.status,
|
||||
"circuit_state": p.circuit_state,
|
||||
"consecutive_failures": p.consecutive_failures,
|
||||
"balance": p.balance,
|
||||
"rate_limit_per_minute": p.rate_limit_per_minute,
|
||||
"last_health_at": p.last_health_at,
|
||||
"last_error": p.last_error,
|
||||
"healthy": healthy,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"messages_by_status": await self.messages.count_by_status(tenant_id),
|
||||
"queue_by_status": await self.queue.count_by_status(tenant_id),
|
||||
"providers": provider_rows,
|
||||
"rate_limit_deferrals": get_rate_limiter().deferrals,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
async def providers_status(self, tenant_id: UUID) -> list[dict[str, Any]]:
|
||||
stats = await self.stats(tenant_id)
|
||||
return stats["providers"]
|
||||
|
||||
async def metrics(self, tenant_id: UUID) -> dict[str, Any]:
|
||||
"""Aggregated observability metrics for tenant."""
|
||||
stats = await self.stats(tenant_id)
|
||||
# SQLite-friendly aggregation
|
||||
stmt = (
|
||||
select(
|
||||
ProviderLog.provider_config_id,
|
||||
ProviderLog.success,
|
||||
ProviderLog.latency_ms,
|
||||
).where(ProviderLog.tenant_id == tenant_id)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
rows = result.all()
|
||||
by_provider: dict[str, dict[str, Any]] = {}
|
||||
for provider_id, success, latency in rows:
|
||||
key = str(provider_id)
|
||||
bucket = by_provider.setdefault(
|
||||
key,
|
||||
{"total": 0, "successes": 0, "failures": 0, "latencies": []},
|
||||
)
|
||||
bucket["total"] += 1
|
||||
if success:
|
||||
bucket["successes"] += 1
|
||||
else:
|
||||
bucket["failures"] += 1
|
||||
if latency is not None:
|
||||
bucket["latencies"].append(latency)
|
||||
|
||||
provider_metrics = []
|
||||
for provider_id, bucket in by_provider.items():
|
||||
latencies = sorted(bucket["latencies"])
|
||||
avg_latency = (
|
||||
sum(latencies) / len(latencies) if latencies else None
|
||||
)
|
||||
p95 = None
|
||||
if latencies:
|
||||
idx = min(len(latencies) - 1, int(len(latencies) * 0.95))
|
||||
p95 = latencies[idx]
|
||||
total = bucket["total"] or 1
|
||||
provider_metrics.append(
|
||||
{
|
||||
"provider_config_id": provider_id,
|
||||
"total": bucket["total"],
|
||||
"successes": bucket["successes"],
|
||||
"failures": bucket["failures"],
|
||||
"failure_rate": round(bucket["failures"] / total, 4),
|
||||
"avg_latency_ms": round(avg_latency, 2) if avg_latency is not None else None,
|
||||
"p95_latency_ms": p95,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"tenant_id": str(tenant_id),
|
||||
"messages_by_status": stats["messages_by_status"],
|
||||
"queue_by_status": stats["queue_by_status"],
|
||||
"rate_limit_deferrals": stats["rate_limit_deferrals"],
|
||||
"providers": stats["providers"],
|
||||
"provider_delivery": provider_metrics,
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def database_ready() -> bool:
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(select(1))
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
184
backend/services/communication/app/services/otp_service.py
Normal file
184
backend/services/communication/app/services/otp_service.py
Normal file
@ -0,0 +1,184 @@
|
||||
"""OTP platform — generation, verification, expiry, rate limit, brute-force protection.
|
||||
|
||||
IMPORTANT: This OTP module is for **business/platform messaging OTP** only.
|
||||
Core Platform auth OTP (`POST /api/v1/auth/otp/*` via Payamak in core-service)
|
||||
remains independent and must NOT be replaced or coupled here (ADR-012).
|
||||
Reserved Core purposes such as `auth.login` / `auth.verify_mobile` must not be
|
||||
used as Communication challenge purposes for platform messaging flows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.models.foundation import OTPChallenge
|
||||
from app.models.types import OTPStatus
|
||||
from app.repositories.foundation import OTPChallengeRepository
|
||||
from app.services.message_service import MessageService
|
||||
from app.validators import validate_otp_request, validate_otp_verify
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
|
||||
_RESERVED_CORE_PURPOSES = frozenset(
|
||||
{"auth.login", "auth.verify_mobile", "core.otp", "identity.otp"}
|
||||
)
|
||||
|
||||
|
||||
def _hash_code(code: str, tenant_id: UUID, destination: str) -> str:
|
||||
raw = f"{tenant_id}:{destination}:{code}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class OTPService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = OTPChallengeRepository(session)
|
||||
self.messages = MessageService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def request(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> tuple[OTPChallenge, str | None]:
|
||||
validate_otp_request(data)
|
||||
purpose = data.get("purpose")
|
||||
if purpose in _RESERVED_CORE_PURPOSES:
|
||||
raise AppError(
|
||||
"Purpose reserved for Core auth OTP — use Core /auth/otp endpoints",
|
||||
status_code=422,
|
||||
error_code="otp_purpose_reserved",
|
||||
)
|
||||
destination = data["destination"]
|
||||
channel = data.get("channel", "sms")
|
||||
since = datetime.now(timezone.utc) - timedelta(minutes=1)
|
||||
recent = await self.repo.count_recent(tenant_id, destination, since)
|
||||
if recent >= settings.otp_rate_limit_per_minute:
|
||||
raise AppError(
|
||||
"OTP rate limit exceeded",
|
||||
status_code=429,
|
||||
error_code="otp_rate_limited",
|
||||
)
|
||||
|
||||
length = int(data.get("code_length") or 6)
|
||||
length = min(8, max(4, length))
|
||||
code = "".join(secrets.choice("0123456789") for _ in range(length))
|
||||
ttl = int(data.get("ttl_seconds") or settings.otp_default_ttl_seconds)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl)
|
||||
|
||||
challenge = OTPChallenge(
|
||||
tenant_id=tenant_id,
|
||||
channel=channel,
|
||||
destination=destination,
|
||||
code_hash=_hash_code(code, tenant_id, destination),
|
||||
status=OTPStatus.PENDING.value,
|
||||
max_attempts=settings.otp_max_attempts,
|
||||
expires_at=expires_at,
|
||||
purpose=purpose,
|
||||
metadata_json={"code_length": length},
|
||||
)
|
||||
await self.repo.add(challenge)
|
||||
|
||||
sent = await self.messages.send(
|
||||
tenant_id,
|
||||
{
|
||||
"channel": channel,
|
||||
"to_address": destination,
|
||||
"body": f"کد تایید شما: {code}",
|
||||
"priority": "high",
|
||||
"source_module": "communication.otp",
|
||||
"correlation_id": f"otp:{challenge.id}",
|
||||
"process_immediately": True,
|
||||
},
|
||||
actor_id=actor_id,
|
||||
)
|
||||
if sent:
|
||||
challenge.message_id = sent[0].id
|
||||
await self.session.commit()
|
||||
await self.session.refresh(challenge)
|
||||
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.OTP_GENERATED,
|
||||
aggregate_type="otp_challenge",
|
||||
aggregate_id=challenge.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"channel": channel, "destination": destination, "purpose": purpose},
|
||||
)
|
||||
|
||||
debug_code = code if settings.environment in ("development", "test") else None
|
||||
return challenge, debug_code
|
||||
|
||||
async def verify(self, tenant_id: UUID, data: dict[str, Any]) -> dict[str, Any]:
|
||||
validate_otp_verify(data)
|
||||
destination = data["destination"]
|
||||
code = str(data["code"])
|
||||
challenge: OTPChallenge | None = None
|
||||
if data.get("challenge_id"):
|
||||
challenge = await self.repo.get(tenant_id, data["challenge_id"])
|
||||
else:
|
||||
challenge = await self.repo.get_latest_pending(tenant_id, destination)
|
||||
|
||||
if challenge is None:
|
||||
raise NotFoundError("OTP challenge not found")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = challenge.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if challenge.status == OTPStatus.LOCKED.value:
|
||||
raise AppError(
|
||||
"OTP locked due to too many attempts",
|
||||
status_code=423,
|
||||
error_code="otp_locked",
|
||||
)
|
||||
if challenge.status != OTPStatus.PENDING.value:
|
||||
raise AppError(
|
||||
f"OTP challenge is {challenge.status}",
|
||||
status_code=422,
|
||||
error_code="otp_invalid_status",
|
||||
)
|
||||
if expires_at <= now:
|
||||
challenge.status = OTPStatus.EXPIRED.value
|
||||
await self.session.commit()
|
||||
raise AppError("OTP expired", status_code=410, error_code="otp_expired")
|
||||
|
||||
challenge.attempts += 1
|
||||
expected = _hash_code(code, tenant_id, destination)
|
||||
if challenge.code_hash != expected:
|
||||
if challenge.attempts >= challenge.max_attempts:
|
||||
challenge.status = OTPStatus.LOCKED.value
|
||||
await self.session.commit()
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.OTP_FAILED,
|
||||
aggregate_type="otp_challenge",
|
||||
aggregate_id=challenge.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"attempts": challenge.attempts, "destination": destination},
|
||||
)
|
||||
raise AppError(
|
||||
"Invalid OTP code",
|
||||
status_code=400,
|
||||
error_code="otp_invalid",
|
||||
details={"attempts": challenge.attempts},
|
||||
)
|
||||
|
||||
challenge.status = OTPStatus.VERIFIED.value
|
||||
challenge.verified_at = now
|
||||
await self.session.commit()
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.OTP_VERIFIED,
|
||||
aggregate_type="otp_challenge",
|
||||
aggregate_id=challenge.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"destination": destination, "purpose": challenge.purpose},
|
||||
)
|
||||
return {
|
||||
"verified": True,
|
||||
"challenge_id": challenge.id,
|
||||
"status": challenge.status,
|
||||
}
|
||||
206
backend/services/communication/app/services/provider_service.py
Normal file
206
backend/services/communication/app/services/provider_service.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Provider registry & lifecycle services."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.models.foundation import ProviderConfig, SenderNumber
|
||||
from app.models.types import ProviderLifecycleStatus
|
||||
from app.providers import get_provider
|
||||
from app.repositories.foundation import ProviderConfigRepository, SenderNumberRepository
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import validate_provider_payload, validate_sender_number
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
class ProviderService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = ProviderConfigRepository(session)
|
||||
self.senders = SenderNumberRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> ProviderConfig:
|
||||
validate_provider_payload(data)
|
||||
entity = ProviderConfig(
|
||||
tenant_id=tenant_id,
|
||||
name=data["name"],
|
||||
provider_kind=data["provider_kind"],
|
||||
channel=data["channel"],
|
||||
priority=data.get("priority", 100),
|
||||
credentials=data.get("credentials"),
|
||||
settings=data.get("settings"),
|
||||
balance=data.get("balance"),
|
||||
rate_limit_per_minute=data.get("rate_limit_per_minute"),
|
||||
max_retries=data.get("max_retries", 3),
|
||||
is_default=data.get("is_default", False),
|
||||
status=ProviderLifecycleStatus.ACTIVE.value,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.audit.log(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="provider_config",
|
||||
entity_id=entity.id,
|
||||
action="create",
|
||||
actor_id=actor_id,
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
provider_id: UUID,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
actor_id: str | None = None,
|
||||
) -> ProviderConfig:
|
||||
entity = await self.repo.get(tenant_id, provider_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("Provider not found")
|
||||
for key in (
|
||||
"name",
|
||||
"priority",
|
||||
"credentials",
|
||||
"settings",
|
||||
"balance",
|
||||
"rate_limit_per_minute",
|
||||
"max_retries",
|
||||
"status",
|
||||
"is_default",
|
||||
):
|
||||
if key in data and data[key] is not None:
|
||||
setattr(entity, key, data[key])
|
||||
entity.updated_by = actor_id
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID) -> list[ProviderConfig]:
|
||||
return list(await self.repo.list_all_status(tenant_id))
|
||||
|
||||
async def get(self, tenant_id: UUID, provider_id: UUID) -> ProviderConfig:
|
||||
entity = await self.repo.get(tenant_id, provider_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("Provider not found")
|
||||
return entity
|
||||
|
||||
async def delete(
|
||||
self, tenant_id: UUID, provider_id: UUID, *, actor_id: str | None = None
|
||||
) -> None:
|
||||
entity = await self.get(tenant_id, provider_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id)
|
||||
await self.session.commit()
|
||||
|
||||
async def get_balance(self, tenant_id: UUID, provider_id: UUID) -> Decimal | None:
|
||||
entity = await self.get(tenant_id, provider_id)
|
||||
adapter = get_provider(entity.provider_kind)
|
||||
result = await adapter.get_balance(entity.credentials or {})
|
||||
if result.balance is not None:
|
||||
entity.balance = Decimal(str(result.balance))
|
||||
await self.session.commit()
|
||||
return entity.balance
|
||||
|
||||
async def health_probe(self, tenant_id: UUID, provider_id: UUID) -> dict[str, Any]:
|
||||
entity = await self.get(tenant_id, provider_id)
|
||||
adapter = get_provider(entity.provider_kind)
|
||||
health = await adapter.health_check(entity.credentials or {})
|
||||
entity.last_health_at = datetime.now(timezone.utc)
|
||||
if not health.healthy:
|
||||
entity.status = ProviderLifecycleStatus.DEGRADED.value
|
||||
entity.last_error = health.detail
|
||||
elif entity.circuit_state != "open":
|
||||
entity.status = ProviderLifecycleStatus.ACTIVE.value
|
||||
entity.last_error = None
|
||||
await self.session.commit()
|
||||
return {
|
||||
"healthy": health.healthy,
|
||||
"detail": health.detail,
|
||||
"status": entity.status,
|
||||
"circuit_state": entity.circuit_state,
|
||||
}
|
||||
|
||||
async def record_success(self, entity: ProviderConfig) -> None:
|
||||
entity.consecutive_failures = 0
|
||||
if entity.circuit_state != "closed":
|
||||
entity.circuit_state = "closed"
|
||||
entity.circuit_opened_at = None
|
||||
entity.status = ProviderLifecycleStatus.ACTIVE.value
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.PROVIDER_CIRCUIT_CLOSED,
|
||||
aggregate_type="provider_config",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=entity.tenant_id,
|
||||
)
|
||||
entity.last_health_at = datetime.now(timezone.utc)
|
||||
await self.session.flush()
|
||||
|
||||
async def record_failure(self, entity: ProviderConfig, error: str) -> None:
|
||||
entity.consecutive_failures += 1
|
||||
entity.last_error = error
|
||||
entity.last_health_at = datetime.now(timezone.utc)
|
||||
threshold = settings.circuit_breaker_failure_threshold
|
||||
if entity.consecutive_failures >= threshold:
|
||||
entity.circuit_state = "open"
|
||||
entity.circuit_opened_at = datetime.now(timezone.utc)
|
||||
entity.status = ProviderLifecycleStatus.CIRCUIT_OPEN.value
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.PROVIDER_CIRCUIT_OPENED,
|
||||
aggregate_type="provider_config",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=entity.tenant_id,
|
||||
payload={"error": error},
|
||||
)
|
||||
else:
|
||||
entity.status = ProviderLifecycleStatus.DEGRADED.value
|
||||
await self.session.flush()
|
||||
|
||||
def is_circuit_allowing(self, entity: ProviderConfig) -> bool:
|
||||
if entity.circuit_state != "open":
|
||||
return True
|
||||
opened = entity.circuit_opened_at
|
||||
if opened is None:
|
||||
return False
|
||||
if opened.tzinfo is None:
|
||||
opened = opened.replace(tzinfo=timezone.utc)
|
||||
elapsed = (datetime.now(timezone.utc) - opened).total_seconds()
|
||||
if elapsed >= settings.circuit_breaker_reset_seconds:
|
||||
entity.circuit_state = "half_open"
|
||||
return True
|
||||
return False
|
||||
|
||||
async def create_sender(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> SenderNumber:
|
||||
validate_sender_number(data)
|
||||
entity = SenderNumber(
|
||||
tenant_id=tenant_id,
|
||||
channel=data["channel"],
|
||||
value=data["value"],
|
||||
label=data.get("label"),
|
||||
provider_config_id=data.get("provider_config_id"),
|
||||
is_default=data.get("is_default", False),
|
||||
is_active=data.get("is_active", True),
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.senders.add(entity)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_senders(self, tenant_id: UUID) -> list[SenderNumber]:
|
||||
return list(await self.senders.list_by_tenant(tenant_id))
|
||||
190
backend/services/communication/app/services/queue_engine.py
Normal file
190
backend/services/communication/app/services/queue_engine.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""Async queue engine — scheduling, priority, retry, DLQ, batch, rate limiting."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.models.foundation import Message, QueueItem
|
||||
from app.models.types import MessageStatus, QueueItemStatus
|
||||
from app.repositories.foundation import (
|
||||
MessageRepository,
|
||||
ProviderConfigRepository,
|
||||
QueueItemRepository,
|
||||
)
|
||||
from app.services.router import CommunicationRouter
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple in-process sliding window rate limiter (per process)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._hits: dict[str, list[datetime]] = {}
|
||||
self.deferrals: int = 0
|
||||
|
||||
def allow(self, key: str, limit_per_minute: int | None) -> bool:
|
||||
if not limit_per_minute:
|
||||
return True
|
||||
now = datetime.now(timezone.utc)
|
||||
window_start = now - timedelta(minutes=1)
|
||||
hits = [t for t in self._hits.get(key, []) if t >= window_start]
|
||||
if len(hits) >= limit_per_minute:
|
||||
self._hits[key] = hits
|
||||
self.deferrals += 1
|
||||
return False
|
||||
hits.append(now)
|
||||
self._hits[key] = hits
|
||||
return True
|
||||
|
||||
def reset_stats(self) -> None:
|
||||
self.deferrals = 0
|
||||
self._hits.clear()
|
||||
|
||||
|
||||
_rate_limiter = RateLimiter()
|
||||
|
||||
|
||||
def get_rate_limiter() -> RateLimiter:
|
||||
return _rate_limiter
|
||||
|
||||
|
||||
class QueueEngine:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.queue = QueueItemRepository(session)
|
||||
self.messages = MessageRepository(session)
|
||||
self.providers = ProviderConfigRepository(session)
|
||||
self.router = CommunicationRouter(session)
|
||||
self.events = get_event_publisher()
|
||||
self.rate_limiter = _rate_limiter
|
||||
|
||||
async def enqueue(
|
||||
self,
|
||||
message: Message,
|
||||
*,
|
||||
max_attempts: int | None = None,
|
||||
batch_id: str | None = None,
|
||||
) -> QueueItem:
|
||||
available_at = message.scheduled_at or datetime.now(timezone.utc)
|
||||
status = (
|
||||
QueueItemStatus.SCHEDULED.value
|
||||
if message.scheduled_at and message.scheduled_at > datetime.now(timezone.utc)
|
||||
else QueueItemStatus.PENDING.value
|
||||
)
|
||||
item = QueueItem(
|
||||
tenant_id=message.tenant_id,
|
||||
message_id=message.id,
|
||||
status=status,
|
||||
priority=message.priority,
|
||||
max_attempts=max_attempts or settings.default_max_retries,
|
||||
available_at=available_at,
|
||||
batch_id=batch_id,
|
||||
)
|
||||
await self.queue.enqueue(item)
|
||||
message.status = MessageStatus.QUEUED.value
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.MESSAGE_QUEUED,
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
tenant_id=message.tenant_id,
|
||||
payload={
|
||||
"queue_item_id": str(item.id),
|
||||
"channel": message.channel,
|
||||
"to_address": message.to_address,
|
||||
"correlation_id": message.correlation_id,
|
||||
"priority": message.priority,
|
||||
},
|
||||
)
|
||||
await self.session.flush()
|
||||
return item
|
||||
|
||||
async def _channel_rate_limit(
|
||||
self, tenant_id: UUID, channel: str
|
||||
) -> tuple[bool, int | None]:
|
||||
"""Return (allowed, strictest_limit) using active provider rate limits."""
|
||||
providers = await self.providers.list_by_channel(
|
||||
tenant_id, channel, active_only=True
|
||||
)
|
||||
if not providers:
|
||||
return True, None
|
||||
# Use the strictest (lowest) positive rate limit among eligible providers
|
||||
limits = [p.rate_limit_per_minute for p in providers if p.rate_limit_per_minute]
|
||||
if not limits:
|
||||
return True, None
|
||||
limit = min(limits)
|
||||
key = f"{tenant_id}:{channel}:send"
|
||||
return self.rate_limiter.allow(key, limit), limit
|
||||
|
||||
async def process_due(self, tenant_id: UUID | None = None, *, limit: int = 20) -> int:
|
||||
items = await self.queue.claim_next(tenant_id, limit=limit, increment_attempt=False)
|
||||
processed = 0
|
||||
for item in items:
|
||||
message = await self.messages.get(item.tenant_id, item.message_id)
|
||||
if message is None:
|
||||
item.status = QueueItemStatus.FAILED.value
|
||||
item.last_error = "message_missing"
|
||||
continue
|
||||
if message.status == MessageStatus.CANCELLED.value:
|
||||
item.status = QueueItemStatus.CANCELLED.value
|
||||
continue
|
||||
|
||||
allowed, _limit = await self._channel_rate_limit(
|
||||
item.tenant_id, message.channel
|
||||
)
|
||||
if not allowed:
|
||||
# Defer without burning attempt
|
||||
item.status = QueueItemStatus.PENDING.value
|
||||
item.locked_at = None
|
||||
item.available_at = datetime.now(timezone.utc) + timedelta(seconds=5)
|
||||
item.last_error = "rate_limited"
|
||||
continue
|
||||
|
||||
item.attempt += 1
|
||||
await self.session.flush()
|
||||
await self.router.deliver(message)
|
||||
if message.status in (
|
||||
MessageStatus.SENT.value,
|
||||
MessageStatus.DELIVERED.value,
|
||||
):
|
||||
item.status = QueueItemStatus.COMPLETED.value
|
||||
item.completed_at = datetime.now(timezone.utc)
|
||||
elif item.attempt >= item.max_attempts:
|
||||
item.status = QueueItemStatus.DEAD_LETTER.value
|
||||
item.is_dead_letter = True
|
||||
item.last_error = message.error_message
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.QUEUE_DEAD_LETTER,
|
||||
aggregate_type="queue_item",
|
||||
aggregate_id=item.id,
|
||||
tenant_id=item.tenant_id,
|
||||
payload={
|
||||
"message_id": str(message.id),
|
||||
"channel": message.channel,
|
||||
"correlation_id": message.correlation_id,
|
||||
"attempts": item.attempt,
|
||||
"error": message.error_message,
|
||||
},
|
||||
)
|
||||
else:
|
||||
delay = 0 if settings.environment == "test" else min(300, 2**item.attempt)
|
||||
item.status = QueueItemStatus.PENDING.value
|
||||
item.available_at = datetime.now(timezone.utc) + timedelta(seconds=delay)
|
||||
item.last_error = message.error_message
|
||||
message.status = MessageStatus.QUEUED.value
|
||||
processed += 1
|
||||
await self.session.commit()
|
||||
return processed
|
||||
|
||||
async def list_dead_letters(self, tenant_id: UUID) -> list[QueueItem]:
|
||||
return list(await self.queue.list_dead_letters(tenant_id))
|
||||
|
||||
async def stats(self, tenant_id: UUID) -> dict[str, int]:
|
||||
return await self.queue.count_by_status(tenant_id)
|
||||
|
||||
@staticmethod
|
||||
def new_batch_id() -> str:
|
||||
return f"batch-{uuid4()}"
|
||||
229
backend/services/communication/app/services/router.py
Normal file
229
backend/services/communication/app/services/router.py
Normal file
@ -0,0 +1,229 @@
|
||||
"""Communication router — channel routing, provider priority, failover, retry."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.models.foundation import DeliveryEvent, Message, ProviderConfig, ProviderLog
|
||||
from app.models.types import MessageStatus
|
||||
from app.providers import get_provider
|
||||
from app.providers.contracts import SendRequest
|
||||
from app.repositories.foundation import (
|
||||
DeliveryEventRepository,
|
||||
MessageRepository,
|
||||
ProviderLogRepository,
|
||||
SenderNumberRepository,
|
||||
)
|
||||
from app.services.provider_service import ProviderService
|
||||
from shared.exceptions import AppError
|
||||
|
||||
|
||||
class CommunicationRouter:
|
||||
"""Routes outbound messages through tenant providers with automatic failover."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.messages = MessageRepository(session)
|
||||
self.delivery = DeliveryEventRepository(session)
|
||||
self.provider_logs = ProviderLogRepository(session)
|
||||
self.senders = SenderNumberRepository(session)
|
||||
self.providers = ProviderService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def deliver(self, message: Message) -> Message:
|
||||
providers = await self.providers.repo.list_by_channel(
|
||||
message.tenant_id, message.channel, active_only=True
|
||||
)
|
||||
if not providers:
|
||||
await self._fail(message, "no_provider", "No active providers for channel")
|
||||
return message
|
||||
|
||||
last_error = "all_providers_failed"
|
||||
for index, provider in enumerate(providers):
|
||||
if not self.providers.is_circuit_allowing(provider):
|
||||
continue
|
||||
if index > 0:
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.PROVIDER_FAILOVER,
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
tenant_id=message.tenant_id,
|
||||
payload={
|
||||
"from_provider": str(providers[index - 1].id),
|
||||
"to_provider": str(provider.id),
|
||||
},
|
||||
)
|
||||
success = await self._try_provider(message, provider)
|
||||
if success:
|
||||
return message
|
||||
last_error = message.error_message or last_error
|
||||
|
||||
await self._fail(message, "all_providers_failed", last_error)
|
||||
return message
|
||||
|
||||
async def _try_provider(self, message: Message, provider: ProviderConfig) -> bool:
|
||||
adapter = get_provider(provider.provider_kind)
|
||||
from_address = message.from_address
|
||||
if not from_address:
|
||||
sender = await self.senders.get_default(message.tenant_id, message.channel)
|
||||
if sender:
|
||||
from_address = sender.value
|
||||
message.from_address = from_address
|
||||
|
||||
request = SendRequest(
|
||||
tenant_id=message.tenant_id,
|
||||
channel=message.channel,
|
||||
to_address=message.to_address,
|
||||
body=message.body,
|
||||
from_address=from_address,
|
||||
subject=message.subject,
|
||||
metadata=message.metadata_json or {},
|
||||
)
|
||||
result = await adapter.send(request, provider.credentials or {})
|
||||
log = ProviderLog(
|
||||
tenant_id=message.tenant_id,
|
||||
provider_config_id=provider.id,
|
||||
message_id=message.id,
|
||||
request_payload={
|
||||
"to": message.to_address,
|
||||
"channel": message.channel,
|
||||
},
|
||||
response_payload=result.raw_response,
|
||||
success=result.success,
|
||||
latency_ms=result.latency_ms,
|
||||
error_message=result.error_message,
|
||||
)
|
||||
await self.provider_logs.add(log)
|
||||
|
||||
if result.success:
|
||||
message.status = MessageStatus.SENT.value
|
||||
message.provider_config_id = provider.id
|
||||
message.provider_message_id = result.provider_message_id
|
||||
message.sent_at = datetime.now(timezone.utc)
|
||||
message.error_code = None
|
||||
message.error_message = None
|
||||
await self.providers.record_success(provider)
|
||||
await self._timeline(
|
||||
message,
|
||||
MessageStatus.SENT.value,
|
||||
provider_config_id=provider.id,
|
||||
detail="sent via provider",
|
||||
)
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.MESSAGE_SENT,
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
tenant_id=message.tenant_id,
|
||||
payload={
|
||||
"provider_id": str(provider.id),
|
||||
"provider_message_id": result.provider_message_id,
|
||||
"channel": message.channel,
|
||||
"to_address": message.to_address,
|
||||
"correlation_id": message.correlation_id,
|
||||
},
|
||||
)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
message.retry_count += 1
|
||||
message.error_code = result.error_code
|
||||
message.error_message = result.error_message
|
||||
await self.providers.record_failure(provider, result.error_message or "send_failed")
|
||||
await self._timeline(
|
||||
message,
|
||||
MessageStatus.FAILED.value,
|
||||
provider_config_id=provider.id,
|
||||
detail=result.error_message,
|
||||
payload={"error_code": result.error_code},
|
||||
)
|
||||
await self.session.flush()
|
||||
return False
|
||||
|
||||
async def _fail(self, message: Message, code: str, detail: str | None) -> None:
|
||||
message.status = MessageStatus.FAILED.value
|
||||
message.failed_at = datetime.now(timezone.utc)
|
||||
message.error_code = code
|
||||
message.error_message = detail
|
||||
await self._timeline(message, MessageStatus.FAILED.value, detail=detail)
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.MESSAGE_FAILED,
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
tenant_id=message.tenant_id,
|
||||
payload={"error_code": code, "detail": detail},
|
||||
)
|
||||
await self.session.flush()
|
||||
|
||||
async def _timeline(
|
||||
self,
|
||||
message: Message,
|
||||
status: str,
|
||||
*,
|
||||
provider_config_id: UUID | None = None,
|
||||
detail: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
event = DeliveryEvent(
|
||||
tenant_id=message.tenant_id,
|
||||
message_id=message.id,
|
||||
status=status,
|
||||
provider_config_id=provider_config_id,
|
||||
detail=detail,
|
||||
payload=payload,
|
||||
)
|
||||
await self.delivery.add(event)
|
||||
|
||||
async def mark_delivered(
|
||||
self, tenant_id: UUID, message_id: UUID, *, detail: str | None = None
|
||||
) -> Message:
|
||||
message = await self.messages.get(tenant_id, message_id)
|
||||
if message is None:
|
||||
raise AppError("Message not found", status_code=404, error_code="not_found")
|
||||
message.status = MessageStatus.DELIVERED.value
|
||||
message.delivered_at = datetime.now(timezone.utc)
|
||||
await self._timeline(message, MessageStatus.DELIVERED.value, detail=detail)
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.MESSAGE_DELIVERED,
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={
|
||||
"channel": message.channel,
|
||||
"to_address": message.to_address,
|
||||
"correlation_id": message.correlation_id,
|
||||
"provider_message_id": message.provider_message_id,
|
||||
},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(message)
|
||||
return message
|
||||
|
||||
async def cancel(self, tenant_id: UUID, message_id: UUID) -> Message:
|
||||
message = await self.messages.get(tenant_id, message_id)
|
||||
if message is None:
|
||||
raise AppError("Message not found", status_code=404, error_code="not_found")
|
||||
if message.status not in (
|
||||
MessageStatus.QUEUED.value,
|
||||
MessageStatus.SENT.value,
|
||||
):
|
||||
raise AppError(
|
||||
f"Cannot cancel message in status {message.status}",
|
||||
status_code=422,
|
||||
error_code="invalid_status",
|
||||
)
|
||||
message.status = MessageStatus.CANCELLED.value
|
||||
await self._timeline(message, MessageStatus.CANCELLED.value)
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.MESSAGE_CANCELLED,
|
||||
aggregate_type="message",
|
||||
aggregate_id=message.id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(message)
|
||||
return message
|
||||
171
backend/services/communication/app/services/template_service.py
Normal file
171
backend/services/communication/app/services/template_service.py
Normal file
@ -0,0 +1,171 @@
|
||||
"""Template engine — versioning, approval, localization, preview, rendering."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.models.foundation import MessageTemplate
|
||||
from app.models.types import TemplateStatus
|
||||
from app.repositories.foundation import MessageTemplateRepository
|
||||
from app.services.audit_service import AuditService
|
||||
from app.validators import (
|
||||
extract_template_variables,
|
||||
render_template,
|
||||
validate_template,
|
||||
validate_template_status_transition,
|
||||
)
|
||||
from shared.exceptions import NotFoundError
|
||||
|
||||
|
||||
class TemplateService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = MessageTemplateRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, data: dict[str, Any], *, actor_id: str | None = None
|
||||
) -> MessageTemplate:
|
||||
validate_template(data)
|
||||
variables = data.get("variables") or extract_template_variables(data["body"])
|
||||
existing = await self.repo.list_versions(
|
||||
tenant_id, data["template_key"], data.get("locale", "fa")
|
||||
)
|
||||
version = (existing[0].version + 1) if existing else 1
|
||||
entity = MessageTemplate(
|
||||
tenant_id=tenant_id,
|
||||
template_key=data["template_key"],
|
||||
name=data["name"],
|
||||
channel=data["channel"],
|
||||
locale=data.get("locale", "fa"),
|
||||
version=version,
|
||||
status=TemplateStatus.DRAFT.value,
|
||||
body=data["body"],
|
||||
subject=data.get("subject"),
|
||||
variables=variables,
|
||||
created_by=actor_id,
|
||||
updated_by=actor_id,
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.audit.log(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="message_template",
|
||||
entity_id=entity.id,
|
||||
action="create",
|
||||
actor_id=actor_id,
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, template_id: UUID) -> MessageTemplate:
|
||||
entity = await self.repo.get(tenant_id, template_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("Template not found")
|
||||
return entity
|
||||
|
||||
async def list(self, tenant_id: UUID) -> list[MessageTemplate]:
|
||||
return list(await self.repo.list_by_tenant(tenant_id, limit=200))
|
||||
|
||||
async def submit_for_approval(
|
||||
self, tenant_id: UUID, template_id: UUID, *, actor_id: str | None = None
|
||||
) -> MessageTemplate:
|
||||
entity = await self.get(tenant_id, template_id)
|
||||
validate_template_status_transition(
|
||||
entity.status, TemplateStatus.PENDING_APPROVAL.value
|
||||
)
|
||||
entity.status = TemplateStatus.PENDING_APPROVAL.value
|
||||
entity.updated_by = actor_id
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def approve(
|
||||
self, tenant_id: UUID, template_id: UUID, *, actor_id: str | None = None
|
||||
) -> MessageTemplate:
|
||||
entity = await self.get(tenant_id, template_id)
|
||||
validate_template_status_transition(entity.status, TemplateStatus.APPROVED.value)
|
||||
entity.status = TemplateStatus.APPROVED.value
|
||||
entity.approved_by = actor_id
|
||||
entity.approved_at = datetime.now(timezone.utc)
|
||||
entity.updated_by = actor_id
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.TEMPLATE_APPROVED,
|
||||
aggregate_type="message_template",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"template_key": entity.template_key, "version": entity.version},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def reject(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
template_id: UUID,
|
||||
*,
|
||||
reason: str | None = None,
|
||||
actor_id: str | None = None,
|
||||
) -> MessageTemplate:
|
||||
entity = await self.get(tenant_id, template_id)
|
||||
validate_template_status_transition(entity.status, TemplateStatus.REJECTED.value)
|
||||
entity.status = TemplateStatus.REJECTED.value
|
||||
entity.rejection_reason = reason
|
||||
entity.updated_by = actor_id
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.TEMPLATE_REJECTED,
|
||||
aggregate_type="message_template",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"reason": reason},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def preview(
|
||||
self, tenant_id: UUID, template_id: UUID, variables: dict[str, Any]
|
||||
) -> dict[str, str | None]:
|
||||
entity = await self.get(tenant_id, template_id)
|
||||
rendered = render_template(entity.body, variables)
|
||||
subject = (
|
||||
render_template(entity.subject, variables) if entity.subject else None
|
||||
)
|
||||
return {"rendered": rendered, "subject": subject}
|
||||
|
||||
async def resolve_body(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
template_id: UUID | None = None,
|
||||
template_key: str | None = None,
|
||||
locale: str = "fa",
|
||||
variables: dict[str, Any] | None = None,
|
||||
fallback_body: str | None = None,
|
||||
fallback_subject: str | None = None,
|
||||
) -> tuple[str, str | None, MessageTemplate | None]:
|
||||
template: MessageTemplate | None = None
|
||||
if template_id:
|
||||
template = await self.get(tenant_id, template_id)
|
||||
elif template_key:
|
||||
template = await self.repo.get_by_key(
|
||||
tenant_id, template_key, locale=locale, approved_only=True
|
||||
)
|
||||
if template is None:
|
||||
raise NotFoundError(f"Approved template not found: {template_key}")
|
||||
if template:
|
||||
body = render_template(template.body, variables)
|
||||
subject = (
|
||||
render_template(template.subject, variables) if template.subject else None
|
||||
)
|
||||
return body, subject, template
|
||||
if not fallback_body:
|
||||
raise NotFoundError("No template or body provided")
|
||||
return fallback_body, fallback_subject, None
|
||||
160
backend/services/communication/app/services/webhook_service.py
Normal file
160
backend/services/communication/app/services/webhook_service.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""Incoming provider webhooks — delivery updates and status callbacks."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.models.foundation import Message, WebhookReceipt
|
||||
from app.models.types import MessageStatus
|
||||
from app.repositories.foundation import MessageRepository, WebhookReceiptRepository
|
||||
from app.services.router import CommunicationRouter
|
||||
from app.validators import validate_webhook
|
||||
from shared.exceptions import AppError
|
||||
|
||||
|
||||
class WebhookService:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
self.repo = WebhookReceiptRepository(session)
|
||||
self.messages = MessageRepository(session)
|
||||
self.router = CommunicationRouter(session)
|
||||
self.events = get_event_publisher()
|
||||
|
||||
def verify_signature(
|
||||
self,
|
||||
*,
|
||||
payload: dict[str, Any] | None,
|
||||
signature: str | None,
|
||||
secret: str | None,
|
||||
raw_body: str | None = None,
|
||||
) -> bool:
|
||||
if not secret:
|
||||
# Dev/test only — production should configure webhook secrets
|
||||
return settings.environment in ("development", "test")
|
||||
if not signature:
|
||||
return False
|
||||
body = raw_body if raw_body is not None else json.dumps(
|
||||
payload or {}, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
digest = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(digest, signature)
|
||||
|
||||
async def receive(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
webhook_secret: str | None = None,
|
||||
raw_body: str | None = None,
|
||||
) -> WebhookReceipt:
|
||||
validate_webhook(data)
|
||||
external_id = data.get("external_id")
|
||||
if external_id:
|
||||
existing = await self._find_by_external(
|
||||
tenant_id, data["provider_kind"], external_id
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
signature_valid = self.verify_signature(
|
||||
payload=data.get("payload"),
|
||||
signature=data.get("signature"),
|
||||
secret=webhook_secret,
|
||||
raw_body=raw_body,
|
||||
)
|
||||
if webhook_secret and not signature_valid:
|
||||
raise AppError(
|
||||
"Invalid webhook signature",
|
||||
status_code=401,
|
||||
error_code="webhook_signature_invalid",
|
||||
)
|
||||
|
||||
receipt = WebhookReceipt(
|
||||
tenant_id=tenant_id,
|
||||
provider_kind=data["provider_kind"],
|
||||
event_type=data["event_type"],
|
||||
external_id=external_id,
|
||||
message_id=data.get("message_id"),
|
||||
payload=data.get("payload"),
|
||||
signature_valid=signature_valid,
|
||||
processed=False,
|
||||
)
|
||||
await self.repo.add(receipt)
|
||||
self.events.publish(
|
||||
event_type=CommunicationEventType.WEBHOOK_RECEIVED,
|
||||
aggregate_type="webhook_receipt",
|
||||
aggregate_id=receipt.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={
|
||||
"event_type": data["event_type"],
|
||||
"provider_kind": data["provider_kind"],
|
||||
"external_id": external_id,
|
||||
},
|
||||
)
|
||||
|
||||
if signature_valid:
|
||||
try:
|
||||
await self._apply(tenant_id, receipt, data)
|
||||
receipt.processed = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
receipt.processing_error = str(exc)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(receipt)
|
||||
return receipt
|
||||
|
||||
async def _find_by_external(
|
||||
self, tenant_id: UUID, provider_kind: str, external_id: str
|
||||
) -> WebhookReceipt | None:
|
||||
stmt = select(WebhookReceipt).where(
|
||||
WebhookReceipt.tenant_id == tenant_id,
|
||||
WebhookReceipt.provider_kind == provider_kind,
|
||||
WebhookReceipt.external_id == external_id,
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _apply(
|
||||
self, tenant_id: UUID, receipt: WebhookReceipt, data: dict[str, Any]
|
||||
) -> None:
|
||||
message_id = data.get("message_id")
|
||||
payload = data.get("payload") or {}
|
||||
if not message_id and payload.get("provider_message_id"):
|
||||
stmt = select(Message).where(
|
||||
Message.tenant_id == tenant_id,
|
||||
Message.provider_message_id == payload["provider_message_id"],
|
||||
Message.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
message = result.scalar_one_or_none()
|
||||
if message:
|
||||
message_id = message.id
|
||||
receipt.message_id = message.id
|
||||
|
||||
if not message_id:
|
||||
return
|
||||
|
||||
event_type = data["event_type"].lower()
|
||||
if event_type in ("delivered", "delivery", "message.delivered"):
|
||||
await self.router.mark_delivered(
|
||||
tenant_id, message_id, detail="webhook delivery"
|
||||
)
|
||||
elif event_type in ("failed", "failure", "message.failed"):
|
||||
message = await self.messages.get(tenant_id, message_id)
|
||||
if message:
|
||||
message.status = MessageStatus.FAILED.value
|
||||
message.error_message = payload.get("error") or "webhook_failed"
|
||||
await self.session.flush()
|
||||
elif event_type in ("expired", "message.expired"):
|
||||
message = await self.messages.get(tenant_id, message_id)
|
||||
if message:
|
||||
message.status = MessageStatus.EXPIRED.value
|
||||
await self.session.flush()
|
||||
54
backend/services/communication/app/tests/conftest.py
Normal file
54
backend/services/communication/app/tests/conftest.py
Normal file
@ -0,0 +1,54 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
os.environ["ENVIRONMENT"] = "test"
|
||||
os.environ["AUTH_REQUIRED"] = "false"
|
||||
os.environ["COMMUNICATION_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
|
||||
os.environ["COMMUNICATION_DATABASE_URL_SYNC"] = "sqlite:///:memory:"
|
||||
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
|
||||
|
||||
from app.core.config import get_settings # noqa: E402
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.core.database import Base, engine # noqa: E402
|
||||
from app.events.publisher import reset_event_publisher # noqa: E402
|
||||
from app.main import app # noqa: E402
|
||||
from app.providers import reset_provider_registry_for_tests # noqa: E402
|
||||
from app.services.queue_engine import get_rate_limiter # noqa: E402
|
||||
|
||||
TENANT_A = uuid.uuid4()
|
||||
TENANT_B = uuid.uuid4()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="session")
|
||||
async def db_setup():
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.drop_all)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
def _reset_events_and_providers():
|
||||
reset_event_publisher()
|
||||
reset_provider_registry_for_tests()
|
||||
get_rate_limiter().reset_stats()
|
||||
yield
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_setup):
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
|
||||
yield ac
|
||||
|
||||
|
||||
def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]:
|
||||
return {"X-Tenant-ID": str(tenant_id)}
|
||||
157
backend/services/communication/app/tests/test_architecture.py
Normal file
157
backend/services/communication/app/tests/test_architecture.py
Normal file
@ -0,0 +1,157 @@
|
||||
"""Architecture / boundary / documentation validation tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from app.models.foundation import (
|
||||
ContactSource,
|
||||
ManualContact,
|
||||
Message,
|
||||
MessageTemplate,
|
||||
OTPChallenge,
|
||||
ProviderConfig,
|
||||
QueueItem,
|
||||
SenderNumber,
|
||||
)
|
||||
|
||||
|
||||
FORBIDDEN_IMPORT_PREFIXES = (
|
||||
"backend.services.crm",
|
||||
"backend.services.accounting",
|
||||
"backend.services.loyalty",
|
||||
"backend.services.restaurant",
|
||||
"backend.services.marketplace",
|
||||
"backend.core_service",
|
||||
"app.services.crm",
|
||||
"app.models.crm",
|
||||
)
|
||||
|
||||
|
||||
def test_all_models_have_tenant_id():
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
skip = {"alembic_version"}
|
||||
for table in Base.metadata.tables.values():
|
||||
if table.name in skip:
|
||||
continue
|
||||
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
|
||||
|
||||
|
||||
def test_core_aggregates_independent():
|
||||
for model in (
|
||||
ProviderConfig,
|
||||
SenderNumber,
|
||||
MessageTemplate,
|
||||
ManualContact,
|
||||
ContactSource,
|
||||
Message,
|
||||
QueueItem,
|
||||
OTPChallenge,
|
||||
):
|
||||
assert hasattr(model, "tenant_id")
|
||||
assert hasattr(model, "id")
|
||||
assert not model.__mapper__.relationships
|
||||
|
||||
|
||||
def test_permissions_defined():
|
||||
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
||||
|
||||
assert "communication.view" in ALL_PERMISSIONS
|
||||
assert "communication.providers.manage" in ALL_PERMISSIONS
|
||||
assert "communication.templates.approve" in ALL_PERMISSIONS
|
||||
assert "communication.messages.send" in ALL_PERMISSIONS
|
||||
assert "communication.otp.request" in ALL_PERMISSIONS
|
||||
assert "communication.queue.manage" in ALL_PERMISSIONS
|
||||
for prefix in PERMISSION_PREFIXES:
|
||||
assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS)
|
||||
|
||||
|
||||
def test_events_defined():
|
||||
from app.events.types import CommunicationEventType
|
||||
|
||||
assert CommunicationEventType.MESSAGE_SENT.value == "communication.message.sent"
|
||||
assert CommunicationEventType.MESSAGE_DELIVERED.value == "communication.message.delivered"
|
||||
assert CommunicationEventType.PROVIDER_FAILOVER.value == "communication.provider.failover"
|
||||
assert CommunicationEventType.OTP_VERIFIED.value == "communication.otp.verified"
|
||||
assert CommunicationEventType.QUEUE_DEAD_LETTER.value == "communication.queue.dead_letter"
|
||||
|
||||
|
||||
def test_sports_contact_source_type():
|
||||
from app.models.types import ContactSourceType
|
||||
|
||||
assert ContactSourceType.SPORTS.value == "sports"
|
||||
|
||||
|
||||
def test_permission_enforcement_module_exists():
|
||||
from app.api.permissions import require_permissions, user_has_permission
|
||||
|
||||
assert callable(require_permissions)
|
||||
assert callable(user_has_permission)
|
||||
|
||||
|
||||
def test_provider_framework_registered():
|
||||
from app.providers import list_registered_provider_kinds, supported_channels
|
||||
|
||||
kinds = list_registered_provider_kinds()
|
||||
assert "mock" in kinds
|
||||
assert "payamak" in kinds
|
||||
channels = {c["channel"]: c for c in supported_channels()}
|
||||
assert channels["sms"]["status"] == "active"
|
||||
assert "email" in channels
|
||||
assert "whatsapp" in channels
|
||||
assert "telegram" in channels
|
||||
assert "rubika" in channels
|
||||
|
||||
|
||||
def test_no_forbidden_business_module_imports():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
violations: list[str] = []
|
||||
for path in root.rglob("*.py"):
|
||||
if "tests" in path.parts:
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
|
||||
if alias.name.startswith(forbidden) or forbidden in alias.name:
|
||||
violations.append(f"{path}: import {alias.name}")
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
|
||||
if node.module.startswith(forbidden) or forbidden in node.module:
|
||||
violations.append(f"{path}: from {node.module}")
|
||||
assert violations == []
|
||||
|
||||
|
||||
def test_folder_structure():
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
required = [
|
||||
"app/models",
|
||||
"app/repositories",
|
||||
"app/services",
|
||||
"app/validators",
|
||||
"app/schemas",
|
||||
"app/events",
|
||||
"app/permissions",
|
||||
"app/providers",
|
||||
"app/api/v1",
|
||||
"app/tests",
|
||||
"alembic/versions",
|
||||
"README.md",
|
||||
]
|
||||
for rel in required:
|
||||
assert (root / rel).exists(), f"missing {rel}"
|
||||
|
||||
|
||||
def test_phase_documentation_exists():
|
||||
docs = Path(__file__).resolve().parents[4] / "docs"
|
||||
# repo root = parents[4] from app/tests -> communication -> services -> backend -> root
|
||||
# Path: .../communication/app/tests -> parents[0]=tests, [1]=app, [2]=communication, [3]=services, [4]=backend, [5]=root
|
||||
docs = Path(__file__).resolve().parents[5] / "docs"
|
||||
assert (docs / "communication-phase-8.md").exists()
|
||||
progress = (docs / "progress.md").read_text(encoding="utf-8")
|
||||
assert "Phase 8" in progress
|
||||
registry = (docs / "module-registry.md").read_text(encoding="utf-8")
|
||||
assert "communication" in registry.lower()
|
||||
@ -0,0 +1,80 @@
|
||||
"""Health, capability, migration, tenant isolation, security tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health(client: AsyncClient):
|
||||
res = await client.get("/health")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["service"] == "communication-service"
|
||||
assert "sms" in body["channels"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capabilities(client: AsyncClient):
|
||||
res = await client.get("/capabilities")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["independent"] is True
|
||||
assert body["requires_other_modules"] is False
|
||||
assert "provider_framework" in body["features"]
|
||||
assert "otp" in body["features"]
|
||||
channels = {c["channel"] for c in body["channels"]}
|
||||
assert "sms" in channels
|
||||
assert "email" in channels
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_required_for_providers(client: AsyncClient):
|
||||
res = await client.get("/api/v1/providers")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_isolation(client: AsyncClient):
|
||||
a = tenant_headers(TENANT_A)
|
||||
b = tenant_headers(TENANT_B)
|
||||
create = await client.post(
|
||||
"/api/v1/providers",
|
||||
headers=a,
|
||||
json={
|
||||
"name": "A Mock",
|
||||
"provider_kind": "mock",
|
||||
"channel": "sms",
|
||||
"priority": 10,
|
||||
"credentials": {},
|
||||
},
|
||||
)
|
||||
assert create.status_code == 201
|
||||
provider_id = create.json()["id"]
|
||||
|
||||
other = await client.get(f"/api/v1/providers/{provider_id}", headers=b)
|
||||
assert other.status_code == 404
|
||||
|
||||
own = await client.get(f"/api/v1/providers/{provider_id}", headers=a)
|
||||
assert own.status_code == 200
|
||||
|
||||
|
||||
def test_migration_revision_exists():
|
||||
from pathlib import Path
|
||||
|
||||
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
|
||||
files = list(versions.glob("0001_*.py"))
|
||||
assert files
|
||||
text = files[0].read_text(encoding="utf-8")
|
||||
assert "0001_initial" in text
|
||||
|
||||
|
||||
def test_permissions_security_tree():
|
||||
from app.permissions.definitions import ALL_PERMISSIONS
|
||||
|
||||
assert all(p.startswith("communication.") for p in ALL_PERMISSIONS)
|
||||
143
backend/services/communication/app/tests/test_providers_sms.py
Normal file
143
backend/services/communication/app/tests/test_providers_sms.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Provider, router, failover, SMS platform tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.providers import reset_provider_registry_for_tests
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
async def _create_provider(client, headers, *, name, priority, force_fail=False):
|
||||
return await client.post(
|
||||
"/api/v1/providers",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": name,
|
||||
"provider_kind": "mock",
|
||||
"channel": "sms",
|
||||
"priority": priority,
|
||||
"credentials": {"force_fail": True} if force_fail else {},
|
||||
"max_retries": 1,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_crud_and_status(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
created = await _create_provider(client, h, name="Primary", priority=1)
|
||||
assert created.status_code == 201
|
||||
pid = created.json()["id"]
|
||||
|
||||
listed = await client.get("/api/v1/providers", headers=h)
|
||||
assert listed.status_code == 200
|
||||
assert any(p["id"] == pid for p in listed.json())
|
||||
|
||||
status = await client.get("/api/v1/providers/status", headers=h)
|
||||
assert status.status_code == 200
|
||||
assert any(p["id"] == pid for p in status.json())
|
||||
|
||||
balance = await client.get(f"/api/v1/providers/{pid}/balance", headers=h)
|
||||
assert balance.status_code == 200
|
||||
|
||||
health = await client.post(f"/api/v1/providers/{pid}/health", headers=h)
|
||||
assert health.status_code == 200
|
||||
assert health.json()["healthy"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sender_numbers(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
res = await client.post(
|
||||
"/api/v1/providers/senders",
|
||||
headers=h,
|
||||
json={"channel": "sms", "value": "1000", "is_default": True},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
listed = await client.get("/api/v1/providers/senders/list", headers=h)
|
||||
assert listed.status_code == 200
|
||||
assert any(s["value"] == "1000" for s in listed.json())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sms_send_and_delivery_timeline(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
await _create_provider(client, h, name="SMS", priority=1)
|
||||
send = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989121234567",
|
||||
"body": "hello",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert send.status_code == 201
|
||||
messages = send.json()
|
||||
assert len(messages) == 1
|
||||
assert messages[0]["status"] == "sent"
|
||||
mid = messages[0]["id"]
|
||||
|
||||
timeline = await client.get(f"/api/v1/messages/{mid}/timeline", headers=h)
|
||||
assert timeline.status_code == 200
|
||||
statuses = [e["status"] for e in timeline.json()]
|
||||
assert "sent" in statuses
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_provider_failover(client: AsyncClient):
|
||||
mock = reset_provider_registry_for_tests()
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
# Lower priority number = higher priority
|
||||
await _create_provider(client, h, name="Failing", priority=1, force_fail=True)
|
||||
await _create_provider(client, h, name="Backup", priority=2, force_fail=False)
|
||||
|
||||
send = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989121111111",
|
||||
"body": "failover",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert send.status_code == 201
|
||||
assert send.json()[0]["status"] == "sent"
|
||||
assert len(mock.sent) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_queue_and_dead_letter(client: AsyncClient):
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
# Only failing provider — eventually dead letter after retries
|
||||
await _create_provider(client, h, name="AlwaysFail", priority=1, force_fail=True)
|
||||
send = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989122222222",
|
||||
"body": "will fail",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert send.status_code == 201
|
||||
mid = send.json()[0]["id"]
|
||||
# First attempt fails then re-queues for retry (status may be queued or failed)
|
||||
assert send.json()[0]["status"] in {"failed", "queued"}
|
||||
|
||||
# Force more queue processing for DLQ path (bypass backoff by reclaiming)
|
||||
for _ in range(8):
|
||||
await client.post("/api/v1/messages/queue/process?limit=20", headers=h)
|
||||
|
||||
dlq = await client.get("/api/v1/messages/queue/dead-letters", headers=h)
|
||||
assert dlq.status_code == 200
|
||||
assert len(dlq.json()) >= 1
|
||||
|
||||
msg = await client.get(f"/api/v1/messages/{mid}", headers=h)
|
||||
assert msg.json()["status"] == "failed"
|
||||
206
backend/services/communication/app/tests/test_templates_otp.py
Normal file
206
backend/services/communication/app/tests/test_templates_otp.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Template, contacts, OTP, webhook, monitoring tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
async def _provider(client, headers):
|
||||
await client.post(
|
||||
"/api/v1/providers",
|
||||
headers=headers,
|
||||
json={
|
||||
"name": "Mock",
|
||||
"provider_kind": "mock",
|
||||
"channel": "sms",
|
||||
"priority": 1,
|
||||
"credentials": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_engine_flow(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
created = await client.post(
|
||||
"/api/v1/templates",
|
||||
headers=h,
|
||||
json={
|
||||
"template_key": "welcome",
|
||||
"name": "Welcome",
|
||||
"channel": "sms",
|
||||
"locale": "fa",
|
||||
"body": "سلام {{name}}",
|
||||
"variables": ["name"],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201
|
||||
tid = created.json()["id"]
|
||||
assert created.json()["status"] == "draft"
|
||||
|
||||
preview = await client.post(
|
||||
f"/api/v1/templates/{tid}/preview",
|
||||
headers=h,
|
||||
json={"variables": {"name": "Ali"}},
|
||||
)
|
||||
assert preview.status_code == 200
|
||||
assert preview.json()["rendered"] == "سلام Ali"
|
||||
|
||||
await client.post(f"/api/v1/templates/{tid}/submit", headers=h)
|
||||
approved = await client.post(f"/api/v1/templates/{tid}/approve", headers=h)
|
||||
assert approved.status_code == 200
|
||||
assert approved.json()["status"] == "approved"
|
||||
|
||||
await _provider(client, h)
|
||||
send = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989123333333",
|
||||
"template_key": "welcome",
|
||||
"template_variables": {"name": "Sara"},
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert send.status_code == 201
|
||||
assert "Sara" in send.json()[0]["body"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_contact_sources(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
manual = await client.post(
|
||||
"/api/v1/contacts/manual",
|
||||
headers=h,
|
||||
json={"display_name": "User", "phone": "+989124444444"},
|
||||
)
|
||||
assert manual.status_code == 201
|
||||
|
||||
csv_import = await client.post(
|
||||
"/api/v1/contacts/manual/import-csv",
|
||||
headers=h,
|
||||
json={"csv_text": "display_name,phone\nCSV User,+989125555555\n"},
|
||||
)
|
||||
assert csv_import.status_code == 201
|
||||
assert len(csv_import.json()) == 1
|
||||
|
||||
source = await client.post(
|
||||
"/api/v1/contacts/sources",
|
||||
headers=h,
|
||||
json={
|
||||
"name": "CRM API",
|
||||
"source_type": "crm",
|
||||
"base_url": "http://crm.local",
|
||||
"endpoint_path": "/contacts",
|
||||
"auth_config": {
|
||||
"mock_contacts": [
|
||||
{"id": "c1", "phone": "+989126666666", "display_name": "CRM Contact"}
|
||||
]
|
||||
},
|
||||
"field_mapping": {
|
||||
"phone": "phone",
|
||||
"display_name": "display_name",
|
||||
"id": "id",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert source.status_code == 201
|
||||
sid = source.json()["id"]
|
||||
|
||||
resolved = await client.post(
|
||||
"/api/v1/contacts/resolve",
|
||||
headers=h,
|
||||
json={"source_id": sid, "channel": "sms"},
|
||||
)
|
||||
assert resolved.status_code == 200
|
||||
assert resolved.json()[0]["address"] == "+989126666666"
|
||||
assert resolved.json()[0]["source_type"] == "crm"
|
||||
# Ensure we did not create duplicated CRM rows in manual contacts
|
||||
manuals = await client.get("/api/v1/contacts/manual", headers=h)
|
||||
phones = {c["phone"] for c in manuals.json()}
|
||||
assert "+989126666666" not in phones
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_otp_platform(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
await _provider(client, h)
|
||||
req = await client.post(
|
||||
"/api/v1/otp/request",
|
||||
headers=h,
|
||||
json={"destination": "+989127777777", "channel": "sms", "purpose": "login"},
|
||||
)
|
||||
assert req.status_code == 201
|
||||
body = req.json()
|
||||
assert body["debug_code"]
|
||||
challenge_id = body["challenge_id"]
|
||||
|
||||
bad = await client.post(
|
||||
"/api/v1/otp/verify",
|
||||
headers=h,
|
||||
json={
|
||||
"destination": "+989127777777",
|
||||
"code": "000000",
|
||||
"challenge_id": challenge_id,
|
||||
},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
ok = await client.post(
|
||||
"/api/v1/otp/verify",
|
||||
headers=h,
|
||||
json={
|
||||
"destination": "+989127777777",
|
||||
"code": body["debug_code"],
|
||||
"challenge_id": challenge_id,
|
||||
},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
assert ok.json()["verified"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_delivery_update(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
await _provider(client, h)
|
||||
send = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989128888888",
|
||||
"body": "track me",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
mid = send.json()[0]["id"]
|
||||
hook = await client.post(
|
||||
"/api/v1/webhooks/incoming",
|
||||
headers=h,
|
||||
json={
|
||||
"provider_kind": "mock",
|
||||
"event_type": "delivered",
|
||||
"message_id": mid,
|
||||
"payload": {"status": "delivered"},
|
||||
},
|
||||
)
|
||||
assert hook.status_code == 201
|
||||
assert hook.json()["processed"] is True
|
||||
|
||||
msg = await client.get(f"/api/v1/messages/{mid}", headers=h)
|
||||
assert msg.json()["status"] == "delivered"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitoring_stats(client: AsyncClient):
|
||||
h = tenant_headers(TENANT_A)
|
||||
await _provider(client, h)
|
||||
stats = await client.get("/api/v1/monitoring/stats", headers=h)
|
||||
assert stats.status_code == 200
|
||||
body = stats.json()
|
||||
assert "messages_by_status" in body
|
||||
assert "queue_by_status" in body
|
||||
assert "providers" in body
|
||||
@ -0,0 +1,236 @@
|
||||
"""Enterprise validation suite — rate limit, idempotency, failover, metrics, OTP isolation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.events.types import CommunicationEventType
|
||||
from app.providers import reset_provider_registry_for_tests
|
||||
from app.services.queue_engine import get_rate_limiter
|
||||
from app.tests.conftest import tenant_headers
|
||||
|
||||
|
||||
async def _mock_provider(client, headers, *, name, priority, force_fail=False, rate_limit=None):
|
||||
payload = {
|
||||
"name": name,
|
||||
"provider_kind": "mock",
|
||||
"channel": "sms",
|
||||
"priority": priority,
|
||||
"credentials": {"force_fail": True} if force_fail else {},
|
||||
"max_retries": 1,
|
||||
}
|
||||
if rate_limit is not None:
|
||||
payload["rate_limit_per_minute"] = rate_limit
|
||||
return await client.post("/api/v1/providers", headers=headers, json=payload)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_correlation_id_idempotency(client: AsyncClient):
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
await _mock_provider(client, h, name="P", priority=1)
|
||||
body = {
|
||||
"channel": "sms",
|
||||
"to_address": "+989120000001",
|
||||
"body": "hello",
|
||||
"correlation_id": "idem-1",
|
||||
"process_immediately": True,
|
||||
}
|
||||
first = await client.post("/api/v1/messages/send", headers=h, json=body)
|
||||
second = await client.post("/api/v1/messages/send", headers=h, json=body)
|
||||
assert first.status_code == 201
|
||||
assert second.status_code == 201
|
||||
assert first.json()[0]["id"] == second.json()[0]["id"]
|
||||
listed = await client.get("/api/v1/messages", headers=h)
|
||||
assert len(listed.json()) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_defers_without_duplicate_send(client: AsyncClient):
|
||||
get_rate_limiter().reset_stats()
|
||||
mock = reset_provider_registry_for_tests()
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
await _mock_provider(client, h, name="Limited", priority=1, rate_limit=1)
|
||||
|
||||
# First send consumes the only slot
|
||||
r1 = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989120000002",
|
||||
"body": "one",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert r1.status_code == 201
|
||||
sent_after_first = len(mock.sent)
|
||||
|
||||
# Second send should be rate-limited / deferred (still queued or eventually sent after process)
|
||||
r2 = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989120000003",
|
||||
"body": "two",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 201
|
||||
# Either deferred (no additional provider send) or limiter recorded deferral
|
||||
assert get_rate_limiter().deferrals >= 1 or len(mock.sent) == sent_after_first
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multi_provider_disable_failover(client: AsyncClient):
|
||||
reset_provider_registry_for_tests()
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
await _mock_provider(client, h, name="P1", priority=1, force_fail=True)
|
||||
await _mock_provider(client, h, name="P2", priority=2, force_fail=True)
|
||||
await _mock_provider(client, h, name="P3", priority=3, force_fail=False)
|
||||
|
||||
send = await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": "+989120000004",
|
||||
"body": "cascade",
|
||||
"process_immediately": True,
|
||||
},
|
||||
)
|
||||
assert send.status_code == 201
|
||||
assert send.json()[0]["status"] == "sent"
|
||||
events = get_event_publisher().published
|
||||
failovers = [
|
||||
e for e in events if e.event_type == CommunicationEventType.PROVIDER_FAILOVER.value
|
||||
]
|
||||
assert len(failovers) >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_ready_and_metrics(client: AsyncClient):
|
||||
ready = await client.get("/health/ready")
|
||||
assert ready.status_code == 200
|
||||
assert ready.json()["database"] == "up"
|
||||
|
||||
metrics_root = await client.get("/metrics")
|
||||
assert metrics_root.status_code == 200
|
||||
assert "idempotency" in metrics_root.json()["features"]
|
||||
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
await _mock_provider(client, h, name="M", priority=1)
|
||||
tenant_metrics = await client.get("/api/v1/monitoring/metrics", headers=h)
|
||||
assert tenant_metrics.status_code == 200
|
||||
body = tenant_metrics.json()
|
||||
assert "provider_delivery" in body
|
||||
assert "rate_limit_deferrals" in body
|
||||
assert "queue_by_status" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capabilities_include_sports_and_idempotency(client: AsyncClient):
|
||||
res = await client.get("/capabilities")
|
||||
body = res.json()
|
||||
assert "sports" in body["contact_source_types"]
|
||||
assert "idempotency" in body["features"]
|
||||
assert "metrics" in body["features"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_otp_rejects_core_reserved_purpose(client: AsyncClient):
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
await _mock_provider(client, h, name="OTP", priority=1)
|
||||
res = await client.post(
|
||||
"/api/v1/otp/request",
|
||||
headers=h,
|
||||
json={
|
||||
"destination": "+989120000005",
|
||||
"channel": "sms",
|
||||
"purpose": "auth.login",
|
||||
},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
assert res.json()["error"]["code"] == "otp_purpose_reserved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sports_contact_source(client: AsyncClient):
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
res = await client.post(
|
||||
"/api/v1/contacts/sources",
|
||||
headers=h,
|
||||
json={
|
||||
"name": "Sports API",
|
||||
"source_type": "sports",
|
||||
"base_url": "http://sports.local",
|
||||
"auth_config": {
|
||||
"mock_contacts": [{"id": "1", "phone": "+989120000006", "display_name": "Athlete"}]
|
||||
},
|
||||
"field_mapping": {"phone": "phone", "display_name": "display_name", "id": "id"},
|
||||
},
|
||||
)
|
||||
assert res.status_code == 201
|
||||
sid = res.json()["id"]
|
||||
resolved = await client.post(
|
||||
"/api/v1/contacts/resolve",
|
||||
headers=h,
|
||||
json={"source_id": sid, "channel": "sms"},
|
||||
)
|
||||
assert resolved.status_code == 200
|
||||
assert resolved.json()[0]["source_type"] == "sports"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_queue_process_no_crash(client: AsyncClient):
|
||||
h = tenant_headers(uuid.uuid4())
|
||||
await _mock_provider(client, h, name="C", priority=1)
|
||||
for i in range(5):
|
||||
await client.post(
|
||||
"/api/v1/messages/send",
|
||||
headers=h,
|
||||
json={
|
||||
"channel": "sms",
|
||||
"to_address": f"+98912000001{i}",
|
||||
"body": f"m{i}",
|
||||
"process_immediately": False,
|
||||
},
|
||||
)
|
||||
|
||||
async def _process():
|
||||
return await client.post("/api/v1/messages/queue/process?limit=10", headers=h)
|
||||
|
||||
results = await asyncio.gather(*[_process() for _ in range(3)])
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_helper_denies_without_role(monkeypatch):
|
||||
from app.api.permissions import user_has_permission
|
||||
from shared.security import CurrentUser
|
||||
|
||||
user = CurrentUser(user_id="u1", username="x", roles=["viewer"])
|
||||
assert user_has_permission(user, "communication.messages.send") is False
|
||||
admin = CurrentUser(user_id="a1", username="a", roles=["tenant_admin"])
|
||||
assert user_has_permission(admin, "communication.messages.send") is True
|
||||
|
||||
|
||||
def test_otp_isolation_documented():
|
||||
from pathlib import Path
|
||||
|
||||
text = Path(__file__).resolve().parents[1].joinpath("services/otp_service.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "Core Platform auth OTP" in text
|
||||
assert "ADR-012" in text
|
||||
|
||||
|
||||
def test_migration_0002_exists():
|
||||
from pathlib import Path
|
||||
|
||||
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
|
||||
assert (versions / "0002_validation_hardening.py").exists()
|
||||
60
backend/services/communication/app/tests/test_validators.py
Normal file
60
backend/services/communication/app/tests/test_validators.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Validator unit tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.validators import (
|
||||
ValidationError,
|
||||
extract_template_variables,
|
||||
render_template,
|
||||
validate_channel,
|
||||
validate_contact,
|
||||
validate_message_send,
|
||||
validate_otp_request,
|
||||
validate_provider_payload,
|
||||
validate_template,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_channel():
|
||||
assert validate_channel("sms") == "sms"
|
||||
with pytest.raises(ValidationError):
|
||||
validate_channel("carrier-pigeon")
|
||||
|
||||
|
||||
def test_validate_provider_payload():
|
||||
validate_provider_payload(
|
||||
{"name": "P", "provider_kind": "mock", "channel": "sms", "priority": 10}
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
validate_provider_payload({"name": "", "provider_kind": "mock", "channel": "sms"})
|
||||
|
||||
|
||||
def test_template_render():
|
||||
assert extract_template_variables("Hi {{name}} {{code}}") == ["code", "name"]
|
||||
assert render_template("Hi {{name}}", {"name": "Ali"}) == "Hi Ali"
|
||||
with pytest.raises(ValidationError):
|
||||
render_template("Hi {{name}}", {})
|
||||
|
||||
|
||||
def test_validate_template_variables_declared():
|
||||
with pytest.raises(ValidationError):
|
||||
validate_template(
|
||||
{
|
||||
"template_key": "t",
|
||||
"name": "T",
|
||||
"channel": "sms",
|
||||
"body": "Hi {{name}}",
|
||||
"variables": [],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_validate_contact_and_message():
|
||||
validate_contact({"phone": "+989121234567"})
|
||||
with pytest.raises(ValidationError):
|
||||
validate_contact({})
|
||||
validate_message_send(
|
||||
{"channel": "sms", "to_address": "+989121234567", "body": "x"}
|
||||
)
|
||||
validate_otp_request({"destination": "+989121234567", "channel": "sms"})
|
||||
210
backend/services/communication/app/validators/__init__.py
Normal file
210
backend/services/communication/app/validators/__init__.py
Normal file
@ -0,0 +1,210 @@
|
||||
"""Reusable validators for Communication domain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from shared.exceptions import ValidationAppError
|
||||
|
||||
from app.models.types import (
|
||||
ChannelType,
|
||||
ContactSourceType,
|
||||
ProviderKind,
|
||||
QueuePriority,
|
||||
TemplateStatus,
|
||||
)
|
||||
|
||||
PHONE_RE = re.compile(r"^\+?[0-9]{8,15}$")
|
||||
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
TEMPLATE_VAR_RE = re.compile(r"\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}")
|
||||
|
||||
|
||||
class ValidationError(ValidationAppError):
|
||||
"""Local alias for Communication validators."""
|
||||
|
||||
|
||||
def validate_channel(channel: str) -> str:
|
||||
try:
|
||||
return ChannelType(channel).value
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"Unsupported channel: {channel}",
|
||||
details={"allowed": [c.value for c in ChannelType]},
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_provider_kind(kind: str) -> str:
|
||||
try:
|
||||
return ProviderKind(kind).value
|
||||
except ValueError as exc:
|
||||
raise ValidationError(
|
||||
f"Unsupported provider kind: {kind}",
|
||||
details={"allowed": [k.value for k in ProviderKind]},
|
||||
) from exc
|
||||
|
||||
|
||||
def validate_provider_payload(data: dict[str, Any]) -> None:
|
||||
if not data.get("name"):
|
||||
raise ValidationError("Provider name is required")
|
||||
validate_channel(data["channel"])
|
||||
validate_provider_kind(data["provider_kind"])
|
||||
priority = data.get("priority", 100)
|
||||
if not isinstance(priority, int) or priority < 1 or priority > 10000:
|
||||
raise ValidationError("priority must be an integer between 1 and 10000")
|
||||
rate = data.get("rate_limit_per_minute")
|
||||
if rate is not None and (not isinstance(rate, int) or rate < 1):
|
||||
raise ValidationError("rate_limit_per_minute must be a positive integer")
|
||||
|
||||
|
||||
def validate_sender_number(data: dict[str, Any]) -> None:
|
||||
validate_channel(data["channel"])
|
||||
value = (data.get("value") or "").strip()
|
||||
if not value:
|
||||
raise ValidationError("Sender number value is required")
|
||||
if data["channel"] == ChannelType.SMS.value and not PHONE_RE.match(value):
|
||||
# Allow alphanumeric short codes for SMS
|
||||
if not re.match(r"^[A-Za-z0-9+\-]{3,32}$", value):
|
||||
raise ValidationError("Invalid sender number format")
|
||||
|
||||
|
||||
def validate_template(data: dict[str, Any]) -> None:
|
||||
if not data.get("template_key"):
|
||||
raise ValidationError("template_key is required")
|
||||
if not data.get("name"):
|
||||
raise ValidationError("template name is required")
|
||||
if not data.get("body"):
|
||||
raise ValidationError("template body is required")
|
||||
validate_channel(data["channel"])
|
||||
locale = data.get("locale", "fa")
|
||||
if not isinstance(locale, str) or len(locale) < 2:
|
||||
raise ValidationError("locale must be a valid language code")
|
||||
declared = data.get("variables")
|
||||
if declared is not None and not isinstance(declared, list):
|
||||
raise ValidationError("variables must be a list")
|
||||
found = set(TEMPLATE_VAR_RE.findall(data["body"]))
|
||||
if declared is not None:
|
||||
missing = found - set(declared)
|
||||
if missing:
|
||||
raise ValidationError(
|
||||
"Template body references undeclared variables",
|
||||
details={"missing": sorted(missing)},
|
||||
)
|
||||
|
||||
|
||||
def validate_template_status_transition(current: str, new: str) -> None:
|
||||
allowed = {
|
||||
TemplateStatus.DRAFT.value: {
|
||||
TemplateStatus.PENDING_APPROVAL.value,
|
||||
TemplateStatus.ARCHIVED.value,
|
||||
},
|
||||
TemplateStatus.PENDING_APPROVAL.value: {
|
||||
TemplateStatus.APPROVED.value,
|
||||
TemplateStatus.REJECTED.value,
|
||||
TemplateStatus.DRAFT.value,
|
||||
},
|
||||
TemplateStatus.APPROVED.value: {TemplateStatus.ARCHIVED.value},
|
||||
TemplateStatus.REJECTED.value: {
|
||||
TemplateStatus.DRAFT.value,
|
||||
TemplateStatus.ARCHIVED.value,
|
||||
},
|
||||
TemplateStatus.ARCHIVED.value: set(),
|
||||
}
|
||||
if new not in allowed.get(current, set()):
|
||||
raise ValidationError(f"Cannot transition template from {current} to {new}")
|
||||
|
||||
|
||||
def validate_contact(data: dict[str, Any]) -> None:
|
||||
phone = data.get("phone")
|
||||
email = data.get("email")
|
||||
if not phone and not email and not data.get("push_token"):
|
||||
raise ValidationError("At least one of phone, email, or push_token is required")
|
||||
if phone and not PHONE_RE.match(phone):
|
||||
raise ValidationError("Invalid phone format")
|
||||
if email and not EMAIL_RE.match(email):
|
||||
raise ValidationError("Invalid email format")
|
||||
|
||||
|
||||
def validate_contact_source(data: dict[str, Any]) -> None:
|
||||
if not data.get("name"):
|
||||
raise ValidationError("Contact source name is required")
|
||||
try:
|
||||
ContactSourceType(data["source_type"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise ValidationError(
|
||||
"Invalid contact source type",
|
||||
details={"allowed": [s.value for s in ContactSourceType]},
|
||||
) from exc
|
||||
source_type = data["source_type"]
|
||||
if source_type not in (ContactSourceType.MANUAL.value, ContactSourceType.CSV.value):
|
||||
if not data.get("base_url"):
|
||||
raise ValidationError("base_url is required for API contact sources")
|
||||
|
||||
|
||||
def validate_message_send(data: dict[str, Any]) -> None:
|
||||
validate_channel(data["channel"])
|
||||
if not data.get("to_address") and not data.get("contact_source_id"):
|
||||
raise ValidationError("to_address or contact_source_id is required")
|
||||
if data.get("to_address") and data["channel"] == ChannelType.SMS.value:
|
||||
if not PHONE_RE.match(data["to_address"]):
|
||||
raise ValidationError("Invalid SMS destination phone")
|
||||
if not data.get("body") and not data.get("template_key") and not data.get("template_id"):
|
||||
raise ValidationError("body or template_key/template_id is required")
|
||||
priority = data.get("priority", QueuePriority.NORMAL.value)
|
||||
try:
|
||||
QueuePriority(priority)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("Invalid priority") from exc
|
||||
|
||||
|
||||
def validate_otp_request(data: dict[str, Any]) -> None:
|
||||
validate_channel(data.get("channel", ChannelType.SMS.value))
|
||||
destination = data.get("destination")
|
||||
if not destination:
|
||||
raise ValidationError("destination is required")
|
||||
channel = data.get("channel", ChannelType.SMS.value)
|
||||
if channel == ChannelType.SMS.value and not PHONE_RE.match(destination):
|
||||
raise ValidationError("Invalid OTP destination phone")
|
||||
if channel == ChannelType.EMAIL.value and not EMAIL_RE.match(destination):
|
||||
raise ValidationError("Invalid OTP destination email")
|
||||
|
||||
|
||||
def validate_otp_verify(data: dict[str, Any]) -> None:
|
||||
if not data.get("destination"):
|
||||
raise ValidationError("destination is required")
|
||||
code = data.get("code")
|
||||
if not code or not str(code).isdigit() or not (4 <= len(str(code)) <= 8):
|
||||
raise ValidationError("code must be a 4-8 digit OTP")
|
||||
|
||||
|
||||
def validate_webhook(data: dict[str, Any]) -> None:
|
||||
if not data.get("provider_kind"):
|
||||
raise ValidationError("provider_kind is required")
|
||||
if not data.get("event_type"):
|
||||
raise ValidationError("event_type is required")
|
||||
|
||||
|
||||
def extract_template_variables(body: str) -> list[str]:
|
||||
return sorted(set(TEMPLATE_VAR_RE.findall(body)))
|
||||
|
||||
|
||||
def render_template(body: str, variables: dict[str, Any] | None = None) -> str:
|
||||
variables = variables or {}
|
||||
found = TEMPLATE_VAR_RE.findall(body)
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
if key not in variables:
|
||||
raise ValidationError(
|
||||
f"Missing template variable: {key}",
|
||||
details={"variable": key},
|
||||
)
|
||||
return str(variables[key])
|
||||
|
||||
# Ensure all found vars are present when rendering
|
||||
missing = [k for k in found if k not in variables]
|
||||
if missing:
|
||||
raise ValidationError(
|
||||
"Missing template variables",
|
||||
details={"missing": missing},
|
||||
)
|
||||
return TEMPLATE_VAR_RE.sub(repl, body)
|
||||
7
backend/services/communication/pytest.ini
Normal file
7
backend/services/communication/pytest.ini
Normal file
@ -0,0 +1,7 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = app/tests
|
||||
pythonpath = .
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
14
backend/services/communication/requirements.txt
Normal file
14
backend/services/communication/requirements.txt
Normal file
@ -0,0 +1,14 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
pydantic[email]==2.7.4
|
||||
pydantic-settings==2.3.4
|
||||
sqlalchemy==2.0.31
|
||||
alembic==1.13.2
|
||||
asyncpg==0.29.0
|
||||
psycopg[binary]==3.2.1
|
||||
httpx==0.27.0
|
||||
pyjwt[crypto]==2.8.0
|
||||
-e ../../shared-lib
|
||||
pytest==8.2.2
|
||||
pytest-asyncio==0.23.7
|
||||
aiosqlite==0.20.0
|
||||
41
backend/services/communication/scripts/ensure_db.py
Normal file
41
backend/services/communication/scripts/ensure_db.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Ensure communication_db exists before migration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def main() -> None:
|
||||
sync_url = os.environ.get("COMMUNICATION_DATABASE_URL_SYNC", "")
|
||||
if not sync_url:
|
||||
print("COMMUNICATION_DATABASE_URL_SYNC not set", file=sys.stderr)
|
||||
return
|
||||
|
||||
parsed = urlparse(sync_url.replace("+psycopg", ""))
|
||||
db_name = (parsed.path or "").lstrip("/") or "communication_db"
|
||||
|
||||
import psycopg
|
||||
|
||||
conn = psycopg.connect(
|
||||
host=parsed.hostname or "localhost",
|
||||
port=parsed.port or 5432,
|
||||
user=parsed.username,
|
||||
password=parsed.password,
|
||||
dbname="postgres",
|
||||
autocommit=True,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,))
|
||||
if cur.fetchone() is None:
|
||||
cur.execute(f'CREATE DATABASE "{db_name}"')
|
||||
print(f"Created database: {db_name}")
|
||||
else:
|
||||
print(f"Database exists: {db_name}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
22
backend/services/hospitality/Dockerfile.dev
Normal file
22
backend/services/hospitality/Dockerfile.dev
Normal file
@ -0,0 +1,22 @@
|
||||
# Dev image: dependencies only — code mounted with uvicorn --reload
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/shared-lib/ /shared-lib/
|
||||
COPY backend/services/hospitality/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
EXPOSE 8007
|
||||
41
backend/services/hospitality/alembic.ini
Normal file
41
backend/services/hospitality/alembic.ini
Normal file
@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[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
|
||||
1
backend/services/hospitality/app/__init__.py
Normal file
1
backend/services/hospitality/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.10.0.0"
|
||||
75
backend/services/hospitality/app/core/config.py
Normal file
75
backend/services/hospitality/app/core/config.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Hospitality Platform Service settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
service_name: str = Field(
|
||||
default="hospitality-service", validation_alias="HOSPITALITY_SERVICE_NAME"
|
||||
)
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/hospitality_db",
|
||||
validation_alias="HOSPITALITY_DATABASE_URL",
|
||||
)
|
||||
database_url_sync: str = Field(
|
||||
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/hospitality_db",
|
||||
validation_alias="HOSPITALITY_DATABASE_URL_SYNC",
|
||||
)
|
||||
|
||||
core_service_url: str = Field(
|
||||
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
|
||||
)
|
||||
|
||||
keycloak_enabled: bool = True
|
||||
keycloak_server_url: str = Field(
|
||||
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
|
||||
)
|
||||
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
|
||||
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_audience: str = "account"
|
||||
jwt_verify_signature: bool = True
|
||||
auth_required: bool = True
|
||||
|
||||
cors_origins: str = Field(
|
||||
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||
validation_alias="CORS_ORIGINS",
|
||||
)
|
||||
|
||||
@property
|
||||
def keycloak_public_base(self) -> str:
|
||||
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
|
||||
|
||||
@property
|
||||
def keycloak_public_realm_url(self) -> str:
|
||||
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
28
backend/services/hospitality/app/core/database.py
Normal file
28
backend/services/hospitality/app/core/database.py
Normal file
@ -0,0 +1,28 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine_kwargs: dict = {"pool_pre_ping": True, "future": True}
|
||||
# Shared in-memory SQLite for tests — StaticPool keeps one connection/DB.
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
_engine_kwargs["poolclass"] = StaticPool
|
||||
_engine_kwargs["connect_args"] = {"check_same_thread": False}
|
||||
|
||||
engine = create_async_engine(settings.database_url, **_engine_kwargs)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
14
backend/services/hospitality/app/core/logging.py
Normal file
14
backend/services/hospitality/app/core/logging.py
Normal file
@ -0,0 +1,14 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
49
backend/services/hospitality/app/core/security.py
Normal file
49
backend/services/hospitality/app/core/security.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""JWT authentication dependencies for Hospitality service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.auth import JWTSettings, JWTValidator
|
||||
from shared.exceptions import UnauthorizedError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_jwt_validator() -> JWTValidator:
|
||||
return JWTValidator(
|
||||
JWTSettings(
|
||||
keycloak_enabled=settings.keycloak_enabled,
|
||||
keycloak_server_url=settings.keycloak_server_url,
|
||||
keycloak_realm=settings.keycloak_realm,
|
||||
jwt_algorithm=settings.jwt_algorithm,
|
||||
jwt_audience=settings.jwt_audience,
|
||||
jwt_verify_signature=settings.jwt_verify_signature,
|
||||
jwt_issuer=settings.keycloak_public_realm_url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
|
||||
|
||||
async def get_optional_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser | None:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
return None
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
24
backend/services/hospitality/app/middlewares/tenant.py
Normal file
24
backend/services/hospitality/app/middlewares/tenant.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Tenant header resolution middleware."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
|
||||
|
||||
|
||||
class TenantHeaderMiddleware(BaseHTTPMiddleware):
|
||||
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response:
|
||||
setattr(request.state, STATE_TENANT_ID, None)
|
||||
raw = request.headers.get(HEADER_TENANT_ID)
|
||||
if raw:
|
||||
try:
|
||||
setattr(request.state, STATE_TENANT_ID, UUID(raw))
|
||||
except ValueError:
|
||||
pass
|
||||
return await call_next(request)
|
||||
19
backend/services/hospitality/app/models/__init__.py
Normal file
19
backend/services/hospitality/app/models/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Import all models for Alembic metadata discovery."""
|
||||
from app.models.foundation import ( # noqa: F401
|
||||
Branch,
|
||||
BundleDefinition,
|
||||
DiningArea,
|
||||
DiningTable,
|
||||
FeatureToggle,
|
||||
HospitalityAuditLog,
|
||||
HospitalityConfiguration,
|
||||
HospitalityEvent,
|
||||
HospitalityPermission,
|
||||
HospitalityRole,
|
||||
HospitalitySetting,
|
||||
Menu,
|
||||
MenuCategory,
|
||||
MenuItem,
|
||||
TenantBundle,
|
||||
Venue,
|
||||
)
|
||||
53
backend/services/hospitality/app/models/base.py
Normal file
53
backend/services/hospitality/app/models/base.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Shared model mixins."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.types import GUID
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
GUID(), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class TenantMixin:
|
||||
"""Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded."""
|
||||
|
||||
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
|
||||
|
||||
class SoftDeleteMixin:
|
||||
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
|
||||
class ActorAuditMixin:
|
||||
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
|
||||
class OptimisticLockMixin:
|
||||
"""Optimistic locking where concurrent updates must be conflict-safe."""
|
||||
|
||||
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||
510
backend/services/hospitality/app/models/foundation.py
Normal file
510
backend/services/hospitality/app/models/foundation.py
Normal file
@ -0,0 +1,510 @@
|
||||
"""Hospitality foundation aggregates — Phase 10.0.
|
||||
|
||||
Independent aggregates use UUID references within hospitality_db only.
|
||||
No SQLAlchemy relationship graphs between aggregates.
|
||||
Venue formats are configurable catalog values — no format-specific engines.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
AuditAction,
|
||||
BundleKey,
|
||||
BundleStatus,
|
||||
GUID,
|
||||
LifecycleStatus,
|
||||
MenuStatus,
|
||||
TableStatus,
|
||||
VenueFormat,
|
||||
)
|
||||
|
||||
|
||||
class Venue(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Root hospitality venue (cafe / restaurant / bakery / …) for a tenant."""
|
||||
|
||||
__tablename__ = "venues"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.DRAFT, nullable=False
|
||||
)
|
||||
venue_format: Mapped[VenueFormat] = mapped_column(
|
||||
default=VenueFormat.RESTAURANT, nullable=False
|
||||
)
|
||||
legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
|
||||
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_venues_tenant_code"),
|
||||
Index("ix_venues_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_venues_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
Index("ix_venues_tenant_format", "tenant_id", "venue_format"),
|
||||
)
|
||||
|
||||
|
||||
class Branch(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Unlimited branches / outlets under a venue."""
|
||||
|
||||
__tablename__ = "branches"
|
||||
|
||||
venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
address: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "venue_id", "code", name="uq_branches_tenant_code"
|
||||
),
|
||||
Index("ix_branches_venue", "tenant_id", "venue_id"),
|
||||
Index("ix_branches_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class DiningArea(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Floor / dining area shell for table service."""
|
||||
|
||||
__tablename__ = "dining_areas"
|
||||
|
||||
venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "venue_id", "code", name="uq_dining_areas_tenant_code"
|
||||
),
|
||||
Index("ix_dining_areas_venue", "tenant_id", "venue_id"),
|
||||
Index("ix_dining_areas_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class DiningTable(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Table shell — seating / QR table binding engines come in later phases."""
|
||||
|
||||
__tablename__ = "dining_tables"
|
||||
|
||||
venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
dining_area_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
capacity: Mapped[int] = mapped_column(Integer, default=2, nullable=False)
|
||||
status: Mapped[TableStatus] = mapped_column(
|
||||
default=TableStatus.AVAILABLE, nullable=False
|
||||
)
|
||||
qr_token: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "venue_id", "code", name="uq_dining_tables_tenant_code"
|
||||
),
|
||||
Index("ix_dining_tables_venue", "tenant_id", "venue_id"),
|
||||
Index("ix_dining_tables_area", "tenant_id", "dining_area_id"),
|
||||
Index("ix_dining_tables_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class Menu(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Digital menu shell — catalog depth expands in later phases."""
|
||||
|
||||
__tablename__ = "menus"
|
||||
|
||||
venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[MenuStatus] = mapped_column(default=MenuStatus.DRAFT, nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "venue_id", "code", name="uq_menus_tenant_code"),
|
||||
Index("ix_menus_venue", "tenant_id", "venue_id"),
|
||||
Index("ix_menus_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class MenuCategory(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "menu_categories"
|
||||
|
||||
venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
menu_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "menu_id", "code", name="uq_menu_categories_tenant_code"
|
||||
),
|
||||
Index("ix_menu_categories_menu", "tenant_id", "menu_id"),
|
||||
Index("ix_menu_categories_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class MenuItem(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Menu item shell — pricing/modifiers engines come later."""
|
||||
|
||||
__tablename__ = "menu_items"
|
||||
|
||||
venue_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
menu_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
base_price: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||
is_available: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "menu_id", "code", name="uq_menu_items_tenant_code"
|
||||
),
|
||||
Index("ix_menu_items_menu", "tenant_id", "menu_id"),
|
||||
Index("ix_menu_items_category", "tenant_id", "category_id"),
|
||||
Index("ix_menu_items_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class BundleDefinition(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Catalog of licensable bundles available to a tenant (feature-based licensing)."""
|
||||
|
||||
__tablename__ = "bundle_definitions"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
bundle_key: Mapped[BundleKey] = mapped_column(nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[BundleStatus] = mapped_column(
|
||||
default=BundleStatus.AVAILABLE, nullable=False
|
||||
)
|
||||
feature_keys: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
permission_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
api_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_bundle_definitions_tenant_code"),
|
||||
UniqueConstraint(
|
||||
"tenant_id", "bundle_key", name="uq_bundle_definitions_tenant_key"
|
||||
),
|
||||
Index("ix_bundle_definitions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class TenantBundle(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Activated bundle for a tenant/venue — hidden features must not expose APIs."""
|
||||
|
||||
__tablename__ = "tenant_bundles"
|
||||
|
||||
venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
bundle_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
bundle_key: Mapped[BundleKey] = mapped_column(nullable=False)
|
||||
status: Mapped[BundleStatus] = mapped_column(
|
||||
default=BundleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
activated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"venue_id",
|
||||
"bundle_key",
|
||||
name="uq_tenant_bundles_tenant_venue_key",
|
||||
),
|
||||
Index("ix_tenant_bundles_status", "tenant_id", "status"),
|
||||
Index("ix_tenant_bundles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class FeatureToggle(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Per-tenant/venue feature toggle for capability discovery."""
|
||||
|
||||
__tablename__ = "feature_toggles"
|
||||
|
||||
venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
feature_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
bundle_key: Mapped[BundleKey | None] = mapped_column(nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"venue_id",
|
||||
"feature_key",
|
||||
name="uq_feature_toggles_tenant_venue_key",
|
||||
),
|
||||
Index("ix_feature_toggles_enabled", "tenant_id", "enabled"),
|
||||
Index("ix_feature_toggles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class HospitalityRole(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "hospitality_roles"
|
||||
|
||||
venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
permission_keys: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "code", name="uq_hospitality_roles_tenant_code"
|
||||
),
|
||||
Index("ix_hospitality_roles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class HospitalityPermission(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "hospitality_permissions"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
bundle_key: Mapped[BundleKey | None] = mapped_column(nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "code", name="uq_hospitality_permissions_tenant_code"
|
||||
),
|
||||
Index("ix_hospitality_permissions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class HospitalityConfiguration(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "hospitality_configurations"
|
||||
|
||||
venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "code", name="uq_hospitality_configurations_tenant_code"
|
||||
),
|
||||
Index("ix_hospitality_configurations_venue", "tenant_id", "venue_id"),
|
||||
Index(
|
||||
"ix_hospitality_configurations_tenant_deleted", "tenant_id", "is_deleted"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class HospitalityEvent(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Domain event shell records (not the outbox bus)."""
|
||||
|
||||
__tablename__ = "hospitality_events"
|
||||
|
||||
venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
status: Mapped[LifecycleStatus] = mapped_column(
|
||||
default=LifecycleStatus.ACTIVE, nullable=False
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_hospitality_events_venue", "tenant_id", "venue_id"),
|
||||
Index("ix_hospitality_events_type", "tenant_id", "event_type"),
|
||||
Index("ix_hospitality_events_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class HospitalitySetting(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "hospitality_settings"
|
||||
|
||||
venue_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
value: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "venue_id", "key", name="uq_hospitality_settings_tenant_key"
|
||||
),
|
||||
Index("ix_hospitality_settings_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class HospitalityAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||
"""Append-only audit trail — no soft delete."""
|
||||
|
||||
__tablename__ = "hospitality_audit_logs"
|
||||
|
||||
entity_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
action: Mapped[AuditAction] = mapped_column(nullable=False)
|
||||
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_hospitality_audit_entity", "tenant_id", "entity_type", "entity_id"),
|
||||
Index("ix_hospitality_audit_actor", "tenant_id", "actor_user_id"),
|
||||
)
|
||||
110
backend/services/hospitality/app/models/types.py
Normal file
110
backend/services/hospitality/app/models/types.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""Hospitality domain enums and dialect-safe GUID type."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.types import CHAR, TypeDecorator
|
||||
|
||||
|
||||
class GUID(TypeDecorator):
|
||||
impl = CHAR
|
||||
cache_ok = True
|
||||
|
||||
def load_dialect_impl(self, dialect):
|
||||
if dialect.name == "postgresql":
|
||||
return dialect.type_descriptor(PG_UUID(as_uuid=True))
|
||||
return dialect.type_descriptor(CHAR(36))
|
||||
|
||||
def process_bind_param(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if dialect.name == "postgresql":
|
||||
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
|
||||
return str(value)
|
||||
|
||||
def process_result_value(self, value, dialect):
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, uuid.UUID):
|
||||
return value
|
||||
return uuid.UUID(str(value))
|
||||
|
||||
|
||||
class LifecycleStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
SUSPENDED = "suspended"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class VenueFormat(str, enum.Enum):
|
||||
"""Configurable venue formats — never hardcoded business engines."""
|
||||
|
||||
CAFE = "cafe"
|
||||
COFFEE_SHOP = "coffee_shop"
|
||||
RESTAURANT = "restaurant"
|
||||
FAST_FOOD = "fast_food"
|
||||
BAKERY = "bakery"
|
||||
PASTRY = "pastry"
|
||||
ICE_CREAM = "ice_cream"
|
||||
JUICE_BAR = "juice_bar"
|
||||
CLOUD_KITCHEN = "cloud_kitchen"
|
||||
FOOD_COURT = "food_court"
|
||||
CATERING = "catering"
|
||||
TAKE_AWAY = "take_away"
|
||||
OTHER = "other"
|
||||
|
||||
|
||||
class BundleKey(str, enum.Enum):
|
||||
"""Licensable feature bundles — activation is tenant-scoped."""
|
||||
|
||||
DIGITAL_MENU = "digital_menu"
|
||||
QR_MENU = "qr_menu"
|
||||
QR_ORDERING = "qr_ordering"
|
||||
POS_LITE = "pos_lite"
|
||||
POS_PRO = "pos_pro"
|
||||
KITCHEN = "kitchen"
|
||||
RESERVATION = "reservation"
|
||||
TABLE_SERVICE = "table_service"
|
||||
DELIVERY_CONNECTOR = "delivery_connector"
|
||||
ACCOUNTING_CONNECTOR = "accounting_connector"
|
||||
CRM_CONNECTOR = "crm_connector"
|
||||
LOYALTY_CONNECTOR = "loyalty_connector"
|
||||
COMMUNICATION_CONNECTOR = "communication_connector"
|
||||
WEBSITE_CONNECTOR = "website_connector"
|
||||
ANALYTICS = "analytics"
|
||||
AI_ASSISTANT = "ai_assistant"
|
||||
|
||||
|
||||
class BundleStatus(str, enum.Enum):
|
||||
AVAILABLE = "available"
|
||||
ACTIVE = "active"
|
||||
SUSPENDED = "suspended"
|
||||
RETIRED = "retired"
|
||||
|
||||
|
||||
class TableStatus(str, enum.Enum):
|
||||
AVAILABLE = "available"
|
||||
OCCUPIED = "occupied"
|
||||
RESERVED = "reserved"
|
||||
CLEANING = "cleaning"
|
||||
OUT_OF_SERVICE = "out_of_service"
|
||||
|
||||
|
||||
class MenuStatus(str, enum.Enum):
|
||||
DRAFT = "draft"
|
||||
PUBLISHED = "published"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class AuditAction(str, enum.Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
RESTORE = "restore"
|
||||
STATUS_CHANGE = "status_change"
|
||||
ACTIVATE = "activate"
|
||||
DEACTIVATE = "deactivate"
|
||||
82
backend/services/hospitality/app/repositories/base.py
Normal file
82
backend/services/hospitality/app/repositories/base.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""Tenant-aware base repository with soft-delete helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Generic, Sequence, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=Base)
|
||||
|
||||
|
||||
class TenantBaseRepository(Generic[ModelT]):
|
||||
model: type[ModelT]
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
|
||||
clauses = [
|
||||
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||
]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_including_deleted(
|
||||
self, tenant_id: UUID, entity_id: UUID
|
||||
) -> ModelT | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def add(self, entity: ModelT) -> ModelT:
|
||||
self.session.add(entity)
|
||||
await self.session.flush()
|
||||
return entity
|
||||
|
||||
async def delete(self, entity: ModelT) -> None:
|
||||
await self.session.delete(entity)
|
||||
await self.session.flush()
|
||||
|
||||
async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None:
|
||||
entity.is_deleted = True # type: ignore[attr-defined]
|
||||
entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined]
|
||||
if deleted_by is not None and hasattr(entity, "deleted_by"):
|
||||
entity.deleted_by = deleted_by # type: ignore[attr-defined]
|
||||
await self.session.flush()
|
||||
|
||||
async def restore(self, entity: ModelT) -> None:
|
||||
entity.is_deleted = False # type: ignore[attr-defined]
|
||||
entity.deleted_at = None # type: ignore[attr-defined]
|
||||
if hasattr(entity, "deleted_by"):
|
||||
entity.deleted_by = None # type: ignore[attr-defined]
|
||||
await self.session.flush()
|
||||
|
||||
async def list_by_tenant(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||
) -> Sequence[ModelT]:
|
||||
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(self.model).where(*clauses).offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||
if hasattr(self.model, "is_deleted"):
|
||||
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||
stmt = select(func.count()).select_from(self.model).where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
8
backend/services/hospitality/pytest.ini
Normal file
8
backend/services/hospitality/pytest.ini
Normal file
@ -0,0 +1,8 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
asyncio_default_fixture_loop_scope = function
|
||||
testpaths = app/tests
|
||||
pythonpath = .
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
14
backend/services/hospitality/requirements.txt
Normal file
14
backend/services/hospitality/requirements.txt
Normal file
@ -0,0 +1,14 @@
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.30.1
|
||||
pydantic[email]==2.7.4
|
||||
pydantic-settings==2.3.4
|
||||
sqlalchemy==2.0.31
|
||||
alembic==1.13.2
|
||||
asyncpg==0.29.0
|
||||
psycopg[binary]>=3.2.2
|
||||
httpx==0.27.0
|
||||
pyjwt[crypto]==2.8.0
|
||||
-e ../../shared-lib
|
||||
pytest==8.2.2
|
||||
pytest-asyncio==0.23.7
|
||||
aiosqlite==0.20.0
|
||||
22
backend/services/loyalty/Dockerfile.dev
Normal file
22
backend/services/loyalty/Dockerfile.dev
Normal file
@ -0,0 +1,22 @@
|
||||
# Dev image: dependencies only — code mounted with uvicorn --reload
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PYTHONPATH=/app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY backend/shared-lib/ /shared-lib/
|
||||
COPY backend/services/loyalty/requirements.txt /app/requirements.txt
|
||||
|
||||
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||
&& pip install --upgrade pip \
|
||||
&& pip install -r requirements.txt
|
||||
|
||||
EXPOSE 8004
|
||||
81
backend/services/loyalty/README.md
Normal file
81
backend/services/loyalty/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Loyalty Service — Enterprise Loyalty Platform
|
||||
|
||||
> Phase 7.1 Membership Engine complete (foundation 7.0 + lifecycle). Independent shared service — **not** part of CRM.
|
||||
|
||||
Reusable by Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare,
|
||||
Salon, Gym, and future modules via API + Events only.
|
||||
|
||||
## Ownership
|
||||
|
||||
| Owns | Does not own |
|
||||
| --- | --- |
|
||||
| LoyaltyProgram, MembershipTier, Member + lifecycle | CRM sales entities |
|
||||
| MembershipLifecycleEvent history | Accounting / Posting Engine |
|
||||
| PointAccount shell (no mutable balance) | Notification delivery |
|
||||
| Reward catalog shell | Analytics store (later) |
|
||||
| Campaign shell (versioned rules later) | Identity / Wallet UI / Gift card engine (later phases) |
|
||||
| Loyalty audit trail | |
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Independent database: `loyalty_db` (ADR-001, ADR-011)
|
||||
- Row-level tenancy via `tenant_id` from request context (ADR-003)
|
||||
- Membership lifecycle transitions validated in `MembershipEngineService`
|
||||
- Program soft-delete rejected while blocking members exist
|
||||
- Direct balance modification is prohibited — ledger in Phase 7.2
|
||||
- Soft delete releases unique keys so codes can be reused; audit via `/api/v1/audit`
|
||||
- Inter-service communication: REST + Events only (transactional outbox)
|
||||
- Platform providers are **contracts only** under `app/providers/`
|
||||
- Route permissions enforced (`loyalty.*`); invalid `X-Tenant-ID` rejected
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
app/
|
||||
api/v1/ # Loyalty HTTP APIs only
|
||||
models/ # foundation aggregates
|
||||
repositories/
|
||||
services/
|
||||
validators/
|
||||
schemas/
|
||||
events/
|
||||
permissions/
|
||||
providers/
|
||||
tests/
|
||||
alembic/versions/0001_initial.py
|
||||
```
|
||||
|
||||
## API Prefix
|
||||
|
||||
`/api/v1` on port **8004**
|
||||
|
||||
| Resource | Path |
|
||||
| --- | --- |
|
||||
| Programs | `/api/v1/programs` |
|
||||
| Tiers | `/api/v1/tiers` |
|
||||
| Members | `/api/v1/members` |
|
||||
| Point accounts | `/api/v1/point-accounts` |
|
||||
| Rewards | `/api/v1/rewards` |
|
||||
| Campaigns | `/api/v1/campaigns` |
|
||||
| Health | `/health` |
|
||||
|
||||
## Permissions
|
||||
|
||||
`loyalty.*` including `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`,
|
||||
`loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*`
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
cd backend/services/loyalty
|
||||
pip install -r requirements.txt
|
||||
pytest -q
|
||||
uvicorn app.main:app --port 8004 --reload
|
||||
```
|
||||
|
||||
## Related Documents
|
||||
|
||||
- [Phase 7.0](../../../docs/loyalty-phase-7-0.md)
|
||||
- [Module Registry](../../../docs/module-registry.md#loyalty)
|
||||
- [ADR-011](../../../docs/architecture/adr/ADR-011.md)
|
||||
- [Architecture](../../../docs/architecture/architecture.md)
|
||||
41
backend/services/loyalty/alembic.ini
Normal file
41
backend/services/loyalty/alembic.ini
Normal file
@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
[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
|
||||
42
backend/services/loyalty/alembic/env.py
Normal file
42
backend/services/loyalty/alembic/env.py
Normal file
@ -0,0 +1,42 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||
if config.config_file_name:
|
||||
fileConfig(config.config_file_name)
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
context.configure(
|
||||
url=settings.database_url_sync,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
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)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
19
backend/services/loyalty/alembic/versions/0001_initial.py
Normal file
19
backend/services/loyalty/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Initial Loyalty schema — Phase 7.0 foundation."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0001_initial"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.drop_all(bind=bind)
|
||||
@ -0,0 +1,102 @@
|
||||
"""Phase 7.1 — Membership lifecycle schema (additive)."""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0002_phase_71_membership"
|
||||
down_revision = "0001_initial"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("membership_started_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("membership_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("frozen_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"members", sa.Column("freeze_reason", sa.String(length=500), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"members", sa.Column("cancel_reason", sa.String(length=500), nullable=True)
|
||||
)
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"members",
|
||||
sa.Column("transferred_to_member_id", sa.CHAR(length=36), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_members_tenant_expires",
|
||||
"members",
|
||||
["tenant_id", "membership_expires_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"membership_lifecycle_events",
|
||||
sa.Column("id", sa.CHAR(length=36), primary_key=True),
|
||||
sa.Column("tenant_id", sa.CHAR(length=36), nullable=False),
|
||||
sa.Column("member_id", sa.CHAR(length=36), nullable=False),
|
||||
sa.Column("action", sa.String(length=30), nullable=False),
|
||||
sa.Column("from_status", sa.String(length=30), nullable=False),
|
||||
sa.Column("to_status", sa.String(length=30), nullable=False),
|
||||
sa.Column("reason", sa.String(length=500), nullable=True),
|
||||
sa.Column("metadata_json", sa.JSON(), nullable=True),
|
||||
sa.Column("actor_user_id", sa.String(length=100), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_membership_lifecycle_member",
|
||||
"membership_lifecycle_events",
|
||||
["tenant_id", "member_id"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_membership_lifecycle_action",
|
||||
"membership_lifecycle_events",
|
||||
["tenant_id", "action"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_membership_lifecycle_created",
|
||||
"membership_lifecycle_events",
|
||||
["tenant_id", "created_at"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index(
|
||||
"ix_membership_lifecycle_created", table_name="membership_lifecycle_events"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_membership_lifecycle_action", table_name="membership_lifecycle_events"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_membership_lifecycle_member", table_name="membership_lifecycle_events"
|
||||
)
|
||||
op.drop_table("membership_lifecycle_events")
|
||||
op.drop_index("ix_members_tenant_expires", table_name="members")
|
||||
op.drop_column("members", "transferred_to_member_id")
|
||||
op.drop_column("members", "expired_at")
|
||||
op.drop_column("members", "cancel_reason")
|
||||
op.drop_column("members", "cancelled_at")
|
||||
op.drop_column("members", "freeze_reason")
|
||||
op.drop_column("members", "frozen_at")
|
||||
op.drop_column("members", "membership_expires_at")
|
||||
op.drop_column("members", "membership_started_at")
|
||||
op.drop_column("members", "activated_at")
|
||||
1
backend/services/loyalty/app/__init__.py
Normal file
1
backend/services/loyalty/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.7.1.0"
|
||||
39
backend/services/loyalty/app/api/deps.py
Normal file
39
backend/services/loyalty/app/api/deps.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Common API dependencies."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Depends, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import get_current_user
|
||||
from shared.exceptions import TenantNotResolvedError
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
from shared.tenant import STATE_TENANT_ID
|
||||
|
||||
__all__ = [
|
||||
"get_db",
|
||||
"get_pagination",
|
||||
"require_tenant",
|
||||
"get_current_user",
|
||||
"AsyncSession",
|
||||
"CurrentUser",
|
||||
]
|
||||
|
||||
|
||||
def get_pagination(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=500),
|
||||
) -> PaginationParams:
|
||||
return PaginationParams(page=page, page_size=page_size)
|
||||
|
||||
|
||||
def require_tenant(request: Request) -> UUID:
|
||||
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
|
||||
if tenant_id is None:
|
||||
raise TenantNotResolvedError(
|
||||
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
|
||||
)
|
||||
return tenant_id
|
||||
52
backend/services/loyalty/app/api/permissions.py
Normal file
52
backend/services/loyalty/app/api/permissions.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""Permission enforcement helpers for Loyalty APIs."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import get_current_user
|
||||
from shared.exceptions import ForbiddenError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_ADMIN_ROLES = frozenset(
|
||||
{"platform_admin", "tenant_owner", "tenant_admin"}
|
||||
)
|
||||
|
||||
|
||||
def user_has_permission(user: CurrentUser, permission: str) -> bool:
|
||||
if any(role in _ADMIN_ROLES for role in user.roles):
|
||||
return True
|
||||
roles = set(user.roles)
|
||||
if "loyalty.manage" in roles or permission in roles:
|
||||
return True
|
||||
# Platform-wide loyalty.view grants any *.view leaf.
|
||||
if "loyalty.view" in roles and permission.endswith(".view"):
|
||||
return True
|
||||
# Resource manage (e.g. loyalty.programs.manage) grants that tree.
|
||||
parts = permission.split(".")
|
||||
if len(parts) >= 3:
|
||||
manage = f"{parts[0]}.{parts[1]}.manage"
|
||||
if manage in roles:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def require_permissions(*permissions: str) -> Callable:
|
||||
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
|
||||
|
||||
async def _dependency(
|
||||
user: CurrentUser = Depends(get_current_user),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return user
|
||||
if any(user_has_permission(user, perm) for perm in permissions):
|
||||
return user
|
||||
raise ForbiddenError(
|
||||
"دسترسی مجاز نیست",
|
||||
error_code="permission_denied",
|
||||
details={"required": list(permissions)},
|
||||
)
|
||||
|
||||
return _dependency
|
||||
22
backend/services/loyalty/app/api/v1/__init__.py
Normal file
22
backend/services/loyalty/app/api/v1/__init__.py
Normal file
@ -0,0 +1,22 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
audit,
|
||||
campaigns,
|
||||
members,
|
||||
point_accounts,
|
||||
programs,
|
||||
rewards,
|
||||
tiers,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(programs.router, prefix="/programs", tags=["programs"])
|
||||
api_router.include_router(tiers.router, prefix="/tiers", tags=["tiers"])
|
||||
api_router.include_router(members.router, prefix="/members", tags=["members"])
|
||||
api_router.include_router(
|
||||
point_accounts.router, prefix="/point-accounts", tags=["point-accounts"]
|
||||
)
|
||||
api_router.include_router(rewards.router, prefix="/rewards", tags=["rewards"])
|
||||
api_router.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||
30
backend/services/loyalty/app/api/v1/audit.py
Normal file
30
backend/services/loyalty/app/api/v1/audit.py
Normal file
@ -0,0 +1,30 @@
|
||||
"""Audit log APIs — Phase 7.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import LOYALTY_AUDIT_VIEW
|
||||
from app.schemas.foundation import LoyaltyAuditLogRead
|
||||
from app.services.audit_service import AuditService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[LoyaltyAuditLogRead])
|
||||
async def list_audit(
|
||||
entity_type: str = Query(..., min_length=1, max_length=50),
|
||||
entity_id: UUID = Query(...),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_AUDIT_VIEW)),
|
||||
):
|
||||
return await AuditService(db).list_for_entity(
|
||||
tenant_id, entity_type, entity_id, limit=limit
|
||||
)
|
||||
79
backend/services/loyalty/app/api/v1/campaigns.py
Normal file
79
backend/services/loyalty/app/api/v1/campaigns.py
Normal file
@ -0,0 +1,79 @@
|
||||
"""Campaign APIs — Phase 7.0 shell."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_CAMPAIGNS_CREATE,
|
||||
LOYALTY_CAMPAIGNS_DELETE,
|
||||
LOYALTY_CAMPAIGNS_UPDATE,
|
||||
LOYALTY_CAMPAIGNS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import CampaignCreate, CampaignRead, CampaignUpdate
|
||||
from app.services.foundation import CampaignService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=CampaignRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_campaign(
|
||||
body: CampaignCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_CREATE)),
|
||||
):
|
||||
return await CampaignService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[CampaignRead])
|
||||
async def list_campaigns(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_VIEW)),
|
||||
):
|
||||
return await CampaignService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{campaign_id}", response_model=CampaignRead)
|
||||
async def get_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_VIEW)),
|
||||
):
|
||||
return await CampaignService(db).get(tenant_id, campaign_id)
|
||||
|
||||
|
||||
@router.patch("/{campaign_id}", response_model=CampaignRead)
|
||||
async def update_campaign(
|
||||
campaign_id: UUID,
|
||||
body: CampaignUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_UPDATE)),
|
||||
):
|
||||
return await CampaignService(db).update(
|
||||
tenant_id, campaign_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{campaign_id}/delete", response_model=CampaignRead)
|
||||
async def soft_delete_campaign(
|
||||
campaign_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_CAMPAIGNS_DELETE)),
|
||||
):
|
||||
return await CampaignService(db).soft_delete(
|
||||
tenant_id, campaign_id, actor=user
|
||||
)
|
||||
15
backend/services/loyalty/app/api/v1/health.py
Normal file
15
backend/services/loyalty/app/api/v1/health.py
Normal file
@ -0,0 +1,15 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app import __version__
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "ok",
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
}
|
||||
216
backend/services/loyalty/app/api/v1/members.py
Normal file
216
backend/services/loyalty/app/api/v1/members.py
Normal file
@ -0,0 +1,216 @@
|
||||
"""Member APIs — Phase 7.0 foundation + Phase 7.1 membership lifecycle."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_MEMBERS_ACTIVATE,
|
||||
LOYALTY_MEMBERS_CANCEL,
|
||||
LOYALTY_MEMBERS_CREATE,
|
||||
LOYALTY_MEMBERS_DELETE,
|
||||
LOYALTY_MEMBERS_ENROLL,
|
||||
LOYALTY_MEMBERS_EXPIRE,
|
||||
LOYALTY_MEMBERS_FREEZE,
|
||||
LOYALTY_MEMBERS_LIFECYCLE_VIEW,
|
||||
LOYALTY_MEMBERS_RENEW,
|
||||
LOYALTY_MEMBERS_RESUME,
|
||||
LOYALTY_MEMBERS_TRANSFER,
|
||||
LOYALTY_MEMBERS_UPDATE,
|
||||
LOYALTY_MEMBERS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
MemberActivateRequest,
|
||||
MemberCancelRequest,
|
||||
MemberCreate,
|
||||
MemberEnrollRequest,
|
||||
MemberExpireRequest,
|
||||
MemberFreezeRequest,
|
||||
MemberRead,
|
||||
MemberRenewRequest,
|
||||
MemberResumeRequest,
|
||||
MembershipLifecycleEventRead,
|
||||
MemberTransferRequest,
|
||||
MemberUpdate,
|
||||
)
|
||||
from app.services.foundation import MemberService
|
||||
from app.services.membership_engine import MembershipEngineService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=MemberRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_member(
|
||||
body: MemberCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_CREATE)),
|
||||
):
|
||||
return await MemberService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[MemberRead])
|
||||
async def list_members(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_VIEW)),
|
||||
):
|
||||
return await MemberService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{member_id}", response_model=MemberRead)
|
||||
async def get_member(
|
||||
member_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_VIEW)),
|
||||
):
|
||||
return await MemberService(db).get(tenant_id, member_id)
|
||||
|
||||
|
||||
@router.patch("/{member_id}", response_model=MemberRead)
|
||||
async def update_member(
|
||||
member_id: UUID,
|
||||
body: MemberUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_UPDATE)),
|
||||
):
|
||||
return await MemberService(db).update(tenant_id, member_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{member_id}/enroll", response_model=MemberRead)
|
||||
async def enroll_member(
|
||||
member_id: UUID,
|
||||
body: MemberEnrollRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_ENROLL)),
|
||||
):
|
||||
return await MemberService(db).enroll(tenant_id, member_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{member_id}/activate", response_model=MemberRead)
|
||||
async def activate_member(
|
||||
member_id: UUID,
|
||||
body: MemberActivateRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_ACTIVATE)),
|
||||
):
|
||||
return await MembershipEngineService(db).activate(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/renew", response_model=MemberRead)
|
||||
async def renew_member(
|
||||
member_id: UUID,
|
||||
body: MemberRenewRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_RENEW)),
|
||||
):
|
||||
return await MembershipEngineService(db).renew(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/freeze", response_model=MemberRead)
|
||||
async def freeze_member(
|
||||
member_id: UUID,
|
||||
body: MemberFreezeRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_FREEZE)),
|
||||
):
|
||||
return await MembershipEngineService(db).freeze(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/resume", response_model=MemberRead)
|
||||
async def resume_member(
|
||||
member_id: UUID,
|
||||
body: MemberResumeRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_RESUME)),
|
||||
):
|
||||
return await MembershipEngineService(db).resume(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/cancel", response_model=MemberRead)
|
||||
async def cancel_member(
|
||||
member_id: UUID,
|
||||
body: MemberCancelRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_CANCEL)),
|
||||
):
|
||||
return await MembershipEngineService(db).cancel(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/expire", response_model=MemberRead)
|
||||
async def expire_member(
|
||||
member_id: UUID,
|
||||
body: MemberExpireRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_EXPIRE)),
|
||||
):
|
||||
return await MembershipEngineService(db).expire(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/transfer", response_model=MemberRead)
|
||||
async def transfer_member(
|
||||
member_id: UUID,
|
||||
body: MemberTransferRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_TRANSFER)),
|
||||
):
|
||||
return await MembershipEngineService(db).transfer(
|
||||
tenant_id, member_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{member_id}/lifecycle",
|
||||
response_model=list[MembershipLifecycleEventRead],
|
||||
)
|
||||
async def list_member_lifecycle(
|
||||
member_id: UUID,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_LIFECYCLE_VIEW)),
|
||||
):
|
||||
return await MembershipEngineService(db).list_lifecycle(
|
||||
tenant_id, member_id, limit=limit
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{member_id}/delete", response_model=MemberRead)
|
||||
async def soft_delete_member(
|
||||
member_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_MEMBERS_DELETE)),
|
||||
):
|
||||
return await MemberService(db).soft_delete(tenant_id, member_id, actor=user)
|
||||
83
backend/services/loyalty/app/api/v1/point_accounts.py
Normal file
83
backend/services/loyalty/app/api/v1/point_accounts.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""Point account APIs — Phase 7.0 shell (no balance mutation)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_POINT_ACCOUNTS_CREATE,
|
||||
LOYALTY_POINT_ACCOUNTS_MANAGE,
|
||||
LOYALTY_POINT_ACCOUNTS_UPDATE,
|
||||
LOYALTY_POINT_ACCOUNTS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
PointAccountCreate,
|
||||
PointAccountRead,
|
||||
PointAccountUpdate,
|
||||
)
|
||||
from app.services.foundation import PointAccountService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=PointAccountRead, status_code=status.HTTP_201_CREATED)
|
||||
async def open_point_account(
|
||||
body: PointAccountCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_POINT_ACCOUNTS_CREATE)),
|
||||
):
|
||||
return await PointAccountService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[PointAccountRead])
|
||||
async def list_point_accounts(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_POINT_ACCOUNTS_VIEW)),
|
||||
):
|
||||
return await PointAccountService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{account_id}", response_model=PointAccountRead)
|
||||
async def get_point_account(
|
||||
account_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_POINT_ACCOUNTS_VIEW)),
|
||||
):
|
||||
return await PointAccountService(db).get(tenant_id, account_id)
|
||||
|
||||
|
||||
@router.patch("/{account_id}", response_model=PointAccountRead)
|
||||
async def update_point_account(
|
||||
account_id: UUID,
|
||||
body: PointAccountUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_POINT_ACCOUNTS_UPDATE)),
|
||||
):
|
||||
return await PointAccountService(db).update(
|
||||
tenant_id, account_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{account_id}/delete", response_model=PointAccountRead)
|
||||
async def soft_delete_point_account(
|
||||
account_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_POINT_ACCOUNTS_MANAGE)),
|
||||
):
|
||||
return await PointAccountService(db).soft_delete(
|
||||
tenant_id, account_id, actor=user
|
||||
)
|
||||
83
backend/services/loyalty/app/api/v1/programs.py
Normal file
83
backend/services/loyalty/app/api/v1/programs.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""Loyalty program APIs — Phase 7.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_PROGRAMS_CREATE,
|
||||
LOYALTY_PROGRAMS_DELETE,
|
||||
LOYALTY_PROGRAMS_UPDATE,
|
||||
LOYALTY_PROGRAMS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
LoyaltyProgramCreate,
|
||||
LoyaltyProgramRead,
|
||||
LoyaltyProgramUpdate,
|
||||
)
|
||||
from app.services.foundation import LoyaltyProgramService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=LoyaltyProgramRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_program(
|
||||
body: LoyaltyProgramCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_PROGRAMS_CREATE)),
|
||||
):
|
||||
return await LoyaltyProgramService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[LoyaltyProgramRead])
|
||||
async def list_programs(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_PROGRAMS_VIEW)),
|
||||
):
|
||||
return await LoyaltyProgramService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{program_id}", response_model=LoyaltyProgramRead)
|
||||
async def get_program(
|
||||
program_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_PROGRAMS_VIEW)),
|
||||
):
|
||||
return await LoyaltyProgramService(db).get(tenant_id, program_id)
|
||||
|
||||
|
||||
@router.patch("/{program_id}", response_model=LoyaltyProgramRead)
|
||||
async def update_program(
|
||||
program_id: UUID,
|
||||
body: LoyaltyProgramUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_PROGRAMS_UPDATE)),
|
||||
):
|
||||
return await LoyaltyProgramService(db).update(
|
||||
tenant_id, program_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{program_id}/delete", response_model=LoyaltyProgramRead)
|
||||
async def soft_delete_program(
|
||||
program_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_PROGRAMS_DELETE)),
|
||||
):
|
||||
return await LoyaltyProgramService(db).soft_delete(
|
||||
tenant_id, program_id, actor=user
|
||||
)
|
||||
75
backend/services/loyalty/app/api/v1/rewards.py
Normal file
75
backend/services/loyalty/app/api/v1/rewards.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Reward catalog APIs — Phase 7.0 shell."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_REWARDS_CREATE,
|
||||
LOYALTY_REWARDS_DELETE,
|
||||
LOYALTY_REWARDS_UPDATE,
|
||||
LOYALTY_REWARDS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import RewardCreate, RewardRead, RewardUpdate
|
||||
from app.services.foundation import RewardService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=RewardRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_reward(
|
||||
body: RewardCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_CREATE)),
|
||||
):
|
||||
return await RewardService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[RewardRead])
|
||||
async def list_rewards(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_VIEW)),
|
||||
):
|
||||
return await RewardService(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{reward_id}", response_model=RewardRead)
|
||||
async def get_reward(
|
||||
reward_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_VIEW)),
|
||||
):
|
||||
return await RewardService(db).get(tenant_id, reward_id)
|
||||
|
||||
|
||||
@router.patch("/{reward_id}", response_model=RewardRead)
|
||||
async def update_reward(
|
||||
reward_id: UUID,
|
||||
body: RewardUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_UPDATE)),
|
||||
):
|
||||
return await RewardService(db).update(tenant_id, reward_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{reward_id}/delete", response_model=RewardRead)
|
||||
async def soft_delete_reward(
|
||||
reward_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_REWARDS_DELETE)),
|
||||
):
|
||||
return await RewardService(db).soft_delete(tenant_id, reward_id, actor=user)
|
||||
90
backend/services/loyalty/app/api/v1/tiers.py
Normal file
90
backend/services/loyalty/app/api/v1/tiers.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Membership tier APIs — Phase 7.0."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.permissions.definitions import (
|
||||
LOYALTY_TIERS_CREATE,
|
||||
LOYALTY_TIERS_DELETE,
|
||||
LOYALTY_TIERS_UPDATE,
|
||||
LOYALTY_TIERS_VIEW,
|
||||
)
|
||||
from app.schemas.foundation import (
|
||||
MembershipTierCreate,
|
||||
MembershipTierRead,
|
||||
MembershipTierUpdate,
|
||||
)
|
||||
from app.services.foundation import MembershipTierService
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=MembershipTierRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tier(
|
||||
body: MembershipTierCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_TIERS_CREATE)),
|
||||
):
|
||||
return await MembershipTierService(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=list[MembershipTierRead])
|
||||
async def list_tiers(
|
||||
program_id: UUID | None = Query(default=None),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_TIERS_VIEW)),
|
||||
):
|
||||
service = MembershipTierService(db)
|
||||
if program_id is not None:
|
||||
return await service.list_by_program(
|
||||
tenant_id,
|
||||
program_id,
|
||||
offset=pagination.offset,
|
||||
limit=pagination.page_size,
|
||||
)
|
||||
return await service.list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{tier_id}", response_model=MembershipTierRead)
|
||||
async def get_tier(
|
||||
tier_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(LOYALTY_TIERS_VIEW)),
|
||||
):
|
||||
return await MembershipTierService(db).get(tenant_id, tier_id)
|
||||
|
||||
|
||||
@router.patch("/{tier_id}", response_model=MembershipTierRead)
|
||||
async def update_tier(
|
||||
tier_id: UUID,
|
||||
body: MembershipTierUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_TIERS_UPDATE)),
|
||||
):
|
||||
return await MembershipTierService(db).update(
|
||||
tenant_id, tier_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tier_id}/delete", response_model=MembershipTierRead)
|
||||
async def soft_delete_tier(
|
||||
tier_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(LOYALTY_TIERS_DELETE)),
|
||||
):
|
||||
return await MembershipTierService(db).soft_delete(tenant_id, tier_id, actor=user)
|
||||
91
backend/services/loyalty/app/core/config.py
Normal file
91
backend/services/loyalty/app/core/config.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""تنظیمات Loyalty Service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||
debug: bool = True
|
||||
log_level: str = "INFO"
|
||||
service_name: str = Field(
|
||||
default="loyalty-service", validation_alias="LOYALTY_SERVICE_NAME"
|
||||
)
|
||||
api_v1_prefix: str = "/api/v1"
|
||||
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/loyalty_db",
|
||||
validation_alias="LOYALTY_DATABASE_URL",
|
||||
)
|
||||
database_url_sync: str = Field(
|
||||
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/loyalty_db",
|
||||
validation_alias="LOYALTY_DATABASE_URL_SYNC",
|
||||
)
|
||||
|
||||
core_service_url: str = Field(
|
||||
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
|
||||
)
|
||||
|
||||
keycloak_enabled: bool = True
|
||||
keycloak_server_url: str = Field(
|
||||
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
|
||||
)
|
||||
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
|
||||
|
||||
jwt_algorithm: str = "RS256"
|
||||
jwt_audience: str = "account"
|
||||
jwt_verify_signature: bool = True
|
||||
auth_required: bool = True
|
||||
|
||||
cors_origins: str = Field(
|
||||
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||
validation_alias="CORS_ORIGINS",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _production_guards(self) -> Settings:
|
||||
if self.environment == "production":
|
||||
if self.debug:
|
||||
raise ValueError("LOYALTY debug must be false in production")
|
||||
defaults = ("superapp_password", "localhost:5432")
|
||||
if any(token in self.database_url for token in defaults):
|
||||
raise ValueError(
|
||||
"LOYALTY_DATABASE_URL must be set explicitly in production"
|
||||
)
|
||||
if any(token in self.database_url_sync for token in defaults):
|
||||
raise ValueError(
|
||||
"LOYALTY_DATABASE_URL_SYNC must be set explicitly in production"
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
def keycloak_public_base(self) -> str:
|
||||
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
|
||||
|
||||
@property
|
||||
def keycloak_public_realm_url(self) -> str:
|
||||
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
|
||||
|
||||
@property
|
||||
def cors_origin_list(self) -> list[str]:
|
||||
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
|
||||
|
||||
settings = get_settings()
|
||||
21
backend/services/loyalty/app/core/database.py
Normal file
21
backend/services/loyalty/app/core/database.py
Normal file
@ -0,0 +1,21 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
engine = create_async_engine(settings.database_url, pool_pre_ping=True, future=True)
|
||||
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
14
backend/services/loyalty/app/core/logging.py
Normal file
14
backend/services/loyalty/app/core/logging.py
Normal file
@ -0,0 +1,14 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO") -> None:
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
return logging.getLogger(name)
|
||||
39
backend/services/loyalty/app/core/security.py
Normal file
39
backend/services/loyalty/app/core/security.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""JWT authentication dependencies for Loyalty service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
|
||||
from app.core.config import settings
|
||||
from shared.auth import JWTSettings, JWTValidator
|
||||
from shared.exceptions import UnauthorizedError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
_bearer = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_jwt_validator() -> JWTValidator:
|
||||
return JWTValidator(
|
||||
JWTSettings(
|
||||
keycloak_enabled=settings.keycloak_enabled,
|
||||
keycloak_server_url=settings.keycloak_server_url,
|
||||
keycloak_realm=settings.keycloak_realm,
|
||||
jwt_algorithm=settings.jwt_algorithm,
|
||||
jwt_audience=settings.jwt_audience,
|
||||
jwt_verify_signature=settings.jwt_verify_signature,
|
||||
jwt_issuer=settings.keycloak_public_realm_url,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||
) -> CurrentUser:
|
||||
if not settings.auth_required:
|
||||
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||
return await get_jwt_validator().validate(credentials.credentials)
|
||||
100
backend/services/loyalty/app/events/publisher.py
Normal file
100
backend/services/loyalty/app/events/publisher.py
Normal file
@ -0,0 +1,100 @@
|
||||
"""Loyalty event publisher — transactional outbox (ADR-006) + in-memory test double."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Protocol
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from shared.events import EventEnvelope, EventStatus
|
||||
|
||||
from app.core.config import settings
|
||||
from app.events.types import LoyaltyEventType
|
||||
from app.models.outbox import OutboxEvent
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
async def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: LoyaltyEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope: ...
|
||||
|
||||
|
||||
class InMemoryEventPublisher:
|
||||
"""Records published envelopes for tests and local verification."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.published: list[EventEnvelope] = []
|
||||
|
||||
def record(self, envelope: EventEnvelope) -> EventEnvelope:
|
||||
self.published.append(envelope)
|
||||
return envelope
|
||||
|
||||
|
||||
class TransactionalEventPublisher:
|
||||
"""Persist outbox row in the current transaction; optional in-memory mirror for tests."""
|
||||
|
||||
def __init__(
|
||||
self, session: AsyncSession, memory: InMemoryEventPublisher | None = None
|
||||
) -> None:
|
||||
self.session = session
|
||||
if memory is not None:
|
||||
self.memory = memory
|
||||
elif settings.environment == "test":
|
||||
self.memory = get_event_publisher()
|
||||
else:
|
||||
self.memory = None
|
||||
|
||||
async def publish(
|
||||
self,
|
||||
*,
|
||||
event_type: LoyaltyEventType,
|
||||
aggregate_type: str,
|
||||
aggregate_id: UUID,
|
||||
tenant_id: UUID,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> EventEnvelope:
|
||||
envelope = EventEnvelope(
|
||||
event_id=uuid4(),
|
||||
event_type=event_type.value,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=str(aggregate_id),
|
||||
tenant_id=tenant_id,
|
||||
source_service=settings.service_name,
|
||||
payload=payload or {},
|
||||
)
|
||||
row = OutboxEvent(
|
||||
tenant_id=tenant_id,
|
||||
event_type=envelope.event_type,
|
||||
aggregate_type=aggregate_type,
|
||||
aggregate_id=str(aggregate_id),
|
||||
payload={
|
||||
"event_id": str(envelope.event_id),
|
||||
"source_service": settings.service_name,
|
||||
**(payload or {}),
|
||||
},
|
||||
status=EventStatus.PENDING,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
if self.memory is not None:
|
||||
self.memory.record(envelope)
|
||||
return envelope
|
||||
|
||||
|
||||
_default_publisher = InMemoryEventPublisher()
|
||||
|
||||
|
||||
def get_event_publisher() -> InMemoryEventPublisher:
|
||||
return _default_publisher
|
||||
|
||||
|
||||
def reset_event_publisher() -> InMemoryEventPublisher:
|
||||
global _default_publisher
|
||||
_default_publisher = InMemoryEventPublisher()
|
||||
return _default_publisher
|
||||
33
backend/services/loyalty/app/events/types.py
Normal file
33
backend/services/loyalty/app/events/types.py
Normal file
@ -0,0 +1,33 @@
|
||||
"""Loyalty event type contracts (publish-only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class LoyaltyEventType(str, enum.Enum):
|
||||
PROGRAM_CREATED = "loyalty.program.created"
|
||||
PROGRAM_UPDATED = "loyalty.program.updated"
|
||||
PROGRAM_DELETED = "loyalty.program.deleted"
|
||||
TIER_CREATED = "loyalty.tier.created"
|
||||
TIER_UPDATED = "loyalty.tier.updated"
|
||||
TIER_DELETED = "loyalty.tier.deleted"
|
||||
MEMBER_CREATED = "loyalty.member.created"
|
||||
MEMBER_UPDATED = "loyalty.member.updated"
|
||||
MEMBER_ENROLLED = "loyalty.member.enrolled"
|
||||
MEMBER_ACTIVATED = "loyalty.member.activated"
|
||||
MEMBER_RENEWED = "loyalty.member.renewed"
|
||||
MEMBER_FROZEN = "loyalty.member.frozen"
|
||||
MEMBER_RESUMED = "loyalty.member.resumed"
|
||||
MEMBER_CANCELLED = "loyalty.member.cancelled"
|
||||
MEMBER_EXPIRED = "loyalty.member.expired"
|
||||
MEMBER_TRANSFERRED = "loyalty.member.transferred"
|
||||
MEMBER_DELETED = "loyalty.member.deleted"
|
||||
POINT_ACCOUNT_OPENED = "loyalty.point_account.opened"
|
||||
POINT_ACCOUNT_UPDATED = "loyalty.point_account.updated"
|
||||
POINT_ACCOUNT_DELETED = "loyalty.point_account.deleted"
|
||||
REWARD_CREATED = "loyalty.reward.created"
|
||||
REWARD_UPDATED = "loyalty.reward.updated"
|
||||
REWARD_DELETED = "loyalty.reward.deleted"
|
||||
CAMPAIGN_CREATED = "loyalty.campaign.created"
|
||||
CAMPAIGN_UPDATED = "loyalty.campaign.updated"
|
||||
CAMPAIGN_DELETED = "loyalty.campaign.deleted"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user