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:
Mortezakoohjani 2026-07-25 22:35:23 +03:30
parent 067b499193
commit e41ecfad4c
288 changed files with 37244 additions and 1515 deletions

View File

@ -90,6 +90,24 @@ CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/c
CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db
CRM_SERVICE_NAME=crm-service
# ---- Loyalty Service ----
LOYALTY_SERVICE_URL=http://loyalty-service:8004
LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db
LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db
LOYALTY_SERVICE_NAME=loyalty-service
# ---- Communication Service (Phase 8) ----
COMMUNICATION_SERVICE_URL=http://communication-service:8005
COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db
COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db
COMMUNICATION_SERVICE_NAME=communication-service
# ---- Sports Center Service (Phase 9.0) ----
SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006
SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db
SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db
SPORTS_CENTER_SERVICE_NAME=sports-center-service
# ---- OTP / Payamak SMS (Core Service) ----
# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک)
PAYAMAK_USERNAME=9155105404

View File

@ -15,6 +15,7 @@
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) |
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
| [`docs/progress.md`](./docs/progress.md) | Completed work |
@ -36,7 +37,7 @@ TorbatYar/
├── backend/
│ ├── core-service/ # FastAPI Core Platform
│ ├── shared-lib/ # Shared backend library
│ └── services/ # Identity, Accounting, CRM (+ future modules)
│ └── services/ # Identity, Accounting, CRM, Loyalty, Communication, Sports Center (+ future)
├── frontend/ # Next.js
├── docs/ # Documentation architecture (canonical)
├── infrastructure/ # Nginx, Keycloak, deploy env samples
@ -58,6 +59,9 @@ docker compose up -d --build
- **Identity API:** http://localhost:8001/docs
- **Accounting API:** http://localhost:8002/docs
- **CRM API:** http://localhost:8003/docs
- **Loyalty API:** http://localhost:8004/docs
- **Communication API:** http://localhost:8005/docs
- **Sports Center API:** http://localhost:8006/docs
- **Keycloak:** http://localhost:8080
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).

View 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

View 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`.

View 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

View 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()

View File

@ -0,0 +1,19 @@
"""Initial Communication schema — Phase 8.08.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)

View File

@ -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

View File

@ -0,0 +1 @@
__version__ = "0.8.10.1"

View 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

View 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

View 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"])

View 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
]

View 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"],
}

View 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)

View 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)

View 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)

View 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)

View 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)

View 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
)

View 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()

View 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

View 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)

View 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)

View 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

View 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"

View 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()

View 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)

View 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",
]

View 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)

View 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)

View 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"

View File

@ -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.",
]

View 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"]},
]

View 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: ...

View 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")

View 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")

View 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())

View 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

View 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

View 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)

View 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

View 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)

View File

@ -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

View 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,
}

View 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))

View 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()}"

View 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

View 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

View 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()

View 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)}

View 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()

View File

@ -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)

View 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"

View 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

View File

@ -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()

View 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"})

View 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)

View File

@ -0,0 +1,7 @@
[pytest]
asyncio_mode = auto
testpaths = app/tests
pythonpath = .
python_files = test_*.py
python_classes = Test*
python_functions = test_*

View 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

View 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()

View 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

View 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

View File

@ -0,0 +1 @@
__version__ = "0.10.0.0"

View 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()

View 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

View 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)

View 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)

View 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)

View 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,
)

View 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)

View 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"),
)

View 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"

View 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())

View 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_*

View 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

View 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

View 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)

View 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

View 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()

View 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)

View File

@ -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")

View File

@ -0,0 +1 @@
__version__ = "0.7.1.0"

View 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

View 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

View 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"])

View 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
)

View 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
)

View 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__,
}

View 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)

View 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
)

View 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
)

View 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)

View 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)

View 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()

View 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

View 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)

View 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)

View 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

View 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