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_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db
|
||||||
CRM_SERVICE_NAME=crm-service
|
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) ----
|
# ---- OTP / Payamak SMS (Core Service) ----
|
||||||
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
|
||||||
PAYAMAK_USERNAME=9155105404
|
PAYAMAK_USERNAME=9155105404
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
| [`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/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||||
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
| [`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/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||||
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||||
@ -36,7 +37,7 @@ TorbatYar/
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ ├── core-service/ # FastAPI Core Platform
|
│ ├── core-service/ # FastAPI Core Platform
|
||||||
│ ├── shared-lib/ # Shared backend library
|
│ ├── shared-lib/ # Shared backend library
|
||||||
│ └── services/ # Identity, Accounting, CRM (+ future modules)
|
│ └── services/ # Identity, Accounting, CRM, Loyalty, Communication, Sports Center (+ future)
|
||||||
├── frontend/ # Next.js
|
├── frontend/ # Next.js
|
||||||
├── docs/ # Documentation architecture (canonical)
|
├── docs/ # Documentation architecture (canonical)
|
||||||
├── infrastructure/ # Nginx, Keycloak, deploy env samples
|
├── infrastructure/ # Nginx, Keycloak, deploy env samples
|
||||||
@ -58,6 +59,9 @@ docker compose up -d --build
|
|||||||
- **Identity API:** http://localhost:8001/docs
|
- **Identity API:** http://localhost:8001/docs
|
||||||
- **Accounting API:** http://localhost:8002/docs
|
- **Accounting API:** http://localhost:8002/docs
|
||||||
- **CRM API:** http://localhost:8003/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
|
- **Keycloak:** http://localhost:8080
|
||||||
|
|
||||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.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