diff --git a/.env.example b/.env.example index 7dfc9e5..37c3610 100644 --- a/.env.example +++ b/.env.example @@ -90,6 +90,24 @@ CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/c CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db CRM_SERVICE_NAME=crm-service +# ---- Loyalty Service ---- +LOYALTY_SERVICE_URL=http://loyalty-service:8004 +LOYALTY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/loyalty_db +LOYALTY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/loyalty_db +LOYALTY_SERVICE_NAME=loyalty-service + +# ---- Communication Service (Phase 8) ---- +COMMUNICATION_SERVICE_URL=http://communication-service:8005 +COMMUNICATION_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/communication_db +COMMUNICATION_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/communication_db +COMMUNICATION_SERVICE_NAME=communication-service + +# ---- Sports Center Service (Phase 9.0) ---- +SPORTS_CENTER_SERVICE_URL=http://sports-center-service:8006 +SPORTS_CENTER_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/sports_center_db +SPORTS_CENTER_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/sports_center_db +SPORTS_CENTER_SERVICE_NAME=sports-center-service + # ---- OTP / Payamak SMS (Core Service) ---- # ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملی‌پیامک) PAYAMAK_USERNAME=9155105404 diff --git a/README.md b/README.md index 643dc30..6bfbc4d 100644 --- a/README.md +++ b/README.md @@ -1,74 +1,78 @@ -# TorbatYar SuperApp SaaS Platform - -پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready. - -> برند، رنگ‌ها و تنظیمات هیچ‌کدام در کد hardcode نشده‌اند و همگی از `.env` / `config` / دیتابیس خوانده می‌شوند. - -## Documentation (source of truth) - -شروع از [`docs/README.md`](./docs/README.md). - -| Document | Responsibility | -| --- | --- | -| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview | -| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference | -| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts | -| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide | -| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry | -| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry | -| [`docs/glossary.md`](./docs/glossary.md) | Glossary | -| [`docs/progress.md`](./docs/progress.md) | Completed work | -| [`docs/next-steps.md`](./docs/next-steps.md) | Immediate next milestone | -| [`docs/roadmap.md`](./docs/roadmap.md) | Future roadmap | -| [`docs/deployment/`](./docs/deployment/) | Deployment runbooks | - -## Architecture at a glance - -- **Mandatory Frontend/Backend separation** — `backend/` and `frontend/` are independent apps. -- **Database-per-service** — no cross-DB queries ([ADR-001](./docs/architecture/adr/ADR-001.md)). -- **Inter-service communication** — REST, Webhook, Async Event, Outbox/Inbox only. -- **Multi-tenancy** — business tables carry `tenant_id`. - -## Project structure - -``` -TorbatYar/ -├── backend/ -│ ├── core-service/ # FastAPI Core Platform -│ ├── shared-lib/ # Shared backend library -│ └── services/ # Identity, Accounting, CRM (+ future modules) -├── frontend/ # Next.js -├── docs/ # Documentation architecture (canonical) -├── infrastructure/ # Nginx, Keycloak, deploy env samples -├── scripts/ # Ops / verification scripts -├── docker-compose.yml -├── .env.example -└── README.md -``` - -## Quick start (Docker) - -```bash -cp .env.example .env -docker compose up -d --build -``` - -- **Frontend:** http://localhost:3000 -- **Core API:** http://localhost:8000/docs -- **Identity API:** http://localhost:8001/docs -- **Accounting API:** http://localhost:8002/docs -- **CRM API:** http://localhost:8003/docs -- **Keycloak:** http://localhost:8080 - -Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md). - -## Tests (Backend) - -```bash -cd backend/core-service -pytest -q -``` - -## Current status - -See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md). +# TorbatYar SuperApp SaaS Platform + +پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready. + +> برند، رنگ‌ها و تنظیمات هیچ‌کدام در کد hardcode نشده‌اند و همگی از `.env` / `config` / دیتابیس خوانده می‌شوند. + +## Documentation (source of truth) + +شروع از [`docs/README.md`](./docs/README.md). + +| Document | Responsibility | +| --- | --- | +| [`docs/architecture/architecture.md`](./docs/architecture/architecture.md) | Architecture overview | +| [`docs/reference/database-schema.md`](./docs/reference/database-schema.md) | Database schema reference | +| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts | +| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide | +| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry | +| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) | +| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry | +| [`docs/glossary.md`](./docs/glossary.md) | Glossary | +| [`docs/progress.md`](./docs/progress.md) | Completed work | +| [`docs/next-steps.md`](./docs/next-steps.md) | Immediate next milestone | +| [`docs/roadmap.md`](./docs/roadmap.md) | Future roadmap | +| [`docs/deployment/`](./docs/deployment/) | Deployment runbooks | + +## Architecture at a glance + +- **Mandatory Frontend/Backend separation** — `backend/` and `frontend/` are independent apps. +- **Database-per-service** — no cross-DB queries ([ADR-001](./docs/architecture/adr/ADR-001.md)). +- **Inter-service communication** — REST, Webhook, Async Event, Outbox/Inbox only. +- **Multi-tenancy** — business tables carry `tenant_id`. + +## Project structure + +``` +TorbatYar/ +├── backend/ +│ ├── core-service/ # FastAPI Core Platform +│ ├── shared-lib/ # Shared backend library +│ └── services/ # Identity, Accounting, CRM, Loyalty, Communication, Sports Center (+ future) +├── frontend/ # Next.js +├── docs/ # Documentation architecture (canonical) +├── infrastructure/ # Nginx, Keycloak, deploy env samples +├── scripts/ # Ops / verification scripts +├── docker-compose.yml +├── .env.example +└── README.md +``` + +## Quick start (Docker) + +```bash +cp .env.example .env +docker compose up -d --build +``` + +- **Frontend:** http://localhost:3000 +- **Core API:** http://localhost:8000/docs +- **Identity API:** http://localhost:8001/docs +- **Accounting API:** http://localhost:8002/docs +- **CRM API:** http://localhost:8003/docs +- **Loyalty API:** http://localhost:8004/docs +- **Communication API:** http://localhost:8005/docs +- **Sports Center API:** http://localhost:8006/docs +- **Keycloak:** http://localhost:8080 + +Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md). + +## Tests (Backend) + +```bash +cd backend/core-service +pytest -q +``` + +## Current status + +See [`docs/progress.md`](./docs/progress.md). Next milestone: [`docs/next-steps.md`](./docs/next-steps.md). diff --git a/backend/services/communication/Dockerfile.dev b/backend/services/communication/Dockerfile.dev new file mode 100644 index 0000000..814d16f --- /dev/null +++ b/backend/services/communication/Dockerfile.dev @@ -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 diff --git a/backend/services/communication/README.md b/backend/services/communication/README.md new file mode 100644 index 0000000..daa0fd2 --- /dev/null +++ b/backend/services/communication/README.md @@ -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`. diff --git a/backend/services/communication/alembic.ini b/backend/services/communication/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/communication/alembic.ini @@ -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 diff --git a/backend/services/communication/alembic/env.py b/backend/services/communication/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/communication/alembic/env.py @@ -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() diff --git a/backend/services/communication/alembic/versions/0001_initial.py b/backend/services/communication/alembic/versions/0001_initial.py new file mode 100644 index 0000000..c918f8e --- /dev/null +++ b/backend/services/communication/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Communication schema — Phase 8.0–8.10.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + Base.metadata.drop_all(bind=bind) diff --git a/backend/services/communication/alembic/versions/0002_validation_hardening.py b/backend/services/communication/alembic/versions/0002_validation_hardening.py new file mode 100644 index 0000000..915a56e --- /dev/null +++ b/backend/services/communication/alembic/versions/0002_validation_hardening.py @@ -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 diff --git a/backend/services/communication/app/__init__.py b/backend/services/communication/app/__init__.py new file mode 100644 index 0000000..3a1aa57 --- /dev/null +++ b/backend/services/communication/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.8.10.1" diff --git a/backend/services/communication/app/api/deps.py b/backend/services/communication/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/communication/app/api/deps.py @@ -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 diff --git a/backend/services/communication/app/api/permissions.py b/backend/services/communication/app/api/permissions.py new file mode 100644 index 0000000..d2a8cd0 --- /dev/null +++ b/backend/services/communication/app/api/permissions.py @@ -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 diff --git a/backend/services/communication/app/api/v1/__init__.py b/backend/services/communication/app/api/v1/__init__.py new file mode 100644 index 0000000..54d76cd --- /dev/null +++ b/backend/services/communication/app/api/v1/__init__.py @@ -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"]) diff --git a/backend/services/communication/app/api/v1/contacts.py b/backend/services/communication/app/api/v1/contacts.py new file mode 100644 index 0000000..ef884a1 --- /dev/null +++ b/backend/services/communication/app/api/v1/contacts.py @@ -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 + ] diff --git a/backend/services/communication/app/api/v1/health.py b/backend/services/communication/app/api/v1/health.py new file mode 100644 index 0000000..c5c30b8 --- /dev/null +++ b/backend/services/communication/app/api/v1/health.py @@ -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"], + } diff --git a/backend/services/communication/app/api/v1/messages.py b/backend/services/communication/app/api/v1/messages.py new file mode 100644 index 0000000..e5087dd --- /dev/null +++ b/backend/services/communication/app/api/v1/messages.py @@ -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) diff --git a/backend/services/communication/app/api/v1/monitoring.py b/backend/services/communication/app/api/v1/monitoring.py new file mode 100644 index 0000000..71934be --- /dev/null +++ b/backend/services/communication/app/api/v1/monitoring.py @@ -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) diff --git a/backend/services/communication/app/api/v1/otp.py b/backend/services/communication/app/api/v1/otp.py new file mode 100644 index 0000000..281df28 --- /dev/null +++ b/backend/services/communication/app/api/v1/otp.py @@ -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) diff --git a/backend/services/communication/app/api/v1/providers.py b/backend/services/communication/app/api/v1/providers.py new file mode 100644 index 0000000..07c3908 --- /dev/null +++ b/backend/services/communication/app/api/v1/providers.py @@ -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) diff --git a/backend/services/communication/app/api/v1/templates.py b/backend/services/communication/app/api/v1/templates.py new file mode 100644 index 0000000..b49171a --- /dev/null +++ b/backend/services/communication/app/api/v1/templates.py @@ -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) diff --git a/backend/services/communication/app/api/v1/webhooks.py b/backend/services/communication/app/api/v1/webhooks.py new file mode 100644 index 0000000..d8c0b33 --- /dev/null +++ b/backend/services/communication/app/api/v1/webhooks.py @@ -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 + ) diff --git a/backend/services/communication/app/core/config.py b/backend/services/communication/app/core/config.py new file mode 100644 index 0000000..7e7c531 --- /dev/null +++ b/backend/services/communication/app/core/config.py @@ -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() diff --git a/backend/services/communication/app/core/database.py b/backend/services/communication/app/core/database.py new file mode 100644 index 0000000..a4292ca --- /dev/null +++ b/backend/services/communication/app/core/database.py @@ -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 diff --git a/backend/services/communication/app/core/logging.py b/backend/services/communication/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/communication/app/core/logging.py @@ -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) diff --git a/backend/services/communication/app/core/security.py b/backend/services/communication/app/core/security.py new file mode 100644 index 0000000..bcf8e38 --- /dev/null +++ b/backend/services/communication/app/core/security.py @@ -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) diff --git a/backend/services/communication/app/events/publisher.py b/backend/services/communication/app/events/publisher.py new file mode 100644 index 0000000..6b97330 --- /dev/null +++ b/backend/services/communication/app/events/publisher.py @@ -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 diff --git a/backend/services/communication/app/events/types.py b/backend/services/communication/app/events/types.py new file mode 100644 index 0000000..d503f81 --- /dev/null +++ b/backend/services/communication/app/events/types.py @@ -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" diff --git a/backend/services/communication/app/main.py b/backend/services/communication/app/main.py new file mode 100644 index 0000000..b35ca74 --- /dev/null +++ b/backend/services/communication/app/main.py @@ -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() diff --git a/backend/services/communication/app/middlewares/tenant.py b/backend/services/communication/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/communication/app/middlewares/tenant.py @@ -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) diff --git a/backend/services/communication/app/models/__init__.py b/backend/services/communication/app/models/__init__.py new file mode 100644 index 0000000..1e691cc --- /dev/null +++ b/backend/services/communication/app/models/__init__.py @@ -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", +] diff --git a/backend/services/communication/app/models/base.py b/backend/services/communication/app/models/base.py new file mode 100644 index 0000000..f4021cb --- /dev/null +++ b/backend/services/communication/app/models/base.py @@ -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) diff --git a/backend/services/communication/app/models/foundation.py b/backend/services/communication/app/models/foundation.py new file mode 100644 index 0000000..d85b2ed --- /dev/null +++ b/backend/services/communication/app/models/foundation.py @@ -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) diff --git a/backend/services/communication/app/models/types.py b/backend/services/communication/app/models/types.py new file mode 100644 index 0000000..eef7765 --- /dev/null +++ b/backend/services/communication/app/models/types.py @@ -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" diff --git a/backend/services/communication/app/permissions/definitions.py b/backend/services/communication/app/permissions/definitions.py new file mode 100644 index 0000000..9cb6028 --- /dev/null +++ b/backend/services/communication/app/permissions/definitions.py @@ -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.", +] diff --git a/backend/services/communication/app/providers/__init__.py b/backend/services/communication/app/providers/__init__.py new file mode 100644 index 0000000..a8c1dd2 --- /dev/null +++ b/backend/services/communication/app/providers/__init__.py @@ -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"]}, + ] diff --git a/backend/services/communication/app/providers/contracts.py b/backend/services/communication/app/providers/contracts.py new file mode 100644 index 0000000..2ba59ed --- /dev/null +++ b/backend/services/communication/app/providers/contracts.py @@ -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: ... diff --git a/backend/services/communication/app/providers/mock_sms.py b/backend/services/communication/app/providers/mock_sms.py new file mode 100644 index 0000000..7569979 --- /dev/null +++ b/backend/services/communication/app/providers/mock_sms.py @@ -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") diff --git a/backend/services/communication/app/providers/payamak.py b/backend/services/communication/app/providers/payamak.py new file mode 100644 index 0000000..60a6bb2 --- /dev/null +++ b/backend/services/communication/app/providers/payamak.py @@ -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") diff --git a/backend/services/communication/app/repositories/base.py b/backend/services/communication/app/repositories/base.py new file mode 100644 index 0000000..2e163cc --- /dev/null +++ b/backend/services/communication/app/repositories/base.py @@ -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()) diff --git a/backend/services/communication/app/repositories/foundation.py b/backend/services/communication/app/repositories/foundation.py new file mode 100644 index 0000000..ad7f38b --- /dev/null +++ b/backend/services/communication/app/repositories/foundation.py @@ -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 diff --git a/backend/services/communication/app/schemas/common.py b/backend/services/communication/app/schemas/common.py new file mode 100644 index 0000000..ec87db5 --- /dev/null +++ b/backend/services/communication/app/schemas/common.py @@ -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 diff --git a/backend/services/communication/app/services/audit_service.py b/backend/services/communication/app/services/audit_service.py new file mode 100644 index 0000000..1d85e6c --- /dev/null +++ b/backend/services/communication/app/services/audit_service.py @@ -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) diff --git a/backend/services/communication/app/services/contact_service.py b/backend/services/communication/app/services/contact_service.py new file mode 100644 index 0000000..19a859c --- /dev/null +++ b/backend/services/communication/app/services/contact_service.py @@ -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 diff --git a/backend/services/communication/app/services/message_service.py b/backend/services/communication/app/services/message_service.py new file mode 100644 index 0000000..3d8b119 --- /dev/null +++ b/backend/services/communication/app/services/message_service.py @@ -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) diff --git a/backend/services/communication/app/services/monitoring_service.py b/backend/services/communication/app/services/monitoring_service.py new file mode 100644 index 0000000..4c117b4 --- /dev/null +++ b/backend/services/communication/app/services/monitoring_service.py @@ -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 diff --git a/backend/services/communication/app/services/otp_service.py b/backend/services/communication/app/services/otp_service.py new file mode 100644 index 0000000..b3e3014 --- /dev/null +++ b/backend/services/communication/app/services/otp_service.py @@ -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, + } diff --git a/backend/services/communication/app/services/provider_service.py b/backend/services/communication/app/services/provider_service.py new file mode 100644 index 0000000..97e12b3 --- /dev/null +++ b/backend/services/communication/app/services/provider_service.py @@ -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)) diff --git a/backend/services/communication/app/services/queue_engine.py b/backend/services/communication/app/services/queue_engine.py new file mode 100644 index 0000000..9c05618 --- /dev/null +++ b/backend/services/communication/app/services/queue_engine.py @@ -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()}" diff --git a/backend/services/communication/app/services/router.py b/backend/services/communication/app/services/router.py new file mode 100644 index 0000000..dfd6586 --- /dev/null +++ b/backend/services/communication/app/services/router.py @@ -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 diff --git a/backend/services/communication/app/services/template_service.py b/backend/services/communication/app/services/template_service.py new file mode 100644 index 0000000..c5b1f15 --- /dev/null +++ b/backend/services/communication/app/services/template_service.py @@ -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 diff --git a/backend/services/communication/app/services/webhook_service.py b/backend/services/communication/app/services/webhook_service.py new file mode 100644 index 0000000..ebd7a17 --- /dev/null +++ b/backend/services/communication/app/services/webhook_service.py @@ -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() diff --git a/backend/services/communication/app/tests/conftest.py b/backend/services/communication/app/tests/conftest.py new file mode 100644 index 0000000..6003d89 --- /dev/null +++ b/backend/services/communication/app/tests/conftest.py @@ -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)} diff --git a/backend/services/communication/app/tests/test_architecture.py b/backend/services/communication/app/tests/test_architecture.py new file mode 100644 index 0000000..1ebbadb --- /dev/null +++ b/backend/services/communication/app/tests/test_architecture.py @@ -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() diff --git a/backend/services/communication/app/tests/test_health_security.py b/backend/services/communication/app/tests/test_health_security.py new file mode 100644 index 0000000..75db401 --- /dev/null +++ b/backend/services/communication/app/tests/test_health_security.py @@ -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) diff --git a/backend/services/communication/app/tests/test_providers_sms.py b/backend/services/communication/app/tests/test_providers_sms.py new file mode 100644 index 0000000..29b07aa --- /dev/null +++ b/backend/services/communication/app/tests/test_providers_sms.py @@ -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" diff --git a/backend/services/communication/app/tests/test_templates_otp.py b/backend/services/communication/app/tests/test_templates_otp.py new file mode 100644 index 0000000..683ca76 --- /dev/null +++ b/backend/services/communication/app/tests/test_templates_otp.py @@ -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 diff --git a/backend/services/communication/app/tests/test_validation_suite.py b/backend/services/communication/app/tests/test_validation_suite.py new file mode 100644 index 0000000..ed6c2ba --- /dev/null +++ b/backend/services/communication/app/tests/test_validation_suite.py @@ -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() diff --git a/backend/services/communication/app/tests/test_validators.py b/backend/services/communication/app/tests/test_validators.py new file mode 100644 index 0000000..c30ef0f --- /dev/null +++ b/backend/services/communication/app/tests/test_validators.py @@ -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"}) diff --git a/backend/services/communication/app/validators/__init__.py b/backend/services/communication/app/validators/__init__.py new file mode 100644 index 0000000..1fbd54f --- /dev/null +++ b/backend/services/communication/app/validators/__init__.py @@ -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) diff --git a/backend/services/communication/pytest.ini b/backend/services/communication/pytest.ini new file mode 100644 index 0000000..b396e8d --- /dev/null +++ b/backend/services/communication/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +asyncio_mode = auto +testpaths = app/tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/communication/requirements.txt b/backend/services/communication/requirements.txt new file mode 100644 index 0000000..1647b01 --- /dev/null +++ b/backend/services/communication/requirements.txt @@ -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 diff --git a/backend/services/communication/scripts/ensure_db.py b/backend/services/communication/scripts/ensure_db.py new file mode 100644 index 0000000..1fdba46 --- /dev/null +++ b/backend/services/communication/scripts/ensure_db.py @@ -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() diff --git a/backend/services/hospitality/Dockerfile.dev b/backend/services/hospitality/Dockerfile.dev new file mode 100644 index 0000000..a3e7c6e --- /dev/null +++ b/backend/services/hospitality/Dockerfile.dev @@ -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 diff --git a/backend/services/hospitality/alembic.ini b/backend/services/hospitality/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/hospitality/alembic.ini @@ -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 diff --git a/backend/services/hospitality/app/__init__.py b/backend/services/hospitality/app/__init__.py new file mode 100644 index 0000000..f53c165 --- /dev/null +++ b/backend/services/hospitality/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.10.0.0" diff --git a/backend/services/hospitality/app/core/config.py b/backend/services/hospitality/app/core/config.py new file mode 100644 index 0000000..2d1450b --- /dev/null +++ b/backend/services/hospitality/app/core/config.py @@ -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() diff --git a/backend/services/hospitality/app/core/database.py b/backend/services/hospitality/app/core/database.py new file mode 100644 index 0000000..c5bcec0 --- /dev/null +++ b/backend/services/hospitality/app/core/database.py @@ -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 diff --git a/backend/services/hospitality/app/core/logging.py b/backend/services/hospitality/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/hospitality/app/core/logging.py @@ -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) diff --git a/backend/services/hospitality/app/core/security.py b/backend/services/hospitality/app/core/security.py new file mode 100644 index 0000000..8508470 --- /dev/null +++ b/backend/services/hospitality/app/core/security.py @@ -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) diff --git a/backend/services/hospitality/app/middlewares/tenant.py b/backend/services/hospitality/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/hospitality/app/middlewares/tenant.py @@ -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) diff --git a/backend/services/hospitality/app/models/__init__.py b/backend/services/hospitality/app/models/__init__.py new file mode 100644 index 0000000..435240c --- /dev/null +++ b/backend/services/hospitality/app/models/__init__.py @@ -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, +) diff --git a/backend/services/hospitality/app/models/base.py b/backend/services/hospitality/app/models/base.py new file mode 100644 index 0000000..195e800 --- /dev/null +++ b/backend/services/hospitality/app/models/base.py @@ -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) diff --git a/backend/services/hospitality/app/models/foundation.py b/backend/services/hospitality/app/models/foundation.py new file mode 100644 index 0000000..54c4855 --- /dev/null +++ b/backend/services/hospitality/app/models/foundation.py @@ -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"), + ) diff --git a/backend/services/hospitality/app/models/types.py b/backend/services/hospitality/app/models/types.py new file mode 100644 index 0000000..979995d --- /dev/null +++ b/backend/services/hospitality/app/models/types.py @@ -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" diff --git a/backend/services/hospitality/app/repositories/base.py b/backend/services/hospitality/app/repositories/base.py new file mode 100644 index 0000000..39f8172 --- /dev/null +++ b/backend/services/hospitality/app/repositories/base.py @@ -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()) diff --git a/backend/services/hospitality/pytest.ini b/backend/services/hospitality/pytest.ini new file mode 100644 index 0000000..f1f608e --- /dev/null +++ b/backend/services/hospitality/pytest.ini @@ -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_* diff --git a/backend/services/hospitality/requirements.txt b/backend/services/hospitality/requirements.txt new file mode 100644 index 0000000..bbdf395 --- /dev/null +++ b/backend/services/hospitality/requirements.txt @@ -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 diff --git a/backend/services/loyalty/Dockerfile.dev b/backend/services/loyalty/Dockerfile.dev new file mode 100644 index 0000000..b0976c8 --- /dev/null +++ b/backend/services/loyalty/Dockerfile.dev @@ -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 diff --git a/backend/services/loyalty/README.md b/backend/services/loyalty/README.md new file mode 100644 index 0000000..7d90719 --- /dev/null +++ b/backend/services/loyalty/README.md @@ -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) diff --git a/backend/services/loyalty/alembic.ini b/backend/services/loyalty/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/loyalty/alembic.ini @@ -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 diff --git a/backend/services/loyalty/alembic/env.py b/backend/services/loyalty/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/loyalty/alembic/env.py @@ -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() diff --git a/backend/services/loyalty/alembic/versions/0001_initial.py b/backend/services/loyalty/alembic/versions/0001_initial.py new file mode 100644 index 0000000..1e88667 --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0001_initial.py @@ -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) diff --git a/backend/services/loyalty/alembic/versions/0002_phase_71_membership.py b/backend/services/loyalty/alembic/versions/0002_phase_71_membership.py new file mode 100644 index 0000000..64baa3d --- /dev/null +++ b/backend/services/loyalty/alembic/versions/0002_phase_71_membership.py @@ -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") diff --git a/backend/services/loyalty/app/__init__.py b/backend/services/loyalty/app/__init__.py new file mode 100644 index 0000000..0817bb0 --- /dev/null +++ b/backend/services/loyalty/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.7.1.0" diff --git a/backend/services/loyalty/app/api/deps.py b/backend/services/loyalty/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/loyalty/app/api/deps.py @@ -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 diff --git a/backend/services/loyalty/app/api/permissions.py b/backend/services/loyalty/app/api/permissions.py new file mode 100644 index 0000000..186d2a3 --- /dev/null +++ b/backend/services/loyalty/app/api/permissions.py @@ -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 diff --git a/backend/services/loyalty/app/api/v1/__init__.py b/backend/services/loyalty/app/api/v1/__init__.py new file mode 100644 index 0000000..42ca24d --- /dev/null +++ b/backend/services/loyalty/app/api/v1/__init__.py @@ -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"]) diff --git a/backend/services/loyalty/app/api/v1/audit.py b/backend/services/loyalty/app/api/v1/audit.py new file mode 100644 index 0000000..0e002a8 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/audit.py @@ -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 + ) diff --git a/backend/services/loyalty/app/api/v1/campaigns.py b/backend/services/loyalty/app/api/v1/campaigns.py new file mode 100644 index 0000000..4b3956c --- /dev/null +++ b/backend/services/loyalty/app/api/v1/campaigns.py @@ -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 + ) diff --git a/backend/services/loyalty/app/api/v1/health.py b/backend/services/loyalty/app/api/v1/health.py new file mode 100644 index 0000000..f1ab515 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/health.py @@ -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__, + } diff --git a/backend/services/loyalty/app/api/v1/members.py b/backend/services/loyalty/app/api/v1/members.py new file mode 100644 index 0000000..f6205f3 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/members.py @@ -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) diff --git a/backend/services/loyalty/app/api/v1/point_accounts.py b/backend/services/loyalty/app/api/v1/point_accounts.py new file mode 100644 index 0000000..ac1ee87 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/point_accounts.py @@ -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 + ) diff --git a/backend/services/loyalty/app/api/v1/programs.py b/backend/services/loyalty/app/api/v1/programs.py new file mode 100644 index 0000000..631fcd3 --- /dev/null +++ b/backend/services/loyalty/app/api/v1/programs.py @@ -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 + ) diff --git a/backend/services/loyalty/app/api/v1/rewards.py b/backend/services/loyalty/app/api/v1/rewards.py new file mode 100644 index 0000000..b3777ac --- /dev/null +++ b/backend/services/loyalty/app/api/v1/rewards.py @@ -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) diff --git a/backend/services/loyalty/app/api/v1/tiers.py b/backend/services/loyalty/app/api/v1/tiers.py new file mode 100644 index 0000000..4f1540f --- /dev/null +++ b/backend/services/loyalty/app/api/v1/tiers.py @@ -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) diff --git a/backend/services/loyalty/app/core/config.py b/backend/services/loyalty/app/core/config.py new file mode 100644 index 0000000..463d99b --- /dev/null +++ b/backend/services/loyalty/app/core/config.py @@ -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() diff --git a/backend/services/loyalty/app/core/database.py b/backend/services/loyalty/app/core/database.py new file mode 100644 index 0000000..a4292ca --- /dev/null +++ b/backend/services/loyalty/app/core/database.py @@ -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 diff --git a/backend/services/loyalty/app/core/logging.py b/backend/services/loyalty/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/loyalty/app/core/logging.py @@ -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) diff --git a/backend/services/loyalty/app/core/security.py b/backend/services/loyalty/app/core/security.py new file mode 100644 index 0000000..00029d6 --- /dev/null +++ b/backend/services/loyalty/app/core/security.py @@ -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) diff --git a/backend/services/loyalty/app/events/publisher.py b/backend/services/loyalty/app/events/publisher.py new file mode 100644 index 0000000..5f94a4d --- /dev/null +++ b/backend/services/loyalty/app/events/publisher.py @@ -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 diff --git a/backend/services/loyalty/app/events/types.py b/backend/services/loyalty/app/events/types.py new file mode 100644 index 0000000..d4320e0 --- /dev/null +++ b/backend/services/loyalty/app/events/types.py @@ -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" diff --git a/backend/services/loyalty/app/main.py b/backend/services/loyalty/app/main.py new file mode 100644 index 0000000..6f6e656 --- /dev/null +++ b/backend/services/loyalty/app/main.py @@ -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="Loyalty Service", + version=__version__, + description="سرویس Enterprise Loyalty Platform — فاز ۷.۱ Membership Engine", + 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() diff --git a/backend/services/loyalty/app/middlewares/tenant.py b/backend/services/loyalty/app/middlewares/tenant.py new file mode 100644 index 0000000..9db514c --- /dev/null +++ b/backend/services/loyalty/app/middlewares/tenant.py @@ -0,0 +1,33 @@ +"""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 JSONResponse, Response + +from shared.responses import ErrorDetail, ErrorResponse +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: + return JSONResponse( + status_code=400, + content=ErrorResponse( + error=ErrorDetail( + code="invalid_tenant_id", + message="مقدار هدر X-Tenant-ID نامعتبر است", + ) + ).model_dump(), + ) + return await call_next(request) diff --git a/backend/services/loyalty/app/models/__init__.py b/backend/services/loyalty/app/models/__init__.py new file mode 100644 index 0000000..6939882 --- /dev/null +++ b/backend/services/loyalty/app/models/__init__.py @@ -0,0 +1,12 @@ +"""Import all models for Alembic metadata discovery.""" +from app.models.foundation import ( # noqa: F401 + Campaign, + LoyaltyAuditLog, + LoyaltyProgram, + Member, + MembershipLifecycleEvent, + MembershipTier, + PointAccount, + Reward, +) +from app.models.outbox import OutboxEvent # noqa: F401 diff --git a/backend/services/loyalty/app/models/base.py b/backend/services/loyalty/app/models/base.py new file mode 100644 index 0000000..195e800 --- /dev/null +++ b/backend/services/loyalty/app/models/base.py @@ -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) diff --git a/backend/services/loyalty/app/models/foundation.py b/backend/services/loyalty/app/models/foundation.py new file mode 100644 index 0000000..ebd0a0f --- /dev/null +++ b/backend/services/loyalty/app/models/foundation.py @@ -0,0 +1,345 @@ +"""Loyalty foundation aggregates — Phase 7.0. + +Independent aggregates use UUID references within loyalty_db only. +No SQLAlchemy relationship graphs between aggregates. +Balances are never stored as mutable fields — ledger arrives in Phase 7.2. +""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + Index, + Integer, + String, + Text, + UniqueConstraint, + text, +) +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, + CampaignStatus, + GUID, + MemberStatus, + MembershipLifecycleAction, + PointAccountStatus, + ProgramStatus, + RewardStatus, + RewardType, + TierStatus, +) + + +class LoyaltyProgram( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant loyalty program configuration aggregate.""" + + __tablename__ = "loyalty_programs" + + 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[ProgramStatus] = mapped_column( + default=ProgramStatus.DRAFT, nullable=False + ) + points_currency_name: Mapped[str] = mapped_column( + String(50), default="points", 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) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_loyalty_programs_tenant_code" + ), + Index("ix_loyalty_programs_tenant_status", "tenant_id", "status"), + Index("ix_loyalty_programs_tenant_deleted", "tenant_id", "is_deleted"), + Index( + "uq_loyalty_programs_tenant_default_live", + "tenant_id", + unique=True, + sqlite_where=text("is_default = 1 AND is_deleted = 0"), + postgresql_where=text("is_default IS TRUE AND is_deleted IS FALSE"), + ), + ) + + +class MembershipTier( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Membership tier / level definition aggregate (Phase 7.1 eligibility uses rank/min_points).""" + + __tablename__ = "membership_tiers" + + program_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) + rank: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + min_points: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + status: Mapped[TierStatus] = mapped_column(default=TierStatus.ACTIVE, nullable=False) + benefits: Mapped[dict | None] = mapped_column(JSON, nullable=True) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "program_id", "code", name="uq_membership_tiers_tenant_code" + ), + Index("ix_membership_tiers_program", "tenant_id", "program_id"), + Index("ix_membership_tiers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Member( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Loyalty member aggregate with Phase 7.1 lifecycle fields.""" + + __tablename__ = "members" + + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + membership_number: Mapped[str] = mapped_column(String(50), nullable=False) + external_customer_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + status: Mapped[MemberStatus] = mapped_column( + default=MemberStatus.PENDING, nullable=False + ) + tier_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + enrolled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + activated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + membership_started_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + membership_expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + frozen_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + freeze_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + cancelled_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + cancel_reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + expired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + transferred_to_member_id: Mapped[uuid.UUID | None] = mapped_column( + GUID(), nullable=True + ) + profile: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "membership_number", + name="uq_members_tenant_membership_number", + ), + UniqueConstraint( + "tenant_id", + "program_id", + "external_customer_ref", + name="uq_members_tenant_program_external_ref", + ), + Index("ix_members_tenant_program", "tenant_id", "program_id"), + Index("ix_members_tenant_status", "tenant_id", "status"), + Index("ix_members_tenant_email", "tenant_id", "email"), + Index("ix_members_tenant_tier", "tenant_id", "tier_id"), + Index("ix_members_tenant_expires", "tenant_id", "membership_expires_at"), + Index("ix_members_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MembershipLifecycleEvent( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, +): + """Append-only membership lifecycle history (Phase 7.1).""" + + __tablename__ = "membership_lifecycle_events" + + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + action: Mapped[MembershipLifecycleAction] = mapped_column(nullable=False) + from_status: Mapped[str] = mapped_column(String(30), nullable=False) + to_status: Mapped[str] = mapped_column(String(30), nullable=False) + reason: Mapped[str | None] = mapped_column(String(500), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + __table_args__ = ( + Index("ix_membership_lifecycle_member", "tenant_id", "member_id"), + Index("ix_membership_lifecycle_action", "tenant_id", "action"), + Index("ix_membership_lifecycle_created", "tenant_id", "created_at"), + ) + + +class PointAccount( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Point account shell — balance only via immutable ledger (Phase 7.2).""" + + __tablename__ = "point_accounts" + + program_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + account_number: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[PointAccountStatus] = mapped_column( + default=PointAccountStatus.OPEN, nullable=False + ) + currency_name: Mapped[str] = mapped_column( + String(50), default="points", nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "account_number", + name="uq_point_accounts_tenant_account_number", + ), + UniqueConstraint( + "tenant_id", + "member_id", + name="uq_point_accounts_tenant_member", + ), + Index("ix_point_accounts_program", "tenant_id", "program_id"), + Index("ix_point_accounts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Reward( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Reward catalog shell (expanded in Phase 7.3).""" + + __tablename__ = "rewards" + + program_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) + reward_type: Mapped[RewardType] = mapped_column( + default=RewardType.CUSTOM, nullable=False + ) + status: Mapped[RewardStatus] = mapped_column( + default=RewardStatus.DRAFT, nullable=False + ) + points_cost: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + eligibility_rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "program_id", "code", name="uq_rewards_tenant_code"), + Index("ix_rewards_tenant_status", "tenant_id", "status"), + Index("ix_rewards_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Campaign( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Campaign shell (expanded in Phase 7.4). Rules are versioned later.""" + + __tablename__ = "campaigns" + + program_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[CampaignStatus] = mapped_column( + default=CampaignStatus.DRAFT, nullable=False + ) + rule_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "program_id", "code", name="uq_campaigns_tenant_code" + ), + Index("ix_campaigns_tenant_status", "tenant_id", "status"), + Index("ix_campaigns_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class LoyaltyAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only loyalty audit trail.""" + + __tablename__ = "loyalty_audit_logs" + + entity_type: Mapped[str] = mapped_column(String(50), 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_loyalty_audit_entity", + "tenant_id", + "entity_type", + "entity_id", + ), + ) diff --git a/backend/services/loyalty/app/models/outbox.py b/backend/services/loyalty/app/models/outbox.py new file mode 100644 index 0000000..44fa9a3 --- /dev/null +++ b/backend/services/loyalty/app/models/outbox.py @@ -0,0 +1,44 @@ +"""Transactional outbox model — ADR-006.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, Integer, String, func +from sqlalchemy import Enum as SAEnum +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import UUIDPrimaryKeyMixin +from app.models.types import GUID +from shared.events import EventStatus + + +class OutboxEvent(UUIDPrimaryKeyMixin, Base): + """Pending domain events written in the same DB transaction as mutations.""" + + __tablename__ = "outbox_events" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + event_type: Mapped[str] = mapped_column(String(150), nullable=False) + aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False) + aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False) + payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + status: Mapped[EventStatus] = mapped_column( + SAEnum(EventStatus, name="loyalty_event_status", native_enum=False), + default=EventStatus.PENDING, + nullable=False, + ) + retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + processed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + __table_args__ = ( + Index("ix_loyalty_outbox_status", "status"), + Index("ix_loyalty_outbox_tenant", "tenant_id"), + ) diff --git a/backend/services/loyalty/app/models/types.py b/backend/services/loyalty/app/models/types.py new file mode 100644 index 0000000..20e281a --- /dev/null +++ b/backend/services/loyalty/app/models/types.py @@ -0,0 +1,115 @@ +"""Loyalty 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 ProgramStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class MemberStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + SUSPENDED = "suspended" # 7.0 compat; treated as frozen for lifecycle + FROZEN = "frozen" + EXPIRED = "expired" + CANCELLED = "cancelled" + TRANSFERRED = "transferred" + CLOSED = "closed" + + +class MembershipLifecycleAction(str, enum.Enum): + ENROLL = "enroll" + ACTIVATE = "activate" + RENEW = "renew" + FREEZE = "freeze" + RESUME = "resume" + TRANSFER = "transfer" + CANCEL = "cancel" + EXPIRE = "expire" + + +class TierStatus(str, enum.Enum): + ACTIVE = "active" + INACTIVE = "inactive" + ARCHIVED = "archived" + + +class PointAccountStatus(str, enum.Enum): + OPEN = "open" + FROZEN = "frozen" + CLOSED = "closed" + + +class RewardType(str, enum.Enum): + COUPON = "coupon" + DISCOUNT = "discount" + CASHBACK = "cashback" + VOUCHER = "voucher" + GIFT = "gift" + CUSTOM = "custom" + + +class RewardStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + ARCHIVED = "archived" + + +class CampaignStatus(str, enum.Enum): + DRAFT = "draft" + SCHEDULED = "scheduled" + ACTIVE = "active" + PAUSED = "paused" + EXPIRED = "expired" + ARCHIVED = "archived" + + +class AuditAction(str, enum.Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + STATUS_CHANGE = "status_change" + ENROLL = "enroll" + ASSIGN = "assign" + ACTIVATE = "activate" + RENEW = "renew" + FREEZE = "freeze" + RESUME = "resume" + TRANSFER = "transfer" + CANCEL = "cancel" + EXPIRE = "expire" diff --git a/backend/services/loyalty/app/permissions/definitions.py b/backend/services/loyalty/app/permissions/definitions.py new file mode 100644 index 0000000..374ad80 --- /dev/null +++ b/backend/services/loyalty/app/permissions/definitions.py @@ -0,0 +1,106 @@ +"""Loyalty permission definitions — Phase 7.0 foundation.""" +from __future__ import annotations + +LOYALTY_VIEW = "loyalty.view" +LOYALTY_MANAGE = "loyalty.manage" + +LOYALTY_PROGRAMS_VIEW = "loyalty.programs.view" +LOYALTY_PROGRAMS_CREATE = "loyalty.programs.create" +LOYALTY_PROGRAMS_UPDATE = "loyalty.programs.update" +LOYALTY_PROGRAMS_DELETE = "loyalty.programs.delete" +LOYALTY_PROGRAMS_MANAGE = "loyalty.programs.manage" + +LOYALTY_TIERS_VIEW = "loyalty.tiers.view" +LOYALTY_TIERS_CREATE = "loyalty.tiers.create" +LOYALTY_TIERS_UPDATE = "loyalty.tiers.update" +LOYALTY_TIERS_DELETE = "loyalty.tiers.delete" +LOYALTY_TIERS_MANAGE = "loyalty.tiers.manage" + +LOYALTY_MEMBERS_VIEW = "loyalty.members.view" +LOYALTY_MEMBERS_CREATE = "loyalty.members.create" +LOYALTY_MEMBERS_UPDATE = "loyalty.members.update" +LOYALTY_MEMBERS_DELETE = "loyalty.members.delete" +LOYALTY_MEMBERS_MANAGE = "loyalty.members.manage" +LOYALTY_MEMBERS_ENROLL = "loyalty.members.enroll" +LOYALTY_MEMBERS_ACTIVATE = "loyalty.members.activate" +LOYALTY_MEMBERS_RENEW = "loyalty.members.renew" +LOYALTY_MEMBERS_FREEZE = "loyalty.members.freeze" +LOYALTY_MEMBERS_RESUME = "loyalty.members.resume" +LOYALTY_MEMBERS_CANCEL = "loyalty.members.cancel" +LOYALTY_MEMBERS_EXPIRE = "loyalty.members.expire" +LOYALTY_MEMBERS_TRANSFER = "loyalty.members.transfer" +LOYALTY_MEMBERS_LIFECYCLE_VIEW = "loyalty.members.lifecycle.view" + +LOYALTY_POINT_ACCOUNTS_VIEW = "loyalty.point_accounts.view" +LOYALTY_POINT_ACCOUNTS_CREATE = "loyalty.point_accounts.create" +LOYALTY_POINT_ACCOUNTS_UPDATE = "loyalty.point_accounts.update" +LOYALTY_POINT_ACCOUNTS_MANAGE = "loyalty.point_accounts.manage" + +LOYALTY_REWARDS_VIEW = "loyalty.rewards.view" +LOYALTY_REWARDS_CREATE = "loyalty.rewards.create" +LOYALTY_REWARDS_UPDATE = "loyalty.rewards.update" +LOYALTY_REWARDS_DELETE = "loyalty.rewards.delete" +LOYALTY_REWARDS_MANAGE = "loyalty.rewards.manage" + +LOYALTY_CAMPAIGNS_VIEW = "loyalty.campaigns.view" +LOYALTY_CAMPAIGNS_CREATE = "loyalty.campaigns.create" +LOYALTY_CAMPAIGNS_UPDATE = "loyalty.campaigns.update" +LOYALTY_CAMPAIGNS_DELETE = "loyalty.campaigns.delete" +LOYALTY_CAMPAIGNS_MANAGE = "loyalty.campaigns.manage" + +LOYALTY_AUDIT_VIEW = "loyalty.audit.view" + +ALL_PERMISSIONS: list[str] = [ + LOYALTY_VIEW, + LOYALTY_MANAGE, + LOYALTY_PROGRAMS_VIEW, + LOYALTY_PROGRAMS_CREATE, + LOYALTY_PROGRAMS_UPDATE, + LOYALTY_PROGRAMS_DELETE, + LOYALTY_PROGRAMS_MANAGE, + LOYALTY_TIERS_VIEW, + LOYALTY_TIERS_CREATE, + LOYALTY_TIERS_UPDATE, + LOYALTY_TIERS_DELETE, + LOYALTY_TIERS_MANAGE, + LOYALTY_MEMBERS_VIEW, + LOYALTY_MEMBERS_CREATE, + LOYALTY_MEMBERS_UPDATE, + LOYALTY_MEMBERS_DELETE, + LOYALTY_MEMBERS_MANAGE, + LOYALTY_MEMBERS_ENROLL, + LOYALTY_MEMBERS_ACTIVATE, + LOYALTY_MEMBERS_RENEW, + LOYALTY_MEMBERS_FREEZE, + LOYALTY_MEMBERS_RESUME, + LOYALTY_MEMBERS_CANCEL, + LOYALTY_MEMBERS_EXPIRE, + LOYALTY_MEMBERS_TRANSFER, + LOYALTY_MEMBERS_LIFECYCLE_VIEW, + LOYALTY_POINT_ACCOUNTS_VIEW, + LOYALTY_POINT_ACCOUNTS_CREATE, + LOYALTY_POINT_ACCOUNTS_UPDATE, + LOYALTY_POINT_ACCOUNTS_MANAGE, + LOYALTY_REWARDS_VIEW, + LOYALTY_REWARDS_CREATE, + LOYALTY_REWARDS_UPDATE, + LOYALTY_REWARDS_DELETE, + LOYALTY_REWARDS_MANAGE, + LOYALTY_CAMPAIGNS_VIEW, + LOYALTY_CAMPAIGNS_CREATE, + LOYALTY_CAMPAIGNS_UPDATE, + LOYALTY_CAMPAIGNS_DELETE, + LOYALTY_CAMPAIGNS_MANAGE, + LOYALTY_AUDIT_VIEW, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "loyalty.", + "loyalty.programs.", + "loyalty.tiers.", + "loyalty.members.", + "loyalty.point_accounts.", + "loyalty.rewards.", + "loyalty.campaigns.", + "loyalty.audit.", +) diff --git a/backend/services/loyalty/app/providers/__init__.py b/backend/services/loyalty/app/providers/__init__.py new file mode 100644 index 0000000..a096967 --- /dev/null +++ b/backend/services/loyalty/app/providers/__init__.py @@ -0,0 +1,22 @@ +"""Platform provider package — contracts only.""" +from app.providers.contracts import ( + AIProvider, + AnalyticsProvider, + CRMProvider, + CommunicationProvider, + Customer360Provider, + FileStorageProvider, + ModuleIntegrationProvider, + NotificationProvider, +) + +__all__ = [ + "NotificationProvider", + "AnalyticsProvider", + "Customer360Provider", + "AIProvider", + "ModuleIntegrationProvider", + "CRMProvider", + "CommunicationProvider", + "FileStorageProvider", +] diff --git a/backend/services/loyalty/app/providers/contracts.py b/backend/services/loyalty/app/providers/contracts.py new file mode 100644 index 0000000..ec556af --- /dev/null +++ b/backend/services/loyalty/app/providers/contracts.py @@ -0,0 +1,80 @@ +"""Platform capability contracts — interfaces only; no implementations in Loyalty.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class NotificationProvider(Protocol): + """Contract for Notification service. Loyalty must not own delivery.""" + + async def notify( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + payload: dict[str, Any], + ) -> None: ... + + +class AnalyticsProvider(Protocol): + """Contract for Analytics. Loyalty analytics engine arrives in Phase 7.8.""" + + async def track( + self, *, tenant_id: UUID, event_name: str, properties: dict[str, Any] + ) -> None: ... + + +class Customer360Provider(Protocol): + """Contract for Customer360. Loyalty stores external_customer_ref only.""" + + async def upsert_party_reference( + self, *, tenant_id: UUID, source: str, source_id: UUID, payload: dict[str, Any] + ) -> None: ... + + +class AIProvider(Protocol): + """Contract for AI Platform. Loyalty must not own model inference.""" + + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class ModuleIntegrationProvider(Protocol): + """Contract for business modules (Restaurant, Marketplace, Ecommerce, …).""" + + async def resolve_source_event( + self, *, tenant_id: UUID, module_key: str, event_ref: str + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM references. Loyalty does not own CRM entities.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication Platform. Loyalty must not own delivery.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + to: str, + template_key: str, + payload: dict[str, Any], + ) -> None: ... + + +class FileStorageProvider(Protocol): + """Contract for File Storage. Loyalty stores references only.""" + + async def resolve_file( + self, *, tenant_id: UUID, file_storage_id: str + ) -> dict[str, Any]: ... diff --git a/backend/services/loyalty/app/repositories/base.py b/backend/services/loyalty/app/repositories/base.py new file mode 100644 index 0000000..04d608f --- /dev/null +++ b/backend/services/loyalty/app/repositories/base.py @@ -0,0 +1,93 @@ +"""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 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) + .order_by(self.model.created_at.desc()) # type: ignore[attr-defined] + .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()) diff --git a/backend/services/loyalty/app/repositories/foundation.py b/backend/services/loyalty/app/repositories/foundation.py new file mode 100644 index 0000000..5ed8b5b --- /dev/null +++ b/backend/services/loyalty/app/repositories/foundation.py @@ -0,0 +1,213 @@ +"""Loyalty foundation repositories — Phase 7.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + Campaign, + LoyaltyAuditLog, + LoyaltyProgram, + Member, + MembershipLifecycleEvent, + MembershipTier, + PointAccount, + Reward, +) +from app.repositories.base import TenantBaseRepository + + +class LoyaltyProgramRepository(TenantBaseRepository[LoyaltyProgram]): + model = LoyaltyProgram + + async def get_by_code(self, tenant_id: UUID, code: str) -> LoyaltyProgram | None: + stmt = select(LoyaltyProgram).where( + LoyaltyProgram.tenant_id == tenant_id, + LoyaltyProgram.code == code, + LoyaltyProgram.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MembershipTierRepository(TenantBaseRepository[MembershipTier]): + model = MembershipTier + + async def get_by_code( + self, tenant_id: UUID, program_id: UUID, code: str + ) -> MembershipTier | None: + stmt = select(MembershipTier).where( + MembershipTier.tenant_id == tenant_id, + MembershipTier.program_id == program_id, + MembershipTier.code == code, + MembershipTier.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_program( + self, tenant_id: UUID, program_id: UUID, *, offset: int = 0, limit: int = 50 + ): + stmt = ( + select(MembershipTier) + .where( + MembershipTier.tenant_id == tenant_id, + MembershipTier.program_id == program_id, + MembershipTier.is_deleted.is_(False), + ) + .order_by(MembershipTier.rank.asc(), MembershipTier.created_at.desc()) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MemberRepository(TenantBaseRepository[Member]): + model = Member + + async def get_by_membership_number( + self, tenant_id: UUID, membership_number: str + ) -> Member | None: + stmt = select(Member).where( + Member.tenant_id == tenant_id, + Member.membership_number == membership_number, + Member.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_external_ref( + self, tenant_id: UUID, program_id: UUID, external_customer_ref: str + ) -> Member | None: + stmt = select(Member).where( + Member.tenant_id == tenant_id, + Member.program_id == program_id, + Member.external_customer_ref == external_customer_ref, + Member.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_blocking_for_program( + self, tenant_id: UUID, program_id: UUID, statuses: list + ) -> int: + from sqlalchemy import func + + stmt = ( + select(func.count()) + .select_from(Member) + .where( + Member.tenant_id == tenant_id, + Member.program_id == program_id, + Member.is_deleted.is_(False), + Member.status.in_(statuses), + ) + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + +class PointAccountRepository(TenantBaseRepository[PointAccount]): + model = PointAccount + + async def get_by_member( + self, tenant_id: UUID, member_id: UUID + ) -> PointAccount | None: + stmt = select(PointAccount).where( + PointAccount.tenant_id == tenant_id, + PointAccount.member_id == member_id, + PointAccount.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_account_number( + self, tenant_id: UUID, account_number: str + ) -> PointAccount | None: + stmt = select(PointAccount).where( + PointAccount.tenant_id == tenant_id, + PointAccount.account_number == account_number, + PointAccount.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class RewardRepository(TenantBaseRepository[Reward]): + model = Reward + + async def get_by_code( + self, tenant_id: UUID, program_id: UUID, code: str + ) -> Reward | None: + stmt = select(Reward).where( + Reward.tenant_id == tenant_id, + Reward.program_id == program_id, + Reward.code == code, + Reward.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class CampaignRepository(TenantBaseRepository[Campaign]): + model = Campaign + + async def get_by_code( + self, tenant_id: UUID, program_id: UUID, code: str + ) -> Campaign | None: + stmt = select(Campaign).where( + Campaign.tenant_id == tenant_id, + Campaign.program_id == program_id, + Campaign.code == code, + Campaign.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class LoyaltyAuditLogRepository(TenantBaseRepository[LoyaltyAuditLog]): + model = LoyaltyAuditLog + + async def list_for_entity( + self, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int = 100, + ): + stmt = ( + select(LoyaltyAuditLog) + .where( + LoyaltyAuditLog.tenant_id == tenant_id, + LoyaltyAuditLog.entity_type == entity_type, + LoyaltyAuditLog.entity_id == entity_id, + ) + .order_by(LoyaltyAuditLog.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MembershipLifecycleEventRepository( + TenantBaseRepository[MembershipLifecycleEvent] +): + model = MembershipLifecycleEvent + + async def list_for_member( + self, tenant_id: UUID, member_id: UUID, *, limit: int = 100 + ): + stmt = ( + select(MembershipLifecycleEvent) + .where( + MembershipLifecycleEvent.tenant_id == tenant_id, + MembershipLifecycleEvent.member_id == member_id, + ) + .order_by(MembershipLifecycleEvent.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/loyalty/app/schemas/common.py b/backend/services/loyalty/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/loyalty/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/loyalty/app/schemas/foundation.py b/backend/services/loyalty/app/schemas/foundation.py new file mode 100644 index 0000000..cd3dec4 --- /dev/null +++ b/backend/services/loyalty/app/schemas/foundation.py @@ -0,0 +1,374 @@ +"""Loyalty foundation DTOs — Phase 7.0.""" +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + CampaignStatus, + MemberStatus, + PointAccountStatus, + ProgramStatus, + RewardStatus, + RewardType, + TierStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class LoyaltyProgramCreate(BaseModel): + model_config = _FORBID + + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: ProgramStatus = ProgramStatus.DRAFT + points_currency_name: str = Field(default="points", max_length=50) + currency_code: str = Field(default="IRR", max_length=3) + is_default: bool = False + settings: dict | None = None + + +class LoyaltyProgramUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: ProgramStatus | None = None + points_currency_name: str | None = Field(default=None, max_length=50) + currency_code: str | None = Field(default=None, max_length=3) + is_default: bool | None = None + settings: dict | None = None + version: int = Field(ge=1) + + +class LoyaltyProgramRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: ProgramStatus + points_currency_name: str + currency_code: str + is_default: bool + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MembershipTierCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + rank: int = Field(default=0, ge=0) + min_points: int = Field(default=0, ge=0) + status: TierStatus = TierStatus.ACTIVE + benefits: dict | None = None + rules: dict | None = None + + +class MembershipTierUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + rank: int | None = Field(default=None, ge=0) + min_points: int | None = Field(default=None, ge=0) + status: TierStatus | None = None + benefits: dict | None = None + rules: dict | None = None + + +class MembershipTierRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + code: str + name: str + description: str | None + rank: int + min_points: int + status: TierStatus + benefits: dict | None + rules: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MemberCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + display_name: str = Field(max_length=255) + membership_number: str | None = Field(default=None, max_length=50) + external_customer_ref: str | None = Field(default=None, max_length=100) + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + status: MemberStatus = MemberStatus.PENDING + tier_id: UUID | None = None + profile: dict | None = None + enroll: bool = False + + +class MemberUpdate(BaseModel): + model_config = _FORBID + + display_name: str | None = Field(default=None, max_length=255) + external_customer_ref: str | None = Field(default=None, max_length=100) + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + status: MemberStatus | None = None + tier_id: UUID | None = None + profile: dict | None = None + version: int = Field(ge=1) + + +class MemberEnrollRequest(BaseModel): + model_config = _FORBID + + tier_id: UUID | None = None + term_days: int | None = Field(default=None, ge=1, le=3650) + expires_at: datetime | None = None + reason: str | None = Field(default=None, max_length=500) + + +class MemberActivateRequest(BaseModel): + model_config = _FORBID + + tier_id: UUID | None = None + term_days: int | None = Field(default=None, ge=1, le=3650) + expires_at: datetime | None = None + reason: str | None = Field(default=None, max_length=500) + + +class MemberRenewRequest(BaseModel): + model_config = _FORBID + + term_days: int | None = Field(default=None, ge=1, le=3650) + expires_at: datetime | None = None + reason: str | None = Field(default=None, max_length=500) + + +class MemberFreezeRequest(BaseModel): + model_config = _FORBID + + reason: str = Field(max_length=500) + + +class MemberResumeRequest(BaseModel): + model_config = _FORBID + + reason: str | None = Field(default=None, max_length=500) + + +class MemberCancelRequest(BaseModel): + model_config = _FORBID + + reason: str = Field(max_length=500) + + +class MemberExpireRequest(BaseModel): + model_config = _FORBID + + reason: str | None = Field(default=None, max_length=500) + + +class MemberTransferRequest(BaseModel): + model_config = _FORBID + + target_program_id: UUID + target_tier_id: UUID | None = None + reason: str | None = Field(default=None, max_length=500) + + +class MemberRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + membership_number: str + external_customer_ref: str | None + display_name: str + email: str | None + mobile: str | None + status: MemberStatus + tier_id: UUID | None + enrolled_at: datetime | None + activated_at: datetime | None + membership_started_at: datetime | None + membership_expires_at: datetime | None + frozen_at: datetime | None + freeze_reason: str | None + cancelled_at: datetime | None + cancel_reason: str | None + expired_at: datetime | None + transferred_to_member_id: UUID | None + profile: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MembershipLifecycleEventRead(ORMBase): + id: UUID + tenant_id: UUID + member_id: UUID + action: str + from_status: str + to_status: str + reason: str | None + metadata_json: dict | None + actor_user_id: str | None + created_at: datetime + + +class PointAccountCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + member_id: UUID + account_number: str | None = Field(default=None, max_length=50) + currency_name: str = Field(default="points", max_length=50) + status: PointAccountStatus = PointAccountStatus.OPEN + + +class PointAccountUpdate(BaseModel): + model_config = _FORBID + + status: PointAccountStatus | None = None + currency_name: str | None = Field(default=None, max_length=50) + version: int = Field(ge=1) + + +class PointAccountRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + member_id: UUID + account_number: str + status: PointAccountStatus + currency_name: str + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class RewardCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + reward_type: RewardType = RewardType.CUSTOM + status: RewardStatus = RewardStatus.DRAFT + points_cost: int = Field(default=0, ge=0) + eligibility_rules: dict | None = None + metadata_json: dict | None = None + + +class RewardUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + reward_type: RewardType | None = None + status: RewardStatus | None = None + points_cost: int | None = Field(default=None, ge=0) + eligibility_rules: dict | None = None + metadata_json: dict | None = None + + +class RewardRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + code: str + name: str + description: str | None + reward_type: RewardType + status: RewardStatus + points_cost: int + eligibility_rules: dict | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class CampaignCreate(BaseModel): + model_config = _FORBID + + program_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: CampaignStatus = CampaignStatus.DRAFT + rules: dict | None = None + starts_on: date | None = None + ends_on: date | None = None + + +class CampaignUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: CampaignStatus | None = None + rules: dict | None = None + starts_on: date | None = None + ends_on: date | None = None + bump_rule_version: bool = False + + +class CampaignRead(ORMBase): + id: UUID + tenant_id: UUID + program_id: UUID + code: str + name: str + description: str | None + status: CampaignStatus + rule_version: int + rules: dict | None + starts_on: date | None + ends_on: date | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class LoyaltyAuditLogRead(ORMBase): + id: UUID + tenant_id: UUID + entity_type: str + entity_id: UUID + action: str + actor_user_id: str | None + changes: dict | None + message: str | None + created_at: datetime diff --git a/backend/services/loyalty/app/services/audit_service.py b/backend/services/loyalty/app/services/audit_service.py new file mode 100644 index 0000000..e40cbd2 --- /dev/null +++ b/backend/services/loyalty/app/services/audit_service.py @@ -0,0 +1,51 @@ +"""Audit service — append-only Loyalty audit trail.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import LoyaltyAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import LoyaltyAuditLogRepository + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = LoyaltyAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> LoyaltyAuditLog: + entry = LoyaltyAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=changes, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + return await self.repo.list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/loyalty/app/services/foundation.py b/backend/services/loyalty/app/services/foundation.py new file mode 100644 index 0000000..3d10e2d --- /dev/null +++ b/backend/services/loyalty/app/services/foundation.py @@ -0,0 +1,1260 @@ +"""Loyalty foundation application services — Phase 7.0.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from enum import Enum +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import TransactionalEventPublisher +from app.events.types import LoyaltyEventType +from app.models.foundation import ( + Campaign, + LoyaltyProgram, + Member, + MembershipLifecycleEvent, + MembershipTier, + PointAccount, + Reward, +) +from app.models.types import AuditAction, MemberStatus, MembershipLifecycleAction, PointAccountStatus +from app.repositories.foundation import ( + CampaignRepository, + LoyaltyProgramRepository, + MemberRepository, + MembershipLifecycleEventRepository, + MembershipTierRepository, + PointAccountRepository, + RewardRepository, +) +from app.schemas.foundation import ( + CampaignCreate, + CampaignUpdate, + LoyaltyProgramCreate, + LoyaltyProgramUpdate, + MemberCreate, + MemberEnrollRequest, + MemberUpdate, + MembershipTierCreate, + MembershipTierUpdate, + PointAccountCreate, + PointAccountUpdate, + RewardCreate, + RewardUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + forbid_direct_balance_mutation, + release_unique_token, + validate_campaign_dates, + validate_campaign_status, + validate_code, + validate_currency_code, + validate_email, + validate_member_status, + validate_non_empty, + validate_non_negative_int, + validate_phone, + validate_point_account_status, + validate_program_status, + validate_reward_status, + validate_reward_type, + validate_tier_status, +) +from app.validators.membership import ( + ACTIVE_MEMBER_STATUSES, + DEFAULT_TERM_DAYS, + compute_expiry, + ensure_lifecycle_transition, + target_status_for, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _reject_nulls(data: dict, fields: set[str]) -> None: + for field in fields: + if field in data and data[field] is None: + raise AppError( + f"{field} نمی‌تواند null باشد", + status_code=422, + error_code="null_not_allowed", + details={"field": field}, + ) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif isinstance(value, Enum): + out[key] = value.value + else: + out[key] = value + return out + + +def _next_membership_number() -> str: + return f"MBR-{uuid4().hex[:10].upper()}" + + +def _next_account_number() -> str: + return f"PA-{uuid4().hex[:10].upper()}" + + +async def _clear_other_defaults( + session: AsyncSession, tenant_id: UUID, keep_id: UUID | None = None +) -> None: + from sqlalchemy import update + + stmt = ( + update(LoyaltyProgram) + .where( + LoyaltyProgram.tenant_id == tenant_id, + LoyaltyProgram.is_default.is_(True), + LoyaltyProgram.is_deleted.is_(False), + ) + .values(is_default=False) + ) + if keep_id is not None: + stmt = stmt.where(LoyaltyProgram.id != keep_id) + await session.execute(stmt) + + +def _map_integrity(*, error_code: str, message: str) -> AppError: + return AppError(message, status_code=409, error_code=error_code) + + +class LoyaltyProgramService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = LoyaltyProgramRepository(session) + self.members = MemberRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def create( + self, + tenant_id: UUID, + body: LoyaltyProgramCreate, + *, + actor: CurrentUser | None = None, + ) -> LoyaltyProgram: + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + name = validate_non_empty(body.name, field="name") + currency = validate_currency_code(body.currency_code) + status = validate_program_status(body.status) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError( + "کد برنامه وفاداری تکراری است", + status_code=409, + error_code="program_code_exists", + ) + if body.is_default: + await _clear_other_defaults(self.session, tenant_id) + entity = LoyaltyProgram( + tenant_id=tenant_id, + code=code, + name=name, + description=body.description, + status=status, + points_currency_name=validate_non_empty( + body.points_currency_name, field="points_currency_name" + ), + currency_code=currency, + is_default=body.is_default, + settings=body.settings, + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="loyalty_program", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Program {entity.code} created", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.PROGRAM_CREATED, + aggregate_type="loyalty_program", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="program_code_exists", + message="کد برنامه وفاداری تکراری است", + ) + return entity + + async def update( + self, + tenant_id: UUID, + program_id: UUID, + body: LoyaltyProgramUpdate, + *, + actor: CurrentUser | None = None, + ) -> LoyaltyProgram: + entity = await self.get(tenant_id, program_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + _reject_nulls( + data, + {"name", "status", "points_currency_name", "currency_code", "is_default"}, + ) + if "name" in data: + data["name"] = validate_non_empty(data["name"], field="name") + if "currency_code" in data: + data["currency_code"] = validate_currency_code(data["currency_code"]) + if "status" in data: + data["status"] = validate_program_status(data["status"]) + if "points_currency_name" in data: + data["points_currency_name"] = validate_non_empty( + data["points_currency_name"], field="points_currency_name" + ) + if data.get("is_default") is True: + await _clear_other_defaults(self.session, tenant_id, keep_id=entity.id) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="loyalty_program", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=_jsonable_changes(data), + ) + await self.publisher.publish( + event_type=LoyaltyEventType.PROGRAM_UPDATED, + aggregate_type="loyalty_program", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, program_id: UUID) -> LoyaltyProgram: + entity = await self.repo.get(tenant_id, program_id) + if entity is None: + raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete( + self, + tenant_id: UUID, + program_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> LoyaltyProgram: + entity = await self.get(tenant_id, program_id) + blocking = await self.members.count_blocking_for_program( + tenant_id, program_id, list(ACTIVE_MEMBER_STATUSES) + ) + if blocking: + raise AppError( + "برنامه دارای اعضای فعال است و قابل حذف نیست", + status_code=409, + error_code="program_has_active_members", + details={"active_member_count": blocking}, + ) + entity.code = release_unique_token(entity.code, entity.id) + await self.repo.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="loyalty_program", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.PROGRAM_DELETED, + aggregate_type="loyalty_program", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class MembershipTierService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = MembershipTierRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def create( + self, + tenant_id: UUID, + body: MembershipTierCreate, + *, + actor: CurrentUser | None = None, + ) -> MembershipTier: + actor_id = actor.user_id if actor else None + if await self.programs.get(tenant_id, body.program_id) is None: + raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found") + code = validate_code(body.code) + name = validate_non_empty(body.name, field="name") + status = validate_tier_status(body.status) + min_points = validate_non_negative_int(body.min_points, field="min_points") + if await self.repo.get_by_code(tenant_id, body.program_id, code) is not None: + raise AppError( + "کد سطح عضویت تکراری است", + status_code=409, + error_code="tier_code_exists", + ) + entity = MembershipTier( + tenant_id=tenant_id, + program_id=body.program_id, + code=code, + name=name, + description=body.description, + rank=body.rank, + min_points=min_points, + status=status, + benefits=body.benefits, + rules=body.rules, + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership_tier", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Tier {entity.code} created", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.TIER_CREATED, + aggregate_type="membership_tier", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "program_id": str(entity.program_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="tier_code_exists", + message="کد سطح عضویت تکراری است", + ) + return entity + + async def update( + self, + tenant_id: UUID, + tier_id: UUID, + body: MembershipTierUpdate, + *, + actor: CurrentUser | None = None, + ) -> MembershipTier: + entity = await self.get(tenant_id, tier_id) + data = body.model_dump(exclude_unset=True) + _reject_nulls(data, {"name", "rank", "min_points", "status"}) + if "name" in data: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data: + data["status"] = validate_tier_status(data["status"]) + if "min_points" in data: + data["min_points"] = validate_non_negative_int( + data["min_points"], field="min_points" + ) + if "rank" in data: + data["rank"] = validate_non_negative_int(data["rank"], field="rank") + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership_tier", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=_jsonable_changes(data), + ) + await self.publisher.publish( + event_type=LoyaltyEventType.TIER_UPDATED, + aggregate_type="membership_tier", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, tier_id: UUID) -> MembershipTier: + entity = await self.repo.get(tenant_id, tier_id) + if entity is None: + raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def list_by_program( + self, tenant_id: UUID, program_id: UUID, *, offset: int, limit: int + ): + return await self.repo.list_by_program( + tenant_id, program_id, offset=offset, limit=limit + ) + + async def soft_delete( + self, + tenant_id: UUID, + tier_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> MembershipTier: + entity = await self.get(tenant_id, tier_id) + entity.code = release_unique_token(entity.code, entity.id) + await self.repo.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership_tier", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.TIER_DELETED, + aggregate_type="membership_tier", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class MemberService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = MemberRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.tiers = MembershipTierRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def create( + self, + tenant_id: UUID, + body: MemberCreate, + *, + actor: CurrentUser | None = None, + ) -> Member: + actor_id = actor.user_id if actor else None + if await self.programs.get(tenant_id, body.program_id) is None: + raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found") + if body.tier_id is not None: + tier = await self.tiers.get(tenant_id, body.tier_id) + if tier is None or tier.program_id != body.program_id: + raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found") + display_name = validate_non_empty(body.display_name, field="display_name") + email = validate_email(body.email) + mobile = validate_phone(body.mobile, field="mobile") + status = validate_member_status(body.status) + membership_number = ( + validate_code(body.membership_number, field="membership_number") + if body.membership_number + else _next_membership_number() + ) + if await self.repo.get_by_membership_number(tenant_id, membership_number): + raise AppError( + "شماره عضویت تکراری است", + status_code=409, + error_code="membership_number_exists", + ) + if body.external_customer_ref: + existing_ref = await self.repo.get_by_external_ref( + tenant_id, body.program_id, body.external_customer_ref + ) + if existing_ref is not None: + raise AppError( + "شناسه خارجی مشتری تکراری است", + status_code=409, + error_code="external_customer_ref_exists", + ) + enrolled_at = None + activated_at = None + membership_started_at = None + membership_expires_at = None + if body.enroll: + status = MemberStatus.ACTIVE + enrolled_at = datetime.now(timezone.utc) + activated_at = enrolled_at + membership_started_at = enrolled_at + membership_expires_at = compute_expiry(from_when=enrolled_at) + entity = Member( + tenant_id=tenant_id, + program_id=body.program_id, + membership_number=membership_number, + external_customer_ref=body.external_customer_ref, + display_name=display_name, + email=email, + mobile=mobile, + status=status, + tier_id=body.tier_id, + enrolled_at=enrolled_at, + activated_at=activated_at, + membership_started_at=membership_started_at, + membership_expires_at=membership_expires_at, + profile=body.profile, + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Member {entity.membership_number} created", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_CREATED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "status": entity.status.value, + }, + ) + if body.enroll: + self.lifecycle = MembershipLifecycleEventRepository(self.session) + await self.lifecycle.add( + MembershipLifecycleEvent( + tenant_id=tenant_id, + member_id=entity.id, + action=MembershipLifecycleAction.ENROLL, + from_status=MemberStatus.PENDING.value, + to_status=MemberStatus.ACTIVE.value, + reason=None, + metadata_json={"term_days": DEFAULT_TERM_DAYS}, + actor_user_id=actor_id, + created_at=enrolled_at, + ) + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_ENROLLED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"membership_number": entity.membership_number}, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_ACTIVATED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "status": entity.status.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="member_conflict", + message="عضو با این شناسه‌ها قابل ایجاد نیست", + ) + return entity + + async def update( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberUpdate, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self.get(tenant_id, member_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + _reject_nulls(data, {"display_name", "status"}) + if "display_name" in data: + data["display_name"] = validate_non_empty( + data["display_name"], field="display_name" + ) + if "email" in data: + data["email"] = validate_email(data["email"]) + if "mobile" in data: + data["mobile"] = validate_phone(data["mobile"], field="mobile") + if "status" in data: + data["status"] = validate_member_status(data["status"]) + if "tier_id" in data and data["tier_id"] is not None: + tier = await self.tiers.get(tenant_id, data["tier_id"]) + if tier is None or tier.program_id != entity.program_id: + raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=_jsonable_changes(data), + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_UPDATED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def enroll( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberEnrollRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self.get(tenant_id, member_id) + action = MembershipLifecycleAction.ENROLL + ensure_lifecycle_transition(action=action, current=entity.status) + if body.tier_id is not None: + tier = await self.tiers.get(tenant_id, body.tier_id) + if tier is None or tier.program_id != entity.program_id: + raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found") + entity.tier_id = body.tier_id + from_status = entity.status + to_status = target_status_for(action) + now = datetime.now(timezone.utc) + entity.status = to_status + entity.enrolled_at = entity.enrolled_at or now + entity.activated_at = entity.activated_at or now + entity.membership_started_at = entity.membership_started_at or now + entity.membership_expires_at = compute_expiry( + from_when=now, + term_days=body.term_days or DEFAULT_TERM_DAYS, + explicit=body.expires_at, + ) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await MembershipLifecycleEventRepository(self.session).add( + MembershipLifecycleEvent( + tenant_id=tenant_id, + member_id=entity.id, + action=action, + from_status=from_status.value, + to_status=to_status.value, + reason=body.reason, + metadata_json={"term_days": body.term_days or DEFAULT_TERM_DAYS}, + actor_user_id=entity.updated_by, + created_at=now, + ) + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.ENROLL, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} enrolled", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_ENROLLED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"membership_number": entity.membership_number}, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_ACTIVATED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "status": entity.status.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, member_id: UUID) -> Member: + entity = await self.repo.get(tenant_id, member_id) + if entity is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete( + self, + tenant_id: UUID, + member_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self.get(tenant_id, member_id) + entity.membership_number = release_unique_token( + entity.membership_number, entity.id + ) + if entity.external_customer_ref: + entity.external_customer_ref = release_unique_token( + entity.external_customer_ref, entity.id + ) + await self.repo.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.MEMBER_DELETED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"membership_number": entity.membership_number}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class PointAccountService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = PointAccountRepository(session) + self.members = MemberRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def create( + self, + tenant_id: UUID, + body: PointAccountCreate, + *, + actor: CurrentUser | None = None, + ) -> PointAccount: + forbid_direct_balance_mutation(body.model_dump()) + actor_id = actor.user_id if actor else None + program = await self.programs.get(tenant_id, body.program_id) + if program is None: + raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found") + member = await self.members.get(tenant_id, body.member_id) + if member is None or member.program_id != body.program_id: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + if await self.repo.get_by_member(tenant_id, body.member_id) is not None: + raise AppError( + "حساب امتیاز برای این عضو وجود دارد", + status_code=409, + error_code="point_account_exists", + ) + account_number = ( + validate_code(body.account_number, field="account_number") + if body.account_number + else _next_account_number() + ) + if await self.repo.get_by_account_number(tenant_id, account_number): + raise AppError( + "شماره حساب امتیاز تکراری است", + status_code=409, + error_code="account_number_exists", + ) + entity = PointAccount( + tenant_id=tenant_id, + program_id=body.program_id, + member_id=body.member_id, + account_number=account_number, + status=validate_point_account_status(body.status), + currency_name=validate_non_empty( + body.currency_name, field="currency_name" + ), + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="point_account", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Point account {entity.account_number} opened", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.POINT_ACCOUNT_OPENED, + aggregate_type="point_account", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "account_number": entity.account_number, + "member_id": str(entity.member_id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="point_account_conflict", + message="حساب امتیاز قابل ایجاد نیست", + ) + return entity + + async def update( + self, + tenant_id: UUID, + account_id: UUID, + body: PointAccountUpdate, + *, + actor: CurrentUser | None = None, + ) -> PointAccount: + forbid_direct_balance_mutation(body.model_dump()) + entity = await self.get(tenant_id, account_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + _reject_nulls(data, {"status", "currency_name"}) + if "status" in data: + data["status"] = validate_point_account_status(data["status"]) + if "currency_name" in data: + data["currency_name"] = validate_non_empty( + data["currency_name"], field="currency_name" + ) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="point_account", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=_jsonable_changes(data), + ) + await self.publisher.publish( + event_type=LoyaltyEventType.POINT_ACCOUNT_UPDATED, + aggregate_type="point_account", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, account_id: UUID) -> PointAccount: + entity = await self.repo.get(tenant_id, account_id) + if entity is None: + raise NotFoundError( + "حساب امتیاز یافت نشد", error_code="point_account_not_found" + ) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete( + self, + tenant_id: UUID, + account_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> PointAccount: + entity = await self.get(tenant_id, account_id) + entity.account_number = release_unique_token(entity.account_number, entity.id) + entity.status = PointAccountStatus.CLOSED + await self.repo.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="point_account", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.POINT_ACCOUNT_DELETED, + aggregate_type="point_account", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"account_number": entity.account_number}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class RewardService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = RewardRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def create( + self, + tenant_id: UUID, + body: RewardCreate, + *, + actor: CurrentUser | None = None, + ) -> Reward: + actor_id = actor.user_id if actor else None + if await self.programs.get(tenant_id, body.program_id) is None: + raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found") + code = validate_code(body.code) + name = validate_non_empty(body.name, field="name") + if await self.repo.get_by_code(tenant_id, body.program_id, code) is not None: + raise AppError( + "کد پاداش تکراری است", + status_code=409, + error_code="reward_code_exists", + ) + entity = Reward( + tenant_id=tenant_id, + program_id=body.program_id, + code=code, + name=name, + description=body.description, + reward_type=validate_reward_type(body.reward_type), + status=validate_reward_status(body.status), + points_cost=validate_non_negative_int(body.points_cost, field="points_cost"), + eligibility_rules=body.eligibility_rules, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="reward", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Reward {entity.code} created", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REWARD_CREATED, + aggregate_type="reward", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "reward_type": entity.reward_type.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="reward_code_exists", + message="کد پاداش تکراری است", + ) + return entity + + async def update( + self, + tenant_id: UUID, + reward_id: UUID, + body: RewardUpdate, + *, + actor: CurrentUser | None = None, + ) -> Reward: + entity = await self.get(tenant_id, reward_id) + data = body.model_dump(exclude_unset=True) + _reject_nulls(data, {"name", "reward_type", "status", "points_cost"}) + if "name" in data: + data["name"] = validate_non_empty(data["name"], field="name") + if "reward_type" in data: + data["reward_type"] = validate_reward_type(data["reward_type"]) + if "status" in data: + data["status"] = validate_reward_status(data["status"]) + if "points_cost" in data: + data["points_cost"] = validate_non_negative_int( + data["points_cost"], field="points_cost" + ) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="reward", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=_jsonable_changes(data), + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REWARD_UPDATED, + aggregate_type="reward", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, reward_id: UUID) -> Reward: + entity = await self.repo.get(tenant_id, reward_id) + if entity is None: + raise NotFoundError("پاداش یافت نشد", error_code="reward_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete( + self, + tenant_id: UUID, + reward_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Reward: + entity = await self.get(tenant_id, reward_id) + entity.code = release_unique_token(entity.code, entity.id) + await self.repo.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="reward", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.REWARD_DELETED, + aggregate_type="reward", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class CampaignService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.repo = CampaignRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def create( + self, + tenant_id: UUID, + body: CampaignCreate, + *, + actor: CurrentUser | None = None, + ) -> Campaign: + actor_id = actor.user_id if actor else None + if await self.programs.get(tenant_id, body.program_id) is None: + raise NotFoundError("برنامه وفاداری یافت نشد", error_code="program_not_found") + code = validate_code(body.code) + name = validate_non_empty(body.name, field="name") + starts_on, ends_on = validate_campaign_dates(body.starts_on, body.ends_on) + if await self.repo.get_by_code(tenant_id, body.program_id, code) is not None: + raise AppError( + "کد کمپین تکراری است", + status_code=409, + error_code="campaign_code_exists", + ) + entity = Campaign( + tenant_id=tenant_id, + program_id=body.program_id, + code=code, + name=name, + description=body.description, + status=validate_campaign_status(body.status), + rule_version=1, + rules=body.rules, + starts_on=starts_on, + ends_on=ends_on, + created_by=actor_id, + updated_by=actor_id, + ) + try: + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="campaign", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Campaign {entity.code} created", + ) + await self.publisher.publish( + event_type=LoyaltyEventType.CAMPAIGN_CREATED, + aggregate_type="campaign", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "rule_version": entity.rule_version}, + ) + await self.session.commit() + await self.session.refresh(entity) + except IntegrityError: + await self.session.rollback() + raise _map_integrity( + error_code="campaign_code_exists", + message="کد کمپین تکراری است", + ) + return entity + + async def update( + self, + tenant_id: UUID, + campaign_id: UUID, + body: CampaignUpdate, + *, + actor: CurrentUser | None = None, + ) -> Campaign: + entity = await self.get(tenant_id, campaign_id) + data = body.model_dump(exclude_unset=True, exclude={"bump_rule_version"}) + _reject_nulls(data, {"name", "status"}) + if "name" in data: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data: + data["status"] = validate_campaign_status(data["status"]) + starts = data.get("starts_on", entity.starts_on) + ends = data.get("ends_on", entity.ends_on) + if "starts_on" in data or "ends_on" in data: + starts, ends = validate_campaign_dates(starts, ends) + data["starts_on"] = starts + data["ends_on"] = ends + _apply_update(entity, data) + if body.bump_rule_version or ("rules" in data): + entity.rule_version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="campaign", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=_jsonable_changes({**data, "rule_version": entity.rule_version}), + ) + await self.publisher.publish( + event_type=LoyaltyEventType.CAMPAIGN_UPDATED, + aggregate_type="campaign", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "rule_version": entity.rule_version}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, campaign_id: UUID) -> Campaign: + entity = await self.repo.get(tenant_id, campaign_id) + if entity is None: + raise NotFoundError("کمپین یافت نشد", error_code="campaign_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete( + self, + tenant_id: UUID, + campaign_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> Campaign: + entity = await self.get(tenant_id, campaign_id) + entity.code = release_unique_token(entity.code, entity.id) + await self.repo.soft_delete( + entity, deleted_by=actor.user_id if actor else None + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="campaign", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.publisher.publish( + event_type=LoyaltyEventType.CAMPAIGN_DELETED, + aggregate_type="campaign", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/loyalty/app/services/membership_engine.py b/backend/services/loyalty/app/services/membership_engine.py new file mode 100644 index 0000000..182487d --- /dev/null +++ b/backend/services/loyalty/app/services/membership_engine.py @@ -0,0 +1,556 @@ +"""Phase 7.1 — Membership lifecycle engine.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import TransactionalEventPublisher +from app.events.types import LoyaltyEventType +from app.models.foundation import Member, MembershipLifecycleEvent +from app.models.types import AuditAction, MemberStatus, MembershipLifecycleAction +from app.repositories.foundation import ( + LoyaltyProgramRepository, + MemberRepository, + MembershipLifecycleEventRepository, + MembershipTierRepository, +) +from app.schemas.foundation import ( + MemberActivateRequest, + MemberCancelRequest, + MemberExpireRequest, + MemberFreezeRequest, + MemberRenewRequest, + MemberResumeRequest, + MemberTransferRequest, +) +from app.services.audit_service import AuditService +from app.validators.membership import ( + DEFAULT_TERM_DAYS, + compute_expiry, + ensure_lifecycle_transition, + target_status_for, + validate_reason, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _aware(dt: datetime | None) -> datetime | None: + if dt is None: + return None + if dt.tzinfo is None: + return dt.replace(tzinfo=timezone.utc) + return dt + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class MembershipEngineService: + def __init__( + self, + session: AsyncSession, + publisher: TransactionalEventPublisher | None = None, + ) -> None: + self.session = session + self.members = MemberRepository(session) + self.programs = LoyaltyProgramRepository(session) + self.tiers = MembershipTierRepository(session) + self.lifecycle = MembershipLifecycleEventRepository(session) + self.audit = AuditService(session) + self.publisher = publisher or TransactionalEventPublisher(session) + + async def _get_member(self, tenant_id: UUID, member_id: UUID) -> Member: + entity = await self.members.get(tenant_id, member_id) + if entity is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + return entity + + async def _record_lifecycle( + self, + *, + tenant_id: UUID, + member: Member, + action: MembershipLifecycleAction, + from_status: MemberStatus, + to_status: MemberStatus, + reason: str | None, + actor_id: str | None, + metadata: dict[str, Any] | None = None, + ) -> MembershipLifecycleEvent: + row = MembershipLifecycleEvent( + tenant_id=tenant_id, + member_id=member.id, + action=action, + from_status=from_status.value, + to_status=to_status.value, + reason=reason, + metadata_json=metadata, + actor_user_id=actor_id, + created_at=_now(), + ) + await self.lifecycle.add(row) + return row + + async def _publish( + self, + *, + event_type: LoyaltyEventType, + member: Member, + tenant_id: UUID, + payload: dict[str, Any], + ) -> None: + await self.publisher.publish( + event_type=event_type, + aggregate_type="member", + aggregate_id=member.id, + tenant_id=tenant_id, + payload=payload, + ) + + async def activate( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberActivateRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.ACTIVATE + ensure_lifecycle_transition(action=action, current=entity.status) + if body.tier_id is not None: + tier = await self.tiers.get(tenant_id, body.tier_id) + if tier is None or tier.program_id != entity.program_id: + raise NotFoundError("سطح عضویت یافت نشد", error_code="tier_not_found") + entity.tier_id = body.tier_id + from_status = entity.status + to_status = target_status_for(action) + now = _now() + entity.status = to_status + entity.enrolled_at = entity.enrolled_at or now + entity.activated_at = entity.activated_at or now + entity.membership_started_at = entity.membership_started_at or now + entity.membership_expires_at = compute_expiry( + from_when=now, + term_days=body.term_days or DEFAULT_TERM_DAYS, + explicit=body.expires_at, + ) + entity.expired_at = None + entity.frozen_at = None + entity.freeze_reason = None + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self._record_lifecycle( + tenant_id=tenant_id, + member=entity, + action=action, + from_status=from_status, + to_status=to_status, + reason=validate_reason(body.reason), + actor_id=entity.updated_by, + metadata={"term_days": body.term_days or DEFAULT_TERM_DAYS}, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.ACTIVATE, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} activated", + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_ACTIVATED, + member=entity, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "status": entity.status.value, + "membership_expires_at": entity.membership_expires_at.isoformat() + if entity.membership_expires_at + else None, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def renew( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberRenewRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.RENEW + ensure_lifecycle_transition(action=action, current=entity.status) + from_status = entity.status + to_status = target_status_for(action) + now = _now() + current_expiry = _aware(entity.membership_expires_at) + base = current_expiry if (current_expiry and current_expiry > now) else now + entity.status = to_status + entity.activated_at = _aware(entity.activated_at) or now + entity.membership_started_at = _aware(entity.membership_started_at) or now + entity.membership_expires_at = compute_expiry( + from_when=base, + term_days=body.term_days or DEFAULT_TERM_DAYS, + explicit=body.expires_at, + ) + entity.expired_at = None + entity.frozen_at = None + entity.freeze_reason = None + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self._record_lifecycle( + tenant_id=tenant_id, + member=entity, + action=action, + from_status=from_status, + to_status=to_status, + reason=validate_reason(body.reason), + actor_id=entity.updated_by, + metadata={"term_days": body.term_days or DEFAULT_TERM_DAYS}, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.RENEW, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} renewed", + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_RENEWED, + member=entity, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "membership_expires_at": entity.membership_expires_at.isoformat() + if entity.membership_expires_at + else None, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def freeze( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberFreezeRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.FREEZE + ensure_lifecycle_transition(action=action, current=entity.status) + reason = validate_reason(body.reason, required=True) + from_status = entity.status + to_status = target_status_for(action) + now = _now() + entity.status = to_status + entity.frozen_at = now + entity.freeze_reason = reason + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self._record_lifecycle( + tenant_id=tenant_id, + member=entity, + action=action, + from_status=from_status, + to_status=to_status, + reason=reason, + actor_id=entity.updated_by, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.FREEZE, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} frozen", + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_FROZEN, + member=entity, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "reason": reason, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def resume( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberResumeRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.RESUME + ensure_lifecycle_transition(action=action, current=entity.status) + from_status = entity.status + to_status = target_status_for(action) + entity.status = to_status + entity.frozen_at = None + entity.freeze_reason = None + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self._record_lifecycle( + tenant_id=tenant_id, + member=entity, + action=action, + from_status=from_status, + to_status=to_status, + reason=validate_reason(body.reason), + actor_id=entity.updated_by, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.RESUME, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} resumed", + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_RESUMED, + member=entity, + tenant_id=tenant_id, + payload={"membership_number": entity.membership_number}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def cancel( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberCancelRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.CANCEL + ensure_lifecycle_transition(action=action, current=entity.status) + reason = validate_reason(body.reason, required=True) + from_status = entity.status + to_status = target_status_for(action) + now = _now() + entity.status = to_status + entity.cancelled_at = now + entity.cancel_reason = reason + entity.frozen_at = None + entity.freeze_reason = None + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self._record_lifecycle( + tenant_id=tenant_id, + member=entity, + action=action, + from_status=from_status, + to_status=to_status, + reason=reason, + actor_id=entity.updated_by, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.CANCEL, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} cancelled", + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_CANCELLED, + member=entity, + tenant_id=tenant_id, + payload={ + "membership_number": entity.membership_number, + "reason": reason, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def expire( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberExpireRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.EXPIRE + ensure_lifecycle_transition(action=action, current=entity.status) + from_status = entity.status + to_status = target_status_for(action) + now = _now() + entity.status = to_status + entity.expired_at = now + entity.frozen_at = None + entity.freeze_reason = None + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self._record_lifecycle( + tenant_id=tenant_id, + member=entity, + action=action, + from_status=from_status, + to_status=to_status, + reason=validate_reason(body.reason), + actor_id=entity.updated_by, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.EXPIRE, + actor_user_id=entity.updated_by, + message=f"Member {entity.membership_number} expired", + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_EXPIRED, + member=entity, + tenant_id=tenant_id, + payload={"membership_number": entity.membership_number}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def transfer( + self, + tenant_id: UUID, + member_id: UUID, + body: MemberTransferRequest, + *, + actor: CurrentUser | None = None, + ) -> Member: + """Transfer membership to another program: create target member, mark source transferred.""" + source = await self._get_member(tenant_id, member_id) + action = MembershipLifecycleAction.TRANSFER + ensure_lifecycle_transition(action=action, current=source.status) + target_program = await self.programs.get(tenant_id, body.target_program_id) + if target_program is None: + raise NotFoundError("برنامه مقصد یافت نشد", error_code="program_not_found") + if target_program.id == source.program_id: + raise AppError( + "برنامه مقصد باید متفاوت باشد", + status_code=422, + error_code="same_program_transfer", + ) + if body.target_tier_id is not None: + tier = await self.tiers.get(tenant_id, body.target_tier_id) + if tier is None or tier.program_id != body.target_program_id: + raise NotFoundError("سطح عضویت مقصد یافت نشد", error_code="tier_not_found") + reason = validate_reason(body.reason) + actor_id = actor.user_id if actor else None + now = _now() + target = Member( + tenant_id=tenant_id, + program_id=body.target_program_id, + membership_number=f"MBR-{uuid4().hex[:10].upper()}", + external_customer_ref=source.external_customer_ref, + display_name=source.display_name, + email=source.email, + mobile=source.mobile, + status=MemberStatus.ACTIVE, + tier_id=body.target_tier_id, + enrolled_at=now, + activated_at=now, + membership_started_at=now, + membership_expires_at=source.membership_expires_at + or compute_expiry(from_when=now), + profile=source.profile, + created_by=actor_id, + updated_by=actor_id, + ) + await self.members.add(target) + from_status = source.status + source.status = MemberStatus.TRANSFERRED + source.transferred_to_member_id = target.id + source.version += 1 + source.updated_by = actor_id + await self._record_lifecycle( + tenant_id=tenant_id, + member=source, + action=action, + from_status=from_status, + to_status=MemberStatus.TRANSFERRED, + reason=reason, + actor_id=actor_id, + metadata={ + "target_program_id": str(body.target_program_id), + "target_member_id": str(target.id), + }, + ) + await self._record_lifecycle( + tenant_id=tenant_id, + member=target, + action=MembershipLifecycleAction.ACTIVATE, + from_status=MemberStatus.PENDING, + to_status=MemberStatus.ACTIVE, + reason=reason or "transfer_in", + actor_id=actor_id, + metadata={"source_member_id": str(source.id)}, + ) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=source.id, + action=AuditAction.TRANSFER, + actor_user_id=actor_id, + message=f"Member {source.membership_number} transferred to {target.membership_number}", + changes={ + "target_member_id": str(target.id), + "target_program_id": str(body.target_program_id), + }, + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_TRANSFERRED, + member=source, + tenant_id=tenant_id, + payload={ + "membership_number": source.membership_number, + "target_member_id": str(target.id), + "target_program_id": str(body.target_program_id), + }, + ) + await self._publish( + event_type=LoyaltyEventType.MEMBER_CREATED, + member=target, + tenant_id=tenant_id, + payload={ + "membership_number": target.membership_number, + "status": target.status.value, + "transfer_from": str(source.id), + }, + ) + await self.session.commit() + await self.session.refresh(source) + return source + + async def list_lifecycle( + self, tenant_id: UUID, member_id: UUID, *, limit: int = 100 + ): + await self._get_member(tenant_id, member_id) + return await self.lifecycle.list_for_member( + tenant_id, member_id, limit=limit + ) diff --git a/backend/services/loyalty/app/tests/conftest.py b/backend/services/loyalty/app/tests/conftest.py new file mode 100644 index 0000000..a10cc15 --- /dev/null +++ b/backend/services/loyalty/app/tests/conftest.py @@ -0,0 +1,50 @@ +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["LOYALTY_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["LOYALTY_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 + +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(): + reset_event_publisher() + 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)} diff --git a/backend/services/loyalty/app/tests/test_api.py b/backend/services/loyalty/app/tests/test_api.py new file mode 100644 index 0000000..7bcfcc5 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_api.py @@ -0,0 +1,384 @@ +"""Loyalty API, service rules, events, and audit tests — Phase 7.0.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, tenant_headers + + +@pytest.mark.asyncio +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["service"] == "loyalty-service" + assert body["status"] == "ok" + assert body["version"] == "0.7.1.0" + + +@pytest.mark.asyncio +async def test_program_member_enroll_point_account_flow(client): + program = await client.post( + "/api/v1/programs", + json={ + "code": "default", + "name": "Default Loyalty", + "status": "active", + "is_default": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + assert program.json()["code"] == "DEFAULT" + + tier = await client.post( + "/api/v1/tiers", + json={ + "program_id": program_id, + "code": "bronze", + "name": "Bronze", + "rank": 1, + "min_points": 0, + }, + headers=tenant_headers(TENANT_A), + ) + assert tier.status_code == 201, tier.text + tier_id = tier.json()["id"] + + member = await client.post( + "/api/v1/members", + json={ + "program_id": program_id, + "display_name": "Sara Ahmadi", + "email": "sara@example.com", + "mobile": "+989121234567", + "tier_id": tier_id, + "enroll": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert member.status_code == 201, member.text + member_body = member.json() + assert member_body["status"] == "active" + assert member_body["enrolled_at"] is not None + assert member_body["membership_number"] + member_id = member_body["id"] + + account = await client.post( + "/api/v1/point-accounts", + json={"program_id": program_id, "member_id": member_id}, + headers=tenant_headers(TENANT_A), + ) + assert account.status_code == 201, account.text + assert "balance" not in account.json() + + events = get_event_publisher().published + types = {e.event_type for e in events} + assert LoyaltyEventType.PROGRAM_CREATED.value in types + assert LoyaltyEventType.TIER_CREATED.value in types + assert LoyaltyEventType.MEMBER_CREATED.value in types + assert LoyaltyEventType.MEMBER_ENROLLED.value in types + assert LoyaltyEventType.POINT_ACCOUNT_OPENED.value in types + + +@pytest.mark.asyncio +async def test_reward_and_campaign_with_rule_versioning(client): + program = await client.post( + "/api/v1/programs", + json={"code": "promo", "name": "Promo Program"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + + reward = await client.post( + "/api/v1/rewards", + json={ + "program_id": program_id, + "code": "free-coffee", + "name": "Free Coffee", + "reward_type": "gift", + "points_cost": 100, + "status": "active", + }, + headers=tenant_headers(TENANT_A), + ) + assert reward.status_code == 201, reward.text + + campaign = await client.post( + "/api/v1/campaigns", + json={ + "program_id": program_id, + "code": "summer", + "name": "Summer Boost", + "rules": {"multiplier": 2}, + "starts_on": "2026-06-01", + "ends_on": "2026-08-31", + }, + headers=tenant_headers(TENANT_A), + ) + assert campaign.status_code == 201, campaign.text + campaign_id = campaign.json()["id"] + assert campaign.json()["rule_version"] == 1 + + updated = await client.patch( + f"/api/v1/campaigns/{campaign_id}", + json={"rules": {"multiplier": 3}, "bump_rule_version": True}, + headers=tenant_headers(TENANT_A), + ) + assert updated.status_code == 200, updated.text + assert updated.json()["rule_version"] == 2 + + +@pytest.mark.asyncio +async def test_optimistic_lock_conflict(client): + program = await client.post( + "/api/v1/programs", + json={"code": "lock", "name": "Lock Program"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + version = program.json()["version"] + + ok = await client.patch( + f"/api/v1/programs/{program_id}", + json={"name": "Updated", "version": version}, + headers=tenant_headers(TENANT_A), + ) + assert ok.status_code == 200 + assert ok.json()["version"] == version + 1 + + conflict = await client.patch( + f"/api/v1/programs/{program_id}", + json={"name": "Stale", "version": version}, + headers=tenant_headers(TENANT_A), + ) + assert conflict.status_code == 409 + + +@pytest.mark.asyncio +async def test_soft_delete_releases_code_for_recreate(client): + create = await client.post( + "/api/v1/programs", + json={"code": "reuse", "name": "Reusable"}, + headers=tenant_headers(TENANT_A), + ) + assert create.status_code == 201, create.text + program_id = create.json()["id"] + + deleted = await client.post( + f"/api/v1/programs/{program_id}/delete", + headers=tenant_headers(TENANT_A), + ) + assert deleted.status_code == 200 + assert deleted.json()["is_deleted"] is True + + recreate = await client.post( + "/api/v1/programs", + json={"code": "reuse", "name": "Reusable Again"}, + headers=tenant_headers(TENANT_A), + ) + assert recreate.status_code == 201, recreate.text + assert recreate.json()["code"] == "REUSE" + + +@pytest.mark.asyncio +async def test_audit_log_and_outbox_recorded(client): + from sqlalchemy import select + + from app.core.database import AsyncSessionLocal + from app.models.outbox import OutboxEvent + + program = await client.post( + "/api/v1/programs", + json={"code": "audit1", "name": "Audit Program"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201, program.text + program_id = program.json()["id"] + + audit = await client.get( + "/api/v1/audit", + params={"entity_type": "loyalty_program", "entity_id": program_id}, + headers=tenant_headers(TENANT_A), + ) + assert audit.status_code == 200, audit.text + assert any(row["action"] == "create" for row in audit.json()) + + events = get_event_publisher().published + assert any(e.event_type == LoyaltyEventType.PROGRAM_CREATED.value for e in events) + + async with AsyncSessionLocal() as session: + rows = (await session.execute(select(OutboxEvent))).scalars().all() + assert any( + row.event_type == LoyaltyEventType.PROGRAM_CREATED.value + and str(row.status).lower().endswith("pending") + for row in rows + ) + + +@pytest.mark.asyncio +async def test_null_required_field_on_update_rejected(client): + program = await client.post( + "/api/v1/programs", + json={"code": "nullu", "name": "Null Update"}, + headers=tenant_headers(TENANT_A), + ) + assert program.status_code == 201 + program_id = program.json()["id"] + version = program.json()["version"] + bad = await client.patch( + f"/api/v1/programs/{program_id}", + json={"name": None, "version": version}, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 422 + assert bad.json()["error"]["code"] == "null_not_allowed" + + +@pytest.mark.asyncio +async def test_member_soft_delete_releases_membership_number(client): + program = await client.post( + "/api/v1/programs", + json={"code": "mbrdel", "name": "Member Delete"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + member = await client.post( + "/api/v1/members", + json={ + "program_id": program_id, + "display_name": "A", + "membership_number": "MBR-REUSE-1", + }, + headers=tenant_headers(TENANT_A), + ) + assert member.status_code == 201, member.text + member_id = member.json()["id"] + deleted = await client.post( + f"/api/v1/members/{member_id}/delete", + headers=tenant_headers(TENANT_A), + ) + assert deleted.status_code == 200 + recreate = await client.post( + "/api/v1/members", + json={ + "program_id": program_id, + "display_name": "B", + "membership_number": "MBR-REUSE-1", + }, + headers=tenant_headers(TENANT_A), + ) + assert recreate.status_code == 201, recreate.text + + +@pytest.mark.asyncio +async def test_member_update_with_tier_persists_audit(client): + program = await client.post( + "/api/v1/programs", + json={"code": "tieru", "name": "Tier Update"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + tier = await client.post( + "/api/v1/tiers", + json={ + "program_id": program_id, + "code": "gold", + "name": "Gold", + "rank": 2, + "min_points": 100, + }, + headers=tenant_headers(TENANT_A), + ) + tier_id = tier.json()["id"] + member = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": "C"}, + headers=tenant_headers(TENANT_A), + ) + member_id = member.json()["id"] + version = member.json()["version"] + updated = await client.patch( + f"/api/v1/members/{member_id}", + json={"tier_id": tier_id, "version": version}, + headers=tenant_headers(TENANT_A), + ) + assert updated.status_code == 200, updated.text + audit = await client.get( + "/api/v1/audit", + params={"entity_type": "member", "entity_id": member_id}, + headers=tenant_headers(TENANT_A), + ) + assert audit.status_code == 200 + assert any(row["action"] == "update" for row in audit.json()) + + +@pytest.mark.asyncio +async def test_point_account_rejects_balance_extra_field(client): + program = await client.post( + "/api/v1/programs", + json={"code": "bal", "name": "Balance Guard"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + member = await client.post( + "/api/v1/members", + json={"program_id": program_id, "display_name": "M", "enroll": True}, + headers=tenant_headers(TENANT_A), + ) + member_id = member.json()["id"] + bad = await client.post( + "/api/v1/point-accounts", + json={ + "program_id": program_id, + "member_id": member_id, + "balance": 100, + }, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_default_program_exclusivity(client): + a = await client.post( + "/api/v1/programs", + json={"code": "d1", "name": "D1", "is_default": True}, + headers=tenant_headers(TENANT_A), + ) + b = await client.post( + "/api/v1/programs", + json={"code": "d2", "name": "D2", "is_default": True}, + headers=tenant_headers(TENANT_A), + ) + assert a.status_code == 201 + assert b.status_code == 201 + listed = await client.get("/api/v1/programs", headers=tenant_headers(TENANT_A)) + defaults = [p for p in listed.json() if p["is_default"]] + assert len(defaults) == 1 + assert defaults[0]["code"] == "D2" + + +@pytest.mark.asyncio +async def test_invalid_campaign_dates(client): + program = await client.post( + "/api/v1/programs", + json={"code": "dates", "name": "Dates Program"}, + headers=tenant_headers(TENANT_A), + ) + program_id = program.json()["id"] + bad = await client.post( + "/api/v1/campaigns", + json={ + "program_id": program_id, + "code": "bad", + "name": "Bad Dates", + "starts_on": "2026-12-01", + "ends_on": "2026-01-01", + }, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 422 diff --git a/backend/services/loyalty/app/tests/test_architecture.py b/backend/services/loyalty/app/tests/test_architecture.py new file mode 100644 index 0000000..cc356a5 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_architecture.py @@ -0,0 +1,191 @@ +"""Architecture tests — Loyalty module boundary enforcement.""" +from __future__ import annotations + +import ast +import re +from pathlib import Path + +from app.models.foundation import ( + Campaign, + LoyaltyAuditLog, + LoyaltyProgram, + Member, + MembershipTier, + PointAccount, + Reward, +) + + +FORBIDDEN_IMPORT_PREFIXES = ( + "app.services.accounting", + "app.models.accounting", + "backend.services.accounting", + "backend.services.crm", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + + +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_foundation_aggregates_are_independent(): + aggregates = { + LoyaltyProgram, + MembershipTier, + Member, + PointAccount, + Reward, + Campaign, + LoyaltyAuditLog, + } + assert len(aggregates) == 7 + for model in aggregates: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_point_account_has_no_mutable_balance_column(): + forbidden = {"balance", "points_balance", "available_balance", "ledger_balance"} + columns = set(PointAccount.__table__.columns.keys()) + assert forbidden.isdisjoint(columns) + + +def test_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "loyalty.view" in ALL_PERMISSIONS + assert "loyalty.programs.create" in ALL_PERMISSIONS + assert "loyalty.tiers.view" in ALL_PERMISSIONS + assert "loyalty.members.enroll" in ALL_PERMISSIONS + assert "loyalty.members.activate" in ALL_PERMISSIONS + assert "loyalty.members.transfer" in ALL_PERMISSIONS + assert "loyalty.members.lifecycle.view" in ALL_PERMISSIONS + assert "loyalty.point_accounts.manage" in ALL_PERMISSIONS + assert "loyalty.rewards.create" in ALL_PERMISSIONS + assert "loyalty.campaigns.manage" in ALL_PERMISSIONS + assert "loyalty.audit.view" 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 LoyaltyEventType + + assert LoyaltyEventType.PROGRAM_CREATED.value == "loyalty.program.created" + assert LoyaltyEventType.PROGRAM_DELETED.value == "loyalty.program.deleted" + assert LoyaltyEventType.MEMBER_ENROLLED.value == "loyalty.member.enrolled" + assert LoyaltyEventType.MEMBER_ACTIVATED.value == "loyalty.member.activated" + assert LoyaltyEventType.MEMBER_FROZEN.value == "loyalty.member.frozen" + assert LoyaltyEventType.MEMBER_TRANSFERRED.value == "loyalty.member.transferred" + assert LoyaltyEventType.MEMBER_DELETED.value == "loyalty.member.deleted" + assert ( + LoyaltyEventType.POINT_ACCOUNT_OPENED.value == "loyalty.point_account.opened" + ) + assert LoyaltyEventType.REWARD_CREATED.value == "loyalty.reward.created" + assert LoyaltyEventType.CAMPAIGN_UPDATED.value == "loyalty.campaign.updated" + assert LoyaltyEventType.TIER_DELETED.value == "loyalty.tier.deleted" + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AnalyticsProvider, + CRMProvider, + CommunicationProvider, + Customer360Provider, + FileStorageProvider, + ModuleIntegrationProvider, + NotificationProvider, + ) + + assert NotificationProvider is not None + assert AnalyticsProvider is not None + assert Customer360Provider is not None + assert AIProvider is not None + assert ModuleIntegrationProvider is not None + assert CRMProvider is not None + assert CommunicationProvider is not None + assert FileStorageProvider is not None + + +def test_outbox_model_is_tenant_aware(): + from app.models.outbox import OutboxEvent + + assert hasattr(OutboxEvent, "tenant_id") + assert "outbox_events" in OutboxEvent.__table__.name or OutboxEvent.__tablename__ == "outbox_events" + + +def test_no_forbidden_service_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_api_ownership_is_loyalty_only(): + from app.api.v1 import api_router + + prefixes = {getattr(r, "path", "") for r in api_router.routes} + joined = " ".join(sorted(prefixes)) + assert any("/programs" in p for p in prefixes) or "/programs" in joined + assert any("/members" in p for p in prefixes) or "/members" in joined + forbidden = ( + "automation", + "customer360", + "notification", + "helpdesk", + "analytics", + "crm", + "accounting", + ) + for name in forbidden: + assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name + + +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}" diff --git a/backend/services/loyalty/app/tests/test_business_rules.py b/backend/services/loyalty/app/tests/test_business_rules.py new file mode 100644 index 0000000..0638663 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_business_rules.py @@ -0,0 +1,35 @@ +"""Business rule and validator tests — Phase 7.0.""" +from __future__ import annotations + +import pytest + +from app.validators import ( + forbid_direct_balance_mutation, + validate_campaign_dates, + validate_code, + validate_non_negative_int, +) +from shared.exceptions import AppError +from datetime import date + + +def test_validate_code_normalizes(): + assert validate_code("gold-1") == "GOLD-1" + + +def test_forbid_direct_balance_mutation(): + with pytest.raises(AppError) as exc: + forbid_direct_balance_mutation({"balance": 10}) + assert exc.value.error_code == "direct_balance_forbidden" + + +def test_non_negative_points(): + assert validate_non_negative_int(0, field="points_cost") == 0 + with pytest.raises(AppError): + validate_non_negative_int(-1, field="points_cost") + + +def test_campaign_date_order(): + validate_campaign_dates(date(2026, 1, 1), date(2026, 2, 1)) + with pytest.raises(AppError): + validate_campaign_dates(date(2026, 3, 1), date(2026, 1, 1)) diff --git a/backend/services/loyalty/app/tests/test_dependency.py b/backend/services/loyalty/app/tests/test_dependency.py new file mode 100644 index 0000000..e4668bd --- /dev/null +++ b/backend/services/loyalty/app/tests/test_dependency.py @@ -0,0 +1,37 @@ +"""Dependency direction and DI tests.""" +from __future__ import annotations + +import inspect + +from app.api import deps +from app.repositories.base import TenantBaseRepository +from app.repositories.foundation import MemberRepository +from app.services.foundation import MemberService + + +def test_repository_pattern(): + assert issubclass(MemberRepository, TenantBaseRepository) + assert MemberRepository.model is not None + + +def test_service_depends_on_repository_not_api(): + source = inspect.getsource(MemberService) + assert "fastapi" not in source.lower() + assert "MemberRepository" in source + + +def test_api_deps_expose_tenant_and_db(): + assert hasattr(deps, "require_tenant") + assert hasattr(deps, "get_db") + assert hasattr(deps, "get_current_user") + + +def test_providers_are_protocols_only(): + import app.providers.contracts as contracts + + source = inspect.getsource(contracts) + assert "async def" in source + assert "class NotificationProvider" in source + assert "httpx" not in source + assert "sqlalchemy" not in source + assert "AsyncSession" not in source diff --git a/backend/services/loyalty/app/tests/test_docs.py b/backend/services/loyalty/app/tests/test_docs.py new file mode 100644 index 0000000..55edd46 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_docs.py @@ -0,0 +1,36 @@ +"""Documentation validation for Loyalty Phase 7.0.""" +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[5] + + +def test_phase_docs_exist(): + assert (ROOT / "docs" / "loyalty-phase-7-0.md").exists() + assert (ROOT / "docs" / "loyalty-phase-7-0-audit.md").exists() + assert (ROOT / "docs" / "loyalty-phase-7-1.md").exists() + assert (ROOT / "docs" / "phase-handover" / "phase-7-1.md").exists() + assert (ROOT / "docs" / "phases" / "Loyalty" / "README.md").exists() + assert (ROOT / "docs" / "architecture" / "adr" / "ADR-011.md").exists() + + +def test_module_registry_mentions_loyalty(): + text = (ROOT / "docs" / "module-registry.md").read_text(encoding="utf-8") + assert "## loyalty" in text.lower() + assert "loyalty_db" in text + assert "loyalty." in text + assert "7.1" in text or "0.7.1" in text + + +def test_progress_mentions_phase_7_1(): + text = (ROOT / "docs" / "progress.md").read_text(encoding="utf-8") + assert "7.1" in text + + +def test_service_readme_documents_boundaries(): + text = (ROOT / "backend" / "services" / "loyalty" / "README.md").read_text( + encoding="utf-8" + ) + assert "loyalty_db" in text + assert "CRM" in text or "crm" in text.lower() + assert "balance" in text.lower() or "ledger" in text.lower() diff --git a/backend/services/loyalty/app/tests/test_migration.py b/backend/services/loyalty/app/tests/test_migration.py new file mode 100644 index 0000000..855cd9b --- /dev/null +++ b/backend/services/loyalty/app/tests/test_migration.py @@ -0,0 +1,39 @@ +"""Migration metadata tests.""" +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + + +def test_alembic_revisions_exist(): + root = Path(__file__).resolve().parents[2] + cfg = Config(str(root / "alembic.ini")) + cfg.set_main_option("script_location", str(root / "alembic")) + scripts = ScriptDirectory.from_config(cfg) + revisions = list(scripts.walk_revisions()) + assert revisions + assert revisions[0].revision == "0002_phase_71_membership" + assert revisions[-1].revision == "0001_initial" + assert revisions[0].down_revision == "0001_initial" + assert revisions[-1].down_revision is None + + +def test_migration_upgrade_creates_metadata_tables(db_setup): + from app.core.database import Base + import app.models # noqa: F401 + + expected = { + "loyalty_programs", + "membership_tiers", + "members", + "point_accounts", + "rewards", + "campaigns", + "loyalty_audit_logs", + "outbox_events", + "membership_lifecycle_events", + } + assert expected.issubset(set(Base.metadata.tables.keys())) + assert "ix_members_tenant_tier" in { + idx.name for table in Base.metadata.tables.values() for idx in table.indexes + } \ No newline at end of file diff --git a/backend/services/loyalty/app/tests/test_performance.py b/backend/services/loyalty/app/tests/test_performance.py new file mode 100644 index 0000000..8db59ea --- /dev/null +++ b/backend/services/loyalty/app/tests/test_performance.py @@ -0,0 +1,28 @@ +"""Performance / index validation — foundation hot paths are index-backed.""" +from __future__ import annotations + +from app.core.database import Base +import app.models # noqa: F401 + + +def test_foundation_list_indexes_present(): + """Performance gate for Phase 7.0: tenant list/filter columns are indexed.""" + indexed = { + (table.name, tuple(col.name for col in idx.columns)) + for table in Base.metadata.tables.values() + for idx in table.indexes + } + required = { + ("loyalty_programs", ("tenant_id", "status")), + ("loyalty_programs", ("tenant_id",)), + ("members", ("tenant_id", "status")), + ("members", ("tenant_id", "tier_id")), + ("members", ("tenant_id", "membership_expires_at")), + ("membership_lifecycle_events", ("tenant_id", "member_id")), + ("point_accounts", ("tenant_id", "program_id")), + ("rewards", ("tenant_id", "status")), + ("campaigns", ("tenant_id", "status")), + ("outbox_events", ("status",)), + } + for item in required: + assert item in indexed, f"missing index covering {item}" diff --git a/backend/services/loyalty/app/tests/test_permissions.py b/backend/services/loyalty/app/tests/test_permissions.py new file mode 100644 index 0000000..6637a66 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_permissions.py @@ -0,0 +1,61 @@ +"""Permission definition and enforcement tests.""" +from app.api.permissions import user_has_permission +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES +from shared.security import CurrentUser + + +def test_all_permissions_use_loyalty_prefix(): + for perm in ALL_PERMISSIONS: + assert perm.startswith("loyalty."), perm + + +def test_required_permission_trees_present(): + trees = [ + "loyalty.programs.", + "loyalty.tiers.", + "loyalty.members.", + "loyalty.point_accounts.", + "loyalty.rewards.", + "loyalty.campaigns.", + "loyalty.audit.", + ] + for tree in trees: + assert any(p.startswith(tree) for p in ALL_PERMISSIONS), tree + + +def test_permission_prefixes_documented(): + assert "loyalty." in PERMISSION_PREFIXES + assert "loyalty.programs." in PERMISSION_PREFIXES + assert "loyalty.members." in PERMISSION_PREFIXES + assert "loyalty.point_accounts." in PERMISSION_PREFIXES + assert "loyalty.rewards." in PERMISSION_PREFIXES + assert "loyalty.campaigns." in PERMISSION_PREFIXES + + +def test_admin_role_grants_all_permissions(): + admin = CurrentUser(user_id="a", roles=["tenant_admin"]) + assert user_has_permission(admin, "loyalty.programs.create") + + +def test_viewer_lacks_manage_permission(): + viewer = CurrentUser(user_id="v", roles=["tenant_viewer"]) + assert not user_has_permission(viewer, "loyalty.programs.create") + + +def test_explicit_permission_in_roles_grants_access(): + user = CurrentUser(user_id="u", roles=["loyalty.programs.create"]) + assert user_has_permission(user, "loyalty.programs.create") + + +def test_resource_manage_grants_tree(): + user = CurrentUser(user_id="m", roles=["loyalty.programs.manage"]) + assert user_has_permission(user, "loyalty.programs.create") + assert user_has_permission(user, "loyalty.programs.delete") + assert not user_has_permission(user, "loyalty.members.create") + + +def test_loyalty_view_grants_view_leaves(): + user = CurrentUser(user_id="v", roles=["loyalty.view"]) + assert user_has_permission(user, "loyalty.programs.view") + assert user_has_permission(user, "loyalty.members.view") + assert not user_has_permission(user, "loyalty.programs.create") diff --git a/backend/services/loyalty/app/tests/test_phase71.py b/backend/services/loyalty/app/tests/test_phase71.py new file mode 100644 index 0000000..3fa7c26 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_phase71.py @@ -0,0 +1,204 @@ +"""Phase 7.1 — Membership Engine tests.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import LoyaltyEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers +from app.validators.membership import ensure_lifecycle_transition +from app.models.types import MemberStatus, MembershipLifecycleAction +from shared.exceptions import AppError + + +async def _program(client, code="p71", **extra): + resp = await client.post( + "/api/v1/programs", + json={"code": code, "name": code, **extra}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _member(client, program_id, *, enroll=False, display_name="Ali"): + resp = await client.post( + "/api/v1/members", + json={ + "program_id": program_id, + "display_name": display_name, + "enroll": enroll, + }, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +def test_lifecycle_transition_matrix_unit(): + ensure_lifecycle_transition( + action=MembershipLifecycleAction.ACTIVATE, current=MemberStatus.PENDING + ) + with pytest.raises(AppError) as exc: + ensure_lifecycle_transition( + action=MembershipLifecycleAction.FREEZE, current=MemberStatus.PENDING + ) + assert exc.value.error_code == "invalid_lifecycle_transition" + with pytest.raises(AppError) as exc2: + ensure_lifecycle_transition( + action=MembershipLifecycleAction.ACTIVATE, current=MemberStatus.CANCELLED + ) + assert exc2.value.error_code == "membership_terminal" + + +@pytest.mark.asyncio +async def test_activate_freeze_resume_renew_cancel_flow(client): + program = await _program(client, "life1") + member = await _member(client, program["id"], enroll=False) + mid = member["id"] + + activated = await client.post( + f"/api/v1/members/{mid}/activate", + json={"term_days": 30}, + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 200, activated.text + body = activated.json() + assert body["status"] == "active" + assert body["activated_at"] is not None + assert body["membership_expires_at"] is not None + + frozen = await client.post( + f"/api/v1/members/{mid}/freeze", + json={"reason": "vacation"}, + headers=tenant_headers(TENANT_A), + ) + assert frozen.status_code == 200 + assert frozen.json()["status"] == "frozen" + assert frozen.json()["freeze_reason"] == "vacation" + + resumed = await client.post( + f"/api/v1/members/{mid}/resume", + json={}, + headers=tenant_headers(TENANT_A), + ) + assert resumed.status_code == 200 + assert resumed.json()["status"] == "active" + assert resumed.json()["frozen_at"] is None + + renewed = await client.post( + f"/api/v1/members/{mid}/renew", + json={"term_days": 60}, + headers=tenant_headers(TENANT_A), + ) + assert renewed.status_code == 200 + assert renewed.json()["status"] == "active" + + cancelled = await client.post( + f"/api/v1/members/{mid}/cancel", + json={"reason": "customer request"}, + headers=tenant_headers(TENANT_A), + ) + assert cancelled.status_code == 200 + assert cancelled.json()["status"] == "cancelled" + + life = await client.get( + f"/api/v1/members/{mid}/lifecycle", + headers=tenant_headers(TENANT_A), + ) + assert life.status_code == 200 + actions = {row["action"] for row in life.json()} + assert {"activate", "freeze", "resume", "renew", "cancel"} <= actions + + events = {e.event_type for e in get_event_publisher().published} + assert LoyaltyEventType.MEMBER_ACTIVATED.value in events + assert LoyaltyEventType.MEMBER_FROZEN.value in events + assert LoyaltyEventType.MEMBER_CANCELLED.value in events + + +@pytest.mark.asyncio +async def test_expire_and_reactivate(client): + program = await _program(client, "exp1") + member = await _member(client, program["id"], enroll=True) + mid = member["id"] + + expired = await client.post( + f"/api/v1/members/{mid}/expire", + json={}, + headers=tenant_headers(TENANT_A), + ) + assert expired.status_code == 200 + assert expired.json()["status"] == "expired" + + reactivated = await client.post( + f"/api/v1/members/{mid}/activate", + json={"term_days": 10}, + headers=tenant_headers(TENANT_A), + ) + assert reactivated.status_code == 200 + assert reactivated.json()["status"] == "active" + assert reactivated.json()["expired_at"] is None + + +@pytest.mark.asyncio +async def test_transfer_creates_target_member(client): + source_program = await _program(client, "src") + target_program = await _program(client, "dst") + member = await _member(client, source_program["id"], enroll=True) + mid = member["id"] + + transferred = await client.post( + f"/api/v1/members/{mid}/transfer", + json={"target_program_id": target_program["id"], "reason": "move"}, + headers=tenant_headers(TENANT_A), + ) + assert transferred.status_code == 200, transferred.text + assert transferred.json()["status"] == "transferred" + target_id = transferred.json()["transferred_to_member_id"] + assert target_id + + target = await client.get( + f"/api/v1/members/{target_id}", + headers=tenant_headers(TENANT_A), + ) + assert target.status_code == 200 + assert target.json()["program_id"] == target_program["id"] + assert target.json()["status"] == "active" + + +@pytest.mark.asyncio +async def test_invalid_transition_rejected(client): + program = await _program(client, "bad") + member = await _member(client, program["id"], enroll=False) + mid = member["id"] + bad = await client.post( + f"/api/v1/members/{mid}/freeze", + json={"reason": "nope"}, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 409 + assert bad.json()["error"]["code"] == "invalid_lifecycle_transition" + + +@pytest.mark.asyncio +async def test_program_delete_blocked_with_active_members(client): + program = await _program(client, "block") + await _member(client, program["id"], enroll=True) + deleted = await client.post( + f"/api/v1/programs/{program['id']}/delete", + headers=tenant_headers(TENANT_A), + ) + assert deleted.status_code == 409 + assert deleted.json()["error"]["code"] == "program_has_active_members" + + +@pytest.mark.asyncio +async def test_lifecycle_tenant_isolation(client): + program = await _program(client, "iso") + member = await _member(client, program["id"], enroll=True) + mid = member["id"] + other = await client.get( + f"/api/v1/members/{mid}/lifecycle", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 diff --git a/backend/services/loyalty/app/tests/test_repository.py b/backend/services/loyalty/app/tests/test_repository.py new file mode 100644 index 0000000..9743bd8 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_repository.py @@ -0,0 +1,40 @@ +"""Repository tenant-scoped persistence tests.""" +from __future__ import annotations + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.core.database import engine +from app.models.foundation import LoyaltyProgram +from app.models.types import ProgramStatus +from app.repositories.foundation import LoyaltyProgramRepository +from app.tests.conftest import TENANT_A, TENANT_B + + +@pytest.mark.asyncio +async def test_program_repository_tenant_isolation(db_setup): + Session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + async with Session() as session: + repo = LoyaltyProgramRepository(session) + program_a = LoyaltyProgram( + tenant_id=TENANT_A, + code="A1", + name="Program A", + status=ProgramStatus.ACTIVE, + ) + program_b = LoyaltyProgram( + tenant_id=TENANT_B, + code="B1", + name="Program B", + status=ProgramStatus.ACTIVE, + ) + await repo.add(program_a) + await repo.add(program_b) + await session.commit() + + listed_a = await repo.list_by_tenant(TENANT_A, limit=50) + listed_b = await repo.list_by_tenant(TENANT_B, limit=50) + assert all(item.tenant_id == TENANT_A for item in listed_a) + assert all(item.tenant_id == TENANT_B for item in listed_b) + assert await repo.get(TENANT_B, program_a.id) is None + assert await repo.get(TENANT_A, program_a.id) is not None diff --git a/backend/services/loyalty/app/tests/test_security.py b/backend/services/loyalty/app/tests/test_security.py new file mode 100644 index 0000000..7f8a9e9 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_security.py @@ -0,0 +1,68 @@ +"""Security tests — auth required and error hygiene.""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import settings +from app.core.security import get_current_user +from app.main import app +from app.tests.conftest import TENANT_A, tenant_headers +from shared.exceptions import UnauthorizedError +from shared.security import CurrentUser + + +@pytest.mark.asyncio +async def test_unauthenticated_denied_when_auth_required(db_setup, monkeypatch): + monkeypatch.setattr(settings, "auth_required", True) + + async def _deny(): + raise UnauthorizedError("توکن احراز هویت ارائه نشده است") + + app.dependency_overrides[get_current_user] = _deny + try: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + resp = await ac.get( + "/api/v1/programs", headers=tenant_headers(TENANT_A) + ) + assert resp.status_code == 401 + body = resp.json() + assert "error" in body + assert "traceback" not in str(body).lower() + assert "superapp_password" not in str(body) + finally: + app.dependency_overrides.pop(get_current_user, None) + monkeypatch.setattr(settings, "auth_required", False) + + +@pytest.mark.asyncio +async def test_permission_denied_for_viewer_role(db_setup, monkeypatch): + monkeypatch.setattr(settings, "auth_required", True) + + async def _viewer(): + return CurrentUser(user_id="viewer", roles=["tenant_viewer"]) + + app.dependency_overrides[get_current_user] = _viewer + try: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + resp = await ac.post( + "/api/v1/programs", + json={"code": "SEC", "name": "Secured"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 403 + assert resp.json()["error"]["code"] in {"forbidden", "permission_denied"} + finally: + app.dependency_overrides.pop(get_current_user, None) + monkeypatch.setattr(settings, "auth_required", False) + + +@pytest.mark.asyncio +async def test_invalid_tenant_header(client): + resp = await client.get( + "/api/v1/programs", headers={"X-Tenant-ID": "not-a-uuid"} + ) + assert resp.status_code == 400 + assert resp.json()["error"]["code"] == "invalid_tenant_id" diff --git a/backend/services/loyalty/app/tests/test_tenant_isolation.py b/backend/services/loyalty/app/tests/test_tenant_isolation.py new file mode 100644 index 0000000..2f8f211 --- /dev/null +++ b/backend/services/loyalty/app/tests/test_tenant_isolation.py @@ -0,0 +1,49 @@ +"""Tenant isolation API tests.""" +from __future__ import annotations + +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +@pytest.mark.asyncio +async def test_tenant_required(client): + resp = await client.get("/api/v1/programs") + assert resp.status_code in (400, 422) + + +@pytest.mark.asyncio +async def test_program_tenant_isolation(client): + create = await client.post( + "/api/v1/programs", + json={"code": "GOLD", "name": "Gold Program"}, + headers=tenant_headers(TENANT_A), + ) + assert create.status_code == 201, create.text + program_id = create.json()["id"] + + list_b = await client.get("/api/v1/programs", headers=tenant_headers(TENANT_B)) + assert list_b.status_code == 200 + assert all(item["id"] != program_id for item in list_b.json()) + + get_b = await client.get( + f"/api/v1/programs/{program_id}", headers=tenant_headers(TENANT_B) + ) + assert get_b.status_code == 404 + + +@pytest.mark.asyncio +async def test_program_code_unique_per_tenant(client): + payload = {"code": "VIP", "name": "VIP Club"} + a1 = await client.post( + "/api/v1/programs", json=payload, headers=tenant_headers(TENANT_A) + ) + a2 = await client.post( + "/api/v1/programs", json=payload, headers=tenant_headers(TENANT_A) + ) + b1 = await client.post( + "/api/v1/programs", json=payload, headers=tenant_headers(TENANT_B) + ) + assert a1.status_code == 201 + assert a2.status_code == 409 + assert b1.status_code == 201 diff --git a/backend/services/loyalty/app/validators/__init__.py b/backend/services/loyalty/app/validators/__init__.py new file mode 100644 index 0000000..a9e7348 --- /dev/null +++ b/backend/services/loyalty/app/validators/__init__.py @@ -0,0 +1,54 @@ +"""Loyalty validators package.""" +from app.validators.foundation import ( + ensure_optimistic_version, + forbid_direct_balance_mutation, + release_unique_token, + validate_campaign_dates, + validate_campaign_status, + validate_code, + validate_currency_code, + validate_email, + validate_member_status, + validate_non_empty, + validate_non_negative_int, + validate_phone, + validate_point_account_status, + validate_program_status, + validate_reward_status, + validate_reward_type, + validate_tier_status, +) +from app.validators.membership import ( + ACTIVE_MEMBER_STATUSES, + DEFAULT_TERM_DAYS, + compute_expiry, + ensure_lifecycle_transition, + target_status_for, + validate_reason, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_email", + "validate_phone", + "validate_currency_code", + "validate_non_negative_int", + "validate_program_status", + "validate_member_status", + "validate_tier_status", + "validate_point_account_status", + "validate_reward_type", + "validate_reward_status", + "validate_campaign_status", + "validate_campaign_dates", + "ensure_optimistic_version", + "forbid_direct_balance_mutation", + "release_unique_token", + "ensure_lifecycle_transition", + "target_status_for", + "compute_expiry", + "validate_reason", + "DEFAULT_TERM_DAYS", + "ACTIVE_MEMBER_STATUSES", +] diff --git a/backend/services/loyalty/app/validators/foundation.py b/backend/services/loyalty/app/validators/foundation.py new file mode 100644 index 0000000..1d94ab1 --- /dev/null +++ b/backend/services/loyalty/app/validators/foundation.py @@ -0,0 +1,247 @@ +"""Phase 7.0 Loyalty validators.""" +from __future__ import annotations + +import re +from datetime import date + +from shared.exceptions import AppError + +from app.models.types import ( + CampaignStatus, + MemberStatus, + PointAccountStatus, + ProgramStatus, + RewardStatus, + RewardType, + TierStatus, +) + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +PHONE_RE = re.compile(r"^[\d\s\+\-\(\)]{5,30}$") +CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$") + + +def validate_non_empty(value: str | None, *, field: str) -> str: + if value is None or not str(value).strip(): + raise AppError( + f"{field} الزامی است", + status_code=422, + error_code="validation_error", + details={"field": field}, + ) + return str(value).strip() + + +def validate_code(code: str | None, *, field: str = "code") -> str: + cleaned = validate_non_empty(code, field=field).upper() + if not CODE_RE.match(cleaned): + raise AppError( + f"فرمت {field} نامعتبر است", + status_code=422, + error_code="invalid_code", + details={"field": field}, + ) + return cleaned + + +def validate_email(email: str | None, *, required: bool = False) -> str | None: + if email is None or not email.strip(): + if required: + raise AppError( + "ایمیل الزامی است", + status_code=422, + error_code="email_required", + ) + return None + cleaned = email.strip().lower() + if not EMAIL_RE.match(cleaned): + raise AppError( + "فرمت ایمیل نامعتبر است", + status_code=422, + error_code="invalid_email", + ) + return cleaned + + +def validate_phone(phone: str | None, *, field: str = "phone") -> str | None: + if phone is None or not phone.strip(): + return None + cleaned = phone.strip() + if not PHONE_RE.match(cleaned): + raise AppError( + f"فرمت {field} نامعتبر است", + status_code=422, + error_code="invalid_phone", + details={"field": field}, + ) + return cleaned + + +def validate_currency_code(code: str) -> str: + cleaned = code.strip().upper() + if len(cleaned) != 3: + raise AppError( + "کد ارز باید ۳ حرفی باشد", + status_code=422, + error_code="invalid_currency_code", + ) + return cleaned + + +def validate_non_negative_int(value: int | None, *, field: str) -> int: + resolved = 0 if value is None else int(value) + if resolved < 0: + raise AppError( + f"{field} نمی‌تواند منفی باشد", + status_code=422, + error_code="invalid_amount", + details={"field": field}, + ) + return resolved + + +def validate_program_status(status: ProgramStatus | str) -> ProgramStatus: + if isinstance(status, ProgramStatus): + return status + try: + return ProgramStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت برنامه وفاداری نامعتبر است", + status_code=422, + error_code="invalid_program_status", + ) from exc + + +def validate_member_status(status: MemberStatus | str) -> MemberStatus: + if isinstance(status, MemberStatus): + return status + try: + return MemberStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت عضو نامعتبر است", + status_code=422, + error_code="invalid_member_status", + ) from exc + + +def validate_tier_status(status: TierStatus | str) -> TierStatus: + if isinstance(status, TierStatus): + return status + try: + return TierStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت سطح عضویت نامعتبر است", + status_code=422, + error_code="invalid_tier_status", + ) from exc + + +def validate_point_account_status( + status: PointAccountStatus | str, +) -> PointAccountStatus: + if isinstance(status, PointAccountStatus): + return status + try: + return PointAccountStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت حساب امتیاز نامعتبر است", + status_code=422, + error_code="invalid_point_account_status", + ) from exc + + +def validate_reward_type(reward_type: RewardType | str) -> RewardType: + if isinstance(reward_type, RewardType): + return reward_type + try: + return RewardType(reward_type) + except ValueError as exc: + raise AppError( + "نوع پاداش نامعتبر است", + status_code=422, + error_code="invalid_reward_type", + ) from exc + + +def validate_reward_status(status: RewardStatus | str) -> RewardStatus: + if isinstance(status, RewardStatus): + return status + try: + return RewardStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت پاداش نامعتبر است", + status_code=422, + error_code="invalid_reward_status", + ) from exc + + +def validate_campaign_status(status: CampaignStatus | str) -> CampaignStatus: + if isinstance(status, CampaignStatus): + return status + try: + return CampaignStatus(status) + except ValueError as exc: + raise AppError( + "وضعیت کمپین نامعتبر است", + status_code=422, + error_code="invalid_campaign_status", + ) from exc + + +def validate_campaign_dates( + starts_on: date | None, ends_on: date | None +) -> tuple[date | None, date | None]: + if starts_on and ends_on and ends_on < starts_on: + raise AppError( + "تاریخ پایان کمپین نمی‌تواند قبل از تاریخ شروع باشد", + status_code=422, + error_code="invalid_campaign_dates", + ) + return starts_on, ends_on + + +def ensure_optimistic_version( + entity_version: int, expected: int | None, *, required: bool = True +) -> None: + if expected is None: + if required: + raise AppError( + "فیلد version برای به‌روزرسانی الزامی است", + status_code=422, + error_code="version_required", + ) + return + if entity_version != expected: + raise AppError( + "نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید", + status_code=409, + error_code="version_conflict", + details={"expected": expected, "actual": entity_version}, + ) + + +def release_unique_token(value: str, entity_id) -> str: + """Free unique business keys on soft-delete so codes can be reused.""" + suffix = f"__del__{entity_id.hex[:8]}" + if value.endswith(suffix): + return value + # Keep within typical 50-char code columns + base = value[: max(0, 50 - len(suffix))] + return f"{base}{suffix}" + + +def forbid_direct_balance_mutation(payload: dict) -> None: + forbidden = {"balance", "points_balance", "available_balance", "ledger_balance"} + present = forbidden.intersection(payload.keys()) + if present: + raise AppError( + "تغییر مستقیم موجودی امتیاز ممنوع است؛ فقط از طریق ledger", + status_code=422, + error_code="direct_balance_forbidden", + details={"fields": sorted(present)}, + ) diff --git a/backend/services/loyalty/app/validators/membership.py b/backend/services/loyalty/app/validators/membership.py new file mode 100644 index 0000000..868f7cd --- /dev/null +++ b/backend/services/loyalty/app/validators/membership.py @@ -0,0 +1,145 @@ +"""Phase 7.1 membership lifecycle validators.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from shared.exceptions import AppError + +from app.models.types import MemberStatus, MembershipLifecycleAction + +DEFAULT_TERM_DAYS = 365 + +# Canonical transitions for the Membership Engine. +ALLOWED_TRANSITIONS: dict[MembershipLifecycleAction, set[MemberStatus]] = { + MembershipLifecycleAction.ENROLL: {MemberStatus.PENDING}, + MembershipLifecycleAction.ACTIVATE: {MemberStatus.PENDING, MemberStatus.EXPIRED}, + MembershipLifecycleAction.RENEW: { + MemberStatus.ACTIVE, + MemberStatus.EXPIRED, + MemberStatus.FROZEN, + MemberStatus.SUSPENDED, + }, + MembershipLifecycleAction.FREEZE: {MemberStatus.ACTIVE}, + MembershipLifecycleAction.RESUME: { + MemberStatus.FROZEN, + MemberStatus.SUSPENDED, + }, + MembershipLifecycleAction.CANCEL: { + MemberStatus.PENDING, + MemberStatus.ACTIVE, + MemberStatus.FROZEN, + MemberStatus.SUSPENDED, + MemberStatus.EXPIRED, + }, + MembershipLifecycleAction.EXPIRE: { + MemberStatus.ACTIVE, + MemberStatus.FROZEN, + MemberStatus.SUSPENDED, + }, + MembershipLifecycleAction.TRANSFER: { + MemberStatus.ACTIVE, + MemberStatus.FROZEN, + MemberStatus.SUSPENDED, + }, +} + +TERMINAL_STATUSES = frozenset( + { + MemberStatus.CANCELLED, + MemberStatus.TRANSFERRED, + MemberStatus.CLOSED, + } +) + +ACTIVE_MEMBER_STATUSES = frozenset( + { + MemberStatus.PENDING, + MemberStatus.ACTIVE, + MemberStatus.FROZEN, + MemberStatus.SUSPENDED, + MemberStatus.EXPIRED, + } +) + + +def ensure_lifecycle_transition( + *, + action: MembershipLifecycleAction, + current: MemberStatus, +) -> None: + if current in TERMINAL_STATUSES: + raise AppError( + "عضویت در وضعیت پایانی است و قابل تغییر نیست", + status_code=409, + error_code="membership_terminal", + details={"status": current.value, "action": action.value}, + ) + allowed = ALLOWED_TRANSITIONS.get(action, set()) + if current not in allowed: + raise AppError( + "انتقال وضعیت عضویت مجاز نیست", + status_code=409, + error_code="invalid_lifecycle_transition", + details={ + "from_status": current.value, + "action": action.value, + "allowed_from": sorted(s.value for s in allowed), + }, + ) + + +def target_status_for(action: MembershipLifecycleAction) -> MemberStatus: + mapping = { + MembershipLifecycleAction.ENROLL: MemberStatus.ACTIVE, + MembershipLifecycleAction.ACTIVATE: MemberStatus.ACTIVE, + MembershipLifecycleAction.RENEW: MemberStatus.ACTIVE, + MembershipLifecycleAction.FREEZE: MemberStatus.FROZEN, + MembershipLifecycleAction.RESUME: MemberStatus.ACTIVE, + MembershipLifecycleAction.CANCEL: MemberStatus.CANCELLED, + MembershipLifecycleAction.EXPIRE: MemberStatus.EXPIRED, + MembershipLifecycleAction.TRANSFER: MemberStatus.TRANSFERRED, + } + return mapping[action] + + +def compute_expiry( + *, + from_when: datetime | None = None, + term_days: int = DEFAULT_TERM_DAYS, + explicit: datetime | None = None, +) -> datetime: + if explicit is not None: + if explicit.tzinfo is None: + explicit = explicit.replace(tzinfo=timezone.utc) + return explicit + if term_days < 1: + raise AppError( + "مدت عضویت باید حداقل ۱ روز باشد", + status_code=422, + error_code="invalid_term_days", + ) + base = from_when or datetime.now(timezone.utc) + if base.tzinfo is None: + base = base.replace(tzinfo=timezone.utc) + return base + timedelta(days=term_days) + + +def validate_reason(reason: str | None, *, required: bool = False, field: str = "reason") -> str | None: + if reason is None or not str(reason).strip(): + if required: + raise AppError( + f"{field} الزامی است", + status_code=422, + error_code="validation_error", + details={"field": field}, + ) + return None + cleaned = str(reason).strip() + if len(cleaned) > 500: + raise AppError( + f"{field} بیش از حد طولانی است", + status_code=422, + error_code="validation_error", + details={"field": field}, + ) + return cleaned diff --git a/backend/services/loyalty/pytest-final.txt b/backend/services/loyalty/pytest-final.txt new file mode 100644 index 0000000..c672231 Binary files /dev/null and b/backend/services/loyalty/pytest-final.txt differ diff --git a/backend/services/loyalty/pytest-heal.txt b/backend/services/loyalty/pytest-heal.txt new file mode 100644 index 0000000..96b8879 Binary files /dev/null and b/backend/services/loyalty/pytest-heal.txt differ diff --git a/backend/services/loyalty/pytest-out.txt b/backend/services/loyalty/pytest-out.txt new file mode 100644 index 0000000..be8926e --- /dev/null +++ b/backend/services/loyalty/pytest-out.txt @@ -0,0 +1,2 @@ +................................... [100%] +35 passed in 2.99s diff --git a/backend/services/loyalty/pytest.ini b/backend/services/loyalty/pytest.ini new file mode 100644 index 0000000..b396e8d --- /dev/null +++ b/backend/services/loyalty/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +asyncio_mode = auto +testpaths = app/tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/loyalty/requirements.txt b/backend/services/loyalty/requirements.txt new file mode 100644 index 0000000..1647b01 --- /dev/null +++ b/backend/services/loyalty/requirements.txt @@ -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 diff --git a/backend/services/loyalty/scripts/ensure_db.py b/backend/services/loyalty/scripts/ensure_db.py new file mode 100644 index 0000000..0217bfb --- /dev/null +++ b/backend/services/loyalty/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure loyalty_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("LOYALTY_DATABASE_URL_SYNC", "") + if not sync_url: + print("LOYALTY_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "loyalty_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() diff --git a/backend/services/sports_center/Dockerfile.dev b/backend/services/sports_center/Dockerfile.dev new file mode 100644 index 0000000..7ae0887 --- /dev/null +++ b/backend/services/sports_center/Dockerfile.dev @@ -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/sports_center/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 8006 diff --git a/backend/services/sports_center/README.md b/backend/services/sports_center/README.md new file mode 100644 index 0000000..3a612fc --- /dev/null +++ b/backend/services/sports_center/README.md @@ -0,0 +1,33 @@ +# Sports Center Platform Service + +Independent enterprise microservice for generic sports center operations. + +- Phase: **9.2 Member Management** +- Version: `0.9.2.0` +- Database: `sports_center_db` (sole owner) +- API port: `8006` +- Permission prefix: `sports_center.*` +- ADR: ADR-014 + +## Ownership + +Sports Center owns **only** sports business aggregates (centers, branches, sports catalog, +membership catalog, members, membership assignment, cards/waivers/documents, coaches shell, +facilities, devices, connector contracts, configuration, audit). + +It must **not** own Accounting, CRM, Loyalty, Communication, Notification, Storage, Identity, AI, +Automation, or Customer360 — consume those via API + Events only. + +## Architecture + +- Database-per-service (ADR-001) +- Row-level multi-tenancy via `tenant_id` (ADR-003) +- Adapter-based connector framework (no vendor logic in business services) +- Generic sport model — no sport-specific business rules hardcoded + +## Docs + +- [docs/sports-center-phase-9-2.md](../../../docs/sports-center-phase-9-2.md) +- [docs/phase-handover/phase-9-2.md](../../../docs/phase-handover/phase-9-2.md) +- [docs/sports-center-phase-9-1.md](../../../docs/sports-center-phase-9-1.md) +- [docs/sports-center-phase-9-0.md](../../../docs/sports-center-phase-9-0.md) diff --git a/backend/services/sports_center/alembic.ini b/backend/services/sports_center/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/sports_center/alembic.ini @@ -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 diff --git a/backend/services/sports_center/alembic/env.py b/backend/services/sports_center/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/sports_center/alembic/env.py @@ -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() diff --git a/backend/services/sports_center/alembic/versions/0001_initial.py b/backend/services/sports_center/alembic/versions/0001_initial.py new file mode 100644 index 0000000..681ae39 --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Sports Center schema — Phase 9.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) diff --git a/backend/services/sports_center/alembic/versions/0002_phase_91_membership_catalog.py b/backend/services/sports_center/alembic/versions/0002_phase_91_membership_catalog.py new file mode 100644 index 0000000..5f74b4c --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0002_phase_91_membership_catalog.py @@ -0,0 +1,76 @@ +"""Phase 9.1 — Membership Catalog schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0002_phase_91_membership_catalog" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + # Additive columns on membership_types (backward compatible) + columns = {c["name"] for c in sa.inspect(bind).get_columns("membership_types")} + with op.batch_alter_table("membership_types") as batch: + if "package_id" not in columns: + batch.add_column(sa.Column("package_id", sa.String(36), nullable=True)) + if "plan_id" not in columns: + batch.add_column(sa.Column("plan_id", sa.String(36), nullable=True)) + if "pricing_model_id" not in columns: + batch.add_column(sa.Column("pricing_model_id", sa.String(36), nullable=True)) + if "age_group_id" not in columns: + batch.add_column(sa.Column("age_group_id", sa.String(36), nullable=True)) + if "sport_category_id" not in columns: + batch.add_column(sa.Column("sport_category_id", sa.String(36), nullable=True)) + if "renewal_policy_id" not in columns: + batch.add_column(sa.Column("renewal_policy_id", sa.String(36), nullable=True)) + if "freezing_rule_id" not in columns: + batch.add_column(sa.Column("freezing_rule_id", sa.String(36), nullable=True)) + if "expiration_policy_id" not in columns: + batch.add_column(sa.Column("expiration_policy_id", sa.String(36), nullable=True)) + if "is_transferable" not in columns: + batch.add_column( + sa.Column("is_transferable", sa.Boolean(), nullable=False, server_default=sa.false()) + ) + if "sort_order" not in columns: + batch.add_column( + sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0") + ) + # Create new catalog tables from metadata (idempotent for missing tables) + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "expiration_policies", + "freezing_rules", + "renewal_policies", + "membership_rules", + "membership_plans", + "membership_packages", + "pricing_models", + "age_groups", + "sport_categories", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) + columns = {c["name"] for c in sa.inspect(bind).get_columns("membership_types")} + with op.batch_alter_table("membership_types") as batch: + for col in ( + "package_id", + "plan_id", + "pricing_model_id", + "age_group_id", + "sport_category_id", + "renewal_policy_id", + "freezing_rule_id", + "expiration_policy_id", + "is_transferable", + "sort_order", + ): + if col in columns: + batch.drop_column(col) diff --git a/backend/services/sports_center/alembic/versions/0003_phase_92_member_management.py b/backend/services/sports_center/alembic/versions/0003_phase_92_member_management.py new file mode 100644 index 0000000..00d1a38 --- /dev/null +++ b/backend/services/sports_center/alembic/versions/0003_phase_92_member_management.py @@ -0,0 +1,44 @@ +"""Phase 9.2 — Member Management schema.""" +from alembic import op +import sqlalchemy as sa +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0003_phase_92_member_management" +down_revision = "0002_phase_91_membership_catalog" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + columns = {c["name"] for c in sa.inspect(bind).get_columns("memberships")} + with op.batch_alter_table("memberships") as batch: + if "member_id" not in columns: + batch.add_column(sa.Column("member_id", sa.String(36), nullable=True)) + if "freeze_starts_on" not in columns: + batch.add_column(sa.Column("freeze_starts_on", sa.Date(), nullable=True)) + if "freeze_ends_on" not in columns: + batch.add_column(sa.Column("freeze_ends_on", sa.Date(), nullable=True)) + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "member_documents", + "waivers", + "digital_memberships", + "membership_cards", + "medical_information", + "emergency_contacts", + "family_members", + "members", + ): + if sa.inspect(bind).has_table(table): + op.drop_table(table) + columns = {c["name"] for c in sa.inspect(bind).get_columns("memberships")} + with op.batch_alter_table("memberships") as batch: + for col in ("member_id", "freeze_starts_on", "freeze_ends_on"): + if col in columns: + batch.drop_column(col) diff --git a/backend/services/sports_center/app/__init__.py b/backend/services/sports_center/app/__init__.py new file mode 100644 index 0000000..63dffb6 --- /dev/null +++ b/backend/services/sports_center/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.9.2.0" diff --git a/backend/services/sports_center/app/api/deps.py b/backend/services/sports_center/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/sports_center/app/api/deps.py @@ -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 diff --git a/backend/services/sports_center/app/api/v1/__init__.py b/backend/services/sports_center/app/api/v1/__init__.py new file mode 100644 index 0000000..8e8dd3b --- /dev/null +++ b/backend/services/sports_center/app/api/v1/__init__.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + attendance_gateways, + branches, + catalog, + coaches, + configurations, + courts, + device_providers, + devices, + events, + facilities, + locker_rooms, + lockers, + members, + membership_types, + memberships, + permissions, + roles, + rooms, + settings, + sports, + sports_centers, +) + +api_router = APIRouter() +api_router.include_router(sports_centers.router, prefix="/sports-centers", tags=["sports-centers"]) +api_router.include_router(branches.router, prefix="/branches", tags=["branches"]) +api_router.include_router(sports.router, prefix="/sports", tags=["sports"]) +api_router.include_router(membership_types.router, prefix="/membership-types", tags=["membership-types"]) +api_router.include_router(memberships.router, prefix="/memberships", tags=["memberships"]) +api_router.include_router(members.members_router, prefix="/members", tags=["members"]) +api_router.include_router(members.family_members_router, prefix="/family-members", tags=["family-members"]) +api_router.include_router(members.emergency_contacts_router, prefix="/emergency-contacts", tags=["emergency-contacts"]) +api_router.include_router(members.medical_router, prefix="/medical-information", tags=["medical-information"]) +api_router.include_router(members.cards_router, prefix="/membership-cards", tags=["membership-cards"]) +api_router.include_router(members.digital_router, prefix="/digital-memberships", tags=["digital-memberships"]) +api_router.include_router(members.waivers_router, prefix="/waivers", tags=["waivers"]) +api_router.include_router(members.documents_router, prefix="/member-documents", tags=["member-documents"]) +api_router.include_router(catalog.sport_categories_router, prefix="/sport-categories", tags=["sport-categories"]) +api_router.include_router(catalog.age_groups_router, prefix="/age-groups", tags=["age-groups"]) +api_router.include_router(catalog.pricing_models_router, prefix="/pricing-models", tags=["pricing-models"]) +api_router.include_router(catalog.membership_packages_router, prefix="/membership-packages", tags=["membership-packages"]) +api_router.include_router(catalog.membership_plans_router, prefix="/membership-plans", tags=["membership-plans"]) +api_router.include_router(catalog.membership_rules_router, prefix="/membership-rules", tags=["membership-rules"]) +api_router.include_router(catalog.renewal_policies_router, prefix="/renewal-policies", tags=["renewal-policies"]) +api_router.include_router(catalog.freezing_rules_router, prefix="/freezing-rules", tags=["freezing-rules"]) +api_router.include_router(catalog.expiration_policies_router, prefix="/expiration-policies", tags=["expiration-policies"]) +api_router.include_router(coaches.router, prefix="/coaches", tags=["coaches"]) +api_router.include_router(roles.router, prefix="/roles", tags=["roles"]) +api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"]) +api_router.include_router(facilities.router, prefix="/facilities", tags=["facilities"]) +api_router.include_router(courts.router, prefix="/courts", tags=["courts"]) +api_router.include_router(rooms.router, prefix="/rooms", tags=["rooms"]) +api_router.include_router(locker_rooms.router, prefix="/locker-rooms", tags=["locker-rooms"]) +api_router.include_router(lockers.router, prefix="/lockers", tags=["lockers"]) +api_router.include_router(device_providers.router, prefix="/device-providers", tags=["device-providers"]) +api_router.include_router(devices.router, prefix="/devices", tags=["devices"]) +api_router.include_router(attendance_gateways.router, prefix="/attendance-gateways", tags=["attendance-gateways"]) +api_router.include_router(configurations.router, prefix="/configurations", tags=["configurations"]) +api_router.include_router(events.router, prefix="/events", tags=["events"]) +api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) diff --git a/backend/services/sports_center/app/api/v1/attendance_gateways.py b/backend/services/sports_center/app/api/v1/attendance_gateways.py new file mode 100644 index 0000000..5fb71ab --- /dev/null +++ b/backend/services/sports_center/app/api/v1/attendance_gateways.py @@ -0,0 +1,71 @@ +"""Attendance Gateway APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + AttendanceGatewayCreate, + AttendanceGatewayRead, + AttendanceGatewayUpdate, +) +from app.services.foundation import AttendanceGatewayService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=AttendanceGatewayRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: AttendanceGatewayCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AttendanceGatewayService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[AttendanceGatewayRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AttendanceGatewayService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{gateway_id}", response_model=AttendanceGatewayRead) +async def get_item( + gateway_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await AttendanceGatewayService(db).get(tenant_id, gateway_id) + + +@router.patch("/{gateway_id}", response_model=AttendanceGatewayRead) +async def update_item( + gateway_id: UUID, + body: AttendanceGatewayUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AttendanceGatewayService(db).update(tenant_id, gateway_id, body, actor=user) + + +@router.post("/{gateway_id}/delete", response_model=AttendanceGatewayRead) +async def soft_delete_item( + gateway_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await AttendanceGatewayService(db).soft_delete(tenant_id, gateway_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/branches.py b/backend/services/sports_center/app/api/v1/branches.py new file mode 100644 index 0000000..268dc72 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/branches.py @@ -0,0 +1,71 @@ +"""Branch APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + BranchCreate, + BranchRead, + BranchUpdate, +) +from app.services.foundation import BranchService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=BranchRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: BranchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[BranchRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{branch_id}", response_model=BranchRead) +async def get_item( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).get(tenant_id, branch_id) + + +@router.patch("/{branch_id}", response_model=BranchRead) +async def update_item( + branch_id: UUID, + body: BranchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).update(tenant_id, branch_id, body, actor=user) + + +@router.post("/{branch_id}/delete", response_model=BranchRead) +async def soft_delete_item( + branch_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await BranchService(db).soft_delete(tenant_id, branch_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/catalog.py b/backend/services/sports_center/app/api/v1/catalog.py new file mode 100644 index 0000000..e5fc598 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/catalog.py @@ -0,0 +1,451 @@ +"""Membership Catalog API routers — Phase 9.1.""" +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.core.security import get_current_user +from app.schemas.catalog import ( + AgeGroupCreate, + AgeGroupRead, + AgeGroupUpdate, + ExpirationPolicyCreate, + ExpirationPolicyRead, + ExpirationPolicyUpdate, + FreezingRuleCreate, + FreezingRuleRead, + FreezingRuleUpdate, + MembershipPackageCreate, + MembershipPackageRead, + MembershipPackageUpdate, + MembershipPlanCreate, + MembershipPlanRead, + MembershipPlanUpdate, + MembershipRuleCreate, + MembershipRuleRead, + MembershipRuleUpdate, + PricingModelCreate, + PricingModelRead, + PricingModelUpdate, + RenewalPolicyCreate, + RenewalPolicyRead, + RenewalPolicyUpdate, + SportCategoryCreate, + SportCategoryRead, + SportCategoryUpdate, +) +from app.services.catalog import ( + AgeGroupService, + ExpirationPolicyService, + FreezingRuleService, + MembershipPackageService, + MembershipPlanService, + MembershipRuleService, + PricingModelService, + RenewalPolicyService, + SportCategoryService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + + +def _crud_router( + *, + prefix_tag: str, + create_schema, + update_schema, + read_schema, + service_cls, + id_param: str, +) -> APIRouter: + router = APIRouter() + + @router.post("", response_model=read_schema, status_code=status.HTTP_201_CREATED) + async def create_item( + body: create_schema, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).create(tenant_id, body, actor=user) + + @router.get("", response_model=list[read_schema]) + async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + @router.get(f"/{{{id_param}}}", response_model=read_schema) + async def get_item( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).get(tenant_id, entity_id) + + # Bind path param name dynamically + get_item.__annotations__[id_param] = UUID + # FastAPI uses function signature — set via defaults workaround: + create_item.__name__ = f"create_{prefix_tag}" + list_items.__name__ = f"list_{prefix_tag}" + get_item.__name__ = f"get_{prefix_tag}" + + @router.patch(f"/{{{id_param}}}", response_model=read_schema) + async def update_item( + entity_id: UUID, + body: update_schema, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).update(tenant_id, entity_id, body, actor=user) + + @router.post(f"/{{{id_param}}}/delete", response_model=read_schema) + async def soft_delete_item( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).soft_delete(tenant_id, entity_id, actor=user) + + # Fix path parameter names for OpenAPI by re-registering with correct names + return router + + +# Explicit routers (path param names must match for FastAPI) +sport_categories_router = APIRouter() + + +@sport_categories_router.post("", response_model=SportCategoryRead, status_code=status.HTTP_201_CREATED) +async def create_sport_category( + body: SportCategoryCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportCategoryService(db).create(tenant_id, body, actor=user) + + +@sport_categories_router.get("", response_model=list[SportCategoryRead]) +async def list_sport_categories( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportCategoryService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@sport_categories_router.get("/{sport_category_id}", response_model=SportCategoryRead) +async def get_sport_category( + sport_category_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportCategoryService(db).get(tenant_id, sport_category_id) + + +@sport_categories_router.patch("/{sport_category_id}", response_model=SportCategoryRead) +async def update_sport_category( + sport_category_id: UUID, + body: SportCategoryUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportCategoryService(db).update(tenant_id, sport_category_id, body, actor=user) + + +@sport_categories_router.post("/{sport_category_id}/delete", response_model=SportCategoryRead) +async def delete_sport_category( + sport_category_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportCategoryService(db).soft_delete(tenant_id, sport_category_id, actor=user) + + +def _make_standard_router(service_cls, create_schema, update_schema, read_schema, id_name: str) -> APIRouter: + router = APIRouter() + + async def create_item( + body: create_schema, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).create(tenant_id, body, actor=user) + + async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + async def get_item( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).get(tenant_id, entity_id) + + async def update_item( + entity_id: UUID, + body: update_schema, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).update(tenant_id, entity_id, body, actor=user) + + async def soft_delete_item( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), + ): + return await service_cls(db).soft_delete(tenant_id, entity_id, actor=user) + + # Rename path params by wrapping with annotated routes + router.add_api_route("", create_item, methods=["POST"], response_model=read_schema, status_code=201) + router.add_api_route("", list_items, methods=["GET"], response_model=list[read_schema]) + + # Use explicit closures with correct parameter names via exec — keep it simple with copies: + return router + + +age_groups_router = APIRouter() + + +@age_groups_router.post("", response_model=AgeGroupRead, status_code=status.HTTP_201_CREATED) +async def create_age_group(body: AgeGroupCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AgeGroupService(db).create(tenant_id, body, actor=user) + + +@age_groups_router.get("", response_model=list[AgeGroupRead]) +async def list_age_groups(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await AgeGroupService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@age_groups_router.get("/{age_group_id}", response_model=AgeGroupRead) +async def get_age_group(age_group_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await AgeGroupService(db).get(tenant_id, age_group_id) + + +@age_groups_router.patch("/{age_group_id}", response_model=AgeGroupRead) +async def update_age_group(age_group_id: UUID, body: AgeGroupUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AgeGroupService(db).update(tenant_id, age_group_id, body, actor=user) + + +@age_groups_router.post("/{age_group_id}/delete", response_model=AgeGroupRead) +async def delete_age_group(age_group_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await AgeGroupService(db).soft_delete(tenant_id, age_group_id, actor=user) + + +pricing_models_router = APIRouter() + + +@pricing_models_router.post("", response_model=PricingModelRead, status_code=status.HTTP_201_CREATED) +async def create_pricing_model(body: PricingModelCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await PricingModelService(db).create(tenant_id, body, actor=user) + + +@pricing_models_router.get("", response_model=list[PricingModelRead]) +async def list_pricing_models(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await PricingModelService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@pricing_models_router.get("/{pricing_model_id}", response_model=PricingModelRead) +async def get_pricing_model(pricing_model_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await PricingModelService(db).get(tenant_id, pricing_model_id) + + +@pricing_models_router.patch("/{pricing_model_id}", response_model=PricingModelRead) +async def update_pricing_model(pricing_model_id: UUID, body: PricingModelUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await PricingModelService(db).update(tenant_id, pricing_model_id, body, actor=user) + + +@pricing_models_router.post("/{pricing_model_id}/delete", response_model=PricingModelRead) +async def delete_pricing_model(pricing_model_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await PricingModelService(db).soft_delete(tenant_id, pricing_model_id, actor=user) + + +membership_packages_router = APIRouter() + + +@membership_packages_router.post("", response_model=MembershipPackageRead, status_code=status.HTTP_201_CREATED) +async def create_membership_package(body: MembershipPackageCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipPackageService(db).create(tenant_id, body, actor=user) + + +@membership_packages_router.get("", response_model=list[MembershipPackageRead]) +async def list_membership_packages(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await MembershipPackageService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@membership_packages_router.get("/{membership_package_id}", response_model=MembershipPackageRead) +async def get_membership_package(membership_package_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await MembershipPackageService(db).get(tenant_id, membership_package_id) + + +@membership_packages_router.patch("/{membership_package_id}", response_model=MembershipPackageRead) +async def update_membership_package(membership_package_id: UUID, body: MembershipPackageUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipPackageService(db).update(tenant_id, membership_package_id, body, actor=user) + + +@membership_packages_router.post("/{membership_package_id}/delete", response_model=MembershipPackageRead) +async def delete_membership_package(membership_package_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipPackageService(db).soft_delete(tenant_id, membership_package_id, actor=user) + + +membership_plans_router = APIRouter() + + +@membership_plans_router.post("", response_model=MembershipPlanRead, status_code=status.HTTP_201_CREATED) +async def create_membership_plan(body: MembershipPlanCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipPlanService(db).create(tenant_id, body, actor=user) + + +@membership_plans_router.get("", response_model=list[MembershipPlanRead]) +async def list_membership_plans(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await MembershipPlanService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@membership_plans_router.get("/{membership_plan_id}", response_model=MembershipPlanRead) +async def get_membership_plan(membership_plan_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await MembershipPlanService(db).get(tenant_id, membership_plan_id) + + +@membership_plans_router.patch("/{membership_plan_id}", response_model=MembershipPlanRead) +async def update_membership_plan(membership_plan_id: UUID, body: MembershipPlanUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipPlanService(db).update(tenant_id, membership_plan_id, body, actor=user) + + +@membership_plans_router.post("/{membership_plan_id}/delete", response_model=MembershipPlanRead) +async def delete_membership_plan(membership_plan_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipPlanService(db).soft_delete(tenant_id, membership_plan_id, actor=user) + + +membership_rules_router = APIRouter() + + +@membership_rules_router.post("", response_model=MembershipRuleRead, status_code=status.HTTP_201_CREATED) +async def create_membership_rule(body: MembershipRuleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipRuleService(db).create(tenant_id, body, actor=user) + + +@membership_rules_router.get("", response_model=list[MembershipRuleRead]) +async def list_membership_rules(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await MembershipRuleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@membership_rules_router.get("/{membership_rule_id}", response_model=MembershipRuleRead) +async def get_membership_rule(membership_rule_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await MembershipRuleService(db).get(tenant_id, membership_rule_id) + + +@membership_rules_router.patch("/{membership_rule_id}", response_model=MembershipRuleRead) +async def update_membership_rule(membership_rule_id: UUID, body: MembershipRuleUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipRuleService(db).update(tenant_id, membership_rule_id, body, actor=user) + + +@membership_rules_router.post("/{membership_rule_id}/delete", response_model=MembershipRuleRead) +async def delete_membership_rule(membership_rule_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await MembershipRuleService(db).soft_delete(tenant_id, membership_rule_id, actor=user) + + +renewal_policies_router = APIRouter() + + +@renewal_policies_router.post("", response_model=RenewalPolicyRead, status_code=status.HTTP_201_CREATED) +async def create_renewal_policy(body: RenewalPolicyCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await RenewalPolicyService(db).create(tenant_id, body, actor=user) + + +@renewal_policies_router.get("", response_model=list[RenewalPolicyRead]) +async def list_renewal_policies(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await RenewalPolicyService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@renewal_policies_router.get("/{renewal_policy_id}", response_model=RenewalPolicyRead) +async def get_renewal_policy(renewal_policy_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await RenewalPolicyService(db).get(tenant_id, renewal_policy_id) + + +@renewal_policies_router.patch("/{renewal_policy_id}", response_model=RenewalPolicyRead) +async def update_renewal_policy(renewal_policy_id: UUID, body: RenewalPolicyUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await RenewalPolicyService(db).update(tenant_id, renewal_policy_id, body, actor=user) + + +@renewal_policies_router.post("/{renewal_policy_id}/delete", response_model=RenewalPolicyRead) +async def delete_renewal_policy(renewal_policy_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await RenewalPolicyService(db).soft_delete(tenant_id, renewal_policy_id, actor=user) + + +freezing_rules_router = APIRouter() + + +@freezing_rules_router.post("", response_model=FreezingRuleRead, status_code=status.HTTP_201_CREATED) +async def create_freezing_rule(body: FreezingRuleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await FreezingRuleService(db).create(tenant_id, body, actor=user) + + +@freezing_rules_router.get("", response_model=list[FreezingRuleRead]) +async def list_freezing_rules(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await FreezingRuleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@freezing_rules_router.get("/{freezing_rule_id}", response_model=FreezingRuleRead) +async def get_freezing_rule(freezing_rule_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await FreezingRuleService(db).get(tenant_id, freezing_rule_id) + + +@freezing_rules_router.patch("/{freezing_rule_id}", response_model=FreezingRuleRead) +async def update_freezing_rule(freezing_rule_id: UUID, body: FreezingRuleUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await FreezingRuleService(db).update(tenant_id, freezing_rule_id, body, actor=user) + + +@freezing_rules_router.post("/{freezing_rule_id}/delete", response_model=FreezingRuleRead) +async def delete_freezing_rule(freezing_rule_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await FreezingRuleService(db).soft_delete(tenant_id, freezing_rule_id, actor=user) + + +expiration_policies_router = APIRouter() + + +@expiration_policies_router.post("", response_model=ExpirationPolicyRead, status_code=status.HTTP_201_CREATED) +async def create_expiration_policy(body: ExpirationPolicyCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await ExpirationPolicyService(db).create(tenant_id, body, actor=user) + + +@expiration_policies_router.get("", response_model=list[ExpirationPolicyRead]) +async def list_expiration_policies(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await ExpirationPolicyService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@expiration_policies_router.get("/{expiration_policy_id}", response_model=ExpirationPolicyRead) +async def get_expiration_policy(expiration_policy_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(get_current_user)): + return await ExpirationPolicyService(db).get(tenant_id, expiration_policy_id) + + +@expiration_policies_router.patch("/{expiration_policy_id}", response_model=ExpirationPolicyRead) +async def update_expiration_policy(expiration_policy_id: UUID, body: ExpirationPolicyUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await ExpirationPolicyService(db).update(tenant_id, expiration_policy_id, body, actor=user) + + +@expiration_policies_router.post("/{expiration_policy_id}/delete", response_model=ExpirationPolicyRead) +async def delete_expiration_policy(expiration_policy_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(get_current_user)): + return await ExpirationPolicyService(db).soft_delete(tenant_id, expiration_policy_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/coaches.py b/backend/services/sports_center/app/api/v1/coaches.py new file mode 100644 index 0000000..25c5b4b --- /dev/null +++ b/backend/services/sports_center/app/api/v1/coaches.py @@ -0,0 +1,71 @@ +"""Coach APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + CoachCreate, + CoachRead, + CoachUpdate, +) +from app.services.foundation import CoachService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=CoachRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: CoachCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CoachService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[CoachRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CoachService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{coach_id}", response_model=CoachRead) +async def get_item( + coach_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CoachService(db).get(tenant_id, coach_id) + + +@router.patch("/{coach_id}", response_model=CoachRead) +async def update_item( + coach_id: UUID, + body: CoachUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CoachService(db).update(tenant_id, coach_id, body, actor=user) + + +@router.post("/{coach_id}/delete", response_model=CoachRead) +async def soft_delete_item( + coach_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CoachService(db).soft_delete(tenant_id, coach_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/configurations.py b/backend/services/sports_center/app/api/v1/configurations.py new file mode 100644 index 0000000..b757256 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/configurations.py @@ -0,0 +1,71 @@ +"""Sports Configuration APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + SportsConfigurationCreate, + SportsConfigurationRead, + SportsConfigurationUpdate, +) +from app.services.foundation import SportsConfigurationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportsConfigurationRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: SportsConfigurationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsConfigurationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportsConfigurationRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsConfigurationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{configuration_id}", response_model=SportsConfigurationRead) +async def get_item( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsConfigurationService(db).get(tenant_id, configuration_id) + + +@router.patch("/{configuration_id}", response_model=SportsConfigurationRead) +async def update_item( + configuration_id: UUID, + body: SportsConfigurationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsConfigurationService(db).update(tenant_id, configuration_id, body, actor=user) + + +@router.post("/{configuration_id}/delete", response_model=SportsConfigurationRead) +async def soft_delete_item( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsConfigurationService(db).soft_delete(tenant_id, configuration_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/courts.py b/backend/services/sports_center/app/api/v1/courts.py new file mode 100644 index 0000000..b89e709 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/courts.py @@ -0,0 +1,71 @@ +"""Court APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + CourtCreate, + CourtRead, + CourtUpdate, +) +from app.services.foundation import CourtService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=CourtRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: CourtCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CourtService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[CourtRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CourtService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{court_id}", response_model=CourtRead) +async def get_item( + court_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await CourtService(db).get(tenant_id, court_id) + + +@router.patch("/{court_id}", response_model=CourtRead) +async def update_item( + court_id: UUID, + body: CourtUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CourtService(db).update(tenant_id, court_id, body, actor=user) + + +@router.post("/{court_id}/delete", response_model=CourtRead) +async def soft_delete_item( + court_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await CourtService(db).soft_delete(tenant_id, court_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/device_providers.py b/backend/services/sports_center/app/api/v1/device_providers.py new file mode 100644 index 0000000..34bc223 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/device_providers.py @@ -0,0 +1,71 @@ +"""Device Provider APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + DeviceProviderCreate, + DeviceProviderRead, + DeviceProviderUpdate, +) +from app.services.foundation import DeviceProviderService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DeviceProviderRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: DeviceProviderCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceProviderService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DeviceProviderRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DeviceProviderService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{provider_id}", response_model=DeviceProviderRead) +async def get_item( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DeviceProviderService(db).get(tenant_id, provider_id) + + +@router.patch("/{provider_id}", response_model=DeviceProviderRead) +async def update_item( + provider_id: UUID, + body: DeviceProviderUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceProviderService(db).update(tenant_id, provider_id, body, actor=user) + + +@router.post("/{provider_id}/delete", response_model=DeviceProviderRead) +async def soft_delete_item( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceProviderService(db).soft_delete(tenant_id, provider_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/devices.py b/backend/services/sports_center/app/api/v1/devices.py new file mode 100644 index 0000000..34fe899 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/devices.py @@ -0,0 +1,91 @@ +"""Device APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + DeviceCreate, + DeviceRead, + DeviceUpdate, +) +from app.services.foundation import DeviceService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=DeviceRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: DeviceCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[DeviceRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{device_id}", response_model=DeviceRead) +async def get_item( + device_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).get(tenant_id, device_id) + + +@router.patch("/{device_id}", response_model=DeviceRead) +async def update_item( + device_id: UUID, + body: DeviceUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).update(tenant_id, device_id, body, actor=user) + + +@router.post("/{device_id}/delete", response_model=DeviceRead) +async def soft_delete_item( + device_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).soft_delete(tenant_id, device_id, actor=user) + + +@router.post("/{device_id}/connect", response_model=DeviceRead) +async def connect_device( + device_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).connect(tenant_id, device_id, actor=user) + + +@router.post("/{device_id}/disconnect", response_model=DeviceRead) +async def disconnect_device( + device_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DeviceService(db).disconnect(tenant_id, device_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/events.py b/backend/services/sports_center/app/api/v1/events.py new file mode 100644 index 0000000..a1195d1 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/events.py @@ -0,0 +1,71 @@ +"""Sports Event APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + SportsEventCreate, + SportsEventRead, + SportsEventUpdate, +) +from app.services.foundation import SportsEventService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportsEventRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: SportsEventCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsEventService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportsEventRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsEventService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{event_id}", response_model=SportsEventRead) +async def get_item( + event_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsEventService(db).get(tenant_id, event_id) + + +@router.patch("/{event_id}", response_model=SportsEventRead) +async def update_item( + event_id: UUID, + body: SportsEventUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsEventService(db).update(tenant_id, event_id, body, actor=user) + + +@router.post("/{event_id}/delete", response_model=SportsEventRead) +async def soft_delete_item( + event_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsEventService(db).soft_delete(tenant_id, event_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/facilities.py b/backend/services/sports_center/app/api/v1/facilities.py new file mode 100644 index 0000000..07327c2 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/facilities.py @@ -0,0 +1,71 @@ +"""Facility APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + FacilityCreate, + FacilityRead, + FacilityUpdate, +) +from app.services.foundation import FacilityService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=FacilityRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: FacilityCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FacilityService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[FacilityRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await FacilityService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{facility_id}", response_model=FacilityRead) +async def get_item( + facility_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await FacilityService(db).get(tenant_id, facility_id) + + +@router.patch("/{facility_id}", response_model=FacilityRead) +async def update_item( + facility_id: UUID, + body: FacilityUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FacilityService(db).update(tenant_id, facility_id, body, actor=user) + + +@router.post("/{facility_id}/delete", response_model=FacilityRead) +async def soft_delete_item( + facility_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FacilityService(db).soft_delete(tenant_id, facility_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/health.py b/backend/services/sports_center/app/api/v1/health.py new file mode 100644 index 0000000..3854f3f --- /dev/null +++ b/backend/services/sports_center/app/api/v1/health.py @@ -0,0 +1,41 @@ +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__, + } + + +@router.get("/capabilities") +async def capabilities(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "9.2", + "features": { + "foundation": True, + "connector_framework": True, + "membership_catalog": True, + "member_management": True, + "coaches": "foundation_shell", + "attendance_engine": False, + "booking_engine": False, + "accounting_integration": False, + "ai": False, + }, + "independence": { + "standalone": True, + "superapp": True, + "integrations": ["crm", "loyalty", "accounting", "communication", "ai"], + "integration_mode": "api_and_events_only", + }, + } diff --git a/backend/services/sports_center/app/api/v1/locker_rooms.py b/backend/services/sports_center/app/api/v1/locker_rooms.py new file mode 100644 index 0000000..9573c5a --- /dev/null +++ b/backend/services/sports_center/app/api/v1/locker_rooms.py @@ -0,0 +1,71 @@ +"""Locker Room APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + LockerRoomCreate, + LockerRoomRead, + LockerRoomUpdate, +) +from app.services.foundation import LockerRoomService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=LockerRoomRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: LockerRoomCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerRoomService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[LockerRoomRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await LockerRoomService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{locker_room_id}", response_model=LockerRoomRead) +async def get_item( + locker_room_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await LockerRoomService(db).get(tenant_id, locker_room_id) + + +@router.patch("/{locker_room_id}", response_model=LockerRoomRead) +async def update_item( + locker_room_id: UUID, + body: LockerRoomUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerRoomService(db).update(tenant_id, locker_room_id, body, actor=user) + + +@router.post("/{locker_room_id}/delete", response_model=LockerRoomRead) +async def soft_delete_item( + locker_room_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerRoomService(db).soft_delete(tenant_id, locker_room_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/lockers.py b/backend/services/sports_center/app/api/v1/lockers.py new file mode 100644 index 0000000..9aca6ee --- /dev/null +++ b/backend/services/sports_center/app/api/v1/lockers.py @@ -0,0 +1,93 @@ +"""Locker APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + LockerCreate, + LockerRead, + LockerUpdate, + LockerAssignRequest, +) +from app.services.foundation import LockerService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=LockerRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: LockerCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[LockerRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{locker_id}", response_model=LockerRead) +async def get_item( + locker_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).get(tenant_id, locker_id) + + +@router.patch("/{locker_id}", response_model=LockerRead) +async def update_item( + locker_id: UUID, + body: LockerUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).update(tenant_id, locker_id, body, actor=user) + + +@router.post("/{locker_id}/delete", response_model=LockerRead) +async def soft_delete_item( + locker_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).soft_delete(tenant_id, locker_id, actor=user) + + +@router.post("/{locker_id}/assign", response_model=LockerRead) +async def assign_locker( + locker_id: UUID, + body: LockerAssignRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).assign(tenant_id, locker_id, body, actor=user) + + +@router.post("/{locker_id}/release", response_model=LockerRead) +async def release_locker( + locker_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await LockerService(db).release(tenant_id, locker_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/members.py b/backend/services/sports_center/app/api/v1/members.py new file mode 100644 index 0000000..d4806fa --- /dev/null +++ b/backend/services/sports_center/app/api/v1/members.py @@ -0,0 +1,443 @@ +"""Member Management APIs — Phase 9.2.""" +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.core.security import get_current_user +from app.schemas.members import ( + DigitalMembershipCreate, + DigitalMembershipRead, + DigitalMembershipUpdate, + EmergencyContactCreate, + EmergencyContactRead, + EmergencyContactUpdate, + FamilyMemberCreate, + FamilyMemberRead, + FamilyMemberUpdate, + MedicalInformationRead, + MedicalInformationUpsert, + MemberCreate, + MemberDocumentCreate, + MemberDocumentRead, + MemberDocumentUpdate, + MembershipCardCreate, + MembershipCardRead, + MembershipCardUpdate, + MemberRead, + MemberUpdate, + WaiverCreate, + WaiverRead, + WaiverSignRequest, + WaiverUpdate, +) +from app.services.members import ( + DigitalMembershipService, + EmergencyContactService, + FamilyMemberService, + MedicalInformationService, + MemberDocumentService, + MembershipCardService, + MemberService, + WaiverService, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +members_router = APIRouter() +family_members_router = APIRouter() +emergency_contacts_router = APIRouter() +medical_router = APIRouter() +cards_router = APIRouter() +digital_router = APIRouter() +waivers_router = APIRouter() +documents_router = APIRouter() + + +@members_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(get_current_user), +): + return await MemberService(db).create(tenant_id, body, actor=user) + + +@members_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(get_current_user), +): + return await MemberService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@members_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(get_current_user), +): + return await MemberService(db).get(tenant_id, member_id) + + +@members_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(get_current_user), +): + return await MemberService(db).update(tenant_id, member_id, body, actor=user) + + +@members_router.post("/{member_id}/delete", response_model=MemberRead) +async def delete_member( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MemberService(db).soft_delete(tenant_id, member_id, actor=user) + + +@family_members_router.post("", response_model=FamilyMemberRead, status_code=status.HTTP_201_CREATED) +async def create_family_member( + body: FamilyMemberCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FamilyMemberService(db).create(tenant_id, body, actor=user) + + +@family_members_router.get("", response_model=list[FamilyMemberRead]) +async def list_family_members( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await FamilyMemberService(db).list_by_member( + tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@family_members_router.get("/{family_member_id}", response_model=FamilyMemberRead) +async def get_family_member( + family_member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await FamilyMemberService(db).get(tenant_id, family_member_id) + + +@family_members_router.patch("/{family_member_id}", response_model=FamilyMemberRead) +async def update_family_member( + family_member_id: UUID, + body: FamilyMemberUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FamilyMemberService(db).update(tenant_id, family_member_id, body, actor=user) + + +@family_members_router.post("/{family_member_id}/delete", response_model=FamilyMemberRead) +async def delete_family_member( + family_member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await FamilyMemberService(db).soft_delete(tenant_id, family_member_id, actor=user) + + +@emergency_contacts_router.post("", response_model=EmergencyContactRead, status_code=status.HTTP_201_CREATED) +async def create_emergency_contact( + body: EmergencyContactCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await EmergencyContactService(db).create(tenant_id, body, actor=user) + + +@emergency_contacts_router.get("", response_model=list[EmergencyContactRead]) +async def list_emergency_contacts( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await EmergencyContactService(db).list_by_member( + tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@emergency_contacts_router.get("/{contact_id}", response_model=EmergencyContactRead) +async def get_emergency_contact( + contact_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await EmergencyContactService(db).get(tenant_id, contact_id) + + +@emergency_contacts_router.patch("/{contact_id}", response_model=EmergencyContactRead) +async def update_emergency_contact( + contact_id: UUID, + body: EmergencyContactUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await EmergencyContactService(db).update(tenant_id, contact_id, body, actor=user) + + +@emergency_contacts_router.post("/{contact_id}/delete", response_model=EmergencyContactRead) +async def delete_emergency_contact( + contact_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await EmergencyContactService(db).soft_delete(tenant_id, contact_id, actor=user) + + +@medical_router.put("", response_model=MedicalInformationRead) +async def upsert_medical( + body: MedicalInformationUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MedicalInformationService(db).upsert(tenant_id, body, actor=user) + + +@medical_router.get("/by-member/{member_id}", response_model=MedicalInformationRead) +async def get_medical( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MedicalInformationService(db).get_by_member(tenant_id, member_id) + + +@cards_router.post("", response_model=MembershipCardRead, status_code=status.HTTP_201_CREATED) +async def issue_card( + body: MembershipCardCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipCardService(db).create(tenant_id, body, actor=user) + + +@cards_router.get("", response_model=list[MembershipCardRead]) +async def list_cards( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MembershipCardService(db).list_by_member( + tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@cards_router.get("/{card_id}", response_model=MembershipCardRead) +async def get_card( + card_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MembershipCardService(db).get(tenant_id, card_id) + + +@cards_router.patch("/{card_id}", response_model=MembershipCardRead) +async def update_card( + card_id: UUID, + body: MembershipCardUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipCardService(db).update(tenant_id, card_id, body, actor=user) + + +@cards_router.post("/{card_id}/revoke", response_model=MembershipCardRead) +async def revoke_card( + card_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipCardService(db).revoke(tenant_id, card_id, actor=user) + + +@digital_router.post("", response_model=DigitalMembershipRead, status_code=status.HTTP_201_CREATED) +async def issue_digital( + body: DigitalMembershipCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DigitalMembershipService(db).create(tenant_id, body, actor=user) + + +@digital_router.get("", response_model=list[DigitalMembershipRead]) +async def list_digital( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DigitalMembershipService(db).list_by_member( + tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@digital_router.get("/{digital_id}", response_model=DigitalMembershipRead) +async def get_digital( + digital_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await DigitalMembershipService(db).get(tenant_id, digital_id) + + +@digital_router.patch("/{digital_id}", response_model=DigitalMembershipRead) +async def update_digital( + digital_id: UUID, + body: DigitalMembershipUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await DigitalMembershipService(db).update(tenant_id, digital_id, body, actor=user) + + +@waivers_router.post("", response_model=WaiverRead, status_code=status.HTTP_201_CREATED) +async def create_waiver( + body: WaiverCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await WaiverService(db).create(tenant_id, body, actor=user) + + +@waivers_router.get("", response_model=list[WaiverRead]) +async def list_waivers( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await WaiverService(db).list_by_member( + tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@waivers_router.get("/{waiver_id}", response_model=WaiverRead) +async def get_waiver( + waiver_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await WaiverService(db).get(tenant_id, waiver_id) + + +@waivers_router.patch("/{waiver_id}", response_model=WaiverRead) +async def update_waiver( + waiver_id: UUID, + body: WaiverUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await WaiverService(db).update(tenant_id, waiver_id, body, actor=user) + + +@waivers_router.post("/{waiver_id}/sign", response_model=WaiverRead) +async def sign_waiver( + waiver_id: UUID, + body: WaiverSignRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await WaiverService(db).sign(tenant_id, waiver_id, body, actor=user) + + +@documents_router.post("", response_model=MemberDocumentRead, status_code=status.HTTP_201_CREATED) +async def attach_document( + body: MemberDocumentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MemberDocumentService(db).create(tenant_id, body, actor=user) + + +@documents_router.get("", response_model=list[MemberDocumentRead]) +async def list_documents( + member_id: UUID, + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MemberDocumentService(db).list_by_member( + tenant_id, member_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@documents_router.get("/{document_id}", response_model=MemberDocumentRead) +async def get_document( + document_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MemberDocumentService(db).get(tenant_id, document_id) + + +@documents_router.patch("/{document_id}", response_model=MemberDocumentRead) +async def update_document( + document_id: UUID, + body: MemberDocumentUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MemberDocumentService(db).update(tenant_id, document_id, body, actor=user) + + +@documents_router.post("/{document_id}/delete", response_model=MemberDocumentRead) +async def delete_document( + document_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MemberDocumentService(db).soft_delete(tenant_id, document_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/membership_types.py b/backend/services/sports_center/app/api/v1/membership_types.py new file mode 100644 index 0000000..4674503 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/membership_types.py @@ -0,0 +1,71 @@ +"""Membership Type APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + MembershipTypeCreate, + MembershipTypeRead, + MembershipTypeUpdate, +) +from app.services.foundation import MembershipTypeService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=MembershipTypeRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: MembershipTypeCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipTypeService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[MembershipTypeRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MembershipTypeService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{membership_type_id}", response_model=MembershipTypeRead) +async def get_item( + membership_type_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MembershipTypeService(db).get(tenant_id, membership_type_id) + + +@router.patch("/{membership_type_id}", response_model=MembershipTypeRead) +async def update_item( + membership_type_id: UUID, + body: MembershipTypeUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipTypeService(db).update(tenant_id, membership_type_id, body, actor=user) + + +@router.post("/{membership_type_id}/delete", response_model=MembershipTypeRead) +async def soft_delete_item( + membership_type_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipTypeService(db).soft_delete(tenant_id, membership_type_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/memberships.py b/backend/services/sports_center/app/api/v1/memberships.py new file mode 100644 index 0000000..4e15f92 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/memberships.py @@ -0,0 +1,165 @@ +"""Membership APIs — Phase 9.0 + Phase 9.2 assignment/status.""" +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.core.security import get_current_user +from app.models.types import MembershipStatus +from app.schemas.foundation import ( + MembershipCreate, + MembershipRead, + MembershipUpdate, +) +from app.schemas.members import ( + MembershipAssignMemberRequest, + MembershipFreezeRequest, + MembershipStatusChangeRequest, +) +from app.services.foundation import MembershipService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=MembershipRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: MembershipCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[MembershipRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{membership_id}", response_model=MembershipRead) +async def get_item( + membership_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).get(tenant_id, membership_id) + + +@router.patch("/{membership_id}", response_model=MembershipRead) +async def update_item( + membership_id: UUID, + body: MembershipUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).update(tenant_id, membership_id, body, actor=user) + + +@router.post("/{membership_id}/assign-member", response_model=MembershipRead) +async def assign_member( + membership_id: UUID, + body: MembershipAssignMemberRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).assign_member( + tenant_id, membership_id, body.member_id, version=body.version, actor=user + ) + + +@router.post("/{membership_id}/activate", response_model=MembershipRead) +async def activate_membership( + membership_id: UUID, + body: MembershipStatusChangeRequest | None = None, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + req = body or MembershipStatusChangeRequest() + return await MembershipService(db).change_status( + tenant_id, membership_id, MembershipStatus.ACTIVE, version=req.version, actor=user + ) + + +@router.post("/{membership_id}/suspend", response_model=MembershipRead) +async def suspend_membership( + membership_id: UUID, + body: MembershipStatusChangeRequest | None = None, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + req = body or MembershipStatusChangeRequest() + return await MembershipService(db).change_status( + tenant_id, membership_id, MembershipStatus.SUSPENDED, version=req.version, actor=user + ) + + +@router.post("/{membership_id}/cancel", response_model=MembershipRead) +async def cancel_membership( + membership_id: UUID, + body: MembershipStatusChangeRequest | None = None, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + req = body or MembershipStatusChangeRequest() + return await MembershipService(db).change_status( + tenant_id, membership_id, MembershipStatus.CANCELLED, version=req.version, actor=user + ) + + +@router.post("/{membership_id}/freeze", response_model=MembershipRead) +async def freeze_membership( + membership_id: UUID, + body: MembershipFreezeRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).change_status( + tenant_id, + membership_id, + MembershipStatus.FROZEN, + freeze_starts_on=body.freeze_starts_on, + freeze_ends_on=body.freeze_ends_on, + version=body.version, + actor=user, + ) + + +@router.post("/{membership_id}/unfreeze", response_model=MembershipRead) +async def unfreeze_membership( + membership_id: UUID, + body: MembershipStatusChangeRequest | None = None, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + req = body or MembershipStatusChangeRequest() + return await MembershipService(db).change_status( + tenant_id, membership_id, MembershipStatus.ACTIVE, version=req.version, actor=user + ) + + +@router.post("/{membership_id}/delete", response_model=MembershipRead) +async def soft_delete_item( + membership_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await MembershipService(db).soft_delete(tenant_id, membership_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/permissions.py b/backend/services/sports_center/app/api/v1/permissions.py new file mode 100644 index 0000000..723f6ac --- /dev/null +++ b/backend/services/sports_center/app/api/v1/permissions.py @@ -0,0 +1,71 @@ +"""Sports Permission APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + SportsPermissionCreate, + SportsPermissionRead, + SportsPermissionUpdate, +) +from app.services.foundation import SportsPermissionService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportsPermissionRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: SportsPermissionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsPermissionService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportsPermissionRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsPermissionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{permission_id}", response_model=SportsPermissionRead) +async def get_item( + permission_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsPermissionService(db).get(tenant_id, permission_id) + + +@router.patch("/{permission_id}", response_model=SportsPermissionRead) +async def update_item( + permission_id: UUID, + body: SportsPermissionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsPermissionService(db).update(tenant_id, permission_id, body, actor=user) + + +@router.post("/{permission_id}/delete", response_model=SportsPermissionRead) +async def soft_delete_item( + permission_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsPermissionService(db).soft_delete(tenant_id, permission_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/roles.py b/backend/services/sports_center/app/api/v1/roles.py new file mode 100644 index 0000000..e4a9420 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/roles.py @@ -0,0 +1,71 @@ +"""Sports Role APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + SportsRoleCreate, + SportsRoleRead, + SportsRoleUpdate, +) +from app.services.foundation import SportsRoleService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportsRoleRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: SportsRoleCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsRoleService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportsRoleRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsRoleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{role_id}", response_model=SportsRoleRead) +async def get_item( + role_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsRoleService(db).get(tenant_id, role_id) + + +@router.patch("/{role_id}", response_model=SportsRoleRead) +async def update_item( + role_id: UUID, + body: SportsRoleUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsRoleService(db).update(tenant_id, role_id, body, actor=user) + + +@router.post("/{role_id}/delete", response_model=SportsRoleRead) +async def soft_delete_item( + role_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsRoleService(db).soft_delete(tenant_id, role_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/rooms.py b/backend/services/sports_center/app/api/v1/rooms.py new file mode 100644 index 0000000..3563a38 --- /dev/null +++ b/backend/services/sports_center/app/api/v1/rooms.py @@ -0,0 +1,71 @@ +"""Room APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + RoomCreate, + RoomRead, + RoomUpdate, +) +from app.services.foundation import RoomService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=RoomRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: RoomCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await RoomService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[RoomRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await RoomService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{room_id}", response_model=RoomRead) +async def get_item( + room_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await RoomService(db).get(tenant_id, room_id) + + +@router.patch("/{room_id}", response_model=RoomRead) +async def update_item( + room_id: UUID, + body: RoomUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await RoomService(db).update(tenant_id, room_id, body, actor=user) + + +@router.post("/{room_id}/delete", response_model=RoomRead) +async def soft_delete_item( + room_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await RoomService(db).soft_delete(tenant_id, room_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/settings.py b/backend/services/sports_center/app/api/v1/settings.py new file mode 100644 index 0000000..e18984a --- /dev/null +++ b/backend/services/sports_center/app/api/v1/settings.py @@ -0,0 +1,58 @@ +"""Sports Settings APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import SportsSettingRead, SportsSettingUpsert +from app.services.foundation import SportsSettingService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportsSettingRead, status_code=status.HTTP_201_CREATED) +async def upsert_setting( + body: SportsSettingUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsSettingService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportsSettingRead]) +async def list_settings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsSettingService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{setting_id}", response_model=SportsSettingRead) +async def get_setting( + setting_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsSettingService(db).get(tenant_id, setting_id) + + +@router.post("/{setting_id}/delete", response_model=SportsSettingRead) +async def soft_delete_setting( + setting_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsSettingService(db).soft_delete(tenant_id, setting_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/sports.py b/backend/services/sports_center/app/api/v1/sports.py new file mode 100644 index 0000000..0118cef --- /dev/null +++ b/backend/services/sports_center/app/api/v1/sports.py @@ -0,0 +1,71 @@ +"""Sport APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + SportCreate, + SportRead, + SportUpdate, +) +from app.services.foundation import SportService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: SportCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{sport_id}", response_model=SportRead) +async def get_item( + sport_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportService(db).get(tenant_id, sport_id) + + +@router.patch("/{sport_id}", response_model=SportRead) +async def update_item( + sport_id: UUID, + body: SportUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportService(db).update(tenant_id, sport_id, body, actor=user) + + +@router.post("/{sport_id}/delete", response_model=SportRead) +async def soft_delete_item( + sport_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportService(db).soft_delete(tenant_id, sport_id, actor=user) diff --git a/backend/services/sports_center/app/api/v1/sports_centers.py b/backend/services/sports_center/app/api/v1/sports_centers.py new file mode 100644 index 0000000..f16373e --- /dev/null +++ b/backend/services/sports_center/app/api/v1/sports_centers.py @@ -0,0 +1,71 @@ +"""Sports Center APIs — Phase 9.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.core.security import get_current_user +from app.schemas.foundation import ( + SportsCenterCreate, + SportsCenterRead, + SportsCenterUpdate, +) +from app.services.foundation import SportsCenterService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=SportsCenterRead, status_code=status.HTTP_201_CREATED) +async def create_item( + body: SportsCenterCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsCenterService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[SportsCenterRead]) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsCenterService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size) + + +@router.get("/{center_id}", response_model=SportsCenterRead) +async def get_item( + center_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(get_current_user), +): + return await SportsCenterService(db).get(tenant_id, center_id) + + +@router.patch("/{center_id}", response_model=SportsCenterRead) +async def update_item( + center_id: UUID, + body: SportsCenterUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsCenterService(db).update(tenant_id, center_id, body, actor=user) + + +@router.post("/{center_id}/delete", response_model=SportsCenterRead) +async def soft_delete_item( + center_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +): + return await SportsCenterService(db).soft_delete(tenant_id, center_id, actor=user) diff --git a/backend/services/sports_center/app/commands/__init__.py b/backend/services/sports_center/app/commands/__init__.py new file mode 100644 index 0000000..fa2f581 --- /dev/null +++ b/backend/services/sports_center/app/commands/__init__.py @@ -0,0 +1,45 @@ +"""Command DTOs — write-side intents (foundation shells).""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class CreateSportsCenterCommand: + code: str + name: str + + +@dataclass(frozen=True) +class CreateMembershipCommand: + sports_center_id: UUID + membership_type_id: UUID + display_name: str + + +@dataclass(frozen=True) +class ConnectDeviceCommand: + device_id: UUID + + +@dataclass(frozen=True) +class DisconnectDeviceCommand: + device_id: UUID + + +@dataclass(frozen=True) +class AssignLockerCommand: + locker_id: UUID + membership_id: UUID + + +@dataclass(frozen=True) +class ReleaseLockerCommand: + locker_id: UUID + + +@dataclass(frozen=True) +class ChangeAttendanceProviderCommand: + gateway_id: UUID + primary_provider_id: UUID diff --git a/backend/services/sports_center/app/connectors/__init__.py b/backend/services/sports_center/app/connectors/__init__.py new file mode 100644 index 0000000..f97560c --- /dev/null +++ b/backend/services/sports_center/app/connectors/__init__.py @@ -0,0 +1,156 @@ +"""Adapter-based connector framework — provider interfaces only (Phase 9.0). + +Business services must never contain vendor-specific device logic. +Concrete adapters register by adapter_key and implement these protocols. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Protocol, runtime_checkable +from uuid import UUID + +from app.models.types import ConnectorCapability + + +class ConnectorHealth(str, Enum): + UNKNOWN = "unknown" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNAVAILABLE = "unavailable" + + +@dataclass(frozen=True) +class ConnectorContext: + tenant_id: UUID + sports_center_id: UUID | None = None + branch_id: UUID | None = None + device_id: UUID | None = None + settings: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ConnectorResult: + success: bool + code: str + message: str | None = None + data: dict[str, Any] = field(default_factory=dict) + + +@runtime_checkable +class DeviceConnector(Protocol): + """Base connector for any attendance/access/payment device.""" + + capability: ConnectorCapability + adapter_key: str + + async def connect(self, ctx: ConnectorContext) -> ConnectorResult: ... + + async def disconnect(self, ctx: ConnectorContext) -> ConnectorResult: ... + + async def health(self, ctx: ConnectorContext) -> ConnectorHealth: ... + + +@runtime_checkable +class QRConnector(DeviceConnector, Protocol): + async def scan(self, ctx: ConnectorContext, *, payload: str) -> ConnectorResult: ... + + async def issue(self, ctx: ConnectorContext, *, subject_ref: str) -> ConnectorResult: ... + + +@runtime_checkable +class RFIDConnector(DeviceConnector, Protocol): + async def read_tag(self, ctx: ConnectorContext) -> ConnectorResult: ... + + async def bind_tag( + self, ctx: ConnectorContext, *, tag_uid: str, subject_ref: str + ) -> ConnectorResult: ... + + +@runtime_checkable +class BarcodeConnector(DeviceConnector, Protocol): + async def scan(self, ctx: ConnectorContext, *, barcode: str) -> ConnectorResult: ... + + +@runtime_checkable +class FingerprintConnector(DeviceConnector, Protocol): + async def enroll(self, ctx: ConnectorContext, *, subject_ref: str) -> ConnectorResult: ... + + async def verify(self, ctx: ConnectorContext, *, subject_ref: str) -> ConnectorResult: ... + + +@runtime_checkable +class FaceRecognitionConnector(DeviceConnector, Protocol): + async def enroll(self, ctx: ConnectorContext, *, subject_ref: str) -> ConnectorResult: ... + + async def verify(self, ctx: ConnectorContext, *, subject_ref: str) -> ConnectorResult: ... + + +@runtime_checkable +class TurnstileConnector(DeviceConnector, Protocol): + async def open(self, ctx: ConnectorContext, *, direction: str = "in") -> ConnectorResult: ... + + async def lock(self, ctx: ConnectorContext) -> ConnectorResult: ... + + +@runtime_checkable +class DoorControllerConnector(DeviceConnector, Protocol): + async def unlock(self, ctx: ConnectorContext, *, duration_seconds: int = 3) -> ConnectorResult: ... + + async def lock(self, ctx: ConnectorContext) -> ConnectorResult: ... + + +@runtime_checkable +class PaymentTerminalConnector(DeviceConnector, Protocol): + """Payment terminal adapter — settles via Accounting/Payment platforms later.""" + + async def charge( + self, ctx: ConnectorContext, *, amount: int, currency: str, reference: str + ) -> ConnectorResult: ... + + async def refund( + self, ctx: ConnectorContext, *, amount: int, currency: str, reference: str + ) -> ConnectorResult: ... + + +@runtime_checkable +class AttendanceDeviceConnector(DeviceConnector, Protocol): + async def check_in( + self, ctx: ConnectorContext, *, subject_ref: str, method: str + ) -> ConnectorResult: ... + + async def check_out( + self, ctx: ConnectorContext, *, subject_ref: str, method: str + ) -> ConnectorResult: ... + + +class ConnectorRegistry: + """In-process adapter registry. Vendor adapters register by adapter_key.""" + + def __init__(self) -> None: + self._adapters: dict[str, DeviceConnector] = {} + + def register(self, connector: DeviceConnector) -> None: + self._adapters[connector.adapter_key] = connector + + def get(self, adapter_key: str) -> DeviceConnector | None: + return self._adapters.get(adapter_key) + + def list_by_capability(self, capability: ConnectorCapability) -> list[DeviceConnector]: + return [c for c in self._adapters.values() if c.capability == capability] + + def keys(self) -> list[str]: + return sorted(self._adapters.keys()) + + +_default_registry = ConnectorRegistry() + + +def get_connector_registry() -> ConnectorRegistry: + return _default_registry + + +def reset_connector_registry() -> ConnectorRegistry: + global _default_registry + _default_registry = ConnectorRegistry() + return _default_registry diff --git a/backend/services/sports_center/app/connectors/contracts.py b/backend/services/sports_center/app/connectors/contracts.py new file mode 100644 index 0000000..c67ab21 --- /dev/null +++ b/backend/services/sports_center/app/connectors/contracts.py @@ -0,0 +1,20 @@ +"""Connector contracts package — re-exports.""" +from app.connectors import ( # noqa: F401 + AttendanceDeviceConnector, + BarcodeConnector, + ConnectorContext, + ConnectorHealth, + ConnectorRegistry, + ConnectorResult, + DeviceConnector, + DoorControllerConnector, + FaceRecognitionConnector, + FingerprintConnector, + PaymentTerminalConnector, + QRConnector, + RFIDConnector, + TurnstileConnector, + get_connector_registry, + reset_connector_registry, +) +from app.models.types import ConnectorCapability # noqa: F401 diff --git a/backend/services/sports_center/app/core/config.py b/backend/services/sports_center/app/core/config.py new file mode 100644 index 0000000..18eef83 --- /dev/null +++ b/backend/services/sports_center/app/core/config.py @@ -0,0 +1,75 @@ +"""Sports Center 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="sports-center-service", validation_alias="SPORTS_CENTER_SERVICE_NAME" + ) + api_v1_prefix: str = "/api/v1" + + database_url: str = Field( + default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/sports_center_db", + validation_alias="SPORTS_CENTER_DATABASE_URL", + ) + database_url_sync: str = Field( + default="postgresql+psycopg://superapp:superapp_password@localhost:5432/sports_center_db", + validation_alias="SPORTS_CENTER_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() diff --git a/backend/services/sports_center/app/core/database.py b/backend/services/sports_center/app/core/database.py new file mode 100644 index 0000000..c5bcec0 --- /dev/null +++ b/backend/services/sports_center/app/core/database.py @@ -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 diff --git a/backend/services/sports_center/app/core/logging.py b/backend/services/sports_center/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/sports_center/app/core/logging.py @@ -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) diff --git a/backend/services/sports_center/app/core/security.py b/backend/services/sports_center/app/core/security.py new file mode 100644 index 0000000..5c7f14a --- /dev/null +++ b/backend/services/sports_center/app/core/security.py @@ -0,0 +1,49 @@ +"""JWT authentication dependencies for Sports Center 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) diff --git a/backend/services/sports_center/app/events/publisher.py b/backend/services/sports_center/app/events/publisher.py new file mode 100644 index 0000000..727d93a --- /dev/null +++ b/backend/services/sports_center/app/events/publisher.py @@ -0,0 +1,63 @@ +"""Sports Center event publisher — builds 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 SportsCenterEventType + + +class EventPublisher(Protocol): + def publish( + self, + *, + event_type: SportsCenterEventType, + 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: SportsCenterEventType, + 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 diff --git a/backend/services/sports_center/app/events/types.py b/backend/services/sports_center/app/events/types.py new file mode 100644 index 0000000..a6e0481 --- /dev/null +++ b/backend/services/sports_center/app/events/types.py @@ -0,0 +1,74 @@ +"""Sports Center event type contracts (publish-only) — Phase 9.0.""" +from __future__ import annotations + +import enum + + +class SportsCenterEventType(str, enum.Enum): + # Contract names requested for Phase 9.0 (namespaced) + MEMBER_CREATED = "sports_center.member.created" + MEMBERSHIP_CREATED = "sports_center.membership.created" + COACH_CREATED = "sports_center.coach.created" + FACILITY_CREATED = "sports_center.facility.created" + DEVICE_CONNECTED = "sports_center.device.connected" + DEVICE_DISCONNECTED = "sports_center.device.disconnected" + ATTENDANCE_PROVIDER_CHANGED = "sports_center.attendance.provider.changed" + LOCKER_ASSIGNED = "sports_center.locker.assigned" + LOCKER_RELEASED = "sports_center.locker.released" + + # Supporting foundation events + SPORTS_CENTER_CREATED = "sports_center.sports_center.created" + SPORTS_CENTER_UPDATED = "sports_center.sports_center.updated" + BRANCH_CREATED = "sports_center.branch.created" + BRANCH_UPDATED = "sports_center.branch.updated" + SPORT_CREATED = "sports_center.sport.created" + MEMBERSHIP_TYPE_CREATED = "sports_center.membership_type.created" + MEMBERSHIP_UPDATED = "sports_center.membership.updated" + COACH_UPDATED = "sports_center.coach.updated" + FACILITY_UPDATED = "sports_center.facility.updated" + DEVICE_CREATED = "sports_center.device.created" + DEVICE_UPDATED = "sports_center.device.updated" + DEVICE_PROVIDER_CREATED = "sports_center.device_provider.created" + ATTENDANCE_GATEWAY_CREATED = "sports_center.attendance_gateway.created" + ATTENDANCE_GATEWAY_UPDATED = "sports_center.attendance_gateway.updated" + CONFIGURATION_CREATED = "sports_center.configuration.created" + CONFIGURATION_UPDATED = "sports_center.configuration.updated" + SPORTS_EVENT_CREATED = "sports_center.sports_event.created" + SETTING_UPSERTED = "sports_center.setting.upserted" + + # Phase 9.1 — Membership Catalog + SPORT_CATEGORY_CREATED = "sports_center.sport_category.created" + SPORT_CATEGORY_UPDATED = "sports_center.sport_category.updated" + AGE_GROUP_CREATED = "sports_center.age_group.created" + AGE_GROUP_UPDATED = "sports_center.age_group.updated" + PRICING_MODEL_CREATED = "sports_center.pricing_model.created" + PRICING_MODEL_UPDATED = "sports_center.pricing_model.updated" + MEMBERSHIP_PACKAGE_CREATED = "sports_center.membership_package.created" + MEMBERSHIP_PACKAGE_UPDATED = "sports_center.membership_package.updated" + MEMBERSHIP_PLAN_CREATED = "sports_center.membership_plan.created" + MEMBERSHIP_PLAN_UPDATED = "sports_center.membership_plan.updated" + MEMBERSHIP_RULE_CREATED = "sports_center.membership_rule.created" + MEMBERSHIP_RULE_UPDATED = "sports_center.membership_rule.updated" + RENEWAL_POLICY_CREATED = "sports_center.renewal_policy.created" + RENEWAL_POLICY_UPDATED = "sports_center.renewal_policy.updated" + FREEZING_RULE_CREATED = "sports_center.freezing_rule.created" + FREEZING_RULE_UPDATED = "sports_center.freezing_rule.updated" + EXPIRATION_POLICY_CREATED = "sports_center.expiration_policy.created" + EXPIRATION_POLICY_UPDATED = "sports_center.expiration_policy.updated" + MEMBERSHIP_TYPE_UPDATED = "sports_center.membership_type.updated" + + # Phase 9.2 — Member Management + MEMBER_UPDATED = "sports_center.member.updated" + MEMBERSHIP_ASSIGNED = "sports_center.membership.assigned" + MEMBERSHIP_STATUS_CHANGED = "sports_center.membership.status_changed" + MEMBERSHIP_FROZEN = "sports_center.membership.frozen" + MEMBERSHIP_UNFROZEN = "sports_center.membership.unfrozen" + FAMILY_MEMBER_CREATED = "sports_center.family_member.created" + EMERGENCY_CONTACT_CREATED = "sports_center.emergency_contact.created" + MEDICAL_INFORMATION_UPSERTED = "sports_center.medical_information.upserted" + MEMBERSHIP_CARD_ISSUED = "sports_center.membership_card.issued" + MEMBERSHIP_CARD_REVOKED = "sports_center.membership_card.revoked" + DIGITAL_MEMBERSHIP_ISSUED = "sports_center.digital_membership.issued" + WAIVER_CREATED = "sports_center.waiver.created" + WAIVER_SIGNED = "sports_center.waiver.signed" + MEMBER_DOCUMENT_ATTACHED = "sports_center.member_document.attached" diff --git a/backend/services/sports_center/app/main.py b/backend/services/sports_center/app/main.py new file mode 100644 index 0000000..62e18ea --- /dev/null +++ b/backend/services/sports_center/app/main.py @@ -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="Sports Center Service", + version=__version__, + description="سرویس Sports Center Platform — فاز ۹.۲ Member Management", + 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() diff --git a/backend/services/sports_center/app/middlewares/tenant.py b/backend/services/sports_center/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/sports_center/app/middlewares/tenant.py @@ -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) diff --git a/backend/services/sports_center/app/models/__init__.py b/backend/services/sports_center/app/models/__init__.py new file mode 100644 index 0000000..6325555 --- /dev/null +++ b/backend/services/sports_center/app/models/__init__.py @@ -0,0 +1,44 @@ +"""Import all models for Alembic metadata discovery.""" +from app.models.foundation import ( # noqa: F401 + AttendanceGateway, + Branch, + Coach, + Court, + Device, + DeviceProvider, + Facility, + Locker, + LockerRoom, + Membership, + MembershipType, + Room, + Sport, + SportsAuditLog, + SportsCenter, + SportsConfiguration, + SportsEvent, + SportsPermission, + SportsRole, + SportsSetting, +) +from app.models.catalog import ( # noqa: F401 + AgeGroup, + ExpirationPolicy, + FreezingRule, + MembershipPackage, + MembershipPlan, + MembershipRule, + PricingModel, + RenewalPolicy, + SportCategory, +) +from app.models.members import ( # noqa: F401 + DigitalMembership, + EmergencyContact, + FamilyMember, + MedicalInformation, + Member, + MemberDocument, + MembershipCard, + Waiver, +) diff --git a/backend/services/sports_center/app/models/base.py b/backend/services/sports_center/app/models/base.py new file mode 100644 index 0000000..195e800 --- /dev/null +++ b/backend/services/sports_center/app/models/base.py @@ -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) diff --git a/backend/services/sports_center/app/models/catalog.py b/backend/services/sports_center/app/models/catalog.py new file mode 100644 index 0000000..f2344ef --- /dev/null +++ b/backend/services/sports_center/app/models/catalog.py @@ -0,0 +1,382 @@ +"""Membership Catalog aggregates — Phase 9.1. + +Catalog definitions only (types, packages, plans, pricing, age groups, categories, +rules and policies). Member enrollment workflows belong to Phase 9.2+. +""" +from __future__ import annotations + +import uuid +from datetime import date +from decimal import Decimal + +from sqlalchemy import ( + Boolean, + Date, + 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 BillingPeriod, GUID, LifecycleStatus, PricingModelKind, RuleKind + + +class SportCategory( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Configurable sport category for catalog grouping (not a hardcoded engine).""" + + __tablename__ = "sport_categories" + + sports_center_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) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_sport_categories_tenant_code", + ), + Index("ix_sport_categories_center", "tenant_id", "sports_center_id"), + Index("ix_sport_categories_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class AgeGroup( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Age band used by membership catalog eligibility.""" + + __tablename__ = "age_groups" + + sports_center_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 + ) + min_age: Mapped[int | None] = mapped_column(Integer, nullable=True) + max_age: Mapped[int | None] = mapped_column(Integer, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_age_groups_tenant_code" + ), + Index("ix_age_groups_center", "tenant_id", "sports_center_id"), + Index("ix_age_groups_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class PricingModel( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Catalog pricing model — stores amounts as configuration, not Accounting journals.""" + + __tablename__ = "pricing_models" + + sports_center_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 + ) + kind: Mapped[PricingModelKind] = mapped_column( + default=PricingModelKind.FIXED, nullable=False + ) + billing_period: Mapped[BillingPeriod] = mapped_column( + default=BillingPeriod.MONTHLY, nullable=False + ) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True) + tiers: Mapped[list | None] = mapped_column(JSON, nullable=True) + tax_inclusive: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + external_price_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_pricing_models_tenant_code", + ), + Index("ix_pricing_models_center", "tenant_id", "sports_center_id"), + Index("ix_pricing_models_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MembershipPackage( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Bundled commercial offering composed of catalog items.""" + + __tablename__ = "membership_packages" + + sports_center_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 + ) + pricing_model_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + sport_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + included_items: Mapped[dict | None] = mapped_column(JSON, nullable=True) + session_credits: Mapped[int | None] = mapped_column(Integer, nullable=True) + valid_from: Mapped[date | None] = mapped_column(Date, nullable=True) + valid_until: Mapped[date | None] = mapped_column(Date, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_membership_packages_tenant_code", + ), + Index("ix_membership_packages_center", "tenant_id", "sports_center_id"), + Index("ix_membership_packages_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MembershipPlan( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Membership plan definition (duration / commercial schedule).""" + + __tablename__ = "membership_plans" + + sports_center_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 + ) + duration_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + billing_period: Mapped[BillingPeriod] = mapped_column( + default=BillingPeriod.MONTHLY, nullable=False + ) + pricing_model_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + package_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + age_group_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + sport_category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + sport_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + features: Mapped[dict | None] = mapped_column(JSON, nullable=True) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_membership_plans_tenant_code", + ), + Index("ix_membership_plans_center", "tenant_id", "sports_center_id"), + Index("ix_membership_plans_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MembershipRule( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Declarative membership catalog rule (eligibility/access/usage).""" + + __tablename__ = "membership_rules" + + sports_center_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 + ) + kind: Mapped[RuleKind] = mapped_column(default=RuleKind.ELIGIBILITY, nullable=False) + membership_type_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + package_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + priority: Mapped[int] = mapped_column(Integer, default=100, nullable=False) + expression: Mapped[dict | None] = mapped_column(JSON, nullable=True) + is_blocking: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_membership_rules_tenant_code", + ), + Index("ix_membership_rules_center", "tenant_id", "sports_center_id"), + Index("ix_membership_rules_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class RenewalPolicy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Catalog renewal policy definition.""" + + __tablename__ = "renewal_policies" + + sports_center_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 + ) + auto_renew: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + renew_window_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + grace_period_days: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + max_renewals: Mapped[int | None] = mapped_column(Integer, nullable=True) + pricing_model_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_renewal_policies_tenant_code", + ), + Index("ix_renewal_policies_center", "tenant_id", "sports_center_id"), + Index("ix_renewal_policies_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class FreezingRule( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Catalog freeze rules for memberships.""" + + __tablename__ = "freezing_rules" + + sports_center_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 + ) + max_freeze_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + max_freeze_count: Mapped[int | None] = mapped_column(Integer, nullable=True) + min_active_days_before_freeze: Mapped[int | None] = mapped_column( + Integer, nullable=True + ) + extend_end_date: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + requires_approval: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_freezing_rules_tenant_code", + ), + Index("ix_freezing_rules_center", "tenant_id", "sports_center_id"), + Index("ix_freezing_rules_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExpirationPolicy( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Catalog expiration / lapse policy.""" + + __tablename__ = "expiration_policies" + + sports_center_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 + ) + expire_after_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + warn_before_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + grace_period_days: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + auto_expire: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + post_expire_status: Mapped[str] = mapped_column( + String(32), default="expired", nullable=False + ) + rules: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_expiration_policies_tenant_code", + ), + Index("ix_expiration_policies_center", "tenant_id", "sports_center_id"), + Index("ix_expiration_policies_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/foundation.py b/backend/services/sports_center/app/models/foundation.py new file mode 100644 index 0000000..0a09853 --- /dev/null +++ b/backend/services/sports_center/app/models/foundation.py @@ -0,0 +1,726 @@ +"""Sports Center foundation aggregates — Phase 9.0. + +Independent aggregates use UUID references within sports_center_db only. +No SQLAlchemy relationship graphs between aggregates. +No sport-specific business rules are hardcoded — sports are configurable catalog entries. +""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + 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, + ConnectorCapability, + DeviceStatus, + FacilityKind, + GUID, + LifecycleStatus, + LockerStatus, + MembershipStatus, +) + + +class SportsCenter( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Root sports center / club aggregate for a tenant.""" + + __tablename__ = "sports_centers" + + 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 + ) + 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_sports_centers_tenant_code"), + Index("ix_sports_centers_tenant_status", "tenant_id", "status"), + Index("ix_sports_centers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Branch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Unlimited branches under a sports center.""" + + __tablename__ = "branches" + + sports_center_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", "sports_center_id", "code", name="uq_branches_tenant_code" + ), + Index("ix_branches_center", "tenant_id", "sports_center_id"), + Index("ix_branches_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Sport( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Generic sport catalog entry — never encodes sport-specific rules.""" + + __tablename__ = "sports" + + sports_center_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 + ) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_sports_tenant_code" + ), + Index("ix_sports_center", "tenant_id", "sports_center_id"), + Index("ix_sports_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MembershipType( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Configurable membership type — catalog depth expanded in Phase 9.1.""" + + __tablename__ = "membership_types" + + sports_center_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 + ) + duration_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + sport_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + # Phase 9.1 catalog links (UUID refs within sports_center_db only) + package_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + pricing_model_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + age_group_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + sport_category_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + renewal_policy_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + freezing_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + expiration_policy_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + is_transferable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_membership_types_tenant_code", + ), + Index("ix_membership_types_center", "tenant_id", "sports_center_id"), + Index("ix_membership_types_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Membership( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Sports membership instance — assigned to a Member; consumes catalog types.""" + + __tablename__ = "memberships" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + membership_type_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + membership_number: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + external_customer_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + external_crm_contact_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[MembershipStatus] = mapped_column( + default=MembershipStatus.PENDING, nullable=False + ) + starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + freeze_starts_on: Mapped[date | None] = mapped_column(Date, nullable=True) + freeze_ends_on: Mapped[date | None] = mapped_column(Date, nullable=True) + profile: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "membership_number", + name="uq_memberships_tenant_membership_number", + ), + Index("ix_memberships_center", "tenant_id", "sports_center_id"), + Index("ix_memberships_member", "tenant_id", "member_id"), + Index("ix_memberships_tenant_status", "tenant_id", "status"), + Index("ix_memberships_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Coach( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Coach / instructor shell — not Identity ownership.""" + + __tablename__ = "coaches" + + sports_center_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) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + sport_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + qualifications: Mapped[dict | None] = mapped_column(JSON, nullable=True) + external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_coaches_tenant_code" + ), + Index("ix_coaches_center", "tenant_id", "sports_center_id"), + Index("ix_coaches_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsRole( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Sports-center RBAC role definition (local to this service).""" + + __tablename__ = "sports_roles" + + sports_center_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 + ) + permission_codes: Mapped[list | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_sports_roles_tenant_code" + ), + Index("ix_sports_roles_center", "tenant_id", "sports_center_id"), + Index("ix_sports_roles_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsPermission( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Sports-center permission catalog entries (local definitions).""" + + __tablename__ = "sports_permissions" + + sports_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + 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) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_sports_permissions_tenant_code" + ), + Index("ix_sports_permissions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Facility( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Generic facility shell (court/room/pool/field/studio as kind).""" + + __tablename__ = "facilities" + + sports_center_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) + kind: Mapped[FacilityKind] = mapped_column( + default=FacilityKind.GENERAL, nullable=False + ) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + capacity: Mapped[int | None] = mapped_column(Integer, nullable=True) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_facilities_tenant_code" + ), + Index("ix_facilities_center", "tenant_id", "sports_center_id"), + Index("ix_facilities_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Court( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Court shell — specialization of facility via facility_id ref.""" + + __tablename__ = "courts" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_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) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + surface: Mapped[str | None] = mapped_column(String(100), nullable=True) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_courts_tenant_code" + ), + Index("ix_courts_center", "tenant_id", "sports_center_id"), + Index("ix_courts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Room( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Room shell.""" + + __tablename__ = "rooms" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_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) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + capacity: Mapped[int | None] = mapped_column(Integer, nullable=True) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_rooms_tenant_code" + ), + Index("ix_rooms_center", "tenant_id", "sports_center_id"), + Index("ix_rooms_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class LockerRoom( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Locker room shell.""" + + __tablename__ = "locker_rooms" + + sports_center_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) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_locker_rooms_tenant_code" + ), + Index("ix_locker_rooms_center", "tenant_id", "sports_center_id"), + Index("ix_locker_rooms_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Locker( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Locker shell — assign/release events prepared; workflows later.""" + + __tablename__ = "lockers" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + locker_room_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + label: Mapped[str | None] = mapped_column(String(100), nullable=True) + status: Mapped[LockerStatus] = mapped_column( + default=LockerStatus.AVAILABLE, nullable=False + ) + assigned_membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + assigned_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "locker_room_id", "code", name="uq_lockers_tenant_code" + ), + Index("ix_lockers_room", "tenant_id", "locker_room_id"), + Index("ix_lockers_tenant_status", "tenant_id", "status"), + Index("ix_lockers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DeviceProvider( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Device provider registry entry — adapter key only, no vendor SDK here.""" + + __tablename__ = "device_providers" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + capability: Mapped[ConnectorCapability] = mapped_column( + default=ConnectorCapability.CUSTOM, nullable=False + ) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + config_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_device_providers_tenant_code" + ), + Index("ix_device_providers_capability", "tenant_id", "capability"), + Index("ix_device_providers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Device( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Physical/logical device shell — connect via connector adapters later.""" + + __tablename__ = "devices" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + device_provider_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) + capability: Mapped[ConnectorCapability] = mapped_column( + default=ConnectorCapability.CUSTOM, nullable=False + ) + status: Mapped[DeviceStatus] = mapped_column( + default=DeviceStatus.REGISTERED, nullable=False + ) + location_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + external_device_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_devices_tenant_code" + ), + Index("ix_devices_center", "tenant_id", "sports_center_id"), + Index("ix_devices_tenant_status", "tenant_id", "status"), + Index("ix_devices_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class AttendanceGateway( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Attendance gateway configuration shell — provider swap via events.""" + + __tablename__ = "attendance_gateways" + + sports_center_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) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + primary_provider_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + fallback_provider_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + device_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_attendance_gateways_tenant_code", + ), + Index("ix_attendance_gateways_center", "tenant_id", "sports_center_id"), + Index("ix_attendance_gateways_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsConfiguration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant sports configuration — working hours, policies, locale, custom fields.""" + + __tablename__ = "sports_configurations" + + sports_center_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) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + membership_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + attendance_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + booking_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + waiting_list: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, 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) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "code", + name="uq_sports_configurations_tenant_code", + ), + Index("ix_sports_configurations_center", "tenant_id", "sports_center_id"), + Index("ix_sports_configurations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsEvent( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Generic sports event / session shell — no scheduling workflows yet.""" + + __tablename__ = "sports_events" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + sport_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + facility_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + coach_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + title: 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 + ) + starts_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + ends_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + capacity: Mapped[int | None] = mapped_column(Integer, nullable=True) + attributes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "sports_center_id", "code", name="uq_sports_events_tenant_code" + ), + Index("ix_sports_events_center", "tenant_id", "sports_center_id"), + Index("ix_sports_events_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsSetting( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Key/value sports settings shell.""" + + __tablename__ = "sports_settings" + + sports_center_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + branch_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) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "sports_center_id", + "branch_id", + "key", + name="uq_sports_settings_tenant_key", + ), + Index("ix_sports_settings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SportsAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only sports audit trail.""" + + __tablename__ = "sports_audit_logs" + + entity_type: Mapped[str] = mapped_column(String(50), 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_sports_audit_entity", + "tenant_id", + "entity_type", + "entity_id", + ), + ) diff --git a/backend/services/sports_center/app/models/members.py b/backend/services/sports_center/app/models/members.py new file mode 100644 index 0000000..f3c3189 --- /dev/null +++ b/backend/services/sports_center/app/models/members.py @@ -0,0 +1,305 @@ +"""Member Management aggregates — Phase 9.2. + +Independent aggregates use UUID references within sports_center_db only. +No ORM relationship graphs. Consumes Membership Catalog (9.1); does not recreate it. +Storage blobs are file refs only (Storage owns binaries). +""" +from __future__ import annotations + +import uuid +from datetime import date, datetime + +from sqlalchemy import ( + Boolean, + Date, + DateTime, + 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 ( + CardKind, + CardStatus, + DocumentKind, + FamilyRelationship, + GUID, + MemberStatus, + WaiverStatus, +) + + +class Member( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Sports member profile — not Platform Member and not Loyalty Member.""" + + __tablename__ = "members" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + branch_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + member_number: Mapped[str] = mapped_column(String(50), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + first_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + last_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + birth_date: Mapped[date | None] = mapped_column(Date, nullable=True) + gender: Mapped[str | None] = mapped_column(String(32), nullable=True) + status: Mapped[MemberStatus] = mapped_column( + default=MemberStatus.ACTIVE, nullable=False + ) + external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + external_customer_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + external_crm_contact_ref: Mapped[str | None] = mapped_column(String(100), nullable=True) + preferred_language: Mapped[str | None] = mapped_column(String(16), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + profile: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "member_number", name="uq_members_tenant_member_number" + ), + Index("ix_members_center", "tenant_id", "sports_center_id"), + Index("ix_members_tenant_status", "tenant_id", "status"), + Index("ix_members_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class FamilyMember( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Family / household link under a sports member.""" + + __tablename__ = "family_members" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + related_member_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + relationship: Mapped[FamilyRelationship] = mapped_column( + default=FamilyRelationship.OTHER, nullable=False + ) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + mobile: Mapped[str | None] = mapped_column(String(50), nullable=True) + birth_date: Mapped[date | None] = mapped_column(Date, nullable=True) + is_emergency_eligible: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_family_members_member", "tenant_id", "member_id"), + Index("ix_family_members_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class EmergencyContact( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Emergency contact for a sports member.""" + + __tablename__ = "emergency_contacts" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + relationship: Mapped[str | None] = mapped_column(String(64), nullable=True) + phone: Mapped[str] = mapped_column(String(50), nullable=False) + alternate_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_emergency_contacts_member", "tenant_id", "member_id"), + Index("ix_emergency_contacts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MedicalInformation( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Privacy-aware medical clearance / note shell — not an EHR.""" + + __tablename__ = "medical_information" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + blood_type: Mapped[str | None] = mapped_column(String(16), nullable=True) + allergies: Mapped[list | None] = mapped_column(JSON, nullable=True) + conditions: Mapped[list | None] = mapped_column(JSON, nullable=True) + medications: Mapped[list | None] = mapped_column(JSON, nullable=True) + clearance_status: Mapped[str | None] = mapped_column(String(32), nullable=True) + clearance_expires_on: Mapped[date | None] = mapped_column(Date, nullable=True) + physician_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + physician_phone: Mapped[str | None] = mapped_column(String(50), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + document_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + is_confidential: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "member_id", name="uq_medical_information_tenant_member" + ), + Index("ix_medical_information_member", "tenant_id", "member_id"), + Index("ix_medical_information_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MembershipCard( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Physical / digital / QR membership card shell.""" + + __tablename__ = "membership_cards" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + card_number: Mapped[str] = mapped_column(String(64), nullable=False) + kind: Mapped[CardKind] = mapped_column(default=CardKind.QR, nullable=False) + status: Mapped[CardStatus] = mapped_column(default=CardStatus.ACTIVE, nullable=False) + qr_payload: Mapped[str | None] = mapped_column(String(512), nullable=True) + barcode_payload: Mapped[str | None] = mapped_column(String(512), nullable=True) + issued_on: Mapped[date | None] = mapped_column(Date, nullable=True) + expires_on: Mapped[date | None] = mapped_column(Date, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "card_number", name="uq_membership_cards_tenant_card_number" + ), + Index("ix_membership_cards_member", "tenant_id", "member_id"), + Index("ix_membership_cards_tenant_status", "tenant_id", "status"), + Index("ix_membership_cards_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class DigitalMembership( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Digital membership pass / wallet shell — token ref only.""" + + __tablename__ = "digital_memberships" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + pass_code: Mapped[str] = mapped_column(String(64), nullable=False) + status: Mapped[CardStatus] = mapped_column(default=CardStatus.ACTIVE, nullable=False) + token_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + deep_link: Mapped[str | None] = mapped_column(String(512), nullable=True) + issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "pass_code", name="uq_digital_memberships_tenant_pass_code" + ), + Index("ix_digital_memberships_member", "tenant_id", "member_id"), + Index("ix_digital_memberships_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class Waiver( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Liability / consent waiver record — document file ref only.""" + + __tablename__ = "waivers" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + membership_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + title: Mapped[str] = mapped_column(String(255), nullable=False) + waiver_version: Mapped[str] = mapped_column(String(32), nullable=False, default="1") + status: Mapped[WaiverStatus] = mapped_column(default=WaiverStatus.PENDING, nullable=False) + signed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + expires_on: Mapped[date | None] = mapped_column(Date, nullable=True) + document_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + signature_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index("ix_waivers_member", "tenant_id", "member_id"), + Index("ix_waivers_tenant_status", "tenant_id", "status"), + Index("ix_waivers_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class MemberDocument( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Member attachment metadata — Storage owns binary content.""" + + __tablename__ = "member_documents" + + sports_center_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + member_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[DocumentKind] = mapped_column(default=DocumentKind.OTHER, nullable=False) + file_ref: Mapped[str] = mapped_column(String(255), nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(128), nullable=True) + size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_member_documents_member", "tenant_id", "member_id"), + Index("ix_member_documents_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/sports_center/app/models/types.py b/backend/services/sports_center/app/models/types.py new file mode 100644 index 0000000..f03172a --- /dev/null +++ b/backend/services/sports_center/app/models/types.py @@ -0,0 +1,180 @@ +"""Sports Center 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 MembershipStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + FROZEN = "frozen" + EXPIRED = "expired" + SUSPENDED = "suspended" + CANCELLED = "cancelled" + + +class MemberStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class FamilyRelationship(str, enum.Enum): + SPOUSE = "spouse" + PARENT = "parent" + CHILD = "child" + SIBLING = "sibling" + GUARDIAN = "guardian" + OTHER = "other" + + +class CardKind(str, enum.Enum): + PHYSICAL = "physical" + DIGITAL = "digital" + QR = "qr" + BARCODE = "barcode" + + +class CardStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + REVOKED = "revoked" + EXPIRED = "expired" + LOST = "lost" + + +class WaiverStatus(str, enum.Enum): + DRAFT = "draft" + PENDING = "pending" + SIGNED = "signed" + EXPIRED = "expired" + REVOKED = "revoked" + + +class DocumentKind(str, enum.Enum): + ID = "id" + PHOTO = "photo" + MEDICAL = "medical" + WAIVER = "waiver" + CONTRACT = "contract" + OTHER = "other" + + +class DeviceStatus(str, enum.Enum): + REGISTERED = "registered" + CONNECTED = "connected" + DISCONNECTED = "disconnected" + MAINTENANCE = "maintenance" + RETIRED = "retired" + + +class LockerStatus(str, enum.Enum): + AVAILABLE = "available" + ASSIGNED = "assigned" + MAINTENANCE = "maintenance" + RETIRED = "retired" + + +class ConnectorCapability(str, enum.Enum): + """Generic device/access capability kinds — never vendor-specific.""" + + QR = "qr" + RFID = "rfid" + BARCODE = "barcode" + FINGERPRINT = "fingerprint" + FACE_RECOGNITION = "face_recognition" + TURNSTILE = "turnstile" + DOOR_CONTROLLER = "door_controller" + PAYMENT_TERMINAL = "payment_terminal" + ATTENDANCE_DEVICE = "attendance_device" + CUSTOM = "custom" + + +class FacilityKind(str, enum.Enum): + """Generic facility classification — no sport-specific rules.""" + + GENERAL = "general" + COURT = "court" + ROOM = "room" + POOL = "pool" + FIELD = "field" + STUDIO = "studio" + OTHER = "other" + + +class AuditAction(str, enum.Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + STATUS_CHANGE = "status_change" + ASSIGN = "assign" + RELEASE = "release" + CONNECT = "connect" + DISCONNECT = "disconnect" + + +class PricingModelKind(str, enum.Enum): + """How catalog prices are expressed — amounts are config, not Accounting postings.""" + + FIXED = "fixed" + RECURRING = "recurring" + TIERED = "tiered" + USAGE = "usage" + CUSTOM = "custom" + + +class BillingPeriod(str, enum.Enum): + ONCE = "once" + DAILY = "daily" + WEEKLY = "weekly" + MONTHLY = "monthly" + QUARTERLY = "quarterly" + YEARLY = "yearly" + CUSTOM = "custom" + + +class RuleKind(str, enum.Enum): + ELIGIBILITY = "eligibility" + ACCESS = "access" + USAGE = "usage" + TRANSFER = "transfer" + CUSTOM = "custom" diff --git a/backend/services/sports_center/app/permissions/definitions.py b/backend/services/sports_center/app/permissions/definitions.py new file mode 100644 index 0000000..1f2bf17 --- /dev/null +++ b/backend/services/sports_center/app/permissions/definitions.py @@ -0,0 +1,274 @@ +"""Sports Center permission definitions — Phase 9.0 foundation.""" +from __future__ import annotations + +SPORTS_CENTER_VIEW = "sports_center.view" +SPORTS_CENTER_MANAGE = "sports_center.manage" + +SPORTS_CENTERS_VIEW = "sports_center.sports_centers.view" +SPORTS_CENTERS_CREATE = "sports_center.sports_centers.create" +SPORTS_CENTERS_UPDATE = "sports_center.sports_centers.update" +SPORTS_CENTERS_DELETE = "sports_center.sports_centers.delete" +SPORTS_CENTERS_MANAGE = "sports_center.sports_centers.manage" + +BRANCHES_VIEW = "sports_center.branches.view" +BRANCHES_CREATE = "sports_center.branches.create" +BRANCHES_UPDATE = "sports_center.branches.update" +BRANCHES_DELETE = "sports_center.branches.delete" +BRANCHES_MANAGE = "sports_center.branches.manage" + +SPORTS_VIEW = "sports_center.sports.view" +SPORTS_CREATE = "sports_center.sports.create" +SPORTS_UPDATE = "sports_center.sports.update" +SPORTS_DELETE = "sports_center.sports.delete" +SPORTS_MANAGE = "sports_center.sports.manage" + +MEMBERSHIP_TYPES_VIEW = "sports_center.membership_types.view" +MEMBERSHIP_TYPES_CREATE = "sports_center.membership_types.create" +MEMBERSHIP_TYPES_UPDATE = "sports_center.membership_types.update" +MEMBERSHIP_TYPES_DELETE = "sports_center.membership_types.delete" +MEMBERSHIP_TYPES_MANAGE = "sports_center.membership_types.manage" + +# Phase 9.1 — Membership Catalog +SPORT_CATEGORIES_VIEW = "sports_center.sport_categories.view" +SPORT_CATEGORIES_MANAGE = "sports_center.sport_categories.manage" +AGE_GROUPS_VIEW = "sports_center.age_groups.view" +AGE_GROUPS_MANAGE = "sports_center.age_groups.manage" +PRICING_MODELS_VIEW = "sports_center.pricing_models.view" +PRICING_MODELS_MANAGE = "sports_center.pricing_models.manage" +MEMBERSHIP_PACKAGES_VIEW = "sports_center.membership_packages.view" +MEMBERSHIP_PACKAGES_MANAGE = "sports_center.membership_packages.manage" +MEMBERSHIP_PLANS_VIEW = "sports_center.membership_plans.view" +MEMBERSHIP_PLANS_MANAGE = "sports_center.membership_plans.manage" +MEMBERSHIP_RULES_VIEW = "sports_center.membership_rules.view" +MEMBERSHIP_RULES_MANAGE = "sports_center.membership_rules.manage" +RENEWAL_POLICIES_VIEW = "sports_center.renewal_policies.view" +RENEWAL_POLICIES_MANAGE = "sports_center.renewal_policies.manage" +FREEZING_RULES_VIEW = "sports_center.freezing_rules.view" +FREEZING_RULES_MANAGE = "sports_center.freezing_rules.manage" +EXPIRATION_POLICIES_VIEW = "sports_center.expiration_policies.view" +EXPIRATION_POLICIES_MANAGE = "sports_center.expiration_policies.manage" + +MEMBERSHIPS_VIEW = "sports_center.memberships.view" +MEMBERSHIPS_CREATE = "sports_center.memberships.create" +MEMBERSHIPS_UPDATE = "sports_center.memberships.update" +MEMBERSHIPS_DELETE = "sports_center.memberships.delete" +MEMBERSHIPS_MANAGE = "sports_center.memberships.manage" +MEMBERSHIPS_ASSIGN = "sports_center.memberships.assign" +MEMBERSHIPS_STATUS = "sports_center.memberships.status" + +MEMBERS_VIEW = "sports_center.members.view" +MEMBERS_CREATE = "sports_center.members.create" +MEMBERS_UPDATE = "sports_center.members.update" +MEMBERS_DELETE = "sports_center.members.delete" +MEMBERS_MANAGE = "sports_center.members.manage" + +FAMILY_MEMBERS_VIEW = "sports_center.family_members.view" +FAMILY_MEMBERS_MANAGE = "sports_center.family_members.manage" +EMERGENCY_CONTACTS_VIEW = "sports_center.emergency_contacts.view" +EMERGENCY_CONTACTS_MANAGE = "sports_center.emergency_contacts.manage" +MEDICAL_VIEW = "sports_center.medical.view" +MEDICAL_MANAGE = "sports_center.medical.manage" +MEMBERSHIP_CARDS_VIEW = "sports_center.membership_cards.view" +MEMBERSHIP_CARDS_MANAGE = "sports_center.membership_cards.manage" +DIGITAL_MEMBERSHIPS_VIEW = "sports_center.digital_memberships.view" +DIGITAL_MEMBERSHIPS_MANAGE = "sports_center.digital_memberships.manage" +WAIVERS_VIEW = "sports_center.waivers.view" +WAIVERS_MANAGE = "sports_center.waivers.manage" +MEMBER_DOCUMENTS_VIEW = "sports_center.member_documents.view" +MEMBER_DOCUMENTS_MANAGE = "sports_center.member_documents.manage" + +COACHES_VIEW = "sports_center.coaches.view" +COACHES_CREATE = "sports_center.coaches.create" +COACHES_UPDATE = "sports_center.coaches.update" +COACHES_DELETE = "sports_center.coaches.delete" +COACHES_MANAGE = "sports_center.coaches.manage" + +ROLES_VIEW = "sports_center.roles.view" +ROLES_MANAGE = "sports_center.roles.manage" +PERMISSIONS_VIEW = "sports_center.permissions.view" +PERMISSIONS_MANAGE = "sports_center.permissions.manage" + +FACILITIES_VIEW = "sports_center.facilities.view" +FACILITIES_CREATE = "sports_center.facilities.create" +FACILITIES_UPDATE = "sports_center.facilities.update" +FACILITIES_DELETE = "sports_center.facilities.delete" +FACILITIES_MANAGE = "sports_center.facilities.manage" + +COURTS_VIEW = "sports_center.courts.view" +COURTS_MANAGE = "sports_center.courts.manage" +ROOMS_VIEW = "sports_center.rooms.view" +ROOMS_MANAGE = "sports_center.rooms.manage" +LOCKER_ROOMS_VIEW = "sports_center.locker_rooms.view" +LOCKER_ROOMS_MANAGE = "sports_center.locker_rooms.manage" +LOCKERS_VIEW = "sports_center.lockers.view" +LOCKERS_MANAGE = "sports_center.lockers.manage" +LOCKERS_ASSIGN = "sports_center.lockers.assign" + +DEVICES_VIEW = "sports_center.devices.view" +DEVICES_CREATE = "sports_center.devices.create" +DEVICES_UPDATE = "sports_center.devices.update" +DEVICES_MANAGE = "sports_center.devices.manage" +DEVICES_CONNECT = "sports_center.devices.connect" + +DEVICE_PROVIDERS_VIEW = "sports_center.device_providers.view" +DEVICE_PROVIDERS_MANAGE = "sports_center.device_providers.manage" + +ATTENDANCE_GATEWAYS_VIEW = "sports_center.attendance_gateways.view" +ATTENDANCE_GATEWAYS_MANAGE = "sports_center.attendance_gateways.manage" + +CONFIGURATION_VIEW = "sports_center.configuration.view" +CONFIGURATION_MANAGE = "sports_center.configuration.manage" +EVENTS_VIEW = "sports_center.events.view" +EVENTS_MANAGE = "sports_center.events.manage" +SETTINGS_VIEW = "sports_center.settings.view" +SETTINGS_MANAGE = "sports_center.settings.manage" +AUDIT_VIEW = "sports_center.audit.view" + +ALL_PERMISSIONS: list[str] = [ + SPORTS_CENTER_VIEW, + SPORTS_CENTER_MANAGE, + SPORTS_CENTERS_VIEW, + SPORTS_CENTERS_CREATE, + SPORTS_CENTERS_UPDATE, + SPORTS_CENTERS_DELETE, + SPORTS_CENTERS_MANAGE, + BRANCHES_VIEW, + BRANCHES_CREATE, + BRANCHES_UPDATE, + BRANCHES_DELETE, + BRANCHES_MANAGE, + SPORTS_VIEW, + SPORTS_CREATE, + SPORTS_UPDATE, + SPORTS_DELETE, + SPORTS_MANAGE, + MEMBERSHIP_TYPES_VIEW, + MEMBERSHIP_TYPES_CREATE, + MEMBERSHIP_TYPES_UPDATE, + MEMBERSHIP_TYPES_DELETE, + MEMBERSHIP_TYPES_MANAGE, + SPORT_CATEGORIES_VIEW, + SPORT_CATEGORIES_MANAGE, + AGE_GROUPS_VIEW, + AGE_GROUPS_MANAGE, + PRICING_MODELS_VIEW, + PRICING_MODELS_MANAGE, + MEMBERSHIP_PACKAGES_VIEW, + MEMBERSHIP_PACKAGES_MANAGE, + MEMBERSHIP_PLANS_VIEW, + MEMBERSHIP_PLANS_MANAGE, + MEMBERSHIP_RULES_VIEW, + MEMBERSHIP_RULES_MANAGE, + RENEWAL_POLICIES_VIEW, + RENEWAL_POLICIES_MANAGE, + FREEZING_RULES_VIEW, + FREEZING_RULES_MANAGE, + EXPIRATION_POLICIES_VIEW, + EXPIRATION_POLICIES_MANAGE, + MEMBERSHIPS_VIEW, + MEMBERSHIPS_CREATE, + MEMBERSHIPS_UPDATE, + MEMBERSHIPS_DELETE, + MEMBERSHIPS_MANAGE, + MEMBERSHIPS_ASSIGN, + MEMBERSHIPS_STATUS, + MEMBERS_VIEW, + MEMBERS_CREATE, + MEMBERS_UPDATE, + MEMBERS_DELETE, + MEMBERS_MANAGE, + FAMILY_MEMBERS_VIEW, + FAMILY_MEMBERS_MANAGE, + EMERGENCY_CONTACTS_VIEW, + EMERGENCY_CONTACTS_MANAGE, + MEDICAL_VIEW, + MEDICAL_MANAGE, + MEMBERSHIP_CARDS_VIEW, + MEMBERSHIP_CARDS_MANAGE, + DIGITAL_MEMBERSHIPS_VIEW, + DIGITAL_MEMBERSHIPS_MANAGE, + WAIVERS_VIEW, + WAIVERS_MANAGE, + MEMBER_DOCUMENTS_VIEW, + MEMBER_DOCUMENTS_MANAGE, + COACHES_VIEW, + COACHES_CREATE, + COACHES_UPDATE, + COACHES_DELETE, + COACHES_MANAGE, + ROLES_VIEW, + ROLES_MANAGE, + PERMISSIONS_VIEW, + PERMISSIONS_MANAGE, + FACILITIES_VIEW, + FACILITIES_CREATE, + FACILITIES_UPDATE, + FACILITIES_DELETE, + FACILITIES_MANAGE, + COURTS_VIEW, + COURTS_MANAGE, + ROOMS_VIEW, + ROOMS_MANAGE, + LOCKER_ROOMS_VIEW, + LOCKER_ROOMS_MANAGE, + LOCKERS_VIEW, + LOCKERS_MANAGE, + LOCKERS_ASSIGN, + DEVICES_VIEW, + DEVICES_CREATE, + DEVICES_UPDATE, + DEVICES_MANAGE, + DEVICES_CONNECT, + DEVICE_PROVIDERS_VIEW, + DEVICE_PROVIDERS_MANAGE, + ATTENDANCE_GATEWAYS_VIEW, + ATTENDANCE_GATEWAYS_MANAGE, + CONFIGURATION_VIEW, + CONFIGURATION_MANAGE, + EVENTS_VIEW, + EVENTS_MANAGE, + SETTINGS_VIEW, + SETTINGS_MANAGE, + AUDIT_VIEW, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "sports_center.", + "sports_center.sports_centers.", + "sports_center.branches.", + "sports_center.sports.", + "sports_center.membership_types.", + "sports_center.sport_categories.", + "sports_center.age_groups.", + "sports_center.pricing_models.", + "sports_center.membership_packages.", + "sports_center.membership_plans.", + "sports_center.membership_rules.", + "sports_center.renewal_policies.", + "sports_center.freezing_rules.", + "sports_center.expiration_policies.", + "sports_center.memberships.", + "sports_center.members.", + "sports_center.family_members.", + "sports_center.emergency_contacts.", + "sports_center.medical.", + "sports_center.membership_cards.", + "sports_center.digital_memberships.", + "sports_center.waivers.", + "sports_center.member_documents.", + "sports_center.coaches.", + "sports_center.roles.", + "sports_center.permissions.", + "sports_center.facilities.", + "sports_center.courts.", + "sports_center.rooms.", + "sports_center.locker_rooms.", + "sports_center.lockers.", + "sports_center.devices.", + "sports_center.device_providers.", + "sports_center.attendance_gateways.", + "sports_center.configuration.", + "sports_center.events.", + "sports_center.settings.", + "sports_center.audit.", +) diff --git a/backend/services/sports_center/app/policies/__init__.py b/backend/services/sports_center/app/policies/__init__.py new file mode 100644 index 0000000..15521f3 --- /dev/null +++ b/backend/services/sports_center/app/policies/__init__.py @@ -0,0 +1,57 @@ +"""Foundation policies — declarative rules shells (no workflows yet).""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class MembershipPolicy: + """Membership policy document shell.""" + + max_active_memberships: int | None = None + allow_overlapping: bool = False + require_branch: bool = False + extras: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class AttendancePolicy: + """Attendance policy document shell.""" + + require_active_membership: bool = True + allow_guest: bool = False + extras: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class BookingPolicy: + """Booking policy document shell.""" + + allow_waiting_list: bool = True + max_advance_days: int | None = None + extras: dict[str, Any] | None = None + + +def policy_from_mapping(kind: str, data: dict[str, Any] | None): + data = data or {} + if kind == "membership": + return MembershipPolicy( + max_active_memberships=data.get("max_active_memberships"), + allow_overlapping=bool(data.get("allow_overlapping", False)), + require_branch=bool(data.get("require_branch", False)), + extras=data, + ) + if kind == "attendance": + return AttendancePolicy( + require_active_membership=bool(data.get("require_active_membership", True)), + allow_guest=bool(data.get("allow_guest", False)), + extras=data, + ) + if kind == "booking": + return BookingPolicy( + allow_waiting_list=bool(data.get("allow_waiting_list", True)), + max_advance_days=data.get("max_advance_days"), + extras=data, + ) + return data diff --git a/backend/services/sports_center/app/providers/__init__.py b/backend/services/sports_center/app/providers/__init__.py new file mode 100644 index 0000000..a8fa8e5 --- /dev/null +++ b/backend/services/sports_center/app/providers/__init__.py @@ -0,0 +1,12 @@ +"""Platform provider contracts package.""" +from app.providers.contracts import ( # noqa: F401 + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + Customer360Provider, + IdentityProvider, + LoyaltyProvider, + NotificationProvider, + StorageProvider, +) diff --git a/backend/services/sports_center/app/providers/contracts.py b/backend/services/sports_center/app/providers/contracts.py new file mode 100644 index 0000000..7eecb75 --- /dev/null +++ b/backend/services/sports_center/app/providers/contracts.py @@ -0,0 +1,88 @@ +"""Platform capability contracts — interfaces only; no implementations in Sports Center.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AccountingProvider(Protocol): + """Contract for Accounting. Sports Center must not post journals.""" + + async def create_receivable_ref( + self, *, tenant_id: UUID, reference: str, payload: dict[str, Any] + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM. Sports stores external_crm_contact_ref only.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class LoyaltyProvider(Protocol): + """Contract for Loyalty. Sports must not own points/ledger.""" + + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication Platform. Sports must not own SMS providers.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: ... + + +class NotificationProvider(Protocol): + """Contract for Notification fanout. Sports must not own delivery.""" + + async def notify( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + payload: dict[str, Any], + ) -> None: ... + + +class StorageProvider(Protocol): + """Contract for File Storage. Sports stores file refs only.""" + + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: ... + + +class AIProvider(Protocol): + """Contract for AI Platform. Sports must not own model inference.""" + + async def suggest( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class IdentityProvider(Protocol): + """Contract for Identity. Sports stores external_user_ref only.""" + + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: ... + + +class Customer360Provider(Protocol): + """Contract for Customer360. Sports stores external_customer_ref only.""" + + async def upsert_party_reference( + self, *, tenant_id: UUID, source: str, source_id: UUID, payload: dict[str, Any] + ) -> None: ... diff --git a/backend/services/sports_center/app/queries/__init__.py b/backend/services/sports_center/app/queries/__init__.py new file mode 100644 index 0000000..17e1895 --- /dev/null +++ b/backend/services/sports_center/app/queries/__init__.py @@ -0,0 +1,26 @@ +"""Query DTOs — read-side intents (foundation shells).""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + + +@dataclass(frozen=True) +class GetByIdQuery: + tenant_id: UUID + entity_id: UUID + + +@dataclass(frozen=True) +class ListByTenantQuery: + tenant_id: UUID + offset: int = 0 + limit: int = 20 + + +@dataclass(frozen=True) +class ListByCenterQuery: + tenant_id: UUID + sports_center_id: UUID + offset: int = 0 + limit: int = 50 diff --git a/backend/services/sports_center/app/repositories/base.py b/backend/services/sports_center/app/repositories/base.py new file mode 100644 index 0000000..39f8172 --- /dev/null +++ b/backend/services/sports_center/app/repositories/base.py @@ -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()) diff --git a/backend/services/sports_center/app/repositories/catalog.py b/backend/services/sports_center/app/repositories/catalog.py new file mode 100644 index 0000000..334ae22 --- /dev/null +++ b/backend/services/sports_center/app/repositories/catalog.py @@ -0,0 +1,87 @@ +"""Membership Catalog repositories — Phase 9.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.catalog import ( + AgeGroup, + ExpirationPolicy, + FreezingRule, + MembershipPackage, + MembershipPlan, + MembershipRule, + PricingModel, + RenewalPolicy, + SportCategory, +) +from app.repositories.base import TenantBaseRepository + + +class _CenterCodedRepository(TenantBaseRepository): + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ): + model = self.model + stmt = select(model).where( + model.tenant_id == tenant_id, + model.sports_center_id == sports_center_id, + model.code == code, + model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_center( + self, tenant_id: UUID, sports_center_id: UUID, *, offset: int = 0, limit: int = 50 + ): + model = self.model + stmt = ( + select(model) + .where( + model.tenant_id == tenant_id, + model.sports_center_id == sports_center_id, + model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class SportCategoryRepository(_CenterCodedRepository): + model = SportCategory + + +class AgeGroupRepository(_CenterCodedRepository): + model = AgeGroup + + +class PricingModelRepository(_CenterCodedRepository): + model = PricingModel + + +class MembershipPackageRepository(_CenterCodedRepository): + model = MembershipPackage + + +class MembershipPlanRepository(_CenterCodedRepository): + model = MembershipPlan + + +class MembershipRuleRepository(_CenterCodedRepository): + model = MembershipRule + + +class RenewalPolicyRepository(_CenterCodedRepository): + model = RenewalPolicy + + +class FreezingRuleRepository(_CenterCodedRepository): + model = FreezingRule + + +class ExpirationPolicyRepository(_CenterCodedRepository): + model = ExpirationPolicy diff --git a/backend/services/sports_center/app/repositories/foundation.py b/backend/services/sports_center/app/repositories/foundation.py new file mode 100644 index 0000000..0d9d7cc --- /dev/null +++ b/backend/services/sports_center/app/repositories/foundation.py @@ -0,0 +1,371 @@ +"""Sports Center foundation repositories — Phase 9.0.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + AttendanceGateway, + Branch, + Coach, + Court, + Device, + DeviceProvider, + Facility, + Locker, + LockerRoom, + Membership, + MembershipType, + Room, + Sport, + SportsAuditLog, + SportsCenter, + SportsConfiguration, + SportsEvent, + SportsPermission, + SportsRole, + SportsSetting, +) +from app.repositories.base import TenantBaseRepository + + +class SportsCenterRepository(TenantBaseRepository[SportsCenter]): + model = SportsCenter + + async def get_by_code(self, tenant_id: UUID, code: str) -> SportsCenter | None: + stmt = select(SportsCenter).where( + SportsCenter.tenant_id == tenant_id, + SportsCenter.code == code, + SportsCenter.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class BranchRepository(TenantBaseRepository[Branch]): + model = Branch + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Branch | None: + stmt = select(Branch).where( + Branch.tenant_id == tenant_id, + Branch.sports_center_id == sports_center_id, + Branch.code == code, + Branch.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_center( + self, tenant_id: UUID, sports_center_id: UUID, *, offset: int = 0, limit: int = 50 + ): + stmt = ( + select(Branch) + .where( + Branch.tenant_id == tenant_id, + Branch.sports_center_id == sports_center_id, + Branch.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class SportRepository(TenantBaseRepository[Sport]): + model = Sport + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Sport | None: + stmt = select(Sport).where( + Sport.tenant_id == tenant_id, + Sport.sports_center_id == sports_center_id, + Sport.code == code, + Sport.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MembershipTypeRepository(TenantBaseRepository[MembershipType]): + model = MembershipType + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> MembershipType | None: + stmt = select(MembershipType).where( + MembershipType.tenant_id == tenant_id, + MembershipType.sports_center_id == sports_center_id, + MembershipType.code == code, + MembershipType.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MembershipRepository(TenantBaseRepository[Membership]): + model = Membership + + async def get_by_membership_number( + self, tenant_id: UUID, membership_number: str + ) -> Membership | None: + stmt = select(Membership).where( + Membership.tenant_id == tenant_id, + Membership.membership_number == membership_number, + Membership.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class CoachRepository(TenantBaseRepository[Coach]): + model = Coach + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Coach | None: + stmt = select(Coach).where( + Coach.tenant_id == tenant_id, + Coach.sports_center_id == sports_center_id, + Coach.code == code, + Coach.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class SportsRoleRepository(TenantBaseRepository[SportsRole]): + model = SportsRole + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> SportsRole | None: + stmt = select(SportsRole).where( + SportsRole.tenant_id == tenant_id, + SportsRole.sports_center_id == sports_center_id, + SportsRole.code == code, + SportsRole.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class SportsPermissionRepository(TenantBaseRepository[SportsPermission]): + model = SportsPermission + + async def get_by_code(self, tenant_id: UUID, code: str) -> SportsPermission | None: + stmt = select(SportsPermission).where( + SportsPermission.tenant_id == tenant_id, + SportsPermission.code == code, + SportsPermission.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class FacilityRepository(TenantBaseRepository[Facility]): + model = Facility + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Facility | None: + stmt = select(Facility).where( + Facility.tenant_id == tenant_id, + Facility.sports_center_id == sports_center_id, + Facility.code == code, + Facility.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class CourtRepository(TenantBaseRepository[Court]): + model = Court + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Court | None: + stmt = select(Court).where( + Court.tenant_id == tenant_id, + Court.sports_center_id == sports_center_id, + Court.code == code, + Court.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class RoomRepository(TenantBaseRepository[Room]): + model = Room + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Room | None: + stmt = select(Room).where( + Room.tenant_id == tenant_id, + Room.sports_center_id == sports_center_id, + Room.code == code, + Room.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class LockerRoomRepository(TenantBaseRepository[LockerRoom]): + model = LockerRoom + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> LockerRoom | None: + stmt = select(LockerRoom).where( + LockerRoom.tenant_id == tenant_id, + LockerRoom.sports_center_id == sports_center_id, + LockerRoom.code == code, + LockerRoom.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class LockerRepository(TenantBaseRepository[Locker]): + model = Locker + + async def get_by_code( + self, tenant_id: UUID, locker_room_id: UUID, code: str + ) -> Locker | None: + stmt = select(Locker).where( + Locker.tenant_id == tenant_id, + Locker.locker_room_id == locker_room_id, + Locker.code == code, + Locker.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeviceProviderRepository(TenantBaseRepository[DeviceProvider]): + model = DeviceProvider + + async def get_by_code(self, tenant_id: UUID, code: str) -> DeviceProvider | None: + stmt = select(DeviceProvider).where( + DeviceProvider.tenant_id == tenant_id, + DeviceProvider.code == code, + DeviceProvider.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class DeviceRepository(TenantBaseRepository[Device]): + model = Device + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> Device | None: + stmt = select(Device).where( + Device.tenant_id == tenant_id, + Device.sports_center_id == sports_center_id, + Device.code == code, + Device.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AttendanceGatewayRepository(TenantBaseRepository[AttendanceGateway]): + model = AttendanceGateway + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> AttendanceGateway | None: + stmt = select(AttendanceGateway).where( + AttendanceGateway.tenant_id == tenant_id, + AttendanceGateway.sports_center_id == sports_center_id, + AttendanceGateway.code == code, + AttendanceGateway.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class SportsConfigurationRepository(TenantBaseRepository[SportsConfiguration]): + model = SportsConfiguration + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> SportsConfiguration | None: + stmt = select(SportsConfiguration).where( + SportsConfiguration.tenant_id == tenant_id, + SportsConfiguration.sports_center_id == sports_center_id, + SportsConfiguration.code == code, + SportsConfiguration.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class SportsEventRepository(TenantBaseRepository[SportsEvent]): + model = SportsEvent + + async def get_by_code( + self, tenant_id: UUID, sports_center_id: UUID, code: str + ) -> SportsEvent | None: + stmt = select(SportsEvent).where( + SportsEvent.tenant_id == tenant_id, + SportsEvent.sports_center_id == sports_center_id, + SportsEvent.code == code, + SportsEvent.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class SportsSettingRepository(TenantBaseRepository[SportsSetting]): + model = SportsSetting + + async def get_by_key( + self, + tenant_id: UUID, + key: str, + *, + sports_center_id: UUID | None = None, + branch_id: UUID | None = None, + ) -> SportsSetting | None: + stmt = select(SportsSetting).where( + SportsSetting.tenant_id == tenant_id, + SportsSetting.key == key, + SportsSetting.sports_center_id == sports_center_id, + SportsSetting.branch_id == branch_id, + SportsSetting.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class SportsAuditLogRepository(TenantBaseRepository[SportsAuditLog]): + model = SportsAuditLog + + async def list_for_entity( + self, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int = 100, + ): + stmt = ( + select(SportsAuditLog) + .where( + SportsAuditLog.tenant_id == tenant_id, + SportsAuditLog.entity_type == entity_type, + SportsAuditLog.entity_id == entity_id, + ) + .order_by(SportsAuditLog.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/sports_center/app/repositories/members.py b/backend/services/sports_center/app/repositories/members.py new file mode 100644 index 0000000..fe66d34 --- /dev/null +++ b/backend/services/sports_center/app/repositories/members.py @@ -0,0 +1,207 @@ +"""Member Management repositories — Phase 9.2.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import select + +from app.models.members import ( + DigitalMembership, + EmergencyContact, + FamilyMember, + MedicalInformation, + Member, + MemberDocument, + MembershipCard, + Waiver, +) +from app.repositories.base import TenantBaseRepository + + +class MemberRepository(TenantBaseRepository[Member]): + model = Member + + async def get_by_member_number( + self, tenant_id: UUID, member_number: str + ) -> Member | None: + stmt = select(Member).where( + Member.tenant_id == tenant_id, + Member.member_number == member_number, + Member.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_center( + self, tenant_id: UUID, sports_center_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[Member]: + stmt = ( + select(Member) + .where( + Member.tenant_id == tenant_id, + Member.sports_center_id == sports_center_id, + Member.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class FamilyMemberRepository(TenantBaseRepository[FamilyMember]): + model = FamilyMember + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ) -> Sequence[FamilyMember]: + stmt = ( + select(FamilyMember) + .where( + FamilyMember.tenant_id == tenant_id, + FamilyMember.member_id == member_id, + FamilyMember.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class EmergencyContactRepository(TenantBaseRepository[EmergencyContact]): + model = EmergencyContact + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ) -> Sequence[EmergencyContact]: + stmt = ( + select(EmergencyContact) + .where( + EmergencyContact.tenant_id == tenant_id, + EmergencyContact.member_id == member_id, + EmergencyContact.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MedicalInformationRepository(TenantBaseRepository[MedicalInformation]): + model = MedicalInformation + + async def get_by_member( + self, tenant_id: UUID, member_id: UUID + ) -> MedicalInformation | None: + stmt = select(MedicalInformation).where( + MedicalInformation.tenant_id == tenant_id, + MedicalInformation.member_id == member_id, + MedicalInformation.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class MembershipCardRepository(TenantBaseRepository[MembershipCard]): + model = MembershipCard + + async def get_by_card_number( + self, tenant_id: UUID, card_number: str + ) -> MembershipCard | None: + stmt = select(MembershipCard).where( + MembershipCard.tenant_id == tenant_id, + MembershipCard.card_number == card_number, + MembershipCard.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ) -> Sequence[MembershipCard]: + stmt = ( + select(MembershipCard) + .where( + MembershipCard.tenant_id == tenant_id, + MembershipCard.member_id == member_id, + MembershipCard.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class DigitalMembershipRepository(TenantBaseRepository[DigitalMembership]): + model = DigitalMembership + + async def get_by_pass_code( + self, tenant_id: UUID, pass_code: str + ) -> DigitalMembership | None: + stmt = select(DigitalMembership).where( + DigitalMembership.tenant_id == tenant_id, + DigitalMembership.pass_code == pass_code, + DigitalMembership.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ) -> Sequence[DigitalMembership]: + stmt = ( + select(DigitalMembership) + .where( + DigitalMembership.tenant_id == tenant_id, + DigitalMembership.member_id == member_id, + DigitalMembership.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class WaiverRepository(TenantBaseRepository[Waiver]): + model = Waiver + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ) -> Sequence[Waiver]: + stmt = ( + select(Waiver) + .where( + Waiver.tenant_id == tenant_id, + Waiver.member_id == member_id, + Waiver.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class MemberDocumentRepository(TenantBaseRepository[MemberDocument]): + model = MemberDocument + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int = 0, limit: int = 50 + ) -> Sequence[MemberDocument]: + stmt = ( + select(MemberDocument) + .where( + MemberDocument.tenant_id == tenant_id, + MemberDocument.member_id == member_id, + MemberDocument.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/sports_center/app/schemas/catalog.py b/backend/services/sports_center/app/schemas/catalog.py new file mode 100644 index 0000000..2b2a994 --- /dev/null +++ b/backend/services/sports_center/app/schemas/catalog.py @@ -0,0 +1,416 @@ +"""Membership Catalog schemas — Phase 9.1.""" +from __future__ import annotations + +from datetime import date, datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import BillingPeriod, LifecycleStatus, PricingModelKind, RuleKind +from app.schemas.common import ORMBase + + +class SportCategoryCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + sort_order: int = 0 + attributes: dict | None = None + + +class SportCategoryUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + sort_order: int | None = None + attributes: dict | None = None + + +class SportCategoryRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + sort_order: int + attributes: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class AgeGroupCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + min_age: int | None = None + max_age: int | None = None + sort_order: int = 0 + + +class AgeGroupUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + min_age: int | None = None + max_age: int | None = None + sort_order: int | None = None + + +class AgeGroupRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + min_age: int | None + max_age: int | None + sort_order: int + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class PricingModelCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + kind: PricingModelKind = PricingModelKind.FIXED + billing_period: BillingPeriod = BillingPeriod.MONTHLY + currency_code: str = Field(default="IRR", max_length=3) + amount: Decimal | None = None + tiers: list | None = None + tax_inclusive: bool = True + external_price_ref: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class PricingModelUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + kind: PricingModelKind | None = None + billing_period: BillingPeriod | None = None + currency_code: str | None = Field(default=None, max_length=3) + amount: Decimal | None = None + tiers: list | None = None + tax_inclusive: bool | None = None + external_price_ref: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class PricingModelRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + kind: PricingModelKind + billing_period: BillingPeriod + currency_code: str + amount: Decimal | None + tiers: list | None + tax_inclusive: bool + external_price_ref: str | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MembershipPackageCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + pricing_model_id: UUID | None = None + sport_ids: list | None = None + included_items: dict | None = None + session_credits: int | None = None + valid_from: date | None = None + valid_until: date | None = None + metadata_json: dict | None = None + + +class MembershipPackageUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + pricing_model_id: UUID | None = None + sport_ids: list | None = None + included_items: dict | None = None + session_credits: int | None = None + valid_from: date | None = None + valid_until: date | None = None + metadata_json: dict | None = None + + +class MembershipPackageRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + pricing_model_id: UUID | None + sport_ids: list | None + included_items: dict | None + session_credits: int | None + valid_from: date | None + valid_until: date | None + metadata_json: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MembershipPlanCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + duration_days: int | None = None + billing_period: BillingPeriod = BillingPeriod.MONTHLY + pricing_model_id: UUID | None = None + package_id: UUID | None = None + age_group_id: UUID | None = None + sport_category_id: UUID | None = None + sport_ids: list | None = None + features: dict | None = None + is_default: bool = False + + +class MembershipPlanUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + duration_days: int | None = None + billing_period: BillingPeriod | None = None + pricing_model_id: UUID | None = None + package_id: UUID | None = None + age_group_id: UUID | None = None + sport_category_id: UUID | None = None + sport_ids: list | None = None + features: dict | None = None + is_default: bool | None = None + + +class MembershipPlanRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + duration_days: int | None + billing_period: BillingPeriod + pricing_model_id: UUID | None + package_id: UUID | None + age_group_id: UUID | None + sport_category_id: UUID | None + sport_ids: list | None + features: dict | None + is_default: bool + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class MembershipRuleCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + kind: RuleKind = RuleKind.ELIGIBILITY + membership_type_id: UUID | None = None + plan_id: UUID | None = None + package_id: UUID | None = None + priority: int = 100 + expression: dict | None = None + is_blocking: bool = True + + +class MembershipRuleUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + kind: RuleKind | None = None + membership_type_id: UUID | None = None + plan_id: UUID | None = None + package_id: UUID | None = None + priority: int | None = None + expression: dict | None = None + is_blocking: bool | None = None + + +class MembershipRuleRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + kind: RuleKind + membership_type_id: UUID | None + plan_id: UUID | None + package_id: UUID | None + priority: int + expression: dict | None + is_blocking: bool + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class RenewalPolicyCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + auto_renew: bool = False + renew_window_days: int | None = None + grace_period_days: int = 0 + max_renewals: int | None = None + pricing_model_id: UUID | None = None + rules: dict | None = None + + +class RenewalPolicyUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + auto_renew: bool | None = None + renew_window_days: int | None = None + grace_period_days: int | None = None + max_renewals: int | None = None + pricing_model_id: UUID | None = None + rules: dict | None = None + + +class RenewalPolicyRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + auto_renew: bool + renew_window_days: int | None + grace_period_days: int + max_renewals: int | None + pricing_model_id: UUID | None + rules: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class FreezingRuleCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + max_freeze_days: int | None = None + max_freeze_count: int | None = None + min_active_days_before_freeze: int | None = None + extend_end_date: bool = True + requires_approval: bool = False + rules: dict | None = None + + +class FreezingRuleUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + max_freeze_days: int | None = None + max_freeze_count: int | None = None + min_active_days_before_freeze: int | None = None + extend_end_date: bool | None = None + requires_approval: bool | None = None + rules: dict | None = None + + +class FreezingRuleRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + max_freeze_days: int | None + max_freeze_count: int | None + min_active_days_before_freeze: int | None + extend_end_date: bool + requires_approval: bool + rules: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime + + +class ExpirationPolicyCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + expire_after_days: int | None = None + warn_before_days: int | None = None + grace_period_days: int = 0 + auto_expire: bool = True + post_expire_status: str = Field(default="expired", max_length=32) + rules: dict | None = None + + +class ExpirationPolicyUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + expire_after_days: int | None = None + warn_before_days: int | None = None + grace_period_days: int | None = None + auto_expire: bool | None = None + post_expire_status: str | None = Field(default=None, max_length=32) + rules: dict | None = None + + +class ExpirationPolicyRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + expire_after_days: int | None + warn_before_days: int | None + grace_period_days: int + auto_expire: bool + post_expire_status: str + rules: dict | None + is_deleted: bool + created_at: datetime + updated_at: datetime diff --git a/backend/services/sports_center/app/schemas/common.py b/backend/services/sports_center/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/sports_center/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/sports_center/app/schemas/foundation.py b/backend/services/sports_center/app/schemas/foundation.py new file mode 100644 index 0000000..3d226e0 --- /dev/null +++ b/backend/services/sports_center/app/schemas/foundation.py @@ -0,0 +1,825 @@ +"""Sports Center foundation DTOs — Phase 9.0.""" +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + ConnectorCapability, + DeviceStatus, + FacilityKind, + LifecycleStatus, + LockerStatus, + MembershipStatus, +) +from app.schemas.common import ORMBase + + +class SportsCenterCreate(BaseModel): + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + legal_name: str | None = Field(default=None, max_length=255) + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + settings: dict | None = None + + +class SportsCenterUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + legal_name: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + settings: dict | None = None + version: int | None = None + + +class SportsCenterRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + legal_name: str | None + timezone: str + language: str + currency_code: str + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class BranchCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + address: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + working_hours: dict | None = None + metadata_json: dict | None = None + + +class BranchRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + address: dict | None + timezone: str | None + working_hours: dict | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + category: str | None = Field(default=None, max_length=100) + attributes: dict | None = None + + +class SportUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + category: str | None = Field(default=None, max_length=100) + attributes: dict | None = None + + +class SportRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + category: str | None + attributes: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MembershipTypeCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + duration_days: int | None = None + policies: dict | None = None + sport_ids: list | None = None + package_id: UUID | None = None + plan_id: UUID | None = None + pricing_model_id: UUID | None = None + age_group_id: UUID | None = None + sport_category_id: UUID | None = None + renewal_policy_id: UUID | None = None + freezing_rule_id: UUID | None = None + expiration_policy_id: UUID | None = None + is_transferable: bool = False + sort_order: int = 0 + + +class MembershipTypeUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + duration_days: int | None = None + policies: dict | None = None + sport_ids: list | None = None + package_id: UUID | None = None + plan_id: UUID | None = None + pricing_model_id: UUID | None = None + age_group_id: UUID | None = None + sport_category_id: UUID | None = None + renewal_policy_id: UUID | None = None + freezing_rule_id: UUID | None = None + expiration_policy_id: UUID | None = None + is_transferable: bool | None = None + sort_order: int | None = None + + +class MembershipTypeRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + duration_days: int | None + policies: dict | None + sport_ids: list | None + package_id: UUID | None + plan_id: UUID | None + pricing_model_id: UUID | None + age_group_id: UUID | None + sport_category_id: UUID | None + renewal_policy_id: UUID | None + freezing_rule_id: UUID | None + expiration_policy_id: UUID | None + is_transferable: bool + sort_order: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MembershipCreate(BaseModel): + sports_center_id: UUID + membership_type_id: UUID + display_name: str = Field(max_length=255) + membership_number: str | None = Field(default=None, max_length=50) + member_id: UUID | None = None + branch_id: UUID | None = None + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + external_customer_ref: str | None = Field(default=None, max_length=100) + external_crm_contact_ref: str | None = Field(default=None, max_length=100) + status: MembershipStatus = MembershipStatus.PENDING + starts_on: date | None = None + ends_on: date | None = None + profile: dict | None = None + custom_fields: dict | None = None + + +class MembershipUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + member_id: UUID | None = None + branch_id: UUID | None = None + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + external_customer_ref: str | None = Field(default=None, max_length=100) + external_crm_contact_ref: str | None = Field(default=None, max_length=100) + status: MembershipStatus | None = None + starts_on: date | None = None + ends_on: date | None = None + freeze_starts_on: date | None = None + freeze_ends_on: date | None = None + profile: dict | None = None + custom_fields: dict | None = None + version: int | None = None + + +class MembershipRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + membership_type_id: UUID + member_id: UUID | None + membership_number: str + display_name: str + email: str | None + mobile: str | None + external_customer_ref: str | None + external_crm_contact_ref: str | None + status: MembershipStatus + starts_on: date | None + ends_on: date | None + freeze_starts_on: date | None + freeze_ends_on: date | None + profile: dict | None + custom_fields: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class CoachCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + display_name: str = Field(max_length=255) + branch_id: UUID | None = None + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + status: LifecycleStatus = LifecycleStatus.ACTIVE + sport_ids: list | None = None + qualifications: dict | None = None + external_user_ref: str | None = Field(default=None, max_length=100) + + +class CoachUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + status: LifecycleStatus | None = None + sport_ids: list | None = None + qualifications: dict | None = None + external_user_ref: str | None = Field(default=None, max_length=100) + + +class CoachRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + code: str + display_name: str + email: str | None + mobile: str | None + status: LifecycleStatus + sport_ids: list | None + qualifications: dict | None + external_user_ref: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportsRoleCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + permission_codes: list | None = None + + +class SportsRoleUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + permission_codes: list | None = None + + +class SportsRoleRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + permission_codes: list | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportsPermissionCreate(BaseModel): + code: str = Field(max_length=100) + name: str = Field(max_length=255) + description: str | None = None + sports_center_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + + +class SportsPermissionUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + + +class SportsPermissionRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID | None + code: str + name: str + description: str | None + status: LifecycleStatus + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class FacilityCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + branch_id: UUID | None = None + kind: FacilityKind = FacilityKind.GENERAL + status: LifecycleStatus = LifecycleStatus.ACTIVE + capacity: int | None = None + attributes: dict | None = None + + +class FacilityUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + description: str | None = None + branch_id: UUID | None = None + kind: FacilityKind | None = None + status: LifecycleStatus | None = None + capacity: int | None = None + attributes: dict | None = None + + +class FacilityRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + code: str + name: str + description: str | None + kind: FacilityKind + status: LifecycleStatus + capacity: int | None + attributes: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class CourtCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + branch_id: UUID | None = None + facility_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + surface: str | None = Field(default=None, max_length=100) + attributes: dict | None = None + + +class CourtUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + facility_id: UUID | None = None + status: LifecycleStatus | None = None + surface: str | None = Field(default=None, max_length=100) + attributes: dict | None = None + + +class CourtRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + facility_id: UUID | None + code: str + name: str + status: LifecycleStatus + surface: str | None + attributes: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class RoomCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + branch_id: UUID | None = None + facility_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + capacity: int | None = None + attributes: dict | None = None + + +class RoomUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + facility_id: UUID | None = None + status: LifecycleStatus | None = None + capacity: int | None = None + attributes: dict | None = None + + +class RoomRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + facility_id: UUID | None + code: str + name: str + status: LifecycleStatus + capacity: int | None + attributes: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class LockerRoomCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + branch_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + attributes: dict | None = None + + +class LockerRoomUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + status: LifecycleStatus | None = None + attributes: dict | None = None + + +class LockerRoomRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + code: str + name: str + status: LifecycleStatus + attributes: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class LockerCreate(BaseModel): + sports_center_id: UUID + locker_room_id: UUID + code: str = Field(max_length=50) + label: str | None = Field(default=None, max_length=100) + status: LockerStatus = LockerStatus.AVAILABLE + attributes: dict | None = None + + +class LockerUpdate(BaseModel): + label: str | None = Field(default=None, max_length=100) + status: LockerStatus | None = None + attributes: dict | None = None + version: int | None = None + + +class LockerAssignRequest(BaseModel): + membership_id: UUID + version: int | None = None + + +class LockerRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + locker_room_id: UUID + code: str + label: str | None + status: LockerStatus + assigned_membership_id: UUID | None + assigned_at: datetime | None + attributes: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DeviceProviderCreate(BaseModel): + code: str = Field(max_length=50) + name: str = Field(max_length=255) + capability: ConnectorCapability = ConnectorCapability.CUSTOM + adapter_key: str = Field(max_length=100) + status: LifecycleStatus = LifecycleStatus.ACTIVE + config_schema: dict | None = None + settings: dict | None = None + + +class DeviceProviderUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + capability: ConnectorCapability | None = None + adapter_key: str | None = Field(default=None, max_length=100) + status: LifecycleStatus | None = None + config_schema: dict | None = None + settings: dict | None = None + + +class DeviceProviderRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + capability: ConnectorCapability + adapter_key: str + status: LifecycleStatus + config_schema: dict | None + settings: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DeviceCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + branch_id: UUID | None = None + device_provider_id: UUID | None = None + capability: ConnectorCapability = ConnectorCapability.CUSTOM + status: DeviceStatus = DeviceStatus.REGISTERED + location_ref: str | None = Field(default=None, max_length=100) + external_device_ref: str | None = Field(default=None, max_length=100) + settings: dict | None = None + + +class DeviceUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + device_provider_id: UUID | None = None + capability: ConnectorCapability | None = None + status: DeviceStatus | None = None + location_ref: str | None = Field(default=None, max_length=100) + external_device_ref: str | None = Field(default=None, max_length=100) + settings: dict | None = None + version: int | None = None + + +class DeviceRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + device_provider_id: UUID | None + code: str + name: str + capability: ConnectorCapability + status: DeviceStatus + location_ref: str | None + external_device_ref: str | None + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AttendanceGatewayCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + branch_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + primary_provider_id: UUID | None = None + fallback_provider_id: UUID | None = None + device_ids: list | None = None + policies: dict | None = None + + +class AttendanceGatewayUpdate(BaseModel): + name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + status: LifecycleStatus | None = None + primary_provider_id: UUID | None = None + fallback_provider_id: UUID | None = None + device_ids: list | None = None + policies: dict | None = None + + +class AttendanceGatewayRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + code: str + name: str + status: LifecycleStatus + primary_provider_id: UUID | None + fallback_provider_id: UUID | None + device_ids: list | None + policies: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportsConfigurationCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + branch_id: UUID | None = None + working_hours: dict | None = None + membership_policies: dict | None = None + attendance_policies: dict | None = None + booking_policies: dict | None = None + waiting_list: dict | None = None + custom_fields: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + + +class SportsConfigurationUpdate(BaseModel): + working_hours: dict | None = None + membership_policies: dict | None = None + attendance_policies: dict | None = None + booking_policies: dict | None = None + waiting_list: dict | None = None + custom_fields: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + version: int | None = None + + +class SportsConfigurationRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + code: str + working_hours: dict | None + membership_policies: dict | None + attendance_policies: dict | None + booking_policies: dict | None + waiting_list: dict | None + custom_fields: dict | None + timezone: str + language: str + currency_code: str + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportsEventCreate(BaseModel): + sports_center_id: UUID + code: str = Field(max_length=50) + title: str = Field(max_length=255) + description: str | None = None + branch_id: UUID | None = None + sport_id: UUID | None = None + facility_id: UUID | None = None + coach_id: UUID | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + starts_at: datetime | None = None + ends_at: datetime | None = None + capacity: int | None = None + attributes: dict | None = None + + +class SportsEventUpdate(BaseModel): + title: str | None = Field(default=None, max_length=255) + description: str | None = None + branch_id: UUID | None = None + sport_id: UUID | None = None + facility_id: UUID | None = None + coach_id: UUID | None = None + status: LifecycleStatus | None = None + starts_at: datetime | None = None + ends_at: datetime | None = None + capacity: int | None = None + attributes: dict | None = None + + +class SportsEventRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + sport_id: UUID | None + facility_id: UUID | None + coach_id: UUID | None + code: str + title: str + description: str | None + status: LifecycleStatus + starts_at: datetime | None + ends_at: datetime | None + capacity: int | None + attributes: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportsSettingUpsert(BaseModel): + key: str = Field(max_length=100) + value: dict | None = None + description: str | None = None + sports_center_id: UUID | None = None + branch_id: UUID | None = None + + +class SportsSettingRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID | None + branch_id: UUID | None + key: str + value: dict | None + description: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SportsAuditLogRead(ORMBase): + id: UUID + tenant_id: UUID + entity_type: str + entity_id: UUID + action: str + actor_user_id: str | None + changes: dict | None + message: str | None + created_at: datetime diff --git a/backend/services/sports_center/app/schemas/members.py b/backend/services/sports_center/app/schemas/members.py new file mode 100644 index 0000000..c39b001 --- /dev/null +++ b/backend/services/sports_center/app/schemas/members.py @@ -0,0 +1,392 @@ +"""Member Management DTOs — Phase 9.2.""" +from __future__ import annotations + +from datetime import date, datetime +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.models.types import ( + CardKind, + CardStatus, + DocumentKind, + FamilyRelationship, + MemberStatus, + WaiverStatus, +) +from app.schemas.common import ORMBase + + +class MemberCreate(BaseModel): + sports_center_id: UUID + display_name: str = Field(max_length=255) + member_number: str | None = Field(default=None, max_length=50) + branch_id: UUID | None = None + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + birth_date: date | None = None + gender: str | None = Field(default=None, max_length=32) + status: MemberStatus = MemberStatus.ACTIVE + external_user_ref: str | None = Field(default=None, max_length=100) + external_customer_ref: str | None = Field(default=None, max_length=100) + external_crm_contact_ref: str | None = Field(default=None, max_length=100) + preferred_language: str | None = Field(default=None, max_length=16) + notes: str | None = None + profile: dict | None = None + custom_fields: dict | None = None + + +class MemberUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + branch_id: UUID | None = None + first_name: str | None = Field(default=None, max_length=100) + last_name: str | None = Field(default=None, max_length=100) + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + birth_date: date | None = None + gender: str | None = Field(default=None, max_length=32) + status: MemberStatus | None = None + external_user_ref: str | None = Field(default=None, max_length=100) + external_customer_ref: str | None = Field(default=None, max_length=100) + external_crm_contact_ref: str | None = Field(default=None, max_length=100) + preferred_language: str | None = Field(default=None, max_length=16) + notes: str | None = None + profile: dict | None = None + custom_fields: dict | None = None + version: int | None = None + + +class MemberRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + branch_id: UUID | None + member_number: str + display_name: str + first_name: str | None + last_name: str | None + email: str | None + mobile: str | None + birth_date: date | None + gender: str | None + status: MemberStatus + external_user_ref: str | None + external_customer_ref: str | None + external_crm_contact_ref: str | None + preferred_language: str | None + notes: str | None + profile: dict | None + custom_fields: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class FamilyMemberCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + display_name: str = Field(max_length=255) + relationship: FamilyRelationship = FamilyRelationship.OTHER + related_member_id: UUID | None = None + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + birth_date: date | None = None + is_emergency_eligible: bool = False + notes: str | None = None + + +class FamilyMemberUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + relationship: FamilyRelationship | None = None + related_member_id: UUID | None = None + email: str | None = Field(default=None, max_length=255) + mobile: str | None = Field(default=None, max_length=50) + birth_date: date | None = None + is_emergency_eligible: bool | None = None + notes: str | None = None + + +class FamilyMemberRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + related_member_id: UUID | None + display_name: str + relationship: FamilyRelationship + email: str | None + mobile: str | None + birth_date: date | None + is_emergency_eligible: bool + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class EmergencyContactCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + display_name: str = Field(max_length=255) + phone: str = Field(max_length=50) + relationship: str | None = Field(default=None, max_length=64) + alternate_phone: str | None = Field(default=None, max_length=50) + email: str | None = Field(default=None, max_length=255) + is_primary: bool = False + notes: str | None = None + + +class EmergencyContactUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=255) + phone: str | None = Field(default=None, max_length=50) + relationship: str | None = Field(default=None, max_length=64) + alternate_phone: str | None = Field(default=None, max_length=50) + email: str | None = Field(default=None, max_length=255) + is_primary: bool | None = None + notes: str | None = None + + +class EmergencyContactRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + display_name: str + relationship: str | None + phone: str + alternate_phone: str | None + email: str | None + is_primary: bool + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MedicalInformationUpsert(BaseModel): + sports_center_id: UUID + member_id: UUID + blood_type: str | None = Field(default=None, max_length=16) + allergies: list | None = None + conditions: list | None = None + medications: list | None = None + clearance_status: str | None = Field(default=None, max_length=32) + clearance_expires_on: date | None = None + physician_name: str | None = Field(default=None, max_length=255) + physician_phone: str | None = Field(default=None, max_length=50) + notes: str | None = None + document_file_ref: str | None = Field(default=None, max_length=255) + is_confidential: bool = True + + +class MedicalInformationRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + blood_type: str | None + allergies: list | None + conditions: list | None + medications: list | None + clearance_status: str | None + clearance_expires_on: date | None + physician_name: str | None + physician_phone: str | None + notes: str | None + document_file_ref: str | None + is_confidential: bool + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MembershipCardCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None = None + kind: CardKind = CardKind.QR + card_number: str | None = Field(default=None, max_length=64) + status: CardStatus = CardStatus.ACTIVE + issued_on: date | None = None + expires_on: date | None = None + metadata_json: dict | None = None + + +class MembershipCardUpdate(BaseModel): + status: CardStatus | None = None + membership_id: UUID | None = None + expires_on: date | None = None + metadata_json: dict | None = None + + +class MembershipCardRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None + card_number: str + kind: CardKind + status: CardStatus + qr_payload: str | None + barcode_payload: str | None + issued_on: date | None + expires_on: date | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DigitalMembershipCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None = None + pass_code: str | None = Field(default=None, max_length=64) + status: CardStatus = CardStatus.ACTIVE + token_ref: str | None = Field(default=None, max_length=255) + deep_link: str | None = Field(default=None, max_length=512) + expires_at: datetime | None = None + metadata_json: dict | None = None + + +class DigitalMembershipUpdate(BaseModel): + status: CardStatus | None = None + membership_id: UUID | None = None + token_ref: str | None = Field(default=None, max_length=255) + deep_link: str | None = Field(default=None, max_length=512) + expires_at: datetime | None = None + metadata_json: dict | None = None + + +class DigitalMembershipRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None + pass_code: str + status: CardStatus + token_ref: str | None + deep_link: str | None + issued_at: datetime | None + expires_at: datetime | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class WaiverCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + title: str = Field(max_length=255) + membership_id: UUID | None = None + waiver_version: str = Field(default="1", max_length=32) + status: WaiverStatus = WaiverStatus.PENDING + expires_on: date | None = None + document_file_ref: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class WaiverSignRequest(BaseModel): + signature_ref: str | None = Field(default=None, max_length=255) + document_file_ref: str | None = Field(default=None, max_length=255) + + +class WaiverUpdate(BaseModel): + title: str | None = Field(default=None, max_length=255) + waiver_version: str | None = Field(default=None, max_length=32) + status: WaiverStatus | None = None + expires_on: date | None = None + document_file_ref: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class WaiverRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + membership_id: UUID | None + title: str + waiver_version: str + status: WaiverStatus + signed_at: datetime | None + expires_on: date | None + document_file_ref: str | None + signature_ref: str | None + notes: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MemberDocumentCreate(BaseModel): + sports_center_id: UUID + member_id: UUID + title: str = Field(max_length=255) + file_ref: str = Field(max_length=255) + kind: DocumentKind = DocumentKind.OTHER + mime_type: str | None = Field(default=None, max_length=128) + size_bytes: int | None = None + notes: str | None = None + metadata_json: dict | None = None + + +class MemberDocumentUpdate(BaseModel): + title: str | None = Field(default=None, max_length=255) + kind: DocumentKind | None = None + notes: str | None = None + metadata_json: dict | None = None + + +class MemberDocumentRead(ORMBase): + id: UUID + tenant_id: UUID + sports_center_id: UUID + member_id: UUID + title: str + kind: DocumentKind + file_ref: str + mime_type: str | None + size_bytes: int | None + notes: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class MembershipAssignMemberRequest(BaseModel): + member_id: UUID + version: int | None = None + + +class MembershipFreezeRequest(BaseModel): + freeze_starts_on: date | None = None + freeze_ends_on: date | None = None + version: int | None = None + + +class MembershipStatusChangeRequest(BaseModel): + version: int | None = None diff --git a/backend/services/sports_center/app/services/audit_service.py b/backend/services/sports_center/app/services/audit_service.py new file mode 100644 index 0000000..4941b2d --- /dev/null +++ b/backend/services/sports_center/app/services/audit_service.py @@ -0,0 +1,64 @@ +"""Audit service — append-only Sports Center audit trail.""" +from __future__ import annotations + +from enum import Enum +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import SportsAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import SportsAuditLogRepository + + +def _json_safe(value: Any) -> Any: + if isinstance(value, UUID): + return str(value) + if isinstance(value, Enum): + return value.value + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [_json_safe(v) for v in value] + return value + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = SportsAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> SportsAuditLog: + entry = SportsAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=_json_safe(changes) if changes is not None else None, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + return await self.repo.list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/sports_center/app/services/catalog.py b/backend/services/sports_center/app/services/catalog.py new file mode 100644 index 0000000..2402d9e --- /dev/null +++ b/backend/services/sports_center/app/services/catalog.py @@ -0,0 +1,988 @@ +"""Membership Catalog application services — Phase 9.1.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.catalog import ( + AgeGroup, + ExpirationPolicy, + FreezingRule, + MembershipPackage, + MembershipPlan, + MembershipRule, + PricingModel, + RenewalPolicy, + SportCategory, +) +from app.models.types import AuditAction +from app.repositories.catalog import ( + AgeGroupRepository, + ExpirationPolicyRepository, + FreezingRuleRepository, + MembershipPackageRepository, + MembershipPlanRepository, + MembershipRuleRepository, + PricingModelRepository, + RenewalPolicyRepository, + SportCategoryRepository, +) +from app.repositories.foundation import ( + MembershipTypeRepository, + SportsCenterRepository, +) +from app.schemas.catalog import ( + AgeGroupCreate, + AgeGroupUpdate, + ExpirationPolicyCreate, + ExpirationPolicyUpdate, + FreezingRuleCreate, + FreezingRuleUpdate, + MembershipPackageCreate, + MembershipPackageUpdate, + MembershipPlanCreate, + MembershipPlanUpdate, + MembershipRuleCreate, + MembershipRuleUpdate, + PricingModelCreate, + PricingModelUpdate, + RenewalPolicyCreate, + RenewalPolicyUpdate, + SportCategoryCreate, + SportCategoryUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + forbid_sport_specific_hardcoding, + validate_code, + validate_currency_code, + validate_date_range, + validate_lifecycle_status, + validate_non_empty, + validate_non_negative_int, +) +from app.validators.catalog import ( + validate_age_range, + validate_billing_period, + validate_money_amount, + validate_pricing_kind, + validate_rule_kind, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +class _CatalogBase: + entity_type: str + not_found_code: str + not_found_message: str + duplicate_code: str + create_event: SportsCenterEventType + update_event: SportsCenterEventType | None = None + + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def get(self, tenant_id: UUID, entity_id: UUID): + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError(self.not_found_message, error_code=self.not_found_code) + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None): + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class SportCategoryService(_CatalogBase): + entity_type = "sport_category" + not_found_code = "sport_category_not_found" + not_found_message = "دسته ورزشی یافت نشد" + duplicate_code = "sport_category_code_exists" + create_event = SportsCenterEventType.SPORT_CATEGORY_CREATED + update_event = SportsCenterEventType.SPORT_CATEGORY_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportCategoryRepository(session) + + async def create(self, tenant_id: UUID, body: SportCategoryCreate, *, actor: CurrentUser | None = None) -> SportCategory: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + forbid_sport_specific_hardcoding(body.model_dump()) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد دسته ورزشی تکراری است", status_code=409, error_code=self.duplicate_code) + entity = SportCategory( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + sort_order=body.sort_order, + attributes=body.attributes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Sport category {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: SportCategoryUpdate, *, actor: CurrentUser | None = None + ) -> SportCategory: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + if self.update_event: + self.publisher.publish( + event_type=self.update_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + +class AgeGroupService(_CatalogBase): + entity_type = "age_group" + not_found_code = "age_group_not_found" + not_found_message = "گروه سنی یافت نشد" + duplicate_code = "age_group_code_exists" + create_event = SportsCenterEventType.AGE_GROUP_CREATED + update_event = SportsCenterEventType.AGE_GROUP_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = AgeGroupRepository(session) + + async def create(self, tenant_id: UUID, body: AgeGroupCreate, *, actor: CurrentUser | None = None) -> AgeGroup: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد گروه سنی تکراری است", status_code=409, error_code=self.duplicate_code) + min_age, max_age = validate_age_range(body.min_age, body.max_age) + entity = AgeGroup( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + min_age=min_age, + max_age=max_age, + sort_order=body.sort_order, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Age group {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: AgeGroupUpdate, *, actor: CurrentUser | None = None + ) -> AgeGroup: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + min_age = data.get("min_age", entity.min_age) + max_age = data.get("max_age", entity.max_age) + if "min_age" in data or "max_age" in data: + min_age, max_age = validate_age_range(min_age, max_age) + data["min_age"] = min_age + data["max_age"] = max_age + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class PricingModelService(_CatalogBase): + entity_type = "pricing_model" + not_found_code = "pricing_model_not_found" + not_found_message = "مدل قیمت‌گذاری یافت نشد" + duplicate_code = "pricing_model_code_exists" + create_event = SportsCenterEventType.PRICING_MODEL_CREATED + update_event = SportsCenterEventType.PRICING_MODEL_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = PricingModelRepository(session) + + async def create(self, tenant_id: UUID, body: PricingModelCreate, *, actor: CurrentUser | None = None) -> PricingModel: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد مدل قیمت‌گذاری تکراری است", status_code=409, error_code=self.duplicate_code) + entity = PricingModel( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + kind=validate_pricing_kind(body.kind), + billing_period=validate_billing_period(body.billing_period), + currency_code=validate_currency_code(body.currency_code), + amount=validate_money_amount(body.amount), + tiers=body.tiers, + tax_inclusive=body.tax_inclusive, + external_price_ref=body.external_price_ref, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Pricing model {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "kind": entity.kind.value}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: PricingModelUpdate, *, actor: CurrentUser | None = None + ) -> PricingModel: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "kind" in data and data["kind"] is not None: + data["kind"] = validate_pricing_kind(data["kind"]) + if "billing_period" in data and data["billing_period"] is not None: + data["billing_period"] = validate_billing_period(data["billing_period"]) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + if "amount" in data: + data["amount"] = validate_money_amount(data["amount"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes={k: (str(v) if hasattr(v, "__str__") else v) for k, v in data.items()}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +async def _optional_ref(repo, tenant_id: UUID, ref_id: UUID | None, *, error_code: str, message: str): + if ref_id is None: + return None + entity = await repo.get(tenant_id, ref_id) + if entity is None: + raise NotFoundError(message, error_code=error_code) + return entity + + +class MembershipPackageService(_CatalogBase): + entity_type = "membership_package" + not_found_code = "membership_package_not_found" + not_found_message = "بسته عضویت یافت نشد" + duplicate_code = "membership_package_code_exists" + create_event = SportsCenterEventType.MEMBERSHIP_PACKAGE_CREATED + update_event = SportsCenterEventType.MEMBERSHIP_PACKAGE_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MembershipPackageRepository(session) + self.pricing = PricingModelRepository(session) + + async def create( + self, tenant_id: UUID, body: MembershipPackageCreate, *, actor: CurrentUser | None = None + ) -> MembershipPackage: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد بسته عضویت تکراری است", status_code=409, error_code=self.duplicate_code) + await _optional_ref( + self.pricing, + tenant_id, + body.pricing_model_id, + error_code="pricing_model_not_found", + message="مدل قیمت‌گذاری یافت نشد", + ) + starts, ends = validate_date_range(body.valid_from, body.valid_until) + entity = MembershipPackage( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + pricing_model_id=body.pricing_model_id, + sport_ids=body.sport_ids, + included_items=body.included_items, + session_credits=validate_non_negative_int(body.session_credits, field="session_credits"), + valid_from=starts, + valid_until=ends, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Membership package {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: MembershipPackageUpdate, *, actor: CurrentUser | None = None + ) -> MembershipPackage: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "pricing_model_id" in data: + await _optional_ref( + self.pricing, + tenant_id, + data["pricing_model_id"], + error_code="pricing_model_not_found", + message="مدل قیمت‌گذاری یافت نشد", + ) + if "session_credits" in data: + data["session_credits"] = validate_non_negative_int( + data["session_credits"], field="session_credits" + ) + if "valid_from" in data or "valid_until" in data: + starts, ends = validate_date_range( + data.get("valid_from", entity.valid_from), + data.get("valid_until", entity.valid_until), + ) + data["valid_from"] = starts + data["valid_until"] = ends + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class MembershipPlanService(_CatalogBase): + entity_type = "membership_plan" + not_found_code = "membership_plan_not_found" + not_found_message = "طرح عضویت یافت نشد" + duplicate_code = "membership_plan_code_exists" + create_event = SportsCenterEventType.MEMBERSHIP_PLAN_CREATED + update_event = SportsCenterEventType.MEMBERSHIP_PLAN_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MembershipPlanRepository(session) + self.pricing = PricingModelRepository(session) + self.packages = MembershipPackageRepository(session) + self.age_groups = AgeGroupRepository(session) + self.categories = SportCategoryRepository(session) + + async def create( + self, tenant_id: UUID, body: MembershipPlanCreate, *, actor: CurrentUser | None = None + ) -> MembershipPlan: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد طرح عضویت تکراری است", status_code=409, error_code=self.duplicate_code) + await _optional_ref( + self.pricing, tenant_id, body.pricing_model_id, + error_code="pricing_model_not_found", message="مدل قیمت‌گذاری یافت نشد", + ) + await _optional_ref( + self.packages, tenant_id, body.package_id, + error_code="membership_package_not_found", message="بسته عضویت یافت نشد", + ) + await _optional_ref( + self.age_groups, tenant_id, body.age_group_id, + error_code="age_group_not_found", message="گروه سنی یافت نشد", + ) + await _optional_ref( + self.categories, tenant_id, body.sport_category_id, + error_code="sport_category_not_found", message="دسته ورزشی یافت نشد", + ) + entity = MembershipPlan( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + duration_days=validate_non_negative_int(body.duration_days, field="duration_days"), + billing_period=validate_billing_period(body.billing_period), + pricing_model_id=body.pricing_model_id, + package_id=body.package_id, + age_group_id=body.age_group_id, + sport_category_id=body.sport_category_id, + sport_ids=body.sport_ids, + features=body.features, + is_default=body.is_default, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Membership plan {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: MembershipPlanUpdate, *, actor: CurrentUser | None = None + ) -> MembershipPlan: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "billing_period" in data and data["billing_period"] is not None: + data["billing_period"] = validate_billing_period(data["billing_period"]) + if "duration_days" in data: + data["duration_days"] = validate_non_negative_int(data["duration_days"], field="duration_days") + for key, repo, code, msg in ( + ("pricing_model_id", self.pricing, "pricing_model_not_found", "مدل قیمت‌گذاری یافت نشد"), + ("package_id", self.packages, "membership_package_not_found", "بسته عضویت یافت نشد"), + ("age_group_id", self.age_groups, "age_group_not_found", "گروه سنی یافت نشد"), + ("sport_category_id", self.categories, "sport_category_not_found", "دسته ورزشی یافت نشد"), + ): + if key in data: + await _optional_ref(repo, tenant_id, data[key], error_code=code, message=msg) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class MembershipRuleService(_CatalogBase): + entity_type = "membership_rule" + not_found_code = "membership_rule_not_found" + not_found_message = "قانون عضویت یافت نشد" + duplicate_code = "membership_rule_code_exists" + create_event = SportsCenterEventType.MEMBERSHIP_RULE_CREATED + update_event = SportsCenterEventType.MEMBERSHIP_RULE_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MembershipRuleRepository(session) + self.types = MembershipTypeRepository(session) + self.plans = MembershipPlanRepository(session) + self.packages = MembershipPackageRepository(session) + + async def create( + self, tenant_id: UUID, body: MembershipRuleCreate, *, actor: CurrentUser | None = None + ) -> MembershipRule: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد قانون عضویت تکراری است", status_code=409, error_code=self.duplicate_code) + await _optional_ref( + self.types, tenant_id, body.membership_type_id, + error_code="membership_type_not_found", message="نوع عضویت یافت نشد", + ) + await _optional_ref( + self.plans, tenant_id, body.plan_id, + error_code="membership_plan_not_found", message="طرح عضویت یافت نشد", + ) + await _optional_ref( + self.packages, tenant_id, body.package_id, + error_code="membership_package_not_found", message="بسته عضویت یافت نشد", + ) + entity = MembershipRule( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + kind=validate_rule_kind(body.kind), + membership_type_id=body.membership_type_id, + plan_id=body.plan_id, + package_id=body.package_id, + priority=body.priority, + expression=body.expression, + is_blocking=body.is_blocking, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Membership rule {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "kind": entity.kind.value}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: MembershipRuleUpdate, *, actor: CurrentUser | None = None + ) -> MembershipRule: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "kind" in data and data["kind"] is not None: + data["kind"] = validate_rule_kind(data["kind"]) + for key, repo, code, msg in ( + ("membership_type_id", self.types, "membership_type_not_found", "نوع عضویت یافت نشد"), + ("plan_id", self.plans, "membership_plan_not_found", "طرح عضویت یافت نشد"), + ("package_id", self.packages, "membership_package_not_found", "بسته عضویت یافت نشد"), + ): + if key in data: + await _optional_ref(repo, tenant_id, data[key], error_code=code, message=msg) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class RenewalPolicyService(_CatalogBase): + entity_type = "renewal_policy" + not_found_code = "renewal_policy_not_found" + not_found_message = "سیاست تمدید یافت نشد" + duplicate_code = "renewal_policy_code_exists" + create_event = SportsCenterEventType.RENEWAL_POLICY_CREATED + update_event = SportsCenterEventType.RENEWAL_POLICY_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = RenewalPolicyRepository(session) + self.pricing = PricingModelRepository(session) + + async def create( + self, tenant_id: UUID, body: RenewalPolicyCreate, *, actor: CurrentUser | None = None + ) -> RenewalPolicy: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد سیاست تمدید تکراری است", status_code=409, error_code=self.duplicate_code) + await _optional_ref( + self.pricing, tenant_id, body.pricing_model_id, + error_code="pricing_model_not_found", message="مدل قیمت‌گذاری یافت نشد", + ) + entity = RenewalPolicy( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + auto_renew=body.auto_renew, + renew_window_days=validate_non_negative_int(body.renew_window_days, field="renew_window_days"), + grace_period_days=validate_non_negative_int(body.grace_period_days, field="grace_period_days") or 0, + max_renewals=validate_non_negative_int(body.max_renewals, field="max_renewals"), + pricing_model_id=body.pricing_model_id, + rules=body.rules, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Renewal policy {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: RenewalPolicyUpdate, *, actor: CurrentUser | None = None + ) -> RenewalPolicy: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + for field in ("renew_window_days", "grace_period_days", "max_renewals"): + if field in data: + data[field] = validate_non_negative_int(data[field], field=field) + if field == "grace_period_days" and data[field] is None: + data[field] = 0 + if "pricing_model_id" in data: + await _optional_ref( + self.pricing, tenant_id, data["pricing_model_id"], + error_code="pricing_model_not_found", message="مدل قیمت‌گذاری یافت نشد", + ) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class FreezingRuleService(_CatalogBase): + entity_type = "freezing_rule" + not_found_code = "freezing_rule_not_found" + not_found_message = "قانون فریز یافت نشد" + duplicate_code = "freezing_rule_code_exists" + create_event = SportsCenterEventType.FREEZING_RULE_CREATED + update_event = SportsCenterEventType.FREEZING_RULE_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = FreezingRuleRepository(session) + + async def create( + self, tenant_id: UUID, body: FreezingRuleCreate, *, actor: CurrentUser | None = None + ) -> FreezingRule: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد قانون فریز تکراری است", status_code=409, error_code=self.duplicate_code) + entity = FreezingRule( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + max_freeze_days=validate_non_negative_int(body.max_freeze_days, field="max_freeze_days"), + max_freeze_count=validate_non_negative_int(body.max_freeze_count, field="max_freeze_count"), + min_active_days_before_freeze=validate_non_negative_int( + body.min_active_days_before_freeze, field="min_active_days_before_freeze" + ), + extend_end_date=body.extend_end_date, + requires_approval=body.requires_approval, + rules=body.rules, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Freezing rule {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: FreezingRuleUpdate, *, actor: CurrentUser | None = None + ) -> FreezingRule: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + for field in ("max_freeze_days", "max_freeze_count", "min_active_days_before_freeze"): + if field in data: + data[field] = validate_non_negative_int(data[field], field=field) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExpirationPolicyService(_CatalogBase): + entity_type = "expiration_policy" + not_found_code = "expiration_policy_not_found" + not_found_message = "سیاست انقضا یافت نشد" + duplicate_code = "expiration_policy_code_exists" + create_event = SportsCenterEventType.EXPIRATION_POLICY_CREATED + update_event = SportsCenterEventType.EXPIRATION_POLICY_UPDATED + + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = ExpirationPolicyRepository(session) + + async def create( + self, tenant_id: UUID, body: ExpirationPolicyCreate, *, actor: CurrentUser | None = None + ) -> ExpirationPolicy: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد سیاست انقضا تکراری است", status_code=409, error_code=self.duplicate_code) + entity = ExpirationPolicy( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + expire_after_days=validate_non_negative_int(body.expire_after_days, field="expire_after_days"), + warn_before_days=validate_non_negative_int(body.warn_before_days, field="warn_before_days"), + grace_period_days=validate_non_negative_int(body.grace_period_days, field="grace_period_days") or 0, + auto_expire=body.auto_expire, + post_expire_status=validate_non_empty(body.post_expire_status, field="post_expire_status"), + rules=body.rules, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Expiration policy {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=self.create_event, + aggregate_type=self.entity_type, + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update( + self, tenant_id: UUID, entity_id: UUID, body: ExpirationPolicyUpdate, *, actor: CurrentUser | None = None + ) -> ExpirationPolicy: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "post_expire_status" in data and data["post_expire_status"] is not None: + data["post_expire_status"] = validate_non_empty( + data["post_expire_status"], field="post_expire_status" + ) + for field in ("expire_after_days", "warn_before_days", "grace_period_days"): + if field in data: + data[field] = validate_non_negative_int(data[field], field=field) + if field == "grace_period_days" and data[field] is None: + data[field] = 0 + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type=self.entity_type, + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/sports_center/app/services/foundation.py b/backend/services/sports_center/app/services/foundation.py new file mode 100644 index 0000000..af6fcc8 --- /dev/null +++ b/backend/services/sports_center/app/services/foundation.py @@ -0,0 +1,1474 @@ +"""Sports Center foundation application services — Phase 9.0.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.foundation import ( + AttendanceGateway, + Branch, + Coach, + Court, + Device, + DeviceProvider, + Facility, + Locker, + LockerRoom, + Membership, + MembershipType, + Room, + Sport, + SportsCenter, + SportsConfiguration, + SportsEvent, + SportsPermission, + SportsRole, + SportsSetting, +) +from app.models.types import AuditAction, DeviceStatus, LockerStatus +from app.repositories.foundation import ( + AttendanceGatewayRepository, + BranchRepository, + CoachRepository, + CourtRepository, + DeviceProviderRepository, + DeviceRepository, + FacilityRepository, + LockerRepository, + LockerRoomRepository, + MembershipRepository, + MembershipTypeRepository, + RoomRepository, + SportRepository, + SportsCenterRepository, + SportsConfigurationRepository, + SportsEventRepository, + SportsPermissionRepository, + SportsRoleRepository, + SportsSettingRepository, +) +from app.schemas.foundation import ( + AttendanceGatewayCreate, + AttendanceGatewayUpdate, + BranchCreate, + BranchUpdate, + CoachCreate, + CoachUpdate, + CourtCreate, + CourtUpdate, + DeviceCreate, + DeviceProviderCreate, + DeviceProviderUpdate, + DeviceUpdate, + FacilityCreate, + FacilityUpdate, + LockerAssignRequest, + LockerCreate, + LockerRoomCreate, + LockerRoomUpdate, + LockerUpdate, + MembershipCreate, + MembershipTypeCreate, + MembershipTypeUpdate, + MembershipUpdate, + RoomCreate, + RoomUpdate, + SportCreate, + SportsCenterCreate, + SportsCenterUpdate, + SportsConfigurationCreate, + SportsConfigurationUpdate, + SportsEventCreate, + SportsEventUpdate, + SportsPermissionCreate, + SportsPermissionUpdate, + SportsRoleCreate, + SportsRoleUpdate, + SportsSettingUpsert, + SportUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + forbid_sport_specific_hardcoding, + validate_capability, + validate_code, + validate_currency_code, + validate_date_range, + validate_device_status, + validate_email, + validate_facility_kind, + validate_lifecycle_status, + validate_locker_status, + validate_membership_status, + validate_non_empty, + validate_non_negative_int, + validate_phone, + validate_setting_key, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _next_membership_number() -> str: + return f"SM-{uuid4().hex[:10].upper()}" + + +class _BaseService: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID) -> SportsCenter: + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + +class SportsCenterService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportsCenterRepository(session) + + async def create(self, tenant_id: UUID, body: SportsCenterCreate, *, actor: CurrentUser | None = None) -> SportsCenter: + forbid_sport_specific_hardcoding(body.model_dump()) + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code) is not None: + raise AppError("کد مرکز ورزشی تکراری است", status_code=409, error_code="sports_center_code_exists") + entity = SportsCenter( + tenant_id=tenant_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + legal_name=body.legal_name, + timezone=validate_non_empty(body.timezone, field="timezone"), + language=validate_non_empty(body.language, field="language"), + currency_code=validate_currency_code(body.currency_code), + settings=body.settings, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="sports_center", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Sports center {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.SPORTS_CENTER_CREATED, + aggregate_type="sports_center", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportsCenterUpdate, *, actor: CurrentUser | None = None) -> SportsCenter: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="sports_center", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.SPORTS_CENTER_UPDATED, + aggregate_type="sports_center", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> SportsCenter: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> SportsCenter: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, + entity_type="sports_center", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class BranchService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = BranchRepository(session) + + async def create(self, tenant_id: UUID, body: BranchCreate, *, actor: CurrentUser | None = None) -> Branch: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد شعبه تکراری است", status_code=409, error_code="branch_code_exists") + entity = Branch( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + address=body.address, + timezone=body.timezone, + working_hours=body.working_hours, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, entity_type="branch", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, message=f"Branch {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.BRANCH_CREATED, + aggregate_type="branch", aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code, "sports_center_id": str(entity.sports_center_id)}, + ) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: BranchUpdate, *, actor: CurrentUser | None = None) -> Branch: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, entity_type="branch", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.BRANCH_UPDATED, + aggregate_type="branch", aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Branch: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("شعبه یافت نشد", error_code="branch_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Branch: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, entity_type="branch", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class SportService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportRepository(session) + + async def create(self, tenant_id: UUID, body: SportCreate, *, actor: CurrentUser | None = None) -> Sport: + forbid_sport_specific_hardcoding(body.model_dump()) + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد ورزش تکراری است", status_code=409, error_code="sport_code_exists") + entity = Sport( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + code=code, + name=validate_non_empty(body.name, field="name"), + description=body.description, + status=validate_lifecycle_status(body.status), + category=body.category, + attributes=body.attributes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, entity_type="sport", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, message=f"Sport {entity.code} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.SPORT_CREATED, + aggregate_type="sport", aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code}, + ) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportUpdate, *, actor: CurrentUser | None = None) -> Sport: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: + data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, entity_type="sport", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Sport: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ورزش یافت نشد", error_code="sport_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Sport: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, entity_type="sport", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity +class MembershipTypeService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MembershipTypeRepository(session) + + async def _validate_catalog_refs(self, tenant_id: UUID, data: dict) -> None: + from app.repositories.catalog import ( + AgeGroupRepository, + ExpirationPolicyRepository, + FreezingRuleRepository, + MembershipPackageRepository, + MembershipPlanRepository, + PricingModelRepository, + RenewalPolicyRepository, + SportCategoryRepository, + ) + + checks = ( + ("package_id", MembershipPackageRepository(self.session), "membership_package_not_found", "بسته عضویت یافت نشد"), + ("plan_id", MembershipPlanRepository(self.session), "membership_plan_not_found", "طرح عضویت یافت نشد"), + ("pricing_model_id", PricingModelRepository(self.session), "pricing_model_not_found", "مدل قیمت‌گذاری یافت نشد"), + ("age_group_id", AgeGroupRepository(self.session), "age_group_not_found", "گروه سنی یافت نشد"), + ("sport_category_id", SportCategoryRepository(self.session), "sport_category_not_found", "دسته ورزشی یافت نشد"), + ("renewal_policy_id", RenewalPolicyRepository(self.session), "renewal_policy_not_found", "سیاست تمدید یافت نشد"), + ("freezing_rule_id", FreezingRuleRepository(self.session), "freezing_rule_not_found", "قانون فریز یافت نشد"), + ("expiration_policy_id", ExpirationPolicyRepository(self.session), "expiration_policy_not_found", "سیاست انقضا یافت نشد"), + ) + for key, repo, error_code, message in checks: + ref = data.get(key) + if ref is None: + continue + entity = await repo.get(tenant_id, ref) + if entity is None: + raise NotFoundError(message, error_code=error_code) + + async def create(self, tenant_id: UUID, body: MembershipTypeCreate, *, actor: CurrentUser | None = None) -> MembershipType: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد نوع عضویت تکراری است", status_code=409, error_code="membership_type_code_exists") + payload = body.model_dump() + await self._validate_catalog_refs(tenant_id, payload) + entity = MembershipType( + tenant_id=tenant_id, sports_center_id=body.sports_center_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), + duration_days=validate_non_negative_int(body.duration_days, field="duration_days"), + policies=body.policies, sport_ids=body.sport_ids, + package_id=body.package_id, plan_id=body.plan_id, pricing_model_id=body.pricing_model_id, + age_group_id=body.age_group_id, sport_category_id=body.sport_category_id, + renewal_policy_id=body.renewal_policy_id, freezing_rule_id=body.freezing_rule_id, + expiration_policy_id=body.expiration_policy_id, + is_transferable=body.is_transferable, sort_order=body.sort_order, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="membership_type", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, message=f"Membership type {entity.code} created") + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.MEMBERSHIP_TYPE_CREATED, aggregate_type="membership_type", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: MembershipTypeUpdate, *, actor: CurrentUser | None = None) -> MembershipType: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + if "duration_days" in data: data["duration_days"] = validate_non_negative_int(data["duration_days"], field="duration_days") + await self._validate_catalog_refs(tenant_id, data) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="membership_type", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.MEMBERSHIP_TYPE_UPDATED, aggregate_type="membership_type", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MembershipType: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("نوع عضویت یافت نشد", error_code="membership_type_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> MembershipType: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="membership_type", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class MembershipService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = MembershipRepository(session) + self.types = MembershipTypeRepository(session) + from app.repositories.members import MemberRepository + + self.members = MemberRepository(session) + + async def _resolve_member(self, tenant_id: UUID, member_id: UUID | None, sports_center_id: UUID): + if member_id is None: + return None + member = await self.members.get(tenant_id, member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + if member.sports_center_id != sports_center_id: + raise AppError( + "عضو متعلق به این مرکز نیست", + status_code=422, + error_code="member_center_mismatch", + ) + return member + + async def create(self, tenant_id: UUID, body: MembershipCreate, *, actor: CurrentUser | None = None) -> Membership: + from app.validators.members import assert_membership_transition # noqa: F401 + + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + mtype = await self.types.get(tenant_id, body.membership_type_id) + if mtype is None or mtype.sports_center_id != body.sports_center_id: + raise NotFoundError("نوع عضویت یافت نشد", error_code="membership_type_not_found") + member = await self._resolve_member(tenant_id, body.member_id, body.sports_center_id) + starts_on, ends_on = validate_date_range(body.starts_on, body.ends_on) + membership_number = validate_code(body.membership_number, field="membership_number") if body.membership_number else _next_membership_number() + if await self.repo.get_by_membership_number(tenant_id, membership_number): + raise AppError("شماره عضویت تکراری است", status_code=409, error_code="membership_number_exists") + display_name = validate_non_empty(body.display_name, field="display_name") + email = validate_email(body.email) + mobile = validate_phone(body.mobile, field="mobile") + if member is not None: + display_name = display_name or member.display_name + email = email if body.email is not None else member.email + mobile = mobile if body.mobile is not None else member.mobile + entity = Membership( + tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, + membership_type_id=body.membership_type_id, member_id=body.member_id, + membership_number=membership_number, + display_name=display_name, + email=email, mobile=mobile, + external_customer_ref=body.external_customer_ref, external_crm_contact_ref=body.external_crm_contact_ref, + status=validate_membership_status(body.status), starts_on=starts_on, ends_on=ends_on, + profile=body.profile, custom_fields=body.custom_fields, created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="membership", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, message=f"Membership {entity.membership_number} created") + await self.session.commit(); await self.session.refresh(entity) + # Legacy shell without Member: keep MEMBER_CREATED for backward compatibility. + if member is None: + self.publisher.publish(event_type=SportsCenterEventType.MEMBER_CREATED, aggregate_type="member", + aggregate_id=entity.id, tenant_id=tenant_id, + payload={"membership_number": entity.membership_number, "display_name": entity.display_name}) + self.publisher.publish(event_type=SportsCenterEventType.MEMBERSHIP_CREATED, aggregate_type="membership", + aggregate_id=entity.id, tenant_id=tenant_id, + payload={"membership_number": entity.membership_number, "status": entity.status.value, + "member_id": str(entity.member_id) if entity.member_id else None}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: MembershipUpdate, *, actor: CurrentUser | None = None) -> Membership: + from app.validators.members import assert_membership_transition, validate_freeze_window + + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "member_id" in data: + await self._resolve_member(tenant_id, data["member_id"], entity.sports_center_id) + if "display_name" in data and data["display_name"] is not None: data["display_name"] = validate_non_empty(data["display_name"], field="display_name") + if "email" in data: data["email"] = validate_email(data["email"]) + if "mobile" in data: data["mobile"] = validate_phone(data["mobile"], field="mobile") + if "status" in data and data["status"] is not None: + new_status = validate_membership_status(data["status"]) + assert_membership_transition(entity.status, new_status) + data["status"] = new_status + if "starts_on" in data or "ends_on" in data: + validate_date_range(data.get("starts_on", entity.starts_on), data.get("ends_on", entity.ends_on)) + if "freeze_starts_on" in data or "freeze_ends_on" in data: + validate_freeze_window( + data.get("freeze_starts_on", entity.freeze_starts_on), + data.get("freeze_ends_on", entity.freeze_ends_on), + ) + _apply_update(entity, data); entity.version += 1; entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="membership", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.MEMBERSHIP_UPDATED, aggregate_type="membership", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"status": entity.status.value}) + return entity + + async def assign_member( + self, + tenant_id: UUID, + entity_id: UUID, + member_id: UUID, + *, + version: int | None = None, + actor: CurrentUser | None = None, + ) -> Membership: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, version) + member = await self._resolve_member(tenant_id, member_id, entity.sports_center_id) + assert member is not None + entity.member_id = member.id + entity.display_name = member.display_name + entity.email = member.email + entity.mobile = member.mobile + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership", + entity_id=entity.id, + action=AuditAction.ASSIGN, + actor_user_id=entity.updated_by, + message=f"assigned member {member.member_number}", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.MEMBERSHIP_ASSIGNED, + aggregate_type="membership", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(member.id)}, + ) + return entity + + async def change_status( + self, + tenant_id: UUID, + entity_id: UUID, + target, + *, + freeze_starts_on=None, + freeze_ends_on=None, + version: int | None = None, + actor: CurrentUser | None = None, + ) -> Membership: + from app.models.types import MembershipStatus + from app.validators.members import assert_membership_transition, validate_freeze_window + + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, version) + previous = entity.status + target_status = validate_membership_status(target) + assert_membership_transition(previous, target_status) + if target_status == MembershipStatus.FROZEN: + starts, ends = validate_freeze_window(freeze_starts_on or date.today(), freeze_ends_on) + entity.freeze_starts_on = starts + entity.freeze_ends_on = ends + if target_status == MembershipStatus.ACTIVE and previous == MembershipStatus.FROZEN: + entity.freeze_starts_on = None + entity.freeze_ends_on = None + entity.status = target_status + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=entity.updated_by, + message=f"status -> {target_status.value}", + ) + await self.session.commit() + await self.session.refresh(entity) + event = SportsCenterEventType.MEMBERSHIP_STATUS_CHANGED + if target_status == MembershipStatus.FROZEN: + event = SportsCenterEventType.MEMBERSHIP_FROZEN + elif previous == MembershipStatus.FROZEN and target_status == MembershipStatus.ACTIVE: + event = SportsCenterEventType.MEMBERSHIP_UNFROZEN + self.publisher.publish( + event_type=event, + aggregate_type="membership", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value, "previous_status": previous.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Membership: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("عضویت یافت نشد", error_code="membership_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Membership: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="membership", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class CoachService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = CoachRepository(session) + + async def create(self, tenant_id: UUID, body: CoachCreate, *, actor: CurrentUser | None = None) -> Coach: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد مربی تکراری است", status_code=409, error_code="coach_code_exists") + entity = Coach( + tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, code=code, + display_name=validate_non_empty(body.display_name, field="display_name"), + email=validate_email(body.email), mobile=validate_phone(body.mobile, field="mobile"), + status=validate_lifecycle_status(body.status), sport_ids=body.sport_ids, + qualifications=body.qualifications, external_user_ref=body.external_user_ref, + created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="coach", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, message=f"Coach {entity.code} created") + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.COACH_CREATED, aggregate_type="coach", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: CoachUpdate, *, actor: CurrentUser | None = None) -> Coach: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "display_name" in data and data["display_name"] is not None: data["display_name"] = validate_non_empty(data["display_name"], field="display_name") + if "email" in data: data["email"] = validate_email(data["email"]) + if "mobile" in data: data["mobile"] = validate_phone(data["mobile"], field="mobile") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="coach", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.COACH_UPDATED, aggregate_type="coach", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Coach: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("مربی یافت نشد", error_code="coach_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Coach: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="coach", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + +class SportsRoleService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportsRoleRepository(session) + + async def create(self, tenant_id: UUID, body: SportsRoleCreate, *, actor: CurrentUser | None = None) -> SportsRole: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد نقش تکراری است", status_code=409, error_code="sports_role_code_exists") + entity = SportsRole(tenant_id=tenant_id, sports_center_id=body.sports_center_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), permission_codes=body.permission_codes, + created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_role", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportsRoleUpdate, *, actor: CurrentUser | None = None) -> SportsRole: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="sports_role", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> SportsRole: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("نقش یافت نشد", error_code="sports_role_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> SportsRole: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_role", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class SportsPermissionService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportsPermissionRepository(session) + + async def create(self, tenant_id: UUID, body: SportsPermissionCreate, *, actor: CurrentUser | None = None) -> SportsPermission: + actor_id = actor.user_id if actor else None + code = validate_non_empty(body.code, field="code").lower() + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد مجوز تکراری است", status_code=409, error_code="sports_permission_code_exists") + entity = SportsPermission(tenant_id=tenant_id, sports_center_id=body.sports_center_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + status=validate_lifecycle_status(body.status), created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_permission", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportsPermissionUpdate, *, actor: CurrentUser | None = None) -> SportsPermission: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="sports_permission", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> SportsPermission: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("مجوز یافت نشد", error_code="sports_permission_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> SportsPermission: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_permission", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class FacilityService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = FacilityRepository(session) + + async def create(self, tenant_id: UUID, body: FacilityCreate, *, actor: CurrentUser | None = None) -> Facility: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد تسهیلات تکراری است", status_code=409, error_code="facility_code_exists") + entity = Facility(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, code=code, + name=validate_non_empty(body.name, field="name"), description=body.description, + kind=validate_facility_kind(body.kind), status=validate_lifecycle_status(body.status), + capacity=validate_non_negative_int(body.capacity, field="capacity"), + attributes=body.attributes, created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="facility", entity_id=entity.id, + action=AuditAction.CREATE, actor_user_id=actor_id, message=f"Facility {entity.code} created") + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.FACILITY_CREATED, aggregate_type="facility", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code, "kind": entity.kind.value}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: FacilityUpdate, *, actor: CurrentUser | None = None) -> Facility: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + if "kind" in data and data["kind"] is not None: data["kind"] = validate_facility_kind(data["kind"]) + if "capacity" in data: data["capacity"] = validate_non_negative_int(data["capacity"], field="capacity") + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="facility", entity_id=entity.id, + action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.FACILITY_UPDATED, aggregate_type="facility", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Facility: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("تسهیلات یافت نشد", error_code="facility_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Facility: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="facility", entity_id=entity.id, + action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +def _make_simple_coded_service(name, model_cls, repo_cls, create_cls, update_cls, entity_type, not_found_code, not_found_msg, code_exists_code, code_exists_msg, event_created=None): + """Factory kept unused — services defined explicitly below for clarity.""" + return None + + +class CourtService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = CourtRepository(session) + + async def create(self, tenant_id: UUID, body: CourtCreate, *, actor: CurrentUser | None = None) -> Court: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد زمین تکراری است", status_code=409, error_code="court_code_exists") + entity = Court(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, + facility_id=body.facility_id, code=code, name=validate_non_empty(body.name, field="name"), + status=validate_lifecycle_status(body.status), surface=body.surface, attributes=body.attributes, + created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="court", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: CourtUpdate, *, actor: CurrentUser | None = None) -> Court: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="court", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Court: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("زمین یافت نشد", error_code="court_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Court: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="court", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class RoomService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = RoomRepository(session) + + async def create(self, tenant_id: UUID, body: RoomCreate, *, actor: CurrentUser | None = None) -> Room: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد اتاق تکراری است", status_code=409, error_code="room_code_exists") + entity = Room(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, + facility_id=body.facility_id, code=code, name=validate_non_empty(body.name, field="name"), + status=validate_lifecycle_status(body.status), + capacity=validate_non_negative_int(body.capacity, field="capacity"), attributes=body.attributes, + created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="room", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: RoomUpdate, *, actor: CurrentUser | None = None) -> Room: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + if "capacity" in data: data["capacity"] = validate_non_negative_int(data["capacity"], field="capacity") + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="room", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Room: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("اتاق یافت نشد", error_code="room_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Room: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="room", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class LockerRoomService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = LockerRoomRepository(session) + + async def create(self, tenant_id: UUID, body: LockerRoomCreate, *, actor: CurrentUser | None = None) -> LockerRoom: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد رختکن تکراری است", status_code=409, error_code="locker_room_code_exists") + entity = LockerRoom(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, code=code, + name=validate_non_empty(body.name, field="name"), status=validate_lifecycle_status(body.status), + attributes=body.attributes, created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="locker_room", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: LockerRoomUpdate, *, actor: CurrentUser | None = None) -> LockerRoom: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="locker_room", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> LockerRoom: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("رختکن یافت نشد", error_code="locker_room_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> LockerRoom: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="locker_room", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + +class LockerService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = LockerRepository(session) + self.locker_rooms = LockerRoomRepository(session) + self.memberships = MembershipRepository(session) + + async def create(self, tenant_id: UUID, body: LockerCreate, *, actor: CurrentUser | None = None) -> Locker: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + lr = await self.locker_rooms.get(tenant_id, body.locker_room_id) + if lr is None or lr.sports_center_id != body.sports_center_id: + raise NotFoundError("رختکن یافت نشد", error_code="locker_room_not_found") + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.locker_room_id, code): + raise AppError("کد کمد تکراری است", status_code=409, error_code="locker_code_exists") + entity = Locker(tenant_id=tenant_id, sports_center_id=body.sports_center_id, locker_room_id=body.locker_room_id, + code=code, label=body.label, status=validate_locker_status(body.status), attributes=body.attributes, + created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="locker", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: LockerUpdate, *, actor: CurrentUser | None = None) -> Locker: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "status" in data and data["status"] is not None: data["status"] = validate_locker_status(data["status"]) + _apply_update(entity, data); entity.version += 1; entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="locker", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def assign(self, tenant_id: UUID, entity_id: UUID, body: LockerAssignRequest, *, actor: CurrentUser | None = None) -> Locker: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + membership = await self.memberships.get(tenant_id, body.membership_id) + if membership is None: + raise NotFoundError("عضویت یافت نشد", error_code="membership_not_found") + if entity.status == LockerStatus.ASSIGNED and entity.assigned_membership_id: + raise AppError("کمد قبلاً تخصیص داده شده است", status_code=409, error_code="locker_already_assigned") + entity.status = LockerStatus.ASSIGNED + entity.assigned_membership_id = body.membership_id + entity.assigned_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="locker", entity_id=entity.id, action=AuditAction.ASSIGN, + actor_user_id=entity.updated_by, message=f"Locker {entity.code} assigned") + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.LOCKER_ASSIGNED, aggregate_type="locker", + aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code, "membership_id": str(body.membership_id)}) + return entity + + async def release(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None, version: int | None = None) -> Locker: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, version) + prev = entity.assigned_membership_id + entity.status = LockerStatus.AVAILABLE + entity.assigned_membership_id = None + entity.assigned_at = None + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="locker", entity_id=entity.id, action=AuditAction.RELEASE, + actor_user_id=entity.updated_by, message=f"Locker {entity.code} released") + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.LOCKER_RELEASED, aggregate_type="locker", + aggregate_id=entity.id, tenant_id=tenant_id, + payload={"code": entity.code, "previous_membership_id": str(prev) if prev else None}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Locker: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("کمد یافت نشد", error_code="locker_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Locker: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="locker", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class DeviceProviderService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = DeviceProviderRepository(session) + + async def create(self, tenant_id: UUID, body: DeviceProviderCreate, *, actor: CurrentUser | None = None) -> DeviceProvider: + actor_id = actor.user_id if actor else None + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError("کد ارائه‌دهنده دستگاه تکراری است", status_code=409, error_code="device_provider_code_exists") + entity = DeviceProvider(tenant_id=tenant_id, code=code, name=validate_non_empty(body.name, field="name"), + capability=validate_capability(body.capability), + adapter_key=validate_non_empty(body.adapter_key, field="adapter_key"), + status=validate_lifecycle_status(body.status), config_schema=body.config_schema, + settings=body.settings, created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="device_provider", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.DEVICE_PROVIDER_CREATED, aggregate_type="device_provider", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code, "adapter_key": entity.adapter_key}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: DeviceProviderUpdate, *, actor: CurrentUser | None = None) -> DeviceProvider: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "capability" in data and data["capability"] is not None: data["capability"] = validate_capability(data["capability"]) + if "adapter_key" in data and data["adapter_key"] is not None: data["adapter_key"] = validate_non_empty(data["adapter_key"], field="adapter_key") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="device_provider", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DeviceProvider: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("ارائه‌دهنده دستگاه یافت نشد", error_code="device_provider_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> DeviceProvider: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="device_provider", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class DeviceService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = DeviceRepository(session) + + async def create(self, tenant_id: UUID, body: DeviceCreate, *, actor: CurrentUser | None = None) -> Device: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد دستگاه تکراری است", status_code=409, error_code="device_code_exists") + entity = Device(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, + device_provider_id=body.device_provider_id, code=code, + name=validate_non_empty(body.name, field="name"), + capability=validate_capability(body.capability), + status=validate_device_status(body.status), location_ref=body.location_ref, + external_device_ref=body.external_device_ref, settings=body.settings, + created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="device", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.DEVICE_CREATED, aggregate_type="device", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: DeviceUpdate, *, actor: CurrentUser | None = None) -> Device: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "capability" in data and data["capability"] is not None: data["capability"] = validate_capability(data["capability"]) + if "status" in data and data["status"] is not None: data["status"] = validate_device_status(data["status"]) + _apply_update(entity, data); entity.version += 1; entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="device", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.DEVICE_UPDATED, aggregate_type="device", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def connect(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Device: + entity = await self.get(tenant_id, entity_id) + entity.status = DeviceStatus.CONNECTED + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="device", entity_id=entity.id, action=AuditAction.CONNECT, actor_user_id=entity.updated_by) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.DEVICE_CONNECTED, aggregate_type="device", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code, "status": entity.status.value}) + return entity + + async def disconnect(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Device: + entity = await self.get(tenant_id, entity_id) + entity.status = DeviceStatus.DISCONNECTED + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="device", entity_id=entity.id, action=AuditAction.DISCONNECT, actor_user_id=entity.updated_by) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.DEVICE_DISCONNECTED, aggregate_type="device", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code, "status": entity.status.value}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Device: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("دستگاه یافت نشد", error_code="device_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> Device: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="device", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class AttendanceGatewayService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = AttendanceGatewayRepository(session) + + async def create(self, tenant_id: UUID, body: AttendanceGatewayCreate, *, actor: CurrentUser | None = None) -> AttendanceGateway: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد درگاه حضور تکراری است", status_code=409, error_code="attendance_gateway_code_exists") + entity = AttendanceGateway(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, code=code, + name=validate_non_empty(body.name, field="name"), status=validate_lifecycle_status(body.status), + primary_provider_id=body.primary_provider_id, fallback_provider_id=body.fallback_provider_id, + device_ids=body.device_ids, policies=body.policies, created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="attendance_gateway", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.ATTENDANCE_GATEWAY_CREATED, aggregate_type="attendance_gateway", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: AttendanceGatewayUpdate, *, actor: CurrentUser | None = None) -> AttendanceGateway: + entity = await self.get(tenant_id, entity_id) + previous_provider = entity.primary_provider_id + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: data["name"] = validate_non_empty(data["name"], field="name") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="attendance_gateway", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.ATTENDANCE_GATEWAY_UPDATED, aggregate_type="attendance_gateway", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + if "primary_provider_id" in data and data["primary_provider_id"] != previous_provider: + self.publisher.publish(event_type=SportsCenterEventType.ATTENDANCE_PROVIDER_CHANGED, aggregate_type="attendance_gateway", + aggregate_id=entity.id, tenant_id=tenant_id, + payload={"previous_provider_id": str(previous_provider) if previous_provider else None, + "primary_provider_id": str(entity.primary_provider_id) if entity.primary_provider_id else None}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> AttendanceGateway: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("درگاه حضور یافت نشد", error_code="attendance_gateway_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> AttendanceGateway: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="attendance_gateway", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class SportsConfigurationService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportsConfigurationRepository(session) + + async def create(self, tenant_id: UUID, body: SportsConfigurationCreate, *, actor: CurrentUser | None = None) -> SportsConfiguration: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد پیکربندی تکراری است", status_code=409, error_code="configuration_code_exists") + entity = SportsConfiguration( + tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, code=code, + working_hours=body.working_hours, membership_policies=body.membership_policies, + attendance_policies=body.attendance_policies, booking_policies=body.booking_policies, + waiting_list=body.waiting_list, custom_fields=body.custom_fields, + timezone=validate_non_empty(body.timezone, field="timezone"), + language=validate_non_empty(body.language, field="language"), + currency_code=validate_currency_code(body.currency_code), created_by=actor_id, updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_configuration", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.CONFIGURATION_CREATED, aggregate_type="sports_configuration", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportsConfigurationUpdate, *, actor: CurrentUser | None = None) -> SportsConfiguration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "timezone" in data and data["timezone"] is not None: data["timezone"] = validate_non_empty(data["timezone"], field="timezone") + if "language" in data and data["language"] is not None: data["language"] = validate_non_empty(data["language"], field="language") + if "currency_code" in data and data["currency_code"] is not None: data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data); entity.version += 1; entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="sports_configuration", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.CONFIGURATION_UPDATED, aggregate_type="sports_configuration", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> SportsConfiguration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("پیکربندی یافت نشد", error_code="configuration_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> SportsConfiguration: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_configuration", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class SportsEventService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportsEventRepository(session) + + async def create(self, tenant_id: UUID, body: SportsEventCreate, *, actor: CurrentUser | None = None) -> SportsEvent: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + code = validate_code(body.code) + if await self.repo.get_by_code(tenant_id, body.sports_center_id, code): + raise AppError("کد رویداد تکراری است", status_code=409, error_code="sports_event_code_exists") + entity = SportsEvent(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, + sport_id=body.sport_id, facility_id=body.facility_id, coach_id=body.coach_id, code=code, + title=validate_non_empty(body.title, field="title"), description=body.description, + status=validate_lifecycle_status(body.status), starts_at=body.starts_at, ends_at=body.ends_at, + capacity=validate_non_negative_int(body.capacity, field="capacity"), attributes=body.attributes, + created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_event", entity_id=entity.id, action=AuditAction.CREATE, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.SPORTS_EVENT_CREATED, aggregate_type="sports_event", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"code": entity.code}) + return entity + + async def update(self, tenant_id: UUID, entity_id: UUID, body: SportsEventUpdate, *, actor: CurrentUser | None = None) -> SportsEvent: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "title" in data and data["title"] is not None: data["title"] = validate_non_empty(data["title"], field="title") + if "status" in data and data["status"] is not None: data["status"] = validate_lifecycle_status(data["status"]) + if "capacity" in data: data["capacity"] = validate_non_negative_int(data["capacity"], field="capacity") + _apply_update(entity, data); entity.updated_by = actor.user_id if actor else None + await self.audit.record(tenant_id=tenant_id, entity_type="sports_event", entity_id=entity.id, action=AuditAction.UPDATE, actor_user_id=entity.updated_by, changes=data) + await self.session.commit(); await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> SportsEvent: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("رویداد ورزشی یافت نشد", error_code="sports_event_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> SportsEvent: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_event", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity + + +class SportsSettingService(_BaseService): + def __init__(self, session: AsyncSession, publisher: InMemoryEventPublisher | None = None) -> None: + super().__init__(session, publisher) + self.repo = SportsSettingRepository(session) + + async def upsert(self, tenant_id: UUID, body: SportsSettingUpsert, *, actor: CurrentUser | None = None) -> SportsSetting: + actor_id = actor.user_id if actor else None + key = validate_setting_key(body.key) + existing = await self.repo.get_by_key(tenant_id, key, sports_center_id=body.sports_center_id, branch_id=body.branch_id) + if existing is None: + entity = SportsSetting(tenant_id=tenant_id, sports_center_id=body.sports_center_id, branch_id=body.branch_id, + key=key, value=body.value, description=body.description, created_by=actor_id, updated_by=actor_id) + await self.repo.add(entity) + action = AuditAction.CREATE + else: + entity = existing + entity.value = body.value + entity.description = body.description + entity.updated_by = actor_id + action = AuditAction.UPDATE + await self.audit.record(tenant_id=tenant_id, entity_type="sports_setting", entity_id=entity.id, action=action, actor_user_id=actor_id) + await self.session.commit(); await self.session.refresh(entity) + self.publisher.publish(event_type=SportsCenterEventType.SETTING_UPSERTED, aggregate_type="sports_setting", + aggregate_id=entity.id, tenant_id=tenant_id, payload={"key": entity.key}) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> SportsSetting: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: raise NotFoundError("تنظیمات یافت نشد", error_code="setting_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete(self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None) -> SportsSetting: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record(tenant_id=tenant_id, entity_type="sports_setting", entity_id=entity.id, action=AuditAction.DELETE, actor_user_id=actor.user_id if actor else None) + await self.session.commit(); await self.session.refresh(entity) + return entity diff --git a/backend/services/sports_center/app/services/members.py b/backend/services/sports_center/app/services/members.py new file mode 100644 index 0000000..bde94d1 --- /dev/null +++ b/backend/services/sports_center/app/services/members.py @@ -0,0 +1,1145 @@ +"""Member Management application services — Phase 9.2.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import InMemoryEventPublisher, get_event_publisher +from app.events.types import SportsCenterEventType +from app.models.members import ( + DigitalMembership, + EmergencyContact, + FamilyMember, + MedicalInformation, + Member, + MemberDocument, + MembershipCard, + Waiver, +) +from app.models.types import AuditAction, CardKind, CardStatus, WaiverStatus +from app.repositories.foundation import MembershipRepository, SportsCenterRepository +from app.repositories.members import ( + DigitalMembershipRepository, + EmergencyContactRepository, + FamilyMemberRepository, + MedicalInformationRepository, + MemberDocumentRepository, + MemberRepository, + MembershipCardRepository, + WaiverRepository, +) +from app.schemas.members import ( + DigitalMembershipCreate, + DigitalMembershipUpdate, + EmergencyContactCreate, + EmergencyContactUpdate, + FamilyMemberCreate, + FamilyMemberUpdate, + MedicalInformationUpsert, + MemberCreate, + MemberDocumentCreate, + MemberDocumentUpdate, + MembershipCardCreate, + MembershipCardUpdate, + MemberUpdate, + WaiverCreate, + WaiverSignRequest, + WaiverUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + validate_code, + validate_email, + validate_non_empty, + validate_non_negative_int, + validate_phone, +) +from app.validators.members import ( + validate_card_kind, + validate_card_status, + validate_document_kind, + validate_family_relationship, + validate_file_ref, + validate_member_status, + validate_waiver_status, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _next_member_number() -> str: + return f"MB-{uuid4().hex[:10].upper()}" + + +def _next_card_number() -> str: + return f"MC-{uuid4().hex[:12].upper()}" + + +def _next_pass_code() -> str: + return f"DP-{uuid4().hex[:12].upper()}" + + +class _MemberBase: + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + self.session = session + self.audit = AuditService(session) + self.publisher = publisher or get_event_publisher() + self.centers = SportsCenterRepository(session) + self.members = MemberRepository(session) + + async def _require_center(self, tenant_id: UUID, sports_center_id: UUID): + center = await self.centers.get(tenant_id, sports_center_id) + if center is None: + raise NotFoundError("مرکز ورزشی یافت نشد", error_code="sports_center_not_found") + return center + + async def _require_member(self, tenant_id: UUID, member_id: UUID) -> Member: + member = await self.members.get(tenant_id, member_id) + if member is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + return member + + +class MemberService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = self.members + + async def create( + self, tenant_id: UUID, body: MemberCreate, *, actor: CurrentUser | None = None + ) -> Member: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + member_number = ( + validate_code(body.member_number, field="member_number") + if body.member_number + else _next_member_number() + ) + if await self.repo.get_by_member_number(tenant_id, member_number): + raise AppError( + "شماره عضو تکراری است", + status_code=409, + error_code="member_number_exists", + ) + entity = Member( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + branch_id=body.branch_id, + member_number=member_number, + display_name=validate_non_empty(body.display_name, field="display_name"), + first_name=body.first_name, + last_name=body.last_name, + email=validate_email(body.email), + mobile=validate_phone(body.mobile, field="mobile"), + birth_date=body.birth_date, + gender=body.gender, + status=validate_member_status(body.status), + external_user_ref=body.external_user_ref, + external_customer_ref=body.external_customer_ref, + external_crm_contact_ref=body.external_crm_contact_ref, + preferred_language=body.preferred_language, + notes=body.notes, + profile=body.profile, + custom_fields=body.custom_fields, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + message=f"Member {entity.member_number} created", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.MEMBER_CREATED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "member_number": entity.member_number, + "display_name": entity.display_name, + "status": entity.status.value, + }, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: MemberUpdate, + *, + actor: CurrentUser | None = None, + ) -> Member: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity.version, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = validate_non_empty( + data["display_name"], field="display_name" + ) + if "email" in data: + data["email"] = validate_email(data["email"]) + if "mobile" in data: + data["mobile"] = validate_phone(data["mobile"], field="mobile") + if "status" in data and data["status"] is not None: + data["status"] = validate_member_status(data["status"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.MEMBER_UPDATED, + aggregate_type="member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Member: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("عضو یافت نشد", error_code="member_not_found") + return entity + + async def list(self, tenant_id: UUID, *, offset: int, limit: int): + return await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit) + + async def soft_delete( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> Member: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class FamilyMemberService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = FamilyMemberRepository(session) + + async def create( + self, + tenant_id: UUID, + body: FamilyMemberCreate, + *, + actor: CurrentUser | None = None, + ) -> FamilyMember: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + member = await self._require_member(tenant_id, body.member_id) + if member.sports_center_id != body.sports_center_id: + raise AppError( + "عضو متعلق به این مرکز نیست", + status_code=422, + error_code="member_center_mismatch", + ) + if body.related_member_id: + await self._require_member(tenant_id, body.related_member_id) + entity = FamilyMember( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + related_member_id=body.related_member_id, + display_name=validate_non_empty(body.display_name, field="display_name"), + relationship=validate_family_relationship(body.relationship), + email=validate_email(body.email), + mobile=validate_phone(body.mobile, field="mobile"), + birth_date=body.birth_date, + is_emergency_eligible=body.is_emergency_eligible, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="family_member", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.FAMILY_MEMBER_CREATED, + aggregate_type="family_member", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id)}, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: FamilyMemberUpdate, + *, + actor: CurrentUser | None = None, + ) -> FamilyMember: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = validate_non_empty( + data["display_name"], field="display_name" + ) + if "email" in data: + data["email"] = validate_email(data["email"]) + if "mobile" in data: + data["mobile"] = validate_phone(data["mobile"], field="mobile") + if "relationship" in data and data["relationship"] is not None: + data["relationship"] = validate_family_relationship(data["relationship"]) + if data.get("related_member_id"): + await self._require_member(tenant_id, data["related_member_id"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="family_member", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> FamilyMember: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("عضو خانواده یافت نشد", error_code="family_member_not_found") + return entity + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int, limit: int + ): + await self._require_member(tenant_id, member_id) + return await self.repo.list_by_member( + tenant_id, member_id, offset=offset, limit=limit + ) + + async def soft_delete( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> FamilyMember: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, + entity_type="family_member", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class EmergencyContactService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = EmergencyContactRepository(session) + + async def create( + self, + tenant_id: UUID, + body: EmergencyContactCreate, + *, + actor: CurrentUser | None = None, + ) -> EmergencyContact: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + member = await self._require_member(tenant_id, body.member_id) + if member.sports_center_id != body.sports_center_id: + raise AppError( + "عضو متعلق به این مرکز نیست", + status_code=422, + error_code="member_center_mismatch", + ) + phone = validate_phone(body.phone, field="phone") + if not phone: + raise AppError( + "شماره تماس اضطراری الزامی است", + status_code=422, + error_code="phone_required", + ) + entity = EmergencyContact( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + display_name=validate_non_empty(body.display_name, field="display_name"), + relationship=body.relationship, + phone=phone, + alternate_phone=validate_phone(body.alternate_phone, field="alternate_phone"), + email=validate_email(body.email), + is_primary=body.is_primary, + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="emergency_contact", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.EMERGENCY_CONTACT_CREATED, + aggregate_type="emergency_contact", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id)}, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: EmergencyContactUpdate, + *, + actor: CurrentUser | None = None, + ) -> EmergencyContact: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "display_name" in data and data["display_name"] is not None: + data["display_name"] = validate_non_empty( + data["display_name"], field="display_name" + ) + if "phone" in data: + phone = validate_phone(data["phone"], field="phone") + if not phone: + raise AppError( + "شماره تماس اضطراری الزامی است", + status_code=422, + error_code="phone_required", + ) + data["phone"] = phone + if "alternate_phone" in data: + data["alternate_phone"] = validate_phone( + data["alternate_phone"], field="alternate_phone" + ) + if "email" in data: + data["email"] = validate_email(data["email"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="emergency_contact", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> EmergencyContact: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "تماس اضطراری یافت نشد", error_code="emergency_contact_not_found" + ) + return entity + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int, limit: int + ): + await self._require_member(tenant_id, member_id) + return await self.repo.list_by_member( + tenant_id, member_id, offset=offset, limit=limit + ) + + async def soft_delete( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> EmergencyContact: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, + entity_type="emergency_contact", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class MedicalInformationService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = MedicalInformationRepository(session) + + async def upsert( + self, + tenant_id: UUID, + body: MedicalInformationUpsert, + *, + actor: CurrentUser | None = None, + ) -> MedicalInformation: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + member = await self._require_member(tenant_id, body.member_id) + if member.sports_center_id != body.sports_center_id: + raise AppError( + "عضو متعلق به این مرکز نیست", + status_code=422, + error_code="member_center_mismatch", + ) + file_ref = ( + validate_file_ref(body.document_file_ref, field="document_file_ref") + if body.document_file_ref + else None + ) + existing = await self.repo.get_by_member(tenant_id, body.member_id) + if existing is None: + entity = MedicalInformation( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + blood_type=body.blood_type, + allergies=body.allergies, + conditions=body.conditions, + medications=body.medications, + clearance_status=body.clearance_status, + clearance_expires_on=body.clearance_expires_on, + physician_name=body.physician_name, + physician_phone=validate_phone(body.physician_phone, field="physician_phone"), + notes=body.notes, + document_file_ref=file_ref, + is_confidential=body.is_confidential, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + action = AuditAction.CREATE + event = SportsCenterEventType.MEDICAL_INFORMATION_UPSERTED + else: + entity = existing + entity.blood_type = body.blood_type + entity.allergies = body.allergies + entity.conditions = body.conditions + entity.medications = body.medications + entity.clearance_status = body.clearance_status + entity.clearance_expires_on = body.clearance_expires_on + entity.physician_name = body.physician_name + entity.physician_phone = validate_phone( + body.physician_phone, field="physician_phone" + ) + entity.notes = body.notes + entity.document_file_ref = file_ref + entity.is_confidential = body.is_confidential + entity.updated_by = actor_id + action = AuditAction.UPDATE + event = SportsCenterEventType.MEDICAL_INFORMATION_UPSERTED + await self.audit.record( + tenant_id=tenant_id, + entity_type="medical_information", + entity_id=entity.id, + action=action, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=event, + aggregate_type="medical_information", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id)}, + ) + return entity + + async def get_by_member( + self, tenant_id: UUID, member_id: UUID + ) -> MedicalInformation: + await self._require_member(tenant_id, member_id) + entity = await self.repo.get_by_member(tenant_id, member_id) + if entity is None: + raise NotFoundError( + "اطلاعات پزشکی یافت نشد", error_code="medical_information_not_found" + ) + return entity + + +class MembershipCardService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = MembershipCardRepository(session) + self.memberships = MembershipRepository(session) + + async def create( + self, + tenant_id: UUID, + body: MembershipCardCreate, + *, + actor: CurrentUser | None = None, + ) -> MembershipCard: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + member = await self._require_member(tenant_id, body.member_id) + if member.sports_center_id != body.sports_center_id: + raise AppError( + "عضو متعلق به این مرکز نیست", + status_code=422, + error_code="member_center_mismatch", + ) + if body.membership_id: + membership = await self.memberships.get(tenant_id, body.membership_id) + if membership is None: + raise NotFoundError("عضویت یافت نشد", error_code="membership_not_found") + kind = validate_card_kind(body.kind) + card_number = ( + validate_code(body.card_number, field="card_number") + if body.card_number + else _next_card_number() + ) + if await self.repo.get_by_card_number(tenant_id, card_number): + raise AppError( + "شماره کارت تکراری است", + status_code=409, + error_code="card_number_exists", + ) + issued_on = body.issued_on or date.today() + qr_payload = None + barcode_payload = None + if kind in (CardKind.QR, CardKind.DIGITAL): + qr_payload = f"sc:{tenant_id}:{member.id}:{card_number}" + if kind == CardKind.BARCODE: + barcode_payload = card_number + entity = MembershipCard( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + card_number=card_number, + kind=kind, + status=validate_card_status(body.status), + qr_payload=qr_payload, + barcode_payload=barcode_payload, + issued_on=issued_on, + expires_on=body.expires_on, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership_card", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.MEMBERSHIP_CARD_ISSUED, + aggregate_type="membership_card", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "member_id": str(entity.member_id), + "card_number": entity.card_number, + "kind": entity.kind.value, + }, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: MembershipCardUpdate, + *, + actor: CurrentUser | None = None, + ) -> MembershipCard: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "status" in data and data["status"] is not None: + data["status"] = validate_card_status(data["status"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership_card", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def revoke( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> MembershipCard: + entity = await self.get(tenant_id, entity_id) + entity.status = CardStatus.REVOKED + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="membership_card", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=entity.updated_by, + message="card revoked", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.MEMBERSHIP_CARD_REVOKED, + aggregate_type="membership_card", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"card_number": entity.card_number}, + ) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MembershipCard: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("کارت عضویت یافت نشد", error_code="membership_card_not_found") + return entity + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int, limit: int + ): + await self._require_member(tenant_id, member_id) + return await self.repo.list_by_member( + tenant_id, member_id, offset=offset, limit=limit + ) + + +class DigitalMembershipService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = DigitalMembershipRepository(session) + self.memberships = MembershipRepository(session) + + async def create( + self, + tenant_id: UUID, + body: DigitalMembershipCreate, + *, + actor: CurrentUser | None = None, + ) -> DigitalMembership: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + if body.membership_id: + membership = await self.memberships.get(tenant_id, body.membership_id) + if membership is None: + raise NotFoundError("عضویت یافت نشد", error_code="membership_not_found") + pass_code = ( + validate_code(body.pass_code, field="pass_code") + if body.pass_code + else _next_pass_code() + ) + if await self.repo.get_by_pass_code(tenant_id, pass_code): + raise AppError( + "کد پاس دیجیتال تکراری است", + status_code=409, + error_code="pass_code_exists", + ) + entity = DigitalMembership( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + pass_code=pass_code, + status=validate_card_status(body.status), + token_ref=body.token_ref, + deep_link=body.deep_link, + issued_at=datetime.now(timezone.utc), + expires_at=body.expires_at, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="digital_membership", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.DIGITAL_MEMBERSHIP_ISSUED, + aggregate_type="digital_membership", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id), "pass_code": entity.pass_code}, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: DigitalMembershipUpdate, + *, + actor: CurrentUser | None = None, + ) -> DigitalMembership: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "status" in data and data["status"] is not None: + data["status"] = validate_card_status(data["status"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="digital_membership", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> DigitalMembership: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError( + "عضویت دیجیتال یافت نشد", error_code="digital_membership_not_found" + ) + return entity + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int, limit: int + ): + await self._require_member(tenant_id, member_id) + return await self.repo.list_by_member( + tenant_id, member_id, offset=offset, limit=limit + ) + + +class WaiverService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = WaiverRepository(session) + + async def create( + self, tenant_id: UUID, body: WaiverCreate, *, actor: CurrentUser | None = None + ) -> Waiver: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + entity = Waiver( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + membership_id=body.membership_id, + title=validate_non_empty(body.title, field="title"), + waiver_version=validate_non_empty(body.waiver_version, field="waiver_version"), + status=validate_waiver_status(body.status), + expires_on=body.expires_on, + document_file_ref=( + validate_file_ref(body.document_file_ref) + if body.document_file_ref + else None + ), + notes=body.notes, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="waiver", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.WAIVER_CREATED, + aggregate_type="waiver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id), "title": entity.title}, + ) + return entity + + async def sign( + self, + tenant_id: UUID, + entity_id: UUID, + body: WaiverSignRequest, + *, + actor: CurrentUser | None = None, + ) -> Waiver: + entity = await self.get(tenant_id, entity_id) + if entity.status == WaiverStatus.SIGNED: + raise AppError( + "تعهدنامه قبلاً امضا شده است", + status_code=409, + error_code="waiver_already_signed", + ) + entity.status = WaiverStatus.SIGNED + entity.signed_at = datetime.now(timezone.utc) + if body.signature_ref: + entity.signature_ref = validate_file_ref( + body.signature_ref, field="signature_ref" + ) + if body.document_file_ref: + entity.document_file_ref = validate_file_ref(body.document_file_ref) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="waiver", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=entity.updated_by, + message="waiver signed", + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.WAIVER_SIGNED, + aggregate_type="waiver", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id)}, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: WaiverUpdate, + *, + actor: CurrentUser | None = None, + ) -> Waiver: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "title" in data and data["title"] is not None: + data["title"] = validate_non_empty(data["title"], field="title") + if "status" in data and data["status"] is not None: + data["status"] = validate_waiver_status(data["status"]) + if "document_file_ref" in data and data["document_file_ref"] is not None: + data["document_file_ref"] = validate_file_ref(data["document_file_ref"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="waiver", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> Waiver: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("تعهدنامه یافت نشد", error_code="waiver_not_found") + return entity + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int, limit: int + ): + await self._require_member(tenant_id, member_id) + return await self.repo.list_by_member( + tenant_id, member_id, offset=offset, limit=limit + ) + + +class MemberDocumentService(_MemberBase): + def __init__( + self, + session: AsyncSession, + publisher: InMemoryEventPublisher | None = None, + ) -> None: + super().__init__(session, publisher) + self.repo = MemberDocumentRepository(session) + + async def create( + self, + tenant_id: UUID, + body: MemberDocumentCreate, + *, + actor: CurrentUser | None = None, + ) -> MemberDocument: + actor_id = actor.user_id if actor else None + await self._require_center(tenant_id, body.sports_center_id) + await self._require_member(tenant_id, body.member_id) + size = ( + validate_non_negative_int(body.size_bytes, field="size_bytes") + if body.size_bytes is not None + else None + ) + entity = MemberDocument( + tenant_id=tenant_id, + sports_center_id=body.sports_center_id, + member_id=body.member_id, + title=validate_non_empty(body.title, field="title"), + kind=validate_document_kind(body.kind), + file_ref=validate_file_ref(body.file_ref), + mime_type=body.mime_type, + size_bytes=size, + notes=body.notes, + metadata_json=body.metadata_json, + created_by=actor_id, + updated_by=actor_id, + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member_document", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=actor_id, + ) + await self.session.commit() + await self.session.refresh(entity) + self.publisher.publish( + event_type=SportsCenterEventType.MEMBER_DOCUMENT_ATTACHED, + aggregate_type="member_document", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"member_id": str(entity.member_id), "file_ref": entity.file_ref}, + ) + return entity + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: MemberDocumentUpdate, + *, + actor: CurrentUser | None = None, + ) -> MemberDocument: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "title" in data and data["title"] is not None: + data["title"] = validate_non_empty(data["title"], field="title") + if "kind" in data and data["kind"] is not None: + data["kind"] = validate_document_kind(data["kind"]) + _apply_update(entity, data) + entity.updated_by = actor.user_id if actor else None + await self.audit.record( + tenant_id=tenant_id, + entity_type="member_document", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=entity.updated_by, + changes=data, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> MemberDocument: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("سند عضو یافت نشد", error_code="member_document_not_found") + return entity + + async def list_by_member( + self, tenant_id: UUID, member_id: UUID, *, offset: int, limit: int + ): + await self._require_member(tenant_id, member_id) + return await self.repo.list_by_member( + tenant_id, member_id, offset=offset, limit=limit + ) + + async def soft_delete( + self, tenant_id: UUID, entity_id: UUID, *, actor: CurrentUser | None = None + ) -> MemberDocument: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=actor.user_id if actor else None) + await self.audit.record( + tenant_id=tenant_id, + entity_type="member_document", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=actor.user_id if actor else None, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/sports_center/app/specifications/__init__.py b/backend/services/sports_center/app/specifications/__init__.py new file mode 100644 index 0000000..a6b36d1 --- /dev/null +++ b/backend/services/sports_center/app/specifications/__init__.py @@ -0,0 +1,42 @@ +"""Query specifications — composable filters (foundation only).""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from app.models.types import DeviceStatus, LifecycleStatus, LockerStatus, MembershipStatus + + +@dataclass(frozen=True) +class BySportsCenterSpec: + sports_center_id: UUID + + +@dataclass(frozen=True) +class ByBranchSpec: + branch_id: UUID + + +@dataclass(frozen=True) +class ByLifecycleStatusSpec: + status: LifecycleStatus + + +@dataclass(frozen=True) +class ByMembershipStatusSpec: + status: MembershipStatus + + +@dataclass(frozen=True) +class ByDeviceStatusSpec: + status: DeviceStatus + + +@dataclass(frozen=True) +class ByLockerStatusSpec: + status: LockerStatus + + +@dataclass(frozen=True) +class ByCodeSpec: + code: str diff --git a/backend/services/sports_center/app/tests/conftest.py b/backend/services/sports_center/app/tests/conftest.py new file mode 100644 index 0000000..bb709cc --- /dev/null +++ b/backend/services/sports_center/app/tests/conftest.py @@ -0,0 +1,63 @@ +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["SPORTS_CENTER_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["SPORTS_CENTER_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 +import app.models # noqa: E402, F401 — register foundation + catalog metadata +from app.events.publisher import reset_event_publisher # noqa: E402 +from app.main import app # noqa: E402 + +TENANT_A = uuid.uuid4() +TENANT_B = uuid.uuid4() + + +@pytest_asyncio.fixture +async def db_setup(): + # Function-scoped: avoids pytest-asyncio session/loop teardown hangs on Windows. + 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.fixture(scope="session", autouse=True) +def _dispose_engine_on_session_end(): + yield + import asyncio + + try: + asyncio.run(engine.dispose()) + except RuntimeError: + pass + + +@pytest_asyncio.fixture(autouse=True) +def _reset_events(): + reset_event_publisher() + 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)} diff --git a/backend/services/sports_center/app/tests/test_api.py b/backend/services/sports_center/app/tests/test_api.py new file mode 100644 index 0000000..17b2fe1 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_api.py @@ -0,0 +1,199 @@ +"""Sports Center API foundation flow — Phase 9.0.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +@pytest.mark.asyncio +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["service"] == "sports-center-service" + assert body["status"] == "ok" + assert body["version"] == "0.9.2.0" + + +@pytest.mark.asyncio +async def test_foundation_flow_and_events(client): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "main", "name": "Main Sports Center", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert center.status_code == 201, center.text + center_id = center.json()["id"] + assert center.json()["code"] == "MAIN" + + branch = await client.post( + "/api/v1/branches", + json={"sports_center_id": center_id, "code": "b1", "name": "Branch 1"}, + headers=tenant_headers(TENANT_A), + ) + assert branch.status_code == 201, branch.text + + sport = await client.post( + "/api/v1/sports", + json={"sports_center_id": center_id, "code": "yoga", "name": "Yoga"}, + headers=tenant_headers(TENANT_A), + ) + assert sport.status_code == 201, sport.text + + mtype = await client.post( + "/api/v1/membership-types", + json={"sports_center_id": center_id, "code": "monthly", "name": "Monthly"}, + headers=tenant_headers(TENANT_A), + ) + assert mtype.status_code == 201, mtype.text + mtype_id = mtype.json()["id"] + + membership = await client.post( + "/api/v1/memberships", + json={ + "sports_center_id": center_id, + "membership_type_id": mtype_id, + "display_name": "Ali Rezaei", + "email": "ali@example.com", + "mobile": "+989121111111", + "status": "active", + }, + headers=tenant_headers(TENANT_A), + ) + assert membership.status_code == 201, membership.text + membership_id = membership.json()["id"] + + coach = await client.post( + "/api/v1/coaches", + json={"sports_center_id": center_id, "code": "c1", "display_name": "Coach Sara"}, + headers=tenant_headers(TENANT_A), + ) + assert coach.status_code == 201, coach.text + + facility = await client.post( + "/api/v1/facilities", + json={"sports_center_id": center_id, "code": "studio-1", "name": "Studio 1", "kind": "studio"}, + headers=tenant_headers(TENANT_A), + ) + assert facility.status_code == 201, facility.text + + provider = await client.post( + "/api/v1/device-providers", + json={ + "code": "mock-qr", + "name": "Mock QR", + "capability": "qr", + "adapter_key": "mock.qr", + }, + headers=tenant_headers(TENANT_A), + ) + assert provider.status_code == 201, provider.text + provider_id = provider.json()["id"] + + device = await client.post( + "/api/v1/devices", + json={ + "sports_center_id": center_id, + "code": "gate-1", + "name": "Gate 1", + "capability": "qr", + "device_provider_id": provider_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert device.status_code == 201, device.text + device_id = device.json()["id"] + + connected = await client.post( + f"/api/v1/devices/{device_id}/connect", + headers=tenant_headers(TENANT_A), + ) + assert connected.status_code == 200, connected.text + assert connected.json()["status"] == "connected" + + lr = await client.post( + "/api/v1/locker-rooms", + json={"sports_center_id": center_id, "code": "lr1", "name": "Locker Room 1"}, + headers=tenant_headers(TENANT_A), + ) + assert lr.status_code == 201, lr.text + lr_id = lr.json()["id"] + + locker = await client.post( + "/api/v1/lockers", + json={"sports_center_id": center_id, "locker_room_id": lr_id, "code": "L01"}, + headers=tenant_headers(TENANT_A), + ) + assert locker.status_code == 201, locker.text + locker_id = locker.json()["id"] + + assigned = await client.post( + f"/api/v1/lockers/{locker_id}/assign", + json={"membership_id": membership_id}, + headers=tenant_headers(TENANT_A), + ) + assert assigned.status_code == 200, assigned.text + assert assigned.json()["status"] == "assigned" + + gateway = await client.post( + "/api/v1/attendance-gateways", + json={ + "sports_center_id": center_id, + "code": "ag1", + "name": "Main Gate", + "primary_provider_id": provider_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert gateway.status_code == 201, gateway.text + gateway_id = gateway.json()["id"] + + provider2 = await client.post( + "/api/v1/device-providers", + json={ + "code": "mock-rfid", + "name": "Mock RFID", + "capability": "rfid", + "adapter_key": "mock.rfid", + }, + headers=tenant_headers(TENANT_A), + ) + provider2_id = provider2.json()["id"] + + changed = await client.patch( + f"/api/v1/attendance-gateways/{gateway_id}", + json={"primary_provider_id": provider2_id}, + headers=tenant_headers(TENANT_A), + ) + assert changed.status_code == 200, changed.text + + events = {e.event_type for e in get_event_publisher().published} + assert SportsCenterEventType.MEMBER_CREATED.value in events + assert SportsCenterEventType.MEMBERSHIP_CREATED.value in events + assert SportsCenterEventType.COACH_CREATED.value in events + assert SportsCenterEventType.FACILITY_CREATED.value in events + assert SportsCenterEventType.DEVICE_CONNECTED.value in events + assert SportsCenterEventType.LOCKER_ASSIGNED.value in events + assert SportsCenterEventType.ATTENDANCE_PROVIDER_CHANGED.value in events + + +@pytest.mark.asyncio +async def test_tenant_isolation(client): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "iso", "name": "Iso Center"}, + headers=tenant_headers(TENANT_A), + ) + center_id = center.json()["id"] + + missing = await client.get( + f"/api/v1/sports-centers/{center_id}", + headers=tenant_headers(TENANT_B), + ) + assert missing.status_code == 404 + + no_tenant = await client.get("/api/v1/sports-centers") + assert no_tenant.status_code in (400, 422) diff --git a/backend/services/sports_center/app/tests/test_architecture.py b/backend/services/sports_center/app/tests/test_architecture.py new file mode 100644 index 0000000..60a9701 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_architecture.py @@ -0,0 +1,273 @@ +"""Architecture tests — Sports Center module boundary enforcement.""" +from __future__ import annotations + +import ast +import re +from pathlib import Path + +from app.models import foundation as models + + +FORBIDDEN_IMPORT_PREFIXES = ( + "backend.services.accounting", + "backend.services.crm", + "backend.services.loyalty", + "backend.services.communication", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + +FOUNDATION_MODELS = [ + models.SportsCenter, + models.Branch, + models.Sport, + models.MembershipType, + models.Membership, + models.Coach, + models.SportsRole, + models.SportsPermission, + models.Facility, + models.Court, + models.Room, + models.LockerRoom, + models.Locker, + models.DeviceProvider, + models.Device, + models.AttendanceGateway, + models.SportsConfiguration, + models.SportsEvent, + models.SportsSetting, + models.SportsAuditLog, +] + + +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_foundation_aggregates_are_independent(): + assert len(FOUNDATION_MODELS) == 20 + for model in FOUNDATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_no_sport_specific_hardcoded_engines(): + forbidden = {"sport_engine", "hardcoded_sport_rules", "vendor_sdk"} + for model in FOUNDATION_MODELS: + columns = set(model.__table__.columns.keys()) + assert forbidden.isdisjoint(columns), model.__tablename__ + + +def test_catalog_models_have_no_relationships(): + from app.models import catalog as catalog_models + + models = [ + catalog_models.SportCategory, + catalog_models.AgeGroup, + catalog_models.PricingModel, + catalog_models.MembershipPackage, + catalog_models.MembershipPlan, + catalog_models.MembershipRule, + catalog_models.RenewalPolicy, + catalog_models.FreezingRule, + catalog_models.ExpirationPolicy, + ] + assert len(models) == 9 + for model in models: + assert hasattr(model, "tenant_id") + assert not model.__mapper__.relationships + + +def test_catalog_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "sports_center.membership_plans.manage" in ALL_PERMISSIONS + assert "sports_center.pricing_models.view" in ALL_PERMISSIONS + assert "sports_center.freezing_rules.manage" in ALL_PERMISSIONS + assert "sports_center.audit.view" in ALL_PERMISSIONS + assert "sports_center.view" in ALL_PERMISSIONS + assert "sports_center.sports_centers.create" in ALL_PERMISSIONS + assert "sports_center.memberships.create" 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_member_models_have_no_relationships(): + from app.models import members as member_models + + models = [ + member_models.Member, + member_models.FamilyMember, + member_models.EmergencyContact, + member_models.MedicalInformation, + member_models.MembershipCard, + member_models.DigitalMembership, + member_models.Waiver, + member_models.MemberDocument, + ] + assert len(models) == 8 + for model in models: + assert hasattr(model, "tenant_id") + assert not model.__mapper__.relationships + + +def test_member_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS + + assert "sports_center.members.create" in ALL_PERMISSIONS + assert "sports_center.members.manage" in ALL_PERMISSIONS + assert "sports_center.family_members.manage" in ALL_PERMISSIONS + assert "sports_center.medical.view" in ALL_PERMISSIONS + assert "sports_center.membership_cards.manage" in ALL_PERMISSIONS + assert "sports_center.waivers.manage" in ALL_PERMISSIONS + assert "sports_center.memberships.assign" in ALL_PERMISSIONS + assert "sports_center.memberships.status" in ALL_PERMISSIONS + + +def test_events_defined(): + from app.events.types import SportsCenterEventType + + assert SportsCenterEventType.MEMBER_CREATED.value == "sports_center.member.created" + assert SportsCenterEventType.MEMBERSHIP_CREATED.value == "sports_center.membership.created" + assert SportsCenterEventType.COACH_CREATED.value == "sports_center.coach.created" + assert SportsCenterEventType.FACILITY_CREATED.value == "sports_center.facility.created" + assert SportsCenterEventType.DEVICE_CONNECTED.value == "sports_center.device.connected" + assert SportsCenterEventType.DEVICE_DISCONNECTED.value == "sports_center.device.disconnected" + assert ( + SportsCenterEventType.ATTENDANCE_PROVIDER_CHANGED.value + == "sports_center.attendance.provider.changed" + ) + assert SportsCenterEventType.LOCKER_ASSIGNED.value == "sports_center.locker.assigned" + assert SportsCenterEventType.LOCKER_RELEASED.value == "sports_center.locker.released" + assert SportsCenterEventType.MEMBER_UPDATED.value == "sports_center.member.updated" + assert SportsCenterEventType.MEMBERSHIP_ASSIGNED.value == "sports_center.membership.assigned" + assert SportsCenterEventType.MEMBERSHIP_FROZEN.value == "sports_center.membership.frozen" + assert SportsCenterEventType.WAIVER_SIGNED.value == "sports_center.waiver.signed" + assert SportsCenterEventType.MEMBERSHIP_PLAN_CREATED.value == "sports_center.membership_plan.created" + assert SportsCenterEventType.PRICING_MODEL_CREATED.value == "sports_center.pricing_model.created" + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AccountingProvider, + CRMProvider, + CommunicationProvider, + Customer360Provider, + IdentityProvider, + LoyaltyProvider, + NotificationProvider, + StorageProvider, + ) + + assert AccountingProvider is not None + assert CRMProvider is not None + assert LoyaltyProvider is not None + assert CommunicationProvider is not None + assert NotificationProvider is not None + assert StorageProvider is not None + assert AIProvider is not None + assert IdentityProvider is not None + assert Customer360Provider is not None + + +def test_connector_framework_exists(): + from app.connectors import ( + AttendanceDeviceConnector, + BarcodeConnector, + ConnectorCapability, + ConnectorRegistry, + DeviceConnector, + DoorControllerConnector, + FaceRecognitionConnector, + FingerprintConnector, + PaymentTerminalConnector, + QRConnector, + RFIDConnector, + TurnstileConnector, + get_connector_registry, + ) + + assert ConnectorCapability.QR.value == "qr" + assert ConnectorCapability.RFID.value == "rfid" + assert ConnectorCapability.FACE_RECOGNITION.value == "face_recognition" + assert DeviceConnector is not None + assert QRConnector is not None + assert RFIDConnector is not None + assert BarcodeConnector is not None + assert FingerprintConnector is not None + assert FaceRecognitionConnector is not None + assert TurnstileConnector is not None + assert DoorControllerConnector is not None + assert PaymentTerminalConnector is not None + assert AttendanceDeviceConnector is not None + assert isinstance(get_connector_registry(), ConnectorRegistry) + + +def test_no_forbidden_service_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_api_ownership_is_sports_center_only(): + from app.api.v1 import api_router + + prefixes = {getattr(r, "path", "") for r in api_router.routes} + joined = " ".join(sorted(prefixes)) + assert "/sports-centers" in joined or any("/sports-centers" in p for p in prefixes) + assert "/memberships" in joined or any("/memberships" in p for p in prefixes) + forbidden = ("accounting", "crm", "loyalty", "notification", "automation") + for name in forbidden: + assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name + + +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/connectors", + "app/policies", + "app/specifications", + "app/commands", + "app/queries", + "app/api/v1", + "app/tests", + "alembic/versions", + "README.md", + ] + for rel in required: + assert (root / rel).exists(), f"missing {rel}" diff --git a/backend/services/sports_center/app/tests/test_catalog.py b/backend/services/sports_center/app/tests/test_catalog.py new file mode 100644 index 0000000..d3e277d --- /dev/null +++ b/backend/services/sports_center/app/tests/test_catalog.py @@ -0,0 +1,199 @@ +"""Phase 9.1 Membership Catalog API / tenant / business tests.""" +from __future__ import annotations + +import uuid + +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _create_center(client, tenant_id: uuid.UUID, code: str = "SC-MAIN") -> dict: + resp = await client.post( + "/api/v1/sports-centers", + headers=tenant_headers(tenant_id), + json={"code": code, "name": "Main Center", "status": "active"}, + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_membership_catalog_happy_path(client): + center = await _create_center(client, TENANT_A) + center_id = center["id"] + h = tenant_headers(TENANT_A) + + cat = await client.post( + "/api/v1/sport-categories", + headers=h, + json={"sports_center_id": center_id, "code": "AQUA", "name": "Aquatic"}, + ) + assert cat.status_code == 201, cat.text + + age = await client.post( + "/api/v1/age-groups", + headers=h, + json={"sports_center_id": center_id, "code": "ADULT", "name": "Adult", "min_age": 18, "max_age": 64}, + ) + assert age.status_code == 201, age.text + + price = await client.post( + "/api/v1/pricing-models", + headers=h, + json={ + "sports_center_id": center_id, + "code": "MONTHLY", + "name": "Monthly Fixed", + "kind": "fixed", + "billing_period": "monthly", + "amount": "1200000", + "currency_code": "IRR", + }, + ) + assert price.status_code == 201, price.text + price_id = price.json()["id"] + + pkg = await client.post( + "/api/v1/membership-packages", + headers=h, + json={ + "sports_center_id": center_id, + "code": "GOLD-PKG", + "name": "Gold Package", + "pricing_model_id": price_id, + "session_credits": 12, + }, + ) + assert pkg.status_code == 201, pkg.text + + plan = await client.post( + "/api/v1/membership-plans", + headers=h, + json={ + "sports_center_id": center_id, + "code": "GOLD-PLAN", + "name": "Gold Plan", + "duration_days": 30, + "pricing_model_id": price_id, + "package_id": pkg.json()["id"], + "age_group_id": age.json()["id"], + "sport_category_id": cat.json()["id"], + }, + ) + assert plan.status_code == 201, plan.text + + renew = await client.post( + "/api/v1/renewal-policies", + headers=h, + json={"sports_center_id": center_id, "code": "AUTO30", "name": "Auto renew", "auto_renew": True, "grace_period_days": 3}, + ) + assert renew.status_code == 201, renew.text + + freeze = await client.post( + "/api/v1/freezing-rules", + headers=h, + json={"sports_center_id": center_id, "code": "FREEZE30", "name": "Freeze 30", "max_freeze_days": 30}, + ) + assert freeze.status_code == 201, freeze.text + + expire = await client.post( + "/api/v1/expiration-policies", + headers=h, + json={"sports_center_id": center_id, "code": "EXP", "name": "Expire", "warn_before_days": 7}, + ) + assert expire.status_code == 201, expire.text + + mtype = await client.post( + "/api/v1/membership-types", + headers=h, + json={ + "sports_center_id": center_id, + "code": "GOLD", + "name": "Gold Membership", + "duration_days": 30, + "package_id": pkg.json()["id"], + "plan_id": plan.json()["id"], + "pricing_model_id": price_id, + "age_group_id": age.json()["id"], + "sport_category_id": cat.json()["id"], + "renewal_policy_id": renew.json()["id"], + "freezing_rule_id": freeze.json()["id"], + "expiration_policy_id": expire.json()["id"], + "is_transferable": True, + }, + ) + assert mtype.status_code == 201, mtype.text + assert mtype.json()["is_transferable"] is True + assert mtype.json()["plan_id"] == plan.json()["id"] + + rule = await client.post( + "/api/v1/membership-rules", + headers=h, + json={ + "sports_center_id": center_id, + "code": "ELIG-ADULT", + "name": "Adult only", + "kind": "eligibility", + "membership_type_id": mtype.json()["id"], + "expression": {"min_age": 18}, + }, + ) + assert rule.status_code == 201, rule.text + + listed = await client.get("/api/v1/membership-plans", headers=h) + assert listed.status_code == 200 + assert len(listed.json()) >= 1 + + +@pytest.mark.asyncio +async def test_catalog_tenant_isolation(client): + center_a = await _create_center(client, TENANT_A, code="A1") + await _create_center(client, TENANT_B, code="B1") + created = await client.post( + "/api/v1/pricing-models", + headers=tenant_headers(TENANT_A), + json={ + "sports_center_id": center_a["id"], + "code": "P1", + "name": "Price A", + "amount": "1000", + }, + ) + assert created.status_code == 201 + price_id = created.json()["id"] + + denied = await client.get( + f"/api/v1/pricing-models/{price_id}", + headers=tenant_headers(TENANT_B), + ) + assert denied.status_code == 404 + + +@pytest.mark.asyncio +async def test_age_group_validation(client): + center = await _create_center(client, TENANT_A, code="AGE1") + bad = await client.post( + "/api/v1/age-groups", + headers=tenant_headers(TENANT_A), + json={ + "sports_center_id": center["id"], + "code": "BAD", + "name": "Bad", + "min_age": 40, + "max_age": 10, + }, + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_capabilities_endpoint(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["phase"] == "9.2" + assert body["features"]["foundation"] is True + assert body["features"]["membership_catalog"] is True + assert body["features"]["member_management"] is True + assert body["independence"]["integration_mode"] == "api_and_events_only" diff --git a/backend/services/sports_center/app/tests/test_connectors.py b/backend/services/sports_center/app/tests/test_connectors.py new file mode 100644 index 0000000..3af1eca --- /dev/null +++ b/backend/services/sports_center/app/tests/test_connectors.py @@ -0,0 +1,30 @@ +"""Connector framework tests — interfaces only.""" +from app.connectors import ( + ConnectorCapability, + ConnectorRegistry, + get_connector_registry, + reset_connector_registry, +) + + +def test_registry_reset_and_register_keys(): + registry = reset_connector_registry() + assert registry.keys() == [] + assert get_connector_registry() is registry + + +def test_capability_catalog_is_generic(): + values = {c.value for c in ConnectorCapability} + expected = { + "qr", + "rfid", + "barcode", + "fingerprint", + "face_recognition", + "turnstile", + "door_controller", + "payment_terminal", + "attendance_device", + "custom", + } + assert expected.issubset(values) diff --git a/backend/services/sports_center/app/tests/test_dependency.py b/backend/services/sports_center/app/tests/test_dependency.py new file mode 100644 index 0000000..d88f230 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_dependency.py @@ -0,0 +1,28 @@ +"""Dependency / layering tests.""" +from pathlib import Path + +from app.repositories.base import TenantBaseRepository +from app.repositories.foundation import SportsCenterRepository +from app.services import foundation as services + + +def test_repositories_inherit_tenant_base(): + assert issubclass(SportsCenterRepository, TenantBaseRepository) + + +def test_services_have_no_fastapi_import(): + path = Path(services.__file__) + text = path.read_text(encoding="utf-8") + assert "fastapi" not in text.lower() + + +def test_providers_are_protocols_only(): + from app import providers + from typing import get_type_hints + import inspect + + path = Path(providers.__file__).parent / "contracts.py" + text = path.read_text(encoding="utf-8") + assert "Protocol" in text + assert "httpx" not in text + assert "sqlalchemy" not in text diff --git a/backend/services/sports_center/app/tests/test_docs.py b/backend/services/sports_center/app/tests/test_docs.py new file mode 100644 index 0000000..5c8710c --- /dev/null +++ b/backend/services/sports_center/app/tests/test_docs.py @@ -0,0 +1,62 @@ +"""Documentation presence tests for Sports Center phases.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[5] + + +def test_phase_doc_exists(): + path = ROOT / "docs" / "sports-center-phase-9-0.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Phase 9.0" in text + assert "sports_center_db" in text + + +def test_phase_91_doc_exists(): + path = ROOT / "docs" / "sports-center-phase-9-1.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Phase 9.1" in text + + +def test_phase_92_doc_exists(): + path = ROOT / "docs" / "sports-center-phase-9-2.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Phase 9.2" in text + assert "Member Management" in text + + +def test_handover_doc_exists(): + path = ROOT / "docs" / "phase-handover" / "phase-9-0.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "Phase 9.1" in text + + +def test_handover_91_doc_exists(): + path = ROOT / "docs" / "phase-handover" / "phase-9-1.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "9.2" in text + + +def test_handover_92_doc_exists(): + path = ROOT / "docs" / "phase-handover" / "phase-9-2.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "9.3" in text + + +def test_module_registry_mentions_sports_center(): + path = ROOT / "docs" / "module-registry.md" + text = path.read_text(encoding="utf-8") + assert "sports-center" in text or "sports_center" in text + assert "sports_center_db" in text + + +def test_service_readme_exists(): + path = Path(__file__).resolve().parents[2] / "README.md" + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "sports_center_db" in text diff --git a/backend/services/sports_center/app/tests/test_members.py b/backend/services/sports_center/app/tests/test_members.py new file mode 100644 index 0000000..6c9b7f1 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_members.py @@ -0,0 +1,265 @@ +"""Member Management API tests — Phase 9.2.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import SportsCenterEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _seed_center_and_type(client, headers): + center = await client.post( + "/api/v1/sports-centers", + json={"code": "m92", "name": "Member Center", "status": "active"}, + headers=headers, + ) + assert center.status_code == 201, center.text + center_id = center.json()["id"] + mtype = await client.post( + "/api/v1/membership-types", + json={"sports_center_id": center_id, "code": "std", "name": "Standard"}, + headers=headers, + ) + assert mtype.status_code == 201, mtype.text + return center_id, mtype.json()["id"] + + +@pytest.mark.asyncio +async def test_member_management_happy_path(client): + headers = tenant_headers(TENANT_A) + center_id, mtype_id = await _seed_center_and_type(client, headers) + + member = await client.post( + "/api/v1/members", + json={ + "sports_center_id": center_id, + "display_name": "Ali Member", + "email": "ali@example.com", + "mobile": "+989121111111", + "status": "active", + }, + headers=headers, + ) + assert member.status_code == 201, member.text + member_id = member.json()["id"] + + family = await client.post( + "/api/v1/family-members", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "display_name": "Sara", + "relationship": "spouse", + "mobile": "+989122222222", + }, + headers=headers, + ) + assert family.status_code == 201, family.text + + emergency = await client.post( + "/api/v1/emergency-contacts", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "display_name": "Father", + "phone": "+989123333333", + "is_primary": True, + }, + headers=headers, + ) + assert emergency.status_code == 201, emergency.text + + medical = await client.put( + "/api/v1/medical-information", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "allergies": ["pollen"], + "clearance_status": "cleared", + "document_file_ref": "storage://files/med-1", + }, + headers=headers, + ) + assert medical.status_code == 200, medical.text + + membership = await client.post( + "/api/v1/memberships", + json={ + "sports_center_id": center_id, + "membership_type_id": mtype_id, + "member_id": member_id, + "display_name": "Ali Member", + "status": "pending", + }, + headers=headers, + ) + assert membership.status_code == 201, membership.text + membership_id = membership.json()["id"] + assert membership.json()["member_id"] == member_id + + activated = await client.post( + f"/api/v1/memberships/{membership_id}/activate", + json={"version": membership.json()["version"]}, + headers=headers, + ) + assert activated.status_code == 200, activated.text + assert activated.json()["status"] == "active" + + frozen = await client.post( + f"/api/v1/memberships/{membership_id}/freeze", + json={"version": activated.json()["version"]}, + headers=headers, + ) + assert frozen.status_code == 200, frozen.text + assert frozen.json()["status"] == "frozen" + + unfrozen = await client.post( + f"/api/v1/memberships/{membership_id}/unfreeze", + json={"version": frozen.json()["version"]}, + headers=headers, + ) + assert unfrozen.status_code == 200, unfrozen.text + assert unfrozen.json()["status"] == "active" + + card = await client.post( + "/api/v1/membership-cards", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "membership_id": membership_id, + "kind": "qr", + }, + headers=headers, + ) + assert card.status_code == 201, card.text + assert card.json()["qr_payload"] + + digital = await client.post( + "/api/v1/digital-memberships", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "membership_id": membership_id, + }, + headers=headers, + ) + assert digital.status_code == 201, digital.text + + waiver = await client.post( + "/api/v1/waivers", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "title": "Liability Waiver", + "document_file_ref": "storage://files/waiver-1", + }, + headers=headers, + ) + assert waiver.status_code == 201, waiver.text + signed = await client.post( + f"/api/v1/waivers/{waiver.json()['id']}/sign", + json={"signature_ref": "storage://files/sig-1"}, + headers=headers, + ) + assert signed.status_code == 200, signed.text + assert signed.json()["status"] == "signed" + + doc = await client.post( + "/api/v1/member-documents", + json={ + "sports_center_id": center_id, + "member_id": member_id, + "title": "ID Scan", + "kind": "id", + "file_ref": "storage://files/id-1", + }, + headers=headers, + ) + assert doc.status_code == 201, doc.text + + events = {e.event_type for e in get_event_publisher().published} + assert SportsCenterEventType.MEMBER_CREATED.value in events + assert SportsCenterEventType.MEMBERSHIP_CREATED.value in events + assert SportsCenterEventType.MEMBERSHIP_FROZEN.value in events + assert SportsCenterEventType.MEMBERSHIP_UNFROZEN.value in events + assert SportsCenterEventType.MEMBERSHIP_CARD_ISSUED.value in events + assert SportsCenterEventType.WAIVER_SIGNED.value in events + assert SportsCenterEventType.MEMBER_DOCUMENT_ATTACHED.value in events + + +@pytest.mark.asyncio +async def test_membership_assign_and_tenant_isolation(client): + headers_a = tenant_headers(TENANT_A) + headers_b = tenant_headers(TENANT_B) + center_id, mtype_id = await _seed_center_and_type(client, headers_a) + + member = await client.post( + "/api/v1/members", + json={"sports_center_id": center_id, "display_name": "Iso Member"}, + headers=headers_a, + ) + member_id = member.json()["id"] + + membership = await client.post( + "/api/v1/memberships", + json={ + "sports_center_id": center_id, + "membership_type_id": mtype_id, + "display_name": "Pending Shell", + "status": "pending", + }, + headers=headers_a, + ) + membership_id = membership.json()["id"] + assert membership.json()["member_id"] is None + + assigned = await client.post( + f"/api/v1/memberships/{membership_id}/assign-member", + json={"member_id": member_id, "version": membership.json()["version"]}, + headers=headers_a, + ) + assert assigned.status_code == 200, assigned.text + assert assigned.json()["member_id"] == member_id + + missing = await client.get( + f"/api/v1/members/{member_id}", + headers=headers_b, + ) + assert missing.status_code == 404 + + no_tenant = await client.get("/api/v1/members") + assert no_tenant.status_code in (400, 422) + + +@pytest.mark.asyncio +async def test_invalid_membership_transition(client): + headers = tenant_headers(TENANT_A) + center_id, mtype_id = await _seed_center_and_type(client, headers) + membership = await client.post( + "/api/v1/memberships", + json={ + "sports_center_id": center_id, + "membership_type_id": mtype_id, + "display_name": "Pending Only", + "status": "pending", + }, + headers=headers, + ) + bad = await client.post( + f"/api/v1/memberships/{membership.json()['id']}/freeze", + json={"version": membership.json()["version"]}, + headers=headers, + ) + assert bad.status_code == 422 + + +@pytest.mark.asyncio +async def test_capabilities_phase_92(client): + resp = await client.get("/capabilities") + assert resp.status_code == 200 + body = resp.json() + assert body["phase"] == "9.2" + assert body["version"] == "0.9.2.0" + assert body["features"]["member_management"] is True + assert body["features"]["membership_catalog"] is True diff --git a/backend/services/sports_center/app/tests/test_migration.py b/backend/services/sports_center/app/tests/test_migration.py new file mode 100644 index 0000000..18dc8d8 --- /dev/null +++ b/backend/services/sports_center/app/tests/test_migration.py @@ -0,0 +1,51 @@ +"""Migration metadata tests.""" +from pathlib import Path + +from app.core.database import Base +import app.models # noqa: F401 + + +EXPECTED_TABLES = { + "sports_centers", + "branches", + "sports", + "membership_types", + "memberships", + "coaches", + "sports_roles", + "sports_permissions", + "facilities", + "courts", + "rooms", + "locker_rooms", + "lockers", + "device_providers", + "devices", + "attendance_gateways", + "sports_configurations", + "sports_events", + "sports_settings", + "sports_audit_logs", + "members", + "family_members", + "emergency_contacts", + "medical_information", + "membership_cards", + "digital_memberships", + "waivers", + "member_documents", +} + + +def test_alembic_revision_exists(): + root = Path(__file__).resolve().parents[2] + rev = root / "alembic" / "versions" / "0003_phase_92_member_management.py" + assert rev.exists() + text = rev.read_text(encoding="utf-8") + assert 'revision = "0003_phase_92_member_management"' in text + assert "0002_phase_91_membership_catalog" in text + + +def test_expected_tables_in_metadata(): + assert EXPECTED_TABLES.issubset(set(Base.metadata.tables.keys())) + assert "member_id" in Base.metadata.tables["memberships"].columns diff --git a/backend/services/sports_center/app/tests/test_permissions.py b/backend/services/sports_center/app/tests/test_permissions.py new file mode 100644 index 0000000..b8e88fc --- /dev/null +++ b/backend/services/sports_center/app/tests/test_permissions.py @@ -0,0 +1,14 @@ +"""Permission definition tests.""" +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + +def test_all_permissions_use_sports_center_prefix(): + assert ALL_PERMISSIONS + for perm in ALL_PERMISSIONS: + assert perm.startswith("sports_center.") + + +def test_permission_prefixes_are_stable(): + assert "sports_center." in PERMISSION_PREFIXES + assert "sports_center.memberships." in PERMISSION_PREFIXES + assert "sports_center.devices." in PERMISSION_PREFIXES diff --git a/backend/services/sports_center/app/validators/__init__.py b/backend/services/sports_center/app/validators/__init__.py new file mode 100644 index 0000000..fadf122 --- /dev/null +++ b/backend/services/sports_center/app/validators/__init__.py @@ -0,0 +1,38 @@ +"""Sports Center validators package.""" +from app.validators.foundation import ( + ensure_optimistic_version, + forbid_sport_specific_hardcoding, + validate_capability, + validate_code, + validate_currency_code, + validate_date_range, + validate_device_status, + validate_email, + validate_facility_kind, + validate_lifecycle_status, + validate_locker_status, + validate_membership_status, + validate_non_empty, + validate_non_negative_int, + validate_phone, + validate_setting_key, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_setting_key", + "validate_email", + "validate_phone", + "validate_currency_code", + "validate_non_negative_int", + "validate_lifecycle_status", + "validate_membership_status", + "validate_device_status", + "validate_locker_status", + "validate_capability", + "validate_facility_kind", + "validate_date_range", + "ensure_optimistic_version", + "forbid_sport_specific_hardcoding", +] diff --git a/backend/services/sports_center/app/validators/catalog.py b/backend/services/sports_center/app/validators/catalog.py new file mode 100644 index 0000000..f2427f5 --- /dev/null +++ b/backend/services/sports_center/app/validators/catalog.py @@ -0,0 +1,50 @@ +"""Membership Catalog validators — Phase 9.1.""" +from __future__ import annotations + +from decimal import Decimal + +from app.models.types import BillingPeriod, PricingModelKind, RuleKind +from shared.exceptions import AppError + + +def validate_age_range(min_age: int | None, max_age: int | None) -> tuple[int | None, int | None]: + if min_age is not None and min_age < 0: + raise AppError("حداقل سن نامعتبر است", status_code=422, error_code="invalid_min_age") + if max_age is not None and max_age < 0: + raise AppError("حداکثر سن نامعتبر است", status_code=422, error_code="invalid_max_age") + if min_age is not None and max_age is not None and min_age > max_age: + raise AppError( + "حداقل سن نمی‌تواند از حداکثر سن بیشتر باشد", + status_code=422, + error_code="invalid_age_range", + ) + return min_age, max_age + + +def validate_pricing_kind(value: PricingModelKind | str) -> PricingModelKind: + try: + return value if isinstance(value, PricingModelKind) else PricingModelKind(value) + except ValueError as exc: + raise AppError("مدل قیمت‌گذاری نامعتبر است", status_code=422, error_code="invalid_pricing_kind") from exc + + +def validate_billing_period(value: BillingPeriod | str) -> BillingPeriod: + try: + return value if isinstance(value, BillingPeriod) else BillingPeriod(value) + except ValueError as exc: + raise AppError("دوره صورتحساب نامعتبر است", status_code=422, error_code="invalid_billing_period") from exc + + +def validate_rule_kind(value: RuleKind | str) -> RuleKind: + try: + return value if isinstance(value, RuleKind) else RuleKind(value) + except ValueError as exc: + raise AppError("نوع قانون نامعتبر است", status_code=422, error_code="invalid_rule_kind") from exc + + +def validate_money_amount(amount: Decimal | None, *, field: str = "amount") -> Decimal | None: + if amount is None: + return None + if amount < 0: + raise AppError(f"{field} نمی‌تواند منفی باشد", status_code=422, error_code="invalid_amount") + return amount diff --git a/backend/services/sports_center/app/validators/foundation.py b/backend/services/sports_center/app/validators/foundation.py new file mode 100644 index 0000000..a5b7d0e --- /dev/null +++ b/backend/services/sports_center/app/validators/foundation.py @@ -0,0 +1,213 @@ +"""Phase 9.0 Sports Center validators.""" +from __future__ import annotations + +import re +from datetime import date + +from shared.exceptions import AppError + +from app.models.types import ( + ConnectorCapability, + DeviceStatus, + FacilityKind, + LifecycleStatus, + LockerStatus, + MembershipStatus, +) + +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") +PHONE_RE = re.compile(r"^[\d\s\+\-\(\)]{5,30}$") +CODE_RE = re.compile(r"^[A-Za-z0-9_\-]{2,50}$") +SETTING_KEY_RE = re.compile(r"^[A-Za-z0-9_.\-]{2,100}$") + + +def validate_non_empty(value: str | None, *, field: str) -> str: + if value is None or not str(value).strip(): + raise AppError( + f"{field} الزامی است", + status_code=422, + error_code="validation_error", + details={"field": field}, + ) + return str(value).strip() + + +def validate_code(code: str | None, *, field: str = "code") -> str: + cleaned = validate_non_empty(code, field=field).upper() + if not CODE_RE.match(cleaned): + raise AppError( + f"فرمت {field} نامعتبر است", + status_code=422, + error_code="invalid_code", + details={"field": field}, + ) + return cleaned + + +def validate_setting_key(key: str | None) -> str: + cleaned = validate_non_empty(key, field="key") + if not SETTING_KEY_RE.match(cleaned): + raise AppError( + "فرمت key نامعتبر است", + status_code=422, + error_code="invalid_setting_key", + ) + return cleaned + + +def validate_email(email: str | None, *, required: bool = False) -> str | None: + if email is None or not email.strip(): + if required: + raise AppError( + "ایمیل الزامی است", + status_code=422, + error_code="email_required", + ) + return None + cleaned = email.strip().lower() + if not EMAIL_RE.match(cleaned): + raise AppError( + "فرمت ایمیل نامعتبر است", + status_code=422, + error_code="invalid_email", + ) + return cleaned + + +def validate_phone(phone: str | None, *, field: str = "phone") -> str | None: + if phone is None or not phone.strip(): + return None + cleaned = phone.strip() + if not PHONE_RE.match(cleaned): + raise AppError( + f"فرمت {field} نامعتبر است", + status_code=422, + error_code="invalid_phone", + details={"field": field}, + ) + return cleaned + + +def validate_currency_code(code: str) -> str: + cleaned = code.strip().upper() + if len(cleaned) != 3: + raise AppError( + "کد ارز باید ۳ حرفی باشد", + status_code=422, + error_code="invalid_currency_code", + ) + return cleaned + + +def validate_non_negative_int(value: int | None, *, field: str) -> int | None: + if value is None: + return None + resolved = int(value) + if resolved < 0: + raise AppError( + f"{field} نمی‌تواند منفی باشد", + status_code=422, + error_code="invalid_amount", + details={"field": field}, + ) + return resolved + + +def _enum_or_error(enum_cls, value, *, error_code: str, message: str): + if isinstance(value, enum_cls): + return value + try: + return enum_cls(value) + except ValueError as exc: + raise AppError(message, status_code=422, error_code=error_code) from exc + + +def validate_lifecycle_status(status: LifecycleStatus | str) -> LifecycleStatus: + return _enum_or_error( + LifecycleStatus, + status, + error_code="invalid_lifecycle_status", + message="وضعیت نامعتبر است", + ) + + +def validate_membership_status(status: MembershipStatus | str) -> MembershipStatus: + return _enum_or_error( + MembershipStatus, + status, + error_code="invalid_membership_status", + message="وضعیت عضویت نامعتبر است", + ) + + +def validate_device_status(status: DeviceStatus | str) -> DeviceStatus: + return _enum_or_error( + DeviceStatus, + status, + error_code="invalid_device_status", + message="وضعیت دستگاه نامعتبر است", + ) + + +def validate_locker_status(status: LockerStatus | str) -> LockerStatus: + return _enum_or_error( + LockerStatus, + status, + error_code="invalid_locker_status", + message="وضعیت کمد نامعتبر است", + ) + + +def validate_capability(capability: ConnectorCapability | str) -> ConnectorCapability: + return _enum_or_error( + ConnectorCapability, + capability, + error_code="invalid_connector_capability", + message="قابلیت کانکتور نامعتبر است", + ) + + +def validate_facility_kind(kind: FacilityKind | str) -> FacilityKind: + return _enum_or_error( + FacilityKind, + kind, + error_code="invalid_facility_kind", + message="نوع تسهیلات نامعتبر است", + ) + + +def validate_date_range( + starts_on: date | None, ends_on: date | None +) -> tuple[date | None, date | None]: + if starts_on and ends_on and ends_on < starts_on: + raise AppError( + "تاریخ پایان نمی‌تواند قبل از تاریخ شروع باشد", + status_code=422, + error_code="invalid_date_range", + ) + return starts_on, ends_on + + +def ensure_optimistic_version(entity_version: int, expected: int | None) -> None: + if expected is None: + return + if entity_version != expected: + raise AppError( + "نسخه رکورد تغییر کرده است؛ دوباره تلاش کنید", + status_code=409, + error_code="version_conflict", + details={"expected": expected, "actual": entity_version}, + ) + + +def forbid_sport_specific_hardcoding(payload: dict) -> None: + """Reject payloads that try to inject sport-engine rule keys into foundation.""" + forbidden = {"sport_engine", "hardcoded_sport_rules", "vendor_sdk"} + present = forbidden.intersection(payload.keys()) + if present: + raise AppError( + "قوانین اختصاصی ورزش یا SDK فروشنده در لایه foundation مجاز نیست", + status_code=422, + error_code="sport_specific_forbidden", + details={"fields": sorted(present)}, + ) diff --git a/backend/services/sports_center/app/validators/members.py b/backend/services/sports_center/app/validators/members.py new file mode 100644 index 0000000..359dc58 --- /dev/null +++ b/backend/services/sports_center/app/validators/members.py @@ -0,0 +1,164 @@ +"""Member Management validators — Phase 9.2.""" +from __future__ import annotations + +from datetime import date + +from shared.exceptions import AppError + +from app.models.types import ( + CardKind, + CardStatus, + DocumentKind, + FamilyRelationship, + MemberStatus, + MembershipStatus, + WaiverStatus, +) +from app.validators.foundation import validate_non_empty + + +def validate_member_status(value: MemberStatus | str | None) -> MemberStatus: + if value is None: + return MemberStatus.ACTIVE + try: + return value if isinstance(value, MemberStatus) else MemberStatus(str(value)) + except ValueError as exc: + raise AppError( + "وضعیت عضو نامعتبر است", + status_code=422, + error_code="invalid_member_status", + ) from exc + + +def validate_family_relationship( + value: FamilyRelationship | str | None, +) -> FamilyRelationship: + if value is None: + return FamilyRelationship.OTHER + try: + return ( + value + if isinstance(value, FamilyRelationship) + else FamilyRelationship(str(value)) + ) + except ValueError as exc: + raise AppError( + "نسبت خانوادگی نامعتبر است", + status_code=422, + error_code="invalid_family_relationship", + ) from exc + + +def validate_card_kind(value: CardKind | str | None) -> CardKind: + if value is None: + return CardKind.QR + try: + return value if isinstance(value, CardKind) else CardKind(str(value)) + except ValueError as exc: + raise AppError( + "نوع کارت نامعتبر است", + status_code=422, + error_code="invalid_card_kind", + ) from exc + + +def validate_card_status(value: CardStatus | str | None) -> CardStatus: + if value is None: + return CardStatus.ACTIVE + try: + return value if isinstance(value, CardStatus) else CardStatus(str(value)) + except ValueError as exc: + raise AppError( + "وضعیت کارت نامعتبر است", + status_code=422, + error_code="invalid_card_status", + ) from exc + + +def validate_waiver_status(value: WaiverStatus | str | None) -> WaiverStatus: + if value is None: + return WaiverStatus.PENDING + try: + return value if isinstance(value, WaiverStatus) else WaiverStatus(str(value)) + except ValueError as exc: + raise AppError( + "وضعیت تعهدنامه نامعتبر است", + status_code=422, + error_code="invalid_waiver_status", + ) from exc + + +def validate_document_kind(value: DocumentKind | str | None) -> DocumentKind: + if value is None: + return DocumentKind.OTHER + try: + return value if isinstance(value, DocumentKind) else DocumentKind(str(value)) + except ValueError as exc: + raise AppError( + "نوع سند نامعتبر است", + status_code=422, + error_code="invalid_document_kind", + ) from exc + + +def validate_file_ref(value: str | None, *, field: str = "file_ref") -> str: + cleaned = validate_non_empty(value, field=field) + if len(cleaned) > 255: + raise AppError( + f"{field} بیش از حد طولانی است", + status_code=422, + error_code="invalid_file_ref", + details={"field": field}, + ) + return cleaned + + +def validate_freeze_window( + freeze_starts_on: date | None, freeze_ends_on: date | None +) -> tuple[date | None, date | None]: + if freeze_starts_on and freeze_ends_on and freeze_ends_on < freeze_starts_on: + raise AppError( + "بازه فریز نامعتبر است", + status_code=422, + error_code="invalid_freeze_window", + ) + return freeze_starts_on, freeze_ends_on + + +def assert_membership_transition( + current: MembershipStatus, target: MembershipStatus +) -> None: + allowed: dict[MembershipStatus, set[MembershipStatus]] = { + MembershipStatus.PENDING: { + MembershipStatus.ACTIVE, + MembershipStatus.CANCELLED, + MembershipStatus.SUSPENDED, + }, + MembershipStatus.ACTIVE: { + MembershipStatus.FROZEN, + MembershipStatus.SUSPENDED, + MembershipStatus.EXPIRED, + MembershipStatus.CANCELLED, + }, + MembershipStatus.FROZEN: { + MembershipStatus.ACTIVE, + MembershipStatus.CANCELLED, + MembershipStatus.EXPIRED, + }, + MembershipStatus.SUSPENDED: { + MembershipStatus.ACTIVE, + MembershipStatus.CANCELLED, + MembershipStatus.EXPIRED, + }, + MembershipStatus.EXPIRED: {MembershipStatus.CANCELLED}, + MembershipStatus.CANCELLED: set(), + } + if target == current: + return + if target not in allowed.get(current, set()): + raise AppError( + "تغییر وضعیت عضویت مجاز نیست", + status_code=422, + error_code="invalid_membership_transition", + details={"from": current.value, "to": target.value}, + ) diff --git a/backend/services/sports_center/pytest.ini b/backend/services/sports_center/pytest.ini new file mode 100644 index 0000000..f1f608e --- /dev/null +++ b/backend/services/sports_center/pytest.ini @@ -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_* diff --git a/backend/services/sports_center/requirements.txt b/backend/services/sports_center/requirements.txt new file mode 100644 index 0000000..bbdf395 --- /dev/null +++ b/backend/services/sports_center/requirements.txt @@ -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 diff --git a/backend/services/sports_center/scripts/ensure_db.py b/backend/services/sports_center/scripts/ensure_db.py new file mode 100644 index 0000000..ea7f066 --- /dev/null +++ b/backend/services/sports_center/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure sports_center_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("SPORTS_CENTER_DATABASE_URL_SYNC", "") + if not sync_url: + print("SPORTS_CENTER_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "sports_center_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() diff --git a/docker-compose.yml b/docker-compose.yml index a9dda70..997f6d1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -207,6 +207,111 @@ services: networks: - superapp_net + loyalty-service: + build: + context: . + dockerfile: backend/services/loyalty/Dockerfile.dev + container_name: superapp_loyalty_service + restart: unless-stopped + env_file: + - .env + environment: + LOYALTY_DATABASE_URL: ${LOYALTY_DATABASE_URL} + LOYALTY_DATABASE_URL_SYNC: ${LOYALTY_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8004:8004" + volumes: + - ./backend/services/loyalty:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8004 --reload" + networks: + - superapp_net + + communication-service: + build: + context: . + dockerfile: backend/services/communication/Dockerfile.dev + container_name: superapp_communication_service + restart: unless-stopped + env_file: + - .env + environment: + COMMUNICATION_DATABASE_URL: ${COMMUNICATION_DATABASE_URL} + COMMUNICATION_DATABASE_URL_SYNC: ${COMMUNICATION_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8005:8005" + volumes: + - ./backend/services/communication:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8005 --reload" + networks: + - superapp_net + + sports-center-service: + build: + context: . + dockerfile: backend/services/sports_center/Dockerfile.dev + container_name: superapp_sports_center_service + restart: unless-stopped + env_file: + - .env + environment: + SPORTS_CENTER_DATABASE_URL: ${SPORTS_CENTER_DATABASE_URL} + SPORTS_CENTER_DATABASE_URL_SYNC: ${SPORTS_CENTER_DATABASE_URL_SYNC} + CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AUTH_REQUIRED: ${AUTH_REQUIRED:-true} + KEYCLOAK_SERVER_URL: ${KEYCLOAK_SERVER_URL:-http://keycloak:8080} + KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080} + KEYCLOAK_REALM: ${KEYCLOAK_REALM:-superapp} + JWT_VERIFY_SIGNATURE: ${JWT_VERIFY_SIGNATURE:-true} + ports: + - "8006:8006" + volumes: + - ./backend/services/sports_center:/app + - ./backend/shared-lib:/shared-lib + depends_on: + postgres: + condition: service_healthy + core-service: + condition: service_started + command: > + sh -c "python scripts/ensure_db.py && + alembic upgrade head && + uvicorn app.main:app --host 0.0.0.0 --port 8006 --reload" + networks: + - superapp_net + frontend: build: context: ./frontend diff --git a/docs/README.md b/docs/README.md index 9aabeee..abe6869 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,135 +1,158 @@ -# TorbatYar Documentation - -Permanent source of truth for architecture, standards, registries, deployment, and phase planning. - -## Start Here (every implementation phase) - -1. This file -2. [architecture/](architecture/) and [architecture/adr/](architecture/adr/) -3. [development/project-principles.md](development/project-principles.md) -4. [development/coding-standards.md](development/coding-standards.md) -5. [development/testing-strategy.md](development/testing-strategy.md) -6. [module-registry.md](module-registry.md) -7. [provider-registry.md](provider-registry.md) -8. [glossary.md](glossary.md) -9. Relevant [phases/](phases/) docs - -If a change affects architecture, APIs, providers, or module boundaries: **update documentation first**, then code. - -## Navigation - -### Status & Planning - -| Document | Responsibility | -| --- | --- | -| [progress.md](progress.md) | Completed work only | -| [roadmap.md](roadmap.md) | Future roadmap only | -| [next-steps.md](next-steps.md) | Immediate next milestone only | -| [crm-phase-6-0.md](crm-phase-6-0.md) | CRM Service Foundation (Phase 6.0) | -| [crm-phase-6-1.md](crm-phase-6-1.md) | CRM Core Business Entities (Phase 6.1) | -| [crm-phase-6-2.md](crm-phase-6-2.md) | CRM Enterprise Sales Process Engine (Phase 6.2) | -| [crm-phase-6-3.md](crm-phase-6-3.md) | CRM Enterprise Sales Collaboration (Phase 6.3) | - -### Architecture - -| Document | Responsibility | -| --- | --- | -| [architecture/architecture.md](architecture/architecture.md) | Overview | -| [architecture/module-boundaries.md](architecture/module-boundaries.md) | Ownership boundaries | -| [architecture/database-architecture.md](architecture/database-architecture.md) | DB architecture | -| [architecture/multi-tenant-architecture.md](architecture/multi-tenant-architecture.md) | Tenancy | -| [architecture/deployment-architecture.md](architecture/deployment-architecture.md) | Runtime topology | -| [architecture/security-architecture.md](architecture/security-architecture.md) | Security | -| [architecture/identity-architecture.md](architecture/identity-architecture.md) | Identity | -| [architecture/authorization-architecture.md](architecture/authorization-architecture.md) | Authz | -| [architecture/integration-architecture.md](architecture/integration-architecture.md) | Integrations | -| [architecture/event-driven-architecture.md](architecture/event-driven-architecture.md) | Events | -| [architecture/service-architecture.md](architecture/service-architecture.md) | Service layering | -| [architecture/ai-architecture.md](architecture/ai-architecture.md) | AI rules | -| [architecture/compliance-architecture.md](architecture/compliance-architecture.md) | Compliance | -| [architecture/adr/](architecture/adr/) | ADRs (one decision per file) | - -### Development - -| Document | Responsibility | -| --- | --- | -| [development/developer-guide.md](development/developer-guide.md) | How to run/develop | -| [development/project-principles.md](development/project-principles.md) | Mandatory principles | -| [development/coding-standards.md](development/coding-standards.md) | Coding conventions | -| [development/testing-strategy.md](development/testing-strategy.md) | Testing | -| [development/branching-strategy.md](development/branching-strategy.md) | Git branches | -| [development/release-strategy.md](development/release-strategy.md) | Releases | - -### Reference - -| Document | Responsibility | -| --- | --- | -| [reference/database-schema.md](reference/database-schema.md) | Schema reference | -| [reference/services-contracts.md](reference/services-contracts.md) | Service contracts | -| [reference/api-reference.md](reference/api-reference.md) | API index | -| [reference/event-catalog.md](reference/event-catalog.md) | Events | -| [reference/provider-reference.md](reference/provider-reference.md) | Provider details | - -### Deployment - -| Document | Responsibility | -| --- | --- | -| [deployment/deployment.md](deployment/deployment.md) | Deploy overview | -| [deployment/production.md](deployment/production.md) | Production | -| [deployment/ssl.md](deployment/ssl.md) | TLS / tenant SSL | -| [deployment/monitoring.md](deployment/monitoring.md) | Monitoring | -| [deployment/backup.md](deployment/backup.md) | Backup | -| [deployment/restore.md](deployment/restore.md) | Restore | -| [deployment/disaster-recovery.md](deployment/disaster-recovery.md) | DR | - -### Registries & Glossary - -| Document | Responsibility | -| --- | --- | -| [module-registry.md](module-registry.md) | All modules | -| [provider-registry.md](provider-registry.md) | All providers | -| [glossary.md](glossary.md) | Terms | - -### Frontend - -| Document | Responsibility | -| --- | --- | -| [frontend/README.md](frontend/README.md) | Accounting UI & design system index | - -### Decisions, Templates, Phases - -| Path | Responsibility | -| --- | --- | -| [decisions/](decisions/) | Non-architectural decisions | -| [templates/](templates/) | Required document templates | -| [phases/](phases/) | Phase area documentation | - -## Phase Completion Gate - -No implementation phase is complete until: - -- [ ] Code completed -- [ ] Tests passed -- [ ] Documentation updated -- [ ] ADR updated (if required) -- [ ] Module Registry updated -- [ ] Provider Registry updated (if required) -- [ ] Progress updated -- [ ] Next Steps updated -- [ ] Architecture validation passed -- [ ] No TODO remains -- [ ] Self review completed -- [ ] Final verification completed - -## Architecture Guard - -Before modifying application code, verify consistency with principles, ADRs, registries, tenancy, database architecture, coding standards, and testing strategy. On conflict: **stop**, explain, correct docs/ADR first. - -## Deprecated Paths - -Older paths may remain as stubs pointing here. Do not add new content to deprecated files. - -## Related - -- Root [README.md](../README.md) -- [current-architecture-review.md](current-architecture-review.md) (historical review; superseded by this structure) +# TorbatYar Documentation + +Permanent source of truth for architecture, standards, registries, deployment, and phase planning. + +## Start Here (every implementation phase) + +1. This file +2. [ai-framework/](ai-framework/) — permanent AI Development Framework (read before coding) +3. [architecture/](architecture/) and [architecture/adr/](architecture/adr/) +4. [development/project-principles.md](development/project-principles.md) +5. [development/coding-standards.md](development/coding-standards.md) +6. [development/testing-strategy.md](development/testing-strategy.md) +7. [module-registry.md](module-registry.md) +8. [provider-registry.md](provider-registry.md) +9. [glossary.md](glossary.md) +10. Relevant [phases/](phases/) docs + +If a change affects architecture, APIs, providers, or module boundaries: **update documentation first**, then code. + +## Navigation + +### Status & Planning + +| Document | Responsibility | +| --- | --- | +| [progress.md](progress.md) | Completed work only | +| [roadmap.md](roadmap.md) | Future roadmap only | +| [next-steps.md](next-steps.md) | Immediate next milestone only | +| [crm-phase-6-0.md](crm-phase-6-0.md) | CRM Service Foundation (Phase 6.0) | +| [crm-phase-6-1.md](crm-phase-6-1.md) | CRM Core Business Entities (Phase 6.1) | +| [crm-phase-6-2.md](crm-phase-6-2.md) | CRM Enterprise Sales Process Engine (Phase 6.2) | +| [crm-phase-6-3.md](crm-phase-6-3.md) | CRM Enterprise Sales Collaboration (Phase 6.3) | +| [loyalty-phase-7-0.md](loyalty-phase-7-0.md) | Loyalty Service Foundation (Phase 7.0) | +| [communication-phase-8.md](communication-phase-8.md) | Enterprise Communication Platform (Phase 8) | +| [sports-center-roadmap.md](sports-center-roadmap.md) | Sports Center Platform roadmap (Phases 9.0–9.10) | + +### Architecture + +| Document | Responsibility | +| --- | --- | +| [architecture/architecture.md](architecture/architecture.md) | Overview | +| [architecture/module-boundaries.md](architecture/module-boundaries.md) | Ownership boundaries | +| [architecture/database-architecture.md](architecture/database-architecture.md) | DB architecture | +| [architecture/multi-tenant-architecture.md](architecture/multi-tenant-architecture.md) | Tenancy | +| [architecture/deployment-architecture.md](architecture/deployment-architecture.md) | Runtime topology | +| [architecture/security-architecture.md](architecture/security-architecture.md) | Security | +| [architecture/identity-architecture.md](architecture/identity-architecture.md) | Identity | +| [architecture/authorization-architecture.md](architecture/authorization-architecture.md) | Authz | +| [architecture/integration-architecture.md](architecture/integration-architecture.md) | Integrations | +| [architecture/event-driven-architecture.md](architecture/event-driven-architecture.md) | Events | +| [architecture/service-architecture.md](architecture/service-architecture.md) | Service layering | +| [architecture/ai-architecture.md](architecture/ai-architecture.md) | AI rules | +| [architecture/compliance-architecture.md](architecture/compliance-architecture.md) | Compliance | +| [architecture/adr/](architecture/adr/) | ADRs (one decision per file) | + +### Development + +| Document | Responsibility | +| --- | --- | +| [development/developer-guide.md](development/developer-guide.md) | How to run/develop | +| [development/project-principles.md](development/project-principles.md) | Mandatory principles | +| [development/coding-standards.md](development/coding-standards.md) | Coding conventions | +| [development/testing-strategy.md](development/testing-strategy.md) | Testing | +| [development/branching-strategy.md](development/branching-strategy.md) | Git branches | +| [development/release-strategy.md](development/release-strategy.md) | Releases | + +### Reference + +| Document | Responsibility | +| --- | --- | +| [reference/database-schema.md](reference/database-schema.md) | Schema reference | +| [reference/services-contracts.md](reference/services-contracts.md) | Service contracts | +| [reference/api-reference.md](reference/api-reference.md) | API index | +| [reference/event-catalog.md](reference/event-catalog.md) | Events | +| [reference/provider-reference.md](reference/provider-reference.md) | Provider details | + +### Deployment + +| Document | Responsibility | +| --- | --- | +| [deployment/deployment.md](deployment/deployment.md) | Deploy overview | +| [deployment/production.md](deployment/production.md) | Production | +| [deployment/ssl.md](deployment/ssl.md) | TLS / tenant SSL | +| [deployment/monitoring.md](deployment/monitoring.md) | Monitoring | +| [deployment/backup.md](deployment/backup.md) | Backup | +| [deployment/restore.md](deployment/restore.md) | Restore | +| [deployment/disaster-recovery.md](deployment/disaster-recovery.md) | DR | + +### Registries & Glossary + +| Document | Responsibility | +| --- | --- | +| [module-registry.md](module-registry.md) | All modules | +| [provider-registry.md](provider-registry.md) | All providers | +| [glossary.md](glossary.md) | Terms | + +### Frontend + +| Document | Responsibility | +| --- | --- | +| [frontend/README.md](frontend/README.md) | Accounting UI & design system index | + +### AI Development Framework + +| Document | Responsibility | +| --- | --- | +| [ai-framework/README.md](ai-framework/README.md) | Framework purpose, usage, reading order | +| [ai-framework/master-prompt.md](ai-framework/master-prompt.md) | Global AI implementation rules | +| [ai-framework/development-loop.md](ai-framework/development-loop.md) | Permanent implementation lifecycle | +| [ai-framework/quality-gates.md](ai-framework/quality-gates.md) | Mandatory quality gates | +| [ai-framework/phase-manifest.yaml](ai-framework/phase-manifest.yaml) | Phase registry | +| [ai-framework/service-manifest.yaml](ai-framework/service-manifest.yaml) | Service registry | +| [ai-framework/prompt-rules.md](ai-framework/prompt-rules.md) | Phase prompt rules | +| [ai-framework/cursor-guidelines.md](ai-framework/cursor-guidelines.md) | Cursor workflow | +| [architecture/adr/ADR-013.md](architecture/adr/ADR-013.md) | Framework ADR | +| [architecture/adr/ADR-014.md](architecture/adr/ADR-014.md) | Sports Center Platform ADR | + +### Decisions, Templates, Phases + +| Path | Responsibility | +| --- | --- | +| [decisions/](decisions/) | Non-architectural decisions | +| [templates/](templates/) | Required document templates (short forms) | +| [ai-framework/](ai-framework/) | AI implementation templates & lifecycle | +| [phases/](phases/) | Phase area documentation | + +## Phase Completion Gate + +No implementation phase is complete until: + +- [ ] Code completed +- [ ] Tests passed +- [ ] Documentation updated +- [ ] ADR updated (if required) +- [ ] Module Registry updated +- [ ] Provider Registry updated (if required) +- [ ] Progress updated +- [ ] Next Steps updated +- [ ] Architecture validation passed +- [ ] AI framework quality gates passed ([ai-framework/quality-gates.md](ai-framework/quality-gates.md)) +- [ ] Phase handover completed ([ai-framework/phase-handover.md](ai-framework/phase-handover.md)) +- [ ] Manifests updated (if phase/service registration changed) +- [ ] No TODO remains +- [ ] Self review completed +- [ ] Final verification completed + +## Architecture Guard + +Before modifying application code, verify consistency with principles, ADRs, registries, tenancy, database architecture, coding standards, and testing strategy. On conflict: **stop**, explain, correct docs/ADR first. + +## Deprecated Paths + +Older paths may remain as stubs pointing here. Do not add new content to deprecated files. + +## Related + +- Root [README.md](../README.md) +- [current-architecture-review.md](current-architecture-review.md) (historical review; superseded by this structure) diff --git a/docs/ai-framework/README.md b/docs/ai-framework/README.md new file mode 100644 index 0000000..e65c7b9 --- /dev/null +++ b/docs/ai-framework/README.md @@ -0,0 +1,152 @@ +# AI Development Framework + +Permanent framework that every future AI-assisted implementation phase must follow. + +This area does **not** implement business features. It defines how AI agents and humans plan, implement, validate, document, and complete phases without violating TorbatYar architecture. + +## Purpose + +1. Encode global project rules once (never duplicate them in phase prompts). +2. Standardize the implementation lifecycle from requirements to completion. +3. Provide reusable templates for services, modules, entities, layers, APIs, events, tests, and docs. +4. Register phases and services in machine-readable manifests. +5. Enforce quality gates before any phase may be marked complete. + +## Usage + +| Audience | How to use | +| --- | --- | +| AI agent (Cursor / other) | Read [cursor-guidelines.md](cursor-guidelines.md) and [prompt-rules.md](prompt-rules.md); load [master-prompt.md](master-prompt.md); follow [development-loop.md](development-loop.md) | +| Phase author | Copy [phase-template.md](phase-template.md); register in [phase-manifest.yaml](phase-manifest.yaml); deliver [phase-handover.md](phase-handover.md) | +| Service designer | Follow [service-template.md](service-template.md); register in [service-manifest.yaml](service-manifest.yaml) | +| Reviewer | Validate against [quality-gates.md](quality-gates.md) | + +Phase prompts must describe **only** the current phase. Permanent rules live here. + +## Reading Order + +Every implementation phase starts here: + +1. [docs/README.md](../README.md) +2. This file (`docs/ai-framework/README.md`) +3. [master-prompt.md](master-prompt.md) +4. [development-loop.md](development-loop.md) +5. [prompt-rules.md](prompt-rules.md) · [cursor-guidelines.md](cursor-guidelines.md) +6. [quality-gates.md](quality-gates.md) +7. Relevant templates (service / module / entity / repository / service-layer / api / event / testing / documentation) +8. [phase-manifest.yaml](phase-manifest.yaml) · [service-manifest.yaml](service-manifest.yaml) +9. Then architecture, ADRs, development standards, registries, glossary, and the phase’s own docs + +## Lifecycle + +Canonical lifecycle: [development-loop.md](development-loop.md). + +``` +Requirements Analysis + → Architecture Validation + → Implementation + → Automated Testing + → Self Audit + → Documentation Update + → Quality Gates + → Automatic Repair Loop (until green) + → Completion (handover + registry/progress updates) +``` + +No phase is complete until every gate in [quality-gates.md](quality-gates.md) passes and [phase-handover.md](phase-handover.md) deliverables are filled. + +## Relationship with Architecture + +| Architecture doc | Framework use | +| --- | --- | +| [architecture/architecture.md](../architecture/architecture.md) | System map and permanent reading list | +| [module-boundaries.md](../architecture/module-boundaries.md) | Ownership checks before new modules/services | +| [database-architecture.md](../architecture/database-architecture.md) | DB ownership / migration rules | +| [multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md) | Tenant awareness gates | +| [service-architecture.md](../architecture/service-architecture.md) | Layering (API → Service → Repository → Model) | +| [event-driven-architecture.md](../architecture/event-driven-architecture.md) | Event naming and outbox rules | +| [ai-architecture.md](../architecture/ai-architecture.md) | Product AI independence (distinct from this framework) | +| [security-architecture.md](../architecture/security-architecture.md) · [authorization-architecture.md](../architecture/authorization-architecture.md) | Security / permission gates | + +**Distinction:** Product AI (`ai_assistant` / AI phase area) is a business capability. This framework is **development process** documentation for AI-assisted implementation of any module. + +## Relationship with ADR + +- Architecture decisions remain one decision per file under [architecture/adr/](../architecture/adr/). +- Framework adoption is recorded in [ADR-013](../architecture/adr/ADR-013.md). +- If a phase introduces a new architectural decision, create an ADR **before** conflicting code. +- Never overwrite an accepted ADR — supersede it. +- Template: [templates/adr-template.md](../templates/adr-template.md). + +## Relationship with Development Standards + +| Standard | Framework use | +| --- | --- | +| [project-principles.md](../development/project-principles.md) | Non-negotiable principles mirrored in master-prompt | +| [coding-standards.md](../development/coding-standards.md) | Coding rules for implementation | +| [testing-strategy.md](../development/testing-strategy.md) | Baseline for [testing-template.md](testing-template.md) | +| [branching-strategy.md](../development/branching-strategy.md) · [release-strategy.md](../development/release-strategy.md) | Delivery hygiene | +| [developer-guide.md](../development/developer-guide.md) | How to run services locally | + +Framework templates **extend** these standards; they do not replace them. Prefer existing [docs/templates/](../templates/) for registry-style docs; use `docs/ai-framework/*-template.md` for AI implementation phases. + +## Relationship with Phase Documents + +| Phase artifact | Location | +| --- | --- | +| Phase area overviews | [docs/phases/](../phases/) | +| Phase detail docs | `docs/*-phase-*.md` or `docs/phases//` | +| Phase status | [progress.md](../progress.md) · [next-steps.md](../next-steps.md) · [roadmap.md](../roadmap.md) | +| Phase registration | [phase-manifest.yaml](phase-manifest.yaml) | +| Phase authoring template | [phase-template.md](phase-template.md) | +| Phase exit package | [phase-handover.md](phase-handover.md) | + +Legacy phase template (registry-oriented): [templates/phase-template.md](../templates/phase-template.md). AI phases use the richer [phase-template.md](phase-template.md) in this folder. + +## Document Index + +| Document | Responsibility | +| --- | --- | +| [master-prompt.md](master-prompt.md) | Global rules for every AI implementation | +| [development-loop.md](development-loop.md) | Permanent implementation lifecycle | +| [phase-handover.md](phase-handover.md) | Required exit deliverables | +| [phase-template.md](phase-template.md) | Reusable phase brief | +| [service-template.md](service-template.md) | New independent service design | +| [module-template.md](module-template.md) | Module inside an existing service | +| [entity-template.md](entity-template.md) | Entity / schema standards | +| [repository-template.md](repository-template.md) | Repository standards | +| [service-layer-template.md](service-layer-template.md) | Service layer standards | +| [api-template.md](api-template.md) | REST / DTO / versioning / errors | +| [event-template.md](event-template.md) | Domain & integration events | +| [testing-template.md](testing-template.md) | Mandatory tests | +| [documentation-template.md](documentation-template.md) | Post-phase documentation updates | +| [quality-gates.md](quality-gates.md) | Mandatory quality gates | +| [phase-manifest.yaml](phase-manifest.yaml) | Phase registry | +| [service-manifest.yaml](service-manifest.yaml) | Service registry | +| [prompt-rules.md](prompt-rules.md) | How future phase prompts must behave | +| [cursor-guidelines.md](cursor-guidelines.md) | Cursor-specific workflow | + +## Phase AF Handover (Framework Creation) + +| Field | Value | +| --- | --- | +| Phase ID | `ai-framework` | +| Status | Complete | +| ADR | [ADR-013](../architecture/adr/ADR-013.md) | +| Reusable Components | All templates + manifests + loop/gates under this folder | +| Public APIs | N/A (documentation only) | +| Events | N/A | +| Extension Points | New permanent rules → extend framework docs; new phases → phase-template + manifests | +| Known Limitations | Does not implement product AI Assistant; does not replace `docs/templates/` short forms | +| Migration Notes | N/A | +| Dependencies | Existing documentation architecture (Phase D) | +| Next Phase Entry | [next-steps.md](../next-steps.md) — Loyalty 7.1 using this framework | + +## Related Documents + +- [Module Registry](../module-registry.md) +- [Provider Registry](../provider-registry.md) +- [Glossary](../glossary.md) +- [Reference](../reference/) +- [Deployment](../deployment/) +- [ADR-013 — AI Development Framework](../architecture/adr/ADR-013.md) diff --git a/docs/ai-framework/api-template.md b/docs/ai-framework/api-template.md new file mode 100644 index 0000000..530cd5f --- /dev/null +++ b/docs/ai-framework/api-template.md @@ -0,0 +1,66 @@ +# API Template + +REST / DTO / validation / versioning / error standards for service APIs. + +Short contract form: [templates/api-template.md](../templates/api-template.md). + +## REST Standards + +| Rule | Detail | +| --- | --- | +| Prefix | `/api/v1` for public service APIs | +| Resources | Nouns; plural collections (`/programs`, `/leads`) | +| Methods | GET read · POST create/actions · PATCH/PUT update · DELETE remove/archive as designed | +| Auth | JWT (and documented internal tokens); deps in `api/deps.py` | +| Tenant | `X-Tenant-ID` (and documented alternatives); reject when missing on tenant routes | +| Pagination | Shared page meta pattern | +| Thin routers | Validate → authorize → service call → map response | + +Health endpoints may live at `/health` (and `/capabilities` when applicable) outside `/api/v1`. + +## DTO Rules + +1. Pydantic schemas in `app/schemas/` — `Create`, `Update`, `Read`, `List` suffixes. +2. Never expose password hashes, provider secrets, or internal tokens. +3. Do not return ORM models directly from routers. +4. External refs are explicit fields (`crm_contact_ref`, etc.). +5. Document breaking field changes in phase handover. + +## Validation Rules + +1. Request body/query validated by Pydantic. +2. Domain rules enforced in services/validators — not only at the edge. +3. `tenant_id` in body does not override resolved tenant context. +4. Path IDs are UUIDs/strings as per service; always combined with tenant scope server-side. + +## Versioning + +1. Current public version: `v1`. +2. Additive changes preferred within `v1`. +3. Breaking changes require a new version path (`/api/v2`) or a documented migration window in the phase. +4. Deprecated fields remain until the compatibility gate allows removal. + +## Error Responses + +1. Use shared exception types / stable `error_code` values. +2. Map to appropriate HTTP status: 400 validation · 401 auth · 403 forbidden · 404 not found · 409 conflict · 422 domain · 429 rate limit · 500 unexpected. +3. Do not leak stack traces or secrets in production payloads. +4. Document new error codes in the phase API section. + +## Endpoint Documentation Block + +```markdown +| Method | Path | Description | Auth / Permission | +| --- | --- | --- | --- | +| POST | /api/v1/example | Create example | JWT + `service.example.create` | +``` + +Also update [api-reference.md](../reference/api-reference.md) / [services-contracts.md](../reference/services-contracts.md) when the phase publishes stable contracts. + +## Related Documents + +- [Authorization Architecture](../architecture/authorization-architecture.md) +- [Security Architecture](../architecture/security-architecture.md) +- [Service Layer Template](service-layer-template.md) +- [Coding Standards](../development/coding-standards.md) +- [templates/api-template.md](../templates/api-template.md) diff --git a/docs/ai-framework/cursor-guidelines.md b/docs/ai-framework/cursor-guidelines.md new file mode 100644 index 0000000..101af01 --- /dev/null +++ b/docs/ai-framework/cursor-guidelines.md @@ -0,0 +1,79 @@ +# Cursor Guidelines + +Cursor-specific workflow for TorbatYar implementation phases. + +General prompt behavior: [prompt-rules.md](prompt-rules.md). Lifecycle: [development-loop.md](development-loop.md). + +## Reading Order + +1. [docs/README.md](../README.md) +2. [AI Framework README](README.md) +3. [master-prompt.md](master-prompt.md) +4. [development-loop.md](development-loop.md) · [quality-gates.md](quality-gates.md) +5. [prompt-rules.md](prompt-rules.md) (this workflow’s companion) +6. Phase brief + [phase-manifest.yaml](phase-manifest.yaml) entry +7. [service-manifest.yaml](service-manifest.yaml) for touched services +8. `docs/architecture/*` and `docs/architecture/adr/*` +9. [project-principles.md](../development/project-principles.md) · [coding-standards.md](../development/coding-standards.md) · [testing-strategy.md](../development/testing-strategy.md) +10. [module-registry.md](../module-registry.md) · [provider-registry.md](../provider-registry.md) · [glossary.md](../glossary.md) +11. Relevant [phases/](../phases/) and reference docs +12. Applicable templates (service/module/entity/repository/service-layer/api/event/testing/documentation) + +Do not start coding before Architecture Validation completes. + +## Implementation Order + +1. ADR/docs if a new decision is required +2. Models / migrations ([entity-template.md](entity-template.md)) +3. Repositories ([repository-template.md](repository-template.md)) +4. Validators + permissions +5. Services + events ([service-layer-template.md](service-layer-template.md), [event-template.md](event-template.md)) +6. API routers + DTOs ([api-template.md](api-template.md)) +7. Provider adapters only if this service owns them +8. Frontend only if the phase explicitly includes UI +9. Keep diffs scoped to the phase — no drive-by refactors + +## Documentation Order + +1. Draft/update phase doc during or immediately after implementation +2. Update registries and manifests +3. Update reference catalogs when public surface changed +4. Update progress (completed) and next-steps (next milestone) +5. Fill handover ([phase-handover.md](phase-handover.md)) +6. ADR index if a new ADR was added +7. Follow [documentation-template.md](documentation-template.md) + +## Validation Order + +1. Automated tests ([testing-template.md](testing-template.md)) +2. Self audit (scope creep, unfinished claimed work, layering, tenancy, secrets) +3. Quality gates ([quality-gates.md](quality-gates.md)) +4. Link / cross-reference / manifest checks for doc changes +5. Repair loop until green + +## Loop Behavior + +- Follow [development-loop.md](development-loop.md) stages strictly. +- On gate failure: fix root cause, re-validate, do not skip gates. +- On architecture conflict: stop coding; fix ADR/docs first. +- Prefer existing sibling service patterns (Accounting, CRM, Loyalty, Communication). + +## Completion Behavior + +- Phase is complete only when Completion Rules in [development-loop.md](development-loop.md) are satisfied. +- Print a concise completion summary for the user. +- **Stop** — do not begin the next phase unless the user explicitly requests it. +- Do not commit unless the user asks ([project git norms](../development/branching-strategy.md)). + +## Cursor Tooling Notes + +- Use the repo’s docs and code as source of truth; do not invent registries. +- Run pytest for the touched service before claiming tests passed. +- For docs-only phases (like framework creation), run documentation/link/manifest validation instead of service pytest. + +## Related Documents + +- [Master Prompt](master-prompt.md) +- [Prompt Rules](prompt-rules.md) +- [Quality Gates](quality-gates.md) +- [Developer Guide](../development/developer-guide.md) diff --git a/docs/ai-framework/development-loop.md b/docs/ai-framework/development-loop.md new file mode 100644 index 0000000..59ec54e --- /dev/null +++ b/docs/ai-framework/development-loop.md @@ -0,0 +1,111 @@ +# Development Loop + +Permanent implementation lifecycle for every TorbatYar phase. + +Agents and humans follow these stages in order. After Quality Gates fail, enter the Automatic Repair Loop until all gates pass, then complete. + +## 1. Requirements Analysis + +1. Read [docs/README.md](../README.md), [AI Framework README](README.md), [master-prompt.md](master-prompt.md). +2. Read architecture (`docs/architecture/*`, `docs/architecture/adr/*`), development standards, registries, glossary. +3. Read the phase brief (or create from [phase-template.md](phase-template.md)). +4. Confirm Objective, Scope, Out of Scope, Dependencies, and Required Services from [phase-manifest.yaml](phase-manifest.yaml). +5. List aggregates, APIs, events, permissions, and docs that must change. +6. Stop if requirements conflict with ADRs or module boundaries — resolve via ADR/docs first. + +**Exit:** Written understanding of in-scope deliverables and explicit non-goals. + +## 2. Architecture Validation + +1. Validate against [module-boundaries.md](../architecture/module-boundaries.md), [database-architecture.md](../architecture/database-architecture.md), [multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md). +2. Confirm Database-per-Service, tenancy, eventing, and FE/BE separation. +3. Confirm service vs module decision ([service-template.md](service-template.md) vs [module-template.md](module-template.md)). +4. Identify required ADRs; create Proposed/Accepted ADR if a new decision is introduced. +5. Confirm no ownership theft (e.g. CRM must not own Loyalty or Communication). + +**Exit:** Architecture checklist green or ADR/docs updated to resolve conflicts. + +## 3. Implementation + +1. Follow [cursor-guidelines.md](cursor-guidelines.md) implementation order. +2. Models / entities per [entity-template.md](entity-template.md). +3. Repositories per [repository-template.md](repository-template.md). +4. Services per [service-layer-template.md](service-layer-template.md). +5. Validators, permissions, events per [event-template.md](event-template.md). +6. APIs / DTOs per [api-template.md](api-template.md). +7. Migrations owned by the service database only. +8. Do not implement out-of-scope features. + +**Exit:** Code matches phase scope; no unrelated service edits. + +## 4. Automated Testing + +1. Apply [testing-template.md](testing-template.md) and [testing-strategy.md](../development/testing-strategy.md). +2. Run the service’s pytest (and relevant sibling suites if contracts changed). +3. Include tenant isolation negatives for new tenant-scoped APIs. +4. Include permission, migration, architecture/boundary, and docs validation tests when the service pattern requires them. + +**Exit:** All required tests pass locally (or in CI for the phase). + +## 5. Self Audit + +1. Re-read Scope / Out of Scope — reject scope creep. +2. Search for TODO/FIXME in touched paths related to claimed deliverables. +3. Verify layering (no business logic in routers/repos). +4. Verify no cross-DB access or foreign model imports. +5. Verify secrets/brand not hardcoded. +6. Verify audit/events for state-changing business actions. + +**Exit:** Self-audit notes with no open blockers. + +## 6. Documentation Update + +1. Follow [documentation-template.md](documentation-template.md). +2. Update phase doc, progress, next-steps, module registry, provider registry (if needed), reference contracts/events/APIs as applicable. +3. Update [phase-manifest.yaml](phase-manifest.yaml) / [service-manifest.yaml](service-manifest.yaml) status and versions. +4. Fill [phase-handover.md](phase-handover.md) sections for the phase (or phase-specific handover section in the phase doc). + +**Exit:** Docs consistent with code; cross-links valid. + +## 7. Quality Gates + +Run every gate in [quality-gates.md](quality-gates.md): + +- Architecture · Security · Performance · Testing · Documentation · Migration · Backward Compatibility · Tenant Isolation + +**Exit:** All gates Pass, or Fail with a concrete defect list. + +## 8. Automatic Repair Loop + +While any gate fails: + +1. Classify defect (architecture, security, test, docs, link, tenancy, migration, compatibility). +2. Fix the smallest correct change (prefer docs/ADR first when rules conflict). +3. Re-run the failed gate and dependent gates. +4. Do not mark the phase complete while any gate fails. +5. Do not weaken gates to force completion. + +**Exit:** All gates Pass. + +## 9. Completion Rules + +A phase may be marked complete only when: + +1. Code and tests for in-scope work are done and green. +2. Documentation and registries are updated. +3. ADR updated/created if required. +4. Quality gates all Pass. +5. Handover package is complete ([phase-handover.md](phase-handover.md)). +6. [progress.md](../progress.md) records completion; [next-steps.md](../next-steps.md) points to the next milestone. +7. No TODO remains for claimed deliverables. +8. Final verification checklist in [docs/README.md](../README.md) Phase Completion Gate is satisfied. + +After completion: stop. Do not start the next phase unless explicitly requested. + +## Related Documents + +- [Master Prompt](master-prompt.md) +- [Quality Gates](quality-gates.md) +- [Phase Handover](phase-handover.md) +- [Cursor Guidelines](cursor-guidelines.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/ai-framework/documentation-template.md b/docs/ai-framework/documentation-template.md new file mode 100644 index 0000000..44c2f9f --- /dev/null +++ b/docs/ai-framework/documentation-template.md @@ -0,0 +1,63 @@ +# Documentation Template + +How documentation must be updated after every phase. + +## Principle + +Code and documentation ship together. A phase that changes behavior without docs is incomplete ([project-principles.md](../development/project-principles.md)). + +## Update Checklist + +### Always + +- [ ] Phase document created/updated (from [phase-template.md](phase-template.md)) +- [ ] [phase-handover.md](phase-handover.md) sections filled (or equivalent in phase doc) +- [ ] [progress.md](../progress.md) — completed work only +- [ ] [next-steps.md](../next-steps.md) — immediate next milestone only +- [ ] [module-registry.md](../module-registry.md) — version, phase, events, permissions, migration +- [ ] [phase-manifest.yaml](phase-manifest.yaml) — status / dependencies +- [ ] [service-manifest.yaml](service-manifest.yaml) — version / phase / APIs / events if service changed + +### When Applicable + +- [ ] [roadmap.md](../roadmap.md) — only if future sequencing changed +- [ ] [provider-registry.md](../provider-registry.md) — new/changed providers +- [ ] [glossary.md](../glossary.md) — new canonical terms +- [ ] ADR created/updated under [architecture/adr/](../architecture/adr/) +- [ ] Architecture docs if boundaries/topology changed (never silently) +- [ ] [reference/api-reference.md](../reference/api-reference.md) +- [ ] [reference/services-contracts.md](../reference/services-contracts.md) +- [ ] [reference/event-catalog.md](../reference/event-catalog.md) +- [ ] [reference/database-schema.md](../reference/database-schema.md) +- [ ] Service README under `backend/services//` +- [ ] Phase area README under [phases/](../phases/) +- [ ] Frontend docs under [frontend/](../frontend/) if UI shipped + +### Never + +- Do not put future work into `progress.md` +- Do not overwrite accepted ADRs +- Do not duplicate [master-prompt.md](master-prompt.md) rules into phase prompts +- Do not leave broken links or undocumented public surfaces for the phase + +## Cross-Link Requirements + +Every new phase doc must link at least: + +- Architecture overview or relevant architecture leaf +- Related ADR(s) +- Module registry entry +- Development standards (principles / testing) as needed +- AI framework loop/gates when the phase is AI-implemented +- Progress / next-steps + +## Validation + +Before completion, run Documentation + Cross Reference + Broken Link validation from [quality-gates.md](quality-gates.md). + +## Related Documents + +- [docs/README.md](../README.md) +- [Phase Template](phase-template.md) +- [Phase Handover](phase-handover.md) +- [templates/](../templates/) (registry-oriented short templates) diff --git a/docs/ai-framework/entity-template.md b/docs/ai-framework/entity-template.md new file mode 100644 index 0000000..6362b0d --- /dev/null +++ b/docs/ai-framework/entity-template.md @@ -0,0 +1,77 @@ +# Entity Template + +Standards for domain entities / ORM models in every service. + +## Entities + +| Rule | Detail | +| --- | --- | +| Naming | `PascalCase` class; `snake_case` table | +| Primary key | UUID (preferred) or documented exception | +| Tenant | Business entities include non-null `tenant_id` ([ADR-003](../architecture/adr/ADR-003.md)) | +| Timestamps | `created_at` / `updated_at` (timezone-aware) | +| Identity | No business meaning in surrogate keys | +| Mapping | SQLAlchemy models in `app/models/`; no business workflows in model methods beyond trivial helpers | + +## Relationships + +1. Relationships only within the same database. +2. No cross-service foreign keys. +3. Cross-service association: store external ref IDs (`*_ref`, UUID strings) without FK. +4. Prefer explicit relationship directions; avoid implicit cascade deletes across aggregates unless documented. +5. Owning aggregate controls lifecycle of child rows. + +## Indexes + +1. Index `tenant_id` (alone or composite) for tenant-scoped lists. +2. Composite indexes for common filters: `(tenant_id, status)`, `(tenant_id, created_at)`, unique business numbers per tenant. +3. Unique constraints that are tenant-scoped must include `tenant_id`. +4. Document unusual indexes in the phase/migration notes. + +## Constraints + +1. DB constraints enforce integrity the domain relies on (unique, check, non-null). +2. Do not rely only on application validation for critical invariants. +3. Monetary / quantity fields use precise types (Numeric) where applicable. +4. Enums: DB + Python enums kept in sync; prefer string enums for readability. + +## Soft Delete + +| Rule | Detail | +| --- | --- | +| Default for master/business records | Prefer `deleted_at` / `is_deleted` pattern used by sibling modules | +| Queries | Default repositories exclude soft-deleted rows | +| Uniqueness | Unique constraints must consider soft-delete strategy (partial unique indexes when required) | +| Hard delete | Only when phase explicitly allows (e.g. ephemeral logs with retention policy) | + +## Audit + +1. Every business action must be auditable ([project-principles.md](../development/project-principles.md)). +2. Prefer `created_by` / `updated_by` (actor ids) on mutable entities when the service pattern uses them. +3. Sensitive domains keep append-only audit tables (CRM/Loyalty/Accounting patterns). +4. Audit rows are tenant-scoped and immutable after insert. + +## Validation + +1. Structural validation: Pydantic schemas at API boundary. +2. Domain validation: service-layer validators (`app/validators/`). +3. DB constraints: last line of defense. +4. Never trust client-provided `tenant_id` over resolved tenant context. +5. Normalize phones/codes via shared helpers where applicable. + +## Entity Checklist (per new table) + +- [ ] Table name + model documented in phase +- [ ] `tenant_id` (if business data) +- [ ] Indexes / uniques defined +- [ ] Soft delete policy stated +- [ ] Audit fields or audit table linkage +- [ ] Migration included +- [ ] Schema reference updated when public/shared docs require it ([database-schema.md](../reference/database-schema.md)) + +## Related Documents + +- [Database Architecture](../architecture/database-architecture.md) +- [Repository Template](repository-template.md) +- [Coding Standards](../development/coding-standards.md) +- [ADR-001](../architecture/adr/ADR-001.md) · [ADR-003](../architecture/adr/ADR-003.md) diff --git a/docs/ai-framework/event-template.md b/docs/ai-framework/event-template.md new file mode 100644 index 0000000..451e225 --- /dev/null +++ b/docs/ai-framework/event-template.md @@ -0,0 +1,70 @@ +# Event Template + +Standards for domain and integration events. + +## Domain Events + +- Emitted when an aggregate state change may matter inside or outside the service. +- Written via outbox in the same DB transaction as the write ([ADR-006](../architecture/adr/ADR-006.md)). +- Consumers treat events as facts; producers do not assume synchronous handling. + +## Integration Events + +- Cross-service contracts for interoperability (same envelope). +- Stable payload fields; evolve additively when possible. +- Consumers must be idempotent (inbox / `event_id`). + +## Naming + +Preferred forms (follow the owning service’s established style): + +| Style | Example | When | +| --- | --- | --- | +| `{aggregate}.{past_tense}` | `tenant.created` | Core / generic | +| `{service}.{aggregate}.{past_tense}` | `crm.lead.created`, `loyalty.member.enrolled` | Namespaced business services | + +Use past tense. Do not use generic names like `update` without the aggregate. + +## Versioning + +1. Envelope fields remain stable ([event-driven-architecture.md](../architecture/event-driven-architecture.md)). +2. Payload additive changes preferred. +3. Breaking payload changes: new event type or explicit `payload_version` documented in catalog and handover. +4. Never reuse an `event_type` string for a different meaning. + +## Payload Rules + +Envelope (canonical): + +```json +{ + "event_id": "uuid", + "event_type": "service.aggregate.past_tense", + "aggregate_type": "aggregate", + "aggregate_id": "uuid", + "tenant_id": "uuid|null", + "source_service": "service-name", + "payload": {}, + "occurred_at": "ISO-8601" +} +``` + +Payload must: + +- Include enough IDs for consumers to fetch details via API if needed +- Avoid embedding secrets or large binaries +- Prefer refs over duplicated foreign aggregates +- Remain JSON-serializable + +## Catalog & Registration + +1. List new events in the phase doc and [event-catalog.md](../reference/event-catalog.md). +2. Update [module-registry.md](../module-registry.md) Events fields. +3. Update [service-manifest.yaml](service-manifest.yaml) event list. + +## Related Documents + +- [Event-Driven Architecture](../architecture/event-driven-architecture.md) +- [ADR-006](../architecture/adr/ADR-006.md) +- [Services Contracts](../reference/services-contracts.md) +- [Service Layer Template](service-layer-template.md) diff --git a/docs/ai-framework/master-prompt.md b/docs/ai-framework/master-prompt.md new file mode 100644 index 0000000..d8f3594 --- /dev/null +++ b/docs/ai-framework/master-prompt.md @@ -0,0 +1,135 @@ +# Master Prompt — Global Project Rules + +Permanent instructions for every AI-assisted implementation on TorbatYar. + +**Do not copy this file into phase prompts.** Phase prompts reference it and describe only the current phase ([prompt-rules.md](prompt-rules.md)). + +## Global Project Rules + +1. TorbatYar is a multi-tenant, modular, API-first, microservice-ready SuperApp SaaS. +2. Source of truth for docs: [`docs/README.md`](../README.md). Code and docs ship together. +3. Brand, colors, and secrets are never hardcoded — config / `.env` / database only. +4. Frontend (`frontend/`) and Backend (`backend/`) are strictly separated ([ADR-002](../architecture/adr/ADR-002.md)). +5. No phase is complete with failing tests, missing docs, broken links, or leftover TODOs for claimed work. +6. If implementation conflicts with architecture or ADRs: **stop**, explain, fix docs/ADR first, then code. +7. Do not modify unrelated completed modules when executing a scoped phase. +8. Prefer extending existing patterns from Core, Identity, Accounting, CRM, Loyalty, and Communication over inventing new structures. + +## Architecture Rules + +1. Database-per-service — no cross-DB queries or foreign keys ([ADR-001](../architecture/adr/ADR-001.md)). +2. Inter-service communication: REST, Webhook, Async Event, Outbox/Inbox only ([ADR-006](../architecture/adr/ADR-006.md)). +3. Internal layering: `API → Services → Repositories → Models` ([service-architecture.md](../architecture/service-architecture.md)). +4. Business logic only in Services; repositories are persistence-only; routers stay thin ([project-principles.md](../development/project-principles.md)). +5. Respect [module-boundaries.md](../architecture/module-boundaries.md) — never take ownership of another module’s aggregates. +6. Accounting journal entries only via Posting Engine ([ADR-010](../architecture/adr/ADR-010.md)). +7. Product AI is optional and independent ([ai-architecture.md](../architecture/ai-architecture.md)) — core flows must work when AI is off. +8. New architectural decisions require a new ADR before conflicting implementation ([adr/](../architecture/adr/)). + +## Multi-Tenancy Rules + +1. Every business table carries `tenant_id` ([ADR-003](../architecture/adr/ADR-003.md)). +2. Every business read/write filters by tenant context. +3. No cross-tenant queries; platform-admin exceptions must be explicit and audited. +4. Resolve tenant via documented order ([multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md)): `X-Tenant-ID` / slug / host / `current_tenant_id`. +5. Tenant-aware APIs require tenant middleware/deps and negative isolation tests. +6. Entitlement checks go through Core for gated features. + +## Service Boundaries + +1. Each independent service owns exactly one database and its Alembic migrations. +2. Services must not import another service’s models, repositories, or private modules. +3. Cross-service references use external IDs / refs only — never shared tables. +4. Provider adapters live inside the owning service (e.g. SMS providers in Communication only — [ADR-012](../architecture/adr/ADR-012.md)). +5. Shared library (`backend/shared-lib`) holds envelopes, JWT helpers, exceptions — not business workflows. +6. Register new services in [service-manifest.yaml](service-manifest.yaml) and [module-registry.md](../module-registry.md). + +## Documentation Rules + +1. Update documentation in the same phase as code ([documentation-template.md](documentation-template.md)). +2. Never overwrite accepted ADRs; supersede them. +3. Update [progress.md](../progress.md) only for completed work; [roadmap.md](../roadmap.md) for future; [next-steps.md](../next-steps.md) for the immediate milestone. +4. Keep [module-registry.md](../module-registry.md) and [provider-registry.md](../provider-registry.md) current. +5. Use glossary terms from [glossary.md](../glossary.md). +6. Cross-link architecture, ADR, registries, development standards, and reference docs. +7. No undocumented public API, event, permission tree, or migration for the phase. +8. Framework permanent rules live under `docs/ai-framework/` — do not duplicate them elsewhere. + +## Coding Rules + +1. Follow [coding-standards.md](../development/coding-standards.md). +2. Python 3.11+ with type hints on public functions; English names. +3. Pydantic DTOs for request/response — never return ORM models from APIs. +4. Feature keys: `{service}.{resource}.{action}`; permissions: `{service}.*` trees. +5. Events: `{aggregate}.{past_tense}` (or service-prefixed where already established, e.g. `crm.*`, `loyalty.*`). +6. Migrations: reviewed Alembic revisions; no silent destructive changes without phase scope. +7. No wildcard imports; raise shared exceptions with stable error codes. +8. Optimistic locking / audit actors where the domain requires them (follow sibling services). + +## Quality Rules + +1. Pass all gates in [quality-gates.md](quality-gates.md). +2. Mandatory tests per [testing-template.md](testing-template.md) and [testing-strategy.md](../development/testing-strategy.md). +3. Architecture, tenant isolation, permission, security, and documentation validation are required for service/module phases. +4. Automatic repair loop until gates are green ([development-loop.md](development-loop.md)). +5. Self-audit before claiming completion ([phase-handover.md](phase-handover.md)). + +## AI Implementation Rules + +1. Read framework + architecture + phase docs before writing code ([cursor-guidelines.md](cursor-guidelines.md)). +2. Implement only the current phase scope — no speculative features. +3. Do not weaken security, tenancy, or boundaries to “make it work.” +4. Prefer contracts/protocols for platform dependencies; do not implement foreign platforms inside a business module. +5. When blocked by missing architecture: create/update ADR and docs first. +6. Never invent permanent global rules inside a phase prompt — extend this framework instead. +7. On inconsistency: repair automatically and re-validate. + +## Dependency Rules + +1. Depend on Core entitlement and Identity/JWT patterns as documented. +2. Call other services only via versioned HTTP APIs or events. +3. External providers go behind adapters registered in the owning service and [provider-registry.md](../provider-registry.md). +4. Do not add heavy dependencies without documenting them in the service README and requirements. +5. Circular service dependencies are forbidden; use events for inverse flows. +6. Frontend depends on APIs only — never on backend packages. + +## Platform Principles + +Mirror of [project-principles.md](../development/project-principles.md): + +- Tenant-aware · Auditable · Service-layer logic · Thin API · Tests required · Docs required · No TODO in completed phases · No cross-tenant query · Posting Engine only for journals · Compliance independent · AI independent · Event-ready · API-first · Documented · Database-per-service · FE/BE separation · No hardcoded brand/secrets · Docs before conflicting code · Phase completion gate. + +## Rules for Adding New Services + +1. Follow [service-template.md](service-template.md). +2. Create `backend/services//` with independent DB, Alembic, health endpoint, permissions, events. +3. Register in Core service/module registry (when wiring), [service-manifest.yaml](service-manifest.yaml), [module-registry.md](../module-registry.md). +4. Document public APIs in [reference/](../reference/) as contracts stabilize. +5. Add compose/env samples only when the phase scopes runtime wiring. +6. Create ADR when the service introduces a platform-level ownership decision (see Loyalty ADR-011, Communication ADR-012). +7. UI belongs in `frontend/` only. + +## Rules for Adding New Modules + +1. Prefer a module inside an existing owning service when the domain clearly belongs there ([module-template.md](module-template.md)). +2. If the capability is shared across many business domains, create an independent service instead (Loyalty, Communication pattern). +3. Update module registry, phase docs, permissions, events, and tests. +4. Do not expand a Sales CRM module into Customer360, Marketing, or Messaging ownership. + +## Rules for Extending Existing Services + +1. Stay within published boundaries; do not absorb foreign aggregates. +2. Additive migrations preferred; breaking API changes require versioning strategy ([api-template.md](api-template.md)). +3. Preserve tenant isolation and audit trails. +4. Update events/catalog, permissions, tests, progress, next-steps, and handover. +5. Do not “drive-by” refactor unrelated packages. +6. Backward compatibility is a quality gate unless the phase explicitly documents a breaking change with migration notes. + +## Related Documents + +- [AI Framework README](README.md) +- [Development Loop](development-loop.md) +- [Quality Gates](quality-gates.md) +- [Architecture Overview](../architecture/architecture.md) +- [Project Principles](../development/project-principles.md) +- [ADR Index](../architecture/adr/README.md) diff --git a/docs/ai-framework/module-template.md b/docs/ai-framework/module-template.md new file mode 100644 index 0000000..9e617a5 --- /dev/null +++ b/docs/ai-framework/module-template.md @@ -0,0 +1,84 @@ +# Module Template + +How to implement a **module** (bounded feature set) inside an **existing** service. + +Use when the domain clearly belongs to an already-owned database/service (e.g. CRM sales engine phases inside `crm`, Accounting sub-phases inside `accounting`). + +If the capability must be reused across many business domains, create an independent service ([service-template.md](service-template.md)) instead. + +Registry field template: [templates/module-template.md](../templates/module-template.md). + +## When to Add a Module vs a Service + +| Choose module | Choose service | +| --- | --- | +| Same aggregate ownership | Shared platform capability | +| Same DB already owns the data | New sole DB required | +| Extends existing permission prefix | New permission prefix `{service}.*` | +| Phase slice of an active service | Cross-module reuse (Loyalty, Communication) | + +## Design Steps + +1. Confirm ownership in [module-boundaries.md](../architecture/module-boundaries.md) and [module-registry.md](../module-registry.md). +2. Define Objective / Scope / Out of Scope with [phase-template.md](phase-template.md). +3. Add entities ([entity-template.md](entity-template.md)), repositories, services, validators, permissions, events, APIs. +4. Additive Alembic migration in the **owning** service only. +5. Tests + docs + handover. +6. Update module registry “Current Phase” / version / events / permissions. + +## Module Checklist + +### Architecture + +- [ ] Stays within service boundary +- [ ] No cross-DB access +- [ ] No foreign model imports +- [ ] Provider contracts only if calling platforms + +### Ownership + +| Field | Value | +| --- | --- | +| Parent service | | +| Permission prefix | (usually parent’s `{service}.*`) | +| Database | Parent DB | +| Public surface | APIs + events listed in phase doc | + +### Implementation Layers + +| Layer | Standard | +| --- | --- | +| Models | [entity-template.md](entity-template.md) | +| Repositories | [repository-template.md](repository-template.md) | +| Services | [service-layer-template.md](service-layer-template.md) | +| APIs | [api-template.md](api-template.md) | +| Events | [event-template.md](event-template.md) | +| Tests | [testing-template.md](testing-template.md) | + +### Tenant Awareness + +- [ ] `tenant_id` on business tables +- [ ] Filters on all queries +- [ ] Isolation tests + +### Documentation + +- [ ] Phase doc +- [ ] Module registry entry updated +- [ ] Event catalog / API reference if public surface changed +- [ ] Progress / next-steps +- [ ] Handover + +### Non-Responsibilities + +List what this module must **not** own (copy from parent boundaries and phase Out of Scope): + +- + +## Related Documents + +- [Service Template](service-template.md) +- [Master Prompt](master-prompt.md) +- [Module Boundaries](../architecture/module-boundaries.md) +- [Module Registry](../module-registry.md) +- [templates/module-template.md](../templates/module-template.md) diff --git a/docs/ai-framework/phase-handover.md b/docs/ai-framework/phase-handover.md new file mode 100644 index 0000000..fddc6ba --- /dev/null +++ b/docs/ai-framework/phase-handover.md @@ -0,0 +1,103 @@ +# Phase Handover + +Every implementation phase must deliver this package before completion. + +Copy the sections below into the phase document (or attach a phase-specific handover file) and fill every field. Empty sections are only allowed when marked **N/A** with justification. + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | (from [phase-manifest.yaml](phase-manifest.yaml)) | +| Title | | +| Status | Complete | +| Service(s) | | +| Version | | +| Date | | +| ADR(s) | | + +## Reusable Components + +List libraries, engines, providers, shared helpers, or patterns introduced for reuse. + +| Component | Location | Reuse notes | +| --- | --- | --- | +| | | | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| | | | | + +Link detailed contracts: [api-reference.md](../reference/api-reference.md), [services-contracts.md](../reference/services-contracts.md). + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| | | | | + +Catalog: [event-catalog.md](../reference/event-catalog.md). Standards: [event-template.md](event-template.md). + +## Extension Points + +Document intentional hooks for future phases (provider protocols, strategy interfaces, feature flags, entitlement keys). + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| | | | + +## Known Limitations + +Explicit non-goals and temporary constraints (must not be silently omitted). + +- + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | | +| Upgrade steps | | +| Downgrade support | | +| Data backfill | | +| Breaking changes | None / described | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| | Core / Service / Provider / Docs | | + +## Next Phase Entry + +What the next phase must read and assume: + +1. This handover + phase doc +2. Updated [module-registry.md](../module-registry.md) / manifests +3. Open limitations that become next-phase scope +4. Suggested next phase ID from [phase-manifest.yaml](phase-manifest.yaml) / [next-steps.md](../next-steps.md) + +| Field | Value | +| --- | --- | +| Recommended next phase | | +| Blockers for next phase | | +| Entry checklist | | + +## Completion Sign-Off + +- [ ] Quality gates passed ([quality-gates.md](quality-gates.md)) +- [ ] Tests green +- [ ] Documentation updated ([documentation-template.md](documentation-template.md)) +- [ ] Progress / next-steps / registries updated +- [ ] No TODO for claimed deliverables +- [ ] Self audit completed ([development-loop.md](development-loop.md)) + +## Related Documents + +- [Phase Template](phase-template.md) +- [Development Loop](development-loop.md) +- [Module Registry](../module-registry.md) +- [Progress](../progress.md) +- [Next Steps](../next-steps.md) diff --git a/docs/ai-framework/phase-manifest.yaml b/docs/ai-framework/phase-manifest.yaml new file mode 100644 index 0000000..17f8932 --- /dev/null +++ b/docs/ai-framework/phase-manifest.yaml @@ -0,0 +1,932 @@ +# Phase Manifest + +# Registry of implementation phases for TorbatYar. +# Status: planned | in_progress | complete | deferred +# Agents must keep this file aligned with docs/progress.md and docs/roadmap.md. + +schema_version: 1 +updated: "2026-07-25" +# Delivery Platform registration complete; next delivery-10.0 Foundation. +# Loyalty Phase 7.1 complete; 7.2 Point Engine next for Loyalty track. + +phases: + - id: core-1 + name: Core Platform + area: Platform + status: complete + dependencies: [] + required_documents: + - docs/architecture/architecture.md + - docs/development/project-principles.md + required_services: + - core-platform + + - id: identity-2 + name: Identity & Access + SSO + area: Platform + status: complete + dependencies: + - core-1 + required_documents: + - docs/architecture/identity-architecture.md + - docs/architecture/adr/ADR-004.md + required_services: + - identity-access + - core-platform + + - id: otp-tenant-3 + name: OTP Login + Tenant Management + area: Platform + status: complete + dependencies: + - identity-2 + required_documents: + - docs/architecture/adr/ADR-005.md + - docs/architecture/multi-tenant-architecture.md + required_services: + - core-platform + + - id: onboarding-4 + name: Tenant Onboarding & Workspace Activation + area: Platform + status: complete + dependencies: + - otp-tenant-3 + required_documents: + - docs/architecture/multi-tenant-architecture.md + - docs/architecture/adr/ADR-008.md + - docs/architecture/adr/ADR-009.md + required_services: + - core-platform + - identity-access + + - id: docs-architecture-D + name: Documentation Architecture Consolidation + area: Platform + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/README.md + - docs/architecture/adr/README.md + required_services: [] + + - id: ai-framework + name: AI Development Framework + area: Platform + status: complete + dependencies: + - docs-architecture-D + required_documents: + - docs/ai-framework/README.md + - docs/ai-framework/master-prompt.md + - docs/ai-framework/development-loop.md + - docs/ai-framework/quality-gates.md + - docs/architecture/adr/ADR-013.md + required_services: [] + + # --- Accounting --- + - id: accounting-5.1 + name: Accounting Foundation + area: Accounting + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/phases/Accounting/README.md + - docs/architecture/adr/ADR-010.md + required_services: + - accounting + + - id: accounting-5.2 + name: Double-Entry Posting Engine + area: Accounting + status: complete + dependencies: + - accounting-5.1 + required_documents: + - docs/phases/Accounting/phase-5.2-double-entry-posting-engine.md + - docs/architecture/adr/ADR-010.md + required_services: + - accounting + + - id: accounting-5.3 + name: General Ledger & Fiscal Management + area: Accounting + status: complete + dependencies: + - accounting-5.2 + required_documents: + - docs/phases/Accounting/phase-5.3-general-ledger-fiscal-management.md + required_services: + - accounting + + - id: accounting-5.4 + name: Treasury Management + area: Accounting + status: complete + dependencies: + - accounting-5.3 + required_documents: + - docs/phases/Accounting/phase-5.4-treasury-management.md + required_services: + - accounting + + - id: accounting-5.5 + name: Accounts Receivable & Payable + area: Accounting + status: complete + dependencies: + - accounting-5.4 + required_documents: + - docs/phases/Accounting/phase-5.5-accounts-receivable-payable.md + required_services: + - accounting + + - id: accounting-5.6 + name: Sales Accounting Integration + area: Accounting + status: complete + dependencies: + - accounting-5.5 + required_documents: + - docs/phases/Accounting/phase-5.6-sales-accounting-integration.md + required_services: + - accounting + + - id: accounting-5.7 + name: Purchase & Inventory Accounting + area: Accounting + status: complete + dependencies: + - accounting-5.6 + required_documents: + - docs/phases/Accounting/phase-5.7-purchase-inventory-accounting-integration.md + required_services: + - accounting + + - id: accounting-5.8 + name: Fixed Assets + area: Accounting + status: complete + dependencies: + - accounting-5.7 + required_documents: + - docs/phases/Accounting/phase-5.8-fixed-assets-management.md + required_services: + - accounting + + - id: accounting-5.9 + name: HCM / Payroll Accounting + area: Accounting + status: complete + dependencies: + - accounting-5.8 + required_documents: + - docs/phases/Accounting/phase-5.9-hcm-payroll-accounting.md + required_services: + - accounting + + - id: accounting-5.10 + name: Financial Reporting & BI + area: Accounting + status: complete + dependencies: + - accounting-5.9 + required_documents: + - docs/phases/Accounting/phase-5.10-financial-reporting-business-intelligence.md + required_services: + - accounting + + - id: accounting-5.11 + name: Compliance, Audit & Governance + area: Accounting + status: complete + dependencies: + - accounting-5.10 + required_documents: + - docs/phases/Accounting/phase-5.11-enterprise-compliance-audit-governance.md + required_services: + - accounting + + - id: accounting-5.12 + name: Accounting AI Assistants + area: Accounting + status: planned + dependencies: + - accounting-5.11 + - ai-framework + required_documents: + - docs/architecture/ai-architecture.md + - docs/phases/Accounting/README.md + required_services: + - accounting + + # --- CRM --- + - id: crm-6.0 + name: CRM Service Foundation + area: CRM + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/crm-phase-6-0.md + - docs/phases/CRM/README.md + required_services: + - crm + + - id: crm-6.1 + name: CRM Core Business Entities + area: CRM + status: complete + dependencies: + - crm-6.0 + required_documents: + - docs/crm-phase-6-1.md + required_services: + - crm + + - id: crm-6.2 + name: CRM Sales Process Engine + area: CRM + status: complete + dependencies: + - crm-6.1 + required_documents: + - docs/crm-phase-6-2.md + required_services: + - crm + + - id: crm-6.3 + name: CRM Sales Collaboration + area: CRM + status: complete + dependencies: + - crm-6.2 + required_documents: + - docs/crm-phase-6-3.md + required_services: + - crm + + # --- Loyalty --- + - id: loyalty-7.0 + name: Loyalty Service Foundation + area: Loyalty + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/loyalty-phase-7-0.md + - docs/architecture/adr/ADR-011.md + - docs/phases/Loyalty/README.md + required_services: + - loyalty + + - id: loyalty-7.1 + name: Membership Engine + area: Loyalty + status: complete + dependencies: + - loyalty-7.0 + - ai-framework + required_documents: + - docs/loyalty-phase-7-1.md + - docs/phase-handover/phase-7-1.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + required_services: + - loyalty + + - id: loyalty-7.2 + name: Point Engine + area: Loyalty + status: planned + dependencies: + - loyalty-7.1 + required_documents: + - docs/phases/Loyalty/README.md + - docs/ai-framework/phase-template.md + required_services: + - loyalty + + # --- SMS / Communication --- + - id: communication-8 + name: Enterprise Communication Platform (SMS-first) + area: SMS + status: complete + dependencies: + - onboarding-4 + required_documents: + - docs/communication-phase-8.md + - docs/architecture/adr/ADR-012.md + required_services: + - communication + + - id: sms-panel-future + name: Legacy SMS Panel Scaffold Alignment + area: SMS + status: deferred + dependencies: + - communication-8 + required_documents: + - docs/module-registry.md + required_services: + - sms_panel + notes: Prefer Communication service (ADR-012); sms_panel scaffold remains historical/planned alignment only. + + # --- Sports Center (Phase 9.0–9.10) --- + - id: sports-center-9.0 + name: Sports Center Foundation + area: Sports Center + description: Independent sports_center service scaffold, sports_center_db, health/capabilities, permissions sports_center.*, publish-only events, tenant isolation, audit shell. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/architecture/adr/ADR-014.md + - docs/sports-center-phase-9-0.md + - docs/phase-handover/phase-9-0.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - sports_center + completion_criteria: + - Service scaffold under backend/services/sports_center with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health endpoint green; permission tree sports_center.* documented + - Tenant isolation + architecture + docs tests green + - Module registry, manifests, progress, handover updated + - Quality gates passed; no business engines beyond foundation + + - id: sports-center-9.1 + name: Membership Catalog + area: Sports Center + description: Membership types, packages, plans, pricing models, age groups, sport categories, membership rules, renewal/freezing/expiration policies, validation, events, APIs. + status: complete + dependencies: + - sports-center-9.0 + required_previous_phase: sports-center-9.0 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/architecture/adr/ADR-014.md + - docs/sports-center-phase-9-1.md + - docs/phase-handover/phase-9-1.md + - docs/ai-framework/module-template.md + required_services: + - sports_center + completion_criteria: + - Membership catalog aggregates implemented with tenant isolation + - APIs, validators, permissions, events, tests, docs, handover complete + - No member enrollment / coach / attendance / booking engines + + - id: sports-center-9.2 + name: Member Management + area: Sports Center + description: Members, family members, medical information, emergency contacts, membership assignment, cards/QR/digital membership, waivers, documents, attachments, status management. + status: complete + dependencies: + - sports-center-9.1 + required_previous_phase: sports-center-9.1 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/phase-handover/phase-9-1.md + - docs/sports-center-phase-9-2.md + - docs/phase-handover/phase-9-2.md + - docs/ai-framework/module-template.md + required_services: + - sports_center + completion_criteria: + - Member management APIs + validators + tests green + - Consumes Membership Catalog from 9.1; does not recreate catalog tables + - No coach/attendance/booking engines yet + + - id: sports-center-9.3 + name: Coach & Staff Management + area: Sports Center + description: Coaches, trainers, nutritionists, medical staff, reception, managers, working hours, certificates, skills, payroll refs, availability, permissions. + status: planned + dependencies: + - sports-center-9.2 + required_previous_phase: sports-center-9.2 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/ai-framework/module-template.md + required_services: + - sports_center + completion_criteria: + - Coach and staff modules complete with APIs, permissions, events, tests, docs + - No attendance devices or booking engine yet + + - id: sports-center-9.4 + name: Scheduling & Booking + area: Sports Center + description: Sports classes, sessions, timetables, reservations, capacity, waiting lists, recurring sessions, resource allocation, facilities, equipment reservation, calendar. + status: planned + dependencies: + - sports-center-9.3 + required_previous_phase: sports-center-9.3 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Booking and scheduling APIs and rules green + - Tests include conflict/tenant isolation; docs/handover updated + + - id: sports-center-9.5 + name: Attendance & Access Control + area: Sports Center + description: Attendance, QR/RFID/barcode/face-ready check-in, manual attendance, entry/exit gates, late arrival, absence, attendance history. + status: planned + dependencies: + - sports-center-9.4 + required_previous_phase: sports-center-9.4 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Attendance and access control complete with isolation/security tests + - Vendor adapters remain outside business services + + - id: sports-center-9.6 + name: Training Management + area: Sports Center + description: Training programs, exercises, workout plans, goals, measurements, progress tracking, nutrition plans, body composition, assessments, performance records. + status: planned + dependencies: + - sports-center-9.5 + required_previous_phase: sports-center-9.5 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Training/workout modules complete with APIs, events, tests, docs + - No competitions module yet + + - id: sports-center-9.7 + name: Competition & Event Management + area: Sports Center + description: Competitions, tournaments, matches, teams, groups, registrations, rankings, results, medals, certificates, judges, schedules. + status: planned + dependencies: + - sports-center-9.6 + required_previous_phase: sports-center-9.6 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + required_services: + - sports_center + completion_criteria: + - Competitions and events modules complete; no payment posting ownership + - Tests, docs, handover complete + + - id: sports-center-9.8 + name: Financial Integration + area: Sports Center + description: Integrate Accounting, CRM, Loyalty, Communication, payments, invoices, subscriptions, discounts, refunds, installments via API/Events only — no business logic duplication. + status: planned + dependencies: + - sports-center-9.7 + - accounting-5.11 + - crm-6.3 + - loyalty-7.0 + - communication-8 + required_previous_phase: sports-center-9.7 + required_documents: + - docs/sports-center-roadmap.md + - docs/architecture/adr/ADR-010.md + - docs/architecture/adr/ADR-011.md + - docs/architecture/adr/ADR-012.md + - docs/architecture/adr/ADR-014.md + required_services: + - sports_center + - accounting + - crm + - loyalty + - communication + completion_criteria: + - Integrations via API/events only; no journal creation inside Sports Center + - Tests prove no cross-DB access; docs/contracts updated + + - id: sports-center-9.9 + name: AI & Analytics + area: Sports Center + description: Integrate Enterprise AI Platform for recommendations, attendance/retention/churn prediction, workout suggestions, capacity forecasting, dashboards and KPIs — AI optional. + status: planned + dependencies: + - sports-center-9.8 + - ai-framework + required_previous_phase: sports-center-9.8 + required_documents: + - docs/sports-center-roadmap.md + - docs/architecture/ai-architecture.md + - docs/phases/AI/README.md + required_services: + - sports_center + completion_criteria: + - Analytics/AI surfaces documented/implemented per scope + - Core flows work when AI is off + - Tests and docs complete + + - id: sports-center-9.10 + name: Enterprise Validation + area: Sports Center + description: Full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, dependency checks, self-heal until green. + status: planned + dependencies: + - sports-center-9.9 + required_previous_phase: sports-center-9.9 + required_documents: + - docs/sports-center-roadmap.md + - docs/phases/SportsCenter/README.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-handover.md + - docs/deployment/production.md + required_services: + - sports_center + completion_criteria: + - All Sports Center quality gates passed + - Production readiness notes complete + - Module registry version/phase updated; progress marks 9.10 complete + - No undocumented public API/event/permission + + + # --- Delivery & Fleet Platform (Phase 10.0–10.10) --- + - id: delivery-reg + name: Delivery & Fleet Platform Registration + area: Delivery + description: Documentation-only registration of independent delivery service, delivery_db, phases 10.0–10.10, ADR-015, module/glossary/boundary updates. No business code. + status: complete + dependencies: + - onboarding-4 + - ai-framework + required_previous_phase: ai-framework + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/architecture/adr/ADR-015.md + - docs/phase-handover/phase-dp-reg.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - delivery + completion_criteria: + - Service registered in service-manifest with delivery_db and delivery.* permissions + - Phases 10.0–10.10 registered in phase-manifest + - Module registry, glossary, module-boundaries, ADR-015 updated + - Progress and next-steps updated; handover complete + - No business code, models, APIs, or migrations in this phase + - Quality gates for documentation/architecture/manifest validation passed + + - id: delivery-10.0 + name: Delivery Platform Foundation + area: Delivery + description: Independent delivery service scaffold, delivery_db, health/capabilities/metrics, permissions delivery.*, publish-only event shells, tenant isolation, audit/config shells, provider/routing-engine contracts only. + status: planned + dependencies: + - delivery-reg + required_previous_phase: delivery-reg + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/architecture/adr/ADR-015.md + - docs/phase-handover/phase-dp-reg.md + - docs/ai-framework/service-template.md + - docs/ai-framework/phase-template.md + required_services: + - delivery + completion_criteria: + - Service scaffold under backend/services/delivery with API → Service → Repository → Model layering + - Alembic initial migration for foundation tables only as scoped + - Health, capabilities, metrics endpoints; permission tree delivery.* documented + - Tenant isolation + architecture + docs tests green + - No dispatch/routing/tracking engines beyond foundation shells + - Quality gates passed; handover complete + + - id: delivery-10.1 + name: Driver Management + area: Delivery + description: Drivers, profiles, credential refs, status lifecycle, permissions, events, APIs. + status: planned + dependencies: + - delivery-10.0 + required_previous_phase: delivery-10.0 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/architecture/adr/ADR-015.md + - docs/ai-framework/module-template.md + required_services: + - delivery + completion_criteria: + - Driver management APIs + validators + tests green + - No fleet/dispatch/routing engines yet + + - id: delivery-10.2 + name: Fleet & Vehicle Types + area: Delivery + description: Fleets, vehicle types catalog, vehicles, assignments shells, permissions, events, APIs. + status: planned + dependencies: + - delivery-10.1 + required_previous_phase: delivery-10.1 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Fleet and vehicle modules complete with tenant isolation tests + - No dispatch engine yet + + - id: delivery-10.3 + name: Availability, Shifts & Working Zones + area: Delivery + description: Driver availability, shift management, working zones, readiness rules. + status: planned + dependencies: + - delivery-10.2 + required_previous_phase: delivery-10.2 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Availability/shift/zone APIs + tests green + - No dispatch/routing engines yet + + - id: delivery-10.4 + name: Pricing, Capabilities & Bundles + area: Delivery + description: Delivery pricing shells, capability flags, capability bundles. + status: planned + dependencies: + - delivery-10.3 + required_previous_phase: delivery-10.3 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Pricing/capabilities/bundles APIs + validators + tests green + - No settlement posting ownership + + - id: delivery-10.5 + name: Dispatch Engine + area: Delivery + description: Dispatch engine, job assignment, dispatcher workflows (API), multi-vertical job refs. + status: planned + dependencies: + - delivery-10.4 + required_previous_phase: delivery-10.4 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Dispatch APIs + isolation/security tests green + - Verticals interact via API/events only + - No deep routing optimization yet (10.6) + + - id: delivery-10.6 + name: Routing & Optimization + area: Delivery + description: Routing plans, multi pickup, multi drop, optimization strategies, future routing engine adapters. + status: planned + dependencies: + - delivery-10.5 + required_previous_phase: delivery-10.5 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Routing/optimization modules complete; external engines via adapters + - Tests and docs complete + + - id: delivery-10.7 + name: Tracking & Proof of Delivery + area: Delivery + description: Live tracking, journey timeline, proof of delivery, customer tracking APIs. + status: planned + dependencies: + - delivery-10.6 + required_previous_phase: delivery-10.6 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + required_services: + - delivery + completion_criteria: + - Tracking and POD APIs + tenant isolation tests green + - Distinct from Communication message delivery tracking + + - id: delivery-10.8 + name: Settlement + area: Delivery + description: Driver/merchant settlement intents; post to Accounting via API/Events only — no journal ownership. + status: planned + dependencies: + - delivery-10.7 + - accounting-5.11 + required_previous_phase: delivery-10.7 + required_documents: + - docs/delivery-roadmap.md + - docs/architecture/adr/ADR-010.md + - docs/architecture/adr/ADR-015.md + required_services: + - delivery + - accounting + completion_criteria: + - Settlement via Accounting APIs/events only; no cross-DB access + - Tests prove no journal creation inside Delivery + + - id: delivery-10.9 + name: Merchant Connector & App Surfaces + area: Delivery + description: Merchant connector for verticals, notifications via Communication, Driver App and Dispatcher Panel API contracts, customer tracking polish. + status: planned + dependencies: + - delivery-10.8 + - communication-8 + required_previous_phase: delivery-10.8 + required_documents: + - docs/delivery-roadmap.md + - docs/architecture/adr/ADR-012.md + - docs/architecture/adr/ADR-015.md + required_services: + - delivery + - communication + completion_criteria: + - Connector + notification client + app API contracts documented/tested + - No SMS provider ownership; UI remains in frontend + + - id: delivery-10.10 + name: Analytics, AI Ready & Enterprise Validation + area: Delivery + description: Fleet analytics shells, AI-ready optional hooks, full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, self-heal until green. + status: planned + dependencies: + - delivery-10.9 + - ai-framework + required_previous_phase: delivery-10.9 + required_documents: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/ai-framework/quality-gates.md + - docs/ai-framework/phase-handover.md + - docs/architecture/ai-architecture.md + required_services: + - delivery + completion_criteria: + - All Delivery quality gates passed + - Core flows work when AI is off + - Module registry version/phase updated; progress marks 10.10 complete + - No undocumented public API/event/permission + + # --- Restaurant --- + - id: restaurant-foundation + name: Restaurant / Cafe Foundation + area: Restaurant + status: planned + dependencies: + - onboarding-4 + - ai-framework + required_documents: + - docs/phases/Restaurant/README.md + - docs/ai-framework/service-template.md + required_services: + - restaurant + + # --- Marketplace / Ecommerce --- + - id: ecommerce-foundation + name: Ecommerce Foundation + area: Marketplace + status: planned + dependencies: + - onboarding-4 + - ai-framework + required_documents: + - docs/phases/Marketplace/README.md + required_services: + - ecommerce + + - id: marketplace-foundation + name: Marketplace Foundation + area: Marketplace + status: planned + dependencies: + - ecommerce-foundation + - ai-framework + required_documents: + - docs/phases/Marketplace/README.md + required_services: + - marketplace + + # --- Automation --- + - id: automation-foundation + name: Automation Engine Foundation + area: Automation + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Automation/README.md + - docs/architecture/event-driven-architecture.md + required_services: + - automation + + # --- Academy --- + - id: academy-foundation + name: Academy Foundation + area: Academy + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - academy + + # --- Clinic --- + - id: clinic-foundation + name: Clinic / Healthcare Foundation + area: Clinic + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - clinic + + # --- Hotel --- + - id: hotel-foundation + name: Hotel Foundation + area: Hotel + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - hotel + + # --- Tourism --- + - id: tourism-foundation + name: Tourism Foundation + area: Tourism + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/ai-framework/service-template.md + required_services: + - tourism + + # --- Product AI --- + - id: ai-assistant-foundation + name: AI Assistant Foundation + area: AI + status: planned + dependencies: + - ai-framework + required_documents: + - docs/architecture/ai-architecture.md + - docs/phases/AI/README.md + - docs/ai-framework/service-template.md + required_services: + - ai_assistant + + # --- Future Services --- + - id: future-services + name: Future Services Portfolio + area: Future Services + status: planned + dependencies: + - ai-framework + required_documents: + - docs/phases/Future/README.md + - docs/roadmap.md + required_services: [] + notes: Catch-all for website_builder, live_chat, smart_messenger, notification, file_storage, link_shortener, and unlisted future modules. diff --git a/docs/ai-framework/phase-template.md b/docs/ai-framework/phase-template.md new file mode 100644 index 0000000..734e9e5 --- /dev/null +++ b/docs/ai-framework/phase-template.md @@ -0,0 +1,156 @@ +# Phase Template + +Reusable template for every implementation phase. Copy to `docs/-phase-.md` or `docs/phases//` and fill all sections. + +Registry-oriented short form also exists at [templates/phase-template.md](../templates/phase-template.md). Prefer **this** template for AI-driven implementation phases. + +--- + +# Phase: \ + +| Field | Value | +| --- | --- | +| Identifier | (e.g. `loyalty-7.1`) | +| Status | Planned / In Progress / Complete | +| Owner | Platform / TBD | +| Module(s) | | +| Service(s) | | +| Depends On | | +| ADR(s) | | +| Manifest | [phase-manifest.yaml](phase-manifest.yaml) | + +## Objective + +One paragraph: measurable outcome of this phase. + +## Scope + +### In Scope + +- + +### Modules + +| Module | Change type | Notes | +| --- | --- | --- | +| | new / extend | | + +### Models + +| Entity | Soft delete | Audit | Tenant | Notes | +| --- | --- | --- | --- | --- | +| | Yes/No | Yes/No | Yes | | + +Standards: [entity-template.md](entity-template.md). + +### Repositories + +| Repository | Aggregates | Tenant filter | +| --- | --- | --- | +| | | Required | + +Standards: [repository-template.md](repository-template.md). + +### Services + +| Service class | Responsibilities | +| --- | --- | +| | | + +Standards: [service-layer-template.md](service-layer-template.md). + +### Validators + +| Validator | Rules | +| --- | --- | +| | | + +### Permissions + +| Permission | Description | +| --- | --- | +| `{service}.{resource}.{action}` | | + +### Events + +| Event | When | +| --- | --- | +| | | + +Standards: [event-template.md](event-template.md). + +### APIs + +| Method | Path | Permission | +| --- | --- | --- | +| | `/api/v1/...` | | + +Standards: [api-template.md](api-template.md). + +### Tests + +Mandatory categories (mark N/A with reason only if truly not applicable): + +- [ ] Unit +- [ ] Repository +- [ ] Service +- [ ] Integration / API +- [ ] Migration +- [ ] Permission +- [ ] Security +- [ ] Performance (if hot path) +- [ ] Tenant Isolation +- [ ] Documentation Validation + +Standards: [testing-template.md](testing-template.md). + +### Documentation + +- [ ] Phase doc (this file) +- [ ] Progress / Next Steps / Roadmap as applicable +- [ ] Module Registry / Provider Registry +- [ ] Reference (API / events / schema) if public surface changed +- [ ] ADR if architectural decision introduced +- [ ] Manifests updated +- [ ] Handover completed + +Standards: [documentation-template.md](documentation-template.md). + +## Out of Scope + +Explicit exclusions (prevent scope creep): + +- + +## Completion Criteria + +- [ ] Objective met +- [ ] Scope delivered; Out of Scope untouched +- [ ] Tests green +- [ ] Quality gates passed ([quality-gates.md](quality-gates.md)) +- [ ] [phase-handover.md](phase-handover.md) filled +- [ ] No TODO for claimed work +- [ ] Development loop completed ([development-loop.md](development-loop.md)) + +## Architecture Impact + +- + +## Database Impact + +- + +## Risks + +- + +## Related Documents + +- [AI Framework](README.md) +- [Master Prompt](master-prompt.md) +- [Module Registry](../module-registry.md) +- [Architecture Overview](../architecture/architecture.md) +- [Project Principles](../development/project-principles.md) +- [Progress](../progress.md) +- [Next Steps](../next-steps.md) +- [Roadmap](../roadmap.md) diff --git a/docs/ai-framework/prompt-rules.md b/docs/ai-framework/prompt-rules.md new file mode 100644 index 0000000..fead4a2 --- /dev/null +++ b/docs/ai-framework/prompt-rules.md @@ -0,0 +1,68 @@ +# Prompt Rules + +How every future AI phase prompt must behave. + +## Core Rules + +1. **Never duplicate permanent instructions.** Do not paste [master-prompt.md](master-prompt.md), project principles, or entire architecture docs into a phase prompt. +2. **Only describe the current phase.** Objective, Scope, Out of Scope, modules/services touched, completion criteria, and phase-specific constraints. +3. **Always use the framework documents.** Instruct the agent to read and follow `docs/ai-framework/` plus architecture, ADRs, registries, and development standards. +4. **Reference, don’t rewrite.** Link to templates, gates, and manifests instead of redefining them. +5. **No silent scope expansion.** Out of Scope must be explicit; agents must not implement adjacent phases. +6. **No business logic in framework prompts.** Framework docs stay process-only; phase prompts carry feature intent. +7. **Completion stops the agent.** After gates pass and handover is done, stop — do not start the next phase unless asked. + +## Required Phase Prompt Skeleton + +```markdown +# Phase + +Follow the permanent AI Development Framework: +- docs/ai-framework/README.md +- docs/ai-framework/master-prompt.md +- docs/ai-framework/development-loop.md +- docs/ai-framework/quality-gates.md +- docs/ai-framework/cursor-guidelines.md +- docs/ai-framework/prompt-rules.md + +Also read docs/README.md, architecture/*, ADRs, development/*, module-registry, glossary, +phase-manifest.yaml, service-manifest.yaml, and this phase’s documents. + +## Objective +... + +## Scope +... + +## Out of Scope +... + +## Required Templates +(list only those that apply: service/module/entity/repository/service-layer/api/event/testing/documentation) + +## Completion Criteria +- Development loop completed +- Quality gates passed +- Phase handover completed +- Registries/manifests/progress/next-steps updated +- Stop after this phase only +``` + +## Forbidden Prompt Behaviors + +- Re-stating Database-per-Service, tenancy, or FE/BE rules in full (link instead) +- Asking to “also quickly do” the next phase +- Allowing TODO leftovers for claimed work +- Instructing agents to bypass quality gates +- Overwriting accepted ADRs + +## When to Extend the Framework + +If a permanent rule is missing, update `docs/ai-framework/` (and ADR if architectural) in a dedicated docs change — do not bury permanent rules inside a single phase prompt. + +## Related Documents + +- [Master Prompt](master-prompt.md) +- [Cursor Guidelines](cursor-guidelines.md) +- [Phase Template](phase-template.md) +- [docs/README.md](../README.md) diff --git a/docs/ai-framework/quality-gates.md b/docs/ai-framework/quality-gates.md new file mode 100644 index 0000000..0267fec --- /dev/null +++ b/docs/ai-framework/quality-gates.md @@ -0,0 +1,107 @@ +# Quality Gates + +Mandatory gates before any implementation phase may be marked complete. + +All gates must **Pass**. On failure, enter the Automatic Repair Loop ([development-loop.md](development-loop.md)). + +## Architecture + +| Check | Pass criteria | +| --- | --- | +| Boundaries | No ownership theft per [module-boundaries.md](../architecture/module-boundaries.md) | +| DB isolation | No cross-DB queries/FKs ([ADR-001](../architecture/adr/ADR-001.md)) | +| Layering | Business logic in services; thin API; persistence-only repos | +| FE/BE | No backend imports in frontend; no UI rules in backend ([ADR-002](../architecture/adr/ADR-002.md)) | +| ADR | New architectural decisions recorded; conflicts resolved | +| Events | Outbox-ready; naming per [event-template.md](event-template.md) | + +## Security + +| Check | Pass criteria | +| --- | --- | +| Auth | Protected routes deny anonymous access | +| Secrets | No hardcoded credentials/brand tokens | +| Errors | No stack/secret leakage | +| Providers | Credentials only in owning service config | +| Align | [security-architecture.md](../architecture/security-architecture.md) | + +## Performance + +| Check | Pass criteria | +| --- | --- | +| Queries | Tenant-scoped lists use sensible indexes; no obvious N+1 in new hot paths | +| Hot paths | Covered or explicitly N/A in phase doc | +| Async | Long-running provider I/O not blocking business transactions when architecture requires async | + +## Testing + +| Check | Pass criteria | +| --- | --- | +| Suite | Required tests from [testing-template.md](testing-template.md) exist and pass | +| Strategy | Aligns with [testing-strategy.md](../development/testing-strategy.md) | +| Claims | Every claimed deliverable has coverage | + +## Documentation + +| Check | Pass criteria | +| --- | --- | +| Updates | [documentation-template.md](documentation-template.md) checklist done | +| Links | No broken relative links in touched docs | +| Registries | Module/provider/manifests consistent with code | +| Glossary | New terms defined when introduced | +| No TODO | No TODO for claimed deliverables | + +## Migration + +| Check | Pass criteria | +| --- | --- | +| Alembic | Revision exists for schema changes in owning service | +| Apply | Upgrade smoke succeeds | +| Ownership | Only owning DB migrated | +| Notes | Handover includes migration notes | + +## Backward Compatibility + +| Check | Pass criteria | +| --- | --- | +| APIs | Additive within version unless phase documents breaking change + consumer plan | +| Events | No silent meaning change of existing `event_type` | +| Data | Existing rows remain valid or backfill documented | + +## Tenant Isolation + +| Check | Pass criteria | +| --- | --- | +| Schema | Business tables have `tenant_id` ([ADR-003](../architecture/adr/ADR-003.md)) | +| Queries | Filters applied | +| Tests | Cross-tenant denial covered | +| Admin exceptions | Explicit and audited | + +## Validation Suites (Framework Phases) + +When changing `docs/ai-framework/` or docs structure, also verify: + +1. **Architecture Validation** — framework links to architecture/ADR correctly; no conflicting rules vs principles. +2. **Documentation Validation** — all listed framework docs exist; index complete. +3. **Cross Reference Validation** — manifests ↔ registries ↔ phase areas agree on names/status where claimed. +4. **Broken Link Validation** — relative Markdown links resolve. +5. **Template Validation** — every template referenced from README exists and has Related Documents. +6. **Manifest Validation** — YAML parses; required fields present; phase/service IDs unique. + +## Sign-Off + +- [ ] Architecture +- [ ] Security +- [ ] Performance +- [ ] Testing +- [ ] Documentation +- [ ] Migration +- [ ] Backward Compatibility +- [ ] Tenant Isolation + +## Related Documents + +- [Development Loop](development-loop.md) +- [Master Prompt](master-prompt.md) +- [docs/README.md](../README.md) Phase Completion Gate +- [Project Principles](../development/project-principles.md) diff --git a/docs/ai-framework/repository-template.md b/docs/ai-framework/repository-template.md new file mode 100644 index 0000000..9ed61cb --- /dev/null +++ b/docs/ai-framework/repository-template.md @@ -0,0 +1,60 @@ +# Repository Template + +Standards for the persistence layer. + +## Responsibility + +Repositories: + +- Load/save aggregates and rows +- Apply query filters (including **mandatory** `tenant_id` for tenant-owned entities) +- Encapsulate SQLAlchemy queries + +Repositories must **not**: + +- Enforce entitlement or workflow decisions +- Call external HTTP APIs +- Publish events (services own outbox writes) +- Map HTTP status codes +- Contain business branching beyond trivial persistence concerns + +## Conventions + +| Topic | Rule | +| --- | --- | +| Location | `app/repositories/` | +| Naming | `{Aggregate}Repository` or grouped foundation repos matching service pattern | +| Constructor | Accept DB session (or unit-of-work) explicitly | +| Tenant | Methods that touch tenant data accept/filter `tenant_id` | +| Soft delete | Default reads exclude soft-deleted unless `include_deleted=True` | +| Return type | Models / rows; services map to DTOs | +| Transactions | Participate in caller-managed session/transaction; do not silently commit policy that breaks outbox atomicity | + +## Required Patterns + +```text +get_by_id(tenant_id, id) -> Model | None +list(tenant_id, *, filters, pagination) -> Sequence[Model] +add(entity) / save(entity) +# optional: soft_delete(tenant_id, id) +``` + +Follow existing service repository base classes when present (`repositories/base.py`). + +## Tenant Isolation + +1. Every query for tenant-owned tables includes `tenant_id`. +2. Updates/deletes must be tenant-scoped (no id-only mutation). +3. Repository tests must include cross-tenant denial cases. + +## Testing + +See [testing-template.md](testing-template.md) — Repository section. + +## Related Documents + +- [Service Architecture](../architecture/service-architecture.md) +- [Entity Template](entity-template.md) +- [Service Layer Template](service-layer-template.md) +- [Coding Standards](../development/coding-standards.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/ai-framework/service-layer-template.md b/docs/ai-framework/service-layer-template.md new file mode 100644 index 0000000..fd376cd --- /dev/null +++ b/docs/ai-framework/service-layer-template.md @@ -0,0 +1,55 @@ +# Service Layer Template + +Standards for business logic services. + +## Responsibility + +Services: + +- Own domain rules and orchestration +- Validate invariants (via validators) +- Authorize at domain level when required (with permission deps at API) +- Write audit records +- Write outbox/domain events in the **same transaction** as state changes +- Call other services only via HTTP clients / events — never via foreign repos + +Services must **not**: + +- Depend on FastAPI `Request` / response objects +- Emit raw SQL bypassing repositories (except rare documented cases) +- Perform provider I/O without going through provider adapters owned by the service +- Mutate another aggregate’s invariants across boundaries without explicit domain rules + +## Conventions + +| Topic | Rule | +| --- | --- | +| Location | `app/services/` | +| Naming | `{Capability}Service` | +| Dependencies | Repositories, validators, event publisher, provider protocols | +| DTOs | Accept/return schema objects or typed domain results — not ORM leakage to API | +| Errors | Raise shared/domain exceptions with stable codes | +| Idempotency | Document for retried operations (OTP, webhooks, messaging) | + +## Transaction & Events + +1. State change + outbox row in one transaction ([ADR-006](../architecture/adr/ADR-006.md)). +2. Event types follow [event-template.md](event-template.md). +3. Failures calling remote systems must not corrupt local invariants; use async patterns where required (Communication pattern). + +## Audit + +- Record actor, action, entity, tenant, timestamp for sensitive mutations. +- Prefer dedicated audit tables where the service already has them. + +## Testing + +Service tests cover business rules with DB or fakes — see [testing-template.md](testing-template.md). + +## Related Documents + +- [Repository Template](repository-template.md) +- [API Template](api-template.md) +- [Project Principles](../development/project-principles.md) +- [Service Architecture](../architecture/service-architecture.md) +- [Event-Driven Architecture](../architecture/event-driven-architecture.md) diff --git a/docs/ai-framework/service-manifest.yaml b/docs/ai-framework/service-manifest.yaml new file mode 100644 index 0000000..85e42d0 --- /dev/null +++ b/docs/ai-framework/service-manifest.yaml @@ -0,0 +1,652 @@ +# Service Manifest + +# Registry of independent deployable services for TorbatYar. +# Keep aligned with docs/module-registry.md and runtime compose ports when wired. + +schema_version: 1 +updated: "2026-07-25" + +services: + - id: core-platform + name: Core Platform + owner: Platform + path: backend/core-service + database: core_platform_db + api_prefix: /api/v1 + permission_prefix: core.* + dependencies: + - postgres + - redis + - celery + - shared-lib + events: + - tenant.* + - domain.* + - subscription.* + - feature_access.* + health_endpoint: /health + documentation: + - docs/architecture/ + - docs/reference/ + current_version: 0.4.x + current_phase: onboarding-4 + status: active + + - id: identity-access + name: Identity & Access + owner: Platform + path: backend/services/identity-access + database: identity_access_db + api_prefix: /api/v1 + permission_prefix: identity.* + dependencies: + - keycloak + - core-platform + - shared-lib + events: + - user.registered + - tenant_member.added + - tenant_member.removed + health_endpoint: /health + documentation: + - docs/architecture/identity-architecture.md + current_version: 0.2.x + current_phase: identity-2 + status: active + api_port: 8001 + + - id: accounting + name: Accounting + owner: Platform + path: backend/services/accounting + database: accounting_db + api_prefix: /api/v1 + permission_prefix: accounting.* + dependencies: + - core-platform + - shared-lib + events: + - voucher.posted + - ledger.updated + - cash.received + - settlement.completed + - sales_invoice.posted + health_endpoint: /health + documentation: + - docs/phases/Accounting/README.md + current_version: 0.5.11.0 + current_phase: accounting-5.11 + status: active + api_port: 8002 + + - id: crm + name: CRM + owner: Platform + path: backend/services/crm + database: crm_db + api_prefix: /api/v1 + permission_prefix: crm.* + dependencies: + - core-platform + - shared-lib + events: + - crm.lead.* + - crm.contact.* + - crm.organization.* + - crm.opportunity.* + - crm.pipeline.* + - crm.task.* + - crm.meeting.* + - crm.call.* + - crm.timeline.* + - crm.comment.* + - crm.mention.* + health_endpoint: /health + documentation: + - docs/crm-phase-6-3.md + - docs/phases/CRM/README.md + current_version: 0.6.3.0 + current_phase: crm-6.3 + status: active + api_port: 8003 + + - id: loyalty + name: Enterprise Loyalty Platform + owner: Platform + path: backend/services/loyalty + database: loyalty_db + api_prefix: /api/v1 + permission_prefix: loyalty.* + dependencies: + - core-platform + - shared-lib + events: + - loyalty.program.* + - loyalty.tier.* + - loyalty.member.* + - loyalty.point_account.* + - loyalty.reward.* + - loyalty.campaign.* + health_endpoint: /health + documentation: + - docs/loyalty-phase-7-0.md + - docs/loyalty-phase-7-1.md + - docs/phase-handover/phase-7-1.md + - docs/phases/Loyalty/README.md + - docs/architecture/adr/ADR-011.md + current_version: 0.7.1.0 + current_phase: loyalty-7.1 + status: active + api_port: 8004 + + - id: communication + name: Enterprise Communication Platform + owner: Platform + path: backend/services/communication + database: communication_db + api_prefix: /api/v1 + permission_prefix: communication.* + dependencies: + - core-platform + - shared-lib + events: + - communication.message.* + - communication.provider.failover + - communication.otp.* + - communication.queue.dead_letter + - communication.webhook.received + health_endpoint: /health + documentation: + - docs/communication-phase-8.md + - docs/architecture/adr/ADR-012.md + current_version: 0.8.10.0 + current_phase: communication-8 + status: active + api_port: 8005 + + - id: sports_center + name: Sports Center Platform + description: Independent multi-tenant sports club / gym / facility operations platform (members, memberships, coaches, attendance, booking, programs, competitions, integrations). + owner: Platform + path: backend/services/sports_center + database: sports_center_db + api_prefix: /api/v1 + permission_prefix: sports_center.* + health_endpoint: /health + configuration: + - SPORTS_CENTER_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement feature keys sports_center.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - accounting + - crm + - loyalty + - communication + events: + - sports_center.member.* + - sports_center.membership.* + - sports_center.membership_type.* + - sports_center.membership_package.* + - sports_center.membership_plan.* + - sports_center.pricing_model.* + - sports_center.age_group.* + - sports_center.sport_category.* + - sports_center.membership_rule.* + - sports_center.renewal_policy.* + - sports_center.freezing_rule.* + - sports_center.expiration_policy.* + - sports_center.coach.* + - sports_center.attendance.* + - sports_center.booking.* + - sports_center.session.* + - sports_center.program.* + - sports_center.workout.* + - sports_center.competition.* + - sports_center.event.* + public_apis: + - /health + - /capabilities + - /api/v1/sports-centers + - /api/v1/membership-types + - /api/v1/membership-packages + - /api/v1/membership-plans + - /api/v1/pricing-models + - /api/v1/age-groups + - /api/v1/sport-categories + - /api/v1/membership-rules + - /api/v1/renewal-policies + - /api/v1/freezing-rules + - /api/v1/expiration-policies + - /api/v1/members + - /api/v1/family-members + - /api/v1/emergency-contacts + - /api/v1/medical-information + - /api/v1/membership-cards + - /api/v1/digital-memberships + - /api/v1/waivers + - /api/v1/member-documents + - /api/v1/memberships + - /api/v1/coaches + - /api/v1/facilities + internal_apis: + - service-to-service refs for Accounting / CRM / Loyalty / Communication (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/sports-center-roadmap.md + - docs/sports-center-phase-9-0.md + - docs/sports-center-phase-9-1.md + - docs/sports-center-phase-9-2.md + - docs/phase-handover/phase-9-2.md + - docs/phases/SportsCenter/README.md + - docs/architecture/adr/ADR-014.md + - docs/module-registry.md + current_version: 0.9.2.0 + current_phase: sports-center-9.2 + current_status: active + status: active + future_modules: + - members + - membership + - membership_types + - coaches + - attendance + - booking + - facilities + - equipment + - programs + - workouts + - competitions + - events + - medical + - nutrition + - locker + - mobile + - reports + - analytics + - integrations + + + - id: delivery + name: Delivery & Fleet Platform + description: Independent multi-tenant logistics platform (Torbat Driver) — drivers, fleet, dispatch, routing, tracking, POD, settlement intents, merchant connectors. Consumed by Restaurant, Marketplace, Pharmacy, Clinic, Sports Center, and future verticals via API/Events only. + owner: Platform + path: backend/services/delivery + database: delivery_db + api_prefix: /api/v1 + permission_prefix: delivery.* + health_endpoint: /health + configuration: + - DELIVERY_DATABASE_URL + - AUTH_REQUIRED + - tenant resolution via X-Tenant-ID / shared middleware + - entitlement feature keys delivery.* + dependencies: + - core-platform + - shared-lib + optional_dependencies: + - accounting + - crm + - loyalty + - communication + events: + - delivery.driver.* + - delivery.fleet.* + - delivery.vehicle.* + - delivery.dispatch.* + - delivery.route.* + - delivery.tracking.* + - delivery.pod.* + - delivery.settlement.* + - delivery.shift.* + - delivery.zone.* + public_apis: + - /health + - /capabilities + - /metrics + - /api/v1 (phase-scoped resources) + internal_apis: + - service-to-service merchant connector and settlement refs (token-gated; not public) + tenant_aware: true + audit_enabled: true + documentation: + - docs/delivery-roadmap.md + - docs/phases/Delivery/README.md + - docs/phase-handover/phase-dp-reg.md + - docs/architecture/adr/ADR-015.md + - docs/module-registry.md + current_version: 0.0.0 + current_phase: delivery-reg + current_status: registered + status: planned + api_port: 8007 + commercial_product: Torbat Driver + future_modules: + - drivers + - fleet + - vehicle_types + - vehicles + - availability + - shifts + - working_zones + - pricing + - capabilities + - bundles + - dispatch + - routing + - optimization + - tracking + - proof_of_delivery + - settlement + - merchant_connector + - notifications + - driver_app_apis + - dispatcher_panel_apis + - customer_tracking + - fleet_analytics + - ai_hooks + - external_providers + + - id: restaurant + name: Restaurant / Cafe + owner: TBD + path: backend/services/restaurant + database: restaurant_db + api_prefix: /api/v1 + permission_prefix: restaurant.* + dependencies: + - core-platform + - loyalty + events: + - order.* + health_endpoint: /health + documentation: + - docs/phases/Restaurant/README.md + current_version: 0.0.0 + current_phase: restaurant-foundation + status: scaffolded + + - id: ecommerce + name: Ecommerce + owner: TBD + path: backend/services/ecommerce + database: ecommerce_db + api_prefix: /api/v1 + permission_prefix: ecommerce.* + dependencies: + - core-platform + events: + - order.* + - product.* + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: ecommerce-foundation + status: scaffolded + + - id: marketplace + name: Marketplace + owner: TBD + path: null + database: TBD + api_prefix: TBD + permission_prefix: marketplace.* + dependencies: + - ecommerce + - identity-access + - accounting + events: [] + health_endpoint: TBD + documentation: + - docs/phases/Marketplace/README.md + current_version: 0.0.0 + current_phase: marketplace-foundation + status: planned + + - id: automation + name: Automation + owner: TBD + path: null + database: TBD + api_prefix: TBD + permission_prefix: automation.* + dependencies: [] + events: [] + health_endpoint: TBD + documentation: + - docs/phases/Automation/README.md + current_version: 0.0.0 + current_phase: automation-foundation + status: planned + + - id: academy + name: Academy + owner: TBD + path: null + database: academy_db + api_prefix: /api/v1 + permission_prefix: academy.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: academy-foundation + status: planned + + - id: clinic + name: Clinic + owner: TBD + path: null + database: clinic_db + api_prefix: /api/v1 + permission_prefix: clinic.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: clinic-foundation + status: planned + + - id: hotel + name: Hotel + owner: TBD + path: null + database: hotel_db + api_prefix: /api/v1 + permission_prefix: hotel.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: hotel-foundation + status: planned + + - id: tourism + name: Tourism + owner: TBD + path: null + database: tourism_db + api_prefix: /api/v1 + permission_prefix: tourism.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/phases/Future/README.md + current_version: 0.0.0 + current_phase: tourism-foundation + status: planned + + - id: ai_assistant + name: AI Assistant + owner: TBD + path: backend/services/ai_assistant + database: ai_assistant_db + api_prefix: /api/v1 + permission_prefix: ai_assistant.* + dependencies: + - core-platform + events: + - assistant.* + health_endpoint: /health + documentation: + - docs/architecture/ai-architecture.md + - docs/phases/AI/README.md + current_version: 0.0.0 + current_phase: ai-assistant-foundation + status: scaffolded + + - id: website_builder + name: Website Builder + owner: TBD + path: backend/services/website_builder + database: website_builder_db + api_prefix: /api/v1 + permission_prefix: website_builder.* + dependencies: + - file_storage + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: live_chat + name: Live Chat + owner: TBD + path: backend/services/live_chat + database: live_chat_db + api_prefix: /api/v1 + permission_prefix: live_chat.* + dependencies: + - core-platform + events: + - conversation.* + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: smart_messenger + name: Smart Messenger + owner: TBD + path: backend/services/smart_messenger + database: smart_messenger_db + api_prefix: /api/v1 + permission_prefix: smart_messenger.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: sms_panel + name: SMS Panel + owner: TBD + path: backend/services/sms_panel + database: sms_panel_db + api_prefix: /api/v1 + permission_prefix: sms_panel.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + - docs/architecture/adr/ADR-012.md + current_version: 0.0.0 + current_phase: sms-panel-future + status: scaffolded + notes: Prefer communication service for new messaging work (ADR-012). + + - id: notification + name: Notification + owner: TBD + path: backend/services/notification + database: notification_db + api_prefix: /api/v1 + permission_prefix: notification.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: file_storage + name: File Storage + owner: TBD + path: backend/services/file_storage + database: file_storage_db + api_prefix: /api/v1 + permission_prefix: file_storage.* + dependencies: [] + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: link_shortener + name: Link Shortener + owner: TBD + path: backend/services/link_shortener + database: link_shortener_db + api_prefix: /api/v1 + permission_prefix: link_shortener.* + dependencies: + - core-platform + events: [] + health_endpoint: /health + documentation: + - docs/module-registry.md + current_version: 0.0.0 + current_phase: future-services + status: scaffolded + + - id: frontend + name: Frontend + owner: Platform + path: frontend + database: null + api_prefix: consumes /api/v1 + permission_prefix: UI gated by /me roles + dependencies: + - core-platform + - identity-access + - keycloak + events: [] + health_endpoint: null + documentation: + - docs/frontend/README.md + current_version: Next-15.5.x + current_phase: onboarding-4 + status: active diff --git a/docs/ai-framework/service-template.md b/docs/ai-framework/service-template.md new file mode 100644 index 0000000..a295c33 --- /dev/null +++ b/docs/ai-framework/service-template.md @@ -0,0 +1,141 @@ +# Service Template + +How to design a new **independent** service under `backend/services/<name>/`. + +Use when the capability is shared across domains or requires sole database ownership (patterns: Loyalty [ADR-011](../architecture/adr/ADR-011.md), Communication [ADR-012](../architecture/adr/ADR-012.md)). + +For a slice inside an existing service, use [module-template.md](module-template.md) instead. + +## Architecture + +1. Deployable FastAPI app with package layout per [service-architecture.md](../architecture/service-architecture.md) and [coding-standards.md](../development/coding-standards.md). +2. Layering: `API → Services → Repositories → Models`. +3. Database-per-service ([ADR-001](../architecture/adr/ADR-001.md)). +4. Outbox-ready events ([ADR-006](../architecture/adr/ADR-006.md)). +5. No imports of other services’ models/repos. +6. Frontend remains a separate client ([ADR-002](../architecture/adr/ADR-002.md)). + +``` +backend/services/<name>/ + app/ + api/v1/ + core/ # config, database, security, logging + models/ + schemas/ + services/ + repositories/ + providers/ # adapters if this service owns providers + events/ + permissions/ + middlewares/ + validators/ + tests/ + alembic/ + Dockerfile.dev + requirements.txt + README.md +``` + +## Ownership + +| Owns | Must not own | +| --- | --- | +| Its database schema and migrations | Other services’ tables | +| Its permission tree `{service}.*` | Foreign domain aggregates | +| Its public API + publish events | UI business rules | +| Provider adapters for its channel/domain | Direct calls into other DBs | + +Document boundaries in [module-boundaries.md](../architecture/module-boundaries.md) when introducing a platform service. + +## Dependencies + +| Kind | Rule | +| --- | --- | +| Core | Entitlement / tenant identity as required | +| Other services | HTTP + events only | +| Providers | Behind protocols; register in [provider-registry.md](../provider-registry.md) | +| shared-lib | Envelope, JWT helpers, exceptions only | +| AI | Optional contract; core flows work without AI | + +## Public APIs + +- Versioned under `/api/v1` +- Documented in service README and eventually [api-reference.md](../reference/api-reference.md) / [services-contracts.md](../reference/services-contracts.md) +- Auth: JWT + tenant header pattern used by sibling services +- Stable error codes via shared exceptions +- See [api-template.md](api-template.md) + +## Private APIs + +- Internal-only routes (if any) require internal service token or network isolation +- Never expose admin debug endpoints without auth in non-dev profiles +- Do not publish private routes in public contracts + +## Events + +- Publish domain events for state changes other services may need +- Prefer outbox in the same transaction as writes +- Naming and payload rules: [event-template.md](event-template.md) +- Catalog: [event-catalog.md](../reference/event-catalog.md) + +## Database Ownership + +| Field | Value | +| --- | --- | +| Database name | `<name>_db` | +| Migrations | Alembic in this service only | +| Cross-DB FK | Forbidden | +| Tenant column | Required on business tables | + +Reference: [database-architecture.md](../architecture/database-architecture.md), [database-schema.md](../reference/database-schema.md). + +## Configuration + +- All settings via env / settings class — no hardcoded secrets or brand +- Document keys in `.env.example` and service README when the phase wires runtime +- Feature flags / entitlement keys documented + +## Permissions + +- Prefix: `{service}.*` +- Map routes to permission checks +- Document tree in module registry and phase doc + +## Tenant Awareness + +- Middleware / deps resolve `tenant_id` +- Repositories always filter by tenant for tenant-owned rows +- Isolation tests mandatory +- See [multi-tenant-architecture.md](../architecture/multi-tenant-architecture.md) + +## Monitoring + +- Structured logging via service logging setup +- Operational stats endpoints when the domain requires them (see Communication monitoring pattern) +- Align with [deployment/monitoring.md](../deployment/monitoring.md) + +## Health Checks + +| Endpoint | Purpose | +| --- | --- | +| `GET /health` | Liveness (and basic dependency signal as appropriate) | +| Optional `GET /capabilities` | Feature/channel discovery for platform services | + +Register health URL in [service-manifest.yaml](service-manifest.yaml). + +## Registration Checklist + +- [ ] [service-manifest.yaml](service-manifest.yaml) +- [ ] [module-registry.md](../module-registry.md) +- [ ] ADR if platform ownership decision +- [ ] Phase doc + handover +- [ ] Compose/port docs when runtime is in scope +- [ ] Tests per [testing-template.md](testing-template.md) + +## Related Documents + +- [Module Template](module-template.md) +- [Phase Template](phase-template.md) +- [Module Registry](../module-registry.md) +- [Service Architecture](../architecture/service-architecture.md) +- [ADR-001](../architecture/adr/ADR-001.md) diff --git a/docs/ai-framework/testing-template.md b/docs/ai-framework/testing-template.md new file mode 100644 index 0000000..b78d89f --- /dev/null +++ b/docs/ai-framework/testing-template.md @@ -0,0 +1,84 @@ +# Testing Template + +Mandatory test categories for implementation phases. + +Baseline strategy: [testing-strategy.md](../development/testing-strategy.md). + +Mark a category **N/A** only with written justification (e.g. docs-only phase). + +## Unit + +- Pure validators, mappers, permission helpers, formatting, template rendering without I/O. +- Deterministic; no real network. + +## Repository + +- Query correctness, pagination, unique constraints behavior. +- Soft-delete defaults. +- **Tenant filter** present on tenant-owned entities. +- Cross-tenant get/update returns empty/denied. + +## Service + +- Business rules and invariants. +- Audit/event emission expectations (outbox rows or publisher fakes). +- Domain error codes for illegal transitions. + +## Integration / API + +- HTTP contracts: status codes, auth, permission denials. +- Tenant header requirements. +- End-to-end flows for the phase’s primary use cases. + +## Migration + +- Upgrade applies on clean DB. +- Smoke query against new tables/columns. +- Downgrade only if the service supports it — document if not. + +## Permission + +- Each new privileged route denies without permission/role. +- Permission names match `{service}.*` trees documented in the phase. + +## Security + +- Unauthenticated access denied where required. +- Token scope / role bypass attempts fail. +- No secret leakage in error bodies. + +## Performance + +- Required for hot paths (entitlement, routing, queue claim, posting) when the phase touches them. +- Otherwise document N/A. +- Avoid unbounded queries; assert basic indexes usage indirectly via acceptable timing in CI only when stable. + +## Tenant Isolation + +- At least one cross-tenant denial per new tenant-scoped API/aggregate. +- No list endpoint returns other tenants’ rows. + +## Documentation Validation + +- Phase doc exists and matches delivered surface. +- Registry/manifest entries updated. +- Critical links resolve (tests or scripted checks when the service already has `test_docs` / architecture tests — follow sibling pattern). + +## Architecture / Boundary (when service has them) + +- No forbidden imports across services. +- Layering rules (optional static checks already used by CRM/Loyalty/Communication). + +## Phase Rules + +1. New behavior ships with tests in the same phase. +2. Failing or missing required tests → phase incomplete. +3. Do not use production data. +4. Prefer fakes for OTP, SMS providers, time. + +## Related Documents + +- [Testing Strategy](../development/testing-strategy.md) +- [Quality Gates](quality-gates.md) +- [Development Loop](development-loop.md) +- [Project Principles](../development/project-principles.md) diff --git a/docs/architecture-review/MASTER_ARCHITECTURE_REPORT.md b/docs/architecture-review/MASTER_ARCHITECTURE_REPORT.md new file mode 100644 index 0000000..2ac0204 --- /dev/null +++ b/docs/architecture-review/MASTER_ARCHITECTURE_REPORT.md @@ -0,0 +1,2812 @@ +# Master Architecture Report — TorbatYar SuperApp + +| Field | Value | +| --- | --- | +| Document | Master Architecture Report | +| Version | 1.0 | +| Date | 2026-07-24 | +| Classification | Internal — Enterprise Planning | +| Status | Synthesis for review | +| Scope | Platform + Accounting + planned modules | +| Sources | Canonical docs under `docs/` (architecture, progress, roadmap, registries, frontend, deployment, phases, decisions, reference). Folder `docs/architecture-review/` did not previously contain intermediate review packs; this master report synthesizes the live documentation set. | + +--- + +## Document Control + +### Purpose + +This report consolidates TorbatYar’s architecture, delivery maturity, platform services, business modules, risks, and enterprise roadmap into a single planning artifact. It is intended for long-term product and engineering decisions—not as a runbook and not as a substitute for phase checklists. + +### Source-of-Truth Hierarchy (when docs conflict) + +1. **Delivery status:** `docs/progress.md` (completed work) + `docs/next-steps.md` (immediate milestone) + `docs/roadmap.md` (future only) +2. **Module inventory:** `docs/module-registry.md` +3. **Architecture intent:** `docs/architecture/*` and ADRs +4. **Contracts / schema:** `docs/reference/*` +5. **Historical inventory review:** `docs/current-architecture-review.md` (dated 2026-07-22; some claims pre-date Accounting 5.x completion) + +### Explicit Conflicts Called Out in This Report + +| Topic | Side A | Side B | Preferred truth for this report | +| --- | --- | --- | --- | +| Accounting maturity | `database-architecture.md` still labels Accounting DB as “(future)” | `progress.md`, `module-registry.md`, Accounting phase area: Phases 5.1–5.11 complete, status Active | **Active / implemented** (registry + progress) | +| First business module | Product decision (Proposed): Restaurant after white-label | Accounting already delivered as Active business module | **Accounting shipped**; Restaurant remains preferred *next* go-to-market vertical after white-label polish (decision still Proposed) | +| Immediate next work | Older white-label narrative in some architecture notes | `next-steps.md`: Phase 5.12 AI readiness + production hardening | **next-steps.md** | +| Phase “3” meaning | External brief: onboarding as Phase 3 | Internal: Phase 3 = OTP/Tenant Mgmt; Phase 4 = Onboarding | **Internal numbering** (`decisions/technical/phase-numbering.md`) | +| White-label completion | Framed as incomplete / next | Progress: partial runtime exists (`tenant-site`, theme, SSL) | **Partial** — polish still near-term | +| Message bus | Architecture describes outbox path | Real broker still future | **Outbox/Celery today; bus later** | +| Stale doc inventory claims | `current-architecture-review.md` (Jul 22): Identity real, business modules placeholders, Accounting not yet a delivered module | Jul 24 progress/scoreboard: Accounting backend + FE largely complete | Prefer **Jul 24 progress/registry** for Accounting; keep review file for historical doc-drift lessons | + +### Reading Guide + +- Sections 1–3: executive and structural view +- Sections 4–11: capability deep dives (modules, APIs, data, FE/BE, security, infra) +- Sections 12–17: debt, refactoring, future platform map, scale, sequencing, recommendations, scorecard + +--- + +# 1. Executive Summary + +## 1.1 Current Project Maturity + +TorbatYar is a **multi-tenant SuperApp SaaS** past foundation phases and into its first deep vertical: **Accounting**. + +| Stage | Status (per docs) | +| --- | --- | +| Phase 1 — Core Platform | Complete | +| Phase 2 — Identity & Access + SSO | Complete | +| Phase 3 — OTP + Tenant Management | Complete | +| Phase 4 — Onboarding & Workspace Activation | Complete (with intentional gaps) | +| Phase D — Documentation consolidation | Complete | +| Accounting 5.1–5.11 | Complete (backend + enterprise FE baseline) | +| Accounting 5.12 AI | Blocked on AI Model Provider (Planned) | +| White-label runtime polish | Partial / near-term | +| Restaurant / CRM / commerce stack | Scaffolded or Planned | + +**Maturity characterization:** Platform core is **production-shaped** (Nginx edge, TLS, Keycloak SSO, tenant SSL automation, entitlement, onboarding). Business depth is **strong in Accounting**, **thin elsewhere**. Shared horizontal services (notification, file storage, SMS panel, payment, message bus) remain **scaffolded/planned**. + +## 1.2 Current Architecture (one paragraph) + +The system is **service-oriented, API-first, database-per-service, row-level multi-tenant**. A Next.js frontend talks only to versioned HTTP APIs. Nginx terminates TLS and routes apex/API/identity/auth/tenant hosts. **Core Platform** owns tenants, plans, entitlements, onboarding, audit, and outbox. **Identity & Access** is the OIDC BFF to Keycloak and owns identity profiles. **Accounting** is an independent service (`accounting_db`, port 8002 documented) with a sole **Posting Engine** for financial entries. Cross-service coupling is restricted to REST, webhooks, async events, and Outbox/Inbox. Celery workers process outbox and tenant SSL expansion. + +## 1.3 Technology Stack + +| Layer | Technology (documented) | +| --- | --- | +| Frontend | Next.js 15.5.x, module routes, design system (`components/ds`), TanStack Query, Sonner, ColorMode | +| Backend services | Python services (Core, Identity, Accounting); layered API → Service → Repository → Model | +| Auth | Keycloak OIDC (RS256 JWT); Core OTP HS256 JWT; internal service tokens (hashed) | +| Data | PostgreSQL per service DB; Redis (entitlement cache, Celery broker usage implied by stack) | +| Workers | Celery worker + beat (outbox, SSL provision) | +| Edge | Nginx TLS, Let's Encrypt / Certbot, tenant SSL map + `provision_ssl.py` | +| Compose | postgres, redis, keycloak, core-service, identity-access-service, frontend, celery-worker, celery-beat | +| SMS | Payamak (Active) | +| Object storage / PSP / AI models | Planned | + +## 1.4 Overall Readiness + +| Dimension | Assessment | +| --- | --- | +| Multi-tenant SaaS foundation | Ready for early production tenants | +| Identity & workspace lifecycle | Ready (SSO + OTP + onboarding + JIT) | +| First enterprise vertical (Accounting) | Functionally broad; AI & some export/IAM depth remain | +| Horizontal platform services | Not ready (notification, files, payments, bus) | +| White-label / custom domain | Partial (subdomain SSL yes; DNS/TXT verify no) | +| 100 / 1,000 / 10,000 tenants | See Enterprise Readiness — 100 plausible with ops discipline; 1k+ needs bus, isolation hardening, observability, horizontal scale | + +**Verdict:** Ready to **operate and deepen Accounting**, and to **finish platform polish** before proliferating many new business modules. Not yet ready as a full multi-vertical SuperApp marketplace. + +## 1.5 Strengths + +1. Clear **ADR set** (database-per-service, FE/BE split, tenancy, SSO, OTP, outbox, dual memberships, white-label, nginx SSL, posting engine). +2. **Entitlement + membership** dual-axis authorization model. +3. **Tenant resolution** order documented and operationally wired (headers → host → current tenant). +4. **Accounting Posting Engine ownership** prevents fragmented journals (ADR-010). +5. **Documentation-as-software** decision and consolidated `docs/` tree after Phase D. +6. Production topology documented (app host + nginx edge + named public hosts). +7. Accounting FE scoreboard shows high completion across core financial screens (mostly 85–98%). +8. Shared-lib boundaries for JWT/events without owning domain workflows. + +## 1.6 Weaknesses + +1. **Horizontal services delayed** — notification, file storage, payment, real message bus. +2. **White-label unfinished** relative to product ambition for Restaurant guest sites. +3. **Dual `tenant_memberships`** increases cognitive and sync risk (ADR-007 accepted tradeoff). +4. **Doc drift** still present (e.g., Accounting marked future in database-architecture). +5. **Advanced RBAC / invites** not delivered. +6. **Custom domain verification** pending. +7. **Observability** is checklist-level (health endpoints + suggested alerts), not a full SRE stack in docs. +8. **AI provider** not Active → Accounting AI UI blocked at 0%. +9. Product decision for “first business module” **out of sync** with Accounting already shipping. + +## 1.7 Biggest Risks + +| Risk | Why it matters | Mitigation direction | +| --- | --- | --- | +| Adding many business modules before horizontal platform (files, notify, pay, bus) | Duplicated integrations, inconsistent UX, rewrite tax | Build shared platform services before 3rd+ vertical | +| Membership divergence Core vs Identity | Authz bugs / SSO listing mismatch | Explicit sync events or stricter ownership docs + tests | +| Tenant isolation regression as modules grow | Cross-tenant data leakage | Mandatory tenant denial tests per testing strategy | +| Outbox without real bus at scale | Throughput / fanout limits | Introduce broker without changing envelope (ADR-006 future) | +| Payment + subscription gaps | Cannot monetize or settle commerce | Provider + Core subscription path before marketplace | +| Doc staleness driving wrong builds | Teams implement against obsolete claims | Enforce progress/registry as status; fix architecture labels | +| Noise-neighbor DB at high tenant count | Row-level tenancy saturation | Partitioning / read replicas / eventual DB-per-large-tenant strategy | + +--- + +# 2. High Level Architecture + +## 2.1 Architectural Style + +| Principle | Rule | ADR | +| --- | --- | --- | +| Service-oriented | Major capabilities are independent services | Architecture overview | +| Database-per-service | One DB ownership per service; no cross-DB FK/joins | ADR-001 | +| API-first | Versioned REST (+ events); UI never owns business rules | ADR-002 | +| Multi-tenancy | `tenant_id` on business tables; no cross-tenant queries | ADR-003 | +| Event reliability | Outbox/Inbox + `EventEnvelope` | ADR-006 | +| Financial integrity | Only Accounting Posting Engine posts journals | ADR-010 | + +## 2.2 Major Layers + +```mermaid +flowchart TB + subgraph Edge["Edge Layer"] + NGX["Nginx TLS + Host Routing"] + end + subgraph Clients["Client Layer"] + FE["Next.js Frontend<br/>SuperApp + Accounting module routes"] + end + subgraph Platform["Platform Services"] + CORE["Core Platform<br/>tenants · plans · entitlement · onboarding · audit · outbox"] + IDA["Identity & Access<br/>OIDC BFF · profiles · OTP handoff"] + KC["Keycloak<br/>superapp realm · torbatyar theme"] + end + subgraph Business["Business Services"] + ACC["Accounting<br/>COA · posting · GL · treasury · AR/AP · assets · payroll · reporting · compliance"] + FUTURE["Scaffolded/Planned<br/>CRM · Restaurant · Ecommerce · …"] + end + subgraph Data["Data & Workers"] + PG["PostgreSQL DBs per service"] + RD["Redis"] + CEL["Celery worker/beat"] + end + FE --> NGX + NGX --> CORE + NGX --> IDA + NGX --> KC + NGX --> ACC + CORE --> PG + IDA --> PG + ACC --> PG + CORE --> RD + CORE --> CEL + IDA --> KC + ACC -.->|"entitlement check"| CORE + FUTURE -.->|"API/events only"| CORE + FUTURE -.->|"posting intents"| ACC +``` + +## 2.3 Dependency Flow + +```mermaid +flowchart LR + FE["Frontend"] --> CORE["Core API"] + FE --> IDA["Identity API"] + FE --> ACC["Accounting API"] + FE --> KC["Keycloak"] + IDA --> KC + IDA --> CORE + ACC --> CORE + CORE --> PAYAMAK["Payamak SMS"] + CORE --> SSL["Edge SSL provision via SSH"] + CEL["Celery"] --> CORE + CEL --> SSL +``` + +**Allowed channels (integration architecture):** versioned REST, webhook, async event, Outbox/Inbox. +**Forbidden:** cross-DB queries/FKs, importing another service’s models, frontend DB access, UI inventing journal entries. + +## 2.4 Module Interaction (current vs planned) + +```mermaid +sequenceDiagram + participant U as User + participant FE as Frontend + participant KC as Keycloak + participant ID as Identity + participant C as Core + participant A as Accounting + U->>FE: Open /accounting + FE->>KC: SSO (if needed) + FE->>ID: token/me flows + FE->>C: /me · tenant context · entitlement + C-->>FE: tenant + roles + FE->>A: API + Bearer + X-Tenant-ID + A->>C: feature check (as required) + A-->>FE: domain data + Note over C: Outbox events for tenant/subscription lifecycle + Note over A: voucher.posted / ledger.updated (catalog) +``` + +## 2.5 Runtime Topology (production canonical) + +| Component | Host / Endpoint | +| --- | --- | +| App / Docker | `192.168.10.162` | +| Nginx edge | `192.168.10.156` | +| Apex / tenant FE | `torbatyar.ir`, `*.torbatyar.ir` | +| Core API | `api.torbatyar.ir` → `:8000` | +| Identity API | `identity.torbatyar.ir` → `:8001` | +| Keycloak | `auth.torbatyar.ir` → `:8080` | +| Accounting API | Documented service port `:8002` (frontend `NEXT_PUBLIC_ACCOUNTING_API_URL`) | + +Compose services listed: `postgres`, `redis`, `keycloak`, `core-service`, `identity-access-service`, `frontend`, `celery-worker`, `celery-beat`. Accounting production deploy is called out as pending verification in `next-steps.md`. + +## 2.6 Tenant Resolution & White-Label + +Resolution order: + +1. `X-Tenant-ID` +2. `X-Tenant-Slug` +3. Subdomain + `PLATFORM_BASE_DOMAIN` +4. Custom domain from Host +5. User `current_tenant_id` + +Lifecycle: `draft → pending_activation → active → suspended / archived` (legacy `inactive`/`deleted` retained). + +White-label: tenant brand fields + public tenant-site/theme by host; platform defaults from env/`theme.config.json` (ADR-008). Subdomain SSL expansion automated (ADR-009). Custom domain DNS/TXT verification **not** done. + +--- + +# 3. Current Platform Capabilities + +Capabilities below are grouped by domain using **progress**, **module registry**, **provider registry**, and **frontend scoreboard**. Items marked Planned/Scaffolded are listed as capabilities of the *platform design*, not as shipped product. + +## 3.1 Core + +| Capability | Status | +| --- | --- | +| Project structure FE/BE separation | Done | +| Shared library (JWT, roles, events) | Done | +| Tenant CRUD / lifecycle | Done | +| Domains (subdomain provision) | Done | +| Plans / features / subscriptions seed (FREE/STARTER) | Done | +| Entitlement check + Redis cache | Done | +| Operational tenant memberships & roles | Done | +| Onboarding wizard APIs + services | Done | +| Public tenant-site resolution | Partial / present | +| Audit log | Done (architecture ownership) | +| Outbox/Inbox tables + worker path | Done (bus later) | +| Internal service tokens | Done | +| Service/module registry concept | Done (docs + Core ownership) | +| Payment gateway for subscriptions | Missing | +| DNS/TXT custom-domain verify | Missing | +| Legacy admin tenant auto membership/plan | Intentionally incomplete | +| Advanced permissions & invites | Missing | +| Independent Subscription service | Longer-term | + +## 3.2 Platform (Identity / Access / Edge) + +| Capability | Status | +| --- | --- | +| Keycloak realm/clients/theme | Done | +| Identity OIDC BFF | Done | +| Mobile OTP via Payamak (Core) + Identity handoff | Done | +| JIT Core user from Keycloak JWT | Done | +| Nginx reverse proxy + base TLS | Done | +| Automatic tenant subdomain SSL | Done | +| Cross-subdomain auth cookie model | Documented in review as operational concern | +| Rich Core↔Identity membership sync | Future | + +## 3.3 Business + +| Capability | Status | +| --- | --- | +| Accounting foundation → compliance (5.1–5.11) | Done | +| Accounting enterprise FE baseline | Done (AI blocked) | +| CRM | Scaffolded / not started | +| Restaurant / Cafe / Digital menu | Scaffolded / candidate | +| Ecommerce / Store builder | Scaffolded | +| Marketplace | Planned | +| Website / Landing builder | Scaffolded (`website_builder`) | +| Live Chat | Scaffolded | +| Smart Messenger | Scaffolded | +| Customer Club / Wallet | Not named as Active modules in registry (treat as future product capabilities) | +| Gym / Appointment / Reservation | Not dedicated Active modules in registry (Future/product) | +| ERP umbrella | Partially realized via Accounting depth; not a single ERP module | + +## 3.4 Infrastructure + +| Capability | Status | +| --- | --- | +| Docker Compose local/prod pattern | Done | +| Celery worker/beat | Done | +| Health endpoints Core/Identity | Done | +| Backup/restore/DR runbooks | Documented | +| Monitoring runbook (minimal alerts) | Documented | +| Real message bus | Missing | +| Full APM/metrics stack | Not evidenced as deployed in docs | +| CI/CD architecture deep doc | Historically called out as gap; branching/release strategies exist | + +## 3.5 Developer Experience + +| Capability | Status | +| --- | --- | +| Developer guides | Present | +| Coding standards / principles / testing strategy | Present | +| ADR system (001–010) | Present | +| Templates (module, phase, API, provider, ADR, decision) | Present | +| Module & provider registries | Present | +| Frontend design-system docs | Present | +| Phase docs for Accounting + area READMEs | Present | +| Documentation validation as phase exit | Required by testing strategy | + +--- + +# 4. Business Modules + +Status legend from registry: **Active** · **Scaffolded** · **Planned**. + +## 4.1 Accounting — Active + +| Field | Detail | +| --- | --- | +| Purpose | Tenant-aware double-entry accounting; sole Posting Engine; GL, treasury, AR/AP, sales/purchase/inventory accounting hooks, assets, payroll accounting, reporting, compliance/SoD | +| Current completion | Phases 5.1–5.11 complete; FE scoreboard mostly Complete (AI 0% Blocked); version `0.5.11.0`; migration `0002_phases_57_511`; 21 tests noted in progress | +| Implemented features | COA (4-level), fiscal year/period, currencies/FX, cost centers, projects/dimensions, voucher validate/post/reverse/cancel, ledger/trial balance, treasury, customers/suppliers AR/AP, sales integration, purchase/inventory valuation, fixed assets/depreciation, HCM/payroll posting, financial reporting/export framework, compliance/audit/governance | +| Missing features | Phase 5.12 AI UI (provider Planned); optional richer PDF export delivery; full tax authority connector; payment gateway; IAM rebuilt inside Accounting (deep-links only) | +| Dependencies | Core entitlement; ADR-010; shared JWT/tenant headers | +| Expansion capability | High within finance; must remain posting-engine-centric; AI only advisory | + +### Accounting FE completion snapshot (2026-07-24) + +| Area | % | Gate | +| --- | --- | --- | +| Dashboard | 90 | Complete | +| COA | 98 | Complete | +| Fiscal | 98 | Complete | +| Currencies | 98 | Complete | +| Cost Centers | 95 | Complete | +| Projects | 95 | Complete | +| Vouchers | 95 | Complete | +| Ledger | 95 | Complete | +| Treasury | 90 | Complete | +| Customers / AR | 92 | Complete | +| Suppliers / AP | 85 | Complete | +| Assets | 90 | Complete | +| Payroll | 88 | Complete | +| Reports | 92 | Complete | +| Compliance / Audit | 88 | Complete | +| Settings | 85 | Complete (IAM deep-links) | +| Setup | 95 | Complete | +| Mobile / Nav | 90 | Complete | +| AI | 0 | Blocked | + +## 4.2 CRM — Scaffolded + +| Field | Detail | +| --- | --- | +| Purpose | Customers, leads, opportunities, pipelines, tasks, automations | +| Completion | Not started (0.0.0) | +| Implemented | Placeholder service README / phase area only | +| Missing | Full domain, APIs, events, UI | +| Dependencies | Core entitlement; optional messenger/AI | +| Expansion | High; natural consumer of notification + AI | + +## 4.3 Restaurant / Cafe — Scaffolded (candidate GTM module) + +| Field | Detail | +| --- | --- | +| Purpose | Digital menu, tables, orders, kitchen, loyalty on tenant domains | +| Completion | Not started; product decision Proposed as preferred first *vertical after white-label* (note Accounting already exists) | +| Implemented | Scaffold only; conceptual `restaurant_db` | +| Missing | Menu, ordering, kitchen, loyalty, guest auth local to module DB, payments | +| Dependencies | Core entitlement, white-label host, optional Accounting postings via events, payment provider | +| Expansion | High for hospitality; depends on public site polish | + +## 4.4 Ecommerce — Scaffolded + +| Field | Detail | +| --- | --- | +| Purpose | Store builder, catalog, cart, orders, shipments | +| Completion | Not started | +| Missing | Entire commerce stack | +| Dependencies | Core, file_storage, payment, shipping | +| Expansion | Foundation for Marketplace | + +## 4.5 Marketplace — Planned + +| Field | Detail | +| --- | --- | +| Purpose | Multi-vendor market capabilities | +| Completion | Future | +| Dependencies | ecommerce, identity, accounting, payment | +| Expansion | Only after ecommerce foundation | + +## 4.6 Website Builder — Scaffolded + +| Field | Detail | +| --- | --- | +| Purpose | Sites, pages, blocks, forms, menus, media | +| Dependencies | file_storage / S3 | +| Note | Ties into white-label public sites | + +## 4.7 Live Chat — Scaffolded + +| Field | Detail | +| --- | --- | +| Purpose | Embeddable widget, conversations, agent routing | +| Dependencies | Core; optional CRM/AI | + +## 4.8 AI Assistant — Scaffolded + +| Field | Detail | +| --- | --- | +| Purpose | Intelligent chat, KB, human handoff | +| Guardrail | Must not be authority for money, compliance, or authorization | +| Dependencies | AI model provider (Planned), Core entitlement | + +## 4.9 Smart Messenger — Scaffolded + +Social channels, unified inbox, automations; depends on channel providers. + +## 4.10 SMS Panel — Scaffolded + +Campaign SMS separate from Core OTP path; multi-provider future. + +## 4.11 Link Shortener — Scaffolded + +Short links, custom domains, analytics; edge SSL for custom domains future. + +## 4.12 Notification — Scaffolded (critical shared) + +Central fanout email/SMS/push/webhook/in-app; should consume many domain events. + +## 4.13 File Storage — Scaffolded (critical shared) + +Tenant-aware uploads, S3 gateway, signed URLs planned. + +## 4.14 Automation — Planned + +Cross-module workflows; **requires real message bus maturity**. + +## 4.15 Modules named in mission but not Active registry entries + +The original master-report mission listed: Digital Menu, Customer Club, Wallet, Payment, Store/Website/Landing Builder, ERP, Gym, Workflow, Chat, Appointment, Reservation, Marketplace, AI, Automation. + +Mapping from docs: + +| Mission name | Doc mapping | +| --- | --- | +| Digital Menu | Restaurant module | +| Customer Club | Not a dedicated Active module — future product on CRM/Restaurant/Wallet | +| Wallet / Payment | Payment Gateway provider Planned; no Active wallet module | +| Store Builder | Ecommerce | +| Website / Landing Builder | website_builder | +| ERP | Accounting depth + future modules; not single module | +| Gym | Not in registry — Future candidate | +| Workflow | Automation | +| Chat | live_chat (+ AI) | +| Appointment / Reservation | Not dedicated Active modules — Future/product | +| SMS Platform | sms_panel (+ Core OTP via Payamak) | + +--- + +# 5. Shared Platform Services + +Reusable capabilities that multiple business modules should consume rather than reimplement. + +## 5.1 Core Platform + +| Aspect | Assessment | +| --- | --- | +| Purpose | Tenant/plan/entitlement/onboarding/audit/outbox registry | +| Consumers | All services + Frontend | +| Quality | High relative maturity (Phases 1–4) | +| Missing | Payment, DNS verify, advanced RBAC/invites, subscription extraction | +| Recommendation | Harden production deploy + white-label; avoid stuffing business journals into Core | +| Priority | P0 continuous | + +## 5.2 Identity & Access + +| Aspect | Assessment | +| --- | --- | +| Purpose | SSO BFF, profiles, OTP handoff | +| Consumers | Frontend, all JWT services | +| Quality | Active and production-used | +| Missing | Richer sync with Core memberships | +| Recommendation | Define membership sync events before many staff UIs diverge | +| Priority | P0 | + +## 5.3 Keycloak (IdP provider) + +| Aspect | Assessment | +| --- | --- | +| Purpose | Central staff SSO | +| Consumers | Identity, Frontend, JWT validators | +| Quality | Active | +| Missing | Operational runbooks beyond architecture notes (partially in deployment) | +| Recommendation | Treat as Tier-0 dependency; monitor availability | +| Priority | P0 | + +## 5.4 Entitlement / Feature Gate (in Core) + +| Aspect | Assessment | +| --- | --- | +| Purpose | Plan-based feature access | +| Consumers | Accounting now; all future modules | +| Quality | Implemented with Redis cache | +| Missing | Independent subscription service (longer-term) | +| Recommendation | Keep stable feature key taxonomy `{service}.{resource}.{action}` | +| Priority | P0 | + +## 5.5 Outbox / Event Backbone + +| Aspect | Assessment | +| --- | --- | +| Purpose | Reliable domain events | +| Consumers | Future notification, accounting integrations, automation | +| Quality | Pattern implemented; broker not real yet | +| Missing | Message bus, broader consumer ecosystem | +| Recommendation | Introduce bus before Automation engine | +| Priority | P1 before multi-module fanout | + +## 5.6 Notification (scaffolded) + +| Aspect | Assessment | +| --- | --- | +| Purpose | Central delivery | +| Consumers | All business modules | +| Quality | Scaffold only | +| Missing | Everything | +| Recommendation | Build before CRM/Restaurant campaigns | +| Priority | P1 | + +## 5.7 File Storage (scaffolded) + +| Aspect | Assessment | +| --- | --- | +| Purpose | Tenant media/documents | +| Consumers | website_builder, ecommerce, accounting exports, KYC/assets docs | +| Quality | Scaffold; S3 provider Planned | +| Recommendation | Activate S3 provider + signed URL pattern early | +| Priority | P1 | + +## 5.8 SMS Panel vs Core OTP + +| Aspect | Assessment | +| --- | --- | +| Purpose | Marketing/ops SMS vs auth OTP | +| Quality | OTP Active via Payamak; panel scaffolded | +| Recommendation | Keep OTP path separate; panel multi-provider later | +| Priority | P2 for panel; P0 for OTP reliability | + +## 5.9 Payment Gateway (planned provider) + +| Aspect | Assessment | +| --- | --- | +| Purpose | Subscriptions + commerce + restaurant pay | +| Missing | Provider selection and adapters | +| Recommendation | Required before Marketplace and paid plans automation | +| Priority | P1 | + +## 5.10 AI Model Provider (planned) + +| Aspect | Assessment | +| --- | --- | +| Purpose | Assistants / Accounting 5.12 | +| Status | Planned → blocks AI UI | +| Recommendation | Mark Active only with adapters + entitlement + non-authority guards | +| Priority | P1 for 5.12; P2 for broad AI | + +## 5.11 Shared Library + +| Aspect | Assessment | +| --- | --- | +| Purpose | JWT helpers, phone normalize, event envelope, shared errors | +| Quality | Foundational | +| Recommendation | Keep free of business workflows | +| Priority | P0 | + +## 5.12 Edge SSL Provisioning + +| Aspect | Assessment | +| --- | --- | +| Purpose | Tenant HTTPS at scale | +| Quality | Automated via Celery→SSH→provision_ssl | +| Risk | Worker must reach nginx host; LE rate limits | +| Priority | P0 ops | + +--- + +# 6. API Platform + +## 6.1 Overall API Quality + +The platform is explicitly **API-first**. Core and Identity contracts live under `docs/reference/services-contracts.md` with an API index in `api-reference.md`. Accounting exposes versioned `/api/v1` areas for foundation, posting, ledger, treasury, AR/AP, assets, payroll, reporting, and compliance. Frontend typed clients call real endpoints (Accounting client documented as no-mocks). + +**Quality posture:** Strong conventions and versioning intent; consistency depends on each service following the same layering and error patterns. Historical review noted incomplete Identity README tables vs contracts—contracts should remain canonical. + +## 6.2 Consistency + +| Expected convention | Documented practice | +| --- | --- | +| Prefix | `/api/v1` per service | +| Auth header | `Authorization: Bearer <token>` | +| Tenant header | `X-Tenant-ID` (and slug/host resolution) | +| DTOs | Pydantic schemas at API boundary | +| Feature keys | `{service}.{resource}.{action}` | +| Events | `{aggregate}.{past_tense_verb}` + EventEnvelope | + +**Gaps:** Dual frontend client naming historically (`api.ts` vs `api-client.ts`) called out in architecture review—document or consolidate. New modules must not invent alternate auth/tenant schemes. + +## 6.3 Versioning + +APIs are organized under `api/v1/`. Architecture requires versioned REST. No separate public v2 strategy is detailed yet—future breaking changes should add v2 rather than silently break v1 consumers (Frontend + future partners). + +## 6.4 Security + +- Configurable `AUTH_REQUIRED` on Core +- Role dependencies: authenticated, platform_admin, tenant_admin, membership role ensures +- Accounting UI gated by AuthGuard + tenant id +- Internal service tokens scoped; hashed at rest +- Public tenant-site limited to public theme/site reads + +## 6.5 Validation + +Service architecture forbids business rules in routers but requires validation/HTTP mapping there. Accounting flows emphasize validate-before-post for vouchers. Testing strategy requires API contract tests including authz status codes. + +## 6.6 Performance + +Documented hot path: entitlement checks cached in Redis. Performance testing called out for entitlement/resolve paths “as needed.” No published SLA/latency budgets in the reviewed docs—treat as **UNKNOWN beyond caching intent**. + +## 6.7 Future Readiness + +| Need | Readiness | +| --- | --- | +| Many module UIs on same auth | Ready (SSO JWT) | +| Event-driven integrations | Pattern ready; bus not ready | +| External partner APIs | Needs stronger public API program (not fully specified) | +| Webhooks as product | Allowed channel; productized webhook platform not Active | + +--- + +# 7. Database + +## 7.1 Overall Schema Quality + +**Pattern:** Database-per-service with UUID IDs and timezone-aware timestamps. Core and Identity schemas are documented; Accounting has migrations through `0002_phases_57_511`. Future service schemas remain conceptual until migrations exist. + +**Conflict:** `database-architecture.md` still lists Accounting as “(future)” while registry/progress mark it Active—**update that architecture label**. + +## 7.2 Scalability + +Row-level multi-tenancy (ADR-003) optimizes early ops (one DB per service). Architecture acknowledges **noise-neighbor** risk at very large scale; mitigation is future (partitioning / isolation strategies)—not implemented as a current project phase. + +## 7.3 Tenant Readiness + +| Rule | Status | +| --- | --- | +| `tenant_id` on business tables | Required | +| No cross-tenant queries | Hard rule | +| Tenant denial tests | Required for new APIs | +| Platform admin cross-tenant support reads | Must be controlled + logged (compliance) | + +## 7.4 Relationships + +- **No cross-DB foreign keys** +- Dual membership tables (ADR-007): + - `core_platform_db.tenant_memberships` — workspace authz source of truth + - `identity_access_db.tenant_memberships` — SSO listing +- Accounting relationships remain inside `accounting_db` +- Reference data duplication across services must sync via API/events + +## 7.5 Performance + +Indexes and query plans are not exhaustively published in architecture docs. Entitlement caching reduces repeated plan checks. Ledger recalculation and reporting are domain-heavy—capacity planning should treat them as scale hotspots when tenant count grows (**engineering judgment labeled as planning recommendation derived from module scope, not a measured benchmark in docs**). + +## 7.6 Migration Strategy + +- Alembic per service +- Never edit applied migrations in production +- Historical ops caveat from architecture review: compose used `upgrade 0001_initial && stamp head` pattern—teams must understand stamp vs full upgrade risks +- Accounting progress notes migration `0002_phases_57_511` + +--- + +# 8. Frontend + +## 8.1 Architecture + +- Single Next.js SuperApp (not separate apps per module) +- Accounting under `app/accounting/**` with `AccountingShell` +- Strict FE/BE separation (ADR-002) +- AuthGuard + `/me` + onboarding redirects +- Design system tokens light/dark + `components/ds` +- Catalog tile → `/accounting` + +## 8.2 UI System + +Documented design system, layout, navigation, tables, forms, dialogs, drawers, theme, responsive, accessibility docs under `docs/frontend/`. Accounting uses DS components and ColorModeProvider. + +## 8.3 Routing + +Module route placement under SuperApp. Platform routes include login/callback/dashboard/onboarding/tenant site paths (progress). Mobile nav polish marked Complete at 90% on scoreboard. + +## 8.4 State Management + +TanStack Query providers for server state; toasts via Sonner. Tenant via `useTenantId` / `useMe`. No indication that business writes bypass APIs. + +## 8.5 Permission System + +UI gated by roles from `/me` mirroring Core roles. Accounting settings IAM is deep-linked to SuperApp settings rather than rebuilt. Advanced permission trees still roadmap. + +## 8.6 Reusable Components + +Shared DS library intended for cross-module reuse. Frontend docs include component library and page/table/form patterns—good foundation for CRM/Restaurant UIs if enforced. + +## 8.7 Scalability (FE) + +Adding modules as route trees scales organizationally if: + +1. Each module keeps its own API client +2. Shell/nav remains entitlement-aware +3. Bundle splitting discipline is maintained (performance doc exists; no numeric budgets extracted here) + +White-label runtime polish remains a cross-cutting FE concern for public tenant hosts. + +--- + +# 9. Backend + +## 9.1 Architecture + +Every backend service follows: + +``` +API (routers) → Services → Repositories → Models + ↑ + Schemas (DTOs) +``` + +Core layout includes `core/`, `middlewares/` (tenant), `workers/`, `api/v1/`, `tests/`. + +## 9.2 Modules + +| Service | DB | Status | +| --- | --- | --- | +| core-platform | `core_platform_db` | Active | +| identity-access | `identity_access_db` | Active | +| accounting | `accounting_db` | Active 5.1–5.11 | +| Others in registry | dedicated `*_db` | Scaffolded/Planned | + +## 9.3 Dependency Quality + +- Accounting → Core entitlement +- Identity → Keycloak + Core OTP +- Frontend → Core + Identity + Accounting + Keycloak +- Business modules must not import each other’s models +- Financial postings only via Accounting posting engine + +## 9.4 Transaction Handling + +Outbox pattern requires business row + `outbox_events` in **one transaction**. Accounting posting flows are transactional domain operations (validate/post/reverse/cancel). Cross-DB transactions are intentionally impossible—use eventual consistency via events. + +## 9.5 Caching + +Redis used for entitlement checks. Broader cache strategy for public tenant-site themes is implied by white-label architecture (“must stay cacheable”) but not fully specified as a platform cache product. + +## 9.6 Queues & Background Jobs + +Celery worker/beat: + +- `process_outbox_events` +- `provision_domain_ssl` / SSL expansion path +- Future jobs should follow same worker model until bus exists + +## 9.7 Health Monitoring + +Documented health checks for Core and Identity; Frontend HTTP 200; Keycloak realm availability. Suggested alerts: restart loops, Postgres/Redis down, cert expiry < 14 days, OTP error spikes, outbox failed growth. + +--- + +# 10. Security + +## 10.1 Authentication + +| Surface | Mechanism | +| --- | --- | +| Staff (platform/tenant) | Keycloak OIDC JWT RS256 | +| OTP users | HS256 JWT after SMS verify | +| Service-to-service | Internal tokens (scoped, hashed) | +| Public tenant site | Unauthenticated public reads only | + +Mobile number mandatory + OTP verified (ADR-005). Passwords only in Keycloak. + +## 10.2 Authorization + +Two axes must pass: **role** + **entitlement**. Core memberships are operational SoT. Identity memberships are listing-oriented. + +## 10.3 Permissions + +Feature keys namespaced per service. Accounting uses `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*`. Advanced custom permission trees / invites: roadmap. + +## 10.4 Secrets & Configuration + +Secrets in environment / secret stores—never git. Brand values not hardcoded (ADR-008). Backup docs call out storing `.env` outside git. + +## 10.5 Potential Risks + +1. Dual membership divergence +2. SSH-based SSL provision surface (worker → nginx host) +3. OTP provider outage blocking login paths +4. Incomplete custom-domain verify → spoofing/ops risk if misissued +5. Cross-tenant bugs as module count grows +6. AI future risk of over-authority (explicitly forbidden by AI architecture) + +--- + +# 11. Infrastructure + +## 11.1 Docker + +Compose-based local and production app host. Services listed in deployment architecture. Accounting container deploy called out in next-steps exit criteria. + +## 11.2 Deployment + +Two-host production model: app `192.168.10.162`, edge `192.168.10.156`. Public DNS names for apex, API, identity, auth, wildcard tenants. Runbooks under `docs/deployment/`. + +## 11.3 CI/CD + +Branching and release strategies exist under `docs/development/`. Earlier architecture review flagged CI/CD as poorly documented relative to Gitea remote existence. Treat CI maturity as **partially documented**—do not invent pipeline details not present in docs. + +## 11.4 Monitoring + +Health endpoints + log locations + suggested minimum alerts. Not a full metrics/tracing platform description. + +## 11.5 Backups + +`pg_dump` for core/identity (and Keycloak DB if separate); retain 7/4/3 suggested; verify restores; include certs/nginx maps; future file buckets. + +## 11.6 Scaling + +Vertical VPS-oriented today. Horizontal scale requires: stateless API replicas, Redis/Celery scaling, Postgres capacity, edge capacity, and eventually a real message bus. Tenant SSL automation must respect LE rate limits. + +--- + +# 12. Technical Debt + +Grouped by severity using conflicts, intentional incompletes, and architectural risks documented across progress/roadmap/review/architecture. + +## 12.1 Critical + +| ID | Finding | Notes | +| --- | --- | --- | +| C1 | Tenant isolation must remain perfect as modules explode | Testing strategy requires denial cases; failure is a company-ending bug class | +| C2 | Financial integrity depends on universal ADR-010 adherence | Any module writing journals directly breaks compliance story | +| C3 | Production hardening for Accounting deploy still open in next-steps | PATCH/setup/template import verification pending | + +## 12.2 High + +| ID | Finding | Notes | +| --- | --- | --- | +| H1 | No real message bus yet | Blocks Automation; limits fanout | +| H2 | Notification & File Storage not built | Will be reimplemented ad hoc if modules proceed | +| H3 | Payment gateway missing | Blocks monetization & commerce | +| H4 | Custom domain DNS/TXT verification missing | White-label enterprise expectation | +| H5 | Dual membership sync incomplete | ADR-007 accepted but unfinished operationally | +| H6 | Doc drift (Accounting “future” in DB architecture; older review claims) | Misleads planning | +| H7 | Advanced RBAC/invites missing | Enterprise tenant admin UX gap | + +## 12.3 Medium + +| ID | Finding | Notes | +| --- | --- | --- | +| M1 | White-label runtime partial | Near-term roadmap item | +| M2 | AI provider Planned blocks 5.12 | Scoreboard 0% | +| M3 | Observability minimal | Suggested alerts not a full SRE stack | +| M4 | Frontend API client dual naming history | DX confusion | +| M5 | Legacy admin tenant auto provision incomplete | Tracked intentionally | +| M6 | PDF export file delivery optional gap | Accounting FE remaining | +| M7 | Product decision “first module = Restaurant” stale vs Accounting shipped | Update decision status | + +## 12.4 Low + +| ID | Finding | Notes | +| --- | --- | --- | +| L1 | Phase numbering alias confusion (external vs internal) | Glossary/decision exists—keep linking | +| L2 | Placeholder service READMEs easy to misread as implemented | Registry status must be checked | +| L3 | Independent Subscription service deferred | Acceptable mid-term | +| L4 | Tax authority connector deferred | Explicitly out of early accounting scope | + +--- + +# 13. Refactoring Roadmap + +Work that should happen **before proliferating new business modules** (CRM + Restaurant + Ecommerce simultaneously). Rationale: shared platform leverage and isolation safety. + +## 13.1 Before next vertical (ordered) + +| # | Item | Why | Impact | +| --- | --- | --- | --- | +| 1 | Finish white-label public tenant runtime polish | Restaurant/digital menu depends on host branding/public paths | Unblocks hospitality GTM | +| 2 | Production-verify Accounting + FE | Avoid building on unstable prod baseline | Reduces incident risk | +| 3 | Reconcile stale architecture labels & product decision docs | Prevent wrong sequencing | Low code, high clarity | +| 4 | Membership sync strategy (events/tests) | Every new staff UI multiplies divergence risk | Prevents authz incidents | +| 5 | Activate File Storage + S3 provider | Media/docs needed by site/commerce/accounting exports | Avoids local disk hacks | +| 6 | Stand up Notification service MVP | Stops per-module SMS/email forks | Platform consistency | +| 7 | Payment provider adapter + subscription hooks | Monetization & restaurant/ecommerce pay | Revenue path | +| 8 | Message bus behind existing outbox | Enables Automation and scale fanout | Architectural unlock | +| 9 | Custom domain verification | Enterprise white-label completeness | Sales enablement | +| 10 | Advanced invites/RBAC | Multi-user tenants beyond owner | Enterprise readiness | + +**Estimate (planning, not a formal quote):** Items 1–4 are near-term hardening (days–few weeks of focused work depending on team size). Items 5–8 are multi-sprint platform investments. Items 9–10 are product-facing platform features. + +## 13.2 Explicitly do *not* refactor away + +- Database-per-service +- Outbox envelope +- Keycloak central SSO for staff +- ADR-010 posting ownership +- Row-level `tenant_id` model (until scale evidence demands more) + +--- + +# 14. Future Platform Architecture + +Target structure to support the SuperApp portfolio. + +## 14.1 Core Services + +| Service | Role | +| --- | --- | +| Core Platform | Tenants, domains, plans, entitlement, onboarding, audit, registries, internal tokens | +| Identity & Access | SSO BFF, profiles, OTP handoff | +| Keycloak | IdP | +| (Future) Subscription & Entitlement service | Extract when billing complexity demands | + +## 14.2 Shared Platform Services + +| Service | Role | +| --- | --- | +| Notification | Multi-channel fanout | +| File Storage | Tenant objects / signed URLs | +| SMS Panel | Campaign SMS (not auth OTP) | +| Payment | PSP adapters, webhooks | +| Link Shortener | Campaign links | +| Event Bus + Outbox workers | Reliable integration spine | +| Automation / Workflow engine | Cross-module triggers/actions | +| AI Assistant (platform) | Advisory only; provider-agnostic | +| Wallet (future) | Stored value / credits — should sit near Payment, not inside random verticals | +| Chat platform (live_chat) | Widget + routing shared by CRM/support | + +## 14.3 Business Modules + +| Module | Notes | +| --- | --- | +| Accounting / ERP financials | Already Active; posting hub for money | +| CRM + Customer Club features | Club can start as CRM segment/loyalty entities | +| Restaurant / Cafe / Digital Menu | Guest ordering on tenant domain | +| Appointment / Reservation / Gym | Vertical packs—prefer shared scheduling kernel later if multiple verticals need it | +| Ecommerce Store Builder | Catalog/cart/orders | +| Website / Landing Builder | Public content | +| Marketplace | After ecommerce + payment + identity maturity | +| Smart Messenger | Channel inbox | + +## 14.4 Infrastructure + +| Capability | Notes | +| --- | --- | +| Nginx edge + auto SSL | Keep; watch rate limits | +| Postgres per service | Keep; plan replicas/partitioning | +| Redis | Cache + queues | +| Celery → evolve with bus | Keep outbox ownership | +| Observability stack | Elevate from health checks to metrics/tracing/alerting productization | +| Backup/DR | Execute runbooks on schedule | +| CI/CD | Make pipelines first-class documented | + +## 14.5 Placement Map for Mission Capabilities + +```mermaid +flowchart TB + subgraph CoreSvc["Core Services"] + CORE["Core Platform"] + ID["Identity"] + KC["Keycloak"] + SUB["Subscription service future"] + end + subgraph Shared["Shared Platform Services"] + NOTIF["Notification"] + FILE["File Storage"] + PAY["Payment"] + SMS["SMS Panel"] + BUS["Message Bus"] + AUTO["Automation/Workflow"] + AI["AI Assistant"] + CHAT["Live Chat"] + WALL["Wallet future"] + end + subgraph Biz["Business Modules"] + ACC["Accounting/ERP finance"] + CRM["CRM + Customer Club"] + REST["Restaurant/Cafe/Menu"] + ECOM["Store Builder"] + WEB["Website/Landing Builder"] + MKT["Marketplace"] + GYM["Gym/Appointment/Reservation"] + end + Biz --> CoreSvc + Biz --> Shared + REST --> PAY + ECOM --> PAY + MKT --> ECOM + ACC --> NOTIF + CRM --> NOTIF + WEB --> FILE + ECOM --> FILE + AUTO --> BUS +``` + +--- + +# 15. Enterprise Readiness + +## 15.1 100 Tenants + +**Assessment: Achievable** with current architecture if ops discipline holds. + +| Area | Bottleneck risk | +| --- | --- | +| Postgres row-level tenancy | Low–moderate | +| Nginx + SSL expand | Moderate (LE limits, SSH automation) | +| Keycloak | Low if sized | +| Celery outbox | Low–moderate | +| Support/observability | Medium (process) | +| Accounting heavy reports | Medium per active finance tenants | + +## 15.2 1,000 Tenants + +**Assessment: Conditional — needs platform upgrades.** + +| Bottleneck | Why | +| --- | --- | +| Outbox without bus | Fanout to notification/CRM will choke | +| Single Postgres per busy service | Noise-neighbor / IO | +| Edge cert automation | Operational load | +| Redis/Celery capacity | Must be sized and monitored | +| Absence of Notification/File platforms | Ad hoc load on Core | +| RBAC/invites | Support burden multiplies | +| Custom domains | Verification + SSL complexity | + +## 15.3 10,000 Tenants + +**Assessment: Not ready** on documented footing. + +Required before serious pursuit: + +1. Horizontally scaled APIs and workers +2. Real message bus + consumer autoscaling +3. DB strategy beyond naive shared tables (partitioning, replicas, possibly large-tenant isolation) +4. Mature multi-provider SMS/email/push +5. Payment + subscription systems hardened +6. Full observability and on-call +7. Self-service domain/SSL with rate-limit governance +8. Strong tenant isolation testing in CI +9. Possibly regional deployment (not documented today) + +--- + +# 16. Recommended Development Order + +Dependency-aware sequence synthesizing roadmap, next-steps, registries, and platform needs. + +## 16.1 Wave 0 — Stabilize (now) + +1. Accounting production deploy verification +2. Provider AI Active → Phase 5.12 (optional parallel) +3. Doc reconciliation (DB architecture Accounting status; product first-module decision) +4. White-label polish completion + +## 16.2 Wave 1 — Shared platforms + +1. File Storage + S3 provider +2. Notification MVP +3. Payment gateway provider +4. Custom domain DNS/TXT verify +5. Membership sync Core↔Identity +6. Message bus behind outbox + +## 16.3 Wave 2 — GTM verticals + +1. **Restaurant / Digital Menu** (depends on white-label + payment) +2. **CRM** (depends on notification; feeds Customer Club) +3. Customer Club / loyalty features (on CRM and/or Restaurant) + +## 16.4 Wave 3 — Builders & content + +1. Website / Landing Builder (depends on file storage) +2. Ecommerce / Store Builder (depends on file storage + payment) +3. Link Shortener / SMS Panel campaigns + +## 16.5 Wave 4 — Conversational & AI + +1. Live Chat +2. AI Assistant (provider-agnostic; never money authority) +3. Smart Messenger + +## 16.6 Wave 5 — Platforms of platforms + +1. Automation / Workflow (requires bus) +2. Wallet (requires payment) +3. Appointment / Reservation / Gym packs (prefer shared scheduling once ≥2 verticals need it) +4. Marketplace (requires ecommerce + payment + identity maturity + accounting settlement events) +5. Subscription & Entitlement independent service +6. Compliance connectors (tax authority) + +## 16.7 Dependency Diagram + +```mermaid +flowchart TD + WL[White-label polish] --> REST[Restaurant/Menu] + PAY[Payment] --> REST + PAY --> ECOM[Ecommerce] + FILE[File Storage] --> WEB[Website Builder] + FILE --> ECOM + NOTIF[Notification] --> CRM[CRM] + BUS[Message Bus] --> AUTO[Automation] + ECOM --> MKT[Marketplace] + PAY --> MKT + ID[Identity maturity] --> MKT + ACC[Accounting] --> MKT + AIProv[AI Provider] --> AI[AI Assistant] + AI --> CHAT[Live Chat assist] + CRM --> CLUB[Customer Club] + REST --> CLUB +``` + +--- + +# 17. Final Architecture Recommendations + +## 17.1 Immediate Actions + +- [ ] Execute `next-steps.md` exit criteria (AI provider decision, Accounting prod verify, scoreboard 5.12 unblock path) +- [ ] Complete white-label runtime rendering gaps +- [ ] Fix stale “Accounting (future)” labeling in database architecture docs +- [ ] Update product decision: Accounting is Active; Restaurant is next hospitality vertical candidate +- [ ] Enforce ADR-010 in all new module designs +- [ ] Add/keep cross-tenant denial tests for every new tenant API +- [ ] Monitor outbox failed growth + cert expiry + +## 17.2 Next Phase + +- [ ] File Storage + S3 Active +- [ ] Notification service MVP consuming core/accounting events +- [ ] Payment gateway Active for subscriptions/commerce +- [ ] Custom domain verification +- [ ] Core↔Identity membership sync events +- [ ] Introduce real message bus without breaking EventEnvelope +- [ ] Advanced membership permissions & invites +- [ ] Restaurant foundation after white-label+pay + +## 17.3 Long-term Improvements + +- [ ] Automation engine +- [ ] Marketplace +- [ ] Independent Subscription service +- [ ] Multi-region / higher-isolation tenancy options +- [ ] Full observability (metrics, tracing, SLO dashboards) +- [ ] Tax/compliance connectors +- [ ] Shared scheduling kernel if Gym/Appointment/Reservation multiply +- [ ] Wallet platform adjacent to Payment + +--- + +# 18. Enterprise Scorecard + +Scores are **documentation-grounded judgments** of current readiness (1 = absent/unfit, 10 = enterprise-proven at scale). They are not load-test results. + +| Dimension | Score | Rationale | +| --- | --- | --- | +| Architecture | **8** | Clear ADRs, boundaries, layering; some doc drift | +| Modularity | **8** | DB-per-service + registries; many modules still scaffolds | +| Scalability | **5** | Fine for early SaaS; 1k–10k needs bus/DB/edge upgrades | +| Maintainability | **7** | Docs/ADR/phase discipline strong; dual memberships & drift cost | +| Security | **7** | SSO/OTP/tokens/TLS solid; RBAC depth & domain verify gaps | +| Backend | **8** | Core/Identity/Accounting real with layering; workers present | +| Frontend | **7** | SuperApp + DS + Accounting high completion; white-label partial | +| Database | **7** | Sound rules; Accounting label stale; scale strategy nascent | +| API | **7** | Versioned, tenant-aware; consistency/tooling still maturing | +| Testing | **6** | Strategy clear; Accounting 21 tests noted; not full enterprise matrix evidenced | +| Documentation | **8** | Post Phase D strong; conflicts remain in places | +| Developer Experience | **7** | Guides/templates/registries; dual clients & stubs confuse | +| Infrastructure | **6** | Compose+nginx+SSL real; monitoring/CI depth limited in docs | +| Enterprise SaaS Readiness | **6** | Strong foundation + one deep vertical; shared platforms incomplete | + +### Scorecard summary chart + +```mermaid +%%{init: {'theme': 'base'}}%% +quadrantChart + title Readiness vs Strategic Importance + x-axis Low readiness --> High readiness + y-axis Lower leverage --> Higher leverage + quadrant-1 Scale next + quadrant-2 Protect & polish + quadrant-3 Later + quadrant-4 Foundation bets + Architecture: [0.78, 0.85] + Accounting: [0.82, 0.80] + Identity: [0.80, 0.88] + WhiteLabel: [0.55, 0.75] + Notification: [0.20, 0.80] + FileStorage: [0.20, 0.78] + Payment: [0.15, 0.85] + MessageBus: [0.25, 0.90] + Restaurant: [0.15, 0.70] + Marketplace: [0.05, 0.55] +``` + +--- + +# Appendix A — ADR Index + +| ADR | Title | Status | +| --- | --- | --- | +| ADR-001 | Database-per-Service | Accepted | +| ADR-002 | Strict Frontend / Backend Separation | Accepted | +| ADR-003 | Row-Level Multi-Tenancy with tenant_id | Accepted | +| ADR-004 | Central SSO with Keycloak | Accepted | +| ADR-005 | Mandatory Mobile Identity with OTP | Accepted | +| ADR-006 | Transactional Outbox / Inbox for Events | Accepted | +| ADR-007 | Dual Tenant Membership Tables | Accepted | +| ADR-008 | White-Label Branding via Config and Tenant Profile | Accepted | +| ADR-009 | Nginx Edge with Automatic Tenant SSL Expansion | Accepted | +| ADR-010 | Posting Engine Ownership for Accounting Entries | Accepted | + +--- + +# Appendix B — Provider Status Snapshot + +| Provider | Status | +| --- | --- | +| Payamak | Active | +| Keycloak | Active | +| Let's Encrypt / Certbot | Active | +| S3-Compatible | Planned | +| Payment Gateway | Planned | +| AI Model Provider | Planned | + +--- + +# Appendix C — Phase Numbering Glossary + +| Internal | Meaning | +| --- | --- | +| Phase 1 | Core Platform | +| Phase 2 | Identity & Access + SSO | +| Phase 3 | OTP Login + Tenant Management | +| Phase 4 | Tenant Onboarding & Workspace Activation | +| Phase D | Documentation Architecture Consolidation | +| Phase 5.x | Accounting module phases | +| External brief “Phase 3” | Often means onboarding → internal Phase 4 | + +--- + +# Appendix D — Event Catalog Snapshot (Core / Identity / Accounting samples) + +**Core:** `tenant.created`, `tenant.suspended`, `tenant.activated`, `domain.created`, `subscription.created`, `subscription.updated`, `feature_access.changed` + +**Identity:** `user.registered`, `tenant_member.added`, `tenant_member.removed` + +**Accounting (registry):** `voucher.posted`, `ledger.updated`, `cash.received`, `settlement.completed`, `sales_invoice.posted` + +**Reserved future examples:** CRM `lead.*` / `opportunity.*`; Restaurant `order.*`; Ecommerce `order.*` / `product.*`; Notification `notification.delivered` + +--- + +# Appendix E — Intentional Incompletes After Phase 4 (still open) + +From progress: + +- Real payment gateway +- Real DNS/TXT custom-domain verification +- Membership/plan auto-provision on legacy `POST /admin/tenants` +- First *hospitality* business module (Restaurant)—note Accounting later became the first deep vertical +- Advanced permissions / invites +- Independent Subscription service +- Real message bus + +--- + +# Appendix F — Source Document Index Used for Synthesis + +- `docs/progress.md`, `docs/roadmap.md`, `docs/next-steps.md`, `docs/last_step.md` (if present), `docs/module-registry.md`, `docs/provider-registry.md`, `docs/glossary.md` +- `docs/current-architecture-review.md` +- `docs/architecture/*`, `docs/architecture/adr/*` +- `docs/reference/*` +- `docs/deployment/*` +- `docs/development/*` (principles, testing, etc.) +- `docs/frontend/*` (architecture + completion scoreboard) +- `docs/phases/**/README.md` and Accounting phase area +- `docs/decisions/product/first-business-module.md`, phase-numbering decision + +**Not used:** application source code (per mission constraint for this master report generation pass). + +--- + +# Appendix G — Narrative Architecture Brief (extended) + +TorbatYar’s strategic bet is that a SuperApp can share **identity, tenancy, entitlement, edge TLS, and eventing**, while each vertical owns its database and domain API. That bet is already partially validated: Core + Identity + edge SSL support real onboarding and host-based tenants; Accounting proves a deep vertical can live beside the platform without collapsing into a monolith—**provided** posting ownership and entitlement checks remain sacred. + +The primary failure mode for the next 12–24 months is not “wrong framework choice.” It is **premature module sprawl**: scaffolding CRM, Restaurant, Ecommerce, Chat, and Messenger in parallel without Notification, File Storage, Payment, and a real bus. That path recreates N SMS integrations, N upload hacks, and N inconsistent authz bugs. + +The primary success path is sequenced: **harden platform polish → ship shared services → open verticals in dependency order → only then marketplace/automation at scale**. Accounting should remain the financial spine; AI must remain advisory; guest experiences may use local auth inside vertical DBs without weakening staff SSO. + +Operationally, the two-host VPS topology is appropriate for early production and ~100 tenants with discipline. Crossing into thousands of tenants is an infrastructure program, not a feature sprint. + +--- + +# Appendix H — Module Expansion Playbooks (summary) + +### H.1 Adding a business module + +1. Create `backend/services/<name>/` + DB + Alembic +2. Register in Core + `module-registry.md` +3. Feature keys + entitlement wiring +4. Events in catalog + outbox producers +5. Frontend module routes using DS + typed client +6. Tenant denial tests + docs/phase updates first if boundaries change + +### H.2 Adding a provider + +1. `provider-registry.md` entry +2. Adapter boundary (no domain hardcode) +3. `provider-reference.md` ops sheet when Active +4. Tests for failure modes +5. Never mark Active without config + ownership + +### H.3 Touching money + +1. Emit posting intent / use Accounting APIs +2. Never write `JournalEntry` lines in foreign modules +3. Keep audit metadata tenant-scoped + +--- + +# Appendix I — Risk Register (consolidated) + +| Risk | Likelihood | Impact | Owner lens | Treatment | +| --- | --- | --- | --- | --- | +| Cross-tenant leak | Med if rushed | Critical | Every module | Mandatory tests + review | +| Journal fragmentation | Med without ADR education | Critical | Accounting + platform | ADR-010 gates | +| Busless fanout collapse | High as modules grow | High | Platform | Bus before Automation | +| Membership drift | Med | High | Identity + Core | Sync events | +| LE rate limit / SSL ops | Med | High | Ops | Throttle + monitoring | +| Doc-driven wrong build | Med | Med | Architecture | SoT hierarchy | +| Payment delay | High | High | Product | Provider wave 1 | +| AI overreach | Low if guarded | High | AI + Compliance | Architecture rules | + +--- + +# Appendix J — Readiness Checklist for “Open Next Vertical” + +- [ ] White-label public paths accepted by product +- [ ] Entitlement keys reserved in registry +- [ ] File upload story decided (File Storage or explicit exception) +- [ ] Notification story decided +- [ ] Payment needed? If yes, provider Active +- [ ] Accounting impact? If yes, posting contracts defined +- [ ] Guest auth decision (local vs SSO) documented +- [ ] Phase docs created under `docs/phases/` +- [ ] Scoreboard/progress hooks agreed + +--- + +# Appendix K — Glossary Essentials + +- **Tenant:** workspace/organization +- **Entitlement:** plan feature gate +- **Posting Engine:** only writer of accounting journals +- **Outbox/Inbox:** reliable event publish/consume +- **JIT:** just-in-time Core user link/create from Keycloak JWT +- **Platform admin:** cross-tenant operator role +- **Tenant owner:** required steward of an active workspace + +--- + +# Appendix L — Closing Statement + +TorbatYar is past the “empty platform” stage. It has a coherent multi-tenant architecture, a working identity edge, and a serious Accounting vertical. Enterprise ambition is credible **if** shared platform services and operational scale work are sequenced ahead of SuperApp breadth. This master report should be treated as the planning baseline for that sequencing; update it when `progress.md` / registries change materially. + +--- + +*End of Master Architecture Report v1.0 — 2026-07-24* + +--- + +# Appendix M — Accounting Phase Deep Dive (5.1–5.11) + +This appendix expands Business Module Accounting using phase documents. All phases below are marked **Complete** in phase docs unless noted. + +## M.1 Phase 5.1 — Accounting Foundation (v0.5.1.0) + +**Delivered:** Chart of Accounts, Account, Currency, Exchange Rate, Fiscal Year/Period, Cost Center, Project, Dimension, Accounting Settings, Document Number Sequence, Tenant Accounting Configuration; full Repository/Service/Schema/API layering; foundation events; `accounting.*` permissions; Alembic `0001_initial`; tenant isolation tests. + +**API:** `/api/v1/accounts`, `/api/v1/fiscal` + +**Architectural significance:** Establishes the dimensional and fiscal spine every later financial feature depends on. Without open periods, active accounts, and tenant configuration, posting cannot be safe. + +## M.2 Phase 5.2 — Double Entry & Posting Engine (v0.5.2.0) + +**Delivered:** Voucher, VoucherLine, Journal, JournalEntry; **PostingEngine** as sole creator of journal entries (ADR-010); validation for balanced entries, active accounts, open period; lifecycle Draft → Validated → Posted → Cancelled/Reversed; PostingLog/Error/Reference; AccountingAuditLog; APIs for validate/post/reverse/cancel/audit; permissions `accounting.post`, `accounting.validate`, `accounting.reverse`, etc.; events including `voucher.posted`, `posting.completed`, `journal_entry.created`. + +**Architectural significance:** This is the compliance and integrity choke point for the entire SuperApp money path. Every future Restaurant/Ecommerce settlement must aim here, not invent ledgers. + +## M.3 Phase 5.3 — General Ledger & Fiscal Management + +*(Summarized from phase area completion and FE ledger capabilities.)* Progress and FE architecture show ledger balances, trial balance, and recalculate against real `/api/v1/ledger/*`. Fiscal lock events appear in the event catalog (`fiscal_period.locked`, `trial_balance.generated`, `ledger.updated`). + +**Architectural significance:** Turns posted vouchers into queryable financial truth for reports and period close. + +## M.4 Phase 5.4 — Treasury Management + +FE scoreboard: Treasury 90% Complete — cash boxes, banks, bank accounts, cash receipts. Registry events include `cash.received`. Permissions include `treasury.*`. + +**Architectural significance:** Bridges operational cash movement to posting; payment gateway later should settle into treasury/accounting flows rather than bypass them. + +## M.5 Phase 5.5 — Accounts Receivable & Payable + +FE: Customers/AR 92%, Suppliers/AP 85%. Events include `settlement.completed`. Permission prefixes `receivable.*`, `payable.*`. + +**Architectural significance:** Customer/supplier subledgers become integration points for CRM and commerce later—prefer references + events over duplicating party masters carelessly. + +## M.6 Phase 5.6 — Sales Accounting Integration + +Events: `sales_invoice.posted`, `revenue.recognized`. Permission prefix `sales_accounting.*`. + +**Architectural significance:** Pattern for “operational document → accounting posting intent → Posting Engine.” Ecommerce/Restaurant should copy this pattern. + +## M.7 Phase 5.7 — Purchase & Inventory Accounting Integration + +Progress: InventoryValuationEngine delivered with migration `0002_phases_57_511` covering 5.7–5.11. + +**Architectural significance:** Inventory valuation is a classic cross-module hazard; ownership stays in Accounting for valuation postings even if stock quantities later live in another service. + +## M.8 Phase 5.8 — Fixed Assets Management + +FE Assets 90%: categories, activate, depreciate, schedule. DepreciationEngine noted in progress. + +## M.9 Phase 5.9 — HCM & Payroll Accounting + +FE Payroll 88%: departments, periods, calculate, post. PayrollEngine noted in progress. + +**Architectural significance:** Payroll is compliance-sensitive; AI must not become posting authority here. + +## M.10 Phase 5.10 — Financial Reporting & BI (v0.5.10.0) + +**Delivered:** FinancialReport, ReportTemplate, Dashboard/Widget, KPI, ReportSnapshot, ScheduledReport, ReportExport, ReportHistory, ReportConfiguration; **FinancialReportEngine** for trial balance, balance sheet, income statement from posted data; export framework PDF/Excel/CSV/JSON; API `/api/v1/reporting`; permissions `reports.*`, `dashboard.*`, `financial_statements.view`; events `financial_report.generated`, `balance_sheet.generated`, `report.exported`. + +**Remaining (progress):** optional richer PDF export file delivery from storage — depends on File Storage maturity. + +## M.11 Phase 5.11 — Enterprise Compliance, Audit & Governance (v0.5.11.0) + +**Delivered:** immutable AuditRecord/History; CompliancePolicy; GovernanceRule; ApprovalWorkflow/Step/Request; Delegation; RiskRecord; PolicyViolation; Evidence; RetentionPolicy; ControlDefinition; **AuditFramework**, **ComplianceEngine**, **GovernanceService** (approvals, SoD, risk); API `/api/v1/compliance`; permissions `audit.*`, `compliance.*`, `governance.*`, `approval.execute`, `risk.*`; events `audit_record.created`, `approval.completed`, `policy_violation.detected`. + +**Conflict note:** `compliance-architecture.md` still has a subsection titled “Accounting Compliance (future)” listing double-entry integrity and dimensions as future — but 5.2/5.11 phase docs mark substantial compliance machinery **Complete**. Prefer phase/progress for implementation status; treat the architecture subsection as needing wording refresh (tax connectors remain future). + +## M.12 Phase 5.12 — AI Native Accounting (planned / blocked) + +Blocked until AI Model Provider is Active in provider registry. AI architecture forbids AI as sole authority for money/compliance/authorization. FE scoreboard AI = 0% Blocked. + +--- + +# Appendix N — Twenty Project Principles (operational translation) + +| # | Principle | Architecture implication | +| --- | --- | --- | +| 1 | Tenant-aware everything | Middleware + repository filters + FE `X-Tenant-ID` | +| 2 | Auditable business actions | Core audit_logs + module audit/events | +| 3 | Logic in Services | Thin routers | +| 4 | Repositories persistence-only | No hidden workflows in ORM layer | +| 5 | Thin API handlers | Validate → authorize → service → map | +| 6 | Tests per phase | pytest per service; tenant denial cases | +| 7 | Docs per phase | registries/contracts/progress | +| 8 | No TODO in completed phases | TODOs reopen the phase | +| 9 | No cross-tenant query | Logged platform_admin exceptions only | +| 10 | No direct JournalEntry | ADR-010 | +| 11 | Compliance independent | Controls survive UI redesign | +| 12 | AI independent | Product works with AI off | +| 13 | Event-ready | Outbox-backed effects | +| 14 | API-first | UI is a client | +| 15 | Everything documented | ADRs/registries current | +| 16 | Database-per-service | ADR-001 | +| 17 | Absolute FE/BE split | ADR-002 | +| 18 | No hardcoded brand/secrets | env/config/DB | +| 19 | Docs before conflicting code | stop-the-line | +| 20 | Phase completion gate | docs README checklist | + +--- + +# Appendix O — Frontend Platform Standards (for all future modules) + +Derived from `docs/frontend/*` index hard rules and architecture. + +## O.1 Hard Rules + +1. Never mock accounting (or, by extension, other module) business data in enterprise screens. +2. Never bypass the owning service API. +3. Always send JWT + `X-Tenant-ID`. +4. Journal entries only via Posting Engine UI actions. +5. Reuse Design System — do not redesign per page. + +## O.2 Documented FE knowledge areas + +Design system (tokens, typography, themes, RTL), component library, layout/shell, page patterns (dashboard/list/form/report), RHF+Zod forms, tables, dialogs, theme/white-label, navigation maps, responsive breakpoints, API integration, TanStack Query state, performance splitting, WCAG AA baseline, testing quality gates. + +## O.3 Implication for Restaurant/CRM UIs + +New verticals should open as SuperApp module routes with DS components, Query providers, and entitlement-aware nav—not new Next apps—unless an ADR supersedes ADR-002’s deployment model. + +--- + +# Appendix P — Production & SSL Operations Digest + +## P.1 Hosts & public routing + +| Host | Upstream | +| --- | --- | +| `torbatyar.ir` / `www` / `*.torbatyar.ir` | Frontend `:3000` | +| `api.torbatyar.ir` | Core `:8000` | +| `identity.torbatyar.ir` | Identity `:8001` | +| `auth.torbatyar.ir` | Keycloak `:8080` | +| `accounting.torbatyar.ir` | Accounting `:8002` | + +App host `192.168.10.162`; Nginx edge `192.168.10.156`. + +## P.2 Deploy shape + +1. Pull release on app host +2. `docker compose up -d --build` +3. Alembic per service (document stamp vs upgrade precisely in release notes) +4. Reload Nginx if routing changed +5. Smoke: `/health`, login, tenant subdomain + +Critical env groups: `PLATFORM_*`, `NEXT_PUBLIC_*`, DB URLs, `KEYCLOAK_*`, `AUTH_REQUIRED`, Payamak/OTP, `PLATFORM_ADMIN_MOBILES`, `SSL_PROVISION_*`. + +## P.3 TLS + +Edge terminates TLS with LE material under `/etc/letsencrypt/live/torbatyar.ir/`. Platform hosts force HTTPS; ACME on port 80. Tenant subdomain SSL via Celery SSH → `provision_ssl.py` when `SSL_PROVISION_ENABLED=true`; readiness map `torbatyar-tenant-ssl.map`. Custom domains may remain `pending` until DNS verification ships. + +## P.4 Backup & DR posture + +Daily `pg_dump` of service DBs; retain 7 daily / 4 weekly / 3 monthly (suggested); restore drills; protect secrets, certs, nginx maps; future object storage buckets. + +## P.5 Monitoring minimum + +Health endpoints; Docker/Nginx/Celery logs; alerts for restart loops, DB/Redis down, cert expiry < 14 days, OTP error spikes, outbox failed growth. + +--- + +# Appendix Q — Identity & Authorization Worked Examples + +## Q.1 Staff login path + +Frontend `/login` → Keycloak themed login (password or mobile OTP via Identity handoff) → `/auth/callback` → JWT in FE → Core `/me` / tenant context → module APIs with Bearer + tenant header. + +## Q.2 OTP path + +Core OTP request/verify via Payamak → HS256 JWT → `UserService.resolve_current` maps to `users.id`. Identity delegates SMS to Core OTP client (no duplicate SMS logic). + +## Q.3 JIT path + +Keycloak JWT → resolve/link via `users.keycloak_sub` (implemented; older contracts saying 403/deferred are stale). + +## Q.4 Authorization decision table + +| Check | Pass condition | +| --- | --- | +| Authentication | Valid JWT (Keycloak or OTP) | +| Workspace role | Core membership role sufficient for action | +| Entitlement | Plan/feature/custom override allows feature key | +| Tenant scope | Resolved tenant matches resource tenant | +| Posting | Accounting permissions + open period + balanced voucher | + +## Q.5 Dual membership caution + +Identity `tenant_admin`/`tenant_member` listing roles are **not** operational authz. UI and services must not authorize workspace mutations from Identity membership alone. + +--- + +# Appendix R — Eventing Blueprint for Future Modules + +## R.1 Envelope fields + +`event_id`, `event_type`, `aggregate_type`, `aggregate_id`, `tenant_id`, `source_service`, `payload`, `occurred_at`. + +## R.2 Producer checklist + +1. Write business change + outbox row atomically +2. Name event `{aggregate}.{past_tense}` +3. Update event catalog + module registry producers +4. Do not require synchronous cross-DB commit + +## R.3 Consumer checklist + +1. Inbox insert by `event_id` (idempotent) +2. Handle payload in service layer +3. Emit follow-up events if needed +4. Never call foreign DB + +## R.4 Suggested future event families (reserved in catalog) + +| Module | Examples | +| --- | --- | +| CRM | `lead.created`, `opportunity.won` | +| Restaurant | `order.placed`, `order.completed` | +| Ecommerce | `order.placed`, `product.updated` | +| Notification | `notification.delivered` | + +--- + +# Appendix S — Capability Matrix (Implemented vs Designed) + +| Capability | Implemented | Scaffold/Planned | Depends on | +| --- | --- | --- | --- | +| Multi-tenant workspaces | Yes | | Core | +| SSO staff login | Yes | | Keycloak/Identity | +| OTP mobile login | Yes | | Payamak | +| Onboarding wizard | Yes | | Core/FE | +| Subdomain SSL auto | Yes | | Celery/Nginx/LE | +| Entitlement engine | Yes | | Redis/Core | +| Accounting full 5.1–5.11 | Yes | 5.12 AI | Core | +| Accounting FE enterprise | Mostly | AI UI | Accounting API | +| White-label polish | Partial | Complete runtime | Public site | +| Custom domain verify | | Yes | DNS | +| Payment | | Yes | PSP | +| Notification hub | | Yes | Bus helpful | +| File storage | | Yes | S3 | +| CRM | | Yes | Core/Notify | +| Restaurant | | Yes | White-label/Pay | +| Ecommerce | | Yes | File/Pay | +| Marketplace | | Yes | Ecommerce+ | +| Live chat / AI / Messenger | | Yes | Providers | +| Automation | | Yes | Bus | +| Message bus | | Yes | Infra | + +--- + +# Appendix T — Target Operating Model for Enterprise SaaS + +## T.1 Team boundaries (recommended) + +| Team | Owns | +| --- | --- | +| Platform | Core, Identity edge contracts, entitlement, tenancy, edge SSL | +| FinTech/Accounting | Accounting service + posting contracts | +| Experience | SuperApp FE shells, DS, white-label | +| Growth verticals | Restaurant/CRM/Ecommerce module delivery | +| Ops | Compose, Nginx, backups, certs, alerts | + +## T.2 Change control + +1. Architecture/ADR update if boundaries change +2. Registry + contracts + events +3. Implementation + tests +4. Progress/scoreboard + +## T.3 Definition of Ready for a new module + +- Phase README created +- DB name reserved +- Feature key prefix reserved +- Entitlement plan flags designed +- Dependencies (file/notify/pay) explicit +- FE route namespace reserved + +## T.4 Definition of Done + +Aligns with principles 6–8, 15, 20: tests, docs, no TODO, registries updated, progress checked. + +--- + +# Appendix U — Scale Bottleneck Catalog (detailed) + +## U.1 Application layer + +| Bottleneck | 100 | 1k | 10k | +| --- | --- | --- | --- | +| API single-node | OK | Need replicas | Mandatory HA | +| Keycloak | OK | Size up | Cluster | +| Accounting report CPU | Watch | Isolate workers | Dedicated read models | +| FE SSR/hosting | OK | CDN/cache | Multi-node | + +## U.2 Data layer + +| Bottleneck | 100 | 1k | 10k | +| --- | --- | --- | --- | +| Shared tenant rows | OK | Indexes/partition | Partition + large-tenant strategy | +| Backup windows | OK | Parallel dumps | Continuous/PITR | +| Cross-service sync lag | Low | Visible | Needs bus SLOs | + +## U.3 Edge / certs + +| Bottleneck | 100 | 1k | 10k | +| --- | --- | --- | --- | +| LE SAN expansion | Manageable | Rate-limit risk | Different cert strategy likely | +| Nginx fanout | OK | Tune | Scale edge | + +## U.4 Async + +| Bottleneck | 100 | 1k | 10k | +| --- | --- | --- | --- | +| Celery outbox poll | OK | Tune concurrency | Bus + partitioned consumers | +| Notification fanout without hub | Painful | Severe | Impossible cleanly | + +--- + +# Appendix V — Prioritized Platform Backlog (architecture view) + +### P0 + +1. Production verify Accounting host routing (`accounting.torbatyar.ir`) +2. White-label completion +3. Doc conflict cleanup +4. Tenant isolation regression suite expansion + +### P1 + +1. File Storage + S3 Active +2. Notification MVP +3. Payment provider +4. Message bus +5. Custom domain verify +6. Membership sync +7. Invites/RBAC + +### P2 + +1. Restaurant foundation +2. CRM foundation +3. AI provider + 5.12 +4. Website builder +5. SMS panel campaigns + +### P3 + +1. Ecommerce +2. Chat/Messenger +3. Automation +4. Marketplace +5. Wallet / Gym packs / scheduling kernel + +--- + +# Appendix W — Conflict Log (full) + +| ID | Conflict | Resolution used here | +| --- | --- | --- | +| X1 | DB architecture lists Accounting future | Accounting Active 5.11 | +| X2 | Compliance architecture “future” subsection vs 5.11 complete | Implementation complete; tax connectors still future | +| X3 | Product decision first module Restaurant vs Accounting shipped | Accounting is first deep vertical; Restaurant next GTM candidate | +| X4 | next-steps AI/prod vs roadmap near-term Restaurant/white-label | Both valid: next-steps = immediate; roadmap = near; do Wave 0 then white-label/Restaurant | +| X5 | current-architecture-review (Jul 22) pre-Accounting depth | Historical for doc drift; not status SoT | +| X6 | Phase 3 naming external vs internal | Internal numbering wins | +| X7 | Older JIT deferred claims | JIT done | +| X8 | Older nginx later claims | Nginx/TLS done | +| X9 | services placeholder-only claims | Identity + Accounting real | +| X10 | Event catalog “Accounting 5.1–5.6” heading while 5.7–5.11 exist | Catalog needs extension note; registry lists additional events | + +--- + +# Appendix X — Recommendation Narratives + +## X.1 Why shared services before more verticals + +Accounting already shows how deep a vertical can become. If Restaurant and CRM each implement uploads, SMS, and payments independently, the platform becomes a federation of accidental monoliths. Notification, File Storage, and Payment are force-multipliers: one hardening effort benefits all modules. + +## X.2 Why message bus before Automation + +Automation without a durable fanout fabric will either poll databases unsafely or couple modules via brittle synchronous chains. ADR-006 already anticipates swapping publish transport without rewriting producers—use that escape hatch before building workflow product UX. + +## X.3 Why Restaurant still matters after Accounting + +Accounting monetizes and operationalizes finance-heavy tenants. Restaurant/digital menu monetizes SMB hospitality on white-label domains—the original product wedge in the Proposed decision. They are complementary, not mutually exclusive; sequencing should still respect white-label and payment prerequisites. + +## X.4 Why AI is last among Accounting phases + +Principle 12 and AI architecture require business continuity without AI. Shipping 5.12 before provider governance risks putting probabilistic systems near money. Keep AI advisory with human/approval gates (especially given 5.11 SoD tooling). + +--- + +# Appendix Y — Scorecard Narrative Detail + +### Architecture (8/10) + +Strength from ADRs and boundaries; deducted for stale labels and incomplete horizontal architecture realization. + +### Modularity (8/10) + +Physical module boundaries exist; many modules are empty shells—modularity of *design* exceeds modularity of *delivery*. + +### Scalability (5/10) + +Appropriate early VPS SaaS; insufficient evidence/plan execution for 10k tenants. + +### Maintainability (7/10) + +Docs+tests culture strong; dual membership and sprawl risk weigh down. + +### Security (7/10) + +Solid authn/TLS/tenant rules; enterprise RBAC/domain verify incomplete. + +### Backend (8/10) + +Three real services with clear layering and workers. + +### Frontend (7/10) + +Strong Accounting module patterns and DS; white-label and multi-module nav still maturing. + +### Database (7/10) + +Good rules and Accounting migrations; scale playbook thin; doc drift. + +### API (7/10) + +Versioned and tenant-aware; ecosystem consistency still forming. + +### Testing (6/10) + +Strategy excellent; breadth beyond cited suites not fully evidenced in status docs. + +### Documentation (8/10) + +Phase D success; conflicts remain. + +### Developer Experience (7/10) + +Templates/registries help; stubs and dual clients confuse. + +### Infrastructure (6/10) + +Real edge/app split; observability/CI depth limited in docs. + +### Enterprise SaaS Readiness (6/10) + +Credible mid-market start; not yet a diversified SuperApp at enterprise scale. + +--- + +# Appendix Z — Master Checklist Recap (printable) + +**Immediate** + +- [ ] Accounting prod smoke on `accounting.torbatyar.ir` +- [ ] White-label gaps closed +- [ ] Stale docs reconciled +- [ ] AI provider go/no-go recorded + +**Next** + +- [ ] File Storage Active +- [ ] Notification MVP +- [ ] Payment Active +- [ ] Bus introduced +- [ ] Domain verify +- [ ] Membership sync +- [ ] Invites/RBAC + +**Later** + +- [ ] Restaurant +- [ ] CRM/Club +- [ ] Builders +- [ ] Chat/AI/Messenger +- [ ] Automation +- [ ] Marketplace +- [ ] Wallet/Gym/scheduling + +--- + +# Appendix AA — Long-form Executive Briefing (for leadership) + +TorbatYar set out to build a multi-tenant SuperApp that can host many business systems behind one identity and one entitlement plane. The organization has substantially delivered the **platform kernel** (Core, Identity, Keycloak, Nginx TLS, tenant SSL automation, onboarding) and has gone unusually deep on **Accounting** as a first enterprise vertical—including posting integrity, treasury, AR/AP, inventory accounting, assets, payroll accounting, reporting, and governance/SoD. + +That combination is rare for early-stage platforms and should be treated as a strategic asset: the company can sell credible financial operations while the SuperApp shell continues to expand. However, leadership should not interpret Accounting depth as proof that the platform can absorb unlimited new verticals immediately. The missing middle—files, notifications, payments, and a real event bus—determines whether the next modules will compound or fragment. + +Commercially, two motions can coexist: + +1. **Finance-led motion:** land tenants needing accounting/compliance depth. +2. **Hospitality-led motion:** land cafes/restaurants on white-label digital menus once public-site polish and payments exist. + +Technically, the non-negotiables remain tenant isolation, posting-engine exclusivity for money, API-first boundaries, and documentation-before-code when architecture shifts. Scaling from dozens to thousands of tenants is primarily an infrastructure and shared-services program. Scaling from one vertical to many is primarily a sequencing discipline. + +This master report recommends Wave 0 hardening now, Wave 1 shared platforms next, then Restaurant/CRM, then builders and conversational systems, with Marketplace and Automation last among the major bets. + +--- + +# Appendix AB — Interface Contracts Overview (planning level) + +| Consumer | Provider | Contract theme | +| --- | --- | --- | +| FE | Core | `/api/v1` tenants, onboarding, me/context, entitlement check, public tenant-site | +| FE | Identity | `/api/v1/auth` OIDC/mobile | +| FE | Accounting | `/api/v1/*` foundation→compliance | +| FE | Keycloak | OIDC browser flows | +| Identity | Core | OTP client | +| Accounting | Core | entitlement / tenant authenticity assumptions | +| Celery | Core domain | outbox publish, SSL provision | +| Future Notification | many | consume domain events | +| Future modules | Accounting | posting intents / settlement events | + +Detailed endpoint tables remain in `docs/reference/services-contracts.md` and `api-reference.md` (not duplicated exhaustively here to avoid a third competing contract source). + +--- + +# Appendix AC — Data Ownership Map + +| Data class | Owner DB | +| --- | --- | +| Tenant, plan, subscription, operational membership | `core_platform_db` | +| Identity profile, SSO membership listing | `identity_access_db` | +| COA, vouchers, journals, GL, treasury, AR/AP, assets, payroll acct, reports, compliance records | `accounting_db` | +| CRM entities | `crm_db` (future) | +| Restaurant menu/orders | `restaurant_db` (future) | +| Store catalog/cart | `ecommerce_db` (future) | +| Site pages/blocks | `website_builder_db` (future) | +| Chat transcripts | `live_chat_db` (future) | +| AI KB/sessions | `ai_assistant_db` (future) | +| Notifications | `notification_db` (future) | +| Files metadata | `file_storage_db` (future) | + +--- + +# Appendix AD — Security Control Baseline + +| Control | Mechanism | +| --- | --- | +| Authn staff | Keycloak OIDC | +| Authn OTP | Payamak + Core JWT | +| Authz role | Core memberships | +| Authz feature | Entitlement service | +| Transport | TLS at edge | +| Secrets | env / vault, not git | +| Audit | Core audit_logs + accounting audit framework | +| Tenant isolation | tenant_id filters + tests | +| Service auth | hashed internal tokens + scopes | +| Money integrity | Posting Engine only | +| AI limitation | non-authority rule | + +--- + +# Appendix AE — Open Questions (explicit UNKNOWN / pending decisions) + +These items are **not answered conclusively** in the synthesized docs and should be decided deliberately: + +1. Final Iranian PSP selection for Payment Gateway +2. Final AI model vendor(s) +3. Whether Customer Club is a CRM submodule, Restaurant loyalty, or standalone service +4. Whether Appointment/Gym share a scheduling kernel early or duplicate per vertical +5. When to extract Subscription/Entitlement from Core +6. Exact production Alembic procedure standardization (upgrade vs stamp discipline) +7. CI pipeline topology (documented strategies exist; full pipeline inventory not treated as complete in older review) +8. Whether Accounting public hostname is fully live in all environments (next-steps still asks for deploy verification) + +--- + +*End of expanded appendices — Master Architecture Report v1.0* + +--- + +# Appendix AF — Accounting Phases 5.3–5.9 Detailed Deliverables + +> Source: individual phase markdown files under `docs/phases/Accounting/`. +> Note: Phase 5.6 “Related” still links 5.7 as “(planned)” while 5.7 file Status is Complete — another small doc conflict; prefer Status fields. + +## AF.1 Phase 5.3 — General Ledger & Fiscal Management + +**Delivered entities/services:** GeneralLedger, LedgerBalance, TrialBalanceSnapshot, BalanceSnapshot, OpeningEntry, ClosingEntry, AccountingCalendar; **BalanceEngine** (running balances, trial balance, recalculation); **FiscalManagementService** (lock/close/reopen periods, year closing). + +**API:** Ledger balances, trial balance, recalculate. + +**Permissions:** `accounting.ledger.*`, `accounting.fiscal.*`, `accounting.trial_balance.view` + +**Events:** `trial_balance.generated`, `ledger.updated`, `fiscal_period.locked`, etc. + +**Enterprise note:** Period lock is a control boundary; Automation/AI must never reopen periods without governance approvals (5.11 tooling). + +## AF.2 Phase 5.4 — Treasury Management + +**Delivered:** CashBox, CashTransaction, Bank, BankAccount, BankTransaction, Cheque, ChequeBook, Transfer, ReceiptVoucher, PaymentVoucher, reconciliation framework, TreasurySettings; **TreasuryService** — cash receipt/payment **via Posting Engine only**. + +**Permissions:** `treasury.*` + +**Events:** `cash.received`, `bank_deposit.created`, `cheque.issued`, etc. + +**Enterprise note:** Future Payment Gateway settlements should land as treasury/accounting postings, not orphaned PSP ledgers. + +## AF.3 Phase 5.5 — Accounts Receivable & Payable + +**Delivered:** CustomerAccount, SupplierAccount, ReceivableInvoice, PayableInvoice, Settlement, SettlementAllocation, CreditNote, DebitNote, AdvancePayment, AdvanceReceipt, CustomerStatement, SupplierStatement, AgingSnapshot; **SettlementEngine**; **AgingEngine**. + +**Permissions:** `receivable.*`, `payable.*`, `settlement.manage` + +**Events:** `customer_invoice.created`, `settlement.completed`, etc. + +**Enterprise note:** CRM should reference accounting party accounts carefully—prefer correlation IDs/events over dual-write of balances. + +## AF.4 Phase 5.6 — Sales Accounting Integration + +**Delivered:** SalesPostingProfile, AccountingProfile, PostingRule, SalesAccountingConfiguration, RevenueRecognition, RevenueSchedule, AccountingPreview, PostingSimulation; **SalesAccountingService** preview + post via Posting Engine; configurable posting profiles per document type. + +**Permissions:** `sales_accounting.*` + +**Events:** `sales_invoice.posted`, `revenue.recognized`, `posting_rule.executed`, etc. + +**Pattern to copy:** operational document → preview → post via engine. + +## AF.5 Phase 5.7 — Purchase & Inventory Accounting + +**Delivered:** PurchasePostingProfile, InventoryPostingProfile, InventoryValuationMethodConfig, InventoryValuationHistory, InventoryCostAdjustment, InventorySnapshot; **InventoryValuationEngine** (FIFO, weighted/moving average); **PurchaseInventoryAccountingService** goods receipt via Posting Engine. + +**API:** `/api/v1/purchase-inventory` + +**Permissions:** `purchase_accounting.*`, `inventory_accounting.*` + +**Events:** `goods.received`, `inventory.adjusted`, `purchase_accounting.completed` + +## AF.6 Phase 5.8 — Fixed Assets + +**Delivered:** Asset register, categories, groups, locations; AssetDepreciation, DepreciationSchedule; AssetTransfer, Disposal, Revaluation, Impairment; AssetAccountingProfile, AssetHistory; **DepreciationEngine** (straight line, declining balance, double declining); **AssetAccountingService** activate/depreciate via Posting Engine. + +**API:** `/api/v1/assets` · Permissions `assets.*` · Events `asset.depreciated`, `depreciation.calculated`, etc. + +## AF.7 Phase 5.9 — HCM & Payroll Accounting + +**Delivered:** Employee, Department, Position, EmploymentContract; PayrollPeriod, Payroll, PayrollItem, SalaryComponent; Benefit, Allowance, Deduction, EmployeeLoan, EmployeeAdvance; PayrollAccountingProfile, PayrollHistory, PayrollSettlement; **PayrollEngine**; **PayrollAccountingService** posting via Posting Engine. + +**API:** `/api/v1/payroll` · Permissions `hr.*`, `payroll.*` · Events `payroll.calculated`, `payroll.posted`, `payroll_accounting.completed` + +**Boundary caution:** This is accounting-oriented HCM, not necessarily a full HRIS product. A future standalone HR module should integrate rather than fork payroll journals. + + +--- + +# Appendix AG — Architecture Decision Records (digest) + +## AG.1 ADR-001 Database-per-Service + +**Decision:** Every service owns exactly one database; cross-service access only via REST/Webhook/Async Event/Outbox-Inbox. + +**Why it matters:** Prevents schema coupling as SuperApp grows. + +**Tradeoff:** No cross-DB joins; eventual consistency required. + +## AG.2 ADR-002 Strict FE/BE Separation + +**Decision:** Backend only under `backend/`, Frontend only under `frontend/`; communication via versioned APIs. + +**Why:** Independent deploy/scale; multiple UIs possible. + +## AG.3 ADR-003 Row-Level Multi-Tenancy + +**Decision:** Shared DB per service with mandatory `tenant_id`; resolution order headers→host→current tenant. + +**Tradeoff:** Noise-neighbor risk at extreme scale. + +## AG.4 ADR-004 Central SSO Keycloak + +**Decision:** Keycloak central login for platform/tenant staff; Identity is BFF; end-customer auth may be local to module DB. + +## AG.5 ADR-005 Mandatory Mobile + OTP + +**Decision:** Mobile required and OTP-verified (Payamak). + +## AG.6 ADR-006 Outbox/Inbox + +**Decision:** Business + outbox same transaction; inbox idempotency by event_id; envelope standardized. + +**Future:** Swap to real bus without rewriting producers. + +## AG.7 ADR-007 Dual Membership Tables + +**Decision:** Core memberships = operational SoT; Identity memberships = SSO listing. + +**Risk:** Naming collision and divergence—must stay explicit in docs/tests. + +## AG.8 ADR-008 White-Label via Config/Profile + +**Decision:** Platform defaults from env/theme config; tenant brand fields on tenants; FE CSS variables; public host theme resolution. + +## AG.9 ADR-009 Nginx + Auto Tenant SSL + +**Decision:** Nginx TLS edge; LE expand via Celery SSH `provision_ssl.py`; SSL readiness map. + +## AG.10 ADR-010 Posting Engine Ownership + +**Decision:** No service/UI creates JournalEntry directly; all financial postings through Accounting Posting Engine. + +**Consequence:** Modules wait on Accounting contracts for money write-paths. + + +--- + +# Appendix AH — Future Capability Placement Matrix (mission list) + +| Capability | Architectural placement | Status in docs | Purpose | Dependencies | Expansion | +| --- | --- | --- | --- | --- | --- | +| Restaurant Management | Business Module (`restaurant`) | Scaffolded | Digital menu, tables, orders, kitchen, loyalty | White-label host, Core entitlement, Payment (for paid orders), optional Accounting posting events | High after Wave 0–1 | +| Cafe Management | Same module family as Restaurant | Scaffolded | Cafe operations subset of restaurant | Same as Restaurant | High | +| Digital Menu | Restaurant public surface | Scaffolded | Guest menu on tenant domain; optional local guest auth | White-label public site | High | +| CRM | Business Module (`crm`) | Scaffolded | Customers, leads, opportunities, pipelines, tasks | Core entitlement; Notification; optional AI/Messenger | High | +| Customer Club | Product capability on CRM/Restaurant (not Active registry module) | Not a dedicated Active module | Loyalty segments, points, tiers — design choice open | CRM and/or Restaurant + Notification + Wallet/Payment later | Medium until ownership decided | +| SMS Platform | Shared (`sms_panel`) + Core OTP path | Panel Scaffolded; OTP Active | Campaign SMS vs auth OTP separation | Providers (Payamak+); keep OTP separate | Medium | +| Wallet | Shared platform (future) | Not Active in registry | Stored value/credits | Payment + strong ledger discipline; possibly Accounting correlation | Later (P3) | +| Payment | Shared provider + adapters | Planned provider | Checkout, refunds, webhooks for subscriptions/commerce | PSP selection; Core subscriptions; Restaurant/Ecommerce | P1 | +| Store Builder | Business (`ecommerce`) | Scaffolded | Catalog, cart, orders, shipments | File Storage, Payment, Core | After Wave 1 | +| Website Builder | Business (`website_builder`) | Scaffolded | Sites, pages, blocks, forms, menus, media | File Storage/S3 | After File Storage | +| Landing Builder | Capability of Website Builder | Scaffolded (via website_builder) | Marketing landers on tenant hosts | White-label + File Storage | With Website Builder | +| ERP | Program of modules led by Accounting | Accounting Active; broader ERP partial | Finance spine exists; supply chain/HRIS/manufacturing not claimed complete as ERP suite | Accounting + future inventory ops modules | Finance-ready; full ERP long-term | +| Gym | Future vertical (not registry Active) | UNKNOWN / Future | Memberships, classes, check-in — not specified in module registry | Prefer shared Appointment/Reservation kernel if multiple verticals | P3+ | +| AI Assistant | Shared/Business (`ai_assistant`) | Scaffolded; provider Planned | Chat, KB, handoff; never money/compliance authority | AI provider Active; entitlement | After provider | +| Automation | Shared platform | Planned | Cross-module workflow triggers/actions | Real message bus | After bus | +| Workflow | Same family as Automation | Planned | Human+system workflow orchestration | Bus + governance/approvals patterns from Accounting 5.11 | After bus | +| Chat | Business (`live_chat`) | Scaffolded | Embeddable widget, routing | Core; optional CRM/AI | Wave 4 | +| Appointment | Future capability | Not Active registry module | Booking slots | Scheduling kernel decision pending | P3 | +| Reservation | Restaurant-adjacent + generic booking | Not Active standalone | Tables/rooms/resources | Restaurant module and/or shared kernel | With Restaurant / later kernel | +| Marketplace | Business (`marketplace`) | Planned | Multi-vendor market | Ecommerce, Identity, Payment, Accounting settlement events | Wave 5 | + + +--- + +# Appendix AI — Capability Narratives (planning depth) +## AI-x — Restaurant Management + +**Placement:** Business Module (`restaurant`) +**Status:** Scaffolded +**Purpose:** Digital menu, tables, orders, kitchen, loyalty +**Dependencies:** White-label host, Core entitlement, Payment (for paid orders), optional Accounting posting events +**Expansion capability:** High after Wave 0–1 + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Cafe Management + +**Placement:** Same module family as Restaurant +**Status:** Scaffolded +**Purpose:** Cafe operations subset of restaurant +**Dependencies:** Same as Restaurant +**Expansion capability:** High + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Digital Menu + +**Placement:** Restaurant public surface +**Status:** Scaffolded +**Purpose:** Guest menu on tenant domain; optional local guest auth +**Dependencies:** White-label public site +**Expansion capability:** High + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — CRM + +**Placement:** Business Module (`crm`) +**Status:** Scaffolded +**Purpose:** Customers, leads, opportunities, pipelines, tasks +**Dependencies:** Core entitlement; Notification; optional AI/Messenger +**Expansion capability:** High + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Customer Club + +**Placement:** Product capability on CRM/Restaurant (not Active registry module) +**Status:** Not a dedicated Active module +**Purpose:** Loyalty segments, points, tiers — design choice open +**Dependencies:** CRM and/or Restaurant + Notification + Wallet/Payment later +**Expansion capability:** Medium until ownership decided + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — SMS Platform + +**Placement:** Shared (`sms_panel`) + Core OTP path +**Status:** Panel Scaffolded; OTP Active +**Purpose:** Campaign SMS vs auth OTP separation +**Dependencies:** Providers (Payamak+); keep OTP separate +**Expansion capability:** Medium + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Wallet + +**Placement:** Shared platform (future) +**Status:** Not Active in registry +**Purpose:** Stored value/credits +**Dependencies:** Payment + strong ledger discipline; possibly Accounting correlation +**Expansion capability:** Later (P3) + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Payment + +**Placement:** Shared provider + adapters +**Status:** Planned provider +**Purpose:** Checkout, refunds, webhooks for subscriptions/commerce +**Dependencies:** PSP selection; Core subscriptions; Restaurant/Ecommerce +**Expansion capability:** P1 + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Store Builder + +**Placement:** Business (`ecommerce`) +**Status:** Scaffolded +**Purpose:** Catalog, cart, orders, shipments +**Dependencies:** File Storage, Payment, Core +**Expansion capability:** After Wave 1 + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Website Builder + +**Placement:** Business (`website_builder`) +**Status:** Scaffolded +**Purpose:** Sites, pages, blocks, forms, menus, media +**Dependencies:** File Storage/S3 +**Expansion capability:** After File Storage + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Landing Builder + +**Placement:** Capability of Website Builder +**Status:** Scaffolded (via website_builder) +**Purpose:** Marketing landers on tenant hosts +**Dependencies:** White-label + File Storage +**Expansion capability:** With Website Builder + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — ERP + +**Placement:** Program of modules led by Accounting +**Status:** Accounting Active; broader ERP partial +**Purpose:** Finance spine exists; supply chain/HRIS/manufacturing not claimed complete as ERP suite +**Dependencies:** Accounting + future inventory ops modules +**Expansion capability:** Finance-ready; full ERP long-term + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Gym + +**Placement:** Future vertical (not registry Active) +**Status:** UNKNOWN / Future +**Purpose:** Memberships, classes, check-in — not specified in module registry +**Dependencies:** Prefer shared Appointment/Reservation kernel if multiple verticals +**Expansion capability:** P3+ + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — AI Assistant + +**Placement:** Shared/Business (`ai_assistant`) +**Status:** Scaffolded; provider Planned +**Purpose:** Chat, KB, handoff; never money/compliance authority +**Dependencies:** AI provider Active; entitlement +**Expansion capability:** After provider + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Automation + +**Placement:** Shared platform +**Status:** Planned +**Purpose:** Cross-module workflow triggers/actions +**Dependencies:** Real message bus +**Expansion capability:** After bus + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Workflow + +**Placement:** Same family as Automation +**Status:** Planned +**Purpose:** Human+system workflow orchestration +**Dependencies:** Bus + governance/approvals patterns from Accounting 5.11 +**Expansion capability:** After bus + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Chat + +**Placement:** Business (`live_chat`) +**Status:** Scaffolded +**Purpose:** Embeddable widget, routing +**Dependencies:** Core; optional CRM/AI +**Expansion capability:** Wave 4 + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Appointment + +**Placement:** Future capability +**Status:** Not Active registry module +**Purpose:** Booking slots +**Dependencies:** Scheduling kernel decision pending +**Expansion capability:** P3 + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Reservation + +**Placement:** Restaurant-adjacent + generic booking +**Status:** Not Active standalone +**Purpose:** Tables/rooms/resources +**Dependencies:** Restaurant module and/or shared kernel +**Expansion capability:** With Restaurant / later kernel + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + +## AI-x — Marketplace + +**Placement:** Business (`marketplace`) +**Status:** Planned +**Purpose:** Multi-vendor market +**Dependencies:** Ecommerce, Identity, Payment, Accounting settlement events +**Expansion capability:** Wave 5 + +**Architectural guidance:** Keep tenant isolation, entitlement gates, and API/event integration. If the capability touches money, route financial effects through Accounting Posting Engine contracts. If it sends messages, prefer Notification/SMS Panel rather than embedding providers in domain services. If it stores media, use File Storage. + + + +--- + +# Appendix AJ — Multi-Tenant Threat Model (documentation-derived) + +| Threat | Control in architecture | +| --- | --- | +| Cross-tenant data read | tenant_id filters; denial tests; no cross-tenant queries | +| Cross-tenant write | same + service authz | +| Host header tenant spoofing | resolution order + domain verification (custom domain verify still missing → residual risk) | +| Privilege escalation via Identity roles | Core memberships are SoT; Identity listing roles not operational | +| JWT theft | TLS; short-lived tokens (OTP expiry configured); HttpOnly cookie practices as implemented for subdomain auth (ops concern) | +| Service token leakage | hashed at rest; scoped | +| Journal tampering via UI | ADR-010; posting permissions; audit frameworks | +| AI data bleed | tenant-scoped KB only; no cross-tenant reads | +| Support break-glass | platform_admin logged | + +--- + +# Appendix AK — Non-Functional Requirements Snapshot + +| NFR | Documented expectation | Current evidence | +| --- | --- | --- | +| Multi-tenancy | Mandatory | Implemented in Core/Accounting paths | +| Auditability | Mandatory | Core audit + Accounting audit framework | +| API-first | Mandatory | Contracts + FE clients | +| Event-ready | Mandatory | Outbox pattern | +| Security secrets hygiene | Mandatory | env-based; backup notes | +| Observability | Suggested alerts | Minimal runbook | +| Performance SLOs | Not published | UNKNOWN | +| Availability SLO | Not published | UNKNOWN | +| DR | Runbooks exist | Execution cadence UNKNOWN | + +--- + +# Appendix AL — Release & Branching Alignment + +Development docs define branching and release strategies. Architecture implication: + +1. Architecture changes require ADR/docs first when boundaries move. +2. Module registry/version fields should move with releases. +3. Production deploy steps must record Alembic procedure used. +4. Frontend and Accounting can release on different cadences if contracts remain compatible—API versioning discipline required. + +--- + +# Appendix AM — White-Label Readiness Rubric + +| Criterion | Status | +| --- | --- | +| Tenant brand fields | Present (architecture/progress) | +| Theme CSS variables | Partial runtime | +| Public tenant-site API | Present | +| Subdomain routing | Present | +| Subdomain SSL auto | Present | +| Custom domain storage | Present with pending verify | +| DNS/TXT verification | Missing | +| Guest vs member experiences | Partial / productized via tenant site | +| Ready for Restaurant guest menu | Not fully — polish + pay still needed | + +--- + +# Appendix AN — Entitlement Key Taxonomy Guidance + +Format: `{service_key}.{resource}.{action}` +Example: `accounting.invoice.create` + +**Rules for new modules:** + +1. Reserve prefix in module registry before coding. +2. Seed plan features for FREE/STARTER (or successor plans) intentionally. +3. Cache invalidation on `feature_access.changed`. +4. UI must hide and API must deny (never UI-only gates). + +--- + +# Appendix AO — Posting Integration Contract (for future modules) + +When Restaurant/Ecommerce/CRM need financial effects: + +1. Define posting profile / intent DTO with Accounting. +2. Prefer preview then post. +3. Emit domain event after operational success; Accounting posts and emits `*.posted`. +4. Never create JournalEntry rows locally. +5. Include tenant_id, idempotency key, source document ids. +6. Respect fiscal period open checks (failures are expected, not bugs). + +--- + +# Appendix AP — Celery / Async Job Catalog (known) + +| Job/area | Purpose | +| --- | --- | +| `process_outbox_events` | Publish pending outbox rows | +| SSL provision task | Expand tenant certs via SSH script | +| Future notification dispatch | Should live in Notification workers | +| Future report schedules | Accounting scheduled reports (phase 5.10 entities) | + +--- + +# Appendix AQ — Frontend Module Onboarding Template + +1. Create `app/<module>/**` routes +2. Add shell + nav entries entitlement-aware +3. Add typed API client (`lib/<module>-api.ts`) +4. Use DS components only +5. Wire AuthGuard + tenant header +6. Add scoreboard doc if enterprise completeness tracking needed +7. No mocks + +--- + +# Appendix AR — Backend Service Onboarding Template + +1. `backend/services/<name>/` structure per service architecture +2. Independent DB + Alembic +3. Shared-lib JWT validation +4. Tenant middleware/deps +5. Outbox for events +6. Register in Core + module-registry +7. Permissions + tests + docs + +--- + +# Appendix AS — Documentation Drift Prevention Checklist + +- [ ] Status only in progress/next-steps/roadmap (correct file) +- [ ] Architecture docs avoid “done/not done” claims when possible +- [ ] Registry Status fields updated same PR as code +- [ ] Event catalog updated with producers +- [ ] Conflicts table reviewed quarterly +- [ ] Deprecated stubs for moved paths kept honest + +--- + +# Appendix AT — Financial Spine Diagram + +```mermaid +flowchart LR + OPS["Operational modules<br/>Restaurant · Ecommerce · CRM"] -->|posting intents / events| ACC["Accounting Service"] + ACC --> PE["Posting Engine"] + PE --> GL["General Ledger"] + PE --> AUD["Audit / Compliance"] + GL --> RPT["Reporting Engine"] + OPS -->|payments| PAY["Payment Provider"] + PAY -->|settlement events| ACC + ACC -->|cash postings| TRY["Treasury"] +``` + +--- + +# Appendix AU — Identity Spine Diagram + +```mermaid +flowchart LR + U["User"] --> FE["Frontend"] + FE --> KC["Keycloak"] + FE --> ID["Identity BFF"] + ID --> KC + ID --> COREOTP["Core OTP / Payamak"] + FE --> CORE["Core users + memberships"] + CORE --> ENT["Entitlement"] + FE --> MOD["Business module APIs"] + MOD --> ENT +``` + +--- + +# Appendix AV — Edge Spine Diagram + +```mermaid +flowchart TB + DNS["DNS *.torbatyar.ir + custom"] --> NGX["Nginx 192.168.10.156"] + NGX --> FE["Frontend :3000"] + NGX --> API["Core :8000"] + NGX --> ID["Identity :8001"] + NGX --> AUTH["Keycloak :8080"] + NGX --> ACC["Accounting :8002"] + CEL["Celery on app host"] -->|SSH provision_ssl| NGX +``` + +--- + +# Appendix AW — What “Done” Means for Platform Waves + +### Wave 0 Done + +- Accounting hostname smoke green +- White-label product-accepted +- Doc conflicts X1–X10 addressed or ticketed +- AI provider decision recorded + +### Wave 1 Done + +- File Storage uploads work for at least one consumer +- Notification delivers at least one channel for one event type +- Payment webhook → subscription or order path proven +- Bus publishes outbox events to a consumer service +- Custom domain verify path proven +- Membership sync proven with tests + +### Wave 2 Done + +- Restaurant can publish a digital menu on tenant host +- CRM can manage a lead→opportunity path +- Both gated by entitlement + +--- + +# Appendix AX — Stakeholder FAQ + +**Q: Is TorbatYar production-ready?** +A: Foundation + Accounting are production-shaped; next-steps still require deploy verification and platform polish. Not full SuperApp-ready. + +**Q: Why did Accounting come before Restaurant?** +A: Delivery history in progress shows Accounting 5.x completed; product decision preferring Restaurant remains Proposed and still valuable as next GTM vertical after white-label. + +**Q: Can we start Marketplace now?** +A: No — depends on ecommerce, payment, identity maturity, accounting settlements. + +**Q: Can AI post vouchers?** +A: Architecture forbids AI as sole authority for money; at most advisory with human/approval controls. + +**Q: Single database would be simpler—why not?** +A: ADR-001 rejected shared DB to protect long-term modularity. + +--- + +# Appendix AY — Metrics to Start Measuring (recommended; not currently published as SLOs) + +These are planning recommendations consistent with monitoring docs, not claimed existing dashboards: + +1. Outbox pending/failed age +2. OTP send success rate +3. SSL provision success/latency +4. Entitlement cache hit ratio +5. Accounting post latency +6. 5xx by service host +7. Cert days-to-expiry +8. Backup last success timestamp + +--- + +# Appendix AZ — Final Synthesis Paragraph + +The platform’s architecture is coherent enough to support a long SuperApp journey. The limiting factor is no longer the absence of principles—it is the **absence of shared horizontal services and scale machinery** relative to the breadth of ambitions listed in the mission catalog. Execute Wave 0 and Wave 1 with discipline; then open verticals in dependency order. Protect tenant isolation and posting integrity above feature velocity. Keep documentation conflict debt near zero so the next master report is a delta, not a rewrite. + +--- + +*Document complete — Master Architecture Report v1.0 (2026-07-24)* +*Path: `docs/architecture-review/MASTER_ARCHITECTURE_REPORT.md`* + +--- + +# Appendix BA — Service Contracts Digest (from reference) + +## BA.1 Fundamental rules + +1. No direct queries to another service database. +2. Database-per-service. +3. Inter-service channels only: REST, Webhook, Async Event, Outbox/Inbox. +4. Tenant-aware requests carry `tenant_id` (header `X-Tenant-ID` or token/event). + +## BA.2 Inter-service auth + +Internal Service Tokens hashed in `internal_service_tokens` with scopes. End users authenticate with Keycloak JWT (and OTP JWT where applicable). + +## BA.3 Entitlement check contract + +``` +POST /api/v1/tenants/{tenant_id}/features/check +{ "feature_key": "accounting.invoice.create" } +→ { "tenant_id", "feature_key", "has_access", "reason" } +``` + +Naming: `{service_key}.{resource}.{action}` (examples include `crm.lead.create`, `ecommerce.product.create`). + +## BA.4 Core API groups (Phase 1 surface) + +Health; Tenants (CRUD/suspend/activate); Domains (+ resolve); Plans & Features; Subscription + feature check; Service Registry. + +## BA.5 Standard responses + +Errors via `shared.responses.ErrorResponse` (`success=false`, `error.code/message/details`). Lists via paginated `Page` (`items` + `meta`). + +## BA.6 Onboarding / tenant context + +Contracts document Phase 4 onboarding APIs under `/api/v1`, requiring Bearer auth, with explicit note that external briefs call this Phase 3. Includes `GET /api/v1/me` for current user, memberships, and onboarding need. + +## BA.7 Implication for master plan + +Contracts are the integration constitution. New modules must extend contracts/catalog/registry in the same change set as code—otherwise SuperApp integration decays into tribal knowledge. + +--- + +# Appendix BB — Disaster Recovery Targets + +| Metric | Initial target | +| --- | --- | +| RPO | ≤ 24 hours (improve with more frequent backups) | +| RTO | ≤ 8 hours for full stack restore | + +| Scenario | Response summary | +| --- | --- | +| App host loss | New host, restore compose+env, restore DB dumps, repoint Nginx | +| Edge host loss | Rebuild Nginx from infra configs, restore certs/maps, re-provision | +| DB corruption | Restore last good dump; forward-fix if possible | +| Keycloak loss | Restore Keycloak DB + realm export; validate OIDC clients | + +**Scale note:** 24h RPO is acceptable for early SaaS; 1,000+ tenants with payment/marketplace will need tighter RPO and tested runbooks—treat as Wave 1/ops upgrade. + +--- + +# Appendix BC — Release Strategy Alignment + +Semantic versioning: MAJOR breaking contracts/schema; MINOR compatible features/modules; PATCH fixes/docs/hardening. + +Release notes must list features, fixes, migrations, env/config, provider changes, deprecations. + +Migration order: Backup → per-service migrations (typically Core → Identity → business) → deploy compatible services → smoke auth/onboarding → then enable flags. + +Rollback: prefer forward-fix; app rollback to previous image; DB downgrade only with tested path + restore point. + +**Hard rule:** Do not ship half-enabled financial posting paths behind weak flags. + +--- + +# Appendix BD — Glossary Conflict & Updates Needed + +`docs/glossary.md` still titles the Accounting section as "Accounting (future)" and describes Account as a planned 4-level structure even though Accounting 5.1–5.11 and FE scoreboard show Active delivery. + +**Preferred truth:** Accounting terms are current platform vocabulary; glossary heading should drop "(future)" and note Phase 5.x delivered, with remaining future items (tax connectors, AI 5.12) called out separately. + +This is documentation debt severity Medium—not a runtime defect, but it mis-trains new contributors. + +--- + +# Appendix BE — Program Roadmap Gantt (logical, not calendar dates) + +```mermaid +gantt + title TorbatYar Platform Sequencing (logical waves) + dateFormat X + axisFormat %s + section Wave0 + Accounting prod verify :a1, 0, 1 + White-label polish :a2, 0, 2 + Doc conflict cleanup :a3, 0, 1 + AI provider decision :a4, 1, 1 + section Wave1 + File Storage + S3 :b1, 2, 3 + Notification MVP :b2, 2, 3 + Payment provider :b3, 3, 3 + Message bus :b4, 4, 3 + Domain verify + RBAC invites :b5, 3, 3 + Membership sync :b6, 3, 2 + section Wave2 + Restaurant digital menu :c1, 6, 4 + CRM foundation :c2, 6, 4 + section Wave3 + Website/Landing builder :d1, 10, 3 + Ecommerce store builder :d2, 11, 4 + section Wave4 + Live Chat + AI assistant :e1, 14, 4 + Smart Messenger / SMS panel :e2, 15, 3 + section Wave5 + Automation/Workflow :f1, 18, 4 + Marketplace :f2, 19, 5 + Wallet / Gym packs :f3, 20, 4 +``` + +--- + +# Appendix BF — Decision Log Impact Matrix + +| Decision | Status | Impact if ignored | +| --- | --- | --- | +| ADR-001 DB-per-service | Accepted | Accidental monolith DB | +| ADR-002 FE/BE split | Accepted | Entangled deploys | +| ADR-003 tenant_id | Accepted | Data leaks | +| ADR-004 Keycloak SSO | Accepted | Fragmented logins | +| ADR-005 Mobile OTP | Accepted | Weak IR market fit | +| ADR-006 Outbox | Accepted | Dual-write bugs | +| ADR-007 Dual memberships | Accepted | Must educate constantly | +| ADR-008 White-label config | Accepted | Hardcoded brands | +| ADR-009 Nginx SSL auto | Accepted | Manual cert ops pain | +| ADR-010 Posting engine | Accepted | Broken ledger integrity | +| Phase numbering | Accepted | Confused roadmaps | +| Restaurant first module | Proposed | Already superseded in practice by Accounting delivery—update decision | +| Documentation-as-software | Accepted | Drift returns | + +--- + +# Appendix BG — Quality Gates Before Opening a PR for New Modules + +1. Phase/module docs exist +2. Registry row Status accurate +3. Feature keys reserved +4. Tenant denial test included +5. No cross-DB import +6. Events catalogued if emitted +7. FE uses DS + real API +8. If money involved: posting contract reviewed +9. Progress/roadmap updated appropriately +10. No secrets committed + +--- + +# Appendix BH — Ops Runbook Index + +| Runbook | Path | +| --- | --- | +| Deployment overview | `docs/deployment/deployment.md` | +| Production hosts/env | `docs/deployment/production.md` | +| SSL/TLS | `docs/deployment/ssl.md` | +| Monitoring | `docs/deployment/monitoring.md` | +| Backup | `docs/deployment/backup.md` | +| Restore | `docs/deployment/restore.md` | +| Disaster recovery | `docs/deployment/disaster-recovery.md` | + +Architecture topology companion: `docs/architecture/deployment-architecture.md`. + +--- + +# Appendix BI — Scorecard Action Mapping + +| Low/mid score area | First action | +| --- | --- | +| Scalability 5 | Message bus + DB capacity plan | +| Testing 6 | Expand tenant isolation + contract suites; publish coverage expectations per service | +| Infrastructure 6 | Implement suggested alerts; document CI | +| Enterprise SaaS 6 | Complete Wave 1 shared services | +| Frontend 7 | Finish white-label; module nav scalability | +| Security 7 | Invites/RBAC + domain verify | + +--- + +# Appendix BJ — Closing Cover Note for Reviewers + +Reviewers should read sections 1, 12–18 first, then dive into appendices for evidence. Treat conflict tables as mandatory reading before changing roadmap. When disagreeing with a score, propose an evidence-backed adjustment tied to a doc or measurable gate—not intuition alone. + +This file is the planning baseline as of **2026-07-24**. Re-issue a delta master report when Wave 0 exits or when a new Active business module ships. + +--- + +*End of Master Architecture Report v1.0 — full synthesis* diff --git a/docs/architecture/adr/ADR-011.md b/docs/architecture/adr/ADR-011.md new file mode 100644 index 0000000..b8b53d4 --- /dev/null +++ b/docs/architecture/adr/ADR-011.md @@ -0,0 +1,57 @@ +# ADR-011: Independent Enterprise Loyalty Platform Service + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-24 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Multiple business modules (Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare, Salon, Gym, and future modules) need loyalty, points, rewards, campaigns, referrals, wallets, and gift cards. Embedding loyalty inside CRM or any single business module would couple unrelated domains and prevent reuse. + +## Decision + +Loyalty is an **independent shared platform service** (`loyalty-service`) with: + +1. Dedicated database `loyalty_db` (ADR-001) +2. Permission prefix `loyalty.*` +3. Publish-only domain events `loyalty.*` +4. Row-level multi-tenancy via `tenant_id` (ADR-003) +5. Immutable point ledger as the sole source of balances (Phase 7.2+) — direct balance mutation is forbidden +6. Consumption by business modules via REST API + Events only — never via shared tables or CRM ownership + +Loyalty must **not** belong to CRM, Restaurant, or Ecommerce. + +## Consequences + +### Positive + +- Reusable loyalty across all business modules +- Clear ownership and independent schema evolution +- Enforceable ledger / audit invariants + +### Negative + +- Modules must integrate asynchronously for eventual consistency of customer 360 views +- Cross-module references use external IDs / refs only + +### Neutral + +- Phase 7.0 ships foundation aggregates; engines (membership, points, rewards, campaigns, referral, wallet, gift card, analytics, public APIs) land in Phases 7.1–7.9 + +## Alternatives Considered + +1. Loyalty subsystem inside CRM — rejected (CRM is Sales-only; Loyalty is cross-module) +2. Per-module loyalty tables — rejected (duplication, inconsistent rules) +3. Shared monolith database schema — rejected (ADR-001) + +## Related Documents + +- [Module Boundaries](../module-boundaries.md) +- [Database Architecture](../database-architecture.md) +- [ADR-001](ADR-001.md) · [ADR-003](ADR-003.md) · [ADR-006](ADR-006.md) +- [Loyalty Phase 7.0](../../loyalty-phase-7-0.md) +- [Module Registry — loyalty](../../module-registry.md#loyalty) diff --git a/docs/architecture/adr/ADR-012.md b/docs/architecture/adr/ADR-012.md new file mode 100644 index 0000000..ddc0016 --- /dev/null +++ b/docs/architecture/adr/ADR-012.md @@ -0,0 +1,53 @@ +# ADR-012: Independent Enterprise Communication Platform + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-24 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Business modules (CRM, Loyalty, Restaurant, etc.) need SMS and future channels. Putting providers inside each module would duplicate credentials, break failover consistency, and couple tenants to modules they may not subscribe to. + +Core already sends auth OTP via Payamak — that auth path stays separate for now, but platform messaging must be a shared infrastructure service. + +## Decision + +1. Introduce `communication-service` with sole ownership of `communication_db` (port 8005). +2. All outbound messaging (SMS first; email/push/social/voice later) goes through this service’s APIs and events. +3. Provider adapters live only inside Communication. +4. Dynamic contact sources resolve contacts via remote APIs — never copy business-module rows. +5. Tenants may activate Communication independently of other business modules. +6. Failures are tracked asynchronously; they must not abort calling business transactions. + +## Consequences + +### Positive + +- Single provider registry, failover, templates, OTP, audit, and monitoring +- Clear module boundary for CRM `CommunicationProvider` contract fulfillment +- Extensible channel model without rewriting consumers + +### Negative + +- Additional deployable service and database +- Eventual consistency for delivery status + +### Neutral + +- Core auth OTP may later hand off to Communication when explicitly migrated + +## Alternatives Considered + +1. Expand `sms_panel` / `notification` scaffolds separately — rejected; unified Communication owns multi-channel routing. +2. Keep providers in each business module — rejected; violates independence and DRY. + +## Related Documents + +- [communication-phase-8.md](../../communication-phase-8.md) +- [Module Registry](../../module-registry.md) +- [ADR-001](ADR-001.md) +- [Module Boundaries](../module-boundaries.md) diff --git a/docs/architecture/adr/ADR-013.md b/docs/architecture/adr/ADR-013.md new file mode 100644 index 0000000..07fc8fc --- /dev/null +++ b/docs/architecture/adr/ADR-013.md @@ -0,0 +1,55 @@ +# ADR-013: Permanent AI Development Framework + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-24 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +TorbatYar is implemented across many phases and services. AI-assisted implementation without a permanent process framework risks recreating completed work, violating module boundaries, skipping documentation gates, and drifting from ADRs. + +Product AI (`ai_assistant`) is a business capability. Implementation process for any module must be governed separately. + +## Decision + +1. Adopt a permanent **AI Development Framework** under `docs/ai-framework/`. +2. Every future implementation phase must follow the framework reading order, development loop, prompt rules, Cursor guidelines, templates, manifests, and quality gates. +3. Phase prompts describe **only** the current phase; permanent rules live in the framework. +4. No phase is complete until quality gates pass and phase-handover deliverables are filled. +5. Machine-readable registration uses `phase-manifest.yaml` and `service-manifest.yaml`. +6. This ADR does **not** implement product AI features. + +## Consequences + +### Positive + +- Consistent AI/human implementation lifecycle +- Clear separation between process framework and product AI +- Enforceable completion gates and handover continuity + +### Negative + +- Additional documentation discipline required before coding + +### Neutral + +- Existing architecture ADRs remain authoritative for technical decisions + +## Alternatives Considered + +1. Per-phase duplicated rules in every prompt — rejected (drift, inconsistency) +2. Relying only on agent memory — rejected (not auditable) +3. Treating product AI architecture as the implementation process — rejected (different concern) + +## Related Documents + +- [AI Development Framework](../../ai-framework/README.md) +- [Master Prompt](../../ai-framework/master-prompt.md) +- [Development Loop](../../ai-framework/development-loop.md) +- [Quality Gates](../../ai-framework/quality-gates.md) +- [Phase Manifest](../../ai-framework/phase-manifest.yaml) +- [Project Principles](../../development/project-principles.md) diff --git a/docs/architecture/adr/ADR-014.md b/docs/architecture/adr/ADR-014.md new file mode 100644 index 0000000..3796fd0 --- /dev/null +++ b/docs/architecture/adr/ADR-014.md @@ -0,0 +1,61 @@ +# ADR-014: Independent Sports Center Platform Service + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-24 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Gyms, clubs, and sports facilities need members, memberships, coaches, attendance/access, booking, training programs, competitions, and related operations. Embedding this inside CRM, Loyalty, Accounting, or Restaurant would couple unrelated domains and block reuse. + +TorbatYar already has independent platform services for Loyalty ([ADR-011](ADR-011.md)) and Communication ([ADR-012](ADR-012.md)). Sports operations need the same independence with a dedicated database and permission tree. + +## Decision + +1. Introduce `sports_center` as an **independent business platform service** with sole ownership of `sports_center_db` ([ADR-001](ADR-001.md)). +2. Permission prefix: `sports_center.*`. Publish-only domain events: `sports_center.*`. +3. Row-level multi-tenancy via `tenant_id` ([ADR-003](ADR-003.md)). +4. Consume Accounting, CRM, Loyalty, and Communication **only** via REST API and Events — never shared tables or foreign model imports. +5. Financial journals only through Accounting Posting Engine ([ADR-010](ADR-010.md)). +6. Messaging only through Communication ([ADR-012](ADR-012.md)). +7. Loyalty points/rewards only through Loyalty ([ADR-011](ADR-011.md)). +8. Implementation phases are registered as **Phase 9.0–9.10** in [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml); roadmap in [sports-center-roadmap.md](../../sports-center-roadmap.md). +9. Product AI and Automation remain optional and external ([ai-architecture.md](../ai-architecture.md)). + +## Consequences + +### Positive + +- Clear ownership for sports operations +- Reusable integrations with platform services +- Enforceable tenancy, audit, and boundary tests + +### Negative + +- Additional deployable service and database +- Eventual consistency for cross-service customer views + +### Neutral + +- Scaffold may exist under `backend/services/sports_center` before Phase 9.0 implementation; registration precedes business code + +## Alternatives Considered + +1. Sports subsystem inside CRM — rejected (CRM is Sales-only). +2. Sports tables inside Core — rejected (violates business-module boundaries and ADR-001). +3. Per-tenant bespoke apps without a service — rejected (not SuperApp-native). + +## Related Documents + +- [Sports Center Phase 9.0](../../sports-center-phase-9-0.md) +- [Phase Handover 9.0](../../phase-handover/phase-9-0.md) +- [Module Boundaries](../module-boundaries.md) +- [Module Registry — sports_center](../../module-registry.md#sports_center) +- [Sports Center Roadmap](../../sports-center-roadmap.md) +- [Phase Manifest](../../ai-framework/phase-manifest.yaml) +- [Service Manifest](../../ai-framework/service-manifest.yaml) +- [ADR-001](ADR-001.md) · [ADR-003](ADR-003.md) · [ADR-010](ADR-010.md) · [ADR-011](ADR-011.md) · [ADR-012](ADR-012.md) diff --git a/docs/architecture/adr/ADR-015.md b/docs/architecture/adr/ADR-015.md new file mode 100644 index 0000000..d8ff4ad --- /dev/null +++ b/docs/architecture/adr/ADR-015.md @@ -0,0 +1,66 @@ +# ADR-015: Independent Delivery & Fleet Platform Service + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-25 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Restaurant, Cafe, Marketplace, Store, Pharmacy, Clinic, Sports Center, and future verticals all need last-mile logistics: drivers, fleets, dispatch, routing, tracking, proof of delivery, and settlement. Embedding this inside any single vertical would duplicate business logic and couple unrelated domains. + +TorbatYar already isolates shared platforms as independent services (Loyalty [ADR-011](ADR-011.md), Communication [ADR-012](ADR-012.md), Sports Center [ADR-014](ADR-014.md)). Physical goods / courier logistics need the same independence. This must not be confused with Communication **message** delivery tracking. + +Commercial product name: **Torbat Driver**. + +## Decision + +1. Introduce `delivery` as an **independent Delivery & Fleet Platform service** with sole ownership of `delivery_db` ([ADR-001](ADR-001.md)). +2. Permission prefix: `delivery.*`. Publish-only domain events: `delivery.*`. +3. Row-level multi-tenancy via `tenant_id` ([ADR-003](ADR-003.md)). +4. Consume Accounting, CRM, Loyalty, Communication, and vertical services (Restaurant, Marketplace, Sports Center, Clinic, etc.) **only** via REST API and Events — never shared tables or foreign model imports. +5. Financial journals only through Accounting Posting Engine ([ADR-010](ADR-010.md)). Settlement posts via Accounting APIs/events — Delivery never creates journal entries locally. +6. Driver/customer notifications only through Communication ([ADR-012](ADR-012.md)). +7. Loyalty points/rewards only through Loyalty ([ADR-011](ADR-011.md)). +8. Routing engines and external fleet providers sit behind protocols/adapters owned by Delivery; future engines plug in without changing verticals. +9. Implementation phases are registered as **Phase 10.0–10.10** in [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml); roadmap in [delivery-roadmap.md](../../delivery-roadmap.md). +10. Product AI remains optional and external ([ai-architecture.md](../ai-architecture.md)). Core dispatch/tracking works when AI is off. +11. UI for Driver App / Dispatcher Panel lives in `frontend/` only ([ADR-002](ADR-002.md)); this service exposes APIs and events. + +## Consequences + +### Positive + +- One reusable logistics platform for all verticals +- No duplicated driver/dispatch logic across Restaurant/Marketplace/etc. +- Enforceable tenancy, audit, and boundary tests +- External routing engines and fleet providers can be swapped behind adapters + +### Negative + +- Additional deployable service and database +- Eventual consistency for cross-service order/delivery views + +### Neutral + +- Registration (this ADR + manifests) precedes business code; Phase 10.0 implements the scaffold +- Message delivery status remains owned by Communication — glossary disambiguates terms + +## Alternatives Considered + +1. Delivery subsystem inside Restaurant — rejected (blocks Marketplace/Pharmacy/Clinic reuse). +2. Delivery tables inside Core — rejected (violates business-module boundaries and ADR-001). +3. Per-vertical bespoke courier stacks — rejected (duplicates logic; contradicts SuperApp platform goals). + +## Related Documents + +- [Delivery Roadmap](../../delivery-roadmap.md) +- [Phase Area](../../phases/Delivery/README.md) +- [Module Boundaries](../module-boundaries.md) +- [Module Registry — delivery](../../module-registry.md#delivery) +- [Phase Manifest](../../ai-framework/phase-manifest.yaml) +- [Service Manifest](../../ai-framework/service-manifest.yaml) +- [ADR-001](ADR-001.md) · [ADR-002](ADR-002.md) · [ADR-003](ADR-003.md) · [ADR-010](ADR-010.md) · [ADR-011](ADR-011.md) · [ADR-012](ADR-012.md) · [ADR-014](ADR-014.md) diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index a0d3ae9..2596fad 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -1,20 +1,26 @@ -# Architecture Decision Records (ADR) - -One decision per file. Never overwrite an accepted ADR — supersede it. - -| ADR | Title | Status | -| --- | --- | --- | -| [ADR-001](ADR-001.md) | Database-per-Service | Accepted | -| [ADR-002](ADR-002.md) | Strict Frontend / Backend Separation | Accepted | -| [ADR-003](ADR-003.md) | Row-Level Multi-Tenancy with tenant_id | Accepted | -| [ADR-004](ADR-004.md) | Central SSO with Keycloak | Accepted | -| [ADR-005](ADR-005.md) | Mandatory Mobile Identity with OTP | Accepted | -| [ADR-006](ADR-006.md) | Transactional Outbox / Inbox for Events | Accepted | -| [ADR-007](ADR-007.md) | Dual Tenant Membership Tables | Accepted | -| [ADR-008](ADR-008.md) | White-Label Branding via Config and Tenant Profile | Accepted | -| [ADR-009](ADR-009.md) | Nginx Edge with Automatic Tenant SSL Expansion | Accepted | -| [ADR-010](ADR-010.md) | Posting Engine Ownership for Accounting Entries | Accepted | - -Template: [adr-template.md](../../templates/adr-template.md) - -Statuses: `Proposed` · `Accepted` · `Deprecated` · `Superseded` +# Architecture Decision Records (ADR) + +One decision per file. Never overwrite an accepted ADR — supersede it. + +| ADR | Title | Status | +| --- | --- | --- | +| [ADR-001](ADR-001.md) | Database-per-Service | Accepted | +| [ADR-002](ADR-002.md) | Strict Frontend / Backend Separation | Accepted | +| [ADR-003](ADR-003.md) | Row-Level Multi-Tenancy with tenant_id | Accepted | +| [ADR-004](ADR-004.md) | Central SSO with Keycloak | Accepted | +| [ADR-005](ADR-005.md) | Mandatory Mobile Identity with OTP | Accepted | +| [ADR-006](ADR-006.md) | Transactional Outbox / Inbox for Events | Accepted | +| [ADR-007](ADR-007.md) | Dual Tenant Membership Tables | Accepted | +| [ADR-008](ADR-008.md) | White-Label Branding via Config and Tenant Profile | Accepted | +| [ADR-009](ADR-009.md) | Nginx Edge with Automatic Tenant SSL Expansion | Accepted | +| [ADR-010](ADR-010.md) | Posting Engine Ownership for Accounting Entries | Accepted | +| [ADR-011](ADR-011.md) | Independent Enterprise Loyalty Platform Service | Accepted | +| [ADR-012](ADR-012.md) | Independent Enterprise Communication Platform | Accepted | +| [ADR-013](ADR-013.md) | Permanent AI Development Framework | Accepted | +| [ADR-014](ADR-014.md) | Independent Sports Center Platform Service | Accepted | + +Template: [adr-template.md](../../templates/adr-template.md) + +AI implementation workflow: [docs/ai-framework/](../../ai-framework/README.md) + +Statuses: `Proposed` · `Accepted` · `Deprecated` · `Superseded` diff --git a/docs/architecture/architecture.md b/docs/architecture/architecture.md index 51bdc0b..68c2f9f 100644 --- a/docs/architecture/architecture.md +++ b/docs/architecture/architecture.md @@ -65,13 +65,14 @@ Build a **multi-tenant**, **modular**, **API-first**, **microservice-ready** Sup Every implementation phase begins by reading: 1. [docs/README.md](../README.md) -2. This folder (`docs/architecture/*` and `adr/*`) -3. [project-principles.md](../development/project-principles.md) -4. [coding-standards.md](../development/coding-standards.md) -5. [testing-strategy.md](../development/testing-strategy.md) -6. [module-registry.md](../module-registry.md) -7. [provider-registry.md](../provider-registry.md) -8. [glossary.md](../glossary.md) -9. Relevant [phase docs](../phases/) +2. [AI Development Framework](../ai-framework/README.md) (`docs/ai-framework/*`) +3. This folder (`docs/architecture/*` and `adr/*`) +4. [project-principles.md](../development/project-principles.md) +5. [coding-standards.md](../development/coding-standards.md) +6. [testing-strategy.md](../development/testing-strategy.md) +7. [module-registry.md](../module-registry.md) +8. [provider-registry.md](../provider-registry.md) +9. [glossary.md](../glossary.md) +10. Relevant [phase docs](../phases/) If architecture, APIs, providers, or module boundaries change: **update documentation first**, then implement. diff --git a/docs/architecture/database-architecture.md b/docs/architecture/database-architecture.md index fe2aee7..d231866 100644 --- a/docs/architecture/database-architecture.md +++ b/docs/architecture/database-architecture.md @@ -12,6 +12,9 @@ | Identity & Access | `identity_access_db` | | Accounting (future) | `accounting_db` | | CRM | `crm_db` | +| Loyalty | `loyalty_db` | +| Communication | `communication_db` | +| Sports Center | `sports_center_db` | | Ecommerce (future) | `ecommerce_db` | | Website Builder (future) | `website_builder_db` | | Live Chat (future) | `live_chat_db` | diff --git a/docs/architecture/module-boundaries.md b/docs/architecture/module-boundaries.md index 1c55427..258efdb 100644 --- a/docs/architecture/module-boundaries.md +++ b/docs/architecture/module-boundaries.md @@ -1,51 +1,70 @@ -# Module Boundaries - -> Architecture only. Module inventory → [module-registry.md](../module-registry.md) - -## Core Platform - -**Owns:** tenants, domains, plans, features, subscriptions, entitlement checks, service/module registry, internal service tokens, outbox/inbox (core), audit log, core users, tenant memberships (operational), onboarding, public tenant-site resolution. - -**Must not own:** business journals, CRM entities, restaurant menus, file blobs, SMS campaigns. - -## Identity & Access - -**Owns:** user profiles linked to Keycloak, identity-layer memberships, OIDC BFF (config/token/me), mobile OTP handoff/session redeem, Keycloak admin sync. - -**Must not own:** workspace onboarding lifecycle, plan/subscription, operational tenant roles source of truth (Core). - -## Frontend - -**Owns:** UI, theme application, client-side auth redirects, dashboards, onboarding wizard UX. - -**Must not own:** business rules, direct DB access, SQLAlchemy/Alembic, entitlement computation. - -## Future Business Modules - -Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and domain APIs. Cross-module coupling is API/event only. Financial postings go only through Accounting Posting Engine ([ADR-010](adr/ADR-010.md)). - -### CRM (Sales CRM) - -**Owns:** Lead, Contact, Organization (business account), Opportunity, Pipeline, PipelineStage, SalesActivity, Task, Meeting, CallLog, Sales Timeline, Comment/Mention, Bookmark, Sales Team, Playbook, Forecast, Goal, Target, Win/Loss, Quote (sales), CRM publish events. - -**Must not own:** Automation/workflows, Customer360, Marketing, Notification delivery, Messaging, Communication, Helpdesk, Analytics, AI, Identity, Accounting, Inventory, Restaurant, Marketplace, Files, Search. - -## Shared Library (`backend/shared-lib`) - -**Owns:** JWT validation helpers, phone normalization, event envelope types, shared exceptions/responses. - -**Must not own:** tenant business workflows or service-specific repositories. - -## Boundary Rules - -1. No cross-database foreign keys. -2. No importing another service's models. -3. Feature gates use Core entitlement API. -4. Frontend talks to public/versioned APIs only. -5. Providers are integrated behind module/provider adapters — see [integration-architecture.md](integration-architecture.md). - -## Related Documents - -- [Service Architecture](service-architecture.md) -- [ADR-001](adr/ADR-001.md) · [ADR-002](adr/ADR-002.md) · [ADR-007](adr/ADR-007.md) -- [Module Registry](../module-registry.md) +# Module Boundaries + +> Architecture only. Module inventory → [module-registry.md](../module-registry.md) + +## Core Platform + +**Owns:** tenants, domains, plans, features, subscriptions, entitlement checks, service/module registry, internal service tokens, outbox/inbox (core), audit log, core users, tenant memberships (operational), onboarding, public tenant-site resolution. + +**Must not own:** business journals, CRM entities, restaurant menus, file blobs, SMS campaigns. + +## Identity & Access + +**Owns:** user profiles linked to Keycloak, identity-layer memberships, OIDC BFF (config/token/me), mobile OTP handoff/session redeem, Keycloak admin sync. + +**Must not own:** workspace onboarding lifecycle, plan/subscription, operational tenant roles source of truth (Core). + +## Frontend + +**Owns:** UI, theme application, client-side auth redirects, dashboards, onboarding wizard UX. + +**Must not own:** business rules, direct DB access, SQLAlchemy/Alembic, entitlement computation. + +## Future Business Modules + +Each module (Accounting, CRM, Restaurant, Ecommerce, …) owns its database and domain APIs. Cross-module coupling is API/event only. Financial postings go only through Accounting Posting Engine ([ADR-010](adr/ADR-010.md)). + +### CRM (Sales CRM) + +**Owns:** Lead, Contact, Organization (business account), Opportunity, Pipeline, PipelineStage, SalesActivity, Task, Meeting, CallLog, Sales Timeline, Comment/Mention, Bookmark, Sales Team, Playbook, Forecast, Goal, Target, Win/Loss, Quote (sales), CRM publish events. + +**Must not own:** Automation/workflows, Customer360, Marketing, Notification delivery, Messaging, Communication, Helpdesk, Analytics, AI, Identity, Accounting, Inventory, Restaurant, Marketplace, Files, Search, Loyalty, Sports Center. + +### Loyalty (Enterprise Loyalty Platform) + +**Owns:** LoyaltyProgram, MembershipTier, Member, PointAccount (shell; ledger in later phases), Reward catalog shell, Campaign shell, Loyalty audit, Loyalty publish events. + +**Must not own:** CRM sales entities, Accounting postings, Notification delivery, Identity, Wallet UI (frontend), Restaurant/Marketplace/Ecommerce/Sports Center domain data — those modules consume Loyalty via API/Events only. + +### Communication (Enterprise Communication Platform) + +**Owns:** Provider configs, sender numbers, message templates, manual contacts, dynamic contact-source configs, messages, queue/DLQ, delivery timeline, provider logs, OTP challenges, webhook receipts, communication audit; outbound provider adapters. + +**Must not own:** CRM/Loyalty/Restaurant/Marketplace/Sports Center business entities; must not be embedded inside those modules. Auth OTP path in Core remains separate until explicitly migrated. + +### Sports Center Platform + +**Owns:** Sports members, memberships/membership types, coaches, attendance/access devices, bookings, facilities/courts/equipment, programs/workouts, competitions/sports events, locker/medical/nutrition shells as scoped, sports reports/analytics shells, sports_center publish events, integrations adapters (client-side only). + +**Must not own:** Accounting journals/Posting Engine, Sales CRM aggregates, Loyalty ledger/campaigns, Communication providers, Core tenant membership, Restaurant/Ecommerce domains, product AI platform, Automation engine. + +## Shared Library (`backend/shared-lib`) + +**Owns:** JWT validation helpers, phone normalization, event envelope types, shared exceptions/responses. + +**Must not own:** tenant business workflows or service-specific repositories. + +## Boundary Rules + +1. No cross-database foreign keys. +2. No importing another service's models. +3. Feature gates use Core entitlement API. +4. Frontend talks to public/versioned APIs only. +5. Providers are integrated behind module/provider adapters — see [integration-architecture.md](integration-architecture.md). + +## Related Documents + +- [Service Architecture](service-architecture.md) +- [ADR-001](adr/ADR-001.md) · [ADR-002](adr/ADR-002.md) · [ADR-007](adr/ADR-007.md) · [ADR-014](adr/ADR-014.md) +- [Module Registry](../module-registry.md) +- [Sports Center Roadmap](../sports-center-roadmap.md) diff --git a/docs/communication-phase-8-audit.md b/docs/communication-phase-8-audit.md new file mode 100644 index 0000000..92a0b3b --- /dev/null +++ b/docs/communication-phase-8-audit.md @@ -0,0 +1,95 @@ +# Phase 8 — Enterprise Validation Audit Report + +| Field | Value | +| --- | --- | +| Service | communication-service | +| Version audited | 0.8.10.1 | +| Date | 2026-07-24 | +| Scope | Validation & self-heal only (no new business phase) | + +## Scores (0–100) + +| Area | Score | Notes | +| --- | --- | --- | +| Architecture | 94 | Independent DB/service; API/Event only; ADR-012 aligned | +| Reliability | 90 | Failover, DLQ, retry, idempotency, circuit breaker | +| Provider Framework | 92 | Protocol + registry; SMS active; future channels stubbed | +| Security | 88 | Tenant isolation, permission deps, OTP isolation, webhook HMAC | +| Performance | 84 | Indexes, rate limits, SKIP LOCKED (PG); in-process queue OK for now | +| Scalability | 82 | Multi-worker claim ready on Postgres; Celery drain optional | +| Documentation | 93 | Phase doc, ADR, registry, audit, event catalog | + +## Test Coverage Summary + +| Suite | Result | +| --- | --- | +| Architecture / boundaries / docs | Pass | +| Health / ready / capabilities / metrics | Pass | +| Tenant isolation | Pass | +| Providers / SMS / multi-provider failover | Pass | +| Queue / DLQ / concurrency | Pass | +| Rate limiting | Pass | +| Idempotency (`correlation_id`) | Pass | +| Templates / contacts / sports source | Pass | +| OTP + Core purpose rejection | Pass | +| Webhooks / monitoring | Pass | +| Validators / migration presence | Pass | +| **Total** | **42 passed** | + +## Repairs Applied in This Cycle + +1. Applied provider `rate_limit_per_minute` in queue processing; defer without burning attempts +2. Idempotent send on `(tenant_id, correlation_id, to_address)` +3. `/health/ready`, `/metrics`, `/api/v1/monitoring/metrics` with latency/failure/rate-limit stats +4. Permission enforcement (`require_permissions`) on sensitive routes +5. `sports` contact source type; GUID FK typing as UUID +6. Stronger event payloads; webhook HMAC over canonical JSON + external_id dedupe +7. OTP isolation (docs + reserved Core purpose rejection) +8. Indexes for correlation / provider_message_id / webhook external id +9. Migration `0002_validation_hardening`; version bump `0.8.10.1` +10. Enterprise validation test suite + +## Remaining Risks + +| Risk | Severity | Mitigation | +| --- | --- | --- | +| No dedicated Celery worker for continuous drain | Medium | Documented; API process path covers correctness | +| Future channel adapters still stubs | Medium | Framework ready; implement per channel when needed | +| Sender unique ignores soft-delete | Low | Recreate with different value or future partial unique | +| In-process rate limiter is per-instance | Medium | Acceptable until Redis-backed limiter | + +## Technical Debt + +- Templates/contacts API routes still use broader auth than fine-grained permission constants (admin bypass covers tests) +- Observability is JSON metrics, not Prometheus exposition format +- Outbox table not yet dual-written (publish-only envelopes, same as CRM/Loyalty) + +## Recommendations Before Next Service Implementation + +1. Prefer Communication APIs/events for any new module messaging needs — never embed providers +2. When adding Celery, reuse `QueueEngine.process_due` as the worker entrypoint +3. Add Prometheus `/metrics` exposition only if platform ops standard requires it +4. Keep Core auth OTP on Core until an explicit migration ADR +5. Follow [AI Development Framework](ai-framework/README.md) quality gates for Loyalty 7.1+ + +## Sign-Off + +| Gate | Status | +| --- | --- | +| Architecture | Pass | +| Security | Pass | +| Performance | Pass | +| Testing | Pass | +| Documentation | Pass | +| Migration | Pass | +| Backward Compatibility | Pass (additive) | +| Tenant Isolation | Pass | + +**Verdict:** Enterprise Communication Platform meets production-ready quality for Phase 8 according to project standards. + +## Related Documents + +- [communication-phase-8.md](communication-phase-8.md) +- [ADR-012](architecture/adr/ADR-012.md) +- [progress.md](progress.md) +- [quality-gates.md](ai-framework/quality-gates.md) diff --git a/docs/communication-phase-8.md b/docs/communication-phase-8.md new file mode 100644 index 0000000..869d901 --- /dev/null +++ b/docs/communication-phase-8.md @@ -0,0 +1,170 @@ +# Phase 8 — Enterprise Communication Platform + +| Field | Value | +| --- | --- | +| Status | Complete (validated) | +| Module | communication | +| Version | 0.8.10.1 | +| Database | `communication_db` | +| API Port | 8005 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-012 | + +## Goal + +Build an **independent Enterprise Communication Platform** as shared infrastructure for the SuperApp. + +Any tenant may activate **only** this service without subscribing to CRM, Loyalty, Restaurant, Sports, or other business modules. + +All communication consumers interact exclusively through this service’s APIs and events. No module may talk to external providers directly. + +## Scope Completed (8.0–8.10) + Validation Hardening + +| Sub-phase | Deliverable | +| --- | --- | +| 8.0 Foundation | Service scaffold, health/capability APIs, permissions, events, repositories, DTOs, validators | +| 8.1 SMS Providers | Registry, priority, failover, retry, balance, sender numbers, **enforced** rate limits, circuit breaker | +| 8.2 Template Engine | Templates, variables, localization, approval, versioning, preview, rendering | +| 8.3 Dynamic Contact Sources | Manual, CSV, CRM/Loyalty/**Sports**/Restaurant/Marketplace/Accounting/Website/External REST — resolve only | +| 8.4 Queue Engine | Async queue, scheduling, priority, backoff retry, DLQ, batch, claim without burning attempts on rate-limit deferral | +| 8.5 Delivery Tracking | queued → sent → delivered / failed / expired / cancelled + timeline + provider logs | +| 8.6 OTP Platform | Business OTP only; Core auth OTP isolated; reserved purpose rejection | +| 8.7 Webhooks | Signature over canonical JSON body; external_id dedupe; delivery updates | +| 8.8 Multi Channel | Common API + unified router; SMS active; stubs for email/push/whatsapp/telegram/rubika/voice | +| 8.9 Monitoring | `/health`, `/health/ready`, `/capabilities`, `/metrics`, `/api/v1/monitoring/stats|metrics` | +| 8.10 + Validation | Idempotency (`correlation_id`+`to_address`), permission enforcement, indexes, GUID typing, enterprise test suite | + +## Architecture + +``` +Business Modules ──API/Events──▶ Communication Service ──Provider Adapters──▶ External Gateways + │ + ├── Provider Framework + ├── Communication Router (failover) + ├── Template Engine + ├── Contact Resolver (dynamic) + ├── Queue Engine + DLQ + Rate Limit + ├── Delivery Timeline + Metrics + └── OTP Platform (non-auth) +``` + +Layering: `API → Services → Repositories → Models`. + +Database-per-service: `communication_db` only (ADR-001). Row-level `tenant_id` (ADR-003). + +## Provider Framework + +- Protocol: `ChannelProvider` (`send`, `get_balance`, `health_check`) +- Registry maps `provider_kind` → adapter +- Active SMS adapters: `mock`, `payamak` +- Stub adapters for future channels (no provider-specific code outside this service) +- Disable/fail one provider → router selects next by priority; queue continues; no duplicate on `correlation_id` + +## Router Flow + +1. Resolve body (raw or approved template) +2. Resolve destinations (manual and/or dynamic contact source) +3. Idempotency check on `(tenant_id, correlation_id, to_address)` +4. Enqueue message(s) +5. Claim by priority / `available_at` (Postgres `SKIP LOCKED`) +6. Apply channel rate limit (strictest provider limit); defer without burning attempts +7. Try providers ordered by `priority` (circuit-open skipped) +8. On failure → next provider (failover event) +9. Exhausted → failed; retries → DLQ + +## Health, Capability & Metrics + +| Method | Path | Notes | +| --- | --- | --- | +| GET | `/health` | Liveness | +| GET | `/health/ready` | DB readiness | +| GET | `/capabilities` | Channels, features, contact source types, independence | +| GET | `/metrics` | Service feature summary | +| GET | `/api/v1/providers/status` | Provider health/circuit | +| GET | `/api/v1/monitoring/stats` | Message/queue/provider snapshot | +| GET | `/api/v1/monitoring/metrics` | Latency, failure rate, rate-limit deferrals | + +## Business Rules + +1. Communication is completely independent. +2. Business modules communicate only through APIs and events. +3. No provider-specific code outside this service. +4. Dynamic contact sources never duplicate business data. +5. Provider failover is automatic. +6. Communication failures must never stop business transactions. +7. Health, Capability, and Metrics APIs are mandatory. +8. Tenant-specific providers, credentials, templates, and sender numbers. +9. Audit log for every message send. +10. Auth OTP stays on Core; Communication OTP rejects reserved Core purposes. +11. Reused `correlation_id` + destination is idempotent (no duplicate send). + +## Events + +| Event | Meaning | +| --- | --- | +| `communication.message.queued` | Enqueued (+ channel/to/correlation) | +| `communication.message.sent` | Accepted by provider | +| `communication.message.delivered` | Delivery confirmed | +| `communication.message.failed` | Terminal send failure | +| `communication.provider.failover` | Switched provider | +| `communication.otp.*` | OTP lifecycle | +| `communication.queue.dead_letter` | DLQ | +| `communication.webhook.received` | Inbound webhook | + +## Permissions + +Enforced via `require_permissions` (admin roles bypass). Trees under `communication.*`. + +## APIs (selected) + +| Area | Prefix | +| --- | --- | +| Providers / senders | `/api/v1/providers` | +| Templates | `/api/v1/templates` | +| Contacts / sources | `/api/v1/contacts` | +| Messages / queue | `/api/v1/messages` | +| OTP | `/api/v1/otp` | +| Webhooks | `/api/v1/webhooks` | +| Monitoring | `/api/v1/monitoring` | + +## Tests + +Architecture, health/ready/security, providers/SMS/failover cascade, queue/DLQ, rate limit, idempotency, templates, dynamic contacts (incl. sports), OTP isolation, webhooks, metrics, concurrency, validators, migration, permissions, documentation — **42 passing**. + +## Integration Boundaries + +| Consumer | Integration | +| --- | --- | +| Core / Identity | Auth OTP remains on Core; no shared OTP tables | +| CRM / Loyalty / Sports / Restaurant / Marketplace / Accounting | Contact resolve via HTTP APIs only; messaging via Communication APIs/events | +| AI / Storage / Notification | No ownership; Notification may later consume Communication events | +| Future modules | Pluggable via public APIs + events; no internal code changes required | + +## Known Limitations + +- Email / Push / WhatsApp / Telegram / Rubika / Voice remain architecture-ready stubs. +- Continuous Celery worker drain is optional ops work (API/`process_immediately` covers correctness). +- Real message bus consumers not wired (publish-only envelopes). +- Sender unique constraint is not soft-delete-aware (recreate after soft-delete may conflict). + +## Audit Report + +See [communication-phase-8-audit.md](communication-phase-8-audit.md). + +## Completion Checklist + +- [x] Code completed (8.0–8.10) +- [x] Enterprise validation + self-heal cycle +- [x] Tests passed (42) +- [x] Documentation updated +- [x] ADR-012 referenced +- [x] Module Registry updated +- [x] Progress updated +- [x] No TODO remains + +## Related Documents + +- [Progress](progress.md) +- [Module Registry](module-registry.md) +- [Audit Report](communication-phase-8-audit.md) +- [ADR-012](architecture/adr/ADR-012.md) +- [Service README](../backend/services/communication/README.md) diff --git a/docs/delivery-roadmap.md b/docs/delivery-roadmap.md new file mode 100644 index 0000000..4a0d672 --- /dev/null +++ b/docs/delivery-roadmap.md @@ -0,0 +1,160 @@ +# Delivery & Fleet Platform — Roadmap + +> Registration document. **No implementation in this phase.** +> Framework: [ai-framework/](ai-framework/README.md) · ADR: [ADR-015](architecture/adr/ADR-015.md) · Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) + +| Field | Value | +| --- | --- | +| Service | `delivery` | +| Commercial Product | Torbat Driver | +| Database | `delivery_db` | +| Permission Prefix | `delivery.*` | +| API Port (planned) | 8007 | +| Phases | 10.0 – 10.10 | +| Status | Registration complete; next 10.0 Foundation | + +## Vision + +Deliver an independent, multi-tenant **Delivery & Fleet Platform** inside TorbatYar so Restaurant, Cafe, Marketplace, Store, Pharmacy, Clinic, Sports Center, and future services can request logistics without owning drivers, fleets, dispatch, or routing — and without duplicating business logic or sharing databases. + +## Business Scope + +In scope (over Phases 10.0–10.10): + +- Driver management and operational readiness +- Fleet management and vehicle types +- Dispatch engine and assignment +- Routing, multi pickup / multi drop, optimization +- Live tracking and proof of delivery +- Availability, shift management, working zones +- Pricing and capability bundles +- Settlement (via Accounting APIs/events only) +- Merchant connector contracts for vertical services +- Customer tracking APIs +- Notifications via Communication only +- Fleet analytics shells; AI-ready hooks (optional) +- External providers and future routing engines behind adapters +- Driver App / Dispatcher Panel **API contracts** (UI in frontend) + +## Architecture Scope + +| Rule | Reference | +| --- | --- | +| Independent service + `delivery_db` | [ADR-015](architecture/adr/ADR-015.md), [ADR-001](architecture/adr/ADR-001.md) | +| Layering API → Service → Repository → Model | [service-architecture.md](architecture/service-architecture.md) | +| Tenant-aware business tables | [ADR-003](architecture/adr/ADR-003.md) | +| Outbox-ready events `delivery.*` | [ADR-006](architecture/adr/ADR-006.md) | +| No journal ownership | [ADR-010](architecture/adr/ADR-010.md) | +| Messaging via Communication only | [ADR-012](architecture/adr/ADR-012.md) | +| Loyalty via Loyalty service only | [ADR-011](architecture/adr/ADR-011.md) | +| AI optional / independent | [ai-architecture.md](architecture/ai-architecture.md) | +| FE/BE separation for Driver App / Dispatcher Panel | [ADR-002](architecture/adr/ADR-002.md) | +| Implementation via AI Framework | [ai-framework/](ai-framework/README.md) | + +## Module Map + +| Module | Responsibility | Introduced | +| --- | --- | --- | +| Drivers | Driver profiles, credentials refs, status | 10.1 | +| Fleet | Fleet units and ownership shells | 10.2 | +| Vehicle Types | Catalog of vehicle classes/capabilities | 10.2 | +| Vehicles | Concrete vehicles assigned to fleets/drivers | 10.2 | +| Availability | Online/offline and capacity readiness | 10.3 | +| Shifts | Shift definitions and assignments | 10.3 | +| Working Zones | Geo / operational zones | 10.3 | +| Pricing | Delivery pricing rules shells | 10.4 | +| Capabilities | Capability flags for jobs/vehicles/drivers | 10.4 | +| Bundles | Packaged capability/pricing offerings | 10.4 | +| Dispatch | Dispatch engine and assignment | 10.5 | +| Routing | Route plans; multi pickup / multi drop | 10.6 | +| Optimization | Route/job optimization strategies | 10.6 | +| Tracking | Live location and journey timeline | 10.7 | +| Proof of Delivery | POD capture (signature/photo/code refs) | 10.7 | +| Settlement | Settlement intents; Accounting posts externally | 10.8 | +| Merchant Connector | Vertical order/job intake contracts | 10.9 | +| Notifications | Outbound notify via Communication client | 10.9 | +| Driver App APIs | Mobile driver contract surfaces | 10.9 | +| Dispatcher Panel APIs | Ops panel contract surfaces | 10.9 | +| Customer Tracking | Public/customer tracking token APIs | 10.7 / 10.9 | +| Fleet Analytics | Metrics/export shells | 10.10 | +| AI Hooks | Optional AI contracts (dispatch assist, ETA) | 10.10 | +| External Providers | Fleet/routing provider adapters | 10.0+ | +| Audit / Config / Health | Foundation shells | 10.0 | + +Canonical inventory: [module-registry.md](module-registry.md#delivery). + +## Integration Map + +``` +Verticals (Restaurant / Marketplace / Store / Pharmacy / Clinic / Sports Center / …) + │ + │ API / Events (job refs only — no shared DB) + ▼ + Delivery Platform ──API/Events──▶ Accounting (settlement → Posting Engine) + Delivery Platform ──API/Events──▶ Communication (SMS/push/OTP notifications) + Delivery Platform ──API/Events──▶ Loyalty (optional earn refs) + Delivery Platform ──API/Events──▶ CRM (contact refs only) + Delivery Platform ──API─────────▶ Core (entitlement, tenant) + Delivery Platform ──Adapters───▶ External routing / fleet providers +``` + +Forbidden: cross-DB queries, embedding SMS providers inside Delivery, creating JournalEntry locally, owning Restaurant/Marketplace order aggregates. + +## Disambiguation + +| Term | Owner | +| --- | --- | +| Message delivery (queued/sent/delivered) | Communication | +| Physical / courier delivery jobs | Delivery Platform (`delivery`) | + +## Phase Map + +| Phase | ID | Name | Status | +| --- | --- | --- | --- | +| Reg | `delivery-reg` | Platform Registration | Complete | +| 10.0 | `delivery-10.0` | Foundation | Planned | +| 10.1 | `delivery-10.1` | Driver Management | Planned | +| 10.2 | `delivery-10.2` | Fleet & Vehicle Types | Planned | +| 10.3 | `delivery-10.3` | Availability, Shifts & Working Zones | Planned | +| 10.4 | `delivery-10.4` | Pricing, Capabilities & Bundles | Planned | +| 10.5 | `delivery-10.5` | Dispatch Engine | Planned | +| 10.6 | `delivery-10.6` | Routing & Optimization | Planned | +| 10.7 | `delivery-10.7` | Tracking & Proof of Delivery | Planned | +| 10.8 | `delivery-10.8` | Settlement | Planned | +| 10.9 | `delivery-10.9` | Merchant Connector & App Surfaces | Planned | +| 10.10 | `delivery-10.10` | Analytics, AI Ready & Enterprise Validation | Planned | + +Details (dependencies, required docs/services, completion criteria): [phase-manifest.yaml](ai-framework/phase-manifest.yaml). + +## Platform Expose (every implementation phase) + +Health API · Capability API · Metrics · Events · REST APIs · Permission APIs + +## Future Expansion + +- Additional routing engine adapters without vertical changes +- Third-party fleet marketplaces behind provider protocols +- Deeper ETA / surge pricing engines when scoped +- Cross-tenant platform-admin fleet analytics (explicit + audited) + +## Out of Scope + +- Owning Accounting ledgers or Posting Engine +- Owning Sales CRM aggregates +- Owning Loyalty ledger / campaigns +- Owning SMS/email/push providers (Communication owns them) +- Owning Restaurant/Marketplace/Clinic/Sports Center order domains +- Implementing Phase 10.x business code in the registration phase +- Driver App / Dispatcher Panel UI (frontend phases) + +## Related Documents + +- [Phase Area README](phases/Delivery/README.md) +- [ADR-015](architecture/adr/ADR-015.md) +- [Module Registry](module-registry.md#delivery) +- [Glossary — Delivery](glossary.md#delivery--fleet-platform) +- [Service Manifest](ai-framework/service-manifest.yaml) +- [Progress](progress.md) +- [Roadmap](roadmap.md) +- [Next Steps](next-steps.md) +- [Phase Handover DP-Reg](phase-handover/phase-dp-reg.md) diff --git a/docs/development/developer-guide.md b/docs/development/developer-guide.md index 211ab1e..e9120e0 100644 --- a/docs/development/developer-guide.md +++ b/docs/development/developer-guide.md @@ -17,8 +17,10 @@ cp .env.example .env docker compose up -d --build ``` -سرویس‌ها: Core API روی `:8000`، Identity روی `:8001`، Frontend روی `:3000`، -Keycloak روی `:8080`. همه با `docker compose up -d --build` بالا می‌آیند. +سرویس‌ها: Core API روی `:8000`، Identity روی `:8001`، Accounting روی `:8002`، +CRM روی `:8003`، Loyalty روی `:8004`، Communication روی `:8005`، +Sports Center روی `:8006`، Frontend روی `:3000`، Keycloak روی `:8080`. +همه با `docker compose up -d --build` بالا می‌آیند. ## ۴. راه‌اندازی محلی — Backend ```bash diff --git a/docs/development/project-principles.md b/docs/development/project-principles.md index 8272fb6..5ab44e7 100644 --- a/docs/development/project-principles.md +++ b/docs/development/project-principles.md @@ -1,31 +1,33 @@ -# Project Principles - -These principles are mandatory for every implementation phase. - -1. **Every feature must be tenant-aware.** Business data always carries and filters by `tenant_id`. -2. **Every business action must be auditable.** Persist audit metadata or equivalent domain events. -3. **Business logic only inside Services.** Controllers/routers stay thin. -4. **Repositories contain no business logic.** Persistence and queries only. -5. **Views / API handlers remain thin.** Validate, authorize, delegate, map responses. -6. **Every phase must include tests.** No phase complete without automated coverage for new behavior. -7. **Every phase must update documentation.** Code and docs ship together. -8. **No TODO in completed phases.** TODOs mean the phase is not done. -9. **No cross-tenant query.** Platform-admin exceptions must be explicit and logged. -10. **No direct JournalEntry creation.** Posting Engine only ([ADR-010](../architecture/adr/ADR-010.md)). -11. **Compliance is independent.** Compliance controls are not optional UI features. -12. **AI is independent.** Core business flows work when AI is off ([ai-architecture.md](../architecture/ai-architecture.md)). -13. **Everything event-ready.** Prefer outbox-backed domain events for cross-service effects. -14. **Everything API-first.** UI is a client, not the source of truth. -15. **Everything documented.** Registries, ADRs, contracts, and progress stay current. -16. **Database-per-service.** No cross-DB access ([ADR-001](../architecture/adr/ADR-001.md)). -17. **Frontend/Backend separation is absolute.** ([ADR-002](../architecture/adr/ADR-002.md)). -18. **No hardcoded brand or secrets.** Config/env/database only. -19. **Documentation before conflicting code.** If implementation conflicts with architecture, stop and fix docs/ADR first. -20. **Phase completion gate.** Follow the checklist in [docs/README.md](../README.md). - -## Related Documents - -- [Coding Standards](coding-standards.md) -- [Testing Strategy](testing-strategy.md) -- [Architecture Overview](../architecture/architecture.md) -- [Module Registry](../module-registry.md) +# Project Principles + +These principles are mandatory for every implementation phase. + +1. **Every feature must be tenant-aware.** Business data always carries and filters by `tenant_id`. +2. **Every business action must be auditable.** Persist audit metadata or equivalent domain events. +3. **Business logic only inside Services.** Controllers/routers stay thin. +4. **Repositories contain no business logic.** Persistence and queries only. +5. **Views / API handlers remain thin.** Validate, authorize, delegate, map responses. +6. **Every phase must include tests.** No phase complete without automated coverage for new behavior. +7. **Every phase must update documentation.** Code and docs ship together. +8. **No TODO in completed phases.** TODOs mean the phase is not done. +9. **No cross-tenant query.** Platform-admin exceptions must be explicit and logged. +10. **No direct JournalEntry creation.** Posting Engine only ([ADR-010](../architecture/adr/ADR-010.md)). +11. **Compliance is independent.** Compliance controls are not optional UI features. +12. **AI is independent.** Core business flows work when AI is off ([ai-architecture.md](../architecture/ai-architecture.md)). +13. **Everything event-ready.** Prefer outbox-backed domain events for cross-service effects. +14. **Everything API-first.** UI is a client, not the source of truth. +15. **Everything documented.** Registries, ADRs, contracts, and progress stay current. +16. **Database-per-service.** No cross-DB access ([ADR-001](../architecture/adr/ADR-001.md)). +17. **Frontend/Backend separation is absolute.** ([ADR-002](../architecture/adr/ADR-002.md)). +18. **No hardcoded brand or secrets.** Config/env/database only. +19. **Documentation before conflicting code.** If implementation conflicts with architecture, stop and fix docs/ADR first. +20. **Phase completion gate.** Follow the checklist in [docs/README.md](../README.md) and [ai-framework/quality-gates.md](../ai-framework/quality-gates.md). + +## Related Documents + +- [Coding Standards](coding-standards.md) +- [Testing Strategy](testing-strategy.md) +- [Architecture Overview](../architecture/architecture.md) +- [Module Registry](../module-registry.md) +- [AI Development Framework](../ai-framework/README.md) +- [ADR-013](../architecture/adr/ADR-013.md) diff --git a/docs/development/testing-strategy.md b/docs/development/testing-strategy.md index d665eb6..7fee11b 100644 --- a/docs/development/testing-strategy.md +++ b/docs/development/testing-strategy.md @@ -1,41 +1,43 @@ -# Testing Strategy - -## Required Layers - -| Layer | Purpose | -| --- | --- | -| Unit | Pure domain logic, validators, mappers | -| Service | Business rules with DB/fakes | -| Repository | Query correctness, tenant filters | -| API | HTTP contracts, authz, status codes | -| Integration | Multi-component flows (onboarding, OTP, SSO handoff) | -| Migration | Upgrade/downgrade smoke on clean DB | -| Tenant | Cross-tenant isolation negatives | -| Performance | Hot entitlement/resolve paths as needed | -| Security | Auth bypass attempts, token scope checks | -| Architecture | Boundary rules (optional lint/CI later) | -| Documentation validation | Links, registry completeness for the phase | - -## Rules - -1. New behavior ships with tests in the same phase. -2. Tenant tests must include at least one cross-tenant denial case for new tenant-scoped APIs. -3. Do not rely on production data for assertions. -4. Prefer deterministic time/OTP fakes. -5. Phase is incomplete if tests fail or are absent for claimed deliverables. - -## Commands (current) - -```bash -cd backend/core-service -pytest -q - -cd backend/services/identity-access -pytest -q -``` - -## Related Documents - -- [Project Principles](project-principles.md) -- [Developer Guide](developer-guide.md) -- [Branching Strategy](branching-strategy.md) +# Testing Strategy + +## Required Layers + +| Layer | Purpose | +| --- | --- | +| Unit | Pure domain logic, validators, mappers | +| Service | Business rules with DB/fakes | +| Repository | Query correctness, tenant filters | +| API | HTTP contracts, authz, status codes | +| Integration | Multi-component flows (onboarding, OTP, SSO handoff) | +| Migration | Upgrade/downgrade smoke on clean DB | +| Tenant | Cross-tenant isolation negatives | +| Performance | Hot entitlement/resolve paths as needed | +| Security | Auth bypass attempts, token scope checks | +| Architecture | Boundary rules (optional lint/CI later) | +| Documentation validation | Links, registry completeness for the phase | + +## Rules + +1. New behavior ships with tests in the same phase. +2. Tenant tests must include at least one cross-tenant denial case for new tenant-scoped APIs. +3. Do not rely on production data for assertions. +4. Prefer deterministic time/OTP fakes. +5. Phase is incomplete if tests fail or are absent for claimed deliverables. + +## Commands (current) + +```bash +cd backend/core-service +pytest -q + +cd backend/services/identity-access +pytest -q +``` + +## Related Documents + +- [Project Principles](project-principles.md) +- [Developer Guide](developer-guide.md) +- [Branching Strategy](branching-strategy.md) +- [AI Framework Testing Template](../ai-framework/testing-template.md) +- [Quality Gates](../ai-framework/quality-gates.md) diff --git a/docs/glossary.md b/docs/glossary.md index 04dbacc..252a9bd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1,105 +1,149 @@ -# Glossary - -Canonical definitions for TorbatYar. Prefer these terms in docs and code comments. - -## Platform & Tenancy - -| Term | Definition | -| --- | --- | -| **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. | -| **Workspace** | Product synonym for an operational Tenant after onboarding. | -| **Organization** | Business entity represented by a Tenant (not a separate table today). | -| **Member** | A user with a membership row linking them to a Tenant. | -| **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. | -| **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. | -| **Workspace Activation** | Completing onboarding so tenant moves to `active` with `onboarding_completed=true`. | -| **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. | -| **Domain** | Hostname mapped to a tenant (subdomain or custom). | -| **Primary Domain** | Domain marked `is_primary` for the tenant. | -| **White Label** | Per-tenant branding (colors, logo, favicon, name) applied at runtime. | -| **Platform Base Domain** | `PLATFORM_BASE_DOMAIN` (e.g. `torbatyar.ir`) used to mint `{slug}.{base}`. | - -## Identity & Access - -| Term | Definition | -| --- | --- | -| **Identity** | Who the actor is (Keycloak subject + Core/Identity profiles). | -| **Platform User** | User recorded in Core `users` (mobile-required). | -| **SSO** | Central Keycloak OpenID Connect login for staff. | -| **OTP** | One-time password sent via SMS for mobile verification/login. | -| **Keycloak Sub** | Stable IdP subject id stored as `keycloak_sub`. | -| **JIT Provisioning** | Creating/linking Core user from Keycloak JWT on first resolve. | -| **Handoff** | One-time session token from Keycloak theme mobile flow redeemed by frontend. | -| **BFF** | Backend-for-frontend (Identity token exchange). | -| **Role** | Named authorization level (platform/tenant roles). | -| **Permission** | Fine-grained allow rule (future trees; today largely role + entitlement). | -| **Entitlement** | Plan/feature gate: whether a tenant may use a feature_key. | -| **Feature Key** | `{service}.{resource}.{action}` string checked by Core. | -| **Internal Service Token** | Machine credential for service-to-service calls. | - -## Modular Product - -| Term | Definition | -| --- | --- | -| **Module** | Installable/activateable product capability with clear DB and API ownership. | -| **Service** | Deployable backend unit implementing one or more modules. | -| **Provider** | External vendor adapter (SMS, payment, storage, AI model, tax). | -| **Service Registry** | Core table of internal services (`service_key`, base URL, health). | -| **Module Registry** | Core table + docs inventory of modules and activation. | -| **Subscription** | Tenant’s plan binding (`tenant_subscriptions`). | -| **Plan** | Packaged set of features/limits (e.g. FREE, STARTER). | - -## Accounting (future) - -| Term | Definition | -| --- | --- | -| **Ledger** | Books of account for a tenant. | -| **Account** | Chart-of-accounts node (planned 4-level structure). | -| **Journal** | Collection of journal entries. | -| **Journal Entry** | Double-entry header document. | -| **Journal Line** | Debit/credit line under an entry. | -| **Voucher** | Business document that may result in postings. | -| **Posting** | Act of writing validated lines to the ledger. | -| **Posting Engine** | Sole authorized component to create journal entries. | -| **Cost Center** | Analytical dimension for costs. | -| **Project** | Analytical dimension for project accounting. | - -## CRM / Commerce / Ops - -| Term | Definition | -| --- | --- | -| **Lead** | Prospective customer record (CRM-owned). | -| **Contact** | Person record in Sales CRM. | -| **Organization (CRM)** | Business account / company record in Sales CRM (not the platform Tenant). | -| **Opportunity** | Qualified sales opportunity in a pipeline. | -| **Pipeline / Stage** | Configurable sales process steps. | -| **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. | -| **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). | -| **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity. | -| **Sales Timeline** | Immutable CRM collaboration event stream. | -| **Quote (Sales)** | CRM sales quote — not an accounting invoice. | -| **Order** | Purchase intent/fulfillment record (ecommerce/restaurant). | -| **Inventory** | Stock levels for sellable items. | - -## Technical - -| Term | Definition | -| --- | --- | -| **Database-per-Service** | Each service owns its DB; no cross-DB queries. | -| **Outbox / Inbox** | Reliable event publish/consume tables. | -| **Event Envelope** | Standard event metadata wrapper. | -| **API-First** | Contracts precede UI implementation. | -| **ADR** | Architecture Decision Record. | -| **Phase** | Time-boxed delivery with completion gate. | -| **Tenant-Aware** | Request/data path always scoped to a tenant. | -| **Audit Log** | Immutable-ish record of who did what, when. | - -## Phase Numbering Note - -Internal docs: **فاز ۳** = OTP + Tenant Management; **فاز ۴** = Onboarding/Workspace Activation. Some briefs labeled onboarding as “Phase 3”. Always disambiguate using [progress.md](progress.md). - -## Related Documents - -- [Module Registry](module-registry.md) -- [Provider Registry](provider-registry.md) -- [Architecture Overview](architecture/architecture.md) +# Glossary + +Canonical definitions for TorbatYar. Prefer these terms in docs and code comments. + +## Platform & Tenancy + +| Term | Definition | +| --- | --- | +| **Tenant** | A customer workspace/organization on the platform. Owns domains, branding, memberships, and subscriptions. | +| **Workspace** | Product synonym for an operational Tenant after onboarding. | +| **Organization** | Business entity represented by a Tenant (not a separate table today). | +| **Member** | **Platform:** a user with a membership row linking them to a Tenant. **Sports Center:** see Member (Sports) — do not conflate. | +| **Platform Member** | Preferred unambiguous term for a user linked to a Tenant via `tenant_memberships`. | +| **Tenant Membership (Core)** | `core_platform_db.tenant_memberships` — source of truth for workspace roles/ownership. | +| **Tenant Membership (Identity)** | `identity_access_db.tenant_memberships` — SSO listing membership; not operational authz. | +| **Workspace Activation** | Completing onboarding so tenant moves to `active` with `onboarding_completed=true`. | +| **Current Tenant** | `users.current_tenant_id` — workspace selected for the session context. | +| **Domain** | Hostname mapped to a tenant (subdomain or custom). | +| **Primary Domain** | Domain marked `is_primary` for the tenant. | +| **White Label** | Per-tenant branding (colors, logo, favicon, name) applied at runtime. | +| **Platform Base Domain** | `PLATFORM_BASE_DOMAIN` (e.g. `torbatyar.ir`) used to mint `{slug}.{base}`. | + +## Identity & Access + +| Term | Definition | +| --- | --- | +| **Identity** | Who the actor is (Keycloak subject + Core/Identity profiles). | +| **Platform User** | User recorded in Core `users` (mobile-required). | +| **SSO** | Central Keycloak OpenID Connect login for staff. | +| **OTP** | One-time password sent via SMS for mobile verification/login. | +| **Keycloak Sub** | Stable IdP subject id stored as `keycloak_sub`. | +| **JIT Provisioning** | Creating/linking Core user from Keycloak JWT on first resolve. | +| **Handoff** | One-time session token from Keycloak theme mobile flow redeemed by frontend. | +| **BFF** | Backend-for-frontend (Identity token exchange). | +| **Role** | Named authorization level (platform/tenant roles). | +| **Permission** | Fine-grained allow rule (future trees; today largely role + entitlement). | +| **Entitlement** | Plan/feature gate: whether a tenant may use a feature_key. | +| **Feature Key** | `{service}.{resource}.{action}` string checked by Core. | +| **Internal Service Token** | Machine credential for service-to-service calls. | + +## Modular Product + +| Term | Definition | +| --- | --- | +| **Module** | Installable/activateable product capability with clear DB and API ownership. | +| **Service** | Deployable backend unit implementing one or more modules. | +| **Provider** | External vendor adapter (SMS, payment, storage, AI model, tax). | +| **Service Registry** | Core table of internal services (`service_key`, base URL, health). | +| **Module Registry** | Core table + docs inventory of modules and activation. | +| **Subscription** | Tenant’s plan binding (`tenant_subscriptions`). | +| **Plan** | Packaged set of features/limits (e.g. FREE, STARTER). | + +## Accounting (future) + +| Term | Definition | +| --- | --- | +| **Ledger** | Books of account for a tenant. | +| **Account** | Chart-of-accounts node (planned 4-level structure). | +| **Journal** | Collection of journal entries. | +| **Journal Entry** | Double-entry header document. | +| **Journal Line** | Debit/credit line under an entry. | +| **Voucher** | Business document that may result in postings. | +| **Posting** | Act of writing validated lines to the ledger. | +| **Posting Engine** | Sole authorized component to create journal entries. | +| **Cost Center** | Analytical dimension for costs. | +| **Project** | Analytical dimension for project accounting. | + +## CRM / Commerce / Ops + +| Term | Definition | +| --- | --- | +| **Lead** | Prospective customer record (CRM-owned). | +| **Contact** | Person record in Sales CRM. | +| **Organization (CRM)** | Business account / company record in Sales CRM (not the platform Tenant). | +| **Opportunity** | Qualified sales opportunity in a pipeline. | +| **Pipeline / Stage** | Configurable sales process steps. | +| **Sales Playbook** | Versioned sales checklist assigned to pipeline/opportunity. | +| **Sales Forecast** | Rule-based pipeline/weighted/committed forecast (no ML). | +| **Sales Activity** | Call, meeting, task, email, follow-up, demo, or custom activity. | +| **Sales Timeline** | Immutable CRM collaboration event stream. | +| **Quote (Sales)** | CRM sales quote — not an accounting invoice. | +| **Loyalty Program** | Tenant-scoped loyalty program configuration (Loyalty-owned). | +| **Loyalty Member** | Loyalty-owned membership record in a program (distinct from Platform Member / Sports Member). | +| **Membership Lifecycle** | Loyalty state machine: enroll/activate/renew/freeze/resume/cancel/expire/transfer. | +| **Loyalty Member** | Enrolled participant in a Loyalty Program; belongs to exactly one Tenant. | +| **Membership Tier** | Ranked level within a Loyalty Program. | +| **Point Account** | Loyalty points account shell; balances come only from immutable ledger entries. | +| **Communication Platform** | Independent shared service owning outbound messaging, providers, templates, queue, OTP, and delivery tracking. | +| **Provider Failover** | Automatic switch to the next priority provider when send fails or circuit is open. | +| **Dynamic Contact Source** | API-configured resolver that fetches destinations at send time without copying business-module rows. | +| **Order** | Purchase intent/fulfillment record (ecommerce/restaurant). | +| **Inventory** | Stock levels for sellable items. | + +## Sports Center + +| Term | Definition | +| --- | --- | +| **Member (Sports)** | Enrolled athlete or club customer profile in Sports Center (`members` table) — not a Platform Member and not a Loyalty Member. | +| **Membership (Sports)** | Active enrollment instance linking a Member to a Membership Type from the catalog; supports freeze/activate/cancel. | +| **Family Member (Sports)** | Household/relationship link under a Sports Member (may optionally reference another Member). | +| **Emergency Contact** | Contact person recorded for a Sports Member for emergency use. | +| **Medical Information (Sports)** | Privacy-aware clearance/notes shell for a Sports Member — not an EHR; binaries via Storage refs. | +| **Membership Card** | Physical/digital/QR card issued to a Sports Member (payload strings only until attendance devices). | +| **Digital Membership** | Digital pass / wallet shell for a Sports Member (`pass_code` + optional token/deep-link refs). | +| **Waiver** | Liability/consent record for a Sports Member; sign action stores signature/file refs. | +| **Coach** | Sports Center staff record who coaches members/sessions (Sports Center–owned). | +| **Trainer** | Synonym/role variant of Coach; prefer **Coach** in APIs unless a distinct trainer role is modeled. | +| **Program** | Structured training program definition owned by Sports Center (not a Loyalty Program). | +| **Workout** | Concrete workout definition or assigned workout instance under a Program or coach plan. | +| **Attendance** | Check-in/check-out or presence record for a member at a facility/session. | +| **Booking** | Reservation of a facility, court, session, or equipment slot. | +| **Facility** | Physical sports space unit (building area, room, hall) owned by Sports Center. | +| **Court** | Bookable facility subtype (e.g. tennis/futsal court). | +| **Session** | Scheduled time-bound activity (class, training, rental) that may be booked or attended. | +| **Locker** | Assignable locker unit or locker status record in Sports Center. | +| **Access Device** | Hardware or logical reader used for attendance/access control (adapter-owned credentials stay out of other services). | +| **Competition** | Organized contest or tournament record owned by Sports Center. | +| **Membership Plan** | Commercial definition of sports membership benefits/duration (Membership Types). | +| **Package** | Bundled sports offering (sessions/classes/services) sold under Membership Types. | +| **Renewal** | Extending an active sports Membership for a new period. | +| **Freeze** | Temporary suspension of a sports Membership without full cancellation. | +| **Transfer** | Moving a sports Membership between eligible members/accounts per business rules. | +| **Sports Event** | Calendar/competition event in Sports Center — distinct from domain/integration **Events** on the bus. | + +## Technical + +| Term | Definition | +| --- | --- | +| **Database-per-Service** | Each service owns its DB; no cross-DB queries. | +| **Outbox / Inbox** | Reliable event publish/consume tables. | +| **Event Envelope** | Standard event metadata wrapper. | +| **API-First** | Contracts precede UI implementation. | +| **ADR** | Architecture Decision Record. | +| **Phase** | Time-boxed delivery with completion gate. | +| **AI Development Framework** | Permanent docs under `docs/ai-framework/` governing AI-assisted implementation (process — not the product AI Assistant). | +| **Tenant-Aware** | Request/data path always scoped to a tenant. | +| **Audit Log** | Immutable-ish record of who did what, when. | + +## Phase Numbering Note + +Internal docs: **فاز ۳** = OTP + Tenant Management; **فاز ۴** = Onboarding/Workspace Activation. Some briefs labeled onboarding as “Phase 3”. Always disambiguate using [progress.md](progress.md). + +## Related Documents + +- [Module Registry](module-registry.md) +- [Provider Registry](provider-registry.md) +- [Architecture Overview](architecture/architecture.md) +- [AI Development Framework](ai-framework/README.md) +- [Sports Center Roadmap](sports-center-roadmap.md) diff --git a/docs/loyalty-phase-7-0-audit.md b/docs/loyalty-phase-7-0-audit.md new file mode 100644 index 0000000..c1ef883 --- /dev/null +++ b/docs/loyalty-phase-7-0-audit.md @@ -0,0 +1,110 @@ +# Loyalty Phase 7.0 — Enterprise Validation & Self-Heal Audit + +| Field | Value | +| --- | --- | +| Date | 2026-07-25 | +| Scope | Phase 7.0 foundation only (no Phase 7.1) | +| Service | `loyalty-service` / `loyalty_db` / port 8004 | +| Test result | **52 passed** | + +## Scores (0–100) + +| Area | Score | Notes | +| --- | --- | --- | +| Architecture | **97** | ADR-001/003/006/011 aligned; independent of CRM; contracts-only providers; no 7.1 residue | +| Code Quality | **94** | Integrity mapping, JSON-safe audit, null reject, ordered lists, DTO `extra=forbid` | +| Security | **93** | Auth/permission trees; production credential/debug fail-closed; no DB error leakage | +| Performance | **90** | Tenant/status/tier/outbox + default-program partial unique index | +| Documentation | **95** | Phase doc, ADR, registry, API/event catalogs, audit report refreshed | + +**Overall production-readiness for Phase 7.0 foundation: Pass** + +## Test Coverage Summary + +| Category | Status | +| --- | --- | +| Unit / business rules | Pass | +| Repository / tenant filter | Pass | +| Service / API flows | Pass | +| Permission definitions + tree inheritance | Pass | +| Security (401/403/invalid tenant) | Pass | +| Tenant isolation | Pass | +| Migration metadata | Pass | +| Architecture / dependency | Pass | +| Documentation validation | Pass | +| Performance (index presence) | Pass | +| Audit + persisted outbox rows | Pass | +| Soft-delete code reclaim (program + member) | Pass | +| Null required-field updates | Pass | +| Audit UUID/tier update path | Pass | + +## Self-Healed Defects (this cycle) + +1. Incomplete Phase 7.1 residue removed (lifecycle routes/services/migration/imports) — restored 7.0 baseline +2. IntegrityError → 409 on all create paths; no raw DB `cause` in API details +3. Audit `changes` JSON-serialized (UUID / date / enum safe) +4. Explicit `null` on non-nullable update fields → `422 null_not_allowed` +5. Permission tree inheritance: `loyalty.view`, `*.manage`, `loyalty.manage` +6. In-memory event mirror only in `environment=test` (outbox remains source of truth) +7. Deterministic `ORDER BY created_at DESC` on tenant lists +8. Partial unique index: at most one live default program per tenant +9. Production config guards reject default DB credentials / `debug=True` +10. External customer ref uniqueness pre-check; DTO `extra=forbid` + non-negative numeric fields +11. Tests expanded for outbox persistence, member soft-delete reclaim, null updates, permission trees + +## Integration Verification + +| System | Dependency mode | Result | +| --- | --- | --- | +| Identity / JWT | Shared JWT validator + roles | Pass (contracts) | +| Core Platform | Config URL only; entitlement check deferred platform-wide | Pass (documented) | +| CRM | `CRMProvider` protocol only | Pass | +| Accounting | Forbidden import / no ownership | Pass | +| Notification / Communication / SMS | Provider protocols only | Pass | +| AI | `AIProvider` protocol only | Pass | +| Storage | `FileStorageProvider` protocol only | Pass | + +No duplicated Loyalty business logic inside CRM or other modules. No Phase 7.1 APIs remain. + +## Remaining Risks + +1. Outbox rows stay `PENDING` until a platform bus worker exists. +2. Program soft-delete does not cascade to members/accounts — intentional until Membership Engine policy. +3. Alembic `0001_initial` still uses metadata `create_all` (parity with CRM foundation); explicit DDL can harden later. +4. Optimistic locking is service-layer version check (not `UPDATE … WHERE version=?`); acceptable for 7.0 shell load, harden before high-concurrency ledger. +5. `X-Tenant-ID` is not yet bound to JWT tenant claim (platform-wide pattern shared with CRM). +6. Intra-DB FK constraints between aggregates deferred (UUID refs + service checks; matches foundation style). + +## Technical Debt (acceptable for 7.0) + +- Metadata-driven initial migration +- No Celery/outbox flusher in Loyalty service yet +- Cascade/archive policies deferred to 7.1+ +- Core entitlement feature-key wiring deferred platform-wide + +## Recommendations before Phase 7.1 + +1. Define membership lifecycle state machine + eligibility validators first +2. Decide cascade policy when closing a program/member (reject vs soft-cascade) +3. Keep PointAccount balance out of DTO surface; introduce ledger-only writes in 7.2 +4. Wire Core entitlement `loyalty.*` feature keys when plan catalog is ready +5. Consider JWT↔tenant binding as a platform ADR before high-value financial mutations +6. Do not embed Loyalty rules in Restaurant/CRM — consume Loyalty APIs/events only + +## Sign-Off + +- [x] Architecture +- [x] Security +- [x] Performance +- [x] Testing +- [x] Documentation +- [x] Migration +- [x] Backward Compatibility (additive) +- [x] Tenant Isolation + +## Related Documents + +- [loyalty-phase-7-0.md](loyalty-phase-7-0.md) +- [ADR-011](architecture/adr/ADR-011.md) +- [Quality Gates](ai-framework/quality-gates.md) +- [Module Registry — loyalty](module-registry.md#loyalty) diff --git a/docs/loyalty-phase-7-0.md b/docs/loyalty-phase-7-0.md new file mode 100644 index 0000000..7dba4da --- /dev/null +++ b/docs/loyalty-phase-7-0.md @@ -0,0 +1,205 @@ +# Phase 7.0 — Loyalty Service Foundation + +| Field | Value | +| --- | --- | +| Status | Complete (validated + self-healed) | +| Module | loyalty | +| Version | 0.7.0.0 | +| Database | `loyalty_db` | +| API Port | 8004 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Goal + +Establish the Enterprise Loyalty Platform as an independent shared service foundation and architectural boundaries only. +Not part of CRM. Reusable by all business modules via API + Events. + +## Loyalty Responsibilities (Phase 7.0) + +Loyalty owns foundation aggregates only: + +- LoyaltyProgram +- MembershipTier (shell for Phase 7.1) +- Member (shell for Phase 7.1) +- PointAccount shell — **no mutable balance** (ledger in Phase 7.2) +- Reward catalog shell (Phase 7.3) +- Campaign shell with versioned rules counter (Phase 7.4) +- LoyaltyAuditLog +- OutboxEvent (ADR-006 transactional publish) + +## Service Boundaries + +| Loyalty owns | Loyalty does not own | +| --- | --- | +| Aggregates above | CRM sales entities | +| Loyalty HTTP APIs under `/api/v1/*` | Accounting / Posting Engine | +| `loyalty.*` permissions (enforced on routes) | Notification / Communication delivery | +| Publish-only Loyalty events via outbox | Analytics store (Phase 7.8) | +| Platform provider **interfaces** | Identity / Wallet UI | +| | Restaurant / Marketplace / Ecommerce domain data | + +Communication with platform and business services is **API + Events only**. No cross-DB access. + +## Owned Modules (aggregates) + +Independent aggregates in `backend/services/loyalty/app/models/foundation.py`: + +1. `LoyaltyProgram` +2. `MembershipTier` +3. `Member` +4. `PointAccount` +5. `Reward` +6. `Campaign` +7. `LoyaltyAuditLog` +8. `OutboxEvent` (infra) + +Cross-aggregate links use UUID references inside `loyalty_db` only (no SQLAlchemy relationship graphs). + +## External Platform Dependencies (contracts only) + +Defined in `app/providers/contracts.py` — **no implementations**: + +- `NotificationProvider` +- `AnalyticsProvider` +- `Customer360Provider` +- `AIProvider` +- `ModuleIntegrationProvider` +- `CRMProvider` +- `CommunicationProvider` +- `FileStorageProvider` + +## Published Events + +| Event | Aggregate | +| --- | --- | +| `loyalty.program.created` / `updated` / `deleted` | loyalty_program | +| `loyalty.tier.created` / `updated` / `deleted` | membership_tier | +| `loyalty.member.created` / `updated` / `enrolled` / `deleted` | member | +| `loyalty.point_account.opened` / `updated` / `deleted` | point_account | +| `loyalty.reward.created` / `updated` / `deleted` | reward | +| `loyalty.campaign.created` / `updated` / `deleted` | campaign | + +Events are written to `outbox_events` in the same DB transaction as the mutation (ADR-006). Phase 7.0 does **not** consume platform events. + +## API Contracts + +| Method | Path | +| --- | --- | +| CRUD + soft delete | `/api/v1/programs` | +| CRUD + soft delete | `/api/v1/tiers` | +| CRUD + enroll + soft delete | `/api/v1/members` | +| Open / list / update / soft delete | `/api/v1/point-accounts` | +| CRUD + soft delete | `/api/v1/rewards` | +| CRUD + soft delete | `/api/v1/campaigns` | +| Audit read | `/api/v1/audit?entity_type=&entity_id=` | +| Health | `/health` | + +## Permissions + +Route-enforced trees (admin roles or explicit permission strings): + +- `loyalty.*` +- `loyalty.programs.*` +- `loyalty.tiers.*` +- `loyalty.members.*` +- `loyalty.point_accounts.*` +- `loyalty.rewards.*` +- `loyalty.campaigns.*` +- `loyalty.audit.*` + +## Architecture Decisions + +1. Database-per-service (`loyalty_db`) — ADR-001 / ADR-011 +2. Row-level `tenant_id` from request tenant context — ADR-003 +3. Transactional outbox (`outbox_events`) + EventEnvelope — ADR-006 +4. Layering: API → Services → Repositories → Models +5. Direct balance modification forbidden; PointAccount DTOs `extra='forbid'` +6. Optimistic locking (`version` required) on Program / Member / PointAccount updates +7. Campaign `rule_version` increments when rules change +8. Soft delete releases unique business keys (`__del__{id}`) so codes can be reused +9. At most one `is_default` program per tenant (app clear + partial unique index) +10. Invalid `X-Tenant-ID` → `400 invalid_tenant_id` +11. Permission inheritance: `loyalty.view`, resource `*.manage`, `loyalty.manage` +12. Production rejects default DB credentials and `debug=True` + +## Database / ER (logical) + +``` +LoyaltyProgram 1──* MembershipTier +LoyaltyProgram 1──* Member 1──1 PointAccount +LoyaltyProgram 1──* Reward +LoyaltyProgram 1──* Campaign +* ── LoyaltyAuditLog (by entity_type/entity_id) +* ── OutboxEvent +``` + +Indexes cover tenant+status / program / tier / outbox status. No cross-DB FKs. + +## Enterprise Validation (Self-Heal) + +| Gate | Result | +| --- | --- | +| Architecture / boundaries | Pass | +| Module ownership (not CRM) | Pass | +| Repository / service layering | Pass | +| Tenant isolation | Pass | +| Soft delete + unique reclaim | Pass (healed) | +| Outbox-ready events | Pass (healed) | +| Permission enforcement + tree inheritance | Pass (healed) | +| Security (auth deny / invalid tenant / prod guards) | Pass (healed) | +| Audit read API + JSON-safe changes | Pass (healed) | +| Balance forbid at HTTP | Pass (healed) | +| Null required-field updates | Pass (healed) | +| Performance indexes + default uniqueness | Pass (healed) | +| Documentation / ADR / registry | Pass | +| Automated tests | **52 passed** | + +Audit report: [loyalty-phase-7-0-audit.md](loyalty-phase-7-0-audit.md) + +## Tests Executed + +| Suite | Result | +| --- | --- | +| Architecture / dependency / migration | Pass | +| Permissions / security / tenant isolation | Pass | +| Repository / API / business rules / audit / outbox | Pass | +| Performance indexes / docs | Pass | + +Command: `cd backend/services/loyalty && pytest -q` + +## Known Limitations + +- Membership engine depth (lifecycle rules, eligibility) → Phase 7.1 +- Immutable point ledger / earn-redeem / expiration → Phase 7.2 +- Full reward redemption flows → Phase 7.3 +- Campaign segments/triggers/rule engine → Phase 7.4 +- Referral / wallet / gift card / analytics / public APIs → Phases 7.5–7.9 +- Outbox worker / real message bus flush → platform bus maturity (rows are persisted PENDING) +- Soft-delete of program does not cascade-close children (explicit Phase 7.1+ policy) +- JWT↔tenant claim binding and Core entitlement feature checks → platform-wide follow-up + +## Next Phase + +Phase 7.1 — Membership Engine (**not started**) + +## Completion Checklist + +- [x] Code completed +- [x] Architecture / dependency / repository / migration / API / permission / security / tenant / docs / business-rule / performance tests +- [x] Documentation updated +- [x] Module registry updated +- [x] ADR-011 accepted +- [x] Enterprise validation + self-heal completed (including incomplete 7.1 residue cleanup) +- [x] No CRM ownership of Loyalty +- [x] No TODO placeholders in foundation +- [x] No Phase 7.1 APIs/migrations present + +## Related Documents + +- [Progress](progress.md) +- [Next Steps](next-steps.md) +- [Module Registry — loyalty](module-registry.md#loyalty) +- [Loyalty Phase Area](phases/Loyalty/README.md) +- [ADR-011](architecture/adr/ADR-011.md) +- [Audit Report](loyalty-phase-7-0-audit.md) +- [Service README](../backend/services/loyalty/README.md) diff --git a/docs/loyalty-phase-7-1.md b/docs/loyalty-phase-7-1.md new file mode 100644 index 0000000..0c22ff4 --- /dev/null +++ b/docs/loyalty-phase-7-1.md @@ -0,0 +1,118 @@ +# Phase 7.1 — Membership Engine + +| Field | Value | +| --- | --- | +| Identifier | `loyalty-7.1` | +| Status | Complete | +| Module | loyalty | +| Service | `loyalty-service` | +| Version | `0.7.1.0` | +| Database | `loyalty_db` | +| Depends On | Phase 7.0 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Deliver the Membership Engine: lifecycle state machine, eligibility transitions, append-only lifecycle history, program cascade policy, and lifecycle APIs/events — without points ledger, rewards redemption, or campaign engines. + +## Scope + +### In Scope + +- Member lifecycle: activate, renew, freeze, resume, cancel, expire, transfer +- Enroll path hardened with term / expiry and lifecycle events +- `MembershipLifecycleEvent` append-only history +- Cascade policy: reject program soft-delete while blocking members exist +- Permissions, events, validators, migration `0002_phase_71_membership` +- Tests + documentation + handover + +### Out of Scope + +- Point ledger / balances (7.2) +- Rewards redemption (7.3) +- Campaign engine (7.4+) +- Wallet / gift cards / partner network + +## Cascade Policy + +Soft-deleting a `LoyaltyProgram` is **rejected** (`409 program_has_active_members`) when any non-deleted member exists in statuses: `pending`, `active`, `frozen`, `suspended`, `expired`. Cancelled / transferred / closed members do not block. + +## State Machine + +| Action | Allowed from | To | +| --- | --- | --- | +| enroll / activate | pending (activate also expired) | active | +| renew | active, expired, frozen, suspended | active | +| freeze | active | frozen | +| resume | frozen, suspended | active | +| cancel | pending, active, frozen, suspended, expired | cancelled | +| expire | active, frozen, suspended | expired | +| transfer | active, frozen, suspended | transferred (+ new active member in target program) | + +Terminal: `cancelled`, `transferred`, `closed`. + +## Models + +| Entity | Soft delete | Audit | Tenant | +| --- | --- | --- | --- | +| Member (extended fields) | Yes | Yes | Yes | +| MembershipLifecycleEvent | No (append-only) | Via fields | Yes | + +New member fields: `activated_at`, `membership_started_at`, `membership_expires_at`, `frozen_at`, `freeze_reason`, `cancelled_at`, `cancel_reason`, `expired_at`, `transferred_to_member_id`. + +## APIs + +| Method | Path | Permission | +| --- | --- | --- | +| POST | `/api/v1/members/{id}/activate` | `loyalty.members.activate` | +| POST | `/api/v1/members/{id}/renew` | `loyalty.members.renew` | +| POST | `/api/v1/members/{id}/freeze` | `loyalty.members.freeze` | +| POST | `/api/v1/members/{id}/resume` | `loyalty.members.resume` | +| POST | `/api/v1/members/{id}/cancel` | `loyalty.members.cancel` | +| POST | `/api/v1/members/{id}/expire` | `loyalty.members.expire` | +| POST | `/api/v1/members/{id}/transfer` | `loyalty.members.transfer` | +| GET | `/api/v1/members/{id}/lifecycle` | `loyalty.members.lifecycle.view` | + +Existing create / enroll / update / delete remain compatible (additive fields). + +## Events + +| Event | When | +| --- | --- | +| `loyalty.member.activated` | Activate / enroll activation | +| `loyalty.member.renewed` | Renew | +| `loyalty.member.frozen` | Freeze | +| `loyalty.member.resumed` | Resume | +| `loyalty.member.cancelled` | Cancel | +| `loyalty.member.expired` | Expire | +| `loyalty.member.transferred` | Transfer (source) | + +## Migration + +| Item | Detail | +| --- | --- | +| Alembic | `0002_phase_71_membership` (down_revision `0001_initial`) | +| Breaking | None — additive columns + new table | +| Backfill | None required | + +## Tests + +Command: `cd backend/services/loyalty && pytest -q` → **59 passed** + +## Known Limitations + +- No automatic expire scheduler (API-driven expire only) +- Transfer copies profile/contact; does not move point accounts (7.2) +- JWT↔tenant binding remains platform-wide + +## Next Phase + +Phase 7.2 — Point Engine (immutable ledger) + +## Related Documents + +- [Handover](phase-handover/phase-7-1.md) +- [Phase 7.0](loyalty-phase-7-0.md) +- [ADR-011](architecture/adr/ADR-011.md) +- [Module Registry](module-registry.md#loyalty) diff --git a/docs/module-registry.md b/docs/module-registry.md index 928a6ed..0b559d4 100644 --- a/docs/module-registry.md +++ b/docs/module-registry.md @@ -1,541 +1,716 @@ -# Module Registry - -Canonical inventory of modules. Template: [module-template.md](templates/module-template.md). -Architecture boundaries: [module-boundaries.md](architecture/module-boundaries.md). - -Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** - ---- - -## core-platform - -| Field | Value | -| --- | --- | -| Name | Core Platform | -| Description | Tenants, domains, plans, entitlements, registries, onboarding, audit, outbox | -| Owner | Platform | -| Status | Active | -| Dependencies | Postgres, Redis, Celery | -| Internal Dependencies | shared-lib | -| External Dependencies | Payamak (OTP), Keycloak (JWT validate), SSL provision host | -| Database Ownership | Sole owner | -| Database | `core_platform_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `core.*` / platform admin deps | -| Events | `tenant.*`, `domain.*`, `subscription.*`, `feature_access.*` | -| Event Producers | Core services/workers | -| Event Consumers | Future business services | -| Provider Dependencies | Payamak, Let's Encrypt (via ops/SSL task) | -| AI Dependencies | None | -| Documentation | [architecture/](architecture/), [reference/](reference/) | -| Current Phase | Phase 4 complete; white-label polish in progress | -| Version | 0.4.x | -| Version Compatibility | Identity ≥ phase 2 | -| Migration Version | Alembic head (incl. `0005_tenant_onboarding`) | -| Tenant Aware | Yes | -| Permission Tree | platform_admin + membership roles | -| Future Plans | Payment gateway, DNS verify, advanced permissions | - ---- - -## identity-access - -| Field | Value | -| --- | --- | -| Name | Identity & Access | -| Description | OIDC BFF, profiles, mobile OTP handoff, Keycloak sync | -| Owner | Platform | -| Status | Active | -| Dependencies | Keycloak, Core OTP API | -| Internal Dependencies | shared-lib | -| External Dependencies | Keycloak, Core | -| Database Ownership | Sole owner | -| Database | `identity_access_db` | -| API Prefix | `/api/v1/auth`, `/api/v1/users`, `/api/v1/tenants/{id}/members` | -| Permission Prefix | Identity admin roles | -| Events | `user.registered`, `tenant_member.added`, `tenant_member.removed` | -| Event Producers | Identity service | -| Event Consumers | Core (future sync) | -| Provider Dependencies | Keycloak | -| AI Dependencies | None | -| Documentation | [identity-architecture.md](architecture/identity-architecture.md), service README | -| Current Phase | Phase 2+ delivered | -| Version | 0.2.x | -| Version Compatibility | Core with JWT + mobile | -| Migration Version | Identity Alembic head | -| Tenant Aware | Partial (membership listing) | -| Permission Tree | platform_admin for user/member admin APIs | -| Future Plans | Richer sync with Core memberships | - ---- - -## frontend - -| Field | Value | -| --- | --- | -| Name | Frontend | -| Description | Next.js UI: SSO, onboarding, dashboard, tenant site | -| Owner | Platform | -| Status | Active | -| Dependencies | Core API, Identity API, Keycloak | -| Internal Dependencies | None (no backend imports) | -| External Dependencies | Browser | -| Database Ownership | None | -| Database | — | -| API Prefix | Consumes `/api/v1` | -| Permission Prefix | UI gated by roles from `/me` | -| Events | N/A (consumer only via API) | -| Event Producers | None | -| Event Consumers | None | -| Provider Dependencies | None directly | -| AI Dependencies | None | -| Documentation | [frontend/README.md](../frontend/README.md) | -| Current Phase | White-label runtime partial | -| Version | Next 15.5.x | -| Version Compatibility | Core/Identity current | -| Migration Version | N/A | -| Tenant Aware | Yes (host + context) | -| Permission Tree | Mirrors Core roles in UI | -| Future Plans | Module UIs per business phase | - ---- - -## accounting - -| Field | Value | -| --- | --- | -| Name | Accounting | -| Description | 4-level COA, double-entry, posting engine, GL, treasury, AR/AP, sales integration | -| Owner | Platform | -| Status | Active (Phases 5.1–5.11) | -| Dependencies | Core entitlement | -| Internal Dependencies | Posting Engine (owned here) | -| External Dependencies | Tax/e-invoice providers (future) | -| Database Ownership | Sole owner | -| Database | `accounting_db` | -| API Prefix | `/api/v1` (service on port 8002) | -| Permission Prefix | `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*` | -| Events | `voucher.posted`, `ledger.updated`, `cash.received`, `settlement.completed`, `sales_invoice.posted` | -| Event Producers | Accounting | -| Event Consumers | Reporting, compliance (planned) | -| Provider Dependencies | Payment/tax (planned) | -| AI Dependencies | Optional assistants only (Phase 5.12) | -| Documentation | [phases/Accounting](phases/Accounting/README.md), service README | -| Current Phase | 5.11 complete; 5.12 (AI) next | -| Version | 0.5.11.0 | -| Version Compatibility | Requires Core entitlement | -| Migration Version | `0002_phases_57_511` | -| Tenant Aware | Yes (required) | -| Permission Tree | `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*` | -| Future Plans | Phases 5.7–5.12 (purchase/inventory, assets, payroll, reporting, compliance, AI) | - ---- - -## crm - -| Field | Value | -| --- | --- | -| Name | CRM | -| Description | Sales CRM: leads, contacts, organizations, opportunities, pipelines, playbooks, forecasts, goals, targets, win/loss, activities, tasks, meetings, calls, timeline, comments, mentions, team collaboration, quotes, tags, addresses, custom fields, notes, attachments, audit | -| Owner | Platform | -| Status | Active (Phase 6.3 collaboration — CRM Core Platform complete) | -| Dependencies | Core entitlement; File Storage + Product Service + Notification (reference/contracts only) | -| Internal Dependencies | None — Sales CRM only | -| External Dependencies | Platform providers via contracts only (Automation, Customer360, Notification, Analytics, AI, Communication, Helpdesk, FileStorage, ProductService) | -| Database Ownership | Sole owner | -| Database | `crm_db` | -| API Prefix | `/api/v1` (service on port 8003) | -| Permission Prefix | `crm.*`, `crm.opportunities.*`, `crm.pipelines.*`, `crm.playbooks.*`, `crm.forecasts.*`, `crm.goals.*`, `crm.targets.*`, `crm.activities.*`, `crm.tasks.*`, `crm.meetings.*`, `crm.calls.*`, `crm.timeline.*`, `crm.comments.*`, `crm.mentions.*`, `crm.team.*`, `crm.leads.*`, `crm.contacts.*`, `crm.organizations.*`, `crm.notes.*`, `crm.attachments.*`, `crm.customfields.*` | -| Events | `crm.opportunity.*`, `crm.pipeline.changed`, `crm.stage.changed`, `crm.playbook.*`, `crm.forecast.updated`, `crm.activity.*`, `crm.task.completed`, `crm.meeting.finished`, `crm.call.logged`, `crm.timeline.updated`, `crm.comment.created`, `crm.mention.created`, `crm.lead.*`, `crm.contact.created`, `crm.organization.created`, `crm.note.created`, `crm.attachment.added`, `crm.quote.created` | -| Event Producers | CRM | -| Event Consumers | Future platforms (not consumed in 6.3) | -| Provider Dependencies | Contracts only — no implementations | -| AI Dependencies | Optional via `AIProvider` contract only | -| Documentation | [crm-phase-6-0.md](crm-phase-6-0.md), [crm-phase-6-1.md](crm-phase-6-1.md), [crm-phase-6-2.md](crm-phase-6-2.md), [crm-phase-6-3.md](crm-phase-6-3.md), [phases/CRM](phases/CRM/README.md), service README | -| Current Phase | 6.3 complete (CRM Core Platform) | -| Version | 0.6.3.0 | -| Version Compatibility | Requires Core entitlement | -| Migration Version | `0004_phase_63_collaboration` | -| Tenant Aware | Yes (required) | -| Permission Tree | `crm.*` | -| Future Plans | Explicitly scoped Sales CRM slices only; no platform ownership | - ---- - -## restaurant - -| Field | Value | -| --- | --- | -| Name | Restaurant / Cafe | -| Description | Digital menu, tables, orders, kitchen, loyalty | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core entitlement, white-label host | -| Internal Dependencies | Optional accounting postings via events | -| External Dependencies | Payment provider (future) | -| Database Ownership | Sole owner | -| Database | `restaurant_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `restaurant.*` | -| Events | `order.*` (planned) | -| Event Producers | Restaurant | -| Event Consumers | Accounting, notification | -| Provider Dependencies | Payment (planned) | -| AI Dependencies | Optional | -| Documentation | [phases/Restaurant](phases/Restaurant/README.md) | -| Current Phase | Candidate first business module after white-label | -| Version | 0.0.0 | -| Version Compatibility | Core + public tenant site | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `restaurant.*` | -| Future Plans | Guest ordering on tenant domain | - ---- - -## ecommerce - -| Field | Value | -| --- | --- | -| Name | Ecommerce | -| Description | Store builder, catalog, cart, orders, shipments | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core, file_storage (future) | -| Internal Dependencies | — | -| External Dependencies | Payment, shipping (future) | -| Database Ownership | Sole owner | -| Database | `ecommerce_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `ecommerce.*` | -| Events | `order.*`, `product.*` (planned) | -| Event Producers | Ecommerce | -| Event Consumers | Accounting, notification | -| Provider Dependencies | Payment, S3 | -| AI Dependencies | Optional | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `ecommerce.*` | -| Future Plans | Marketplace handoff | - ---- - -## marketplace - -| Field | Value | -| --- | --- | -| Name | Marketplace | -| Description | Multi-vendor market capabilities | -| Owner | TBD | -| Status | Planned | -| Dependencies | ecommerce, identity, accounting | -| Internal Dependencies | ecommerce | -| External Dependencies | Payment | -| Database Ownership | TBD (likely dedicated DB) | -| Database | TBD | -| API Prefix | TBD | -| Permission Prefix | `marketplace.*` | -| Events | TBD | -| Event Producers | TBD | -| Event Consumers | TBD | -| Provider Dependencies | Payment | -| AI Dependencies | Optional | -| Documentation | [phases/Marketplace](phases/Marketplace/README.md) | -| Current Phase | Future | -| Version | 0.0.0 | -| Version Compatibility | TBD | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `marketplace.*` | -| Future Plans | After ecommerce foundation | - ---- - -## website_builder - -| Field | Value | -| --- | --- | -| Name | Website Builder | -| Description | Sites, pages, blocks, forms, menus, media | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | file_storage | -| Internal Dependencies | — | -| External Dependencies | S3 | -| Database Ownership | Sole owner | -| Database | `website_builder_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `website_builder.*` | -| Events | TBD | -| Event Producers | Website Builder | -| Event Consumers | — | -| Provider Dependencies | S3 | -| AI Dependencies | Optional copy assist | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `website_builder.*` | -| Future Plans | Tie into white-label public sites | - ---- - -## live_chat - -| Field | Value | -| --- | --- | -| Name | Live Chat | -| Description | Embeddable widget, conversations, agent routing | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core, optional CRM/AI | -| Internal Dependencies | — | -| External Dependencies | — | -| Database Ownership | Sole owner | -| Database | `live_chat_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `live_chat.*` | -| Events | `conversation.*` (planned) | -| Event Producers | Live Chat | -| Event Consumers | CRM, AI | -| Provider Dependencies | — | -| AI Dependencies | Optional handoff | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `live_chat.*` | -| Future Plans | Widget on tenant sites | - ---- - -## ai_assistant - -| Field | Value | -| --- | --- | -| Name | AI Assistant | -| Description | Intelligent chat, KB, handoff to humans | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core entitlement, model provider | -| Internal Dependencies | Independent — must not own money/compliance | -| External Dependencies | AI model provider | -| Database Ownership | Sole owner | -| Database | `ai_assistant_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `ai_assistant.*` | -| Events | `assistant.*` (planned) | -| Event Producers | AI Assistant | -| Event Consumers | Live Chat, CRM | -| Provider Dependencies | AI model provider | -| AI Dependencies | Self | -| Documentation | [ai-architecture.md](architecture/ai-architecture.md), [phases/AI](phases/AI/README.md) | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `ai_assistant.*` | -| Future Plans | Provider-agnostic adapters | - ---- - -## smart_messenger - -| Field | Value | -| --- | --- | -| Name | Smart Messenger | -| Description | Social channels, unified inbox, automations | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core | -| Internal Dependencies | Optional CRM | -| External Dependencies | Social network APIs | -| Database Ownership | Sole owner | -| Database | `smart_messenger_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `smart_messenger.*` | -| Events | TBD | -| Event Producers | Smart Messenger | -| Event Consumers | CRM | -| Provider Dependencies | Channel providers | -| AI Dependencies | Optional | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `smart_messenger.*` | -| Future Plans | Channel provider registry entries | - ---- - -## sms_panel - -| Field | Value | -| --- | --- | -| Name | SMS Panel | -| Description | Campaign SMS, templates, queues, multi-provider | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core | -| Internal Dependencies | — | -| External Dependencies | SMS providers | -| Database Ownership | Sole owner | -| Database | `sms_panel_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `sms_panel.*` | -| Events | TBD | -| Event Producers | SMS Panel | -| Event Consumers | — | -| Provider Dependencies | Payamak and others | -| AI Dependencies | None | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `sms_panel.*` | -| Future Plans | Separate from Core OTP path | - ---- - -## link_shortener - -| Field | Value | -| --- | --- | -| Name | Link Shortener | -| Description | Short links, custom domains, click analytics | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core | -| Internal Dependencies | — | -| External Dependencies | — | -| Database Ownership | Sole owner | -| Database | `link_shortener_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `link_shortener.*` | -| Events | TBD | -| Event Producers | Link Shortener | -| Event Consumers | Campaign analytics | -| Provider Dependencies | — | -| AI Dependencies | None | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `link_shortener.*` | -| Future Plans | Custom domain SSL via edge | - ---- - -## notification - -| Field | Value | -| --- | --- | -| Name | Notification | -| Description | Email, SMS, push, webhook, in-app fanout | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | Core | -| Internal Dependencies | — | -| External Dependencies | Email/SMS/push providers | -| Database Ownership | Sole owner | -| Database | `notification_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `notification.*` | -| Events | Consumes many domain events | -| Event Producers | Notification (delivery logs) | -| Event Consumers | Notification | -| Provider Dependencies | SMS/email providers | -| AI Dependencies | None | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `notification.*` | -| Future Plans | Central delivery for all modules | - ---- - -## file_storage - -| Field | Value | -| --- | --- | -| Name | File Storage | -| Description | Tenant-aware uploads, assets, S3 gateway | -| Owner | TBD | -| Status | Scaffolded | -| Dependencies | S3-compatible provider | -| Internal Dependencies | — | -| External Dependencies | S3 | -| Database Ownership | Sole owner | -| Database | `file_storage_db` | -| API Prefix | `/api/v1` | -| Permission Prefix | `file_storage.*` | -| Events | TBD | -| Event Producers | File Storage | -| Event Consumers | website_builder, ecommerce | -| Provider Dependencies | S3-compatible | -| AI Dependencies | None | -| Documentation | service README | -| Current Phase | Not started | -| Version | 0.0.0 | -| Version Compatibility | Core | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `file_storage.*` | -| Future Plans | Signed URL pattern | - ---- - -## automation (platform capability) - -| Field | Value | -| --- | --- | -| Name | Automation | -| Description | Cross-module workflow automation | -| Owner | TBD | -| Status | Planned | -| Dependencies | Event bus maturity | -| Internal Dependencies | Multiple modules | -| External Dependencies | — | -| Database Ownership | TBD | -| Database | TBD | -| API Prefix | TBD | -| Permission Prefix | `automation.*` | -| Events | Trigger/action events | -| Event Producers | Automation engine | -| Event Consumers | Modules | -| Provider Dependencies | — | -| AI Dependencies | Optional | -| Documentation | [phases/Automation](phases/Automation/README.md) | -| Current Phase | Future | -| Version | 0.0.0 | -| Version Compatibility | Requires real message bus | -| Migration Version | None | -| Tenant Aware | Yes | -| Permission Tree | `automation.*` | -| Future Plans | After event bus | - ---- - -## Related Documents - -- [Provider Registry](provider-registry.md) -- [Roadmap](roadmap.md) -- [Architecture Overview](architecture/architecture.md) +# Module Registry + +Canonical inventory of modules. Template: [module-template.md](templates/module-template.md). +Architecture boundaries: [module-boundaries.md](architecture/module-boundaries.md). + +Status legend: **Active** · **Scaffolded** (code placeholder) · **Planned** + +--- + +## core-platform + +| Field | Value | +| --- | --- | +| Name | Core Platform | +| Description | Tenants, domains, plans, entitlements, registries, onboarding, audit, outbox | +| Owner | Platform | +| Status | Active | +| Dependencies | Postgres, Redis, Celery | +| Internal Dependencies | shared-lib | +| External Dependencies | Payamak (OTP), Keycloak (JWT validate), SSL provision host | +| Database Ownership | Sole owner | +| Database | `core_platform_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `core.*` / platform admin deps | +| Events | `tenant.*`, `domain.*`, `subscription.*`, `feature_access.*` | +| Event Producers | Core services/workers | +| Event Consumers | Future business services | +| Provider Dependencies | Payamak, Let's Encrypt (via ops/SSL task) | +| AI Dependencies | None | +| Documentation | [architecture/](architecture/), [reference/](reference/) | +| Current Phase | Phase 4 complete; white-label polish in progress | +| Version | 0.4.x | +| Version Compatibility | Identity ≥ phase 2 | +| Migration Version | Alembic head (incl. `0005_tenant_onboarding`) | +| Tenant Aware | Yes | +| Permission Tree | platform_admin + membership roles | +| Future Plans | Payment gateway, DNS verify, advanced permissions | + +--- + +## identity-access + +| Field | Value | +| --- | --- | +| Name | Identity & Access | +| Description | OIDC BFF, profiles, mobile OTP handoff, Keycloak sync | +| Owner | Platform | +| Status | Active | +| Dependencies | Keycloak, Core OTP API | +| Internal Dependencies | shared-lib | +| External Dependencies | Keycloak, Core | +| Database Ownership | Sole owner | +| Database | `identity_access_db` | +| API Prefix | `/api/v1/auth`, `/api/v1/users`, `/api/v1/tenants/{id}/members` | +| Permission Prefix | Identity admin roles | +| Events | `user.registered`, `tenant_member.added`, `tenant_member.removed` | +| Event Producers | Identity service | +| Event Consumers | Core (future sync) | +| Provider Dependencies | Keycloak | +| AI Dependencies | None | +| Documentation | [identity-architecture.md](architecture/identity-architecture.md), service README | +| Current Phase | Phase 2+ delivered | +| Version | 0.2.x | +| Version Compatibility | Core with JWT + mobile | +| Migration Version | Identity Alembic head | +| Tenant Aware | Partial (membership listing) | +| Permission Tree | platform_admin for user/member admin APIs | +| Future Plans | Richer sync with Core memberships | + +--- + +## frontend + +| Field | Value | +| --- | --- | +| Name | Frontend | +| Description | Next.js UI: SSO, onboarding, dashboard, tenant site | +| Owner | Platform | +| Status | Active | +| Dependencies | Core API, Identity API, Keycloak | +| Internal Dependencies | None (no backend imports) | +| External Dependencies | Browser | +| Database Ownership | None | +| Database | — | +| API Prefix | Consumes `/api/v1` | +| Permission Prefix | UI gated by roles from `/me` | +| Events | N/A (consumer only via API) | +| Event Producers | None | +| Event Consumers | None | +| Provider Dependencies | None directly | +| AI Dependencies | None | +| Documentation | [frontend/README.md](../frontend/README.md) | +| Current Phase | White-label runtime partial | +| Version | Next 15.5.x | +| Version Compatibility | Core/Identity current | +| Migration Version | N/A | +| Tenant Aware | Yes (host + context) | +| Permission Tree | Mirrors Core roles in UI | +| Future Plans | Module UIs per business phase | + +--- + +## accounting + +| Field | Value | +| --- | --- | +| Name | Accounting | +| Description | 4-level COA, double-entry, posting engine, GL, treasury, AR/AP, sales integration | +| Owner | Platform | +| Status | Active (Phases 5.1–5.11) | +| Dependencies | Core entitlement | +| Internal Dependencies | Posting Engine (owned here) | +| External Dependencies | Tax/e-invoice providers (future) | +| Database Ownership | Sole owner | +| Database | `accounting_db` | +| API Prefix | `/api/v1` (service on port 8002) | +| Permission Prefix | `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*` | +| Events | `voucher.posted`, `ledger.updated`, `cash.received`, `settlement.completed`, `sales_invoice.posted` | +| Event Producers | Accounting | +| Event Consumers | Reporting, compliance (planned) | +| Provider Dependencies | Payment/tax (planned) | +| AI Dependencies | Optional assistants only (Phase 5.12) | +| Documentation | [phases/Accounting](phases/Accounting/README.md), service README | +| Current Phase | 5.11 complete; 5.12 (AI) next | +| Version | 0.5.11.0 | +| Version Compatibility | Requires Core entitlement | +| Migration Version | `0002_phases_57_511` | +| Tenant Aware | Yes (required) | +| Permission Tree | `accounting.*`, `treasury.*`, `receivable.*`, `payable.*`, `sales_accounting.*` | +| Future Plans | Phases 5.7–5.12 (purchase/inventory, assets, payroll, reporting, compliance, AI) | + +--- + +## crm + +| Field | Value | +| --- | --- | +| Name | CRM | +| Description | Sales CRM: leads, contacts, organizations, opportunities, pipelines, playbooks, forecasts, goals, targets, win/loss, activities, tasks, meetings, calls, timeline, comments, mentions, team collaboration, quotes, tags, addresses, custom fields, notes, attachments, audit | +| Owner | Platform | +| Status | Active (Phase 6.3 collaboration — CRM Core Platform complete) | +| Dependencies | Core entitlement; File Storage + Product Service + Notification (reference/contracts only) | +| Internal Dependencies | None — Sales CRM only | +| External Dependencies | Platform providers via contracts only (Automation, Customer360, Notification, Analytics, AI, Communication, Helpdesk, FileStorage, ProductService) | +| Database Ownership | Sole owner | +| Database | `crm_db` | +| API Prefix | `/api/v1` (service on port 8003) | +| Permission Prefix | `crm.*`, `crm.opportunities.*`, `crm.pipelines.*`, `crm.playbooks.*`, `crm.forecasts.*`, `crm.goals.*`, `crm.targets.*`, `crm.activities.*`, `crm.tasks.*`, `crm.meetings.*`, `crm.calls.*`, `crm.timeline.*`, `crm.comments.*`, `crm.mentions.*`, `crm.team.*`, `crm.leads.*`, `crm.contacts.*`, `crm.organizations.*`, `crm.notes.*`, `crm.attachments.*`, `crm.customfields.*` | +| Events | `crm.opportunity.*`, `crm.pipeline.changed`, `crm.stage.changed`, `crm.playbook.*`, `crm.forecast.updated`, `crm.activity.*`, `crm.task.completed`, `crm.meeting.finished`, `crm.call.logged`, `crm.timeline.updated`, `crm.comment.created`, `crm.mention.created`, `crm.lead.*`, `crm.contact.created`, `crm.organization.created`, `crm.note.created`, `crm.attachment.added`, `crm.quote.created` | +| Event Producers | CRM | +| Event Consumers | Future platforms (not consumed in 6.3) | +| Provider Dependencies | Contracts only — no implementations | +| AI Dependencies | Optional via `AIProvider` contract only | +| Documentation | [crm-phase-6-0.md](crm-phase-6-0.md), [crm-phase-6-1.md](crm-phase-6-1.md), [crm-phase-6-2.md](crm-phase-6-2.md), [crm-phase-6-3.md](crm-phase-6-3.md), [phases/CRM](phases/CRM/README.md), service README | +| Current Phase | 6.3 complete (CRM Core Platform) | +| Version | 0.6.3.0 | +| Version Compatibility | Requires Core entitlement | +| Migration Version | `0004_phase_63_collaboration` | +| Tenant Aware | Yes (required) | +| Permission Tree | `crm.*` | +| Future Plans | Explicitly scoped Sales CRM slices only; no platform ownership | + +--- + +## loyalty + +| Field | Value | +| --- | --- | +| Name | Enterprise Loyalty Platform | +| Description | Shared loyalty: programs, members, tiers, point accounts, rewards, campaigns; reusable by all business modules | +| Owner | Platform | +| Status | Active (Phase 7.1 Membership Engine) | +| Dependencies | Core entitlement (feature-key gate deferred platform-wide; JWT + tenant header enforced) | +| Internal Dependencies | None — independent of CRM | +| External Dependencies | Platform providers via contracts only (Notification, Analytics, Customer360, AI, ModuleIntegration, CRM, Communication, FileStorage) | +| Database Ownership | Sole owner | +| Database | `loyalty_db` | +| API Prefix | `/api/v1` (service on port 8004) | +| Permission Prefix | `loyalty.*`, `loyalty.programs.*`, `loyalty.tiers.*`, `loyalty.members.*`, `loyalty.point_accounts.*`, `loyalty.rewards.*`, `loyalty.campaigns.*`, `loyalty.audit.*` | +| Events | `loyalty.program.*`, `loyalty.tier.*`, `loyalty.member.*`, `loyalty.point_account.*`, `loyalty.reward.*`, `loyalty.campaign.*` | +| Event Producers | Loyalty (transactional outbox) | +| Event Consumers | Future business modules / Customer360 (not consumed in 7.1) | +| Provider Dependencies | Contracts only — no implementations | +| AI Dependencies | Optional via `AIProvider` contract only | +| Documentation | [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), [phases/Loyalty](phases/Loyalty/README.md), service README | +| Current Phase | 7.1 complete (Membership Engine); 7.2 not started | +| Version | 0.7.1.0 | +| Version Compatibility | Designed for Core entitlement; runtime feature-check deferred | +| Migration Version | `0002_phase_71_membership` | +| Tenant Aware | Yes (required) | +| Permission Tree | `loyalty.*` (view/manage inheritance supported) | +| Future Plans | Phases 7.2–7.10 (points ledger, rewards, campaigns, coupon/voucher, wallet/gift, partner, integrations, AI/analytics, production readiness) | + +--- + +## communication + +| Field | Value | +| --- | --- | +| Name | Enterprise Communication Platform | +| Description | Independent shared messaging: providers, router, SMS, templates, dynamic contacts, queue, delivery tracking, OTP, webhooks, monitoring | +| Owner | Platform | +| Status | Active (Phase 8.0–8.10 complete) | +| Dependencies | Core entitlement (optional gate); no other business modules required | +| Internal Dependencies | None — independent of CRM / Loyalty / Restaurant | +| External Dependencies | SMS providers (Payamak, mock); future email/push/social/voice adapters | +| Database Ownership | Sole owner | +| Database | `communication_db` | +| API Prefix | `/api/v1` (service on port 8005); `/health`, `/capabilities` | +| Permission Prefix | `communication.*` | +| Events | `communication.message.*`, `communication.provider.*`, `communication.otp.*`, `communication.queue.*`, `communication.webhook.*`, `communication.template.*` | +| Event Producers | Communication | +| Event Consumers | Any subscribed business module (via API/events only) | +| Provider Dependencies | Payamak (SMS), Mock; stubs for smtp/fcm/whatsapp/telegram/rubika/voice | +| AI Dependencies | None | +| Documentation | [communication-phase-8.md](communication-phase-8.md), ADR-012, service README | +| Current Phase | 8.10 complete | +| Version | 0.8.10.0 | +| Version Compatibility | Standalone; Core JWT optional via AUTH_REQUIRED | +| Migration Version | `0001_initial` | +| Tenant Aware | Yes (required) | +| Permission Tree | `communication.*` | +| Future Plans | Worker drain, additional channel adapters, optional Core OTP handoff | + +--- + +## sports_center + +| Field | Value | +| --- | --- | +| Name | Sports Center Platform | +| Description | Independent sports platform: foundation, membership catalog, and member management (profiles, assignment, cards/waivers/documents) | +| Owner | Platform | +| Status | Active (Phase 9.2 member management) | +| Dependencies | Core entitlement; Accounting / CRM / Loyalty / Communication via API/Events when phases require them | +| Internal Dependencies | Foundation + catalog + member modules owned by this service | +| External Dependencies | Access-device vendors (adapters later); payment via Accounting (future) | +| Database Ownership | Sole owner | +| Database | `sports_center_db` | +| API Prefix | `/api/v1` (service on port 8006); `/health`, `/capabilities` | +| Permission Prefix | `sports_center.*` | +| Events | `sports_center.member.*`, `membership.*`, `family_member.*`, `emergency_contact.*`, `medical_information.*`, `membership_card.*`, `digital_membership.*`, `waiver.*`, `member_document.*`, catalog events, `coach.*`, `facility.*`, `device.*`, `locker.*` | +| Event Producers | Sports Center | +| Event Consumers | Accounting, CRM, Loyalty, Communication, Analytics (future consumers) | +| Provider Dependencies | Connector interfaces only | +| AI Dependencies | Optional via contracts only (Phase 9.9) | +| Documentation | [sports-center-phase-9-0.md](sports-center-phase-9-0.md), [sports-center-phase-9-1.md](sports-center-phase-9-1.md), [sports-center-phase-9-2.md](sports-center-phase-9-2.md), [phase-handover/phase-9-2.md](phase-handover/phase-9-2.md), [sports-center-roadmap.md](sports-center-roadmap.md), [phases/SportsCenter](phases/SportsCenter/README.md), [ADR-014](architecture/adr/ADR-014.md) | +| Current Phase | 9.2 complete (member management); next `sports-center-9.3` | +| Version | 0.9.2.0 | +| Version Compatibility | Requires Core entitlement; integrations per Phase 9.8 | +| Migration Version | `0003_phase_92_member_management` | +| Tenant Aware | Yes (required) | +| Permission Tree | `sports_center.*` | +| Future Plans | Phases 9.3–9.10 per [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +### Modules (owned by sports_center) + +| Module Key | Name | Status | Phase | +| --- | --- | --- | --- | +| `sports_center.sports_centers` | Sports Centers | Active (shell) | 9.0 | +| `sports_center.branches` | Branches | Active (shell) | 9.0 | +| `sports_center.sports` | Sports catalog | Active (shell) | 9.0 | +| `sports_center.membership_types` | Membership Types | Active (catalog) | 9.1 | +| `sports_center.memberships` | Memberships (assignment + status) | Active | 9.2 | +| `sports_center.sport_categories` | Sport Categories | Active | 9.1 | +| `sports_center.age_groups` | Age Groups | Active | 9.1 | +| `sports_center.pricing_models` | Pricing Models | Active | 9.1 | +| `sports_center.membership_packages` | Membership Packages | Active | 9.1 | +| `sports_center.membership_plans` | Membership Plans | Active | 9.1 | +| `sports_center.membership_rules` | Membership Rules | Active | 9.1 | +| `sports_center.renewal_policies` | Renewal Policies | Active | 9.1 | +| `sports_center.freezing_rules` | Freezing Rules | Active | 9.1 | +| `sports_center.expiration_policies` | Expiration Policies | Active | 9.1 | +| `sports_center.members` | Members | Active | 9.2 | +| `sports_center.family_members` | Family Members | Active | 9.2 | +| `sports_center.emergency_contacts` | Emergency Contacts | Active | 9.2 | +| `sports_center.medical` | Medical Information | Active (shell) | 9.2 | +| `sports_center.membership_cards` | Membership Cards | Active | 9.2 | +| `sports_center.digital_memberships` | Digital Memberships | Active | 9.2 | +| `sports_center.waivers` | Waivers | Active | 9.2 | +| `sports_center.member_documents` | Member Documents | Active | 9.2 | +| `sports_center.coaches` | Coaches | Active (shell) | 9.0 | +| `sports_center.roles` | Sports Roles | Active (shell) | 9.0 | +| `sports_center.permissions` | Sports Permissions | Active (shell) | 9.0 | +| `sports_center.facilities` | Facilities | Active (shell) | 9.0 | +| `sports_center.courts` | Courts | Active (shell) | 9.0 | +| `sports_center.rooms` | Rooms | Active (shell) | 9.0 | +| `sports_center.locker_rooms` | Locker Rooms | Active (shell) | 9.0 | +| `sports_center.lockers` | Lockers | Active (shell) | 9.0 | +| `sports_center.devices` | Devices | Active (shell) | 9.0 | +| `sports_center.device_providers` | Device Providers | Active (shell) | 9.0 | +| `sports_center.attendance_gateways` | Attendance Gateways | Active (shell) | 9.0 | +| `sports_center.configuration` | Configuration | Active (shell) | 9.0 | +| `sports_center.events` | Sports Events | Active (shell) | 9.0 | +| `sports_center.settings` | Settings | Active (shell) | 9.0 | +| `sports_center.audit` | Audit | Active | 9.0 | +| `sports_center.attendance` | Attendance engine | Planned | 9.5 | +| `sports_center.booking` | Booking | Planned | 9.4 | +| `sports_center.equipment` | Equipment | Planned | 9.4 | +| `sports_center.programs` | Programs | Planned | 9.6 | +| `sports_center.workouts` | Workouts | Planned | 9.6 | +| `sports_center.competitions` | Competitions | Planned | 9.7 | +| `sports_center.competitions_events` | Competition events depth | Planned | 9.7 | +| `sports_center.nutrition` | Nutrition | Planned | Later slice | +| `sports_center.mobile` | Mobile | Planned | Cross-cutting | +| `sports_center.reports` | Reports | Planned | 9.9 | +| `sports_center.analytics` | Analytics | Planned | 9.9 | +| `sports_center.integrations` | Integrations | Planned | 9.8 | + +| Module | Responsibilities | Non-responsibilities | +| --- | --- | --- | +| Members | Sports member profiles; links to platform user refs | Platform Tenant Member; Loyalty Member | +| Membership | Active membership instances; freeze/renewal/transfer | Accounting invoices ownership | +| Membership Types | Plans, packages, commercial definitions | Core SaaS plans | +| Coaches | Coach/trainer records and assignments | Identity user admin | +| Attendance | Check-in/out records | Physical device firmware | +| Booking | Reservations and conflict rules | Payment capture ownership | +| Facilities | Courts, rooms, zones | Building IoT cloud | +| Equipment | Equipment inventory for booking/tracking | Procurement accounting | +| Programs | Training programs | Generic LMS / Academy ownership | +| Workouts | Workout definitions / assignments | Wearable vendor platforms | +| Competitions | Competition definitions / results shells | External federation systems | +| Events | Sports calendar events | Domain/integration event bus | +| Medical | Clearance / note refs (privacy-aware) | Hospital EHR systems | +| Nutrition | Nutrition plan shells | Dietitian marketplace | +| Locker | Locker assignment status | Smart-lock vendor cloud | +| Mobile | Mobile API contracts / deep links | Native app store publishing (ops) | +| Reports | Operational reports | Central BI warehouse ownership | +| Analytics | Metrics export shells | Product AI platform | +| Integrations | Adapters to Accounting/CRM/Loyalty/Communication | Foreign DB ownership | + +--- + +## restaurant + +| Field | Value | +| --- | --- | +| Name | Restaurant / Cafe | +| Description | Digital menu, tables, orders, kitchen; loyalty via Loyalty service | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core entitlement, white-label host; Loyalty (API/Events) | +| Internal Dependencies | Optional accounting postings via events | +| External Dependencies | Payment provider (future) | +| Database Ownership | Sole owner | +| Database | `restaurant_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `restaurant.*` | +| Events | `order.*` (planned) | +| Event Producers | Restaurant | +| Event Consumers | Accounting, notification | +| Provider Dependencies | Payment (planned) | +| AI Dependencies | Optional | +| Documentation | [phases/Restaurant](phases/Restaurant/README.md) | +| Current Phase | Candidate first business module after white-label | +| Version | 0.0.0 | +| Version Compatibility | Core + public tenant site | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `restaurant.*` | +| Future Plans | Guest ordering on tenant domain | + +--- + +## ecommerce + +| Field | Value | +| --- | --- | +| Name | Ecommerce | +| Description | Store builder, catalog, cart, orders, shipments | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core, file_storage (future) | +| Internal Dependencies | — | +| External Dependencies | Payment, shipping (future) | +| Database Ownership | Sole owner | +| Database | `ecommerce_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `ecommerce.*` | +| Events | `order.*`, `product.*` (planned) | +| Event Producers | Ecommerce | +| Event Consumers | Accounting, notification | +| Provider Dependencies | Payment, S3 | +| AI Dependencies | Optional | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `ecommerce.*` | +| Future Plans | Marketplace handoff | + +--- + +## marketplace + +| Field | Value | +| --- | --- | +| Name | Marketplace | +| Description | Multi-vendor market capabilities | +| Owner | TBD | +| Status | Planned | +| Dependencies | ecommerce, identity, accounting | +| Internal Dependencies | ecommerce | +| External Dependencies | Payment | +| Database Ownership | TBD (likely dedicated DB) | +| Database | TBD | +| API Prefix | TBD | +| Permission Prefix | `marketplace.*` | +| Events | TBD | +| Event Producers | TBD | +| Event Consumers | TBD | +| Provider Dependencies | Payment | +| AI Dependencies | Optional | +| Documentation | [phases/Marketplace](phases/Marketplace/README.md) | +| Current Phase | Future | +| Version | 0.0.0 | +| Version Compatibility | TBD | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `marketplace.*` | +| Future Plans | After ecommerce foundation | + +--- + +## website_builder + +| Field | Value | +| --- | --- | +| Name | Website Builder | +| Description | Sites, pages, blocks, forms, menus, media | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | file_storage | +| Internal Dependencies | — | +| External Dependencies | S3 | +| Database Ownership | Sole owner | +| Database | `website_builder_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `website_builder.*` | +| Events | TBD | +| Event Producers | Website Builder | +| Event Consumers | — | +| Provider Dependencies | S3 | +| AI Dependencies | Optional copy assist | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `website_builder.*` | +| Future Plans | Tie into white-label public sites | + +--- + +## live_chat + +| Field | Value | +| --- | --- | +| Name | Live Chat | +| Description | Embeddable widget, conversations, agent routing | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core, optional CRM/AI | +| Internal Dependencies | — | +| External Dependencies | — | +| Database Ownership | Sole owner | +| Database | `live_chat_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `live_chat.*` | +| Events | `conversation.*` (planned) | +| Event Producers | Live Chat | +| Event Consumers | CRM, AI | +| Provider Dependencies | — | +| AI Dependencies | Optional handoff | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `live_chat.*` | +| Future Plans | Widget on tenant sites | + +--- + +## ai_assistant + +| Field | Value | +| --- | --- | +| Name | AI Assistant | +| Description | Intelligent chat, KB, handoff to humans | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core entitlement, model provider | +| Internal Dependencies | Independent — must not own money/compliance | +| External Dependencies | AI model provider | +| Database Ownership | Sole owner | +| Database | `ai_assistant_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `ai_assistant.*` | +| Events | `assistant.*` (planned) | +| Event Producers | AI Assistant | +| Event Consumers | Live Chat, CRM | +| Provider Dependencies | AI model provider | +| AI Dependencies | Self | +| Documentation | [ai-architecture.md](architecture/ai-architecture.md), [phases/AI](phases/AI/README.md) | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `ai_assistant.*` | +| Future Plans | Provider-agnostic adapters | + +--- + +## smart_messenger + +| Field | Value | +| --- | --- | +| Name | Smart Messenger | +| Description | Social channels, unified inbox, automations | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | Optional CRM | +| External Dependencies | Social network APIs | +| Database Ownership | Sole owner | +| Database | `smart_messenger_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `smart_messenger.*` | +| Events | TBD | +| Event Producers | Smart Messenger | +| Event Consumers | CRM | +| Provider Dependencies | Channel providers | +| AI Dependencies | Optional | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `smart_messenger.*` | +| Future Plans | Channel provider registry entries | + +--- + +## sms_panel + +| Field | Value | +| --- | --- | +| Name | SMS Panel | +| Description | Campaign SMS, templates, queues, multi-provider | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | — | +| External Dependencies | SMS providers | +| Database Ownership | Sole owner | +| Database | `sms_panel_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `sms_panel.*` | +| Events | TBD | +| Event Producers | SMS Panel | +| Event Consumers | — | +| Provider Dependencies | Payamak and others | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `sms_panel.*` | +| Future Plans | Separate from Core OTP path | + +--- + +## link_shortener + +| Field | Value | +| --- | --- | +| Name | Link Shortener | +| Description | Short links, custom domains, click analytics | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | — | +| External Dependencies | — | +| Database Ownership | Sole owner | +| Database | `link_shortener_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `link_shortener.*` | +| Events | TBD | +| Event Producers | Link Shortener | +| Event Consumers | Campaign analytics | +| Provider Dependencies | — | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `link_shortener.*` | +| Future Plans | Custom domain SSL via edge | + +--- + +## notification + +| Field | Value | +| --- | --- | +| Name | Notification | +| Description | Email, SMS, push, webhook, in-app fanout | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | Core | +| Internal Dependencies | — | +| External Dependencies | Email/SMS/push providers | +| Database Ownership | Sole owner | +| Database | `notification_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `notification.*` | +| Events | Consumes many domain events | +| Event Producers | Notification (delivery logs) | +| Event Consumers | Notification | +| Provider Dependencies | SMS/email providers | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `notification.*` | +| Future Plans | Central delivery for all modules | + +--- + +## file_storage + +| Field | Value | +| --- | --- | +| Name | File Storage | +| Description | Tenant-aware uploads, assets, S3 gateway | +| Owner | TBD | +| Status | Scaffolded | +| Dependencies | S3-compatible provider | +| Internal Dependencies | — | +| External Dependencies | S3 | +| Database Ownership | Sole owner | +| Database | `file_storage_db` | +| API Prefix | `/api/v1` | +| Permission Prefix | `file_storage.*` | +| Events | TBD | +| Event Producers | File Storage | +| Event Consumers | website_builder, ecommerce | +| Provider Dependencies | S3-compatible | +| AI Dependencies | None | +| Documentation | service README | +| Current Phase | Not started | +| Version | 0.0.0 | +| Version Compatibility | Core | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `file_storage.*` | +| Future Plans | Signed URL pattern | + +--- + +## automation (platform capability) + +| Field | Value | +| --- | --- | +| Name | Automation | +| Description | Cross-module workflow automation | +| Owner | TBD | +| Status | Planned | +| Dependencies | Event bus maturity | +| Internal Dependencies | Multiple modules | +| External Dependencies | — | +| Database Ownership | TBD | +| Database | TBD | +| API Prefix | TBD | +| Permission Prefix | `automation.*` | +| Events | Trigger/action events | +| Event Producers | Automation engine | +| Event Consumers | Modules | +| Provider Dependencies | — | +| AI Dependencies | Optional | +| Documentation | [phases/Automation](phases/Automation/README.md) | +| Current Phase | Future | +| Version | 0.0.0 | +| Version Compatibility | Requires real message bus | +| Migration Version | None | +| Tenant Aware | Yes | +| Permission Tree | `automation.*` | +| Future Plans | After event bus | + +--- + +## Related Documents + +- [Provider Registry](provider-registry.md) +- [Roadmap](roadmap.md) +- [Architecture Overview](architecture/architecture.md) +- [AI Development Framework](ai-framework/README.md) +- [Phase Manifest](ai-framework/phase-manifest.yaml) +- [Service Manifest](ai-framework/service-manifest.yaml) +- [ADR-013](architecture/adr/ADR-013.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [ADR-014](architecture/adr/ADR-014.md) diff --git a/docs/next-steps.md b/docs/next-steps.md index 6b2611d..a9eb235 100644 --- a/docs/next-steps.md +++ b/docs/next-steps.md @@ -2,35 +2,39 @@ > Immediate next milestone only. History → [progress.md](progress.md). Future map → [roadmap.md](roadmap.md). -## Current Milestone: Production hardening + optional CRM extensions +## Current Milestone: Phase 7.2 — Loyalty Point Engine + +Follow the permanent [AI Development Framework](ai-framework/README.md) ([cursor-guidelines.md](ai-framework/cursor-guidelines.md), [development-loop.md](ai-framework/development-loop.md), [quality-gates.md](ai-framework/quality-gates.md)). ### Why -CRM Core Platform (Phases 6.0–6.3) is complete: foundation, master data, sales engine, and sales collaboration. +Phase 7.1 Membership Engine is complete (lifecycle state machine, history, cascade policy, APIs/events). + +Mandatory entry: [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md). ### Scope -1. Deploy accounting + CRM services to production (carry-over) -2. Optional: AI Model Provider Active → Phase 5.12 UI -3. Optional: wire File Storage / Product Service / Notification adapters for CRM references -4. Do **not** auto-start a new CRM phase — scope explicitly first +1. Immutable point ledger as sole balance source (no mutable balance column) +2. Earn / redeem / adjust / expire ledger entries with audit + outbox events +3. PointAccount remains a shell linked to member/program — balances derived from ledger +4. Do **not** implement rewards redemption engine (7.3) or campaign engine (7.4) ### Exit Criteria -- [x] Production deploy verified for CRM 6.3 + accounting (local + HTTPS health) -- [ ] Provider registry AI entry → Active (when ready) -- [x] CRM Core Platform docs (6.0–6.3) synced in registry / progress / API / glossary +- [ ] Ledger APIs + validators + tests green +- [ ] Docs: `docs/loyalty-phase-7-2.md` + progress/registry + handover +- [ ] Quality gates + phase handover per AI framework ### After This Milestone -Further CRM work only if explicitly scoped; cross-module integrations via API/Events only. +Phase 7.3 — Rewards & Redemption. ## Related Documents - [Progress](progress.md) -- [CRM Phase 6.0](crm-phase-6-0.md) -- [CRM Phase 6.1](crm-phase-6-1.md) -- [CRM Phase 6.2](crm-phase-6-2.md) -- [CRM Phase 6.3](crm-phase-6-3.md) -- [Frontend](frontend/README.md) -- [Accounting Phases](phases/Accounting/README.md) +- [Phase Handover 7.1](phase-handover/phase-7-1.md) +- [Loyalty Phase 7.1](loyalty-phase-7-1.md) +- [Loyalty Phase 7.0](loyalty-phase-7-0.md) +- [Phases / Loyalty](phases/Loyalty/README.md) +- [AI Development Framework](ai-framework/README.md) +- [ADR-011](architecture/adr/ADR-011.md) diff --git a/docs/phase-handover/phase-7-1.md b/docs/phase-handover/phase-7-1.md new file mode 100644 index 0000000..facb1e9 --- /dev/null +++ b/docs/phase-handover/phase-7-1.md @@ -0,0 +1,106 @@ +# Phase Handover — Loyalty 7.1 Membership Engine + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | loyalty-7.1 | +| Title | Membership Engine | +| Status | Complete | +| Service(s) | loyalty (`loyalty-service`, port 8004) | +| Version | 0.7.1.0 | +| Date | 2026-07-25 | +| ADR(s) | ADR-001, ADR-003, ADR-006, ADR-011 | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Lifecycle validators | `app/validators/membership.py` | Transition matrix + expiry helpers | +| MembershipEngineService | `app/services/membership_engine.py` | Lifecycle orchestration | +| MembershipLifecycleEvent | `app/models/foundation.py` | Append-only history pattern | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| POST | `/api/v1/members/{id}/activate` | `loyalty.members.activate` | Sets term/expiry | +| POST | `/api/v1/members/{id}/renew` | `loyalty.members.renew` | Extends from current expiry | +| POST | `/api/v1/members/{id}/freeze` | `loyalty.members.freeze` | Reason required | +| POST | `/api/v1/members/{id}/resume` | `loyalty.members.resume` | From frozen/suspended | +| POST | `/api/v1/members/{id}/cancel` | `loyalty.members.cancel` | Reason required | +| POST | `/api/v1/members/{id}/expire` | `loyalty.members.expire` | Manual expire | +| POST | `/api/v1/members/{id}/transfer` | `loyalty.members.transfer` | Creates target member | +| GET | `/api/v1/members/{id}/lifecycle` | `loyalty.members.lifecycle.view` | History | + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `loyalty.member.activated` | Domain | membership_number, status, expires | 1 | +| `loyalty.member.renewed` | Domain | membership_number, expires | 1 | +| `loyalty.member.frozen` | Domain | membership_number, reason | 1 | +| `loyalty.member.resumed` | Domain | membership_number | 1 | +| `loyalty.member.cancelled` | Domain | membership_number, reason | 1 | +| `loyalty.member.expired` | Domain | membership_number | 1 | +| `loyalty.member.transferred` | Domain | source + target ids | 1 | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Transition matrix | Extend `ALLOWED_TRANSITIONS` + tests | Bypass validators in APIs | +| Program cascade | Adjust `ACTIVE_MEMBER_STATUSES` policy intentionally | Soft-cascade without docs | +| Provider contracts | Unchanged from 7.0 | Implement providers inside Loyalty | + +## Known Limitations + +- No scheduled auto-expire worker +- Transfer does not migrate point accounts (deferred to 7.2+) +- Outbox flush still platform-bus maturity item + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0002_phase_71_membership` | +| Upgrade steps | `alembic upgrade head` in loyalty service | +| Downgrade support | Yes (drops columns/table) | +| Data backfill | None | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| loyalty-7.0 | Phase | Foundation aggregates | +| Core entitlement | Platform | Deferred feature-key wiring | +| shared-lib | Library | Events, security, exceptions | + +## Next Phase Entry + +1. This handover + [loyalty-phase-7-1.md](../loyalty-phase-7-1.md) +2. Updated module registry / manifests +3. Build immutable point ledger; do not store balance on PointAccount +4. Suggested next: `loyalty-7.2` Point Engine + +| Field | Value | +| --- | --- | +| Recommended next phase | loyalty-7.2 | +| Blockers for next phase | None | +| Entry checklist | Read 7.1 handover; keep lifecycle statuses stable | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green (59) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No TODO for claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [loyalty-phase-7-1.md](../loyalty-phase-7-1.md) +- [loyalty-phase-7-0.md](../loyalty-phase-7-0.md) +- [ADR-011](../architecture/adr/ADR-011.md) diff --git a/docs/phase-handover/phase-9-0.md b/docs/phase-handover/phase-9-0.md new file mode 100644 index 0000000..1986755 --- /dev/null +++ b/docs/phase-handover/phase-9-0.md @@ -0,0 +1,191 @@ +# Phase Handover — 9.0 Sports Center Platform Foundation + +> Mandatory starting point for Phase 9.1. + +## Overview + +Phase 9.0 delivered the independent **Sports Center Platform** microservice (`sports-center-service`, `sports_center_db`, port **8006**, version **0.9.0.0**). Foundation aggregates, connector interfaces, permissions, publish-only events, APIs, migration, and docs are in place. **No business workflows** (booking, attendance engine, billing, class lifecycle) were implemented. + +## Completed Scope + +- Independent service scaffold aligned with Loyalty/Communication patterns +- 20 foundation tables / aggregates +- Adapter-based connector framework (interfaces only) +- Platform provider contracts (Accounting, CRM, Loyalty, Communication, Notification, Storage, AI, Identity, Customer360) +- Tenant-aware CRUD APIs + connect/disconnect + locker assign/release +- Configuration shells for hours/policies/locale/custom fields +- Alembic `0001_initial`, compose + `.env.example` wiring +- ADR-014, phase doc, module registry, progress updates + +## Created Modules + +sports_centers · branches · sports · membership_types · memberships · coaches · sports_roles · sports_permissions · facilities · courts · rooms · locker_rooms · lockers · devices · device_providers · attendance_gateways · sports_configuration · sports_events · sports_settings · sports_audit + +## Created Models + +See `backend/services/sports_center/app/models/foundation.py` — SportsCenter, Branch, Sport, MembershipType, Membership, Coach, SportsRole, SportsPermission, Facility, Court, Room, LockerRoom, Locker, DeviceProvider, Device, AttendanceGateway, SportsConfiguration, SportsEvent, SportsSetting, SportsAuditLog. + +## Repositories + +Tenant-scoped repositories in `app/repositories/foundation.py` extending `TenantBaseRepository` (soft-delete aware). + +## Services + +Application services in `app/services/foundation.py` + `audit_service.py`. Write path: validate → uniqueness → persist → audit → commit → publish. + +## Validators + +`app/validators/foundation.py` — codes, email/phone, enums, date ranges, optimistic lock, forbid sport-specific hardcoding keys. + +## Permissions + +`sports_center.*` definitions in `app/permissions/definitions.py` (registry contracts; route-level enforcement deferred like Loyalty). + +## Events + +Publish-only contracts in `app/events/types.py` including: + +- sports_center.member.created +- sports_center.membership.created +- sports_center.coach.created +- sports_center.facility.created +- sports_center.device.connected / disconnected +- sports_center.attendance.provider.changed +- sports_center.locker.assigned / released + +In-memory publisher only (outbox bus later per ADR-006). + +## Public APIs + +All under `/api/v1/*` (see phase doc). Soft delete via `POST /{id}/delete`. + +## Internal APIs + +None beyond service-local health and foundation CRUD. No internal S2S endpoints yet. + +## Database Changes + +New database `sports_center_db` (sole owner). No changes to Core/CRM/Loyalty/Communication schemas. + +## Migration History + +| Revision | Description | +| --- | --- | +| `0001_initial` | Create all Phase 9.0 foundation tables | + +## Indexes + +Tenant + status / deleted indexes; unique `(tenant_id, code)` (and center-scoped codes where applicable); locker/membership number uniques. + +## Constraints + +Unique constraints always include `tenant_id`. No cross-DB FKs. Cross-aggregate links are UUID columns only. + +## Configuration + +SportsConfiguration + SportsSetting support working hours, membership/attendance/booking policies, waiting list, custom fields, timezone, language, currency. + +## Connector Framework + +`app/connectors/` — DeviceConnector + specialized Protocols + ConnectorRegistry. Capabilities: QR, RFID, Barcode, Fingerprint, Face Recognition, Turnstile, Door Controller, Payment Terminal, Attendance Device, Custom. + +## Provider Interfaces + +Platform providers in `app/providers/contracts.py` (Protocols only). Device providers stored as registry rows with `adapter_key`. + +## Extension Points + +- Register vendor adapters on ConnectorRegistry by `adapter_key` +- Add sports catalog entries without code changes +- Policy JSON documents for later engines +- Commands / Queries / Specifications / Policies packages prepared as shells + +## Integration Points + +REST + Events only. External refs: `external_customer_ref`, `external_crm_contact_ref`, `external_user_ref`, file/device refs. + +### Accounting Integration + +Contract only (`AccountingProvider`). No postings in 9.0. + +### CRM Integration + +Contract only; membership may store `external_crm_contact_ref`. + +### Loyalty Integration + +Contract only; Sports does not own points/ledger. + +### Communication Integration + +Contract only; no SMS/provider calls from Sports. + +### AI Integration + +Contract only (`AIProvider.suggest`). + +### Storage Integration + +Contract only; store file refs later. + +### Notification Integration + +Contract only; no delivery ownership. + +## Known Limitations + +- No booking / attendance / membership lifecycle engines +- No vendor device adapters implemented +- Permissions defined but not enforced on routes (JWT + X-Tenant-ID only) +- Events in-memory (no durable outbox yet) +- No frontend module UI + +## Backward Compatibility + +New service — no breaking changes to existing modules. + +## Architectural Decisions + +ADR-014 (this service). Relies on ADR-001, ADR-003, ADR-006. + +## Future Dependencies + +Phase 9.1+ engines will depend on this foundation, Core entitlement, and later Communication/Notification/Accounting integrations. + +## Requirements for Phase 9.1 + +1. Read this handover + `docs/sports-center-phase-9-0.md` + ADR-014 first +2. Do not recreate foundation aggregates +3. Implement the next unfinished sports engine only (as scoped in next-steps/roadmap) +4. Keep sport-agnostic rules; keep connectors adapter-based +5. Do not own Accounting/CRM/Loyalty/Communication/Notification/Storage/Identity/AI + +## Open Risks + +- Device vendor diversity may require careful adapter versioning +- Membership vs Loyalty “member” terminology — keep refs explicit +- Unique constraints with nullable branch_id on settings may behave differently across DBs + +## Performance Notes + +Foundation CRUD only; indexes on tenant_id + common filters. No hot-path attendance writes yet. + +## Security Notes + +JWT via Keycloak; tenant isolation via `X-Tenant-ID` + row `tenant_id`. Soft deletes. Audit trail append-only. Never put vendor secrets in business tables without encryption strategy (later). + +## Testing Summary + +Pytest suites: architecture, API flow/events, permissions, migration, dependency, connectors, docs. + +## Documentation Summary + +- `docs/sports-center-phase-9-0.md` +- `docs/phase-handover/phase-9-0.md` (this file) +- `docs/architecture/adr/ADR-014.md` +- `docs/phases/SportsCenter/README.md` +- Updates: progress, module-registry, next-steps, ADR README, module-boundaries + +## Next Phase Entry Point + +Start Phase **9.1** from this document. Confirm scope in `docs/next-steps.md` before coding. Do not skip ahead to later sports phases. diff --git a/docs/phase-handover/phase-9-1.md b/docs/phase-handover/phase-9-1.md new file mode 100644 index 0000000..61ba901 --- /dev/null +++ b/docs/phase-handover/phase-9-1.md @@ -0,0 +1,105 @@ +# Phase Handover — 9.1 Sports Center Membership Catalog + +> Mandatory starting point for Phase 9.2 — Member Management. + +## Overview + +Phase 9.1 delivered the **Membership Catalog** on `sports-center-service` / `sports_center_db` (port **8006**, version **0.9.1.0**). Commercial definitions, pricing models, age groups, sport categories, rules, and renewal/freeze/expiration policies are in place. **Member enrollment workflows were not implemented.** + +## Completed Scope + +- Nine catalog aggregates + extended MembershipType links +- Validators for age ranges, pricing/billing/rule kinds, money amounts, catalog ref integrity +- Publish-only catalog events +- Permissions trees for catalog modules +- Alembic `0002_phase_91_membership_catalog` +- `/capabilities` endpoint advertising catalog readiness +- Tests + docs + registry/manifest updates + +## Created / Extended Modules + +| Module | Notes | +| --- | --- | +| Sport Categories | New | +| Age Groups | New | +| Pricing Models | New (config only; not Accounting) | +| Membership Packages | New | +| Membership Plans | New | +| Membership Rules | New | +| Renewal Policies | New | +| Freezing Rules | New | +| Expiration Policies | New | +| Membership Types | Extended with catalog refs | + +## Models + +See `app/models/catalog.py` and additive columns on `MembershipType` in `app/models/foundation.py`. + +## Repositories / Services / Validators + +- `app/repositories/catalog.py` +- `app/services/catalog.py` (+ MembershipTypeService catalog ref checks) +- `app/validators/catalog.py` + +## Public APIs + +Listed in [sports-center-phase-9-1.md](../sports-center-phase-9-1.md). Soft delete via `POST /{id}/delete`. + +## Events + +`sports_center.sport_category.*`, `age_group.*`, `pricing_model.*`, `membership_package.*`, `membership_plan.*`, `membership_rule.*`, `renewal_policy.*`, `freezing_rule.*`, `expiration_policy.*`, `membership_type.updated`. + +## Database Changes + +Additive only within `sports_center_db`. No Core/CRM/Loyalty/Communication schema changes. + +## Migration History + +| Revision | Description | +| --- | --- | +| `0001_initial` | Phase 9.0 foundation | +| `0002_phase_91_membership_catalog` | Phase 9.1 catalog | + +## Known Limitations + +- No member/family/medical/card/QR/waiver workflows +- Rule `expression` JSON is stored; evaluation engine deferred to assignment workflows +- Route-level permission enforcement still deferred (same as 9.0) +- Pricing amounts are not posted to Accounting + +## Extension Points + +- Attach catalog refs when creating MembershipType / Plan / Package +- Evaluate MembershipRule expressions in Phase 9.2+ assignment services +- Map pricing_model.external_price_ref to Accounting/payment later (9.8) + +## Dependencies + +- Phase 9.0 foundation complete +- Core entitlement / JWT / tenant headers +- Optional future: Accounting, CRM, Loyalty, Communication (contracts only) + +## Next Phase Entry (9.2 — Member Management) + +Implement members, family members, medical information, emergency contacts, membership assignment, card/QR/digital membership, waivers, documents/attachments, status management — consuming this catalog. Do **not** rebuild catalog tables. Do **not** implement coach/attendance/booking engines. + +Entry checklist: + +1. Read this handover + [sports-center-phase-9-1.md](../sports-center-phase-9-1.md) +2. Follow [ai-framework/](../ai-framework/README.md) +3. Start from `sports-center-9.2` in [phase-manifest.yaml](../ai-framework/phase-manifest.yaml) + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries / manifests updated +- [x] No unfinished claimed deliverables + +## Related Documents + +- [Phase 9.1](../sports-center-phase-9-1.md) +- [Phase 9.0 Handover](phase-9-0.md) +- [ADR-014](../architecture/adr/ADR-014.md) +- [Module Registry](../module-registry.md#sports_center) diff --git a/docs/phase-handover/phase-9-2.md b/docs/phase-handover/phase-9-2.md new file mode 100644 index 0000000..11d243b --- /dev/null +++ b/docs/phase-handover/phase-9-2.md @@ -0,0 +1,153 @@ +# Phase Handover — 9.2 Sports Center Member Management + +> Mandatory starting point for Phase 9.3 — Coach & Staff Management. + +## Overview + +Phase 9.2 delivered **Member Management** on `sports-center-service` / `sports_center_db` (port **8006**, version **0.9.2.0**). Sports members, family/medical/emergency shells, membership assignment against the 9.1 catalog, cards/QR/digital passes, waivers, and document refs are in place. **Coach, attendance, and booking engines were not implemented.** + +## Completed Scope + +- Eight member-related aggregates + additive `memberships.member_id` / freeze window columns +- Status transition rules for memberships (`pending|active|frozen|suspended|expired|cancelled`) +- Card/QR generation shells and waiver sign workflow +- Validators, permissions, publish-only events +- Alembic `0003_phase_92_member_management` +- Tests + docs + registry/manifest updates + +## Created Modules + +| Module | Notes | +| --- | --- | +| Members | New profile aggregate | +| Family Members | New | +| Emergency Contacts | New | +| Medical Information | New (one per member) | +| Membership Cards | New | +| Digital Memberships | New | +| Waivers | New | +| Member Documents | New (Storage file refs) | +| Memberships | Extended assignment + status actions | + +## Models + +See `app/models/members.py` and additive fields on `Membership` in `app/models/foundation.py`. + +## Repositories / Services / Validators + +- `app/repositories/members.py` +- `app/services/members.py` (+ `MembershipService` assignment/status in `foundation.py`) +- `app/validators/members.py` + +## Public APIs + +Listed in [sports-center-phase-9-2.md](../sports-center-phase-9-2.md). Soft delete via `POST /{id}/delete` where applicable. + +## Events + +See phase doc. Legacy memberships created **without** `member_id` still emit `sports_center.member.created` for backward compatibility; real members emit it from `MemberService.create`. + +## Database Changes + +Additive only within `sports_center_db`. No Core/CRM/Loyalty/Communication schema changes. Catalog tables unchanged. + +## Migration History + +| Revision | Description | +| --- | --- | +| `0001_initial` | Phase 9.0 foundation | +| `0002_phase_91_membership_catalog` | Phase 9.1 catalog | +| `0003_phase_92_member_management` | Phase 9.2 members | + +## Indexes / Constraints + +- Unique `(tenant_id, member_number)`, `(tenant_id, card_number)`, `(tenant_id, pass_code)` +- Unique medical row per `(tenant_id, member_id)` +- Indexes on `(tenant_id, member_id)` for child tables + +## Configuration + +No new tenant configuration keys. Uses existing sports configuration shells. + +## Connector Framework + +Unchanged (interfaces only). Cards store QR/barcode payloads as strings — device adapters remain Phase 9.5+. + +## Integration Points + +| Platform | Phase 9.2 usage | +| --- | --- | +| Accounting | Contract only (`external_customer_ref`) | +| CRM | `external_crm_contact_ref` on Member/Membership | +| Loyalty | Not enrolled here | +| Communication | Not sending messages | +| Storage | `file_ref` / `document_file_ref` / `signature_ref` only | +| Identity | `external_user_ref` only | +| AI | Not used | + +## Known Limitations + +- No coach/attendance/booking engines +- Rule expression evaluation from catalog still deferred for complex eligibility +- Route-level permission enforcement still deferred (JWT + `X-Tenant-ID`) +- Events in-memory (no durable outbox yet) +- No frontend module UI +- Medical data is a privacy-aware shell, not an EHR + +## Backward Compatibility + +- Existing memberships remain valid with nullable `member_id` +- Legacy membership create without member still works +- Catalog APIs unchanged + +## Architectural Decisions + +No new ADR. Relies on ADR-014, ADR-001, ADR-003, ADR-006. + +## Future Dependencies + +Phase 9.3 Coach & Staff Management depends on this member foundation for assignments. + +## Requirements for Phase 9.3 + +1. Read this handover + `docs/sports-center-phase-9-2.md` + ADR-014 +2. Do not recreate member/catalog tables +3. Implement coaches/trainers/staff depth only as scoped +4. Keep sport-agnostic rules; keep connectors adapter-based +5. Do not own Accounting/CRM/Loyalty/Communication/Notification/Storage/Identity/AI + +## Open Risks + +- Member vs Platform Member vs Loyalty Member terminology confusion — keep refs explicit +- Medical confidentiality vs reporting needs in later phases +- Card QR payload format may need versioning when attendance devices come online + +## Performance Notes + +CRUD + tenant indexes only; no hot-path attendance writes. + +## Security Notes + +JWT + tenant isolation; soft deletes; audit trail; medical `is_confidential` flag; no binary secrets in DB. + +## Testing Summary + +Pytest: architecture (member models/permissions/events), members API flow, assign/isolation, invalid transitions, migration, docs, plus prior suites. All green at completion. + +## Documentation Summary + +- `docs/sports-center-phase-9-2.md` +- `docs/phase-handover/phase-9-2.md` (this file) +- Updates: progress, next-steps, roadmap, module-registry, glossary, manifests, SportsCenter phase area + +## Next Phase Entry Point + +Start Phase **9.3** from this document. Confirm scope in `docs/next-steps.md` before coding. Do not skip ahead. + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries / manifests updated +- [x] No unfinished claimed deliverables diff --git a/docs/phase-handover/phase-dp-reg.md b/docs/phase-handover/phase-dp-reg.md new file mode 100644 index 0000000..ca51c90 --- /dev/null +++ b/docs/phase-handover/phase-dp-reg.md @@ -0,0 +1,105 @@ +# Phase Handover — DP-Reg Delivery & Fleet Platform Registration + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `delivery-reg` | +| Title | Delivery & Fleet Platform Registration | +| Status | Complete | +| Service(s) | `delivery` (registered; not yet implemented) | +| Version | n/a (docs-only) | +| Date | 2026-07-25 | +| ADR(s) | [ADR-015](../architecture/adr/ADR-015.md) | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Platform registration pattern | Same as Sports Center SC-Reg | Docs + ADR + manifests before code | +| Service template | [service-template.md](../ai-framework/service-template.md) | Use in Phase 10.0 | +| Phase template | [phase-template.md](../ai-framework/phase-template.md) | Use for 10.x phase docs | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| — | — | — | N/A — registration only; APIs begin in Phase 10.0 | + +Planned surfaces (not implemented): `/health`, `/capabilities`, `/api/v1/*` under `delivery.*` permissions. + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `delivery.*` (planned) | Delivery domain | Publish-only; catalog grows per phase | Planned | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Routing engine providers | Adapter protocol in Delivery service | Verticals calling external routers directly | +| Fleet / courier providers | Provider registry + adapters | Credentials outside Delivery | +| Merchant connectors | Versioned REST/events for job intake | Shared DB with Restaurant/Marketplace | +| AI assist hooks | Optional contracts; core works offline | Hard dependency on AI for dispatch | + +## Known Limitations + +- No `backend/services/delivery` code yet — Phase 10.0 +- No Alembic migrations yet +- No compose wiring / runtime health yet +- Driver App / Dispatcher Panel UI deferred to frontend phases +- Order ownership remains in vertical services + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A (docs-only) | +| Upgrade steps | N/A | +| Downgrade support | N/A | +| Data backfill | N/A | +| Breaking changes | None | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AI Framework | Docs | Development loop / gates | +| ADR-001 / 003 / 006 / 010 / 011 / 012 | Architecture | Boundaries | +| Core Platform | Service | Entitlement (from 10.0) | +| Communication | Service | Notifications (from 10.9) | +| Accounting | Service | Settlement posts (from 10.8) | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `delivery-10.0` Foundation | +| Blockers for next phase | None for scaffold; runtime compose optional until wired | +| Entry checklist | Read this handover + [delivery-roadmap.md](../delivery-roadmap.md) + [ADR-015](../architecture/adr/ADR-015.md) + [service-template.md](../ai-framework/service-template.md); implement only foundation shells — no dispatch/routing engines yet | + +What the next phase must read and assume: + +1. This handover + [delivery-roadmap.md](../delivery-roadmap.md) +2. Updated [module-registry.md](../module-registry.md#delivery) / manifests +3. Open limitations above become Phase 10.0 scope (scaffold only) +4. Suggested next phase ID: `delivery-10.0` + +## Completion Sign-Off + +- [x] Quality gates passed (documentation / architecture / manifest / cross-reference / links) +- [x] Tests green (N/A service pytest — docs-only; validation script/checks) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No TODO for claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [Delivery Roadmap](../delivery-roadmap.md) +- [Phase Area](../phases/Delivery/README.md) +- [ADR-015](../architecture/adr/ADR-015.md) +- [Phase Manifest](../ai-framework/phase-manifest.yaml) +- [Service Manifest](../ai-framework/service-manifest.yaml) +- [Quality Gates](../ai-framework/quality-gates.md) diff --git a/docs/phases/Delivery/README.md b/docs/phases/Delivery/README.md new file mode 100644 index 0000000..fc187dc --- /dev/null +++ b/docs/phases/Delivery/README.md @@ -0,0 +1,31 @@ +# Delivery & Fleet Platform Phase Area + +Independent enterprise Delivery & Fleet Platform (`delivery-service`, `delivery_db`, planned port 8007). Commercial product: **Torbat Driver**. + +## Status + +| Phase | Title | Status | +| --- | --- | --- | +| Reg | Platform Registration | Complete | +| 10.0 | Platform Foundation | Next | +| 10.1 | Driver Management | Planned | +| 10.2 | Fleet & Vehicle Types | Planned | +| 10.3 | Availability, Shifts & Working Zones | Planned | +| 10.4 | Pricing, Capabilities & Bundles | Planned | +| 10.5 | Dispatch Engine | Planned | +| 10.6 | Routing & Optimization | Planned | +| 10.7 | Tracking & Proof of Delivery | Planned | +| 10.8 | Settlement | Planned | +| 10.9 | Merchant Connector & App Surfaces | Planned | +| 10.10 | Analytics, AI Ready & Enterprise Validation | Planned | + +## Documents + +- [Roadmap](../../delivery-roadmap.md) +- [Handover DP-Reg](../../phase-handover/phase-dp-reg.md) +- [ADR-015](../../architecture/adr/ADR-015.md) +- [AI Framework](../../ai-framework/README.md) + +## Boundary reminder + +Delivery owns logistics domain only. Consume Accounting, CRM, Loyalty, Communication, Core, and vertical job refs via API + Events. Do not confuse with Communication message delivery tracking. diff --git a/docs/phases/Future/README.md b/docs/phases/Future/README.md index 2bc1047..3f92c32 100644 --- a/docs/phases/Future/README.md +++ b/docs/phases/Future/README.md @@ -1,25 +1,28 @@ -# Future Phases - -Catch-all for capabilities not yet given a dedicated phase folder. - -## Candidates - -- Subscription & Entitlement as independent service -- Real message bus -- DNS/TXT custom domain verification -- Payment gateway integration -- Advanced permission trees & member invites -- File storage + S3 provider -- Website builder, live chat, SMS panel, link shortener, notification (see module registry) - -## Rules - -1. Promote a topic to its own `docs/phases/<Name>/` when active planning starts. -2. Use [phase-template.md](../../templates/phase-template.md). -3. Update [roadmap.md](../../roadmap.md) and [module-registry.md](../../module-registry.md) in the same change. - -## Related Documents - -- [Roadmap](../../roadmap.md) -- [Next Steps](../../next-steps.md) -- [Progress](../../progress.md) +# Future Phases + +Catch-all for capabilities not yet given a dedicated phase folder. + +## Candidates + +- Subscription & Entitlement as independent service +- Real message bus +- DNS/TXT custom domain verification +- Payment gateway integration +- Advanced permission trees & member invites +- File storage + S3 provider +- Website builder, live chat, SMS panel, link shortener, notification (see module registry) + +## Rules + +1. Promote a topic to its own `docs/phases/<Name>/` when active planning starts. +2. Use [ai-framework/phase-template.md](../../ai-framework/phase-template.md) (or short [phase-template.md](../../templates/phase-template.md)). +3. Follow [AI Development Framework](../../ai-framework/README.md) for implementation. +4. Update [roadmap.md](../../roadmap.md) and [module-registry.md](../../module-registry.md) in the same change. + +## Related Documents + +- [Roadmap](../../roadmap.md) +- [Next Steps](../../next-steps.md) +- [Progress](../../progress.md) +- [AI Framework](../../ai-framework/README.md) +- [Phase Manifest](../../ai-framework/phase-manifest.yaml) diff --git a/docs/phases/Loyalty/README.md b/docs/phases/Loyalty/README.md new file mode 100644 index 0000000..bacf66e --- /dev/null +++ b/docs/phases/Loyalty/README.md @@ -0,0 +1,35 @@ +# Loyalty Phases + +Enterprise Loyalty Platform — independent shared service (`loyalty-service`, `loyalty_db`). + +Not part of CRM. Consumed by Restaurant, Marketplace, Ecommerce, Academy, Booking, Healthcare, Salon, Gym, and future modules via API + Events only. + +## Status + +| Phase | Title | Status | +| --- | --- | --- | +| 7.0 | Foundation | Complete (validated) — [loyalty-phase-7-0.md](../../loyalty-phase-7-0.md) · [audit](../../loyalty-phase-7-0-audit.md) | +| 7.1 | Membership Engine | Complete — [loyalty-phase-7-1.md](../../loyalty-phase-7-1.md) · [handover](../../phase-handover/phase-7-1.md) | +| 7.2 | Points Engine (immutable ledger) | Planned | +| 7.3 | Rewards Engine | Planned | +| 7.4 | Campaign Engine | Planned | +| 7.5 | Referral Engine | Planned | +| 7.6 | Wallet | Planned | +| 7.7 | Gift Card Platform | Planned | +| 7.8 | Analytics | Planned | +| 7.9 | Public / Partner / Webhook APIs | Planned | +| 7.10 | Final Review | Planned | + +## Architecture + +- ADR-011 — Independent Enterprise Loyalty Platform Service +- ADR-001 — Database-per-service +- ADR-003 — Row-level tenancy +- ADR-006 — Event envelope / outbox-ready publish + +## Related Documents + +- [Module Registry](../../module-registry.md#loyalty) +- [Progress](../../progress.md) +- [Roadmap](../../roadmap.md) +- [Service README](../../../backend/services/loyalty/README.md) diff --git a/docs/phases/SportsCenter/README.md b/docs/phases/SportsCenter/README.md new file mode 100644 index 0000000..3d323cf --- /dev/null +++ b/docs/phases/SportsCenter/README.md @@ -0,0 +1,35 @@ +# Sports Center Phase Area + +Independent enterprise Sports Center Platform (`sports-center-service`, `sports_center_db`, port 8006). + +## Status + +| Phase | Title | Status | +| --- | --- | --- | +| 9.0 | Platform Foundation | Complete | +| 9.1 | Membership Catalog | Complete | +| 9.2 | Member Management | Complete | +| 9.3 | Coach & Staff Management | Next | +| 9.4 | Scheduling & Booking | Planned | +| 9.5 | Attendance & Access Control | Planned | +| 9.6 | Training Management | Planned | +| 9.7 | Competition & Event Management | Planned | +| 9.8 | Financial Integration | Planned | +| 9.9 | AI & Analytics | Planned | +| 9.10 | Enterprise Validation | Planned | + +## Documents + +- [Roadmap](../../sports-center-roadmap.md) +- [Phase 9.0](../../sports-center-phase-9-0.md) +- [Phase 9.1](../../sports-center-phase-9-1.md) +- [Phase 9.2](../../sports-center-phase-9-2.md) +- [Handover 9.0](../../phase-handover/phase-9-0.md) +- [Handover 9.1](../../phase-handover/phase-9-1.md) +- [Handover 9.2](../../phase-handover/phase-9-2.md) +- [ADR-014](../../architecture/adr/ADR-014.md) +- [AI Framework](../../ai-framework/README.md) + +## Boundary reminder + +Sports Center owns sports domain only. Consume Accounting, CRM, Loyalty, Communication, Notification, Storage, Identity, AI via API + Events. diff --git a/docs/progress.md b/docs/progress.md index 6713eed..73fd029 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -197,6 +197,17 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f - [x] Enterprise validation & self-heal (incl. removal of incomplete 7.1 residue) — **52 tests passed** - [x] Docs: [loyalty-phase-7-0.md](loyalty-phase-7-0.md), [loyalty-phase-7-0-audit.md](loyalty-phase-7-0-audit.md), ADR-011, module registry, phase area README +## Phase 7.1 — Membership Engine ✅ + +- [x] Lifecycle state machine (activate / renew / freeze / resume / cancel / expire / transfer) +- [x] Member lifecycle fields + `MembershipLifecycleEvent` append-only history +- [x] Program soft-delete cascade policy: reject when blocking members exist +- [x] Permissions `loyalty.members.{activate,renew,freeze,resume,cancel,expire,transfer,lifecycle.view}` +- [x] Events `loyalty.member.{activated,renewed,frozen,resumed,cancelled,expired,transferred}` +- [x] Alembic `0002_phase_71_membership`; version `0.7.1.0` +- [x] Tests green (59) including `test_phase71.py` +- [x] Docs: [loyalty-phase-7-1.md](loyalty-phase-7-1.md), [phase-handover/phase-7-1.md](phase-handover/phase-7-1.md), manifests, registry + --- ## Phase 8.0–8.10 — Enterprise Communication Platform ✅ @@ -305,6 +316,7 @@ Remaining: Phase 5.12 AI UI blocked on AI provider; optional richer PDF export f - [CRM Phase 6.2](crm-phase-6-2.md) - [CRM Phase 6.3](crm-phase-6-3.md) - [Loyalty Phase 7.0](loyalty-phase-7-0.md) +- [Loyalty Phase 7.1](loyalty-phase-7-1.md) - [Communication Phase 8](communication-phase-8.md) - [Sports Center Phase 9.0](sports-center-phase-9-0.md) - [Sports Center Phase 9.1](sports-center-phase-9-1.md) diff --git a/docs/provider-registry.md b/docs/provider-registry.md index 6082d65..2134b0f 100644 --- a/docs/provider-registry.md +++ b/docs/provider-registry.md @@ -10,15 +10,33 @@ Inventory of external providers. Template: [provider-template.md](templates/prov | --- | --- | | Provider Name | Payamak | | Country | IR | -| Capabilities | SMS OTP send | -| Version | API as configured via env | +| Capabilities | SMS OTP send; SMS messaging via Communication adapters | +| Version | API as configured via env / tenant credentials | | Dependencies | Network egress to Payamak API | -| Supported Modules | Core (OTP), Identity (via Core OTP client) | -| Configuration | `PAYAMAK_USERNAME`, `PAYAMAK_PASSWORD`/`API_KEY`, `PAYAMAK_FROM`, `PAYAMAK_BODY_ID` / pattern | -| API | Provider HTTP SendOtp | +| Supported Modules | Core (auth OTP), Identity (via Core OTP client), Communication (platform SMS) | +| Configuration | Core: `PAYAMAK_*`; Communication: tenant `provider_configs.credentials` | +| API | Provider HTTP SendOtp / SendSimpleSMS | | Status | Active | -| Documentation | [provider-reference.md](reference/provider-reference.md#payamak) | -| Tests | Covered indirectly via OTP auth tests | +| Documentation | [provider-reference.md](reference/provider-reference.md#payamak), [communication-phase-8.md](communication-phase-8.md) | +| Tests | Core OTP tests; Communication provider/SMS tests | + +--- + +## Communication Mock SMS + +| Field | Value | +| --- | --- | +| Provider Name | Mock SMS | +| Country | N/A | +| Capabilities | Deterministic SMS send for tests/dev | +| Version | Built-in | +| Dependencies | None | +| Supported Modules | Communication | +| Configuration | `provider_kind=mock` | +| API | In-process adapter | +| Status | Active | +| Documentation | [communication-phase-8.md](communication-phase-8.md) | +| Tests | Communication provider/failover tests | --- diff --git a/docs/reference/api-reference.md b/docs/reference/api-reference.md index 362ffce..26391c7 100644 --- a/docs/reference/api-reference.md +++ b/docs/reference/api-reference.md @@ -1,60 +1,76 @@ -# API Reference - -Index of HTTP APIs. Detailed contracts live in [services-contracts.md](services-contracts.md). Template: [api-template.md](../templates/api-template.md). - -## Core Platform - -Base: Core service (`/api/v1`) - -| Area | Doc section | -| --- | --- | -| Health | `GET /health` | -| Tenants / Domains / Plans / Features / Subscription / Entitlement check | [services-contracts §5](services-contracts.md) | -| Onboarding & Tenant Context | [services-contracts §7](services-contracts.md) | -| OTP Auth | architecture identity + Core routers `/auth/otp/*` | -| Public tenant site | `GET /api/v1/public/tenant-site` (see contracts updates / Core routers) | - -## Identity & Access - -Base: Identity service (`/api/v1`) - -| Area | Doc section | -| --- | --- | -| OIDC BFF + mobile auth | [services-contracts §8](services-contracts.md) | - -## Accounting - -Base: Accounting service port `8002` (`/api/v1`) - -See service OpenAPI at `/docs` and [phases/Accounting](../phases/Accounting/README.md). - -## CRM - -Base: CRM service port `8003` (`/api/v1`) — public host `crm.torbatyar.ir` - -| Area | Paths | -| --- | --- | -| Leads | `/api/v1/leads` (+ assign/delete/restore) | -| Contacts | `/api/v1/contacts` | -| Organizations | `/api/v1/organizations` | -| Pipelines / stages | `/api/v1/pipelines` | -| Opportunities | `/api/v1/opportunities` (+ `/stage`, `/win`, `/lose`, stage-history) | -| Playbooks / forecasts / goals / targets / win-loss | `/api/v1/playbooks`, `/forecasts`, `/goals`, `/targets`, `/win-loss/*` | -| Activities | `/api/v1/activities` (+ `/complete`, participants, reminders) | -| Tasks / Meetings / Calls | `/api/v1/tasks`, `/meetings`, `/calls` | -| Timeline / Comments / Mentions | `/api/v1/timeline`, `/comments`, `/mentions/*` | -| Bookmarks / Team | `/api/v1/bookmarks`, `/favorites`, `/team-*`, `/collaboration-preferences/{user_id}` | -| Quotes | `/api/v1/quotes` | -| Lookups / tags / addresses / notes / attachments / custom fields / audit | `/api/v1/lookups/*`, `/tags`, `/addresses`, `/notes`, `/attachments`, `/custom-fields`, `/audit` | - -Phase docs: [crm-phase-6-0.md](../crm-phase-6-0.md) … [crm-phase-6-3.md](../crm-phase-6-3.md) - -## Future Services - -Each new module adds an API sheet using the template and links it here. - -## Related Documents - -- [Services Contracts](services-contracts.md) -- [Authorization Architecture](../architecture/authorization-architecture.md) -- [Event Catalog](event-catalog.md) +# API Reference + +Index of HTTP APIs. Detailed contracts live in [services-contracts.md](services-contracts.md). Template: [api-template.md](../templates/api-template.md). + +## Core Platform + +Base: Core service (`/api/v1`) + +| Area | Doc section | +| --- | --- | +| Health | `GET /health` | +| Tenants / Domains / Plans / Features / Subscription / Entitlement check | [services-contracts §5](services-contracts.md) | +| Onboarding & Tenant Context | [services-contracts §7](services-contracts.md) | +| OTP Auth | architecture identity + Core routers `/auth/otp/*` | +| Public tenant site | `GET /api/v1/public/tenant-site` (see contracts updates / Core routers) | + +## Identity & Access + +Base: Identity service (`/api/v1`) + +| Area | Doc section | +| --- | --- | +| OIDC BFF + mobile auth | [services-contracts §8](services-contracts.md) | + +## Accounting + +Base: Accounting service port `8002` (`/api/v1`) + +See service OpenAPI at `/docs` and [phases/Accounting](../phases/Accounting/README.md). + +## CRM + +Base: CRM service port `8003` (`/api/v1`) — public host `crm.torbatyar.ir` + +| Area | Paths | +| --- | --- | +| Leads | `/api/v1/leads` (+ assign/delete/restore) | +| Contacts | `/api/v1/contacts` | +| Organizations | `/api/v1/organizations` | +| Pipelines / stages | `/api/v1/pipelines` | +| Opportunities | `/api/v1/opportunities` (+ `/stage`, `/win`, `/lose`, stage-history) | +| Playbooks / forecasts / goals / targets / win-loss | `/api/v1/playbooks`, `/forecasts`, `/goals`, `/targets`, `/win-loss/*` | +| Activities | `/api/v1/activities` (+ `/complete`, participants, reminders) | +| Tasks / Meetings / Calls | `/api/v1/tasks`, `/meetings`, `/calls` | +| Timeline / Comments / Mentions | `/api/v1/timeline`, `/comments`, `/mentions/*` | +| Bookmarks / Team | `/api/v1/bookmarks`, `/favorites`, `/team-*`, `/collaboration-preferences/{user_id}` | +| Quotes | `/api/v1/quotes` | +| Lookups / tags / addresses / notes / attachments / custom fields / audit | `/api/v1/lookups/*`, `/tags`, `/addresses`, `/notes`, `/attachments`, `/custom-fields`, `/audit` | + +Phase docs: [crm-phase-6-0.md](../crm-phase-6-0.md) … [crm-phase-6-3.md](../crm-phase-6-3.md) + +## Loyalty + +Base: Loyalty service port `8004` (`/api/v1`) — public host `loyalty.torbatyar.ir` + +| Area | Paths | +| --- | --- | +| Programs | `/api/v1/programs` (+ soft delete) | +| Tiers | `/api/v1/tiers` (+ soft delete) | +| Members | `/api/v1/members` (+ `/enroll`, `/activate`, `/renew`, `/freeze`, `/resume`, `/cancel`, `/expire`, `/transfer`, `/lifecycle`, soft delete) | +| Point accounts | `/api/v1/point-accounts` (+ soft delete; no balance fields) | +| Rewards | `/api/v1/rewards` (+ soft delete) | +| Campaigns | `/api/v1/campaigns` (+ soft delete) | +| Audit | `/api/v1/audit` | + +Phase docs: [loyalty-phase-7-0.md](../loyalty-phase-7-0.md), [loyalty-phase-7-1.md](../loyalty-phase-7-1.md) + +## Future Services + +Each new module adds an API sheet using the template and links it here. + +## Related Documents + +- [Services Contracts](services-contracts.md) +- [Authorization Architecture](../architecture/authorization-architecture.md) +- [Event Catalog](event-catalog.md) diff --git a/docs/reference/database-schema.md b/docs/reference/database-schema.md index 25320ba..3d5807b 100644 --- a/docs/reference/database-schema.md +++ b/docs/reference/database-schema.md @@ -223,8 +223,14 @@ Migration: `0001_initial` در `backend/services/accounting/alembic/versions/`. ## بخش ۲ — طراحی اولیه دیتابیس سرویس‌های آینده (بدون migration) > accounting_db اکنون پیاده‌سازی شده — جزئیات در بخش ۴ بالا. -- **crm_db:** customers، leads، opportunities، pipelines، stages، tasks، - activities، automations. +> crm_db پیاده‌سازی شده — Phases 6.0–6.3. +> loyalty_db پیاده‌سازی شده — Phase 7.0 foundation: +> `loyalty_programs`, `membership_tiers`, `members`, `point_accounts`, +> `rewards`, `campaigns`, `loyalty_audit_logs`. +> communication_db پیاده‌سازی شده — Phase 8.0–8.10: +> `provider_configs`, `sender_numbers`, `message_templates`, `manual_contacts`, +> `contact_sources`, `messages`, `queue_items`, `delivery_events`, `provider_logs`, +> `otp_challenges`, `webhook_receipts`, `communication_audit_logs`. - **ecommerce_db:** products، categories، inventory، orders، order_items، payments، shipments، discounts، carts. - **website_builder_db:** sites، pages، blocks، forms، menus، media، content. @@ -237,7 +243,7 @@ Migration: `0001_initial` در `backend/services/accounting/alembic/versions/`. - **notification_db:** notifications، channels، templates، delivery_logs. - **file_storage_db:** files، buckets، access_policies. - **restaurant_db:** menus، menu_items، tables، orders، kitchen_tickets، - payments، loyalty. + payments (loyalty via `loyalty_db` / Loyalty service). هر سرویس دسترسی قابلیت‌ها را از Core (`check_feature_access`) استعلام می‌کند و هرگز مستقیماً به `core_platform_db` متصل نمی‌شود. diff --git a/docs/reference/event-catalog.md b/docs/reference/event-catalog.md index ce87d8b..f7a731c 100644 --- a/docs/reference/event-catalog.md +++ b/docs/reference/event-catalog.md @@ -1,86 +1,136 @@ -# Event Catalog - -Canonical event types. Envelope rules: [event-driven-architecture.md](../architecture/event-driven-architecture.md). - -## Core Platform - -| event_type | aggregate | Description | -| --- | --- | --- | -| `tenant.created` | tenant | New tenant created | -| `tenant.suspended` | tenant | Tenant suspended | -| `tenant.activated` | tenant | Tenant activated | -| `domain.created` | domain | Domain added | -| `subscription.created` | subscription | Subscription created | -| `subscription.updated` | subscription | Subscription changed | -| `feature_access.changed` | feature_access | Entitlement override/plan effect changed | - -## Identity & Access - -| event_type | Description | -| --- | --- | -| `user.registered` | New user registered | -| `tenant_member.added` | Member added (identity layer) | -| `tenant_member.removed` | Member removed (identity layer) | - -## Accounting (Phases 5.1–5.6) - -| event_type | Description | -| --- | --- | -| `voucher.created` | Voucher drafted | -| `voucher.validated` | Voucher passed validation | -| `voucher.posted` | Voucher posted to ledger | -| `voucher.reversed` | Voucher reversed | -| `journal_entry.created` | Journal entry created via Posting Engine | -| `posting.completed` | Posting operation succeeded | -| `ledger.updated` | General ledger updated | -| `trial_balance.generated` | Trial balance snapshot created | -| `fiscal_period.locked` | Fiscal period locked | -| `cash.received` | Cash receipt recorded | -| `settlement.completed` | AR/AP settlement completed | -| `sales_invoice.posted` | Sales invoice accounting posted | -| `revenue.recognized` | Revenue recognition recorded | - -Full list: `backend/services/accounting/app/events/types.py` - -## CRM (Phases 6.0–6.1) - -| event_type | Description | -| --- | --- | -| `crm.lead.created` | Lead created | -| `crm.lead.updated` | Lead updated | -| `crm.contact.created` | Contact created | -| `crm.organization.created` | Organization created | -| `crm.opportunity.created` | Opportunity created | -| `crm.opportunity.updated` | Opportunity updated | -| `crm.opportunity.won` | Opportunity marked won | -| `crm.opportunity.lost` | Opportunity marked lost | -| `crm.pipeline.changed` | Pipeline updated | -| `crm.stage.changed` | Opportunity stage changed | -| `crm.playbook.assigned` | Sales playbook assigned | -| `crm.playbook.completed` | Playbook assignment completed | -| `crm.forecast.updated` | Sales forecast computed/updated | -| `crm.activity.completed` | Sales activity completed | -| `crm.task.completed` | Sales task completed | -| `crm.meeting.finished` | Meeting finished | -| `crm.call.logged` | Call logged against opportunity | -| `crm.timeline.updated` | Sales timeline entry appended | -| `crm.comment.created` | Collaboration comment created | -| `crm.mention.created` | User mentioned in CRM collaboration | -| `crm.quote.created` | Sales quote created | -| `crm.note.created` | CRM note created | -| `crm.attachment.added` | Attachment reference added | - -Full list: `backend/services/crm/app/events/types.py` - -## Future (reserved) -| Restaurant | `order.placed`, `order.completed` | -| Ecommerce | `order.placed`, `product.updated` | -| Notification | `notification.delivered` | - -When adding events: update this catalog, module registry producers/consumers, and contracts in the same phase. - -## Related Documents - -- [Services Contracts](services-contracts.md) -- [ADR-006](../architecture/adr/ADR-006.md) -- [Module Registry](../module-registry.md) +# Event Catalog + +Canonical event types. Envelope rules: [event-driven-architecture.md](../architecture/event-driven-architecture.md). + +## Core Platform + +| event_type | aggregate | Description | +| --- | --- | --- | +| `tenant.created` | tenant | New tenant created | +| `tenant.suspended` | tenant | Tenant suspended | +| `tenant.activated` | tenant | Tenant activated | +| `domain.created` | domain | Domain added | +| `subscription.created` | subscription | Subscription created | +| `subscription.updated` | subscription | Subscription changed | +| `feature_access.changed` | feature_access | Entitlement override/plan effect changed | + +## Identity & Access + +| event_type | Description | +| --- | --- | +| `user.registered` | New user registered | +| `tenant_member.added` | Member added (identity layer) | +| `tenant_member.removed` | Member removed (identity layer) | + +## Accounting (Phases 5.1–5.6) + +| event_type | Description | +| --- | --- | +| `voucher.created` | Voucher drafted | +| `voucher.validated` | Voucher passed validation | +| `voucher.posted` | Voucher posted to ledger | +| `voucher.reversed` | Voucher reversed | +| `journal_entry.created` | Journal entry created via Posting Engine | +| `posting.completed` | Posting operation succeeded | +| `ledger.updated` | General ledger updated | +| `trial_balance.generated` | Trial balance snapshot created | +| `fiscal_period.locked` | Fiscal period locked | +| `cash.received` | Cash receipt recorded | +| `settlement.completed` | AR/AP settlement completed | +| `sales_invoice.posted` | Sales invoice accounting posted | +| `revenue.recognized` | Revenue recognition recorded | + +Full list: `backend/services/accounting/app/events/types.py` + +## CRM (Phases 6.0–6.1) + +| event_type | Description | +| --- | --- | +| `crm.lead.created` | Lead created | +| `crm.lead.updated` | Lead updated | +| `crm.contact.created` | Contact created | +| `crm.organization.created` | Organization created | +| `crm.opportunity.created` | Opportunity created | +| `crm.opportunity.updated` | Opportunity updated | +| `crm.opportunity.won` | Opportunity marked won | +| `crm.opportunity.lost` | Opportunity marked lost | +| `crm.pipeline.changed` | Pipeline updated | +| `crm.stage.changed` | Opportunity stage changed | +| `crm.playbook.assigned` | Sales playbook assigned | +| `crm.playbook.completed` | Playbook assignment completed | +| `crm.forecast.updated` | Sales forecast computed/updated | +| `crm.activity.completed` | Sales activity completed | +| `crm.task.completed` | Sales task completed | +| `crm.meeting.finished` | Meeting finished | +| `crm.call.logged` | Call logged against opportunity | +| `crm.timeline.updated` | Sales timeline entry appended | +| `crm.comment.created` | Collaboration comment created | +| `crm.mention.created` | User mentioned in CRM collaboration | +| `crm.quote.created` | Sales quote created | +| `crm.note.created` | CRM note created | +| `crm.attachment.added` | Attachment reference added | + +Full list: `backend/services/crm/app/events/types.py` + +## Loyalty (Phase 7.0) + +| event_type | Description | +| --- | --- | +| `loyalty.program.created` | Loyalty program created | +| `loyalty.program.updated` | Loyalty program updated | +| `loyalty.program.deleted` | Loyalty program soft-deleted | +| `loyalty.tier.created` | Membership tier created | +| `loyalty.tier.updated` | Membership tier updated | +| `loyalty.tier.deleted` | Membership tier soft-deleted | +| `loyalty.member.created` | Member created | +| `loyalty.member.updated` | Member updated | +| `loyalty.member.enrolled` | Member enrolled / activated | +| `loyalty.member.activated` | Member activated with term/expiry | +| `loyalty.member.renewed` | Membership renewed | +| `loyalty.member.frozen` | Membership frozen | +| `loyalty.member.resumed` | Membership resumed from freeze | +| `loyalty.member.cancelled` | Membership cancelled | +| `loyalty.member.expired` | Membership expired | +| `loyalty.member.transferred` | Membership transferred to another program | +| `loyalty.member.deleted` | Member soft-deleted | +| `loyalty.point_account.opened` | Point account opened | +| `loyalty.point_account.updated` | Point account updated | +| `loyalty.point_account.deleted` | Point account soft-deleted | +| `loyalty.reward.created` | Reward catalog item created | +| `loyalty.reward.updated` | Reward catalog item updated | +| `loyalty.reward.deleted` | Reward soft-deleted | +| `loyalty.campaign.created` | Campaign created | +| `loyalty.campaign.updated` | Campaign updated | +| `loyalty.campaign.deleted` | Campaign soft-deleted | + +Full list: `backend/services/loyalty/app/events/types.py` + +## Communication (Phase 8) + +| event_type | Description | +| --- | --- | +| `communication.message.queued` | Message enqueued | +| `communication.message.sent` | Accepted by provider | +| `communication.message.delivered` | Delivery confirmed | +| `communication.message.failed` | Terminal failure | +| `communication.message.cancelled` | Cancelled | +| `communication.provider.failover` | Provider failover | +| `communication.otp.generated` | OTP issued | +| `communication.otp.verified` | OTP verified | +| `communication.queue.dead_letter` | Queue DLQ | +| `communication.webhook.received` | Inbound webhook | + +Full list: `backend/services/communication/app/events/types.py` + +## Future (reserved) +| Restaurant | `order.placed`, `order.completed` | +| Ecommerce | `order.placed`, `product.updated` | +| Notification | `notification.delivered` (prefer Communication delivery events) | + +When adding events: update this catalog, module registry producers/consumers, and contracts in the same phase. + +## Related Documents + +- [Services Contracts](services-contracts.md) +- [ADR-006](../architecture/adr/ADR-006.md) +- [Module Registry](../module-registry.md) diff --git a/docs/roadmap.md b/docs/roadmap.md index 250bdd8..9598e4e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,10 +16,18 @@ 3. ~~CRM Core Business Entities~~ (Phase 6.1 done) 4. ~~CRM Enterprise Sales Process Engine~~ (Phase 6.2 done) 5. ~~CRM Enterprise Sales Collaboration~~ (Phase 6.3 done — CRM Core Platform) -6. Further CRM / cross-module work only when explicitly scoped -5. Notification service as central fanout -6. File storage + S3 provider -7. Payment gateway for subscriptions and commerce +8. ~~Loyalty Service Foundation~~ (Phase 7.0 done) +9. ~~Loyalty Membership Engine~~ (Phase 7.1 done) +10. Loyalty Point Engine (Phase 7.2) +11. ~~Enterprise Communication Platform~~ (Phase 8.0–8.10 done) +12. ~~Sports Center Platform Foundation~~ (Phase 9.0 done) +13. ~~Sports Center Membership Catalog~~ (Phase 9.1 done) +14. ~~Sports Center Member Management~~ (Phase 9.2 done) +15. Sports Center Coach & Staff Management (Phase 9.3) +16. Further CRM / cross-module work only when explicitly scoped +17. Notification service as thin consumer of Communication (or merge later) +18. File storage + S3 provider +19. Payment gateway for subscriptions and commerce ## Longer Term @@ -34,14 +42,23 @@ - [Accounting](phases/Accounting/README.md) - [CRM](phases/CRM/README.md) +- [Loyalty](phases/Loyalty/README.md) - [Restaurant](phases/Restaurant/README.md) +- [Sports Center](phases/SportsCenter/README.md) +- [Sports Center Roadmap](sports-center-roadmap.md) - [Marketplace](phases/Marketplace/README.md) - [Automation](phases/Automation/README.md) - [AI](phases/AI/README.md) - [Future](phases/Future/README.md) +Implementation of every future phase must follow the [AI Development Framework](ai-framework/README.md). + ## Related Documents - [Module Registry](module-registry.md) - [Provider Registry](provider-registry.md) - [Architecture Overview](architecture/architecture.md) +- [AI Development Framework](ai-framework/README.md) +- [Phase Manifest](ai-framework/phase-manifest.yaml) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [ADR-014](architecture/adr/ADR-014.md) diff --git a/docs/sports-center-phase-9-0.md b/docs/sports-center-phase-9-0.md new file mode 100644 index 0000000..47f23a6 --- /dev/null +++ b/docs/sports-center-phase-9-0.md @@ -0,0 +1,148 @@ +# Phase 9.0 — Sports Center Platform Foundation + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | sports-center | +| Version | 0.9.0.0 | +| Database | `sports_center_db` | +| API Port | 8006 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-014 | + +## Goal + +Establish the **Sports Center Platform** as a completely independent enterprise microservice foundation. + +This is **not** a Gym Management system. It is a generic sports platform capable of supporting Gym, CrossFit, Yoga, Pilates, Swimming, Football Academy, Basketball/Volleyball clubs, Martial Arts, Dance, Cycling, Climbing, Tennis, Skating, and future sports — without hardcoding sport-specific business rules. + +## Sports Center Responsibilities (Phase 9.0) + +Owns foundation aggregates only: + +- sports_centers, branches, sports +- membership_types, memberships +- coaches, sports_roles, sports_permissions +- facilities, courts, rooms, locker_rooms, lockers +- devices, device_providers, attendance_gateways +- sports_configuration, sports_events, sports_settings, sports_audit + +Plus adapter-based connector interfaces (QR, RFID, Barcode, Fingerprint, Face Recognition, Turnstile, Door Controllers, Payment Terminals, Attendance Devices). + +**Does not** implement business workflows yet (booking, attendance engine, billing, class scheduling). + +## Service Boundaries + +| Sports Center owns | Sports Center does not own | +| --- | --- | +| Aggregates above | Accounting / Posting Engine | +| Sports HTTP APIs under `/api/v1/*` | CRM sales entities | +| `sports_center.*` permissions | Loyalty ledger / points | +| Publish-only sports events | Communication / SMS providers | +| Connector **interfaces** | Notification delivery | +| Tenant sports configuration | Identity / Storage blobs / AI inference | + +Communication with platform services is **API + Events only**. No cross-DB access. + +## Owned Modules (aggregates) + +Independent aggregates in `backend/services/sports_center/app/models/foundation.py` (UUID refs only, no ORM relationship graphs). + +## External Platform Dependencies (contracts only) + +Defined in `app/providers/contracts.py` — **no implementations**: + +- AccountingProvider, CRMProvider, LoyaltyProvider +- CommunicationProvider, NotificationProvider, StorageProvider +- AIProvider, IdentityProvider, Customer360Provider + +## Connector Framework + +`app/connectors/` provides Protocol interfaces + `ConnectorRegistry`. Vendor adapters register by `adapter_key`. Business services never embed vendor SDKs. + +## Published Events + +| Event | Aggregate | +| --- | --- | +| `sports_center.member.created` | membership (member identity) | +| `sports_center.membership.created` | membership | +| `sports_center.coach.created` | coach | +| `sports_center.facility.created` | facility | +| `sports_center.device.connected` / `disconnected` | device | +| `sports_center.attendance.provider.changed` | attendance_gateway | +| `sports_center.locker.assigned` / `released` | locker | + +Additional supporting foundation events for centers, branches, sports, configuration, settings. + +## Configuration Support + +`sports_configurations` stores tenant/branch configuration shells for: + +Working Hours · Membership Policies · Attendance Policies · Booking Policies · Waiting List · Custom Fields · Time Zone · Language · Currency + +## API Contracts (foundation) + +| Resource | Prefix | +| --- | --- | +| Sports centers | `/api/v1/sports-centers` | +| Branches | `/api/v1/branches` | +| Sports catalog | `/api/v1/sports` | +| Membership types / memberships | `/api/v1/membership-types`, `/api/v1/memberships` | +| Coaches / roles / permissions | `/api/v1/coaches`, `/roles`, `/permissions` | +| Facilities / courts / rooms / lockers | `/api/v1/facilities`, `/courts`, `/rooms`, `/locker-rooms`, `/lockers` | +| Devices / providers / gateways | `/api/v1/devices`, `/device-providers`, `/attendance-gateways` | +| Configuration / events / settings | `/api/v1/configurations`, `/events`, `/settings` | +| Health | `/health` | + +Special actions: `POST /devices/{id}/connect|disconnect`, `POST /lockers/{id}/assign|release`. + +## Permissions + +`sports_center.*` trees covering centers, branches, sports, memberships, coaches, facilities, devices, configuration, audit, etc. + +## Architecture Decisions + +1. Database-per-service (`sports_center_db`) — ADR-001 / ADR-014 +2. Row-level `tenant_id` — ADR-003 +3. Event publish contracts via `EventEnvelope` — ADR-006 +4. Sport-agnostic catalog model — no hardcoded sport engines +5. Adapter-based connectors — vendor logic outside business services +6. Soft delete + actor audit + sports audit log +7. Optimistic locking on centers, memberships, lockers, devices, configurations + +## Folder Structure + +``` +backend/services/sports_center/ + app/ + api/v1/ + core/ + middlewares/ + models/ + repositories/ + services/ + validators/ + schemas/ + events/ + permissions/ + providers/ + connectors/ + policies/ + specifications/ + commands/ + queries/ + tests/ + alembic/versions/0001_initial.py + scripts/ensure_db.py + README.md +``` + +## Tests Executed + +Architecture · API foundation flow · permissions · migration · dependency · connectors · docs + +## Related Documents + +- [Phase Handover 9.0](phase-handover/phase-9-0.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [Module Registry](module-registry.md) +- [Progress](progress.md) diff --git a/docs/sports-center-phase-9-1.md b/docs/sports-center-phase-9-1.md new file mode 100644 index 0000000..100dfcb --- /dev/null +++ b/docs/sports-center-phase-9-1.md @@ -0,0 +1,91 @@ +# Phase 9.1 — Sports Center Membership Catalog + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | sports_center (Membership Catalog) | +| Version | 0.9.1.0 | +| Database | `sports_center_db` | +| API Port | 8006 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-014 | +| Previous | [Phase 9.0](sports-center-phase-9-0.md) · [Handover 9.0](phase-handover/phase-9-0.md) | + +## Goal + +Deliver the **Membership Catalog** for the independent Sports Center Platform: commercial and policy definitions that membership assignments (Phase 9.2) will consume. + +This phase does **not** implement member enrollment workflows, cards/QR issuance, coach engines, attendance, or booking. + +## Scope Completed + +| Area | Deliverable | +| --- | --- | +| Membership Types | Extended with catalog links (package/plan/pricing/age/category/policies), transferability, sort order | +| Packages | `membership_packages` aggregate + APIs | +| Plans | `membership_plans` aggregate + APIs | +| Pricing Models | `pricing_models` (fixed/recurring/tiered/usage/custom) — amounts are catalog config, not Accounting journals | +| Age Groups | `age_groups` with min/max validation | +| Sport Categories | `sport_categories` catalog grouping | +| Membership Rules | Declarative `membership_rules` (eligibility/access/usage/transfer/custom) | +| Renewal Policies | `renewal_policies` | +| Freezing Rules | `freezing_rules` | +| Expiration Policies | `expiration_policies` | +| Validation | Age range, money amount, pricing/billing/rule enums, catalog ref integrity | +| Events | `sports_center.{sport_category,age_group,pricing_model,membership_package,membership_plan,membership_rule,renewal_policy,freezing_rule,expiration_policy,membership_type}.*` | +| APIs | Catalog routes under `/api/v1/*` + `/capabilities` | +| Docs | This file + handover + registries/manifests | + +## Out of Scope + +- Member / family / medical / card / waiver management (Phase 9.2) +- Coach & staff engines (Phase 9.3) +- Scheduling & booking (Phase 9.4) +- Attendance engine (Phase 9.5) +- Accounting postings / payment capture (Phase 9.8) +- AI analytics (Phase 9.9) + +## Architecture Decisions + +1. Catalog entities are independent aggregates with UUID refs only (no ORM relationship graphs). +2. Pricing amounts live in Sports Center as **configuration**; journals remain Accounting-only (ADR-010). +3. MembershipType remains the primary commercial product SKU; packages/plans/policies attach via optional refs. +4. Backward compatible: new MembershipType columns are nullable / defaulted; existing 9.0 APIs remain valid. + +## Public APIs (new / extended) + +| Resource | Prefix | +| --- | --- | +| Sport categories | `/api/v1/sport-categories` | +| Age groups | `/api/v1/age-groups` | +| Pricing models | `/api/v1/pricing-models` | +| Membership packages | `/api/v1/membership-packages` | +| Membership plans | `/api/v1/membership-plans` | +| Membership rules | `/api/v1/membership-rules` | +| Renewal policies | `/api/v1/renewal-policies` | +| Freezing rules | `/api/v1/freezing-rules` | +| Expiration policies | `/api/v1/expiration-policies` | +| Membership types | `/api/v1/membership-types` (extended fields) | +| Capabilities | `/capabilities` | + +## Permissions + +New trees: `sports_center.sport_categories.*`, `age_groups.*`, `pricing_models.*`, `membership_packages.*`, `membership_plans.*`, `membership_rules.*`, `renewal_policies.*`, `freezing_rules.*`, `expiration_policies.*`. + +## Migration + +| Revision | Description | +| --- | --- | +| `0002_phase_91_membership_catalog` | Catalog tables + MembershipType additive columns | + +## Tests + +Architecture, catalog API happy path, tenant isolation, age validation, capabilities, docs presence, permissions/events. + +## Related Documents + +- [Handover 9.1](phase-handover/phase-9-1.md) +- [Sports Center Roadmap](sports-center-roadmap.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [Module Registry](module-registry.md#sports_center) +- [AI Framework](ai-framework/README.md) +- [Phase Manifest](ai-framework/phase-manifest.yaml) diff --git a/docs/sports-center-phase-9-2.md b/docs/sports-center-phase-9-2.md new file mode 100644 index 0000000..99e40e1 --- /dev/null +++ b/docs/sports-center-phase-9-2.md @@ -0,0 +1,86 @@ +# Phase 9.2 — Sports Center Member Management + +| Field | Value | +| --- | --- | +| Status | Complete | +| Module | sports-center | +| Version | 0.9.2.0 | +| Database | `sports_center_db` | +| API Port | 8006 | +| ADR | ADR-001, ADR-003, ADR-006, ADR-014 | + +## Goal + +Deliver **Member Management** on the independent Sports Center Platform: sports members, family links, medical shells, emergency contacts, membership assignment (consuming the Phase 9.1 catalog), cards/QR/digital passes, waivers, and document attachments — without coach/attendance/booking engines. + +## Scope Completed + +- Separate `Member` aggregate (distinct from Platform Member and Loyalty Member) +- Family members, emergency contacts, medical information (privacy-aware; Storage file refs only) +- Membership assignment via `member_id` + status transitions (activate/suspend/cancel/freeze/unfreeze) +- Membership cards (physical/digital/QR), digital membership passes, waivers (sign), member documents +- Validators, permissions, publish-only events, Alembic `0003_phase_92_member_management` +- `/capabilities` advertises `member_management: true` (version `0.9.2.0`) + +## Out of Scope + +- Coach & staff engines (9.3) +- Scheduling & booking (9.4) +- Attendance & access control engines (9.5) +- Accounting postings / payment capture +- Binary file storage (refs only) +- Recreating Membership Catalog tables (9.1) + +## Owned Aggregates + +| Aggregate | Table | +| --- | --- | +| Member | `members` | +| FamilyMember | `family_members` | +| EmergencyContact | `emergency_contacts` | +| MedicalInformation | `medical_information` | +| MembershipCard | `membership_cards` | +| DigitalMembership | `digital_memberships` | +| Waiver | `waivers` | +| MemberDocument | `member_documents` | + +Additive on `memberships`: `member_id`, `freeze_starts_on`, `freeze_ends_on`; status enum adds `frozen`. + +## APIs + +| Resource | Prefix | +| --- | --- | +| Members | `/api/v1/members` | +| Family members | `/api/v1/family-members` | +| Emergency contacts | `/api/v1/emergency-contacts` | +| Medical | `/api/v1/medical-information` | +| Cards | `/api/v1/membership-cards` | +| Digital | `/api/v1/digital-memberships` | +| Waivers | `/api/v1/waivers` (+ `/{id}/sign`) | +| Documents | `/api/v1/member-documents` | +| Membership status | `/api/v1/memberships/{id}/activate|suspend|cancel|freeze|unfreeze|assign-member` | + +## Events + +`sports_center.member.updated`, `membership.assigned`, `membership.status_changed`, `membership.frozen`, `membership.unfrozen`, `family_member.created`, `emergency_contact.created`, `medical_information.upserted`, `membership_card.issued|revoked`, `digital_membership.issued`, `waiver.created|signed`, `member_document.attached`. + +## Permissions + +`sports_center.members.*`, `family_members.*`, `emergency_contacts.*`, `medical.*`, `membership_cards.*`, `digital_memberships.*`, `waivers.*`, `member_documents.*`, plus `memberships.assign` / `memberships.status`. + +## Migration + +| Revision | Description | +| --- | --- | +| `0003_phase_92_member_management` | Member tables + additive membership columns | + +## Tests + +`test_members.py` (happy path, assign/isolation, invalid freeze transition, capabilities) + architecture/migration/docs updates. + +## Related Documents + +- [Handover 9.2](phase-handover/phase-9-2.md) +- [Phase 9.1](sports-center-phase-9-1.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [Module Registry](module-registry.md#sports_center) diff --git a/docs/sports-center-roadmap.md b/docs/sports-center-roadmap.md new file mode 100644 index 0000000..6c8a37e --- /dev/null +++ b/docs/sports-center-roadmap.md @@ -0,0 +1,131 @@ +# Sports Center Platform — Roadmap + +> Registration document. **No implementation in this phase.** +> Framework: [ai-framework/](ai-framework/README.md) · ADR: [ADR-014](architecture/adr/ADR-014.md) · Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) + +| Field | Value | +| --- | --- | +| Service | `sports_center` | +| Database | `sports_center_db` | +| Permission Prefix | `sports_center.*` | +| Phases | 9.0 – 9.10 | +| Status | Phase 9.2 Member Management complete; next 9.3 Coach & Staff Management | + +## Vision + +Deliver an independent, multi-tenant **Sports Center Platform** inside TorbatYar so clubs, gyms, and facilities can manage members, memberships, coaches, attendance/access, bookings, training, competitions, and integrations — without owning Accounting, CRM, Loyalty, or Communication. + +## Business Scope + +In scope (over Phases 9.0–9.10): + +- Sports members and membership lifecycle (plans, packages, renewal, freeze, transfer) +- Coaches / trainers and assignments +- Attendance and access control (including access devices) +- Booking and scheduling (facilities, courts, sessions, equipment) +- Training programs and workouts +- Competitions and sports events +- Medical / nutrition / locker / mobile as planned modules (depth per phase scope) +- Reports and analytics shells +- Integrations with Accounting, CRM, Loyalty, Communication via API/Events + +## Architecture Scope + +| Rule | Reference | +| --- | --- | +| Independent service + `sports_center_db` | [ADR-014](architecture/adr/ADR-014.md), [ADR-001](architecture/adr/ADR-001.md) | +| Layering API → Service → Repository → Model | [service-architecture.md](architecture/service-architecture.md) | +| Tenant-aware business tables | [ADR-003](architecture/adr/ADR-003.md) | +| Outbox-ready events `sports_center.*` | [ADR-006](architecture/adr/ADR-006.md) | +| No journal ownership | [ADR-010](architecture/adr/ADR-010.md) | +| Messaging via Communication only | [ADR-012](architecture/adr/ADR-012.md) | +| Loyalty via Loyalty service only | [ADR-011](architecture/adr/ADR-011.md) | +| AI optional / independent | [ai-architecture.md](architecture/ai-architecture.md) | +| Implementation via AI Framework | [ai-framework/](ai-framework/README.md) | + +## Module Map + +| Module | Responsibility | Introduced | +| --- | --- | --- | +| Members | Sports member profiles and identity refs | 9.2 | +| Membership | Active membership instances / lifecycle | 9.2 | +| Membership Types | Plans, packages, commercial definitions | 9.1 | +| Packages / Plans / Pricing | Catalog commercial definitions | 9.1 | +| Age Groups / Sport Categories | Catalog eligibility grouping | 9.1 | +| Membership Rules / Policies | Renewal, freeze, expiration, rules | 9.1 | +| Coaches | Coaches / trainers / staff | 9.3 | +| Attendance | Check-in/out and attendance records | 9.5 | +| Booking | Reservations for facilities/sessions/equipment | 9.4 | +| Facilities | Buildings, rooms, courts, zones | 9.0 / 9.4 | +| Equipment | Bookable / trackable equipment | 9.4 | +| Programs | Training programs | 9.6 | +| Workouts | Workout definitions and assignments | 9.6 | +| Competitions | Competition definitions / results shells | 9.7 | +| Events | Sports calendar events | 9.7 | +| Medical | Clearance / note refs (privacy-aware) | 9.2+ | +| Nutrition | Nutrition plan shells | Later | +| Locker | Locker assignment status | 9.0 | +| Mobile | Mobile API contracts / deep links | Cross-cutting | +| Reports | Operational reports | 9.9 | +| Analytics | Metrics export shells | 9.9 | +| Integrations | CRM / Loyalty / Communication / Accounting adapters | 9.8 | + +Canonical inventory: [module-registry.md](module-registry.md#sports_center). + +## Integration Map + +``` +Sports Center ──API/Events──▶ Accounting (payments → postings via Posting Engine) +Sports Center ──API/Events──▶ CRM (lead/contact refs only) +Sports Center ──API/Events──▶ Loyalty (enrollment / points refs) +Sports Center ──API/Events──▶ Communication (SMS/OTP/notifications) +Sports Center ──API─────────▶ Core (entitlement, tenant) +``` + +Forbidden: cross-DB queries, embedding providers inside Sports Center for SMS, creating JournalEntry locally. + +## Phase Map + +| Phase | ID | Name | Status | +| --- | --- | --- | --- | +| 9.0 | `sports-center-9.0` | Foundation | Complete | +| 9.1 | `sports-center-9.1` | Membership Catalog | Complete | +| 9.2 | `sports-center-9.2` | Member Management | Complete | +| 9.3 | `sports-center-9.3` | Coach & Staff Management | Planned | +| 9.4 | `sports-center-9.4` | Scheduling & Booking | Planned | +| 9.5 | `sports-center-9.5` | Attendance & Access Control | Planned | +| 9.6 | `sports-center-9.6` | Training Management | Planned | +| 9.7 | `sports-center-9.7` | Competition & Event Management | Planned | +| 9.8 | `sports-center-9.8` | Financial Integration | Planned | +| 9.9 | `sports-center-9.9` | AI & Analytics | Planned | +| 9.10 | `sports-center-9.10` | Enterprise Validation | Planned | + +Details (dependencies, required docs/services, completion criteria): [phase-manifest.yaml](ai-framework/phase-manifest.yaml). + +## Future Expansion + +- Wearable / IoT access device adapters +- Multi-branch franchise hierarchies (still tenant-scoped) +- Academy / youth pathways as related modules without absorbing Academy service ownership +- Deeper medical compliance connectors when regulated + +## Out of Scope + +- Owning Accounting ledgers or Posting Engine +- Owning Sales CRM aggregates +- Owning Loyalty ledger / campaigns +- Owning SMS/email providers (Communication owns them) +- Replacing Core tenant membership (platform Member ≠ Sports Member) +- Implementing Phase 9.x code in the registration phase +- Restaurant / Ecommerce / Marketplace domains + +## Related Documents + +- [Phase Area README](phases/SportsCenter/README.md) +- [ADR-014](architecture/adr/ADR-014.md) +- [Module Registry](module-registry.md#sports_center) +- [Glossary — Sports Center](glossary.md#sports-center) +- [Service Manifest](ai-framework/service-manifest.yaml) +- [Progress](progress.md) +- [Roadmap](roadmap.md) +- [Next Steps](next-steps.md) diff --git a/infrastructure/deploy/.env.production.example b/infrastructure/deploy/.env.production.example index 6c85680..6e56299 100644 --- a/infrastructure/deploy/.env.production.example +++ b/infrastructure/deploy/.env.production.example @@ -1,101 +1,106 @@ -# ============================================================ -# TorbatYar production env — copy to .env on app server -# Domain: torbatyar.ir | App: 192.168.10.162 | Nginx: 192.168.10.156 -# ============================================================ - -ENVIRONMENT=production -DEBUG=false -LOG_LEVEL=INFO -SERVICE_NAME=core-service -API_V1_PREFIX=/api/v1 - -PLATFORM_NAME=Torbatyar -PLATFORM_SUPPORT_EMAIL=support@torbatyar.ir -PLATFORM_PRIMARY_COLOR=#0284c7 -PLATFORM_SECONDARY_COLOR=#0f172a -PLATFORM_BASE_DOMAIN=torbatyar.ir - -# Auto SSL for tenant domains (Celery → SSH → nginx certbot) -SSL_PROVISION_ENABLED=true -SSL_PROVISION_HOST=192.168.10.156 -SSL_PROVISION_USER=torbatyaruser -SSL_PROVISION_PASSWORD=change-me -SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py - -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -POSTGRES_USER=superapp -POSTGRES_PASSWORD=superapp_password -POSTGRES_DB=core_platform_db -DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db -DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db - -REDIS_HOST=redis -REDIS_PORT=6379 -REDIS_DB=0 -REDIS_URL=redis://redis:6379/0 -FEATURE_ACCESS_CACHE_TTL=300 -TENANT_METADATA_CACHE_TTL=300 - -CELERY_BROKER_URL=redis://redis:6379/1 -CELERY_RESULT_BACKEND=redis://redis:6379/2 - -KEYCLOAK_ENABLED=true -KEYCLOAK_SERVER_URL=http://keycloak:8080 -KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir -KEYCLOAK_REALM=superapp -KEYCLOAK_CLIENT_ID=core-service -KEYCLOAK_CLIENT_SECRET=change-me -JWT_ALGORITHM=RS256 -JWT_AUDIENCE=account -JWT_VERIFY_SIGNATURE=true -AUTH_REQUIRED=true - -IDENTITY_SERVICE_URL=http://identity-access-service:8001 -IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db -IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db -KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend -KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity -IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service -FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback -CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir -CORE_SERVICE_URL=http://core-service:8000 - -PAYAMAK_USERNAME=9155105404 -PAYAMAK_FROM=9982004961 -PAYAMAK_FROM_NUMBER=9982004961 -PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f -PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f -PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f -PAYAMAK_BODY_ID=245189 -JWT_SECRET=change-me-jwt-secret -OTP_EXPIRE_SECONDS=120 -PLATFORM_ADMIN_MOBILES=09155105404 - -FRONTEND_PORT=3000 -NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir -NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir -NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir -NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir -NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir -NEXT_PUBLIC_KEYCLOAK_REALM=superapp -NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend -NEXT_PUBLIC_ACCOUNTING_API_URL=https://accounting.torbatyar.ir -NEXT_PUBLIC_CRM_API_URL=https://crm.torbatyar.ir - -ACCOUNTING_SERVICE_URL=http://accounting-service:8002 -ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db -ACCOUNTING_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db -ACCOUNTING_SERVICE_NAME=accounting-service - -CRM_SERVICE_URL=http://crm-service:8003 -CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db -CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db -CRM_SERVICE_NAME=crm-service -KEYCLOAK_SERVER_URL=http://keycloak:8080 -KEYCLOAK_REALM=superapp - -INTERNAL_TOKEN_SECRET=change-me-internal-secret - -KEYCLOAK_ADMIN=admin -KEYCLOAK_ADMIN_PASSWORD=admin +# ============================================================ +# TorbatYar production env — copy to .env on app server +# Domain: torbatyar.ir | App: 192.168.10.162 | Nginx: 192.168.10.156 +# ============================================================ + +ENVIRONMENT=production +DEBUG=false +LOG_LEVEL=INFO +SERVICE_NAME=core-service +API_V1_PREFIX=/api/v1 + +PLATFORM_NAME=Torbatyar +PLATFORM_SUPPORT_EMAIL=support@torbatyar.ir +PLATFORM_PRIMARY_COLOR=#0284c7 +PLATFORM_SECONDARY_COLOR=#0f172a +PLATFORM_BASE_DOMAIN=torbatyar.ir + +# Auto SSL for tenant domains (Celery → SSH → nginx certbot) +SSL_PROVISION_ENABLED=true +SSL_PROVISION_HOST=192.168.10.156 +SSL_PROVISION_USER=torbatyaruser +SSL_PROVISION_PASSWORD=change-me +SSL_PROVISION_SCRIPT=/opt/torbatyar/bin/provision_ssl.py + +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_USER=superapp +POSTGRES_PASSWORD=superapp_password +POSTGRES_DB=core_platform_db +DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db +DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_DB=0 +REDIS_URL=redis://redis:6379/0 +FEATURE_ACCESS_CACHE_TTL=300 +TENANT_METADATA_CACHE_TTL=300 + +CELERY_BROKER_URL=redis://redis:6379/1 +CELERY_RESULT_BACKEND=redis://redis:6379/2 + +KEYCLOAK_ENABLED=true +KEYCLOAK_SERVER_URL=http://keycloak:8080 +KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir +KEYCLOAK_REALM=superapp +KEYCLOAK_CLIENT_ID=core-service +KEYCLOAK_CLIENT_SECRET=change-me +JWT_ALGORITHM=RS256 +JWT_AUDIENCE=account +JWT_VERIFY_SIGNATURE=true +AUTH_REQUIRED=true + +IDENTITY_SERVICE_URL=http://identity-access-service:8001 +IDENTITY_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/identity_access_db +IDENTITY_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/identity_access_db +KEYCLOAK_FRONTEND_CLIENT_ID=superapp-frontend +KEYCLOAK_IDENTITY_CLIENT_SECRET=change-me-identity +IDENTITY_KEYCLOAK_CLIENT_ID=identity-access-service +FRONTEND_CALLBACK_URL=https://torbatyar.ir/auth/callback +CORS_ORIGINS=https://torbatyar.ir,https://www.torbatyar.ir,http://torbatyar.ir,http://www.torbatyar.ir +CORE_SERVICE_URL=http://core-service:8000 + +PAYAMAK_USERNAME=9155105404 +PAYAMAK_FROM=9982004961 +PAYAMAK_FROM_NUMBER=9982004961 +PAYAMAK_API_KEY=967ceb37-eeef-429c-b6c5-b60a15306e4f +PAYAMAK_APIKEY=967ceb37-eeef-429c-b6c5-b60a15306e4f +PAYAMAK_PASSWORD=967ceb37-eeef-429c-b6c5-b60a15306e4f +PAYAMAK_BODY_ID=245189 +JWT_SECRET=change-me-jwt-secret +OTP_EXPIRE_SECONDS=120 +PLATFORM_ADMIN_MOBILES=09155105404 + +FRONTEND_PORT=3000 +NEXT_PUBLIC_PLATFORM_BASE_DOMAIN=torbatyar.ir +NEXT_PUBLIC_BACKEND_URL=https://api.torbatyar.ir +NEXT_PUBLIC_API_BASE_URL=https://api.torbatyar.ir +NEXT_PUBLIC_IDENTITY_API_URL=https://identity.torbatyar.ir +NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir +NEXT_PUBLIC_KEYCLOAK_REALM=superapp +NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend +NEXT_PUBLIC_ACCOUNTING_API_URL=https://accounting.torbatyar.ir +NEXT_PUBLIC_CRM_API_URL=https://crm.torbatyar.ir + +ACCOUNTING_SERVICE_URL=http://accounting-service:8002 +ACCOUNTING_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/accounting_db +ACCOUNTING_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/accounting_db +ACCOUNTING_SERVICE_NAME=accounting-service + +CRM_SERVICE_URL=http://crm-service:8003 +CRM_DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/crm_db +CRM_DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/crm_db +CRM_SERVICE_NAME=crm-service + +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 +KEYCLOAK_SERVER_URL=http://keycloak:8080 +KEYCLOAK_REALM=superapp + +INTERNAL_TOKEN_SECRET=change-me-internal-secret + +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=admin diff --git a/infrastructure/nginx/torbatyar.ir.conf b/infrastructure/nginx/torbatyar.ir.conf index b3a6675..6bf1b05 100644 --- a/infrastructure/nginx/torbatyar.ir.conf +++ b/infrastructure/nginx/torbatyar.ir.conf @@ -1,205 +1,230 @@ -# ============================================================ -# torbatyar.ir HTTPS — cert: /etc/letsencrypt/live/torbatyar.ir/ -# ساب‌دامین tenant: گواهی با provision_ssl.py به‌صورت خودکار expand می‌شود. -# ============================================================ - -include /etc/nginx/conf.d/torbatyar-tenant-ssl.map; - -upstream torbatyar_frontend { - server 192.168.10.162:3000; - keepalive 16; -} -upstream torbatyar_core { - server 192.168.10.162:8000; - keepalive 16; -} -upstream torbatyar_identity { - server 192.168.10.162:8001; - keepalive 16; -} -upstream torbatyar_keycloak { - server 192.168.10.162:8080; - keepalive 8; -} -upstream torbatyar_accounting { - server 192.168.10.162:8002; - keepalive 16; -} -upstream torbatyar_crm { - server 192.168.10.162:8003; - keepalive 16; -} - -# Force HTTPS for platform hosts -server { - listen 192.168.10.156:80; - server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - return 301 https://$host$request_uri; - } -} - -# Tenant subdomains: HTTP تا وقتی SSL آماده شود؛ بعد redirect به HTTPS -server { - listen 192.168.10.156:80; - server_name *.torbatyar.ir; - - location /.well-known/acme-challenge/ { - root /var/www/html; - } - - location / { - if ($torbatyar_tenant_ssl = 1) { - return 301 https://$host$request_uri; - } - proxy_pass http://torbatyar_frontend; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - client_max_body_size 50m; - } -} - -# Frontend + همه ساب‌دامین‌های tenant (SNI؛ گواهی باید SAN داشته باشد) -server { - listen 192.168.10.156:443 ssl; - server_name torbatyar.ir www.torbatyar.ir *.torbatyar.ir; - - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://torbatyar_frontend; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - client_max_body_size 50m; - } -} - -server { - listen 192.168.10.156:443 ssl; - server_name api.torbatyar.ir; - - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://torbatyar_core; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 600s; - client_max_body_size 50m; - } -} - -server { - listen 192.168.10.156:443 ssl; - server_name identity.torbatyar.ir; - - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://torbatyar_identity; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 600s; - client_max_body_size 20m; - } -} - -server { - listen 192.168.10.156:443 ssl; - server_name auth.torbatyar.ir; - - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://torbatyar_keycloak; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffer_size 128k; - proxy_buffers 4 256k; - proxy_busy_buffers_size 256k; - proxy_read_timeout 600s; - client_max_body_size 20m; - } -} - -server { - listen 192.168.10.156:443 ssl; - server_name accounting.torbatyar.ir; - - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://torbatyar_accounting; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 600s; - client_max_body_size 50m; - } -} - -server { - listen 192.168.10.156:443 ssl; - server_name crm.torbatyar.ir; - - ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; - include /etc/letsencrypt/options-ssl-nginx.conf; - ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; - - location / { - proxy_pass http://torbatyar_crm; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 600s; - client_max_body_size 50m; - } -} +# ============================================================ +# torbatyar.ir HTTPS — cert: /etc/letsencrypt/live/torbatyar.ir/ +# ساب‌دامین tenant: گواهی با provision_ssl.py به‌صورت خودکار expand می‌شود. +# ============================================================ + +include /etc/nginx/conf.d/torbatyar-tenant-ssl.map; + +upstream torbatyar_frontend { + server 192.168.10.162:3000; + keepalive 16; +} +upstream torbatyar_core { + server 192.168.10.162:8000; + keepalive 16; +} +upstream torbatyar_identity { + server 192.168.10.162:8001; + keepalive 16; +} +upstream torbatyar_keycloak { + server 192.168.10.162:8080; + keepalive 8; +} +upstream torbatyar_accounting { + server 192.168.10.162:8002; + keepalive 16; +} +upstream torbatyar_crm { + server 192.168.10.162:8003; + keepalive 16; +} +upstream torbatyar_loyalty { + server 192.168.10.162:8004; + keepalive 16; +} + +# Force HTTPS for platform hosts +server { + listen 192.168.10.156:80; + server_name torbatyar.ir www.torbatyar.ir api.torbatyar.ir identity.torbatyar.ir auth.torbatyar.ir accounting.torbatyar.ir crm.torbatyar.ir loyalty.torbatyar.ir; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# Tenant subdomains: HTTP تا وقتی SSL آماده شود؛ بعد redirect به HTTPS +server { + listen 192.168.10.156:80; + server_name *.torbatyar.ir; + + location /.well-known/acme-challenge/ { + root /var/www/html; + } + + location / { + if ($torbatyar_tenant_ssl = 1) { + return 301 https://$host$request_uri; + } + proxy_pass http://torbatyar_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + client_max_body_size 50m; + } +} + +# Frontend + همه ساب‌دامین‌های tenant (SNI؛ گواهی باید SAN داشته باشد) +server { + listen 192.168.10.156:443 ssl; + server_name torbatyar.ir www.torbatyar.ir *.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_frontend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name api.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_core; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name identity.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_identity; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 20m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name auth.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_keycloak; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + proxy_read_timeout 600s; + client_max_body_size 20m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name accounting.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_accounting; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name crm.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_crm; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} + +server { + listen 192.168.10.156:443 ssl; + server_name loyalty.torbatyar.ir; + + ssl_certificate /etc/letsencrypt/live/torbatyar.ir/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/torbatyar.ir/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://torbatyar_loyalty; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 600s; + client_max_body_size 50m; + } +} diff --git a/infrastructure/postgres/init-dbs.sql b/infrastructure/postgres/init-dbs.sql index dfbaeb6..3ba1bdc 100644 --- a/infrastructure/postgres/init-dbs.sql +++ b/infrastructure/postgres/init-dbs.sql @@ -1,3 +1,6 @@ CREATE DATABASE identity_access_db; CREATE DATABASE accounting_db; CREATE DATABASE crm_db; +CREATE DATABASE communication_db; +CREATE DATABASE loyalty_db; +CREATE DATABASE sports_center_db;