From 800b0ba2c5782979ee7b98665d6356e889d31046 Mon Sep 17 00:00:00 2001
From: Mortezakoohjani
Date: Tue, 21 Jul 2026 21:43:33 +0330
Subject: [PATCH] Deploy TorbatYar for torbatyar.ir with nginx multi-tenant
routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.
Co-authored-by: Cursor
---
.env.example | 104 ++
.gitignore | 48 +
README.md | 101 +
TorbatYar.code-workspace | 8 +
backend/README.md | 31 +
backend/core-service/Dockerfile | 28 +
backend/core-service/Dockerfile.dev | 22 +
backend/core-service/alembic.ini | 44 +
backend/core-service/alembic/README.md | 18 +
backend/core-service/alembic/env.py | 59 +
backend/core-service/alembic/script.py.mako | 26 +
.../alembic/versions/0001_initial.py | 35 +
.../alembic/versions/0002_users.py | 64 +
.../alembic/versions/0003_platform_admin.py | 37 +
.../alembic/versions/0004_user_sso_link.py | 35 +
.../versions/0005_tenant_onboarding.py | 183 ++
backend/core-service/app/__init__.py | 3 +
backend/core-service/app/api/__init__.py | 1 +
backend/core-service/app/api/deps.py | 106 ++
backend/core-service/app/api/v1/__init__.py | 34 +
.../core-service/app/api/v1/admin/__init__.py | 4 +
.../core-service/app/api/v1/admin/tenants.py | 63 +
backend/core-service/app/api/v1/auth.py | 32 +
backend/core-service/app/api/v1/domains.py | 59 +
backend/core-service/app/api/v1/features.py | 37 +
backend/core-service/app/api/v1/health.py | 24 +
backend/core-service/app/api/v1/me.py | 46 +
backend/core-service/app/api/v1/onboarding.py | 77 +
backend/core-service/app/api/v1/plans.py | 57 +
.../app/api/v1/service_registry.py | 54 +
.../core-service/app/api/v1/subscriptions.py | 61 +
.../core-service/app/api/v1/tenant_context.py | 46 +
backend/core-service/app/api/v1/tenants.py | 80 +
backend/core-service/app/core/__init__.py | 1 +
backend/core-service/app/core/cache.py | 129 ++
backend/core-service/app/core/config.py | 154 ++
backend/core-service/app/core/database.py | 50 +
backend/core-service/app/core/logging.py | 61 +
backend/core-service/app/core/security.py | 97 +
backend/core-service/app/main.py | 84 +
.../core-service/app/middlewares/__init__.py | 1 +
.../core-service/app/middlewares/tenant.py | 108 ++
backend/core-service/app/models/__init__.py | 38 +
backend/core-service/app/models/audit.py | 35 +
backend/core-service/app/models/base.py | 32 +
backend/core-service/app/models/domain.py | 56 +
backend/core-service/app/models/enums.py | 79 +
backend/core-service/app/models/events.py | 62 +
backend/core-service/app/models/membership.py | 62 +
backend/core-service/app/models/plan.py | 74 +
backend/core-service/app/models/registry.py | 92 +
.../core-service/app/models/subscription.py | 66 +
backend/core-service/app/models/tenant.py | 49 +
backend/core-service/app/models/types.py | 41 +
backend/core-service/app/models/user.py | 53 +
.../core-service/app/repositories/__init__.py | 1 +
backend/core-service/app/repositories/base.py | 46 +
.../app/repositories/membership.py | 48 +
backend/core-service/app/repositories/plan.py | 57 +
.../core-service/app/repositories/registry.py | 36 +
.../app/repositories/subscription.py | 39 +
.../core-service/app/repositories/tenant.py | 65 +
backend/core-service/app/repositories/user.py | 21 +
backend/core-service/app/schemas/__init__.py | 1 +
backend/core-service/app/schemas/auth.py | 57 +
backend/core-service/app/schemas/common.py | 18 +
backend/core-service/app/schemas/domain.py | 46 +
.../core-service/app/schemas/membership.py | 19 +
.../core-service/app/schemas/onboarding.py | 88 +
backend/core-service/app/schemas/plan.py | 62 +
.../app/schemas/service_registry.py | 32 +
.../core-service/app/schemas/subscription.py | 40 +
backend/core-service/app/schemas/tenant.py | 79 +
backend/core-service/app/services/__init__.py | 1 +
.../core-service/app/services/auth_service.py | 107 ++
.../app/services/domain_service.py | 58 +
.../app/services/entitlement_service.py | 117 ++
.../app/services/event_service.py | 38 +
.../app/services/membership_service.py | 83 +
.../app/services/onboarding_service.py | 175 ++
.../core-service/app/services/otp_service.py | 77 +
.../app/services/payamak_client.py | 245 +++
.../core-service/app/services/plan_service.py | 127 ++
.../app/services/service_registry_service.py | 51 +
.../app/services/subscription_service.py | 62 +
.../app/services/tenant_context_service.py | 89 +
.../app/services/tenant_service.py | 156 ++
.../app/services/token_service.py | 83 +
.../core-service/app/services/user_service.py | 49 +
backend/core-service/app/tests/__init__.py | 0
backend/core-service/app/tests/conftest.py | 54 +
.../core-service/app/tests/test_domains.py | 41 +
.../core-service/app/tests/test_features.py | 107 ++
backend/core-service/app/tests/test_health.py | 11 +
.../core-service/app/tests/test_middleware.py | 55 +
.../core-service/app/tests/test_onboarding.py | 216 +++
.../core-service/app/tests/test_otp_auth.py | 128 ++
backend/core-service/app/tests/test_outbox.py | 35 +
.../app/tests/test_payamak_client.py | 35 +
.../core-service/app/tests/test_tenants.py | 57 +
backend/core-service/app/utils/phone.py | 12 +
backend/core-service/app/workers/__init__.py | 1 +
.../core-service/app/workers/celery_app.py | 45 +
backend/core-service/app/workers/tasks.py | 72 +
backend/core-service/pytest.ini | 5 +
backend/core-service/requirements.txt | 32 +
backend/services/README.md | 13 +
backend/services/accounting/README.md | 19 +
backend/services/ai_assistant/README.md | 15 +
backend/services/crm/README.md | 18 +
backend/services/ecommerce/README.md | 19 +
backend/services/file_storage/README.md | 14 +
backend/services/identity-access/Dockerfile | 16 +
.../services/identity-access/Dockerfile.dev | 22 +
backend/services/identity-access/README.md | 42 +
backend/services/identity-access/alembic.ini | 29 +
.../services/identity-access/alembic/env.py | 34 +
.../alembic/versions/0001_initial.py | 17 +
.../alembic/versions/0002_user_mobile.py | 40 +
.../alembic/versions/0003_fix_enum_values.py | 32 +
.../services/identity-access/app/__init__.py | 3 +
.../identity-access/app/api/v1/__init__.py | 9 +
.../identity-access/app/api/v1/auth.py | 194 ++
.../identity-access/app/api/v1/health.py | 14 +
.../identity-access/app/api/v1/users.py | 70 +
.../identity-access/app/core/config.py | 158 ++
.../identity-access/app/core/database.py | 23 +
.../identity-access/app/core/logging.py | 24 +
.../identity-access/app/core/security.py | 54 +
backend/services/identity-access/app/main.py | 87 +
.../identity-access/app/models/__init__.py | 4 +
.../identity-access/app/models/membership.py | 38 +
.../identity-access/app/models/types.py | 40 +
.../identity-access/app/models/user.py | 46 +
.../identity-access/app/repositories/user.py | 72 +
.../identity-access/app/schemas/auth.py | 168 ++
.../identity-access/app/schemas/common.py | 11 +
.../identity-access/app/schemas/user.py | 49 +
.../app/services/auth_service.py | 184 ++
.../app/services/handoff_store.py | 36 +
.../app/services/keycloak_client.py | 321 ++++
.../app/services/mobile_auth_service.py | 163 ++
.../app/services/otp_client.py | 67 +
.../app/services/pkce_store.py | 53 +
.../app/services/registration_service.py | 216 +++
.../app/services/user_service.py | 106 ++
.../identity-access/app/tests/conftest.py | 37 +
.../identity-access/app/tests/test_auth.py | 19 +
.../identity-access/app/tests/test_users.py | 16 +
backend/services/identity-access/pytest.ini | 3 +
.../services/identity-access/requirements.txt | 14 +
.../scripts/apply_realm_theme.py | 57 +
.../scripts/enable_mobile_auth_client.py | 116 ++
.../identity-access/scripts/ensure_db.py | 45 +
.../scripts/patch_keycloak_client.py | 100 +
backend/services/link_shortener/README.md | 15 +
backend/services/live_chat/README.md | 15 +
backend/services/notification/README.md | 15 +
backend/services/restaurant/README.md | 16 +
backend/services/smart_messenger/README.md | 14 +
backend/services/sms_panel/README.md | 16 +
backend/services/website_builder/README.md | 16 +
backend/shared-lib/pyproject.toml | 17 +
backend/shared-lib/shared/__init__.py | 14 +
backend/shared-lib/shared/auth/__init__.py | 12 +
backend/shared-lib/shared/auth/jwt.py | 140 ++
backend/shared-lib/shared/auth/roles.py | 24 +
backend/shared-lib/shared/events.py | 57 +
backend/shared-lib/shared/exceptions.py | 79 +
backend/shared-lib/shared/pagination.py | 66 +
backend/shared-lib/shared/phone.py | 39 +
backend/shared-lib/shared/responses.py | 38 +
backend/shared-lib/shared/security.py | 55 +
backend/shared-lib/shared/tenant.py | 29 +
docker-compose.yml | 213 +++
docs/architecture.md | 389 ++++
docs/database_schema.md | 224 +++
docs/developer_guide.md | 110 ++
docs/last_step.md | 86 +
docs/progress.md | 174 ++
docs/services_contracts.md | 270 +++
frontend/.dockerignore | 6 +
frontend/Dockerfile.dev | 15 +
frontend/README.md | 43 +
frontend/app/admin/features/page.tsx | 179 ++
frontend/app/admin/layout.tsx | 21 +
frontend/app/admin/login/page.tsx | 17 +
frontend/app/admin/page.tsx | 108 ++
frontend/app/admin/plans/page.tsx | 304 +++
frontend/app/admin/services/page.tsx | 226 +++
frontend/app/admin/settings/page.tsx | 7 +
frontend/app/admin/tenants/[id]/page.tsx | 492 +++++
frontend/app/admin/tenants/new/page.tsx | 113 ++
frontend/app/admin/tenants/page.tsx | 124 ++
frontend/app/admin/users/page.tsx | 223 +++
frontend/app/auth/callback/page.tsx | 68 +
frontend/app/auth/verify-mobile/page.tsx | 134 ++
frontend/app/dashboard/page.tsx | 206 +++
frontend/app/dashboard/settings/page.tsx | 12 +
frontend/app/layout.tsx | 26 +
frontend/app/login/page.tsx | 31 +
frontend/app/onboarding/page.tsx | 438 +++++
frontend/app/page.tsx | 62 +
frontend/app/register/page.tsx | 31 +
frontend/components/AdminAuthGuard.tsx | 41 +
frontend/components/AppStoreGrid.tsx | 20 +
frontend/components/AuthGuard.tsx | 26 +
frontend/components/HealthStatus.tsx | 21 +
frontend/components/SiteHeader.tsx | 69 +
frontend/components/TenantSwitcher.tsx | 57 +
frontend/components/ThemeProvider.tsx | 18 +
frontend/components/admin/AdminShell.tsx | 89 +
frontend/components/admin/controls.tsx | 238 +++
frontend/components/auth/AuthPanel.tsx | 435 +++++
.../components/settings/AccountSettings.tsx | 331 ++++
frontend/components/ui/AppTile.tsx | 37 +
frontend/components/ui/Badge.tsx | 23 +
frontend/components/ui/Button.tsx | 59 +
frontend/components/ui/Container.tsx | 14 +
frontend/components/ui/SectionTitle.tsx | 19 +
frontend/components/ui/index.ts | 10 +
frontend/hooks/useAuth.ts | 71 +
frontend/hooks/useHeaderSession.ts | 30 +
frontend/hooks/useMe.ts | 37 +
frontend/hooks/useTheme.ts | 20 +
frontend/lib/api-client.ts | 61 +
frontend/lib/api.ts | 464 +++++
frontend/lib/apps-catalog.ts | 112 ++
frontend/lib/auth.ts | 170 ++
frontend/lib/identity.ts | 247 +++
frontend/lib/optional-sso.ts | 29 +
frontend/lib/phone.ts | 47 +
frontend/lib/theme.ts | 50 +
frontend/next-env.d.ts | 6 +
frontend/next.config.mjs | 20 +
frontend/package-lock.json | 1641 +++++++++++++++++
frontend/package.json | 25 +
frontend/postcss.config.mjs | 9 +
.../fonts/yekan-bakh/YekanBakhFaNum-Bold.woff | Bin 0 -> 46504 bytes
.../yekan-bakh/YekanBakhFaNum-ExtraBold.woff | Bin 0 -> 46724 bytes
.../yekan-bakh/YekanBakhFaNum-Light.woff | Bin 0 -> 43604 bytes
.../yekan-bakh/YekanBakhFaNum-Regular.woff | Bin 0 -> 43984 bytes
.../yekan-bakh/YekanBakhFaNum-SemiBold.woff | Bin 0 -> 45120 bytes
.../public/fonts/yekan-bakh/yekan-bakh.css | 66 +
frontend/public/theme.config.json | 7 +
frontend/styles/globals.css | 27 +
frontend/tailwind.config.ts | 27 +
frontend/tsconfig.json | 21 +
frontend/tsconfig.tsbuildinfo | 1 +
infrastructure/deploy/.env.production.example | 79 +
.../keycloak/realm/superapp-realm.json | 100 +
.../keycloak/scripts/apply_realm_theme.py | 57 +
.../keycloak/scripts/patch_frontend_client.py | 9 +
.../keycloak/themes/torbatyar/login/login.ftl | 130 ++
.../login/messages/messages_en.properties | 27 +
.../login/messages/messages_fa.properties | 85 +
.../torbatyar/login/resources/css/fonts.css | 39 +
.../torbatyar/login/resources/css/login.css | 504 +++++
.../resources/fonts/YekanBakhFaNum-Bold.woff | Bin 0 -> 46504 bytes
.../fonts/YekanBakhFaNum-ExtraBold.woff | Bin 0 -> 46724 bytes
.../resources/fonts/YekanBakhFaNum-Light.woff | Bin 0 -> 43604 bytes
.../fonts/YekanBakhFaNum-Regular.woff | Bin 0 -> 43984 bytes
.../fonts/YekanBakhFaNum-SemiBold.woff | Bin 0 -> 45120 bytes
.../login/resources/js/locale-dropdown.js | 40 +
.../login/resources/js/mobile-auth.js | 368 ++++
.../themes/torbatyar/login/theme.properties | 7 +
infrastructure/nginx/torbatyar.ir.conf | 150 ++
.../nginx/torbatyar.ir.ssl.conf.example | 130 ++
infrastructure/postgres/init-dbs.sql | 1 +
scripts/deploy_remote.py | 241 +++
scripts/remote_ssh.py | 120 ++
scripts/verify_endpoints.py | 30 +
272 files changed, 21018 insertions(+)
create mode 100644 .env.example
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 TorbatYar.code-workspace
create mode 100644 backend/README.md
create mode 100644 backend/core-service/Dockerfile
create mode 100644 backend/core-service/Dockerfile.dev
create mode 100644 backend/core-service/alembic.ini
create mode 100644 backend/core-service/alembic/README.md
create mode 100644 backend/core-service/alembic/env.py
create mode 100644 backend/core-service/alembic/script.py.mako
create mode 100644 backend/core-service/alembic/versions/0001_initial.py
create mode 100644 backend/core-service/alembic/versions/0002_users.py
create mode 100644 backend/core-service/alembic/versions/0003_platform_admin.py
create mode 100644 backend/core-service/alembic/versions/0004_user_sso_link.py
create mode 100644 backend/core-service/alembic/versions/0005_tenant_onboarding.py
create mode 100644 backend/core-service/app/__init__.py
create mode 100644 backend/core-service/app/api/__init__.py
create mode 100644 backend/core-service/app/api/deps.py
create mode 100644 backend/core-service/app/api/v1/__init__.py
create mode 100644 backend/core-service/app/api/v1/admin/__init__.py
create mode 100644 backend/core-service/app/api/v1/admin/tenants.py
create mode 100644 backend/core-service/app/api/v1/auth.py
create mode 100644 backend/core-service/app/api/v1/domains.py
create mode 100644 backend/core-service/app/api/v1/features.py
create mode 100644 backend/core-service/app/api/v1/health.py
create mode 100644 backend/core-service/app/api/v1/me.py
create mode 100644 backend/core-service/app/api/v1/onboarding.py
create mode 100644 backend/core-service/app/api/v1/plans.py
create mode 100644 backend/core-service/app/api/v1/service_registry.py
create mode 100644 backend/core-service/app/api/v1/subscriptions.py
create mode 100644 backend/core-service/app/api/v1/tenant_context.py
create mode 100644 backend/core-service/app/api/v1/tenants.py
create mode 100644 backend/core-service/app/core/__init__.py
create mode 100644 backend/core-service/app/core/cache.py
create mode 100644 backend/core-service/app/core/config.py
create mode 100644 backend/core-service/app/core/database.py
create mode 100644 backend/core-service/app/core/logging.py
create mode 100644 backend/core-service/app/core/security.py
create mode 100644 backend/core-service/app/main.py
create mode 100644 backend/core-service/app/middlewares/__init__.py
create mode 100644 backend/core-service/app/middlewares/tenant.py
create mode 100644 backend/core-service/app/models/__init__.py
create mode 100644 backend/core-service/app/models/audit.py
create mode 100644 backend/core-service/app/models/base.py
create mode 100644 backend/core-service/app/models/domain.py
create mode 100644 backend/core-service/app/models/enums.py
create mode 100644 backend/core-service/app/models/events.py
create mode 100644 backend/core-service/app/models/membership.py
create mode 100644 backend/core-service/app/models/plan.py
create mode 100644 backend/core-service/app/models/registry.py
create mode 100644 backend/core-service/app/models/subscription.py
create mode 100644 backend/core-service/app/models/tenant.py
create mode 100644 backend/core-service/app/models/types.py
create mode 100644 backend/core-service/app/models/user.py
create mode 100644 backend/core-service/app/repositories/__init__.py
create mode 100644 backend/core-service/app/repositories/base.py
create mode 100644 backend/core-service/app/repositories/membership.py
create mode 100644 backend/core-service/app/repositories/plan.py
create mode 100644 backend/core-service/app/repositories/registry.py
create mode 100644 backend/core-service/app/repositories/subscription.py
create mode 100644 backend/core-service/app/repositories/tenant.py
create mode 100644 backend/core-service/app/repositories/user.py
create mode 100644 backend/core-service/app/schemas/__init__.py
create mode 100644 backend/core-service/app/schemas/auth.py
create mode 100644 backend/core-service/app/schemas/common.py
create mode 100644 backend/core-service/app/schemas/domain.py
create mode 100644 backend/core-service/app/schemas/membership.py
create mode 100644 backend/core-service/app/schemas/onboarding.py
create mode 100644 backend/core-service/app/schemas/plan.py
create mode 100644 backend/core-service/app/schemas/service_registry.py
create mode 100644 backend/core-service/app/schemas/subscription.py
create mode 100644 backend/core-service/app/schemas/tenant.py
create mode 100644 backend/core-service/app/services/__init__.py
create mode 100644 backend/core-service/app/services/auth_service.py
create mode 100644 backend/core-service/app/services/domain_service.py
create mode 100644 backend/core-service/app/services/entitlement_service.py
create mode 100644 backend/core-service/app/services/event_service.py
create mode 100644 backend/core-service/app/services/membership_service.py
create mode 100644 backend/core-service/app/services/onboarding_service.py
create mode 100644 backend/core-service/app/services/otp_service.py
create mode 100644 backend/core-service/app/services/payamak_client.py
create mode 100644 backend/core-service/app/services/plan_service.py
create mode 100644 backend/core-service/app/services/service_registry_service.py
create mode 100644 backend/core-service/app/services/subscription_service.py
create mode 100644 backend/core-service/app/services/tenant_context_service.py
create mode 100644 backend/core-service/app/services/tenant_service.py
create mode 100644 backend/core-service/app/services/token_service.py
create mode 100644 backend/core-service/app/services/user_service.py
create mode 100644 backend/core-service/app/tests/__init__.py
create mode 100644 backend/core-service/app/tests/conftest.py
create mode 100644 backend/core-service/app/tests/test_domains.py
create mode 100644 backend/core-service/app/tests/test_features.py
create mode 100644 backend/core-service/app/tests/test_health.py
create mode 100644 backend/core-service/app/tests/test_middleware.py
create mode 100644 backend/core-service/app/tests/test_onboarding.py
create mode 100644 backend/core-service/app/tests/test_otp_auth.py
create mode 100644 backend/core-service/app/tests/test_outbox.py
create mode 100644 backend/core-service/app/tests/test_payamak_client.py
create mode 100644 backend/core-service/app/tests/test_tenants.py
create mode 100644 backend/core-service/app/utils/phone.py
create mode 100644 backend/core-service/app/workers/__init__.py
create mode 100644 backend/core-service/app/workers/celery_app.py
create mode 100644 backend/core-service/app/workers/tasks.py
create mode 100644 backend/core-service/pytest.ini
create mode 100644 backend/core-service/requirements.txt
create mode 100644 backend/services/README.md
create mode 100644 backend/services/accounting/README.md
create mode 100644 backend/services/ai_assistant/README.md
create mode 100644 backend/services/crm/README.md
create mode 100644 backend/services/ecommerce/README.md
create mode 100644 backend/services/file_storage/README.md
create mode 100644 backend/services/identity-access/Dockerfile
create mode 100644 backend/services/identity-access/Dockerfile.dev
create mode 100644 backend/services/identity-access/README.md
create mode 100644 backend/services/identity-access/alembic.ini
create mode 100644 backend/services/identity-access/alembic/env.py
create mode 100644 backend/services/identity-access/alembic/versions/0001_initial.py
create mode 100644 backend/services/identity-access/alembic/versions/0002_user_mobile.py
create mode 100644 backend/services/identity-access/alembic/versions/0003_fix_enum_values.py
create mode 100644 backend/services/identity-access/app/__init__.py
create mode 100644 backend/services/identity-access/app/api/v1/__init__.py
create mode 100644 backend/services/identity-access/app/api/v1/auth.py
create mode 100644 backend/services/identity-access/app/api/v1/health.py
create mode 100644 backend/services/identity-access/app/api/v1/users.py
create mode 100644 backend/services/identity-access/app/core/config.py
create mode 100644 backend/services/identity-access/app/core/database.py
create mode 100644 backend/services/identity-access/app/core/logging.py
create mode 100644 backend/services/identity-access/app/core/security.py
create mode 100644 backend/services/identity-access/app/main.py
create mode 100644 backend/services/identity-access/app/models/__init__.py
create mode 100644 backend/services/identity-access/app/models/membership.py
create mode 100644 backend/services/identity-access/app/models/types.py
create mode 100644 backend/services/identity-access/app/models/user.py
create mode 100644 backend/services/identity-access/app/repositories/user.py
create mode 100644 backend/services/identity-access/app/schemas/auth.py
create mode 100644 backend/services/identity-access/app/schemas/common.py
create mode 100644 backend/services/identity-access/app/schemas/user.py
create mode 100644 backend/services/identity-access/app/services/auth_service.py
create mode 100644 backend/services/identity-access/app/services/handoff_store.py
create mode 100644 backend/services/identity-access/app/services/keycloak_client.py
create mode 100644 backend/services/identity-access/app/services/mobile_auth_service.py
create mode 100644 backend/services/identity-access/app/services/otp_client.py
create mode 100644 backend/services/identity-access/app/services/pkce_store.py
create mode 100644 backend/services/identity-access/app/services/registration_service.py
create mode 100644 backend/services/identity-access/app/services/user_service.py
create mode 100644 backend/services/identity-access/app/tests/conftest.py
create mode 100644 backend/services/identity-access/app/tests/test_auth.py
create mode 100644 backend/services/identity-access/app/tests/test_users.py
create mode 100644 backend/services/identity-access/pytest.ini
create mode 100644 backend/services/identity-access/requirements.txt
create mode 100644 backend/services/identity-access/scripts/apply_realm_theme.py
create mode 100644 backend/services/identity-access/scripts/enable_mobile_auth_client.py
create mode 100644 backend/services/identity-access/scripts/ensure_db.py
create mode 100644 backend/services/identity-access/scripts/patch_keycloak_client.py
create mode 100644 backend/services/link_shortener/README.md
create mode 100644 backend/services/live_chat/README.md
create mode 100644 backend/services/notification/README.md
create mode 100644 backend/services/restaurant/README.md
create mode 100644 backend/services/smart_messenger/README.md
create mode 100644 backend/services/sms_panel/README.md
create mode 100644 backend/services/website_builder/README.md
create mode 100644 backend/shared-lib/pyproject.toml
create mode 100644 backend/shared-lib/shared/__init__.py
create mode 100644 backend/shared-lib/shared/auth/__init__.py
create mode 100644 backend/shared-lib/shared/auth/jwt.py
create mode 100644 backend/shared-lib/shared/auth/roles.py
create mode 100644 backend/shared-lib/shared/events.py
create mode 100644 backend/shared-lib/shared/exceptions.py
create mode 100644 backend/shared-lib/shared/pagination.py
create mode 100644 backend/shared-lib/shared/phone.py
create mode 100644 backend/shared-lib/shared/responses.py
create mode 100644 backend/shared-lib/shared/security.py
create mode 100644 backend/shared-lib/shared/tenant.py
create mode 100644 docker-compose.yml
create mode 100644 docs/architecture.md
create mode 100644 docs/database_schema.md
create mode 100644 docs/developer_guide.md
create mode 100644 docs/last_step.md
create mode 100644 docs/progress.md
create mode 100644 docs/services_contracts.md
create mode 100644 frontend/.dockerignore
create mode 100644 frontend/Dockerfile.dev
create mode 100644 frontend/README.md
create mode 100644 frontend/app/admin/features/page.tsx
create mode 100644 frontend/app/admin/layout.tsx
create mode 100644 frontend/app/admin/login/page.tsx
create mode 100644 frontend/app/admin/page.tsx
create mode 100644 frontend/app/admin/plans/page.tsx
create mode 100644 frontend/app/admin/services/page.tsx
create mode 100644 frontend/app/admin/settings/page.tsx
create mode 100644 frontend/app/admin/tenants/[id]/page.tsx
create mode 100644 frontend/app/admin/tenants/new/page.tsx
create mode 100644 frontend/app/admin/tenants/page.tsx
create mode 100644 frontend/app/admin/users/page.tsx
create mode 100644 frontend/app/auth/callback/page.tsx
create mode 100644 frontend/app/auth/verify-mobile/page.tsx
create mode 100644 frontend/app/dashboard/page.tsx
create mode 100644 frontend/app/dashboard/settings/page.tsx
create mode 100644 frontend/app/layout.tsx
create mode 100644 frontend/app/login/page.tsx
create mode 100644 frontend/app/onboarding/page.tsx
create mode 100644 frontend/app/page.tsx
create mode 100644 frontend/app/register/page.tsx
create mode 100644 frontend/components/AdminAuthGuard.tsx
create mode 100644 frontend/components/AppStoreGrid.tsx
create mode 100644 frontend/components/AuthGuard.tsx
create mode 100644 frontend/components/HealthStatus.tsx
create mode 100644 frontend/components/SiteHeader.tsx
create mode 100644 frontend/components/TenantSwitcher.tsx
create mode 100644 frontend/components/ThemeProvider.tsx
create mode 100644 frontend/components/admin/AdminShell.tsx
create mode 100644 frontend/components/admin/controls.tsx
create mode 100644 frontend/components/auth/AuthPanel.tsx
create mode 100644 frontend/components/settings/AccountSettings.tsx
create mode 100644 frontend/components/ui/AppTile.tsx
create mode 100644 frontend/components/ui/Badge.tsx
create mode 100644 frontend/components/ui/Button.tsx
create mode 100644 frontend/components/ui/Container.tsx
create mode 100644 frontend/components/ui/SectionTitle.tsx
create mode 100644 frontend/components/ui/index.ts
create mode 100644 frontend/hooks/useAuth.ts
create mode 100644 frontend/hooks/useHeaderSession.ts
create mode 100644 frontend/hooks/useMe.ts
create mode 100644 frontend/hooks/useTheme.ts
create mode 100644 frontend/lib/api-client.ts
create mode 100644 frontend/lib/api.ts
create mode 100644 frontend/lib/apps-catalog.ts
create mode 100644 frontend/lib/auth.ts
create mode 100644 frontend/lib/identity.ts
create mode 100644 frontend/lib/optional-sso.ts
create mode 100644 frontend/lib/phone.ts
create mode 100644 frontend/lib/theme.ts
create mode 100644 frontend/next-env.d.ts
create mode 100644 frontend/next.config.mjs
create mode 100644 frontend/package-lock.json
create mode 100644 frontend/package.json
create mode 100644 frontend/postcss.config.mjs
create mode 100644 frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Bold.woff
create mode 100644 frontend/public/fonts/yekan-bakh/YekanBakhFaNum-ExtraBold.woff
create mode 100644 frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Light.woff
create mode 100644 frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Regular.woff
create mode 100644 frontend/public/fonts/yekan-bakh/YekanBakhFaNum-SemiBold.woff
create mode 100644 frontend/public/fonts/yekan-bakh/yekan-bakh.css
create mode 100644 frontend/public/theme.config.json
create mode 100644 frontend/styles/globals.css
create mode 100644 frontend/tailwind.config.ts
create mode 100644 frontend/tsconfig.json
create mode 100644 frontend/tsconfig.tsbuildinfo
create mode 100644 infrastructure/deploy/.env.production.example
create mode 100644 infrastructure/keycloak/realm/superapp-realm.json
create mode 100644 infrastructure/keycloak/scripts/apply_realm_theme.py
create mode 100644 infrastructure/keycloak/scripts/patch_frontend_client.py
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/login.ftl
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/messages/messages_en.properties
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/messages/messages_fa.properties
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/css/fonts.css
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/css/login.css
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Bold.woff
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-ExtraBold.woff
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Light.woff
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-Regular.woff
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/fonts/YekanBakhFaNum-SemiBold.woff
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/js/locale-dropdown.js
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/resources/js/mobile-auth.js
create mode 100644 infrastructure/keycloak/themes/torbatyar/login/theme.properties
create mode 100644 infrastructure/nginx/torbatyar.ir.conf
create mode 100644 infrastructure/nginx/torbatyar.ir.ssl.conf.example
create mode 100644 infrastructure/postgres/init-dbs.sql
create mode 100644 scripts/deploy_remote.py
create mode 100644 scripts/remote_ssh.py
create mode 100644 scripts/verify_endpoints.py
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..da79fa1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,104 @@
+# ============================================================
+# SuperApp SaaS Platform - Environment Configuration Example
+# این فایل را به .env کپی کنید و مقادیر واقعی را جایگزین نمایید.
+# ============================================================
+
+# ---- General ----
+ENVIRONMENT=development # development | staging | production
+DEBUG=true
+LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR
+SERVICE_NAME=core-service
+API_V1_PREFIX=/api/v1
+
+# ---- Platform Branding (White-label) ----
+# برند نباید در کد hardcode شود؛ از این مقادیر استفاده کنید.
+PLATFORM_NAME=Torbatyar
+PLATFORM_SUPPORT_EMAIL=support@example.com
+PLATFORM_PRIMARY_COLOR=#0284c7
+PLATFORM_SECONDARY_COLOR=#0f172a
+# دامنه پایه برای تخصیص خودکار زیردامنه tenant در onboarding (مثلاً .torbatyar.xyz)
+# دامنه پایه برای تخصیص خودکار زیردامنه tenant (مثلاً .torbatyar.ir)
+PLATFORM_BASE_DOMAIN=torbatyar.ir
+
+# ---- Core Database (core_platform_db) ----
+POSTGRES_HOST=postgres
+POSTGRES_PORT=5432
+POSTGRES_USER=superapp
+POSTGRES_PASSWORD=superapp_password
+POSTGRES_DB=core_platform_db
+# async driver برای FastAPI/SQLAlchemy
+DATABASE_URL=postgresql+asyncpg://superapp:superapp_password@postgres:5432/core_platform_db
+# sync driver برای Alembic
+DATABASE_URL_SYNC=postgresql+psycopg://superapp:superapp_password@postgres:5432/core_platform_db
+
+# ---- Redis ----
+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 ----
+CELERY_BROKER_URL=redis://redis:6379/1
+CELERY_RESULT_BACKEND=redis://redis:6379/2
+
+# ---- Keycloak / SSO (فاز ۲ — فعال در Docker) ----
+# Production: auth.torbatyar.ir پشت nginx روی 192.168.10.156
+# Dev محلی: میتوانید hosts را به 127.0.0.1 اشاره دهید.
+KEYCLOAK_ENABLED=true
+KEYCLOAK_SERVER_URL=http://keycloak:8080
+KEYCLOAK_PUBLIC_URL=https://auth.torbatyar.ir
+KEYCLOAK_HOSTNAME=auth.torbatyar.ir
+KEYCLOAK_HOSTNAME_STRICT_HTTPS=false
+KEYCLOAK_REALM=superapp
+KEYCLOAK_CLIENT_ID=core-service
+KEYCLOAK_CLIENT_SECRET=change-me
+JWT_ALGORITHM=RS256
+JWT_AUDIENCE=account
+JWT_VERIFY_SIGNATURE=true
+# در production و Docker باید true باشد؛ در تستها false
+AUTH_REQUIRED=true
+
+# ---- Identity & Access Service ----
+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,http://localhost:3000
+CORE_SERVICE_URL=http://core-service:8000
+
+# ---- OTP / Payamak SMS (Core Service) ----
+# ApiKey را در پارامتر password ارسال کنید (طبق مستندات ملیپیامک)
+PAYAMAK_USERNAME=9155105404
+PAYAMAK_FROM=9982004961
+PAYAMAK_FROM_NUMBER=9982004961
+# ApiKey را در پارامتر password ارسال کنید (مستندات ملیپیامک)
+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
+# آدرس رسمی: https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber
+JWT_SECRET=change-me-jwt-secret
+OTP_EXPIRE_SECONDS=120
+
+# شمارههای ادمین پلتفرم (OTP) — با کاما جدا شوند
+PLATFORM_ADMIN_MOBILES=09155105404
+
+# ---- Frontend (Next.js) ----
+FRONTEND_PORT=3000
+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
+
+INTERNAL_TOKEN_SECRET=change-me-internal-secret
+
+# ---- Keycloak Bootstrap ----
+KEYCLOAK_ADMIN=admin
+KEYCLOAK_ADMIN_PASSWORD=admin
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c393362
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+*.egg
+
+# Environment
+.env
+*.env
+!.env.example
+
+# Databases / local
+*.sqlite3
+*.db
+test.db
+
+# Node / Frontend
+node_modules/
+.next/
+dist/
+frontend/.next/
+frontend/node_modules/
+
+# IDE / OS
+.idea/
+.vscode/
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+
+# Deploy temp artifacts
+scripts/_deploy_bundle.tar.gz
+scripts/_tmp_*
+scripts/_check_*
+scripts/_fix_*
+scripts/_debug_*
+scripts/_vols.py
+scripts/_restart_*
+scripts/_reset_*
+scripts/_verify*
+scripts/_finalize_*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fc96730
--- /dev/null
+++ b/README.md
@@ -0,0 +1,101 @@
+# SuperApp SaaS Platform
+
+پلتفرم SaaS چندمستأجری (Multi-tenant)، ماژولار، API-first و microservice-ready.
+این مخزن شامل **فاز ۱ (Core Platform)** و **فاز ۲ (Identity & Access + SSO)** است.
+
+> برند، رنگها و تنظیمات هیچکدام در کد hardcode نشدهاند و همگی از `.env` / `config` / دیتابیس خوانده میشوند.
+
+## معماری در یک نگاه
+
+- **جداسازی اجباری Frontend/Backend:** دو اپلیکیشن کاملاً مستقل (`backend/` و `frontend/`).
+- **الگوی دیتابیس:** Database-per-service. هر سرویس فقط دیتابیس خودش را میشناسد.
+- **ارتباط بین سرویسها:** فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
+- **چندمستأجری:** همه جداول بیزینسی ستون `tenant_id` دارند.
+
+جزئیات کامل در پوشه [`docs/`](./docs) موجود است:
+
+| فایل | توضیح |
+| --- | --- |
+| [`docs/architecture.md`](./docs/architecture.md) | معماری کلان و جداسازی Frontend/Backend |
+| [`docs/database_schema.md`](./docs/database_schema.md) | مدل دیتابیس Core |
+| [`docs/services_contracts.md`](./docs/services_contracts.md) | قراردادهای ارتباطی سرویسها |
+| [`docs/developer_guide.md`](./docs/developer_guide.md) | راهنمای توسعهدهنده |
+| [`docs/progress.md`](./docs/progress.md) | چکلیست پیشرفت فازها |
+| [`docs/last_step.md`](./docs/last_step.md) | آخرین وضعیت |
+
+## ساختار پروژه
+
+```
+superapp-platform/
+├── backend/
+│ ├── core-service/ # FastAPI Core Platform
+│ ├── shared-lib/ # کتابخانه مشترک backend
+│ └── services/ # placeholder سرویسهای آینده
+├── frontend/ # Next.js (کاملاً جدا)
+│ ├── app/
+│ ├── components/
+│ ├── lib/
+│ ├── hooks/
+│ ├── styles/
+│ └── public/
+├── docs/
+├── docker-compose.yml
+├── .env.example
+└── README.md
+```
+
+## راهاندازی سریع (Docker)
+
+```bash
+cp .env.example .env
+docker compose up -d --build
+```
+
+سایت: http://localhost:3000
+
+سرویسها پس از بالا آمدن (یک دستور: `docker compose up -d --build`):
+
+- **Frontend:** http://localhost:3000 (login: http://localhost:3000/login)
+- Core API: http://localhost:8000 (مستندات: http://localhost:8000/docs)
+- Identity API: http://localhost:8001 (SSO: http://localhost:8001/docs)
+- Keycloak: http://localhost:8080 (admin/admin — کاربر نمونه: platform.admin/admin123)
+- PostgreSQL: `localhost:5432`
+- Redis: `localhost:6379`
+
+> Frontend داخل Docker با hot-reload اجرا میشود؛ دیگر نیازی به `npm run dev` جداگانه نیست.
+
+## راهاندازی محلی
+
+### Backend
+```bash
+cd backend/core-service
+python -m venv .venv
+# Windows: .venv\Scripts\Activate.ps1
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload
+```
+
+### Frontend
+```bash
+# ترجیحاً از Docker (همراه بقیه سرویسها):
+docker compose up -d --build
+
+# یا اجرای مستقیم برای دیباگ UI:
+cd frontend
+npm install
+npm run dev
+```
+Frontend: http://localhost:3000
+
+## اجرای تستها (Backend)
+
+```bash
+cd backend/core-service
+pytest -q
+```
+
+## فازهای بعدی
+
+سرویسهای آینده در [`backend/services/`](./backend/services) بهصورت placeholder آمادهاند.
+UI کامل در [`frontend/`](./frontend) توسعه مییابد.
diff --git a/TorbatYar.code-workspace b/TorbatYar.code-workspace
new file mode 100644
index 0000000..876a149
--- /dev/null
+++ b/TorbatYar.code-workspace
@@ -0,0 +1,8 @@
+{
+ "folders": [
+ {
+ "path": "."
+ }
+ ],
+ "settings": {}
+}
\ No newline at end of file
diff --git a/backend/README.md b/backend/README.md
new file mode 100644
index 0000000..64c93e5
--- /dev/null
+++ b/backend/README.md
@@ -0,0 +1,31 @@
+# Backend
+
+تمام کد منبع backend فقط در این پوشه قرار دارد.
+
+## ساختار
+```
+backend/
+├── core-service/ # سرویس Core Platform (FastAPI)
+├── shared-lib/ # کتابخانه مشترک بین سرویسهای backend
+└── services/ # placeholder سرویسهای آینده
+```
+
+## قوانین (اجباری)
+- **هیچ** کد React، Next.js، Tailwind، UI Component یا HTML در backend نیست.
+- frontend در پوشه جداگانه `frontend/` قرار دارد.
+- ارتباط با frontend فقط از طریق REST API.
+
+## راهاندازی
+```bash
+cd backend/core-service
+python -m venv .venv
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload
+```
+
+## تستها
+```bash
+cd backend/core-service
+pytest -q
+```
diff --git a/backend/core-service/Dockerfile b/backend/core-service/Dockerfile
new file mode 100644
index 0000000..f5c181d
--- /dev/null
+++ b/backend/core-service/Dockerfile
@@ -0,0 +1,28 @@
+# ============================================================
+# Core Platform Service - Dockerfile
+# Build context: ریشه مخزن (برای دسترسی به backend/shared-lib)
+# ============================================================
+FROM python:3.11-slim AS base
+
+ENV PYTHONDONTWRITEBYTECODE=1 \
+ PYTHONUNBUFFERED=1 \
+ PIP_NO_CACHE_DIR=1
+
+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/core-service/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
+
+COPY backend/core-service/ /app/
+
+EXPOSE 8000
+
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/backend/core-service/Dockerfile.dev b/backend/core-service/Dockerfile.dev
new file mode 100644
index 0000000..e36c60d
--- /dev/null
+++ b/backend/core-service/Dockerfile.dev
@@ -0,0 +1,22 @@
+# Dev image: فقط وابستگیها — کد با volume mount و 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/core-service/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 8000
diff --git a/backend/core-service/alembic.ini b/backend/core-service/alembic.ini
new file mode 100644
index 0000000..b3d9ee7
--- /dev/null
+++ b/backend/core-service/alembic.ini
@@ -0,0 +1,44 @@
+# پیکربندی Alembic برای Core Platform Service.
+# آدرس دیتابیس از env خوانده میشود (در alembic/env.py تنظیم شده)،
+# بنابراین sqlalchemy.url اینجا خالی است.
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+sqlalchemy.url =
+
+[post_write_hooks]
+
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARNING
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARNING
+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/core-service/alembic/README.md b/backend/core-service/alembic/README.md
new file mode 100644
index 0000000..ed740cc
--- /dev/null
+++ b/backend/core-service/alembic/README.md
@@ -0,0 +1,18 @@
+# Alembic Migrations
+
+مدیریت نسخههای اسکیمای دیتابیس `core_platform_db`.
+
+## دستورها
+
+```bash
+# ساخت migration جدید بر اساس تغییر مدلها
+alembic revision --autogenerate -m "توضیح تغییر"
+
+# اعمال migrationها
+alembic upgrade head
+
+# بازگشت یک مرحله
+alembic downgrade -1
+```
+
+> آدرس دیتابیس از `settings.database_url_sync` (متغیر محیطی `DATABASE_URL_SYNC`) خوانده میشود.
diff --git a/backend/core-service/alembic/env.py b/backend/core-service/alembic/env.py
new file mode 100644
index 0000000..5a14a9c
--- /dev/null
+++ b/backend/core-service/alembic/env.py
@@ -0,0 +1,59 @@
+"""محیط اجرای Alembic.
+
+از درایور sync (psycopg) برای migrationها استفاده میشود؛ آدرس آن از
+تنظیمات (settings.database_url_sync) خوانده میشود تا چیزی hardcode نشود.
+"""
+from __future__ import annotations
+
+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
+
+# ثبت همه مدلها روی Base.metadata برای autogenerate.
+import app.models # noqa: F401
+
+config = context.config
+config.set_main_option("sqlalchemy.url", settings.database_url_sync)
+
+if config.config_file_name is not None:
+ fileConfig(config.config_file_name)
+
+target_metadata = Base.metadata
+
+
+def run_migrations_offline() -> None:
+ context.configure(
+ url=settings.database_url_sync,
+ target_metadata=target_metadata,
+ literal_binds=True,
+ dialect_opts={"paramstyle": "named"},
+ compare_type=True,
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online() -> None:
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section, {}),
+ prefix="sqlalchemy.",
+ poolclass=pool.NullPool,
+ )
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata,
+ compare_type=True,
+ )
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/backend/core-service/alembic/script.py.mako b/backend/core-service/alembic/script.py.mako
new file mode 100644
index 0000000..aa5053c
--- /dev/null
+++ b/backend/core-service/alembic/script.py.mako
@@ -0,0 +1,26 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision: str = ${repr(up_revision)}
+down_revision: Union[str, None] = ${repr(down_revision)}
+branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
+depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
+
+
+def upgrade() -> None:
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade() -> None:
+ ${downgrades if downgrades else "pass"}
diff --git a/backend/core-service/alembic/versions/0001_initial.py b/backend/core-service/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..e457062
--- /dev/null
+++ b/backend/core-service/alembic/versions/0001_initial.py
@@ -0,0 +1,35 @@
+"""initial core platform schema
+
+Revision ID: 0001_initial
+Revises:
+Create Date: 2026-07-06
+
+این migration اولیه، اسکیمای کامل Core Platform را از روی metadata مدلها
+میسازد تا با تعریف مدلها همیشه هماهنگ بماند. migrationهای بعدی بهصورت
+autogenerate و افزایشی ساخته خواهند شد.
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+from app.core.database import Base
+
+# import مدلها تا همه جدولها روی metadata ثبت شوند.
+import app.models # noqa: F401
+
+revision: str = "0001_initial"
+down_revision: Union[str, None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ Base.metadata.create_all(bind=bind)
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+ Base.metadata.drop_all(bind=bind)
diff --git a/backend/core-service/alembic/versions/0002_users.py b/backend/core-service/alembic/versions/0002_users.py
new file mode 100644
index 0000000..237d3b3
--- /dev/null
+++ b/backend/core-service/alembic/versions/0002_users.py
@@ -0,0 +1,64 @@
+"""users table
+
+Revision ID: 0002_users
+Revises: 0001_initial
+Create Date: 2026-07-06
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0002_users"
+down_revision: Union[str, None] = "0001_initial"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "users",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("mobile", sa.String(length=15), nullable=False),
+ sa.Column(
+ "role",
+ sa.Enum(
+ "user",
+ "pending_tenant_admin",
+ "tenant_admin",
+ "platform_admin",
+ name="user_role",
+ ),
+ nullable=False,
+ ),
+ sa.Column(
+ "status",
+ sa.Enum("active", "inactive", "suspended", name="user_status"),
+ nullable=False,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=False,
+ ),
+ sa.PrimaryKeyConstraint("id"),
+ )
+ op.create_index("ix_users_mobile", "users", ["mobile"], unique=True)
+ op.create_index("ix_users_status", "users", ["status"])
+
+
+def downgrade() -> None:
+ op.drop_index("ix_users_status", table_name="users")
+ op.drop_index("ix_users_mobile", table_name="users")
+ op.drop_table("users")
+ op.execute("DROP TYPE IF EXISTS user_role")
+ op.execute("DROP TYPE IF EXISTS user_status")
diff --git a/backend/core-service/alembic/versions/0003_platform_admin.py b/backend/core-service/alembic/versions/0003_platform_admin.py
new file mode 100644
index 0000000..b76462f
--- /dev/null
+++ b/backend/core-service/alembic/versions/0003_platform_admin.py
@@ -0,0 +1,37 @@
+"""promote platform admin mobiles
+
+Revision ID: 0003_platform_admin
+Revises: 0002_users
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "0003_platform_admin"
+down_revision: Union[str, None] = "0002_users"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute(
+ """
+ UPDATE users
+ SET role = 'platform_admin'
+ WHERE mobile IN ('09155105404', '9155105404')
+ """
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ """
+ UPDATE users
+ SET role = 'pending_tenant_admin'
+ WHERE mobile IN ('09155105404', '9155105404')
+ AND role = 'platform_admin'
+ """
+ )
diff --git a/backend/core-service/alembic/versions/0004_user_sso_link.py b/backend/core-service/alembic/versions/0004_user_sso_link.py
new file mode 100644
index 0000000..6335218
--- /dev/null
+++ b/backend/core-service/alembic/versions/0004_user_sso_link.py
@@ -0,0 +1,35 @@
+"""user SSO link and mobile verification fields
+
+Revision ID: 0004_user_sso_link
+Revises: 0003_platform_admin
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0004_user_sso_link"
+down_revision: Union[str, None] = "0003_platform_admin"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("users", sa.Column("keycloak_sub", sa.String(length=100), nullable=True))
+ op.add_column("users", sa.Column("email", sa.String(length=255), nullable=True))
+ op.add_column(
+ "users",
+ sa.Column("mobile_verified", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ )
+ op.create_index("ix_users_keycloak_sub", "users", ["keycloak_sub"], unique=True)
+ op.alter_column("users", "mobile_verified", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_users_keycloak_sub", table_name="users")
+ op.drop_column("users", "mobile_verified")
+ op.drop_column("users", "email")
+ op.drop_column("users", "keycloak_sub")
diff --git a/backend/core-service/alembic/versions/0005_tenant_onboarding.py b/backend/core-service/alembic/versions/0005_tenant_onboarding.py
new file mode 100644
index 0000000..73eeacc
--- /dev/null
+++ b/backend/core-service/alembic/versions/0005_tenant_onboarding.py
@@ -0,0 +1,183 @@
+"""tenant onboarding: profile fields, memberships, domain verification, default plans
+
+Revision ID: 0005_tenant_onboarding
+Revises: 0004_user_sso_link
+Create Date: 2026-07-07
+
+این migration زیرساخت فاز Onboarding/Activation را اضافه میکند:
+ - فیلدهای پروفایل/برندینگ و onboarding_completed روی tenants
+ - مقادیر جدید چرخه عمر tenant_status (draft/pending_activation/archived)
+ - ستونهای is_primary و verification_status روی domains
+ - جدول tenant_memberships (رابطه واقعی user <-> tenant)
+ - ستون current_tenant_id روی users (tenant انتخابی جاری کاربر)
+ - seed پلنهای پیشفرض FREE و STARTER
+"""
+from __future__ import annotations
+
+import uuid
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0005_tenant_onboarding"
+down_revision: Union[str, None] = "0004_user_sso_link"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ bind = op.get_bind()
+ is_postgres = bind.dialect.name == "postgresql"
+
+ # ---- ۱) مقادیر جدید چرخه عمر tenant_status ----
+ if is_postgres:
+ for value in ("DRAFT", "PENDING_ACTIVATION", "ARCHIVED"):
+ op.execute(f"ALTER TYPE tenant_status ADD VALUE IF NOT EXISTS '{value}'")
+
+ # ---- ۲) پروفایل/برندینگ روی tenants ----
+ op.add_column("tenants", sa.Column("business_type", sa.String(length=100), nullable=True))
+ op.add_column(
+ "tenants",
+ sa.Column("default_locale", sa.String(length=20), nullable=False, server_default="fa-IR"),
+ )
+ op.add_column(
+ "tenants",
+ sa.Column("timezone", sa.String(length=50), nullable=False, server_default="Asia/Tehran"),
+ )
+ op.add_column("tenants", sa.Column("primary_color", sa.String(length=20), nullable=True))
+ op.add_column("tenants", sa.Column("secondary_color", sa.String(length=20), nullable=True))
+ op.add_column("tenants", sa.Column("logo_url", sa.String(length=500), nullable=True))
+ op.add_column("tenants", sa.Column("favicon_url", sa.String(length=500), nullable=True))
+ op.add_column(
+ "tenants",
+ sa.Column(
+ "onboarding_completed", sa.Boolean(), nullable=False, server_default=sa.text("false")
+ ),
+ )
+ op.alter_column("tenants", "default_locale", server_default=None)
+ op.alter_column("tenants", "timezone", server_default=None)
+ op.alter_column("tenants", "onboarding_completed", server_default=None)
+
+ # ---- ۳) وضعیت تأیید دامنه ----
+ domain_verification_status = sa.Enum(
+ "pending", "verified", "failed", name="domain_verification_status"
+ )
+ domain_verification_status.create(bind, checkfirst=True)
+ op.add_column(
+ "domains", sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false"))
+ )
+ op.add_column(
+ "domains",
+ sa.Column(
+ "verification_status",
+ domain_verification_status,
+ nullable=False,
+ server_default="pending",
+ ),
+ )
+ op.execute("UPDATE domains SET verification_status = 'verified' WHERE is_verified = true")
+ op.alter_column("domains", "is_primary", server_default=None)
+ op.alter_column("domains", "verification_status", server_default=None)
+
+ # ---- ۴) tenant انتخابی جاری کاربر ----
+ op.add_column("users", sa.Column("current_tenant_id", sa.UUID(), nullable=True))
+ op.create_foreign_key(
+ "fk_users_current_tenant_id",
+ "users",
+ "tenants",
+ ["current_tenant_id"],
+ ["id"],
+ ondelete="SET NULL",
+ )
+
+ # ---- ۵) جدول tenant_memberships ----
+ op.create_table(
+ "tenant_memberships",
+ sa.Column("id", sa.UUID(), nullable=False),
+ sa.Column("tenant_id", sa.UUID(), nullable=False),
+ sa.Column("user_id", sa.UUID(), nullable=False),
+ sa.Column(
+ "role",
+ sa.Enum(
+ "platform_admin",
+ "tenant_owner",
+ "tenant_admin",
+ "tenant_editor",
+ "tenant_viewer",
+ name="tenant_membership_role",
+ ),
+ nullable=False,
+ ),
+ sa.Column(
+ "status",
+ sa.Enum("active", "invited", "disabled", name="tenant_membership_status"),
+ nullable=False,
+ ),
+ sa.Column("is_owner", sa.Boolean(), nullable=False, server_default=sa.text("false")),
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
+ sa.ForeignKeyConstraint(["tenant_id"], ["tenants.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.PrimaryKeyConstraint("id"),
+ sa.UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
+ )
+ op.create_index("ix_tenant_memberships_tenant_id", "tenant_memberships", ["tenant_id"])
+ op.create_index("ix_tenant_memberships_user_id", "tenant_memberships", ["user_id"])
+ op.create_index("ix_tenant_memberships_role", "tenant_memberships", ["role"])
+ op.create_index("ix_tenant_memberships_status", "tenant_memberships", ["status"])
+ op.alter_column("tenant_memberships", "is_owner", server_default=None)
+
+ # ---- ۶) seed پلنهای پیشفرض (idempotent — بر اساس code) ----
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO plans (id, code, name, description, is_active)
+ VALUES (CAST(:id AS uuid), 'FREE', 'رایگان', 'پلن پیشفرض onboarding — بدون هزینه', true)
+ ON CONFLICT (code) DO NOTHING
+ """
+ ).bindparams(id=str(uuid.uuid4()))
+ )
+ op.execute(
+ sa.text(
+ """
+ INSERT INTO plans (id, code, name, description, is_active)
+ VALUES (CAST(:id AS uuid), 'STARTER', 'استارتر', 'پلن مقدماتی برای کسبوکارهای کوچک', true)
+ ON CONFLICT (code) DO NOTHING
+ """
+ ).bindparams(id=str(uuid.uuid4()))
+ )
+
+
+def downgrade() -> None:
+ bind = op.get_bind()
+
+ op.execute("DELETE FROM plans WHERE code IN ('FREE', 'STARTER')")
+
+ op.drop_index("ix_tenant_memberships_status", table_name="tenant_memberships")
+ op.drop_index("ix_tenant_memberships_role", table_name="tenant_memberships")
+ op.drop_index("ix_tenant_memberships_user_id", table_name="tenant_memberships")
+ op.drop_index("ix_tenant_memberships_tenant_id", table_name="tenant_memberships")
+ op.drop_table("tenant_memberships")
+ op.execute("DROP TYPE IF EXISTS tenant_membership_status")
+ op.execute("DROP TYPE IF EXISTS tenant_membership_role")
+
+ op.drop_constraint("fk_users_current_tenant_id", "users", type_="foreignkey")
+ op.drop_column("users", "current_tenant_id")
+
+ op.drop_column("domains", "verification_status")
+ op.drop_column("domains", "is_primary")
+ sa.Enum(name="domain_verification_status").drop(bind, checkfirst=True)
+
+ op.drop_column("tenants", "onboarding_completed")
+ op.drop_column("tenants", "favicon_url")
+ op.drop_column("tenants", "logo_url")
+ op.drop_column("tenants", "secondary_color")
+ op.drop_column("tenants", "primary_color")
+ op.drop_column("tenants", "timezone")
+ op.drop_column("tenants", "default_locale")
+ op.drop_column("tenants", "business_type")
+
+ # توجه: مقادیر جدید tenant_status (draft/pending_activation/archived) در
+ # PostgreSQL قابل حذف مستقیم از نوع enum نیستند؛ در صورت نیاز باید نوع را
+ # بازسازی کرد. در این downgrade نادیده گرفته میشود.
\ No newline at end of file
diff --git a/backend/core-service/app/__init__.py b/backend/core-service/app/__init__.py
new file mode 100644
index 0000000..a41c71b
--- /dev/null
+++ b/backend/core-service/app/__init__.py
@@ -0,0 +1,3 @@
+"""Core Platform Service - بسته اصلی برنامه."""
+
+__version__ = "0.1.0"
diff --git a/backend/core-service/app/api/__init__.py b/backend/core-service/app/api/__init__.py
new file mode 100644
index 0000000..917e81b
--- /dev/null
+++ b/backend/core-service/app/api/__init__.py
@@ -0,0 +1 @@
+"""لایه API."""
diff --git a/backend/core-service/app/api/deps.py b/backend/core-service/app/api/deps.py
new file mode 100644
index 0000000..e27cfde
--- /dev/null
+++ b/backend/core-service/app/api/deps.py
@@ -0,0 +1,106 @@
+"""Dependencyهای مشترک API."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+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, get_optional_user
+from app.models.user import User
+from app.services.user_service import UserService
+from shared.exceptions import TenantNotResolvedError
+from shared.pagination import PaginationParams
+from shared.security import CurrentUser
+
+__all__ = [
+ "get_db",
+ "get_pagination",
+ "require_tenant",
+ "get_current_core_user",
+ "get_optional_core_user",
+ "TenantResolution",
+ "get_tenant_resolution",
+ "AsyncSession",
+]
+
+
+def get_pagination(
+ page: int = Query(default=1, ge=1),
+ page_size: int = Query(default=20, ge=1, le=100),
+) -> PaginationParams:
+ return PaginationParams(page=page, page_size=page_size)
+
+
+def require_tenant(request: Request) -> UUID:
+ """اطمینان از resolve شدن tenant برای endpointهای tenant-aware.
+
+ این dependency مقدار tenant_id را از request.state (که middleware پر
+ کرده) میخواند و در نبود آن خطای مناسب برمیگرداند.
+ """
+ tenant_id = getattr(request.state, "tenant_id", None)
+ if tenant_id is None:
+ raise TenantNotResolvedError(
+ "tenant قابل تشخیص نبود. یکی از هدرهای X-Tenant-ID یا "
+ "X-Tenant-Slug را ارسال کنید یا از دامنه معتبر استفاده نمایید."
+ )
+ return tenant_id
+
+
+async def get_current_core_user(
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+) -> User:
+ """کاربر Core متناظر با هویت جاری (JWT محلی یا Keycloak SSO).
+
+ برای endpointهای onboarding/tenant-context استفاده میشود که نیاز به
+ رکورد واقعی users دارند (نه فقط claims توکن).
+ """
+ return await UserService(db).resolve_current(user)
+
+
+async def get_optional_core_user(
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser | None = Depends(get_optional_user),
+) -> User | None:
+ if user is None:
+ return None
+ try:
+ return await UserService(db).resolve_current(user)
+ except Exception: # noqa: BLE001 - عدم لینک بودن یعنی کاربر Core ندارد
+ return None
+
+
+@dataclass(frozen=True)
+class TenantResolution:
+ """نتیجه تشخیص tenant جاری request (هدر/دامنه/tenant انتخابی کاربر)."""
+
+ tenant_id: UUID | None
+ tenant_slug: str | None
+ source: str # "header" | "domain" | "user_default" | "none"
+
+
+async def get_tenant_resolution(
+ request: Request,
+ core_user: User | None = Depends(get_optional_core_user),
+) -> TenantResolution:
+ """تشخیص tenant جاری: اول هدر/دامنه (middleware)، سپس tenant انتخابی کاربر.
+
+ این dependency جایگزین middleware نمیشود و endpointهای platform-admin
+ فعلی را تحت تأثیر قرار نمیدهد (صرفاً یک ابزار کمکی جدید است).
+ """
+ tenant_id = getattr(request.state, "tenant_id", None)
+ tenant_slug = getattr(request.state, "tenant_slug", None)
+ if tenant_id is not None:
+ return TenantResolution(tenant_id=tenant_id, tenant_slug=tenant_slug, source="header")
+
+ if core_user is not None and core_user.current_tenant_id is not None:
+ return TenantResolution(
+ tenant_id=core_user.current_tenant_id,
+ tenant_slug=None,
+ source="user_default",
+ )
+
+ return TenantResolution(tenant_id=None, tenant_slug=None, source="none")
diff --git a/backend/core-service/app/api/v1/__init__.py b/backend/core-service/app/api/v1/__init__.py
new file mode 100644
index 0000000..706ea82
--- /dev/null
+++ b/backend/core-service/app/api/v1/__init__.py
@@ -0,0 +1,34 @@
+"""API نسخه ۱: تجمیع routerها."""
+from fastapi import APIRouter
+
+from app.api.v1 import (
+ auth,
+ domains,
+ features,
+ health,
+ me,
+ onboarding,
+ plans,
+ service_registry,
+ subscriptions,
+ tenant_context,
+ tenants,
+)
+from app.api.v1.admin import tenants as admin_tenants
+
+api_router = APIRouter()
+
+# health خارج از prefix نسخه هم در main اضافه میشود؛ اینجا برای کامل بودن.
+api_router.include_router(auth.router)
+api_router.include_router(admin_tenants.router)
+api_router.include_router(tenants.router)
+api_router.include_router(domains.router)
+api_router.include_router(plans.router)
+api_router.include_router(features.router)
+api_router.include_router(subscriptions.router)
+api_router.include_router(service_registry.router)
+api_router.include_router(me.router)
+api_router.include_router(onboarding.router)
+api_router.include_router(tenant_context.router)
+
+__all__ = ["api_router", "health"]
diff --git a/backend/core-service/app/api/v1/admin/__init__.py b/backend/core-service/app/api/v1/admin/__init__.py
new file mode 100644
index 0000000..513f75c
--- /dev/null
+++ b/backend/core-service/app/api/v1/admin/__init__.py
@@ -0,0 +1,4 @@
+"""Routerهای پنل ادمین."""
+from app.api.v1.admin import tenants
+
+__all__ = ["tenants"]
diff --git a/backend/core-service/app/api/v1/admin/tenants.py b/backend/core-service/app/api/v1/admin/tenants.py
new file mode 100644
index 0000000..dc0ff44
--- /dev/null
+++ b/backend/core-service/app/api/v1/admin/tenants.py
@@ -0,0 +1,63 @@
+"""APIهای مدیریت Tenant برای پنل ادمین."""
+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
+from app.core.security import require_authenticated
+from app.schemas.tenant import AdminTenantCreate, TenantRead, TenantUpdate
+from app.services.tenant_service import TenantService
+from shared.pagination import Page, PaginationParams
+from shared.security import CurrentUser
+
+router = APIRouter(prefix="/admin/tenants", tags=["admin-tenants"])
+
+
+@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
+async def create_tenant(
+ payload: AdminTenantCreate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> TenantRead:
+ tenant = await TenantService(db).create_for_user(user, payload)
+ return TenantRead.model_validate(tenant)
+
+
+@router.get("", response_model=Page[TenantRead])
+async def list_tenants(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> Page[TenantRead]:
+ items, total = await TenantService(db).list_for_user(
+ user, offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [TenantRead.model_validate(t) for t in items], total, pagination
+ )
+
+
+@router.get("/{tenant_id}", response_model=TenantRead)
+async def get_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> TenantRead:
+ svc = TenantService(db)
+ tenant = await svc.get(tenant_id)
+ svc.ensure_access(tenant, user)
+ return TenantRead.model_validate(tenant)
+
+
+@router.patch("/{tenant_id}", response_model=TenantRead)
+async def update_tenant(
+ tenant_id: UUID,
+ payload: TenantUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> TenantRead:
+ tenant = await TenantService(db).update(tenant_id, payload, user=user)
+ return TenantRead.model_validate(tenant)
diff --git a/backend/core-service/app/api/v1/auth.py b/backend/core-service/app/api/v1/auth.py
new file mode 100644
index 0000000..11e0693
--- /dev/null
+++ b/backend/core-service/app/api/v1/auth.py
@@ -0,0 +1,32 @@
+"""APIهای احراز هویت OTP."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db
+from app.schemas.auth import (
+ OtpRequestPayload,
+ OtpRequestResponse,
+ OtpVerifyPayload,
+ TokenResponse,
+)
+from app.services.auth_service import AuthService
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.post("/otp/request", response_model=OtpRequestResponse)
+async def request_otp(
+ payload: OtpRequestPayload,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ return await AuthService(db).request_otp(payload.mobile, force_sms=payload.force_sms, context=payload.context)
+
+
+@router.post("/otp/verify", response_model=TokenResponse)
+async def verify_otp(
+ payload: OtpVerifyPayload,
+ db: AsyncSession = Depends(get_db),
+) -> TokenResponse:
+ return await AuthService(db).verify_otp(payload)
diff --git a/backend/core-service/app/api/v1/domains.py b/backend/core-service/app/api/v1/domains.py
new file mode 100644
index 0000000..cfd4f12
--- /dev/null
+++ b/backend/core-service/app/api/v1/domains.py
@@ -0,0 +1,59 @@
+"""APIهای مدیریت و resolve دامنه."""
+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
+from app.core.security import require_authenticated, require_tenant_admin
+from app.schemas.domain import (
+ DomainCreate,
+ DomainRead,
+ DomainResolveRequest,
+ DomainResolveResponse,
+)
+from app.services.domain_service import DomainService
+from app.services.tenant_service import TenantService
+
+router = APIRouter(tags=["domains"])
+
+
+@router.post(
+ "/tenants/{tenant_id}/domains",
+ response_model=DomainRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_domain(
+ tenant_id: UUID,
+ payload: DomainCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_tenant_admin),
+) -> DomainRead:
+ domain = await DomainService(db).create(tenant_id, payload)
+ return DomainRead.model_validate(domain)
+
+
+@router.get("/tenants/{tenant_id}/domains", response_model=list[DomainRead])
+async def list_domains(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> list[DomainRead]:
+ domains = await DomainService(db).list_by_tenant(tenant_id)
+ return [DomainRead.model_validate(d) for d in domains]
+
+
+@router.post("/domains/resolve", response_model=DomainResolveResponse)
+async def resolve_domain(
+ payload: DomainResolveRequest, db: AsyncSession = Depends(get_db)
+) -> DomainResolveResponse:
+ domain = await DomainService(db).resolve(payload.domain)
+ tenant = await TenantService(db).get(domain.tenant_id)
+ return DomainResolveResponse(
+ tenant_id=tenant.id,
+ tenant_slug=tenant.slug,
+ domain=domain.domain,
+ is_verified=domain.is_verified,
+ )
diff --git a/backend/core-service/app/api/v1/features.py b/backend/core-service/app/api/v1/features.py
new file mode 100644
index 0000000..ce58b58
--- /dev/null
+++ b/backend/core-service/app/api/v1/features.py
@@ -0,0 +1,37 @@
+"""APIهای مدیریت قابلیتها (Features)."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_db, get_pagination
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.plan import FeatureCreate, FeatureRead
+from app.services.plan_service import PlanService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/features", tags=["features"])
+
+
+@router.post("", response_model=FeatureRead, status_code=status.HTTP_201_CREATED)
+async def create_feature(
+ payload: FeatureCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> FeatureRead:
+ feature = await PlanService(db).create_feature(payload)
+ return FeatureRead.model_validate(feature)
+
+
+@router.get("", response_model=Page[FeatureRead])
+async def list_features(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[FeatureRead]:
+ items, total = await PlanService(db).list_features(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [FeatureRead.model_validate(f) for f in items], total, pagination
+ )
diff --git a/backend/core-service/app/api/v1/health.py b/backend/core-service/app/api/v1/health.py
new file mode 100644
index 0000000..3f42679
--- /dev/null
+++ b/backend/core-service/app/api/v1/health.py
@@ -0,0 +1,24 @@
+"""Endpoint سلامت سرویس."""
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from app import __version__
+from app.core.cache import cache
+from app.core.config import settings
+from app.schemas.common import HealthResponse
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health", response_model=HealthResponse)
+async def health() -> HealthResponse:
+ """بررسی سلامت سرویس و وابستگیها (Redis)."""
+ redis_ok = await cache.ping()
+ return HealthResponse(
+ status="ok",
+ service=settings.service_name,
+ version=__version__,
+ environment=settings.environment,
+ dependencies={"redis": "ok" if redis_ok else "unavailable"},
+ )
diff --git a/backend/core-service/app/api/v1/me.py b/backend/core-service/app/api/v1/me.py
new file mode 100644
index 0000000..3546854
--- /dev/null
+++ b/backend/core-service/app/api/v1/me.py
@@ -0,0 +1,46 @@
+"""API `/me`: هویت جاری، عضویتها و نیاز به onboarding."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_core_user, get_db
+from app.models.user import User
+from app.schemas.onboarding import MembershipSummary, MeResponse
+from app.services.tenant_context_service import TenantContextService
+
+router = APIRouter(prefix="/me", tags=["me"])
+
+
+@router.get("", response_model=MeResponse)
+async def get_me(
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> MeResponse:
+ memberships = await TenantContextService(db).list_memberships_summary(core_user)
+
+ onboarding_required = True
+ if memberships:
+ current = next(
+ (m for m in memberships if m.tenant_id == core_user.current_tenant_id),
+ memberships[0],
+ )
+ onboarding_required = not current.onboarding_completed
+
+ return MeResponse(
+ user_id=core_user.id,
+ mobile=core_user.mobile,
+ email=core_user.email,
+ platform_role=core_user.role.value,
+ onboarding_required=onboarding_required,
+ current_tenant_id=core_user.current_tenant_id,
+ memberships=memberships,
+ )
+
+
+@router.get("/tenants", response_model=list[MembershipSummary])
+async def get_my_tenants(
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> list[MembershipSummary]:
+ return await TenantContextService(db).list_memberships_summary(core_user)
diff --git a/backend/core-service/app/api/v1/onboarding.py b/backend/core-service/app/api/v1/onboarding.py
new file mode 100644
index 0000000..bf356db
--- /dev/null
+++ b/backend/core-service/app/api/v1/onboarding.py
@@ -0,0 +1,77 @@
+"""APIهای Onboarding: ایجاد و فعالسازی tenant توسط کاربر جاری."""
+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_current_core_user, get_db
+from app.core.security import get_current_user
+from app.models.user import User
+from app.schemas.onboarding import (
+ OnboardingBrandingUpdate,
+ OnboardingDomainUpdate,
+ OnboardingTenantCreate,
+ TenantContextRead,
+)
+from app.services.membership_service import MembershipService
+from app.services.onboarding_service import OnboardingService
+from app.services.tenant_context_service import TenantContextService
+from shared.security import CurrentUser
+
+router = APIRouter(prefix="/onboarding/tenant", tags=["onboarding"])
+
+
+@router.post("", response_model=TenantContextRead, status_code=status.HTTP_201_CREATED)
+async def create_onboarding_tenant(
+ payload: OnboardingTenantCreate,
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ tenant = await OnboardingService(db).create_tenant(core_user, payload)
+ return await TenantContextService(db).build(tenant, core_user)
+
+
+@router.patch("/{tenant_id}/branding", response_model=TenantContextRead)
+async def update_onboarding_branding(
+ tenant_id: UUID,
+ payload: OnboardingBrandingUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ await MembershipService(db).ensure_role(
+ tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
+ )
+ tenant = await OnboardingService(db).update_branding(tenant_id, payload)
+ return await TenantContextService(db).build(tenant, core_user)
+
+
+@router.patch("/{tenant_id}/domain", response_model=TenantContextRead)
+async def update_onboarding_domain(
+ tenant_id: UUID,
+ payload: OnboardingDomainUpdate,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ await MembershipService(db).ensure_role(
+ tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
+ )
+ tenant = await OnboardingService(db).update_domain(tenant_id, payload)
+ return await TenantContextService(db).build(tenant, core_user)
+
+
+@router.post("/{tenant_id}/complete", response_model=TenantContextRead)
+async def complete_onboarding(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(get_current_user),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ await MembershipService(db).ensure_role(
+ tenant_id=tenant_id, core_user_id=core_user.id, current_user=user
+ )
+ tenant = await OnboardingService(db).complete(tenant_id)
+ return await TenantContextService(db).build(tenant, core_user)
diff --git a/backend/core-service/app/api/v1/plans.py b/backend/core-service/app/api/v1/plans.py
new file mode 100644
index 0000000..560cc04
--- /dev/null
+++ b/backend/core-service/app/api/v1/plans.py
@@ -0,0 +1,57 @@
+"""APIهای مدیریت پلنها و اتصال قابلیت به پلن."""
+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
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.plan import (
+ PlanCreate,
+ PlanFeatureCreate,
+ PlanFeatureRead,
+ PlanRead,
+)
+from app.services.plan_service import PlanService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/plans", tags=["plans"])
+
+
+@router.post("", response_model=PlanRead, status_code=status.HTTP_201_CREATED)
+async def create_plan(
+ payload: PlanCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> PlanRead:
+ plan = await PlanService(db).create_plan(payload)
+ return PlanRead.model_validate(plan)
+
+
+@router.get("", response_model=Page[PlanRead])
+async def list_plans(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[PlanRead]:
+ items, total = await PlanService(db).list_plans(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create([PlanRead.model_validate(p) for p in items], total, pagination)
+
+
+@router.post(
+ "/{plan_id}/features",
+ response_model=PlanFeatureRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def attach_feature_to_plan(
+ plan_id: UUID,
+ payload: PlanFeatureCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> PlanFeatureRead:
+ plan_feature = await PlanService(db).attach_feature(plan_id, payload)
+ return PlanFeatureRead.model_validate(plan_feature)
diff --git a/backend/core-service/app/api/v1/service_registry.py b/backend/core-service/app/api/v1/service_registry.py
new file mode 100644
index 0000000..3346dca
--- /dev/null
+++ b/backend/core-service/app/api/v1/service_registry.py
@@ -0,0 +1,54 @@
+"""APIهای ServiceRegistry."""
+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
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.service_registry import (
+ ServiceCreate,
+ ServiceRead,
+ ServiceStatusUpdate,
+)
+from app.services.service_registry_service import ServiceRegistryService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/services", tags=["service-registry"])
+
+
+@router.post("", response_model=ServiceRead, status_code=status.HTTP_201_CREATED)
+async def create_service(
+ payload: ServiceCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> ServiceRead:
+ service = await ServiceRegistryService(db).create(payload)
+ return ServiceRead.model_validate(service)
+
+
+@router.get("", response_model=Page[ServiceRead])
+async def list_services(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[ServiceRead]:
+ items, total = await ServiceRegistryService(db).list(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [ServiceRead.model_validate(s) for s in items], total, pagination
+ )
+
+
+@router.patch("/{service_id}/status", response_model=ServiceRead)
+async def update_service_status(
+ service_id: UUID,
+ payload: ServiceStatusUpdate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> ServiceRead:
+ service = await ServiceRegistryService(db).update_status(service_id, payload.status)
+ return ServiceRead.model_validate(service)
diff --git a/backend/core-service/app/api/v1/subscriptions.py b/backend/core-service/app/api/v1/subscriptions.py
new file mode 100644
index 0000000..4ed97cd
--- /dev/null
+++ b/backend/core-service/app/api/v1/subscriptions.py
@@ -0,0 +1,61 @@
+"""APIهای اشتراک و بررسی دسترسی قابلیت."""
+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
+from app.core.security import require_authenticated, require_tenant_admin
+from app.schemas.subscription import (
+ FeatureCheckRequest,
+ FeatureCheckResponse,
+ SubscriptionCreate,
+ SubscriptionRead,
+)
+from app.services.entitlement_service import EntitlementService
+from app.services.subscription_service import SubscriptionService
+
+router = APIRouter(prefix="/tenants/{tenant_id}", tags=["subscription"])
+
+
+@router.post(
+ "/subscription",
+ response_model=SubscriptionRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def create_subscription(
+ tenant_id: UUID,
+ payload: SubscriptionCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_tenant_admin),
+) -> SubscriptionRead:
+ subscription = await SubscriptionService(db).create(tenant_id, payload)
+ return SubscriptionRead.model_validate(subscription)
+
+
+@router.get("/subscription", response_model=SubscriptionRead)
+async def get_subscription(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> SubscriptionRead:
+ subscription = await SubscriptionService(db).get_current(tenant_id)
+ return SubscriptionRead.model_validate(subscription)
+
+
+@router.post("/features/check", response_model=FeatureCheckResponse)
+async def check_feature(
+ tenant_id: UUID,
+ payload: FeatureCheckRequest,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> FeatureCheckResponse:
+ result = await EntitlementService(db).evaluate(tenant_id, payload.feature_key)
+ return FeatureCheckResponse(
+ tenant_id=tenant_id,
+ feature_key=payload.feature_key,
+ has_access=result.has_access,
+ reason=result.reason,
+ )
diff --git a/backend/core-service/app/api/v1/tenant_context.py b/backend/core-service/app/api/v1/tenant_context.py
new file mode 100644
index 0000000..de01c06
--- /dev/null
+++ b/backend/core-service/app/api/v1/tenant_context.py
@@ -0,0 +1,46 @@
+"""APIهای Tenant Context جاری: `/tenant/current` و `/tenant/switch`."""
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.api.deps import get_current_core_user, get_db
+from app.models.enums import MembershipStatus
+from app.models.user import User
+from app.repositories.tenant import TenantRepository
+from app.schemas.onboarding import TenantContextRead, TenantSwitchRequest
+from app.services.membership_service import MembershipService
+from app.services.tenant_context_service import TenantContextService
+from shared.exceptions import ForbiddenError, NotFoundError
+
+router = APIRouter(prefix="/tenant", tags=["tenant-context"])
+
+
+@router.get("/current", response_model=TenantContextRead)
+async def get_current_tenant(
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ ctx_service = TenantContextService(db)
+ tenant = await ctx_service.resolve_current_tenant(core_user)
+ return await ctx_service.build(tenant, core_user)
+
+
+@router.post("/switch", response_model=TenantContextRead)
+async def switch_tenant(
+ payload: TenantSwitchRequest,
+ db: AsyncSession = Depends(get_db),
+ core_user: User = Depends(get_current_core_user),
+) -> TenantContextRead:
+ membership = await MembershipService(db).get(payload.tenant_id, core_user.id)
+ if membership is None or membership.status != MembershipStatus.ACTIVE:
+ raise ForbiddenError("عضو این tenant نیستید یا عضویت غیرفعال است")
+
+ core_user.current_tenant_id = payload.tenant_id
+ await db.commit()
+
+ tenant = await TenantRepository(db).get(payload.tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {payload.tenant_id}")
+ ctx_service = TenantContextService(db)
+ return await ctx_service.build(tenant, core_user)
diff --git a/backend/core-service/app/api/v1/tenants.py b/backend/core-service/app/api/v1/tenants.py
new file mode 100644
index 0000000..667b62d
--- /dev/null
+++ b/backend/core-service/app/api/v1/tenants.py
@@ -0,0 +1,80 @@
+"""APIهای مدیریت Tenant."""
+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
+from app.core.security import require_authenticated, require_platform_admin, require_tenant_admin
+from app.schemas.tenant import TenantCreate, TenantRead, TenantUpdate
+from app.services.tenant_service import TenantService
+from shared.pagination import Page, PaginationParams
+
+router = APIRouter(prefix="/tenants", tags=["tenants"])
+
+
+@router.post("", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
+async def create_tenant(
+ payload: TenantCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).create(payload)
+ return TenantRead.model_validate(tenant)
+
+
+@router.get("", response_model=Page[TenantRead])
+async def list_tenants(
+ pagination: PaginationParams = Depends(get_pagination),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> Page[TenantRead]:
+ items, total = await TenantService(db).list(
+ offset=pagination.offset, limit=pagination.limit
+ )
+ return Page.create(
+ [TenantRead.model_validate(t) for t in items], total, pagination
+ )
+
+
+@router.get("/{tenant_id}", response_model=TenantRead)
+async def get_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_authenticated),
+) -> TenantRead:
+ tenant = await TenantService(db).get(tenant_id)
+ return TenantRead.model_validate(tenant)
+
+
+@router.patch("/{tenant_id}", response_model=TenantRead)
+async def update_tenant(
+ tenant_id: UUID,
+ payload: TenantUpdate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_tenant_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).update(tenant_id, payload)
+ return TenantRead.model_validate(tenant)
+
+
+@router.post("/{tenant_id}/suspend", response_model=TenantRead)
+async def suspend_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).suspend(tenant_id)
+ return TenantRead.model_validate(tenant)
+
+
+@router.post("/{tenant_id}/activate", response_model=TenantRead)
+async def activate_tenant(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> TenantRead:
+ tenant = await TenantService(db).activate(tenant_id)
+ return TenantRead.model_validate(tenant)
diff --git a/backend/core-service/app/core/__init__.py b/backend/core-service/app/core/__init__.py
new file mode 100644
index 0000000..637d092
--- /dev/null
+++ b/backend/core-service/app/core/__init__.py
@@ -0,0 +1 @@
+"""زیرساخت هسته: config، database، cache، security، logging."""
diff --git a/backend/core-service/app/core/cache.py b/backend/core-service/app/core/cache.py
new file mode 100644
index 0000000..f8d77f9
--- /dev/null
+++ b/backend/core-service/app/core/cache.py
@@ -0,0 +1,129 @@
+"""لایه Cache مبتنی بر Redis (async).
+
+اگر Redis در دسترس نباشد (مثلاً در تستها)، عملیات cache بهصورت
+graceful نادیده گرفته میشوند تا برنامه از کار نیفتد.
+
+کلیدهای پیشنهادی:
+ tenant:{tenant_id}:metadata
+ tenant:{tenant_id}:features
+ feature_access:{tenant_id}:{feature_key}
+"""
+from __future__ import annotations
+
+from typing import Any
+
+import redis.asyncio as aioredis
+
+from app.core.config import settings
+from app.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+
+class CacheClient:
+ """پوشش نازک روی redis.asyncio با تحمل خطا (fail-open)."""
+
+ def __init__(self, url: str) -> None:
+ self._url = url
+ self._client: aioredis.Redis | None = None
+ self._enabled = True
+
+ @property
+ def client(self) -> aioredis.Redis | None:
+ if not self._enabled:
+ return None
+ if self._client is None:
+ self._client = aioredis.from_url(
+ self._url, encoding="utf-8", decode_responses=True
+ )
+ return self._client
+
+ async def get(self, key: str) -> str | None:
+ client = self.client
+ if client is None:
+ return None
+ try:
+ return await client.get(key)
+ except Exception as exc: # pragma: no cover - وابسته به محیط
+ logger.warning("cache_get_failed", extra={"key": key, "error": str(exc)})
+ return None
+
+ async def set(self, key: str, value: str, ttl: int | None = None) -> None:
+ client = self.client
+ if client is None:
+ return
+ try:
+ await client.set(key, value, ex=ttl)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_set_failed", extra={"key": key, "error": str(exc)})
+
+ async def ttl(self, key: str) -> int | None:
+ """ثانیه باقیمانده TTL — None اگر کلید نیست یا Redis غیرفعال."""
+ client = self.client
+ if client is None:
+ return None
+ try:
+ remaining = await client.ttl(key)
+ if remaining is None or remaining < 0:
+ return None
+ return int(remaining)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_ttl_failed", extra={"key": key, "error": str(exc)})
+ return None
+
+ async def delete(self, *keys: str) -> None:
+ client = self.client
+ if client is None or not keys:
+ return
+ try:
+ await client.delete(*keys)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_delete_failed", extra={"keys": keys, "error": str(exc)})
+
+ async def delete_pattern(self, pattern: str) -> None:
+ """حذف کلیدها بر اساس الگو (برای invalidate کردن گروهی)."""
+ client = self.client
+ if client is None:
+ return
+ try:
+ async for key in client.scan_iter(match=pattern):
+ await client.delete(key)
+ except Exception as exc: # pragma: no cover
+ logger.warning("cache_delete_pattern_failed", extra={"pattern": pattern, "error": str(exc)})
+
+ async def ping(self) -> bool:
+ client = self.client
+ if client is None:
+ return False
+ try:
+ return bool(await client.ping())
+ except Exception: # pragma: no cover
+ return False
+
+ async def close(self) -> None:
+ if self._client is not None:
+ try:
+ await self._client.aclose()
+ except Exception: # pragma: no cover
+ pass
+ self._client = None
+
+ def disable(self) -> None:
+ """غیرفعالسازی cache (مثلاً در تستها)."""
+ self._enabled = False
+
+
+# ---- کمککنندههای ساخت کلید (تا نام کلیدها یکسان بماند) ----
+def tenant_metadata_key(tenant_id: str) -> str:
+ return f"tenant:{tenant_id}:metadata"
+
+
+def tenant_features_key(tenant_id: str) -> str:
+ return f"tenant:{tenant_id}:features"
+
+
+def feature_access_key(tenant_id: str, feature_key: str) -> str:
+ return f"feature_access:{tenant_id}:{feature_key}"
+
+
+cache = CacheClient(settings.redis_url)
diff --git a/backend/core-service/app/core/config.py b/backend/core-service/app/core/config.py
new file mode 100644
index 0000000..6f2afd2
--- /dev/null
+++ b/backend/core-service/app/core/config.py
@@ -0,0 +1,154 @@
+"""تنظیمات مرکزی برنامه با Pydantic v2 Settings.
+
+همه مقادیر از متغیرهای محیطی (env) خوانده میشوند تا هیچ چیز در کد
+hardcode نشود (نام برند، رنگها، آدرسها و ...).
+"""
+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",
+ )
+
+ # ---- General ----
+ environment: Literal["development", "staging", "production", "test"] = "development"
+ debug: bool = True
+ log_level: str = "INFO"
+ service_name: str = "core-service"
+ api_v1_prefix: str = "/api/v1"
+
+ # ---- Platform Branding (White-label) ----
+ platform_name: str = "SuperApp"
+ platform_support_email: str = "support@example.com"
+ platform_primary_color: str = "#0284c7"
+ platform_secondary_color: str = "#0f172a"
+ # دامنه پایه برای تخصیص زیردامنه خودکار در onboarding (مثلاً tenant.slug + "." + این مقدار)
+ platform_base_domain: str = Field(default="", validation_alias="PLATFORM_BASE_DOMAIN")
+
+ # ---- Database ----
+ # آدرس async برای برنامه و sync برای Alembic.
+ database_url: str = "postgresql+asyncpg://superapp:superapp_password@localhost:5432/core_platform_db"
+ database_url_sync: str = "postgresql+psycopg://superapp:superapp_password@localhost:5432/core_platform_db"
+ db_echo: bool = False
+ db_pool_size: int = 10
+ db_max_overflow: int = 20
+
+ # ---- Redis ----
+ redis_url: str = "redis://localhost:6379/0"
+ feature_access_cache_ttl: int = 300
+ tenant_metadata_cache_ttl: int = 300
+
+ # ---- Celery ----
+ celery_broker_url: str = "redis://localhost:6379/1"
+ celery_result_backend: str = "redis://localhost:6379/2"
+
+ # ---- Keycloak / SSO ----
+ keycloak_enabled: bool = False
+ keycloak_server_url: str = "http://localhost:8080"
+ keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
+ keycloak_realm: str = "superapp"
+ keycloak_client_id: str = "core-service"
+ keycloak_client_secret: str = "change-me"
+ jwt_algorithm: str = "RS256"
+ jwt_audience: str = "account"
+ jwt_verify_signature: bool = False
+ # اگر true باشد همه endpointهای محافظتشده نیازمند JWT معتبر هستند.
+ auth_required: bool = False
+ cors_origins: str = Field(
+ default="http://localhost:3000,http://127.0.0.1:3000",
+ validation_alias="CORS_ORIGINS",
+ )
+
+ # ---- Identity Service ----
+ identity_service_url: str = "http://localhost:8001"
+
+ # ---- Internal Service Tokens ----
+ internal_token_secret: str = "change-me-internal-secret"
+
+ # ---- OTP / Payamak SMS ----
+ payamak_username: str = "9155105404"
+ payamak_from: str = Field(default="9982004961", validation_alias="PAYAMAK_FROM")
+ payamak_api_key: str = Field(default="", validation_alias="PAYAMAK_API_KEY")
+ payamak_apikey: str = Field(default="", validation_alias="PAYAMAK_APIKEY")
+ payamak_password: str = Field(default="", validation_alias="PAYAMAK_PASSWORD")
+ payamak_body_id: int = Field(default=245189, validation_alias="PAYAMAK_BODY_ID")
+ otp_expire_seconds: int = 120
+
+ # ---- Platform bootstrap admins (OTP) ----
+ platform_admin_mobiles: str = Field(
+ default="",
+ validation_alias="PLATFORM_ADMIN_MOBILES",
+ )
+
+ @property
+ def platform_admin_mobile_set(self) -> set[str]:
+ from app.utils.phone import normalize_mobile
+
+ result: set[str] = set()
+ for raw in self.platform_admin_mobiles.split(","):
+ item = raw.strip()
+ if not item:
+ continue
+ try:
+ result.add(normalize_mobile(item))
+ except ValueError:
+ continue
+ return result
+
+ @property
+ def payamak_auth_secret(self) -> str:
+ """ApiKey در پارامتر password (طبق مستندات ملیپیامک)."""
+ return (self.payamak_api_key or self.payamak_apikey or self.payamak_password).strip()
+
+ @property
+ def payamak_verification_body_id(self) -> int:
+ """شناسه الگوی کد تأیید — همان PAYAMAK_BODY_ID در torbatkar-back."""
+ return self.payamak_body_id
+
+ # ---- Local JWT (OTP users) ----
+ jwt_secret: str = "change-me-jwt-secret"
+ jwt_access_token_expire_seconds: int = 3600
+ jwt_refresh_token_expire_seconds: int = 604800
+
+ @property
+ def is_production(self) -> bool:
+ return self.environment == "production"
+
+ @property
+ def keycloak_browser_url(self) -> str:
+ return self.keycloak_public_url or self.keycloak_server_url
+
+ @property
+ def keycloak_public_realm_url(self) -> str:
+ return f"{self.keycloak_browser_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def cors_origin_list(self) -> list[str]:
+ return [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+
+ @property
+ def jwks_url(self) -> str:
+ """آدرس JWKS در Keycloak برای اعتبارسنجی امضای JWT."""
+ return (
+ f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+ "/protocol/openid-connect/certs"
+ )
+
+
+@lru_cache
+def get_settings() -> Settings:
+ """نمونه cache شده تنظیمات (singleton)."""
+ return Settings()
+
+
+settings = get_settings()
diff --git a/backend/core-service/app/core/database.py b/backend/core-service/app/core/database.py
new file mode 100644
index 0000000..af072f9
--- /dev/null
+++ b/backend/core-service/app/core/database.py
@@ -0,0 +1,50 @@
+"""راهاندازی SQLAlchemy 2.x بهصورت async.
+
+- Base declarative مشترک همه مدلها
+- Engine و session factory
+- Dependency برای دریافت AsyncSession در FastAPI
+"""
+from __future__ import annotations
+
+from collections.abc import AsyncGenerator
+
+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):
+ """پایه declarative مشترک برای همه مدلهای Core."""
+
+
+engine = create_async_engine(
+ settings.database_url,
+ echo=settings.db_echo,
+ pool_pre_ping=True,
+ future=True,
+)
+
+AsyncSessionLocal = async_sessionmaker(
+ bind=engine,
+ class_=AsyncSession,
+ expire_on_commit=False,
+ autoflush=False,
+)
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+ """Dependency: یک AsyncSession در طول عمر request فراهم میکند.
+
+ در صورت بروز خطا rollback و در پایان session بسته میشود.
+ """
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ except Exception:
+ await session.rollback()
+ raise
diff --git a/backend/core-service/app/core/logging.py b/backend/core-service/app/core/logging.py
new file mode 100644
index 0000000..cbc8ce6
--- /dev/null
+++ b/backend/core-service/app/core/logging.py
@@ -0,0 +1,61 @@
+"""پیکربندی لاگگیری ساختیافته (structured logging).
+
+از logging استاندارد پایتون با فرمت JSON-friendly استفاده میکنیم تا در
+production قابل جمعآوری توسط ابزارهای log aggregation باشد.
+"""
+from __future__ import annotations
+
+import logging
+import sys
+from typing import Any
+
+from app.core.config import settings
+
+_configured = False
+
+
+class _KeyValueFormatter(logging.Formatter):
+ """فرمت ساده key=value برای خوانایی و پردازش ماشینی."""
+
+ def format(self, record: logging.LogRecord) -> str:
+ base: dict[str, Any] = {
+ "level": record.levelname,
+ "logger": record.name,
+ "service": settings.service_name,
+ "msg": record.getMessage(),
+ }
+ if record.exc_info:
+ base["exc"] = self.formatException(record.exc_info)
+ # فیلدهای اضافی که با extra ارسال شدهاند
+ for key, value in record.__dict__.items():
+ if key in ("args", "msg", "levelname", "name", "exc_info", "exc_text",
+ "stack_info", "created", "msecs", "relativeCreated", "levelno",
+ "pathname", "filename", "module", "funcName", "lineno",
+ "processName", "process", "threadName", "thread", "taskName"):
+ continue
+ base.setdefault(key, value)
+ return " ".join(f"{k}={v!r}" for k, v in base.items())
+
+
+def configure_logging() -> None:
+ """پیکربندی سراسری لاگگیری (idempotent)."""
+ global _configured
+ if _configured:
+ return
+
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(_KeyValueFormatter())
+
+ root = logging.getLogger()
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(settings.log_level.upper())
+
+ # کاهش نویز لاگ کتابخانههای پرحرف
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
+ _configured = True
+
+
+def get_logger(name: str) -> logging.Logger:
+ configure_logging()
+ return logging.getLogger(name)
diff --git a/backend/core-service/app/core/security.py b/backend/core-service/app/core/security.py
new file mode 100644
index 0000000..0b7db84
--- /dev/null
+++ b/backend/core-service/app/core/security.py
@@ -0,0 +1,97 @@
+"""امنیت هسته: اعتبارسنجی JWT و dependencyهای احراز هویت/مجوز."""
+from __future__ import annotations
+
+from functools import lru_cache
+
+import jwt
+from fastapi import Depends
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+
+from app.core.config import settings
+from shared.auth import JWTSettings, JWTValidator, PlatformRole, has_any_role
+from shared.exceptions import ForbiddenError, UnauthorizedError
+from shared.security import CurrentUser
+
+from app.services.token_service import TokenService
+
+bearer_scheme = HTTPBearer(auto_error=False)
+_token_service = TokenService()
+
+
+@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_scheme),
+) -> CurrentUser:
+ if not settings.auth_required:
+ # در تستها یا حالت bootstrap بدون SSO
+ return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
+ if credentials is None or not credentials.credentials:
+ raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
+
+ token = credentials.credentials
+ try:
+ header = jwt.get_unverified_header(token)
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+
+ alg = header.get("alg", "")
+ if alg == "HS256":
+ return _token_service.validate_access_token(token)
+ return await get_jwt_validator().validate(token)
+
+
+async def get_optional_user(
+ credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
+) -> CurrentUser | None:
+ if not settings.auth_required:
+ return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
+ if credentials is None or not credentials.credentials:
+ return None
+ token = credentials.credentials
+ try:
+ header = jwt.get_unverified_header(token)
+ except jwt.PyJWTError:
+ return None
+ if header.get("alg") == "HS256":
+ try:
+ return _token_service.validate_access_token(token)
+ except UnauthorizedError:
+ return None
+ try:
+ return await get_jwt_validator().validate(token)
+ except UnauthorizedError:
+ return None
+
+
+def require_roles(*roles: PlatformRole | str):
+ """Dependency factory: کاربر باید حداقل یکی از نقشها را داشته باشد."""
+
+ async def _checker(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
+ if not settings.auth_required:
+ return user
+ if not has_any_role(user, *roles):
+ raise ForbiddenError("دسترسی کافی برای این عملیات ندارید")
+ return user
+
+ return _checker
+
+
+require_platform_admin = require_roles(PlatformRole.PLATFORM_ADMIN)
+require_tenant_admin = require_roles(
+ PlatformRole.PLATFORM_ADMIN, PlatformRole.TENANT_ADMIN
+)
+require_authenticated = get_current_user
diff --git a/backend/core-service/app/main.py b/backend/core-service/app/main.py
new file mode 100644
index 0000000..e25aa76
--- /dev/null
+++ b/backend/core-service/app/main.py
@@ -0,0 +1,84 @@
+"""نقطه ورود برنامه FastAPI برای Core Platform Service."""
+from __future__ import annotations
+
+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, health
+from app.core.cache import cache
+from app.core.config import settings
+from app.core.logging import configure_logging, get_logger
+from app.middlewares.tenant import TenantResolutionMiddleware
+from shared.exceptions import AppError
+from shared.responses import ErrorDetail, ErrorResponse
+
+configure_logging()
+logger = get_logger(__name__)
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ logger.info("service_starting", extra={"service": settings.service_name})
+ yield
+ await cache.close()
+ logger.info("service_stopped")
+
+
+def create_app() -> FastAPI:
+ app = FastAPI(
+ title=f"{settings.platform_name} - Core Platform Service",
+ version=__version__,
+ description="سرویس هسته پلتفرم SaaS چندمستأجری (فاز ۱).",
+ lifespan=lifespan,
+ )
+
+ # CORS - در production باید origins محدود شود.
+ # allow_origin_regex برای سابدامین tenantها (.torbatyar.ir)
+ 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=["*"],
+ )
+
+ # Middleware تشخیص tenant.
+ app.add_middleware(
+ TenantResolutionMiddleware, base_domain=settings.platform_base_domain or None
+ )
+
+ # ثبت exception handlerها برای تبدیل خطاهای دامنه به پاسخ استاندارد.
+ _register_exception_handlers(app)
+
+ # routerها
+ app.include_router(health.router)
+ app.include_router(api_router, prefix=settings.api_v1_prefix)
+
+ return app
+
+
+def _register_exception_handlers(app: FastAPI) -> None:
+ @app.exception_handler(AppError)
+ async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
+ response = ErrorResponse(
+ error=ErrorDetail(
+ code=exc.error_code, message=exc.message, details=exc.details
+ )
+ )
+ return JSONResponse(status_code=exc.status_code, content=response.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)
+ response = ErrorResponse(
+ error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
+ )
+ return JSONResponse(status_code=500, content=response.model_dump())
+
+
+app = create_app()
diff --git a/backend/core-service/app/middlewares/__init__.py b/backend/core-service/app/middlewares/__init__.py
new file mode 100644
index 0000000..0dfed3b
--- /dev/null
+++ b/backend/core-service/app/middlewares/__init__.py
@@ -0,0 +1 @@
+"""Middlewareهای برنامه."""
diff --git a/backend/core-service/app/middlewares/tenant.py b/backend/core-service/app/middlewares/tenant.py
new file mode 100644
index 0000000..afc344a
--- /dev/null
+++ b/backend/core-service/app/middlewares/tenant.py
@@ -0,0 +1,108 @@
+"""Middleware تشخیص Tenant.
+
+ترتیب تشخیص tenant:
+ 1. هدر X-Tenant-ID
+ 2. هدر X-Tenant-Slug
+ 3. Subdomain (از روی Host)
+ 4. Custom domain (از روی Host)
+
+نتیجه در request.state قرار میگیرد:
+ request.state.tenant_id
+ request.state.tenant_slug
+
+توجه: این middleware اگر tenant پیدا نشد خطا نمیدهد؛ صرفاً state را خالی
+میگذارد. اعمال اجبار روی endpointهای tenant-aware از طریق dependency
+(require_tenant) انجام میشود.
+"""
+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 app.core.cache import cache, tenant_metadata_key
+from app.core.database import AsyncSessionLocal
+from app.core.logging import get_logger
+from app.repositories.tenant import DomainRepository, TenantRepository
+from shared.tenant import HEADER_TENANT_ID, HEADER_TENANT_SLUG
+
+logger = get_logger(__name__)
+
+
+class TenantResolutionMiddleware(BaseHTTPMiddleware):
+ def __init__(self, app, *, base_domain: str | None = None) -> None:
+ super().__init__(app)
+ # base_domain برای تشخیص subdomain استفاده میشود (مثلاً example.com).
+ self.base_domain = base_domain
+
+ async def dispatch(self, request: Request, call_next) -> Response:
+ request.state.tenant_id = None
+ request.state.tenant_slug = None
+
+ try:
+ await self._resolve(request)
+ except Exception as exc: # pragma: no cover - نباید request را بشکند
+ logger.warning("tenant_resolution_error", extra={"error": str(exc)})
+
+ return await call_next(request)
+
+ async def _resolve(self, request: Request) -> None:
+ # ۱) هدر X-Tenant-ID
+ raw_id = request.headers.get(HEADER_TENANT_ID)
+ if raw_id:
+ try:
+ tenant_id = UUID(raw_id)
+ except ValueError:
+ tenant_id = None
+ if tenant_id is not None:
+ await self._set_from_tenant_id(request, tenant_id)
+ if request.state.tenant_id is not None:
+ return
+
+ # ۲) هدر X-Tenant-Slug
+ slug = request.headers.get(HEADER_TENANT_SLUG)
+ if slug:
+ await self._set_from_slug(request, slug.strip().lower())
+ if request.state.tenant_id is not None:
+ return
+
+ # ۳ و ۴) subdomain یا custom domain از روی Host
+ host = request.headers.get("host", "").split(":")[0].strip().lower()
+ if not host:
+ return
+
+ # اگر base_domain تعریف شده و host زیردامنه آن است → slug = بخش اول.
+ if self.base_domain and host.endswith("." + self.base_domain):
+ sub = host[: -(len(self.base_domain) + 1)]
+ if sub and sub != "www":
+ await self._set_from_slug(request, sub)
+ if request.state.tenant_id is not None:
+ return
+
+ # در غیر این صورت host را بهعنوان custom domain در نظر بگیر.
+ await self._set_from_domain(request, host)
+
+ async def _set_from_tenant_id(self, request: Request, tenant_id: UUID) -> None:
+ async with AsyncSessionLocal() as session:
+ tenant = await TenantRepository(session).get(tenant_id)
+ if tenant is not None:
+ request.state.tenant_id = tenant.id
+ request.state.tenant_slug = tenant.slug
+
+ async def _set_from_slug(self, request: Request, slug: str) -> None:
+ async with AsyncSessionLocal() as session:
+ tenant = await TenantRepository(session).get_by_slug(slug)
+ if tenant is not None:
+ request.state.tenant_id = tenant.id
+ request.state.tenant_slug = tenant.slug
+
+ async def _set_from_domain(self, request: Request, host: str) -> None:
+ async with AsyncSessionLocal() as session:
+ domain = await DomainRepository(session).get_by_domain(host)
+ if domain is not None:
+ tenant = await TenantRepository(session).get(domain.tenant_id)
+ if tenant is not None:
+ request.state.tenant_id = tenant.id
+ request.state.tenant_slug = tenant.slug
diff --git a/backend/core-service/app/models/__init__.py b/backend/core-service/app/models/__init__.py
new file mode 100644
index 0000000..0279c73
--- /dev/null
+++ b/backend/core-service/app/models/__init__.py
@@ -0,0 +1,38 @@
+"""مدلهای SQLAlchemy 2.x برای Core Platform.
+
+توجه: import همه مدلها در این ماژول لازم است تا Alembic و Base.metadata
+همه جدولها را بشناسند.
+"""
+from app.models.audit import AuditLog
+from app.models.domain import Domain
+from app.models.events import InboxEvent, OutboxEvent
+from app.models.membership import TenantMembership
+from app.models.plan import Feature, Plan, PlanFeature
+from app.models.registry import (
+ InternalServiceToken,
+ ModuleRegistry,
+ ServiceRegistry,
+ TenantModuleAccess,
+)
+from app.models.subscription import TenantFeatureAccess, TenantSubscription
+from app.models.tenant import Tenant
+from app.models.user import User
+
+__all__ = [
+ "Tenant",
+ "User",
+ "TenantMembership",
+ "Domain",
+ "Plan",
+ "Feature",
+ "PlanFeature",
+ "TenantSubscription",
+ "TenantFeatureAccess",
+ "ServiceRegistry",
+ "ModuleRegistry",
+ "TenantModuleAccess",
+ "InternalServiceToken",
+ "OutboxEvent",
+ "InboxEvent",
+ "AuditLog",
+]
diff --git a/backend/core-service/app/models/audit.py b/backend/core-service/app/models/audit.py
new file mode 100644
index 0000000..ee39545
--- /dev/null
+++ b/backend/core-service/app/models/audit.py
@@ -0,0 +1,35 @@
+"""مدل AuditLog: ثبت رخدادهای مهم برای پیگیری و امنیت."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, Index, String, func
+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
+
+
+class AuditLog(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "audit_logs"
+
+ tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ actor_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ action: Mapped[str] = mapped_column(String(150), nullable=False)
+ resource_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ resource_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ # از نام ستون meta_data برای پرهیز از تداخل با کلمه رزروشده metadata استفاده شده.
+ meta_data: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True)
+ ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
+ user_agent: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_audit_logs_tenant_id", "tenant_id"),
+ Index("ix_audit_logs_created_at", "created_at"),
+ )
diff --git a/backend/core-service/app/models/base.py b/backend/core-service/app/models/base.py
new file mode 100644
index 0000000..0f5a7b1
--- /dev/null
+++ b/backend/core-service/app/models/base.py
@@ -0,0 +1,32 @@
+"""Mixinهای مشترک مدلها: شناسه UUID و زمانهای ایجاد/بهروزرسانی."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.models.types import GUID
+
+
+class UUIDPrimaryKeyMixin:
+ """کلید اصلی از نوع UUID با مقدار پیشفرض تولیدشده در سمت برنامه."""
+
+ id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), primary_key=True, default=uuid.uuid4
+ )
+
+
+class TimestampMixin:
+ """ستونهای created_at و updated_at با مدیریت خودکار."""
+
+ 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,
+ )
diff --git a/backend/core-service/app/models/domain.py b/backend/core-service/app/models/domain.py
new file mode 100644
index 0000000..11d1a9b
--- /dev/null
+++ b/backend/core-service/app/models/domain.py
@@ -0,0 +1,56 @@
+"""مدل Domain: دامنهها/زیردامنههای هر tenant."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import DomainType, DomainVerificationStatus
+from app.models.types import GUID
+
+
+class Domain(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "domains"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ # دامنه بهصورت یکتا در کل پلتفرم (مثلاً acme.example.com یا shop.acme.ir).
+ domain: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
+ domain_type: Mapped[DomainType] = mapped_column(
+ SAEnum(DomainType, name="domain_type"),
+ default=DomainType.SUBDOMAIN,
+ nullable=False,
+ )
+ is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ verified_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ # آیا این دامنه، دامنه اصلی/پیشفرض tenant است (مثلاً زیردامنه ساختهشده در onboarding).
+ is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ verification_status: Mapped[DomainVerificationStatus] = mapped_column(
+ SAEnum(
+ DomainVerificationStatus,
+ name="domain_verification_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=DomainVerificationStatus.PENDING,
+ nullable=False,
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_domains_domain", "domain", unique=True),
+ Index("ix_domains_tenant_id", "tenant_id"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return f""
diff --git a/backend/core-service/app/models/enums.py b/backend/core-service/app/models/enums.py
new file mode 100644
index 0000000..d9b975b
--- /dev/null
+++ b/backend/core-service/app/models/enums.py
@@ -0,0 +1,79 @@
+"""Enumهای دامنه مدلهای Core."""
+from __future__ import annotations
+
+import enum
+
+
+class TenantStatus(str, enum.Enum):
+ # چرخه عمر عملیاتی (فاز onboarding)
+ DRAFT = "draft"
+ PENDING_ACTIVATION = "pending_activation"
+ ACTIVE = "active"
+ SUSPENDED = "suspended"
+ ARCHIVED = "archived"
+ # مقادیر قدیمی (سازگاری با فازهای قبلی)
+ INACTIVE = "inactive"
+ DELETED = "deleted"
+
+
+class TenantMembershipRole(str, enum.Enum):
+ """نقش کاربر داخل یک tenant مشخص (متفاوت از نقش سراسری JWT)."""
+
+ PLATFORM_ADMIN = "platform_admin"
+ TENANT_OWNER = "tenant_owner"
+ TENANT_ADMIN = "tenant_admin"
+ TENANT_EDITOR = "tenant_editor"
+ TENANT_VIEWER = "tenant_viewer"
+
+
+class MembershipStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INVITED = "invited"
+ DISABLED = "disabled"
+
+
+class DomainVerificationStatus(str, enum.Enum):
+ PENDING = "pending"
+ VERIFIED = "verified"
+ FAILED = "failed"
+
+
+class DomainType(str, enum.Enum):
+ SUBDOMAIN = "subdomain"
+ CUSTOM_DOMAIN = "custom_domain"
+
+
+class SubscriptionStatus(str, enum.Enum):
+ TRIALING = "trialing"
+ ACTIVE = "active"
+ PAST_DUE = "past_due"
+ CANCELED = "canceled"
+ EXPIRED = "expired"
+
+
+class LimitPeriod(str, enum.Enum):
+ """دوره سقف مصرف یک قابلیت."""
+
+ DAY = "day"
+ MONTH = "month"
+ YEAR = "year"
+ TOTAL = "total" # بدون بازنشانی دورهای
+
+
+class ServiceStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ MAINTENANCE = "maintenance"
+
+
+class UserRole(str, enum.Enum):
+ USER = "user"
+ PENDING_TENANT_ADMIN = "pending_tenant_admin"
+ TENANT_ADMIN = "tenant_admin"
+ PLATFORM_ADMIN = "platform_admin"
+
+
+class UserStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ SUSPENDED = "suspended"
diff --git a/backend/core-service/app/models/events.py b/backend/core-service/app/models/events.py
new file mode 100644
index 0000000..a405bac
--- /dev/null
+++ b/backend/core-service/app/models/events.py
@@ -0,0 +1,62 @@
+"""مدلهای Outbox و Inbox برای الگوی رویداد قابل اعتماد.
+
+OutboxEvent: رویدادهای خروجی که باید منتشر شوند.
+InboxEvent: رویدادهای ورودی برای idempotency و جلوگیری از پردازش تکراری.
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import Index, Integer, String, func
+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):
+ __tablename__ = "outbox_events"
+
+ event_type: Mapped[str] = mapped_column(String(150), nullable=False)
+ aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
+ aggregate_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+ status: Mapped[EventStatus] = mapped_column(
+ SAEnum(EventStatus, name="event_status"),
+ 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_outbox_events_status", "status"),
+ Index("ix_outbox_events_tenant_id", "tenant_id"),
+ )
+
+
+class InboxEvent(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "inbox_events"
+
+ # شناسه یکتای رویداد منبع برای جلوگیری از پردازش تکراری (idempotency).
+ event_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False, unique=True)
+ source_service: Mapped[str] = mapped_column(String(100), nullable=False)
+ event_type: Mapped[str] = mapped_column(String(150), nullable=False)
+ tenant_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
+ processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ Index("ix_inbox_events_event_id", "event_id", unique=True),
+ Index("ix_inbox_events_tenant_id", "tenant_id"),
+ )
diff --git a/backend/core-service/app/models/membership.py b/backend/core-service/app/models/membership.py
new file mode 100644
index 0000000..597c908
--- /dev/null
+++ b/backend/core-service/app/models/membership.py
@@ -0,0 +1,62 @@
+"""مدل TenantMembership: رابطه واقعی کاربر ↔ tenant (فاز Onboarding).
+
+هر کاربر میتواند در آینده عضو چند tenant باشد؛ نقش و وضعیت عضویت او در هر
+tenant مستقل از نقش سراسری (User.role / JWT roles) نگهداری میشود.
+"""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
+from app.models.enums import MembershipStatus, TenantMembershipRole
+from app.models.types import GUID
+
+
+class TenantMembership(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+ __tablename__ = "tenant_memberships"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+ )
+ role: Mapped[TenantMembershipRole] = mapped_column(
+ SAEnum(
+ TenantMembershipRole,
+ name="tenant_membership_role",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=TenantMembershipRole.TENANT_VIEWER,
+ nullable=False,
+ )
+ status: Mapped[MembershipStatus] = mapped_column(
+ SAEnum(
+ MembershipStatus,
+ name="tenant_membership_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=MembershipStatus.ACTIVE,
+ nullable=False,
+ )
+ is_owner: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership_tenant_user"),
+ Index("ix_tenant_memberships_tenant_id", "tenant_id"),
+ Index("ix_tenant_memberships_user_id", "user_id"),
+ Index("ix_tenant_memberships_role", "role"),
+ Index("ix_tenant_memberships_status", "status"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return (
+ f""
+ )
diff --git a/backend/core-service/app/models/plan.py b/backend/core-service/app/models/plan.py
new file mode 100644
index 0000000..4887ab2
--- /dev/null
+++ b/backend/core-service/app/models/plan.py
@@ -0,0 +1,74 @@
+"""مدلهای Plan، Feature و PlanFeature.
+
+Plan: بسته اشتراک.
+Feature: یک قابلیت قابل کنترل (مثلاً accounting.invoice.create).
+PlanFeature: نگاشت قابلیتها به هر پلن به همراه سقف مصرف.
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import LimitPeriod
+from app.models.types import GUID
+
+
+class Plan(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "plans"
+
+ code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (Index("ix_plans_code", "code", unique=True),)
+
+
+class Feature(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "features"
+
+ # کلید یکتای قابلیت، مثال: accounting.invoice.create
+ feature_key: Mapped[str] = mapped_column(String(150), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # سرویسی که این قابلیت به آن تعلق دارد، مثال: accounting، crm.
+ service_key: Mapped[str] = mapped_column(String(100), nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (
+ Index("ix_features_feature_key", "feature_key", unique=True),
+ Index("ix_features_service_key", "service_key"),
+ )
+
+
+class PlanFeature(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "plan_features"
+
+ plan_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("plans.id", ondelete="CASCADE"), nullable=False
+ )
+ feature_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
+ )
+ # سقف مصرف؛ None یعنی نامحدود.
+ limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ limit_period: Mapped[LimitPeriod | None] = mapped_column(
+ SAEnum(LimitPeriod, name="limit_period"), nullable=True
+ )
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (
+ UniqueConstraint("plan_id", "feature_id", name="uq_plan_feature"),
+ Index("ix_plan_features_plan_id", "plan_id"),
+ Index("ix_plan_features_feature_id", "feature_id"),
+ )
diff --git a/backend/core-service/app/models/registry.py b/backend/core-service/app/models/registry.py
new file mode 100644
index 0000000..8d64f18
--- /dev/null
+++ b/backend/core-service/app/models/registry.py
@@ -0,0 +1,92 @@
+"""مدلهای رجیستری سرویس/ماژول و توکنهای داخلی سرویس.
+
+- ServiceRegistry: ثبت سرویسهای داخلی آینده و آدرسهای آنها.
+- ModuleRegistry: ماژولهای قابل فعال/غیرفعالسازی برای tenantها.
+- TenantModuleAccess: وضعیت فعال بودن هر ماژول برای هر tenant.
+- InternalServiceToken: توکن امن ارتباط بین سرویسها (hash ذخیره میشود).
+"""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import ServiceStatus
+from app.models.types import GUID
+
+
+class ServiceRegistry(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "service_registry"
+
+ service_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ status: Mapped[ServiceStatus] = mapped_column(
+ SAEnum(ServiceStatus, name="service_status"),
+ default=ServiceStatus.ACTIVE,
+ nullable=False,
+ )
+ health_check_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (Index("ix_service_registry_service_key", "service_key", unique=True),)
+
+
+class ModuleRegistry(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "module_registry"
+
+ module_key: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ # ماژول سیستمی همیشه فعال است و توسط tenant قابل غیرفعالسازی نیست.
+ is_system_module: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+
+ __table_args__ = (Index("ix_module_registry_module_key", "module_key", unique=True),)
+
+
+class TenantModuleAccess(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "tenant_module_access"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ module_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("module_registry.id", ondelete="CASCADE"), nullable=False
+ )
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "module_id", name="uq_tenant_module_access"),
+ Index("ix_tenant_module_access_tenant_id", "tenant_id"),
+ )
+
+
+class InternalServiceToken(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "internal_service_tokens"
+
+ service_key: Mapped[str] = mapped_column(String(100), nullable=False)
+ # فقط hash توکن ذخیره میشود، نه خود توکن.
+ token_hash: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
+ # اسکوپها بهصورت رشتهی جداشده با کاما یا فضای مجاز.
+ scopes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_internal_service_tokens_service_key", "service_key"),
+ Index("ix_internal_service_tokens_token_hash", "token_hash", unique=True),
+ )
diff --git a/backend/core-service/app/models/subscription.py b/backend/core-service/app/models/subscription.py
new file mode 100644
index 0000000..5956125
--- /dev/null
+++ b/backend/core-service/app/models/subscription.py
@@ -0,0 +1,66 @@
+"""مدلهای TenantSubscription و TenantFeatureAccess."""
+from __future__ import annotations
+
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, Integer, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import UUIDPrimaryKeyMixin
+from app.models.enums import SubscriptionStatus
+from app.models.types import GUID
+
+
+class TenantSubscription(UUIDPrimaryKeyMixin, Base):
+ __tablename__ = "tenant_subscriptions"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ plan_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("plans.id", ondelete="RESTRICT"), nullable=False
+ )
+ status: Mapped[SubscriptionStatus] = mapped_column(
+ SAEnum(SubscriptionStatus, name="subscription_status"),
+ default=SubscriptionStatus.ACTIVE,
+ 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)
+ trial_ends_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_tenant_subscriptions_tenant_id", "tenant_id"),
+ Index("ix_tenant_subscriptions_status", "status"),
+ )
+
+
+class TenantFeatureAccess(UUIDPrimaryKeyMixin, Base):
+ """دسترسی سفارشی/override یک tenant به یک قابلیت (فراتر از پلن)."""
+
+ __tablename__ = "tenant_feature_access"
+
+ tenant_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
+ )
+ feature_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("features.id", ondelete="CASCADE"), nullable=False
+ )
+ is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ # سقف سفارشی؛ در صورت وجود، جایگزین سقف پلن میشود. None یعنی نامحدود.
+ custom_limit_value: Mapped[int | None] = mapped_column(Integer, nullable=True)
+ used_value: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
+ reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "feature_id", name="uq_tenant_feature_access"),
+ Index("ix_tenant_feature_access_tenant_id", "tenant_id"),
+ Index("ix_tenant_feature_access_feature_id", "feature_id"),
+ )
diff --git a/backend/core-service/app/models/tenant.py b/backend/core-service/app/models/tenant.py
new file mode 100644
index 0000000..50f30a6
--- /dev/null
+++ b/backend/core-service/app/models/tenant.py
@@ -0,0 +1,49 @@
+"""مدل Tenant: مستأجر اصلی پلتفرم."""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean
+from sqlalchemy import Enum as SAEnum
+from sqlalchemy import Index, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
+from app.models.enums import TenantStatus
+from app.models.types import GUID
+
+
+class Tenant(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+ __tablename__ = "tenants"
+
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
+ # slug یکتا برای تشخیص tenant (مثلاً در subdomain).
+ slug: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
+ status: Mapped[TenantStatus] = mapped_column(
+ SAEnum(TenantStatus, name="tenant_status"),
+ default=TenantStatus.ACTIVE,
+ nullable=False,
+ )
+ # شناسه کاربر مالک (از Identity Service آینده؛ اینجا فقط ذخیره میشود).
+ owner_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+
+ # ---- پروفایل/تنظیمات tenant (فاز onboarding) ----
+ business_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ default_locale: Mapped[str] = mapped_column(String(20), default="fa-IR", nullable=False)
+ timezone: Mapped[str] = mapped_column(String(50), default="Asia/Tehran", nullable=False)
+ primary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ secondary_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
+ logo_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ favicon_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ onboarding_completed: Mapped[bool] = mapped_column(
+ Boolean, default=False, nullable=False
+ )
+
+ __table_args__ = (
+ Index("ix_tenants_slug", "slug", unique=True),
+ Index("ix_tenants_status", "status"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return f""
diff --git a/backend/core-service/app/models/types.py b/backend/core-service/app/models/types.py
new file mode 100644
index 0000000..5fb8b70
--- /dev/null
+++ b/backend/core-service/app/models/types.py
@@ -0,0 +1,41 @@
+"""انواع سفارشی قابل حمل دیتابیس.
+
+GUID: روی PostgreSQL از نوع UUID و روی SQLite (تستها) از CHAR(36)
+استفاده میکند تا مدلها هم در production و هم در تست کار کنند.
+"""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.types import CHAR, TypeDecorator
+
+
+class GUID(TypeDecorator):
+ """نوع شناسه سراسری منطبق با UUID.
+
+ در PostgreSQL بهصورت native UUID و در سایر دیالکتها بهصورت رشته
+ ۳۶ کاراکتری ذخیره میشود.
+ """
+
+ 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))
diff --git a/backend/core-service/app/models/user.py b/backend/core-service/app/models/user.py
new file mode 100644
index 0000000..7a9aa39
--- /dev/null
+++ b/backend/core-service/app/models/user.py
@@ -0,0 +1,53 @@
+"""مدل User: کاربران ثبتنامشده از طریق OTP."""
+from __future__ import annotations
+
+import uuid
+
+from sqlalchemy import Boolean, Enum as SAEnum
+from sqlalchemy import ForeignKey, Index, String
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.base import TimestampMixin, UUIDPrimaryKeyMixin
+from app.models.enums import UserRole, UserStatus
+from app.models.types import GUID
+
+
+class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
+ __tablename__ = "users"
+
+ mobile: Mapped[str] = mapped_column(String(15), nullable=False, unique=True)
+ keycloak_sub: Mapped[str | None] = mapped_column(String(100), nullable=True, unique=True)
+ email: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ mobile_verified: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
+ role: Mapped[UserRole] = mapped_column(
+ SAEnum(
+ UserRole,
+ name="user_role",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=UserRole.PENDING_TENANT_ADMIN,
+ nullable=False,
+ )
+ status: Mapped[UserStatus] = mapped_column(
+ SAEnum(
+ UserStatus,
+ name="user_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=UserStatus.ACTIVE,
+ nullable=False,
+ )
+ # tenant انتخابشده جاری کاربر (برای پشتیبانی چندtenant در آینده).
+ current_tenant_id: Mapped[uuid.UUID | None] = mapped_column(
+ GUID(), ForeignKey("tenants.id", ondelete="SET NULL"), nullable=True
+ )
+
+ __table_args__ = (
+ Index("ix_users_mobile", "mobile", unique=True),
+ Index("ix_users_keycloak_sub", "keycloak_sub", unique=True),
+ Index("ix_users_status", "status"),
+ )
+
+ def __repr__(self) -> str: # pragma: no cover
+ return f""
diff --git a/backend/core-service/app/repositories/__init__.py b/backend/core-service/app/repositories/__init__.py
new file mode 100644
index 0000000..e1cb295
--- /dev/null
+++ b/backend/core-service/app/repositories/__init__.py
@@ -0,0 +1 @@
+"""لایه Repository: دسترسی به داده جدا از منطق کسبوکار."""
diff --git a/backend/core-service/app/repositories/base.py b/backend/core-service/app/repositories/base.py
new file mode 100644
index 0000000..336281e
--- /dev/null
+++ b/backend/core-service/app/repositories/base.py
@@ -0,0 +1,46 @@
+"""Repository پایه با عملیات CRUD عمومی (async).
+
+هدف: جلوگیری از تکرار کد و متمرکز کردن الگوهای دسترسی به داده.
+"""
+from __future__ import annotations
+
+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 BaseRepository(Generic[ModelT]):
+ model: type[ModelT]
+
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+
+ async def get(self, entity_id: UUID) -> ModelT | None:
+ return await self.session.get(self.model, entity_id)
+
+ 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 list(
+ self, *, offset: int = 0, limit: int = 20
+ ) -> Sequence[ModelT]:
+ stmt = select(self.model).offset(offset).limit(limit)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def count(self) -> int:
+ stmt = select(func.count()).select_from(self.model)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one())
diff --git a/backend/core-service/app/repositories/membership.py b/backend/core-service/app/repositories/membership.py
new file mode 100644
index 0000000..b474cb0
--- /dev/null
+++ b/backend/core-service/app/repositories/membership.py
@@ -0,0 +1,48 @@
+"""Repository مربوط به TenantMembership."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy import func, select
+
+from app.models.enums import MembershipStatus
+from app.models.membership import TenantMembership
+from app.repositories.base import BaseRepository
+
+
+class TenantMembershipRepository(BaseRepository[TenantMembership]):
+ model = TenantMembership
+
+ async def get_by_tenant_and_user(
+ self, tenant_id: UUID, user_id: UUID
+ ) -> TenantMembership | None:
+ stmt = select(TenantMembership).where(
+ TenantMembership.tenant_id == tenant_id,
+ TenantMembership.user_id == user_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_user(self, user_id: UUID) -> Sequence[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.user_id == user_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def list_by_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.tenant_id == tenant_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def count_active_owners(self, tenant_id: UUID) -> int:
+ stmt = (
+ select(func.count())
+ .select_from(TenantMembership)
+ .where(
+ TenantMembership.tenant_id == tenant_id,
+ TenantMembership.is_owner.is_(True),
+ TenantMembership.status == MembershipStatus.ACTIVE,
+ )
+ )
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one())
diff --git a/backend/core-service/app/repositories/plan.py b/backend/core-service/app/repositories/plan.py
new file mode 100644
index 0000000..2c92cf9
--- /dev/null
+++ b/backend/core-service/app/repositories/plan.py
@@ -0,0 +1,57 @@
+"""Repository مربوط به Plan، Feature و PlanFeature."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy import func, select
+
+from app.models.plan import Feature, Plan, PlanFeature
+from app.repositories.base import BaseRepository
+
+
+class PlanRepository(BaseRepository[Plan]):
+ model = Plan
+
+ async def get_by_code(self, code: str) -> Plan | None:
+ stmt = select(Plan).where(Plan.code == code)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def code_exists(self, code: str) -> bool:
+ stmt = select(func.count()).select_from(Plan).where(Plan.code == code)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+
+class FeatureRepository(BaseRepository[Feature]):
+ model = Feature
+
+ async def get_by_key(self, feature_key: str) -> Feature | None:
+ stmt = select(Feature).where(Feature.feature_key == feature_key)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def key_exists(self, feature_key: str) -> bool:
+ stmt = select(func.count()).select_from(Feature).where(
+ Feature.feature_key == feature_key
+ )
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+
+class PlanFeatureRepository(BaseRepository[PlanFeature]):
+ model = PlanFeature
+
+ async def get(self, plan_id: UUID, feature_id: UUID) -> PlanFeature | None:
+ stmt = select(PlanFeature).where(
+ PlanFeature.plan_id == plan_id,
+ PlanFeature.feature_id == feature_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_plan(self, plan_id: UUID) -> Sequence[PlanFeature]:
+ stmt = select(PlanFeature).where(PlanFeature.plan_id == plan_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
diff --git a/backend/core-service/app/repositories/registry.py b/backend/core-service/app/repositories/registry.py
new file mode 100644
index 0000000..e421023
--- /dev/null
+++ b/backend/core-service/app/repositories/registry.py
@@ -0,0 +1,36 @@
+"""Repository مربوط به ServiceRegistry و رویدادها."""
+from __future__ import annotations
+
+from typing import Sequence
+
+from sqlalchemy import select
+
+from app.models.events import OutboxEvent
+from app.models.registry import ServiceRegistry
+from app.repositories.base import BaseRepository
+from shared.events import EventStatus
+
+
+class ServiceRegistryRepository(BaseRepository[ServiceRegistry]):
+ model = ServiceRegistry
+
+ async def get_by_key(self, service_key: str) -> ServiceRegistry | None:
+ stmt = select(ServiceRegistry).where(
+ ServiceRegistry.service_key == service_key
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+class OutboxRepository(BaseRepository[OutboxEvent]):
+ model = OutboxEvent
+
+ async def list_pending(self, limit: int = 100) -> Sequence[OutboxEvent]:
+ stmt = (
+ select(OutboxEvent)
+ .where(OutboxEvent.status == EventStatus.PENDING)
+ .order_by(OutboxEvent.created_at.asc())
+ .limit(limit)
+ )
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
diff --git a/backend/core-service/app/repositories/subscription.py b/backend/core-service/app/repositories/subscription.py
new file mode 100644
index 0000000..2812a69
--- /dev/null
+++ b/backend/core-service/app/repositories/subscription.py
@@ -0,0 +1,39 @@
+"""Repository مربوط به اشتراک و دسترسی قابلیت tenant."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy import select
+from sqlalchemy.orm import joinedload
+
+from app.models.subscription import TenantFeatureAccess, TenantSubscription
+from app.repositories.base import BaseRepository
+
+
+class SubscriptionRepository(BaseRepository[TenantSubscription]):
+ model = TenantSubscription
+
+ async def get_active_by_tenant(self, tenant_id: UUID) -> TenantSubscription | None:
+ """آخرین اشتراک tenant را برمیگرداند (جدیدترین بر اساس created_at)."""
+ stmt = (
+ select(TenantSubscription)
+ .where(TenantSubscription.tenant_id == tenant_id)
+ .order_by(TenantSubscription.created_at.desc())
+ .limit(1)
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+class TenantFeatureAccessRepository(BaseRepository[TenantFeatureAccess]):
+ model = TenantFeatureAccess
+
+ async def get(
+ self, tenant_id: UUID, feature_id: UUID
+ ) -> TenantFeatureAccess | None:
+ stmt = select(TenantFeatureAccess).where(
+ TenantFeatureAccess.tenant_id == tenant_id,
+ TenantFeatureAccess.feature_id == feature_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
diff --git a/backend/core-service/app/repositories/tenant.py b/backend/core-service/app/repositories/tenant.py
new file mode 100644
index 0000000..6b29642
--- /dev/null
+++ b/backend/core-service/app/repositories/tenant.py
@@ -0,0 +1,65 @@
+"""Repository مربوط به Tenant و Domain."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy import func, select
+
+from app.models.domain import Domain
+from app.models.tenant import Tenant
+from app.repositories.base import BaseRepository
+
+
+class TenantRepository(BaseRepository[Tenant]):
+ model = Tenant
+
+ async def get_by_slug(self, slug: str) -> Tenant | None:
+ stmt = select(Tenant).where(Tenant.slug == slug)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def slug_exists(self, slug: str) -> bool:
+ stmt = select(func.count()).select_from(Tenant).where(Tenant.slug == slug)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
+
+ async def list_by_owner(
+ self, owner_user_id: UUID, *, offset: int = 0, limit: int = 20
+ ) -> Sequence[Tenant]:
+ stmt = (
+ select(Tenant)
+ .where(Tenant.owner_user_id == owner_user_id)
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def count_by_owner(self, owner_user_id: UUID) -> int:
+ stmt = (
+ select(func.count())
+ .select_from(Tenant)
+ .where(Tenant.owner_user_id == owner_user_id)
+ )
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one())
+
+
+class DomainRepository(BaseRepository[Domain]):
+ model = Domain
+
+ async def get_by_domain(self, domain: str) -> Domain | None:
+ stmt = select(Domain).where(Domain.domain == domain)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
+ stmt = select(Domain).where(Domain.tenant_id == tenant_id)
+ result = await self.session.execute(stmt)
+ return result.scalars().all()
+
+ async def domain_exists(self, domain: str) -> bool:
+ stmt = select(func.count()).select_from(Domain).where(Domain.domain == domain)
+ result = await self.session.execute(stmt)
+ return int(result.scalar_one()) > 0
diff --git a/backend/core-service/app/repositories/user.py b/backend/core-service/app/repositories/user.py
new file mode 100644
index 0000000..ec04224
--- /dev/null
+++ b/backend/core-service/app/repositories/user.py
@@ -0,0 +1,21 @@
+"""Repository مربوط به User."""
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from app.models.user import User
+from app.repositories.base import BaseRepository
+
+
+class UserRepository(BaseRepository[User]):
+ model = User
+
+ async def get_by_mobile(self, mobile: str) -> User | None:
+ stmt = select(User).where(User.mobile == mobile)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def get_by_keycloak_sub(self, keycloak_sub: str) -> User | None:
+ stmt = select(User).where(User.keycloak_sub == keycloak_sub)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
diff --git a/backend/core-service/app/schemas/__init__.py b/backend/core-service/app/schemas/__init__.py
new file mode 100644
index 0000000..84c4297
--- /dev/null
+++ b/backend/core-service/app/schemas/__init__.py
@@ -0,0 +1 @@
+"""اسکیماهای Pydantic v2 برای ورودی/خروجی API."""
diff --git a/backend/core-service/app/schemas/auth.py b/backend/core-service/app/schemas/auth.py
new file mode 100644
index 0000000..7364a37
--- /dev/null
+++ b/backend/core-service/app/schemas/auth.py
@@ -0,0 +1,57 @@
+"""اسکیماهای احراز هویت OTP."""
+from __future__ import annotations
+
+from typing import Literal
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.utils.phone import normalize_mobile, normalize_otp_digits
+
+
+class OtpRequestPayload(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ force_sms: bool = False
+ context: Literal["public", "admin"] = "public"
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class OtpRequestResponse(BaseModel):
+ message: str
+ expires_in: int
+ skipped: bool = False
+
+
+class OtpVerifyPayload(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ code: str = Field(..., min_length=4, max_length=8)
+ context: Literal["public", "admin"] = "public"
+ keycloak_sub: str | None = None
+ email: str | None = None
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+ @field_validator("code")
+ @classmethod
+ def validate_code(cls, value: str) -> str:
+ normalized = normalize_otp_digits(value)
+ if not normalized:
+ raise ValueError("کد تأیید نامعتبر است")
+ return normalized
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ expires_in: int
+ is_new_user: bool = False
+ user_id: UUID
+ role: str
diff --git a/backend/core-service/app/schemas/common.py b/backend/core-service/app/schemas/common.py
new file mode 100644
index 0000000..dc12ae0
--- /dev/null
+++ b/backend/core-service/app/schemas/common.py
@@ -0,0 +1,18 @@
+"""اسکیماهای پایه و مشترک."""
+from __future__ import annotations
+
+from pydantic import BaseModel, ConfigDict
+
+
+class ORMModel(BaseModel):
+ """پایه اسکیماهای خروجی که از مدل ORM ساخته میشوند."""
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HealthResponse(BaseModel):
+ status: str = "ok"
+ service: str
+ version: str
+ environment: str
+ dependencies: dict[str, str] = {}
diff --git a/backend/core-service/app/schemas/domain.py b/backend/core-service/app/schemas/domain.py
new file mode 100644
index 0000000..cd5f18c
--- /dev/null
+++ b/backend/core-service/app/schemas/domain.py
@@ -0,0 +1,46 @@
+"""اسکیماهای Domain."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.enums import DomainType
+from app.schemas.common import ORMModel
+
+
+class DomainCreate(BaseModel):
+ domain: str = Field(..., min_length=1, max_length=255)
+ domain_type: DomainType = DomainType.SUBDOMAIN
+
+ @field_validator("domain")
+ @classmethod
+ def normalize_domain(cls, value: str) -> str:
+ return value.strip().lower()
+
+
+class DomainRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ domain: str
+ domain_type: DomainType
+ is_verified: bool
+ verified_at: datetime | None
+ created_at: datetime
+
+
+class DomainResolveRequest(BaseModel):
+ domain: str = Field(..., min_length=1, max_length=255)
+
+ @field_validator("domain")
+ @classmethod
+ def normalize_domain(cls, value: str) -> str:
+ return value.strip().lower()
+
+
+class DomainResolveResponse(BaseModel):
+ tenant_id: UUID
+ tenant_slug: str
+ domain: str
+ is_verified: bool
diff --git a/backend/core-service/app/schemas/membership.py b/backend/core-service/app/schemas/membership.py
new file mode 100644
index 0000000..cf54299
--- /dev/null
+++ b/backend/core-service/app/schemas/membership.py
@@ -0,0 +1,19 @@
+"""اسکیماهای TenantMembership."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from app.models.enums import MembershipStatus, TenantMembershipRole
+from app.schemas.common import ORMModel
+
+
+class MembershipRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ user_id: UUID
+ role: TenantMembershipRole
+ status: MembershipStatus
+ is_owner: bool
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/core-service/app/schemas/onboarding.py b/backend/core-service/app/schemas/onboarding.py
new file mode 100644
index 0000000..007a39e
--- /dev/null
+++ b/backend/core-service/app/schemas/onboarding.py
@@ -0,0 +1,88 @@
+"""اسکیماهای Onboarding و Tenant Context."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.enums import MembershipStatus, TenantMembershipRole, TenantStatus
+from app.schemas.domain import DomainRead
+from app.schemas.tenant import SLUG_PATTERN, TenantRead
+
+
+class OnboardingTenantCreate(BaseModel):
+ """گام ۱ onboarding: ایجاد tenant توسط کاربر جاری."""
+
+ business_name: str = Field(..., min_length=1, max_length=255)
+ slug: str = Field(..., min_length=1, max_length=100)
+ business_type: str | None = Field(default=None, max_length=100)
+ default_locale: str = Field(default="fa-IR", max_length=20)
+ timezone: str = Field(default="Asia/Tehran", max_length=50)
+
+ @field_validator("slug")
+ @classmethod
+ def validate_slug(cls, value: str) -> str:
+ value = value.strip().lower()
+ if not SLUG_PATTERN.match(value):
+ raise ValueError(
+ "slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
+ )
+ return value
+
+
+class OnboardingBrandingUpdate(BaseModel):
+ """گام ۲ onboarding: برندینگ."""
+
+ primary_color: str | None = Field(default=None, max_length=20)
+ secondary_color: str | None = Field(default=None, max_length=20)
+ logo_url: str | None = Field(default=None, max_length=500)
+ favicon_url: str | None = Field(default=None, max_length=500)
+
+
+class OnboardingDomainUpdate(BaseModel):
+ """گام ۳ onboarding: دامنه اختصاصی (زیردامنه بهصورت خودکار از slug ساخته میشود)."""
+
+ custom_domain: str | None = Field(default=None, max_length=255)
+
+ @field_validator("custom_domain")
+ @classmethod
+ def normalize_domain(cls, value: str | None) -> str | None:
+ if value is None or not value.strip():
+ return None
+ return value.strip().lower()
+
+
+class MembershipSummary(BaseModel):
+ tenant_id: UUID
+ tenant_name: str
+ tenant_slug: str
+ tenant_status: TenantStatus
+ onboarding_completed: bool
+ role: TenantMembershipRole
+ status: MembershipStatus
+ is_owner: bool
+
+
+class MeResponse(BaseModel):
+ user_id: UUID
+ mobile: str
+ email: str | None
+ platform_role: str
+ onboarding_required: bool
+ current_tenant_id: UUID | None
+ memberships: list[MembershipSummary]
+
+
+class TenantContextRead(BaseModel):
+ tenant: TenantRead
+ role: TenantMembershipRole | None = None
+ is_owner: bool = False
+ plan_code: str | None = None
+ plan_name: str | None = None
+ subscription_status: str | None = None
+ domains: list[DomainRead] = Field(default_factory=list)
+ primary_domain: str | None = None
+
+
+class TenantSwitchRequest(BaseModel):
+ tenant_id: UUID
diff --git a/backend/core-service/app/schemas/plan.py b/backend/core-service/app/schemas/plan.py
new file mode 100644
index 0000000..84b2d59
--- /dev/null
+++ b/backend/core-service/app/schemas/plan.py
@@ -0,0 +1,62 @@
+"""اسکیماهای Plan، Feature و PlanFeature."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+from app.models.enums import LimitPeriod
+from app.schemas.common import ORMModel
+
+
+# ---- Plan ----
+class PlanCreate(BaseModel):
+ code: str = Field(..., min_length=1, max_length=100)
+ name: str = Field(..., min_length=1, max_length=255)
+ description: str | None = None
+ is_active: bool = True
+
+
+class PlanRead(ORMModel):
+ id: UUID
+ code: str
+ name: str
+ description: str | None
+ is_active: bool
+ created_at: datetime
+
+
+# ---- Feature ----
+class FeatureCreate(BaseModel):
+ feature_key: str = Field(..., min_length=1, max_length=150)
+ name: str = Field(..., min_length=1, max_length=255)
+ description: str | None = None
+ service_key: str = Field(..., min_length=1, max_length=100)
+ is_active: bool = True
+
+
+class FeatureRead(ORMModel):
+ id: UUID
+ feature_key: str
+ name: str
+ description: str | None
+ service_key: str
+ is_active: bool
+
+
+# ---- PlanFeature ----
+class PlanFeatureCreate(BaseModel):
+ feature_id: UUID
+ limit_value: int | None = Field(default=None, ge=0)
+ limit_period: LimitPeriod | None = None
+ is_enabled: bool = True
+
+
+class PlanFeatureRead(ORMModel):
+ id: UUID
+ plan_id: UUID
+ feature_id: UUID
+ limit_value: int | None
+ limit_period: LimitPeriod | None
+ is_enabled: bool
diff --git a/backend/core-service/app/schemas/service_registry.py b/backend/core-service/app/schemas/service_registry.py
new file mode 100644
index 0000000..34bc6d3
--- /dev/null
+++ b/backend/core-service/app/schemas/service_registry.py
@@ -0,0 +1,32 @@
+"""اسکیماهای ServiceRegistry."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field
+
+from app.models.enums import ServiceStatus
+from app.schemas.common import ORMModel
+
+
+class ServiceCreate(BaseModel):
+ service_key: str = Field(..., min_length=1, max_length=100)
+ name: str = Field(..., min_length=1, max_length=255)
+ base_url: str | None = Field(default=None, max_length=500)
+ health_check_url: str | None = Field(default=None, max_length=500)
+ status: ServiceStatus = ServiceStatus.ACTIVE
+
+
+class ServiceStatusUpdate(BaseModel):
+ status: ServiceStatus
+
+
+class ServiceRead(ORMModel):
+ id: UUID
+ service_key: str
+ name: str
+ base_url: str | None
+ status: ServiceStatus
+ health_check_url: str | None
+ created_at: datetime
diff --git a/backend/core-service/app/schemas/subscription.py b/backend/core-service/app/schemas/subscription.py
new file mode 100644
index 0000000..97e0999
--- /dev/null
+++ b/backend/core-service/app/schemas/subscription.py
@@ -0,0 +1,40 @@
+"""اسکیماهای اشتراک و بررسی دسترسی قابلیت."""
+from __future__ import annotations
+
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel
+
+from app.models.enums import SubscriptionStatus
+from app.schemas.common import ORMModel
+
+
+class SubscriptionCreate(BaseModel):
+ plan_id: UUID
+ status: SubscriptionStatus = SubscriptionStatus.ACTIVE
+ starts_at: datetime | None = None
+ ends_at: datetime | None = None
+ trial_ends_at: datetime | None = None
+
+
+class SubscriptionRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ plan_id: UUID
+ status: SubscriptionStatus
+ starts_at: datetime | None
+ ends_at: datetime | None
+ trial_ends_at: datetime | None
+ created_at: datetime
+
+
+class FeatureCheckRequest(BaseModel):
+ feature_key: str
+
+
+class FeatureCheckResponse(BaseModel):
+ tenant_id: UUID
+ feature_key: str
+ has_access: bool
+ reason: str | None = None
diff --git a/backend/core-service/app/schemas/tenant.py b/backend/core-service/app/schemas/tenant.py
new file mode 100644
index 0000000..a27ebf8
--- /dev/null
+++ b/backend/core-service/app/schemas/tenant.py
@@ -0,0 +1,79 @@
+"""اسکیماهای Tenant."""
+from __future__ import annotations
+
+import re
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, Field, field_validator
+
+from app.models.enums import TenantStatus
+from app.schemas.common import ORMModel
+
+SLUG_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?$")
+
+
+class TenantCreate(BaseModel):
+ name: str = Field(..., min_length=1, max_length=255)
+ slug: str = Field(..., min_length=1, max_length=100)
+ owner_user_id: UUID | None = None
+ custom_domain: str | None = Field(default=None, max_length=255)
+
+ @field_validator("slug")
+ @classmethod
+ def validate_slug(cls, value: str) -> str:
+ value = value.strip().lower()
+ if not SLUG_PATTERN.match(value):
+ raise ValueError(
+ "slug باید فقط شامل حروف کوچک انگلیسی، اعداد و خط تیره باشد"
+ )
+ return value
+
+ @field_validator("custom_domain")
+ @classmethod
+ def normalize_domain(cls, value: str | None) -> str | None:
+ if value is None or not value.strip():
+ return None
+ return value.strip().lower()
+
+
+class AdminTenantCreate(BaseModel):
+ """ایجاد tenant توسط کاربر احراز هویتشده (مالک = کاربر جاری)."""
+
+ name: str = Field(..., min_length=1, max_length=255)
+ slug: str = Field(..., min_length=1, max_length=100)
+ custom_domain: str | None = Field(default=None, max_length=255)
+
+ @field_validator("slug")
+ @classmethod
+ def validate_slug(cls, value: str) -> str:
+ return TenantCreate.validate_slug(value)
+
+ @field_validator("custom_domain")
+ @classmethod
+ def normalize_domain(cls, value: str | None) -> str | None:
+ return TenantCreate.normalize_domain(value)
+
+
+class TenantUpdate(BaseModel):
+ name: str | None = Field(default=None, min_length=1, max_length=255)
+ status: TenantStatus | None = None
+ owner_user_id: UUID | None = None
+
+
+class TenantRead(ORMModel):
+ id: UUID
+ name: str
+ slug: str
+ status: TenantStatus
+ owner_user_id: UUID | None
+ business_type: str | None = None
+ default_locale: str = "fa-IR"
+ timezone: str = "Asia/Tehran"
+ primary_color: str | None = None
+ secondary_color: str | None = None
+ logo_url: str | None = None
+ favicon_url: str | None = None
+ onboarding_completed: bool = False
+ created_at: datetime
+ updated_at: datetime
diff --git a/backend/core-service/app/services/__init__.py b/backend/core-service/app/services/__init__.py
new file mode 100644
index 0000000..738cb31
--- /dev/null
+++ b/backend/core-service/app/services/__init__.py
@@ -0,0 +1 @@
+"""لایه Service: منطق کسبوکار Core Platform."""
diff --git a/backend/core-service/app/services/auth_service.py b/backend/core-service/app/services/auth_service.py
new file mode 100644
index 0000000..8fbc48c
--- /dev/null
+++ b/backend/core-service/app/services/auth_service.py
@@ -0,0 +1,107 @@
+"""سرویس احراز هویت: ثبتنام خودکار و صدور توکن."""
+from __future__ import annotations
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.models.audit import AuditLog
+from app.models.enums import UserRole, UserStatus
+from app.models.user import User
+from app.repositories.user import UserRepository
+from app.schemas.auth import OtpRequestResponse, OtpVerifyPayload, TokenResponse
+from app.services.otp_service import OtpService
+from app.services.payamak_client import SmsDeliveryError
+from app.services.token_service import TokenService
+from shared.exceptions import AppError, UnauthorizedError
+
+
+class SmsSendError(AppError):
+ status_code = 502
+ error_code = "sms_delivery_failed"
+
+
+class AuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.otp = OtpService()
+ self.tokens = TokenService()
+
+ async def request_otp(self, mobile: str, *, force_sms: bool = False, context: str = "public") -> OtpRequestResponse:
+ try:
+ expires_in, skipped = await self.otp.request_otp(mobile, force=force_sms)
+ except SmsDeliveryError as exc:
+ raise SmsSendError(str(exc)) from exc
+ message = "کد قبلاً ارسال شده" if skipped else "کد تأیید ارسال شد"
+ return OtpRequestResponse(
+ message=message,
+ expires_in=expires_in,
+ skipped=skipped,
+ )
+
+ async def verify_otp(self, payload: OtpVerifyPayload) -> TokenResponse:
+ if not await self.otp.verify_otp(payload.mobile, payload.code):
+ raise UnauthorizedError("کد تأیید نامعتبر یا منقضی شده است")
+
+ user = await self.users.get_by_mobile(payload.mobile)
+ is_new_user = user is None
+ is_platform_admin = payload.mobile in settings.platform_admin_mobile_set
+
+ if is_new_user:
+ if is_platform_admin:
+ role = UserRole.PLATFORM_ADMIN
+ elif payload.context == "admin":
+ role = UserRole.PENDING_TENANT_ADMIN
+ else:
+ role = UserRole.USER
+ user = User(
+ mobile=payload.mobile,
+ role=role,
+ status=UserStatus.ACTIVE,
+ mobile_verified=True,
+ keycloak_sub=payload.keycloak_sub,
+ email=payload.email,
+ )
+ await self.users.add(user)
+ await self._log_registration(user)
+ await self.session.commit()
+ await self.session.refresh(user)
+ else:
+ if user.status != UserStatus.ACTIVE:
+ raise UnauthorizedError("حساب کاربری غیرفعال است")
+ if is_platform_admin and user.role != UserRole.PLATFORM_ADMIN:
+ user.role = UserRole.PLATFORM_ADMIN
+ user.mobile_verified = True
+ if payload.keycloak_sub and not user.keycloak_sub:
+ user.keycloak_sub = payload.keycloak_sub
+ if payload.email and not user.email:
+ user.email = payload.email
+ await self.session.commit()
+ await self.session.refresh(user)
+
+ access_token, expires_in = self.tokens.create_access_token(
+ user_id=user.id,
+ mobile=user.mobile,
+ role=user.role,
+ )
+ refresh_token = self.tokens.create_refresh_token(user.id)
+
+ return TokenResponse(
+ access_token=access_token,
+ refresh_token=refresh_token,
+ expires_in=expires_in,
+ is_new_user=is_new_user,
+ user_id=user.id,
+ role=user.role.value,
+ )
+
+ async def _log_registration(self, user: User) -> None:
+ log = AuditLog(
+ tenant_id=None,
+ actor_user_id=user.id,
+ action="user.registered",
+ resource_type="user",
+ resource_id=str(user.id),
+ meta_data={"mobile": user.mobile, "role": user.role.value},
+ )
+ self.session.add(log)
diff --git a/backend/core-service/app/services/domain_service.py b/backend/core-service/app/services/domain_service.py
new file mode 100644
index 0000000..bfe7176
--- /dev/null
+++ b/backend/core-service/app/services/domain_service.py
@@ -0,0 +1,58 @@
+"""سرویس مدیریت و resolve دامنهها."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.domain import Domain
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.domain import DomainCreate
+from app.services.event_service import EventService
+from shared.events import CoreEventType
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class DomainService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = DomainRepository(session)
+ self.tenant_repo = TenantRepository(session)
+ self.events = EventService(session)
+
+ async def create(self, tenant_id: UUID, data: DomainCreate) -> Domain:
+ tenant = await self.tenant_repo.get(tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ if await self.repo.domain_exists(data.domain):
+ raise ConflictError(
+ f"دامنه تکراری است: {data.domain}", error_code="domain_taken"
+ )
+
+ domain = Domain(
+ tenant_id=tenant_id,
+ domain=data.domain,
+ domain_type=data.domain_type,
+ )
+ await self.repo.add(domain)
+ await self.events.record(
+ event_type=CoreEventType.DOMAIN_CREATED.value,
+ aggregate_type="domain",
+ aggregate_id=str(domain.id),
+ tenant_id=tenant_id,
+ payload={"domain": domain.domain, "type": domain.domain_type.value},
+ )
+ await self.session.commit()
+ await self.session.refresh(domain)
+ return domain
+
+ async def list_by_tenant(self, tenant_id: UUID) -> Sequence[Domain]:
+ return await self.repo.list_by_tenant(tenant_id)
+
+ async def resolve(self, domain: str) -> Domain:
+ """یافتن دامنه و tenant متناظر آن."""
+ found = await self.repo.get_by_domain(domain.strip().lower())
+ if found is None:
+ raise NotFoundError(f"دامنه یافت نشد: {domain}")
+ return found
diff --git a/backend/core-service/app/services/entitlement_service.py b/backend/core-service/app/services/entitlement_service.py
new file mode 100644
index 0000000..cee6d1d
--- /dev/null
+++ b/backend/core-service/app/services/entitlement_service.py
@@ -0,0 +1,117 @@
+"""سرویس Entitlement: بررسی دسترسی tenant به یک قابلیت.
+
+قوانین بررسی (به ترتیب):
+1. ابتدا Redis cache بررسی میشود.
+2. اگر در cache نبود، از دیتابیس محاسبه میشود.
+3. نتیجه با TTL مشخص در Redis ذخیره میشود.
+4. اگر اشتراک tenant فعال نبود → دسترسی false.
+5. اگر قابلیت در پلن فعال نبود → false.
+6. اگر دسترسی سفارشی (custom access) وجود داشت، آن اولویت دارد.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.cache import cache, feature_access_key
+from app.core.config import settings
+from app.models.enums import SubscriptionStatus, TenantStatus
+from app.repositories.plan import FeatureRepository, PlanFeatureRepository
+from app.repositories.subscription import (
+ SubscriptionRepository,
+ TenantFeatureAccessRepository,
+)
+from app.repositories.tenant import TenantRepository
+
+_ALLOWED_SUBSCRIPTION_STATUSES = {
+ SubscriptionStatus.ACTIVE,
+ SubscriptionStatus.TRIALING,
+}
+
+
+@dataclass
+class AccessResult:
+ has_access: bool
+ reason: str
+
+
+class EntitlementService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.tenant_repo = TenantRepository(session)
+ self.feature_repo = FeatureRepository(session)
+ self.plan_feature_repo = PlanFeatureRepository(session)
+ self.subscription_repo = SubscriptionRepository(session)
+ self.feature_access_repo = TenantFeatureAccessRepository(session)
+
+ async def check_feature_access(
+ self, tenant_id: UUID, feature_key: str
+ ) -> bool:
+ """بررسی ساده دسترسی؛ فقط مقدار boolean برمیگرداند."""
+ result = await self.evaluate(tenant_id, feature_key)
+ return result.has_access
+
+ async def evaluate(self, tenant_id: UUID, feature_key: str) -> AccessResult:
+ """بررسی کامل دسترسی همراه با دلیل، با استفاده از cache."""
+ cache_key = feature_access_key(str(tenant_id), feature_key)
+
+ cached = await cache.get(cache_key)
+ if cached is not None:
+ return AccessResult(has_access=cached == "1", reason="cache")
+
+ result = await self._evaluate_from_db(tenant_id, feature_key)
+
+ await cache.set(
+ cache_key,
+ "1" if result.has_access else "0",
+ ttl=settings.feature_access_cache_ttl,
+ )
+ return result
+
+ async def _evaluate_from_db(
+ self, tenant_id: UUID, feature_key: str
+ ) -> AccessResult:
+ # ۱) tenant باید وجود داشته و فعال باشد.
+ tenant = await self.tenant_repo.get(tenant_id)
+ if tenant is None:
+ return AccessResult(False, "tenant_not_found")
+ if tenant.status != TenantStatus.ACTIVE:
+ return AccessResult(False, "tenant_inactive")
+
+ # ۲) قابلیت باید وجود داشته و فعال باشد.
+ feature = await self.feature_repo.get_by_key(feature_key)
+ if feature is None:
+ return AccessResult(False, "feature_not_found")
+ if not feature.is_active:
+ return AccessResult(False, "feature_inactive")
+
+ # ۳) بررسی override سفارشی tenant (بالاترین اولویت).
+ custom = await self.feature_access_repo.get(tenant_id, feature.id)
+ if custom is not None:
+ if not custom.is_enabled:
+ return AccessResult(False, "custom_disabled")
+ # اگر سقف سفارشی تعریف شده و مصرف از آن گذشته باشد → عدم دسترسی.
+ if (
+ custom.custom_limit_value is not None
+ and custom.used_value >= custom.custom_limit_value
+ ):
+ return AccessResult(False, "custom_limit_exceeded")
+ return AccessResult(True, "custom_enabled")
+
+ # ۴) اشتراک tenant باید فعال باشد.
+ subscription = await self.subscription_repo.get_active_by_tenant(tenant_id)
+ if subscription is None:
+ return AccessResult(False, "no_subscription")
+ if subscription.status not in _ALLOWED_SUBSCRIPTION_STATUSES:
+ return AccessResult(False, "subscription_inactive")
+
+ # ۵) قابلیت باید در پلن اشتراک فعال باشد.
+ plan_feature = await self.plan_feature_repo.get(
+ subscription.plan_id, feature.id
+ )
+ if plan_feature is None or not plan_feature.is_enabled:
+ return AccessResult(False, "feature_not_in_plan")
+
+ return AccessResult(True, "plan_enabled")
diff --git a/backend/core-service/app/services/event_service.py b/backend/core-service/app/services/event_service.py
new file mode 100644
index 0000000..132b3d4
--- /dev/null
+++ b/backend/core-service/app/services/event_service.py
@@ -0,0 +1,38 @@
+"""سرویس ثبت رویداد در Outbox.
+
+طبق الگوی Outbox، رویدادها ابتدا در همان تراکنش دیتابیس ذخیره میشوند و
+سپس توسط worker (Celery) منتشر میگردند. این کار atomicity را تضمین میکند.
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.events import OutboxEvent
+
+
+class EventService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+
+ async def record(
+ self,
+ *,
+ event_type: str,
+ aggregate_type: str,
+ aggregate_id: str | None = None,
+ tenant_id: UUID | None = None,
+ payload: dict | None = None,
+ ) -> OutboxEvent:
+ """ثبت یک رویداد جدید در Outbox (بدون commit؛ در تراکنش جاری)."""
+ event = OutboxEvent(
+ event_type=event_type,
+ aggregate_type=aggregate_type,
+ aggregate_id=aggregate_id,
+ tenant_id=tenant_id,
+ payload=payload or {},
+ )
+ self.session.add(event)
+ await self.session.flush()
+ return event
diff --git a/backend/core-service/app/services/membership_service.py b/backend/core-service/app/services/membership_service.py
new file mode 100644
index 0000000..9d56354
--- /dev/null
+++ b/backend/core-service/app/services/membership_service.py
@@ -0,0 +1,83 @@
+"""سرویس مدیریت عضویت کاربر در tenant (TenantMembership)."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.enums import MembershipStatus, TenantMembershipRole
+from app.models.membership import TenantMembership
+from app.repositories.membership import TenantMembershipRepository
+from shared.auth import PlatformRole, has_role
+from shared.exceptions import ForbiddenError
+from shared.security import CurrentUser
+
+# نقشهایی که اجازه مدیریت تنظیمات tenant (برندینگ/دامنه/تکمیل onboarding) را دارند.
+TENANT_MANAGER_ROLES = {TenantMembershipRole.TENANT_OWNER, TenantMembershipRole.TENANT_ADMIN}
+
+
+class MembershipService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = TenantMembershipRepository(session)
+
+ async def create_owner_membership(
+ self, tenant_id: UUID, user_id: UUID
+ ) -> TenantMembership:
+ existing = await self.repo.get_by_tenant_and_user(tenant_id, user_id)
+ if existing is not None:
+ existing.role = TenantMembershipRole.TENANT_OWNER
+ existing.is_owner = True
+ existing.status = MembershipStatus.ACTIVE
+ await self.session.commit()
+ await self.session.refresh(existing)
+ return existing
+
+ membership = TenantMembership(
+ tenant_id=tenant_id,
+ user_id=user_id,
+ role=TenantMembershipRole.TENANT_OWNER,
+ is_owner=True,
+ status=MembershipStatus.ACTIVE,
+ )
+ await self.repo.add(membership)
+ await self.session.commit()
+ await self.session.refresh(membership)
+ return membership
+
+ async def get(self, tenant_id: UUID, user_id: UUID) -> TenantMembership | None:
+ return await self.repo.get_by_tenant_and_user(tenant_id, user_id)
+
+ async def list_for_user(self, user_id: UUID) -> Sequence[TenantMembership]:
+ return await self.repo.list_by_user(user_id)
+
+ async def list_for_tenant(self, tenant_id: UUID) -> Sequence[TenantMembership]:
+ return await self.repo.list_by_tenant(tenant_id)
+
+ async def ensure_role(
+ self,
+ *,
+ tenant_id: UUID,
+ core_user_id: UUID,
+ current_user: CurrentUser,
+ allowed_roles: set[TenantMembershipRole] = frozenset(TENANT_MANAGER_ROLES),
+ ) -> TenantMembership | None:
+ """اطمینان از اینکه کاربر جاری نقش کافی روی این tenant دارد.
+
+ platform_admin همیشه دسترسی کامل دارد (bypass).
+ """
+ if has_role(current_user, PlatformRole.PLATFORM_ADMIN):
+ return None
+
+ membership = await self.get(tenant_id, core_user_id)
+ if (
+ membership is None
+ or membership.status != MembershipStatus.ACTIVE
+ or membership.role not in allowed_roles
+ ):
+ raise ForbiddenError("دسترسی کافی برای مدیریت این tenant ندارید")
+ return membership
+
+ async def count_active_owners(self, tenant_id: UUID) -> int:
+ return await self.repo.count_active_owners(tenant_id)
diff --git a/backend/core-service/app/services/onboarding_service.py b/backend/core-service/app/services/onboarding_service.py
new file mode 100644
index 0000000..2581bf9
--- /dev/null
+++ b/backend/core-service/app/services/onboarding_service.py
@@ -0,0 +1,175 @@
+"""سرویس Onboarding: ایجاد و فعالسازی tenant توسط کاربر جاری.
+
+جریان کامل:
+ ۱. POST /onboarding/tenant → ایجاد tenant (status=pending_activation) + عضویت owner + پلن پیشفرض + زیردامنه خودکار
+ ۲. PATCH /onboarding/tenant/{id}/branding → تنظیم رنگ/لوگو
+ ۳. PATCH /onboarding/tenant/{id}/domain → افزودن دامنه اختصاصی (اختیاری)
+ ۴. POST /onboarding/tenant/{id}/complete → اعتبارسنجی و فعالسازی نهایی (status=active)
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.models.domain import Domain
+from app.models.enums import DomainType, DomainVerificationStatus, SubscriptionStatus, TenantStatus
+from app.models.tenant import Tenant
+from app.models.user import User
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.onboarding import (
+ OnboardingBrandingUpdate,
+ OnboardingDomainUpdate,
+ OnboardingTenantCreate,
+)
+from app.schemas.subscription import SubscriptionCreate
+from app.schemas.tenant import TenantCreate
+from app.services.event_service import EventService
+from app.services.membership_service import MembershipService
+from app.services.plan_service import PlanService
+from app.services.subscription_service import SubscriptionService
+from app.services.tenant_service import TenantService
+from shared.events import CoreEventType
+from shared.exceptions import ConflictError, ValidationAppError
+
+
+class OnboardingService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.tenant_service = TenantService(session)
+ self.tenant_repo = TenantRepository(session)
+ self.domain_repo = DomainRepository(session)
+ self.membership_service = MembershipService(session)
+ self.plan_service = PlanService(session)
+ self.subscription_service = SubscriptionService(session)
+ self.events = EventService(session)
+
+ async def create_tenant(self, core_user: User, data: OnboardingTenantCreate) -> Tenant:
+ tenant = await self.tenant_service.create(
+ TenantCreate(
+ name=data.business_name,
+ slug=data.slug,
+ owner_user_id=core_user.id,
+ )
+ )
+
+ tenant.status = TenantStatus.PENDING_ACTIVATION
+ tenant.business_type = data.business_type
+ tenant.default_locale = data.default_locale
+ tenant.timezone = data.timezone
+ await self.session.commit()
+ await self.session.refresh(tenant)
+
+ await self.membership_service.create_owner_membership(tenant.id, core_user.id)
+
+ plan = await self.plan_service.ensure_default_plan()
+ await self.subscription_service.create(
+ tenant.id,
+ SubscriptionCreate(plan_id=plan.id, status=SubscriptionStatus.ACTIVE),
+ )
+
+ await self._create_default_subdomain(tenant)
+
+ if core_user.current_tenant_id is None:
+ core_user.current_tenant_id = tenant.id
+ await self.session.commit()
+
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def _create_default_subdomain(self, tenant: Tenant) -> None:
+ base_domain = (settings.platform_base_domain or "").strip().lower()
+ if not base_domain:
+ return
+ host = f"{tenant.slug}.{base_domain}"
+ if await self.domain_repo.domain_exists(host):
+ return
+ domain = Domain(
+ tenant_id=tenant.id,
+ domain=host,
+ domain_type=DomainType.SUBDOMAIN,
+ is_primary=True,
+ is_verified=True,
+ verification_status=DomainVerificationStatus.VERIFIED,
+ )
+ await self.domain_repo.add(domain)
+ await self.events.record(
+ event_type=CoreEventType.DOMAIN_CREATED.value,
+ aggregate_type="domain",
+ aggregate_id=str(domain.id),
+ tenant_id=tenant.id,
+ payload={"domain": host, "type": "subdomain", "auto_assigned": True},
+ )
+ await self.session.commit()
+
+ async def update_branding(
+ self, tenant_id: UUID, data: OnboardingBrandingUpdate
+ ) -> Tenant:
+ tenant = await self.tenant_service.get(tenant_id)
+ if data.primary_color is not None:
+ tenant.primary_color = data.primary_color
+ if data.secondary_color is not None:
+ tenant.secondary_color = data.secondary_color
+ if data.logo_url is not None:
+ tenant.logo_url = data.logo_url
+ if data.favicon_url is not None:
+ tenant.favicon_url = data.favicon_url
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def update_domain(self, tenant_id: UUID, data: OnboardingDomainUpdate) -> Tenant:
+ tenant = await self.tenant_service.get(tenant_id)
+ if data.custom_domain:
+ if await self.domain_repo.domain_exists(data.custom_domain):
+ raise ConflictError(
+ f"دامنه تکراری است: {data.custom_domain}", error_code="domain_taken"
+ )
+ domain = Domain(
+ tenant_id=tenant.id,
+ domain=data.custom_domain,
+ domain_type=DomainType.CUSTOM_DOMAIN,
+ is_primary=False,
+ is_verified=False,
+ verification_status=DomainVerificationStatus.PENDING,
+ )
+ await self.domain_repo.add(domain)
+ await self.events.record(
+ event_type=CoreEventType.DOMAIN_CREATED.value,
+ aggregate_type="domain",
+ aggregate_id=str(domain.id),
+ tenant_id=tenant.id,
+ payload={"domain": data.custom_domain, "type": "custom"},
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def complete(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.tenant_service.get(tenant_id)
+
+ if not tenant.name or not tenant.slug:
+ raise ValidationAppError(
+ "اطلاعات کسبوکار (نام/slug) تکمیل نشده است", error_code="onboarding_incomplete"
+ )
+
+ owners = await self.membership_service.count_active_owners(tenant_id)
+ if owners < 1:
+ raise ValidationAppError(
+ "tenant باید حداقل یک مالک فعال داشته باشد", error_code="owner_required"
+ )
+
+ tenant.onboarding_completed = True
+ tenant.status = TenantStatus.ACTIVE
+ await self.events.record(
+ event_type=CoreEventType.TENANT_ACTIVATED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ payload={"onboarding_completed": True},
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self.tenant_service._invalidate_cache(tenant.id)
+ return tenant
diff --git a/backend/core-service/app/services/otp_service.py b/backend/core-service/app/services/otp_service.py
new file mode 100644
index 0000000..2f2c79a
--- /dev/null
+++ b/backend/core-service/app/services/otp_service.py
@@ -0,0 +1,77 @@
+"""سرویس OTP — همان زنجیره torbatkar-back/User/utils.send_phone_otp_shared."""
+from __future__ import annotations
+
+import random
+import secrets
+
+from app.core.cache import cache
+from app.core.config import settings
+from app.services.payamak_client import PayamakClient, SmsDeliveryError, _normalize_phone_for_sms
+from app.utils.phone import normalize_mobile, normalize_otp_digits
+
+_memory_otp: dict[str, str] = {}
+
+
+def _otp_key(mobile: str) -> str:
+ return f"otp:{mobile}"
+
+
+class OtpService:
+ def __init__(self) -> None:
+ self.sms = PayamakClient()
+
+ def _generate_code(self) -> str:
+ if settings.environment == "test":
+ return "1234"
+ return str(random.randint(1111, 9999))
+
+ async def _get_stored_code(self, mobile: str) -> str | None:
+ key = _otp_key(mobile)
+ stored = await cache.get(key)
+ if stored is None and cache.client is None:
+ stored = _memory_otp.get(mobile)
+ return stored
+
+ async def request_otp(self, mobile: str, *, force: bool = False) -> tuple[int, bool]:
+ """
+ Returns:
+ (expires_in, skipped) — skipped=True یعنی کد قبلی هنوز معتبر است (torbatkar).
+ """
+ normalized = normalize_mobile(mobile)
+ phone_for_sms = _normalize_phone_for_sms(normalized)
+ if not phone_for_sms:
+ raise SmsDeliveryError("شماره موبایل نامعتبر است")
+
+ key = _otp_key(normalized)
+ if not force:
+ stored = await self._get_stored_code(normalized)
+ if stored:
+ remaining = await cache.ttl(key)
+ expires_in = remaining if remaining else settings.otp_expire_seconds
+ return expires_in, True
+
+ code = self._generate_code()
+ ttl = settings.otp_expire_seconds
+
+ await cache.set(key, code, ttl=ttl)
+ if cache.client is None:
+ _memory_otp[normalized] = code
+
+ await self.sms.send_otp(phone_for_sms, code)
+ return ttl, False
+
+ async def verify_otp(self, mobile: str, code: str) -> bool:
+ normalized_code = normalize_otp_digits(code)
+ if not normalized_code:
+ return False
+
+ normalized = normalize_mobile(mobile)
+ key = _otp_key(normalized)
+ stored = await self._get_stored_code(normalized)
+
+ if stored is None or not secrets.compare_digest(stored, normalized_code):
+ return False
+
+ await cache.delete(key)
+ _memory_otp.pop(normalized, None)
+ return True
diff --git a/backend/core-service/app/services/payamak_client.py b/backend/core-service/app/services/payamak_client.py
new file mode 100644
index 0000000..fa6640b
--- /dev/null
+++ b/backend/core-service/app/services/payamak_client.py
@@ -0,0 +1,245 @@
+"""کلاینت Payamak — پورت مستقیم torbatkar-back/SMSPanel/utils.py (send_pattern_sms)."""
+from __future__ import annotations
+
+import asyncio
+import json
+import re
+from typing import Any
+
+import requests
+
+from app.core.config import settings
+from app.core.logging import get_logger
+
+logger = get_logger(__name__)
+
+# همان ثابتهای torbatkar-back
+PAYAMAK_TIMEOUT = (18, 45)
+PAYAMAK_PATTERN_URL = "https://api.payamak-panel.com/post/send.asmx/SendByBaseNumber"
+PAYAMAK_REST_PATTERN_URL = "https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber"
+
+
+class SmsDeliveryError(Exception):
+ def __init__(self, message: str) -> None:
+ self.message = message
+ super().__init__(message)
+
+
+def payamak_is_configured() -> bool:
+ return bool(settings.payamak_username and settings.payamak_auth_secret)
+
+
+def _normalize_phone_for_sms(phone: str | None) -> str | None:
+ """نرمالسازی شماره به فرمت 09xxxxxxxxx — همان torbatkar."""
+ if phone is None:
+ return None
+ digits = re.sub(r"\D", "", str(phone))
+ if digits.startswith("0098"):
+ digits = digits[4:]
+ elif digits.startswith("98") and len(digits) >= 12:
+ digits = digits[2:]
+ if len(digits) == 10:
+ digits = "0" + digits
+ if len(digits) == 11 and digits.startswith("0"):
+ return digits
+ return None
+
+
+def _parse_payamak_response_body(raw_text: str) -> dict[str, Any]:
+ if not raw_text or not str(raw_text).strip():
+ return {}
+ raw_text = str(raw_text).strip()
+ try:
+ data = json.loads(raw_text)
+ if isinstance(data, dict) and isinstance(data.get("d"), str):
+ try:
+ inner = json.loads(data["d"])
+ if isinstance(inner, dict):
+ return inner
+ except json.JSONDecodeError:
+ pass
+ return data if isinstance(data, dict) else {"value": data}
+ except json.JSONDecodeError:
+ pass
+ m = re.search(r"]*>([\s\S]*?)", raw_text, re.I)
+ if m:
+ inner = m.group(1).strip()
+ try:
+ parsed = json.loads(inner)
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
+ except json.JSONDecodeError:
+ pass
+ m = re.search(r"]*>([^<]+)", raw_text, re.I)
+ if m:
+ value = m.group(1).strip()
+ try:
+ n = int(value)
+ return {"RetStatus": n} if n > 0 else {"RetStatus": n, "raw": value}
+ except ValueError:
+ return {"raw": value}
+ m = re.search(r'"RetStatus"\s*:\s*(-?\d+)', raw_text)
+ if m:
+ return {"RetStatus": int(m.group(1)), "_parse_fallback": True}
+ return {"raw": raw_text[:800]}
+
+
+def _payamak_pattern_logical_success(response_data: dict[str, Any]) -> tuple[bool, str | None]:
+ if not isinstance(response_data, dict):
+ return False, "پاسخ نامعتبر از پنل"
+ ss = (response_data.get("StrRetStatus") or response_data.get("strRetStatus") or "").strip()
+ if ss.lower() == "ok":
+ return True, None
+ rs = response_data.get("RetStatus")
+ try:
+ n = int(float(rs))
+ if n > 0:
+ return True, None
+ return False, ss or f"خطای پنل پیامک (کد: {n})"
+ except (TypeError, ValueError):
+ pass
+ raw = response_data.get("raw")
+ if raw:
+ return False, str(raw)[:300]
+ return False, "نتوانستیم نتیجه ارسال را تشخیص دهیم"
+
+
+def send_pattern_sms_sync(
+ to: str,
+ text: list[str] | str,
+ body_id: int,
+ *,
+ username: str | None = None,
+ password: str | None = None,
+) -> dict[str, Any]:
+ """ارسال پترن — کپی منطق torbatkar-back/SMSPanel/utils.send_pattern_sms."""
+ username = username or settings.payamak_username
+ password = password or settings.payamak_auth_secret
+ to = _normalize_phone_for_sms(to) or str(to)
+
+ if not all([username, password, to, text, body_id]):
+ return {"success": False, "error": "پارامترهای لازم ارسال نشده است"}
+
+ if not isinstance(text, list):
+ text = [text]
+
+ try:
+ bid = int(body_id)
+ except (TypeError, ValueError):
+ return {"success": False, "error": "شناسه الگو (bodyId) نامعتبر است"}
+
+ form_items = [
+ ("username", str(username)),
+ ("password", str(password)),
+ ("to", str(to)),
+ ("bodyId", str(bid)),
+ ]
+ for part in text:
+ form_items.append(("text", str(part)))
+
+ json_payload = {
+ "username": username,
+ "password": password,
+ "to": str(to),
+ "text": text,
+ "bodyId": bid,
+ }
+
+ text_param = (
+ json.dumps(text, ensure_ascii=False)
+ if isinstance(text, list) and len(text) > 1
+ else str(text[0] if isinstance(text, list) else text)
+ )
+
+ rest_form = {
+ "username": str(username),
+ "password": str(password),
+ "to": str(to),
+ "bodyId": str(bid),
+ "text": text_param,
+ }
+
+ attempts = [
+ ("rest_https", PAYAMAK_REST_PATTERN_URL, {"data": rest_form}),
+ ("asmx_https", PAYAMAK_PATTERN_URL, {"data": form_items}),
+ ]
+
+ response = None
+ response_data: dict[str, Any] = {}
+ success = False
+ err_logical: str | None = None
+ last_transport_error: str | None = None
+
+ for _label, url, req_kw in attempts:
+ try:
+ req_kw = {**req_kw, "timeout": PAYAMAK_TIMEOUT}
+ response = requests.post(url, **req_kw)
+ response_data = _parse_payamak_response_body(response.text)
+ ok_http = response.status_code == 200
+ ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
+ success = ok_http and ok_logical
+ if success:
+ break
+ if not ok_logical and err_logical:
+ last_transport_error = err_logical
+ except requests.RequestException as exc:
+ last_transport_error = str(exc)
+ continue
+
+ if not success:
+ try:
+ response = requests.post(PAYAMAK_PATTERN_URL, json=json_payload, timeout=PAYAMAK_TIMEOUT)
+ response_data = _parse_payamak_response_body(response.text)
+ ok_http = response.status_code == 200
+ ok_logical, err_logical = _payamak_pattern_logical_success(response_data)
+ success = ok_http and ok_logical
+ except requests.RequestException as exc:
+ last_transport_error = str(exc)
+
+ err_msg = None if success else (err_logical or last_transport_error or "خطا در ارسال پیامک")
+
+ if success:
+ return {
+ "success": True,
+ "message_id": str(response_data.get("RetStatus", "")),
+ "api_response": response_data,
+ }
+ return {
+ "success": False,
+ "error": err_msg,
+ "api_response": response_data,
+ }
+
+
+class PayamakClient:
+ async def send_otp(self, mobile: str, code: str) -> None:
+ if not payamak_is_configured():
+ if settings.environment in ("development", "test"):
+ logger.info("payamak_otp_dev_mode", extra={"mobile": mobile, "code": code})
+ return
+ raise SmsDeliveryError("تنظیمات API پیامک در سرور پیکربندی نشده است")
+
+ phone_for_sms = _normalize_phone_for_sms(mobile)
+ if not phone_for_sms:
+ raise SmsDeliveryError("شماره موبایل نامعتبر است")
+
+ result = await asyncio.to_thread(
+ send_pattern_sms_sync,
+ phone_for_sms,
+ [str(code)],
+ settings.payamak_verification_body_id,
+ )
+
+ if not result.get("success"):
+ err = result.get("error") or "ارسال پیامک ناموفق بود"
+ if "name resolution" in str(err).lower() or "resolve" in str(err).lower():
+ err = "سرویس پیامک موقتاً در دسترس نیست. لطفاً چند دقیقه بعد دوباره تلاش کنید."
+ logger.error(
+ "payamak_otp_failed",
+ extra={"mobile": phone_for_sms, "error": err, "api": result.get("api_response")},
+ )
+ raise SmsDeliveryError(err)
+
+ logger.info(
+ "payamak_otp_sent",
+ extra={"mobile": phone_for_sms, "message_id": result.get("message_id")},
+ )
diff --git a/backend/core-service/app/services/plan_service.py b/backend/core-service/app/services/plan_service.py
new file mode 100644
index 0000000..ddf95a5
--- /dev/null
+++ b/backend/core-service/app/services/plan_service.py
@@ -0,0 +1,127 @@
+"""سرویس مدیریت پلنها، قابلیتها و اتصال آنها."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.plan import Feature, Plan, PlanFeature
+from app.repositories.plan import (
+ FeatureRepository,
+ PlanFeatureRepository,
+ PlanRepository,
+)
+from app.schemas.plan import FeatureCreate, PlanCreate, PlanFeatureCreate
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class PlanService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.plan_repo = PlanRepository(session)
+ self.feature_repo = FeatureRepository(session)
+ self.plan_feature_repo = PlanFeatureRepository(session)
+
+ # ---- Plans ----
+ async def create_plan(self, data: PlanCreate) -> Plan:
+ if await self.plan_repo.code_exists(data.code):
+ raise ConflictError(f"کد پلن تکراری است: {data.code}", error_code="plan_code_taken")
+ plan = Plan(
+ code=data.code,
+ name=data.name,
+ description=data.description,
+ is_active=data.is_active,
+ )
+ await self.plan_repo.add(plan)
+ await self.session.commit()
+ await self.session.refresh(plan)
+ return plan
+
+ async def list_plans(self, *, offset: int, limit: int) -> tuple[Sequence[Plan], int]:
+ items = await self.plan_repo.list(offset=offset, limit=limit)
+ total = await self.plan_repo.count()
+ return items, total
+
+ async def get_plan(self, plan_id: UUID) -> Plan:
+ plan = await self.plan_repo.get(plan_id)
+ if plan is None:
+ raise NotFoundError(f"پلن یافت نشد: {plan_id}")
+ return plan
+
+ async def ensure_default_plan(
+ self, *, code: str = "FREE", name: str = "رایگان"
+ ) -> Plan:
+ """پلن پیشفرض را برمیگرداند؛ در صورت نبود، آن را میسازد (idempotent).
+
+ برای اطمینان از وجود پلن پیشفرض هنگام onboarding استفاده میشود؛ در
+ کنار seed مربوط به migration (که در محیط production زودتر اجرا میشود).
+ """
+ plan = await self.plan_repo.get_by_code(code)
+ if plan is not None:
+ return plan
+ plan = Plan(
+ code=code,
+ name=name,
+ description="پلن پیشفرض تخصیصیافته در زمان onboarding",
+ is_active=True,
+ )
+ await self.plan_repo.add(plan)
+ await self.session.commit()
+ await self.session.refresh(plan)
+ return plan
+
+ # ---- Features ----
+ async def create_feature(self, data: FeatureCreate) -> Feature:
+ if await self.feature_repo.key_exists(data.feature_key):
+ raise ConflictError(
+ f"feature_key تکراری است: {data.feature_key}",
+ error_code="feature_key_taken",
+ )
+ feature = Feature(
+ feature_key=data.feature_key,
+ name=data.name,
+ description=data.description,
+ service_key=data.service_key,
+ is_active=data.is_active,
+ )
+ await self.feature_repo.add(feature)
+ await self.session.commit()
+ await self.session.refresh(feature)
+ return feature
+
+ async def list_features(self, *, offset: int, limit: int) -> tuple[Sequence[Feature], int]:
+ items = await self.feature_repo.list(offset=offset, limit=limit)
+ total = await self.feature_repo.count()
+ return items, total
+
+ # ---- Plan <-> Feature ----
+ async def attach_feature(self, plan_id: UUID, data: PlanFeatureCreate) -> PlanFeature:
+ plan = await self.plan_repo.get(plan_id)
+ if plan is None:
+ raise NotFoundError(f"پلن یافت نشد: {plan_id}")
+ feature = await self.feature_repo.get(data.feature_id)
+ if feature is None:
+ raise NotFoundError(f"قابلیت یافت نشد: {data.feature_id}")
+
+ existing = await self.plan_feature_repo.get(plan_id, data.feature_id)
+ if existing is not None:
+ # بهروزرسانی تنظیمات موجود بهجای ایجاد رکورد تکراری.
+ existing.limit_value = data.limit_value
+ existing.limit_period = data.limit_period
+ existing.is_enabled = data.is_enabled
+ await self.session.commit()
+ await self.session.refresh(existing)
+ return existing
+
+ plan_feature = PlanFeature(
+ plan_id=plan_id,
+ feature_id=data.feature_id,
+ limit_value=data.limit_value,
+ limit_period=data.limit_period,
+ is_enabled=data.is_enabled,
+ )
+ await self.plan_feature_repo.add(plan_feature)
+ await self.session.commit()
+ await self.session.refresh(plan_feature)
+ return plan_feature
diff --git a/backend/core-service/app/services/service_registry_service.py b/backend/core-service/app/services/service_registry_service.py
new file mode 100644
index 0000000..9f3a0a0
--- /dev/null
+++ b/backend/core-service/app/services/service_registry_service.py
@@ -0,0 +1,51 @@
+"""سرویس مدیریت ServiceRegistry (ثبت سرویسهای داخلی آینده)."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.enums import ServiceStatus
+from app.models.registry import ServiceRegistry
+from app.repositories.registry import ServiceRegistryRepository
+from app.schemas.service_registry import ServiceCreate
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class ServiceRegistryService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = ServiceRegistryRepository(session)
+
+ async def create(self, data: ServiceCreate) -> ServiceRegistry:
+ if await self.repo.get_by_key(data.service_key) is not None:
+ raise ConflictError(
+ f"service_key تکراری است: {data.service_key}",
+ error_code="service_key_taken",
+ )
+ service = ServiceRegistry(
+ service_key=data.service_key,
+ name=data.name,
+ base_url=data.base_url,
+ health_check_url=data.health_check_url,
+ status=data.status,
+ )
+ await self.repo.add(service)
+ await self.session.commit()
+ await self.session.refresh(service)
+ return service
+
+ async def list(self, *, offset: int, limit: int) -> tuple[Sequence[ServiceRegistry], int]:
+ items = await self.repo.list(offset=offset, limit=limit)
+ total = await self.repo.count()
+ return items, total
+
+ async def update_status(self, service_id: UUID, status: ServiceStatus) -> ServiceRegistry:
+ service = await self.repo.get(service_id)
+ if service is None:
+ raise NotFoundError(f"سرویس یافت نشد: {service_id}")
+ service.status = status
+ await self.session.commit()
+ await self.session.refresh(service)
+ return service
diff --git a/backend/core-service/app/services/subscription_service.py b/backend/core-service/app/services/subscription_service.py
new file mode 100644
index 0000000..671f1e2
--- /dev/null
+++ b/backend/core-service/app/services/subscription_service.py
@@ -0,0 +1,62 @@
+"""سرویس مدیریت اشتراک tenant."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.cache import cache
+from app.models.subscription import TenantSubscription
+from app.repositories.plan import PlanRepository
+from app.repositories.subscription import SubscriptionRepository
+from app.repositories.tenant import TenantRepository
+from app.schemas.subscription import SubscriptionCreate
+from app.services.event_service import EventService
+from shared.events import CoreEventType
+from shared.exceptions import NotFoundError
+
+
+class SubscriptionService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = SubscriptionRepository(session)
+ self.tenant_repo = TenantRepository(session)
+ self.plan_repo = PlanRepository(session)
+ self.events = EventService(session)
+
+ async def create(self, tenant_id: UUID, data: SubscriptionCreate) -> TenantSubscription:
+ tenant = await self.tenant_repo.get(tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ plan = await self.plan_repo.get(data.plan_id)
+ if plan is None:
+ raise NotFoundError(f"پلن یافت نشد: {data.plan_id}")
+
+ subscription = TenantSubscription(
+ tenant_id=tenant_id,
+ plan_id=data.plan_id,
+ status=data.status,
+ starts_at=data.starts_at,
+ ends_at=data.ends_at,
+ trial_ends_at=data.trial_ends_at,
+ )
+ await self.repo.add(subscription)
+ await self.events.record(
+ event_type=CoreEventType.SUBSCRIPTION_CREATED.value,
+ aggregate_type="subscription",
+ aggregate_id=str(subscription.id),
+ tenant_id=tenant_id,
+ payload={"plan_id": str(data.plan_id), "status": data.status.value},
+ )
+ await self.session.commit()
+ await self.session.refresh(subscription)
+ # با تغییر اشتراک، cache دسترسی قابلیتها باید باطل شود.
+ await cache.delete_pattern(f"feature_access:{tenant_id}:*")
+ await cache.delete(f"tenant:{tenant_id}:features")
+ return subscription
+
+ async def get_current(self, tenant_id: UUID) -> TenantSubscription:
+ subscription = await self.repo.get_active_by_tenant(tenant_id)
+ if subscription is None:
+ raise NotFoundError(f"اشتراکی برای tenant یافت نشد: {tenant_id}")
+ return subscription
diff --git a/backend/core-service/app/services/tenant_context_service.py b/backend/core-service/app/services/tenant_context_service.py
new file mode 100644
index 0000000..584dfa2
--- /dev/null
+++ b/backend/core-service/app/services/tenant_context_service.py
@@ -0,0 +1,89 @@
+"""ساخت TenantContext کامل (tenant + پلن + دامنهها + نقش کاربر) برای frontend."""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.tenant import Tenant
+from app.models.user import User
+from app.repositories.membership import TenantMembershipRepository
+from app.repositories.plan import PlanRepository
+from app.repositories.subscription import SubscriptionRepository
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.domain import DomainRead
+from app.schemas.onboarding import MembershipSummary, TenantContextRead
+from app.schemas.tenant import TenantRead
+from shared.exceptions import NotFoundError
+
+
+class TenantContextService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.tenant_repo = TenantRepository(session)
+ self.domain_repo = DomainRepository(session)
+ self.membership_repo = TenantMembershipRepository(session)
+ self.subscription_repo = SubscriptionRepository(session)
+ self.plan_repo = PlanRepository(session)
+
+ async def build(self, tenant: Tenant, core_user: User | None) -> TenantContextRead:
+ membership = None
+ if core_user is not None:
+ membership = await self.membership_repo.get_by_tenant_and_user(
+ tenant.id, core_user.id
+ )
+
+ domains = await self.domain_repo.list_by_tenant(tenant.id)
+ primary = next((d for d in domains if d.is_primary), None)
+ if primary is None and domains:
+ primary = domains[0]
+
+ subscription = await self.subscription_repo.get_active_by_tenant(tenant.id)
+ plan = await self.plan_repo.get(subscription.plan_id) if subscription else None
+
+ return TenantContextRead(
+ tenant=TenantRead.model_validate(tenant),
+ role=membership.role if membership else None,
+ is_owner=bool(membership.is_owner) if membership else False,
+ plan_code=plan.code if plan else None,
+ plan_name=plan.name if plan else None,
+ subscription_status=subscription.status.value if subscription else None,
+ domains=[DomainRead.model_validate(d) for d in domains],
+ primary_domain=primary.domain if primary else None,
+ )
+
+ async def resolve_current_tenant(self, core_user: User) -> Tenant:
+ """tenant جاری کاربر را برمیگرداند (بر اساس current_tenant_id یا اولین عضویت)."""
+ if core_user.current_tenant_id is not None:
+ tenant = await self.tenant_repo.get(core_user.current_tenant_id)
+ if tenant is not None:
+ return tenant
+
+ memberships = await self.membership_repo.list_by_user(core_user.id)
+ if memberships:
+ tenant = await self.tenant_repo.get(memberships[0].tenant_id)
+ if tenant is not None:
+ return tenant
+
+ raise NotFoundError("هیچ tenantی برای کاربر جاری یافت نشد")
+
+ async def list_memberships_summary(self, core_user: User) -> list[MembershipSummary]:
+ memberships = await self.membership_repo.list_by_user(core_user.id)
+ result: list[MembershipSummary] = []
+ for m in memberships:
+ tenant = await self.tenant_repo.get(m.tenant_id)
+ if tenant is None:
+ continue
+ result.append(
+ MembershipSummary(
+ tenant_id=tenant.id,
+ tenant_name=tenant.name,
+ tenant_slug=tenant.slug,
+ tenant_status=tenant.status,
+ onboarding_completed=tenant.onboarding_completed,
+ role=m.role,
+ status=m.status,
+ is_owner=m.is_owner,
+ )
+ )
+ return result
diff --git a/backend/core-service/app/services/tenant_service.py b/backend/core-service/app/services/tenant_service.py
new file mode 100644
index 0000000..40d9418
--- /dev/null
+++ b/backend/core-service/app/services/tenant_service.py
@@ -0,0 +1,156 @@
+"""سرویس مدیریت Tenant."""
+from __future__ import annotations
+
+from typing import Sequence
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.cache import cache, tenant_metadata_key
+from app.models.enums import DomainType, TenantStatus
+from app.models.tenant import Tenant
+from app.repositories.tenant import DomainRepository, TenantRepository
+from app.schemas.tenant import AdminTenantCreate, TenantCreate, TenantUpdate
+from app.services.event_service import EventService
+from shared.auth import PlatformRole, has_role
+from shared.events import CoreEventType
+from shared.exceptions import ConflictError, ForbiddenError, NotFoundError
+from shared.security import CurrentUser
+
+
+class TenantService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = TenantRepository(session)
+ self.domain_repo = DomainRepository(session)
+ self.events = EventService(session)
+
+ async def create(self, data: TenantCreate) -> Tenant:
+ # slug باید در کل پلتفرم یکتا باشد.
+ if await self.repo.slug_exists(data.slug):
+ raise ConflictError(f"slug تکراری است: {data.slug}", error_code="slug_taken")
+
+ tenant = Tenant(
+ name=data.name,
+ slug=data.slug,
+ owner_user_id=data.owner_user_id,
+ status=TenantStatus.ACTIVE,
+ )
+ await self.repo.add(tenant)
+ if data.custom_domain:
+ await self._add_custom_domain(tenant.id, data.custom_domain)
+ await self.events.record(
+ event_type=CoreEventType.TENANT_CREATED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ payload={"slug": tenant.slug, "name": tenant.name},
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ return tenant
+
+ async def create_for_user(self, user: CurrentUser, data: AdminTenantCreate) -> Tenant:
+ owner_id = UUID(user.user_id)
+ return await self.create(
+ TenantCreate(
+ name=data.name,
+ slug=data.slug,
+ owner_user_id=owner_id,
+ custom_domain=data.custom_domain,
+ )
+ )
+
+ async def _add_custom_domain(self, tenant_id: UUID, domain: str) -> None:
+ if await self.domain_repo.domain_exists(domain):
+ raise ConflictError(
+ f"دامنه تکراری است: {domain}", error_code="domain_taken"
+ )
+ from app.models.domain import Domain
+
+ await self.domain_repo.add(
+ Domain(
+ tenant_id=tenant_id,
+ domain=domain,
+ domain_type=DomainType.CUSTOM_DOMAIN,
+ )
+ )
+
+ async def get(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.repo.get(tenant_id)
+ if tenant is None:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ return tenant
+
+ async def get_by_slug(self, slug: str) -> Tenant | None:
+ return await self.repo.get_by_slug(slug)
+
+ async def list(self, *, offset: int, limit: int) -> tuple[Sequence[Tenant], int]:
+ items = await self.repo.list(offset=offset, limit=limit)
+ total = await self.repo.count()
+ return items, total
+
+ async def list_for_user(
+ self, user: CurrentUser, *, offset: int, limit: int
+ ) -> tuple[Sequence[Tenant], int]:
+ if has_role(user, PlatformRole.PLATFORM_ADMIN):
+ return await self.list(offset=offset, limit=limit)
+ owner_id = UUID(user.user_id)
+ items = await self.repo.list_by_owner(owner_id, offset=offset, limit=limit)
+ total = await self.repo.count_by_owner(owner_id)
+ return items, total
+
+ def ensure_access(self, tenant: Tenant, user: CurrentUser) -> None:
+ if has_role(user, PlatformRole.PLATFORM_ADMIN):
+ return
+ if str(tenant.owner_user_id) != user.user_id:
+ raise ForbiddenError("دسترسی به این tenant ندارید")
+
+ async def update(
+ self, tenant_id: UUID, data: TenantUpdate, user: CurrentUser | None = None
+ ) -> Tenant:
+ tenant = await self.get(tenant_id)
+ if user is not None:
+ self.ensure_access(tenant, user)
+ if data.name is not None:
+ tenant.name = data.name
+ if data.status is not None:
+ tenant.status = data.status
+ if data.owner_user_id is not None:
+ tenant.owner_user_id = data.owner_user_id
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self._invalidate_cache(tenant_id)
+ return tenant
+
+ async def suspend(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.get(tenant_id)
+ tenant.status = TenantStatus.SUSPENDED
+ await self.events.record(
+ event_type=CoreEventType.TENANT_SUSPENDED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self._invalidate_cache(tenant_id)
+ return tenant
+
+ async def activate(self, tenant_id: UUID) -> Tenant:
+ tenant = await self.get(tenant_id)
+ tenant.status = TenantStatus.ACTIVE
+ await self.events.record(
+ event_type=CoreEventType.TENANT_ACTIVATED.value,
+ aggregate_type="tenant",
+ aggregate_id=str(tenant.id),
+ tenant_id=tenant.id,
+ )
+ await self.session.commit()
+ await self.session.refresh(tenant)
+ await self._invalidate_cache(tenant_id)
+ return tenant
+
+ async def _invalidate_cache(self, tenant_id: UUID) -> None:
+ await cache.delete(tenant_metadata_key(str(tenant_id)))
+ await cache.delete_pattern(f"feature_access:{tenant_id}:*")
diff --git a/backend/core-service/app/services/token_service.py b/backend/core-service/app/services/token_service.py
new file mode 100644
index 0000000..638626b
--- /dev/null
+++ b/backend/core-service/app/services/token_service.py
@@ -0,0 +1,83 @@
+"""تولید و اعتبارسنجی JWT محلی برای کاربران OTP."""
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from uuid import UUID
+
+import jwt
+
+from app.core.config import settings
+from app.models.enums import UserRole
+from shared.exceptions import UnauthorizedError
+from shared.security import CurrentUser
+
+OTP_ISSUER = "core-service-otp"
+
+
+class TokenService:
+ def create_access_token(
+ self,
+ *,
+ user_id: UUID,
+ mobile: str,
+ role: UserRole,
+ ) -> tuple[str, int]:
+ expires_in = settings.jwt_access_token_expire_seconds
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": str(user_id),
+ "preferred_username": mobile,
+ "realm_access": {"roles": [role.value]},
+ "iss": OTP_ISSUER,
+ "aud": settings.jwt_audience,
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(seconds=expires_in)).timestamp()),
+ }
+ token = jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+ return token, expires_in
+
+ def validate_access_token(self, token: str) -> CurrentUser:
+ try:
+ claims = jwt.decode(
+ token,
+ settings.jwt_secret,
+ algorithms=["HS256"],
+ audience=settings.jwt_audience,
+ issuer=OTP_ISSUER,
+ )
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+
+ realm_access = claims.get("realm_access") or {}
+ roles = list(realm_access.get("roles", [])) if isinstance(realm_access, dict) else []
+ return CurrentUser(
+ user_id=str(claims.get("sub", "")),
+ username=claims.get("preferred_username"),
+ roles=roles,
+ )
+
+ def create_refresh_token(self, user_id: UUID) -> str:
+ expires_in = settings.jwt_refresh_token_expire_seconds
+ now = datetime.now(timezone.utc)
+ payload = {
+ "sub": str(user_id),
+ "type": "refresh",
+ "iss": OTP_ISSUER,
+ "iat": int(now.timestamp()),
+ "exp": int((now + timedelta(seconds=expires_in)).timestamp()),
+ }
+ return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
+
+ def validate_refresh_token(self, token: str) -> UUID:
+ try:
+ claims = jwt.decode(
+ token,
+ settings.jwt_secret,
+ algorithms=["HS256"],
+ issuer=OTP_ISSUER,
+ )
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن refresh نامعتبر است: {exc}") from exc
+ if claims.get("type") != "refresh":
+ raise UnauthorizedError("توکن refresh نامعتبر است")
+ return UUID(str(claims["sub"]))
diff --git a/backend/core-service/app/services/user_service.py b/backend/core-service/app/services/user_service.py
new file mode 100644
index 0000000..1a40533
--- /dev/null
+++ b/backend/core-service/app/services/user_service.py
@@ -0,0 +1,49 @@
+"""سرویس کمکی برای resolve کردن کاربر Core از روی CurrentUser (JWT).
+
+توکن ممکن است از دو منبع باشد:
+ ۱. JWT محلی OTP (HS256) — sub برابر با users.id است.
+ ۲. JWT مرکزی Keycloak (RS256) — sub برابر با users.keycloak_sub است.
+"""
+from __future__ import annotations
+
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.user import User
+from app.repositories.user import UserRepository
+from shared.exceptions import ForbiddenError
+from shared.security import CurrentUser
+
+
+class UserService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.repo = UserRepository(session)
+
+ async def resolve_current(self, current_user: CurrentUser) -> User:
+ """کاربر Core متناظر با هویت جاری را پیدا میکند.
+
+ اگر کاربر SSO هنوز به یک رکورد Core لینک نشده باشد (مثلاً موبایل
+ تأیید نشده)، خطای قابل مدیریت در frontend برمیگرداند تا کاربر به
+ جریان تکمیل موبایل هدایت شود.
+ """
+ candidate_id: UUID | None = None
+ try:
+ candidate_id = UUID(str(current_user.user_id))
+ except (ValueError, TypeError, AttributeError):
+ candidate_id = None
+
+ if candidate_id is not None:
+ user = await self.repo.get(candidate_id)
+ if user is not None:
+ return user
+
+ user = await self.repo.get_by_keycloak_sub(str(current_user.user_id))
+ if user is not None:
+ return user
+
+ raise ForbiddenError(
+ "پروفایل کاربری هسته یافت نشد. ابتدا شماره موبایل خود را تأیید کنید.",
+ error_code="core_profile_not_linked",
+ )
diff --git a/backend/core-service/app/tests/__init__.py b/backend/core-service/app/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/core-service/app/tests/conftest.py b/backend/core-service/app/tests/conftest.py
new file mode 100644
index 0000000..4e6c3dc
--- /dev/null
+++ b/backend/core-service/app/tests/conftest.py
@@ -0,0 +1,54 @@
+"""پیکربندی مشترک تستها (pytest).
+
+تستها روی SQLite (aiosqlite) اجرا میشوند تا نیازی به PostgreSQL/Redis
+واقعی نباشد. متغیرهای محیطی پیش از import ماژولهای برنامه تنظیم میشوند تا
+engine و session با SQLite ساخته شوند.
+"""
+from __future__ import annotations
+
+import os
+
+# --- تنظیم محیط تست پیش از هر import از برنامه ---
+os.environ["ENVIRONMENT"] = "test"
+os.environ["AUTH_REQUIRED"] = "false"
+os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_core.db"
+os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_core.db"
+os.environ["KEYCLOAK_ENABLED"] = "false"
+os.environ["JWT_VERIFY_SIGNATURE"] = "false"
+os.environ["JWT_SECRET"] = "test-jwt-secret"
+
+import pytest_asyncio # noqa: E402
+from httpx import ASGITransport, AsyncClient # noqa: E402
+
+import app.models # noqa: E402,F401 (ثبت مدلها روی metadata)
+from app.core.cache import cache # noqa: E402
+from app.core.database import AsyncSessionLocal, Base, engine # noqa: E402
+from app.main import app # noqa: E402
+
+# در تستها Redis در دسترس نیست؛ cache را غیرفعال میکنیم (fail-open).
+cache.disable()
+
+
+@pytest_asyncio.fixture
+async def db_setup():
+ """ساخت و پاکسازی اسکیمای دیتابیس برای هر تست (ایزوله)."""
+ async with engine.begin() as conn:
+ 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
+async def client(db_setup):
+ """کلاینت HTTP async برای فراخوانی API درونفرآیندی."""
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ yield ac
+
+
+@pytest_asyncio.fixture
+async def session(db_setup):
+ """یک AsyncSession برای تستهای سطح سرویس/repository."""
+ async with AsyncSessionLocal() as s:
+ yield s
diff --git a/backend/core-service/app/tests/test_domains.py b/backend/core-service/app/tests/test_domains.py
new file mode 100644
index 0000000..8fb3856
--- /dev/null
+++ b/backend/core-service/app/tests/test_domains.py
@@ -0,0 +1,41 @@
+"""تستهای مدیریت و resolve دامنه."""
+from __future__ import annotations
+
+
+async def _create_tenant(client, slug="domainco"):
+ resp = await client.post("/api/v1/tenants", json={"name": "Domain Co", "slug": slug})
+ return resp.json()["id"]
+
+
+async def test_create_domain(client):
+ tenant_id = await _create_tenant(client)
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/domains",
+ json={"domain": "shop.example.com", "domain_type": "custom_domain"},
+ )
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["domain"] == "shop.example.com"
+ assert data["tenant_id"] == tenant_id
+
+
+async def test_resolve_domain(client):
+ tenant_id = await _create_tenant(client, slug="resolveco")
+ await client.post(
+ f"/api/v1/tenants/{tenant_id}/domains",
+ json={"domain": "resolve.example.com"},
+ )
+ resp = await client.post(
+ "/api/v1/domains/resolve", json={"domain": "resolve.example.com"}
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["tenant_id"] == tenant_id
+ assert data["tenant_slug"] == "resolveco"
+
+
+async def test_resolve_unknown_domain_returns_404(client):
+ resp = await client.post(
+ "/api/v1/domains/resolve", json={"domain": "nope.example.com"}
+ )
+ assert resp.status_code == 404
diff --git a/backend/core-service/app/tests/test_features.py b/backend/core-service/app/tests/test_features.py
new file mode 100644
index 0000000..1766be0
--- /dev/null
+++ b/backend/core-service/app/tests/test_features.py
@@ -0,0 +1,107 @@
+"""تستهای قابلیتها و بررسی دسترسی (Entitlement)."""
+from __future__ import annotations
+
+
+async def _setup_tenant_with_plan(client, *, slug, feature_key, attach=True, active=True):
+ """ساخت tenant، پلن، قابلیت، اتصال و اشتراک برای سناریوی تست."""
+ tenant_id = (
+ await client.post("/api/v1/tenants", json={"name": "T", "slug": slug})
+ ).json()["id"]
+
+ plan_id = (
+ await client.post(
+ "/api/v1/plans", json={"code": f"plan-{slug}", "name": "Plan"}
+ )
+ ).json()["id"]
+
+ feature_id = (
+ await client.post(
+ "/api/v1/features",
+ json={
+ "feature_key": feature_key,
+ "name": "Feature",
+ "service_key": feature_key.split(".")[0],
+ },
+ )
+ ).json()["id"]
+
+ if attach:
+ await client.post(
+ f"/api/v1/plans/{plan_id}/features",
+ json={"feature_id": feature_id, "is_enabled": True},
+ )
+
+ await client.post(
+ f"/api/v1/tenants/{tenant_id}/subscription",
+ json={"plan_id": plan_id, "status": "active" if active else "expired"},
+ )
+ return tenant_id
+
+
+async def test_create_feature(client):
+ resp = await client.post(
+ "/api/v1/features",
+ json={
+ "feature_key": "accounting.invoice.create",
+ "name": "ساخت فاکتور",
+ "service_key": "accounting",
+ },
+ )
+ assert resp.status_code == 201
+ assert resp.json()["feature_key"] == "accounting.invoice.create"
+
+
+async def test_feature_key_unique(client):
+ payload = {
+ "feature_key": "crm.lead.create",
+ "name": "Lead",
+ "service_key": "crm",
+ }
+ first = await client.post("/api/v1/features", json=payload)
+ assert first.status_code == 201
+ second = await client.post("/api/v1/features", json=payload)
+ assert second.status_code == 409
+
+
+async def test_check_feature_access_granted(client):
+ tenant_id = await _setup_tenant_with_plan(
+ client, slug="grant", feature_key="ecommerce.product.create"
+ )
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/features/check",
+ json={"feature_key": "ecommerce.product.create"},
+ )
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["has_access"] is True
+ assert body["reason"] == "plan_enabled"
+
+
+async def test_check_feature_access_denied_when_not_in_plan(client):
+ tenant_id = await _setup_tenant_with_plan(
+ client,
+ slug="denyplan",
+ feature_key="live_chat.widget.enable",
+ attach=False,
+ )
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/features/check",
+ json={"feature_key": "live_chat.widget.enable"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["has_access"] is False
+
+
+async def test_check_feature_access_denied_when_subscription_inactive(client):
+ tenant_id = await _setup_tenant_with_plan(
+ client,
+ slug="expired",
+ feature_key="ai_assistant.chat.reply",
+ active=False,
+ )
+ resp = await client.post(
+ f"/api/v1/tenants/{tenant_id}/features/check",
+ json={"feature_key": "ai_assistant.chat.reply"},
+ )
+ assert resp.status_code == 200
+ assert resp.json()["has_access"] is False
diff --git a/backend/core-service/app/tests/test_health.py b/backend/core-service/app/tests/test_health.py
new file mode 100644
index 0000000..db248ed
--- /dev/null
+++ b/backend/core-service/app/tests/test_health.py
@@ -0,0 +1,11 @@
+"""تست endpoint سلامت."""
+from __future__ import annotations
+
+
+async def test_health_ok(client):
+ resp = await client.get("/health")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["status"] == "ok"
+ assert body["service"]
+ assert "redis" in body["dependencies"]
diff --git a/backend/core-service/app/tests/test_middleware.py b/backend/core-service/app/tests/test_middleware.py
new file mode 100644
index 0000000..0dd5027
--- /dev/null
+++ b/backend/core-service/app/tests/test_middleware.py
@@ -0,0 +1,55 @@
+"""تست Middleware تشخیص tenant."""
+from __future__ import annotations
+
+from fastapi import FastAPI, Request
+from httpx import ASGITransport, AsyncClient
+
+from app.middlewares.tenant import TenantResolutionMiddleware
+from app.models.tenant import Tenant
+
+
+def _build_probe_app() -> FastAPI:
+ """اپ کوچک برای بازتاب tenant تشخیصدادهشده از request.state."""
+ probe = FastAPI()
+ probe.add_middleware(TenantResolutionMiddleware)
+
+ @probe.get("/whoami")
+ async def whoami(request: Request):
+ tid = getattr(request.state, "tenant_id", None)
+ slug = getattr(request.state, "tenant_slug", None)
+ return {"tenant_id": str(tid) if tid else None, "tenant_slug": slug}
+
+ return probe
+
+
+async def test_resolve_by_tenant_slug_header(session):
+ tenant = Tenant(name="Mid Co", slug="midco")
+ session.add(tenant)
+ await session.commit()
+
+ transport = ASGITransport(app=_build_probe_app())
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "midco"})
+ assert resp.status_code == 200
+ assert resp.json()["tenant_slug"] == "midco"
+
+
+async def test_resolve_by_tenant_id_header(session):
+ tenant = Tenant(name="Mid Co2", slug="midco2")
+ session.add(tenant)
+ await session.commit()
+ tid = str(tenant.id)
+
+ transport = ASGITransport(app=_build_probe_app())
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ resp = await ac.get("/whoami", headers={"X-Tenant-ID": tid})
+ assert resp.status_code == 200
+ assert resp.json()["tenant_id"] == tid
+
+
+async def test_unresolved_tenant_is_none(session):
+ transport = ASGITransport(app=_build_probe_app())
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ resp = await ac.get("/whoami", headers={"X-Tenant-Slug": "unknown-xyz"})
+ assert resp.status_code == 200
+ assert resp.json()["tenant_id"] is None
diff --git a/backend/core-service/app/tests/test_onboarding.py b/backend/core-service/app/tests/test_onboarding.py
new file mode 100644
index 0000000..665921a
--- /dev/null
+++ b/backend/core-service/app/tests/test_onboarding.py
@@ -0,0 +1,216 @@
+"""تستهای فاز ۳: onboarding و فعالسازی workspace (tenant)."""
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def enable_auth(monkeypatch):
+ monkeypatch.setenv("AUTH_REQUIRED", "true")
+ monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
+ monkeypatch.setenv("PLATFORM_BASE_DOMAIN", "localhost")
+ from app.core.config import get_settings, settings
+
+ get_settings.cache_clear()
+ fresh = get_settings()
+ monkeypatch.setattr(settings, "auth_required", True)
+ monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
+ monkeypatch.setattr(settings, "platform_base_domain", fresh.platform_base_domain)
+
+
+async def _login(client, mobile: str) -> dict:
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ resp = await client.post(
+ "/api/v1/auth/otp/verify", json={"mobile": mobile, "code": "1234"}
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ return {"Authorization": f"Bearer {data['access_token']}"}, data["user_id"]
+
+
+async def test_me_requires_onboarding_for_new_user(client):
+ headers, user_id = await _login(client, "09121110001")
+
+ resp = await client.get("/api/v1/me", headers=headers)
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["user_id"] == user_id
+ assert data["onboarding_required"] is True
+ assert data["current_tenant_id"] is None
+ assert data["memberships"] == []
+
+
+async def test_full_onboarding_flow(client):
+ headers, user_id = await _login(client, "09121110002")
+
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={
+ "business_name": "کافه تربت",
+ "slug": "cafe-torbat-2",
+ "business_type": "cafe",
+ "default_locale": "fa-IR",
+ "timezone": "Asia/Tehran",
+ },
+ headers=headers,
+ )
+ assert create.status_code == 201, create.text
+ body = create.json()
+ tenant_id = body["tenant"]["id"]
+ assert body["tenant"]["status"] == "pending_activation"
+ assert body["tenant"]["onboarding_completed"] is False
+ assert body["role"] == "tenant_owner"
+ assert body["is_owner"] is True
+ assert body["plan_code"] == "FREE"
+ assert body["primary_domain"] == "cafe-torbat-2.localhost"
+ assert body["domains"][0]["domain_type"] == "subdomain"
+ assert body["domains"][0]["is_verified"] is True
+
+ # اگر دوباره تلاش کنیم tenant دیگری بسازیم روی همین slug، باید خطای تکراری بگیریم.
+ dup = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "X", "slug": "cafe-torbat-2"},
+ headers=headers,
+ )
+ assert dup.status_code == 409
+ assert dup.json()["error"]["code"] == "slug_taken"
+
+ branding = await client.patch(
+ f"/api/v1/onboarding/tenant/{tenant_id}/branding",
+ json={"primary_color": "#112233", "secondary_color": "#445566"},
+ headers=headers,
+ )
+ assert branding.status_code == 200
+ assert branding.json()["tenant"]["primary_color"] == "#112233"
+
+ domain = await client.patch(
+ f"/api/v1/onboarding/tenant/{tenant_id}/domain",
+ json={"custom_domain": "cafe2.example.com"},
+ headers=headers,
+ )
+ assert domain.status_code == 200
+ hosts = [d["domain"] for d in domain.json()["domains"]]
+ assert "cafe2.example.com" in hosts
+
+ complete = await client.post(
+ f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
+ )
+ assert complete.status_code == 200
+ completed_body = complete.json()
+ assert completed_body["tenant"]["status"] == "active"
+ assert completed_body["tenant"]["onboarding_completed"] is True
+
+ me = await client.get("/api/v1/me", headers=headers)
+ me_data = me.json()
+ assert me_data["onboarding_required"] is False
+ assert me_data["current_tenant_id"] == tenant_id
+ assert len(me_data["memberships"]) == 1
+ assert me_data["memberships"][0]["role"] == "tenant_owner"
+
+ current = await client.get("/api/v1/tenant/current", headers=headers)
+ assert current.status_code == 200
+ assert current.json()["tenant"]["id"] == tenant_id
+
+ my_tenants = await client.get("/api/v1/me/tenants", headers=headers)
+ assert my_tenants.status_code == 200
+ slugs = [t["tenant_slug"] for t in my_tenants.json()]
+ assert "cafe-torbat-2" in slugs
+
+
+async def test_onboarding_tenant_requires_auth(client):
+ resp = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "X", "slug": "no-auth-tenant"},
+ )
+ assert resp.status_code == 401
+
+
+async def test_branding_forbidden_for_non_member(client):
+ headers_owner, _ = await _login(client, "09121110003")
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "Owner Co", "slug": "owner-co"},
+ headers=headers_owner,
+ )
+ tenant_id = create.json()["tenant"]["id"]
+
+ headers_other, _ = await _login(client, "09121110004")
+ resp = await client.patch(
+ f"/api/v1/onboarding/tenant/{tenant_id}/branding",
+ json={"primary_color": "#000000"},
+ headers=headers_other,
+ )
+ assert resp.status_code == 403
+
+
+async def test_complete_fails_without_required_fields(client):
+ headers, _ = await _login(client, "09121110005")
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "بدون برند", "slug": "no-brand-co"},
+ headers=headers,
+ )
+ tenant_id = create.json()["tenant"]["id"]
+
+ # بدون تنظیم برندینگ کامل هم میتوان تکمیل کرد (فیلدهای برندینگ اختیاریاند)،
+ # اما تکمیل روی tenant دیگری که عضو آن نیستیم باید 403/404 بدهد.
+ other_headers, _ = await _login(client, "09121110006")
+ resp = await client.post(
+ f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=other_headers
+ )
+ assert resp.status_code == 403
+
+ ok = await client.post(
+ f"/api/v1/onboarding/tenant/{tenant_id}/complete", headers=headers
+ )
+ assert ok.status_code == 200
+ assert ok.json()["tenant"]["status"] == "active"
+
+
+async def test_tenant_switch_between_multiple_tenants(client):
+ headers, user_id = await _login(client, "09121110007")
+
+ first = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "First Co", "slug": "first-co"},
+ headers=headers,
+ )
+ first_id = first.json()["tenant"]["id"]
+
+ second = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "Second Co", "slug": "second-co"},
+ headers=headers,
+ )
+ second_id = second.json()["tenant"]["id"]
+
+ # اولین tenant ساختهشده بهعنوان current انتخاب میشود (چون current_tenant_id قبلاً خالی بود)؛
+ # ساخت tenant دوم آن را تغییر نمیدهد.
+ me = await client.get("/api/v1/me", headers=headers)
+ assert me.json()["current_tenant_id"] == first_id
+ assert len(me.json()["memberships"]) == 2
+
+ switch = await client.post(
+ "/api/v1/tenant/switch", json={"tenant_id": second_id}, headers=headers
+ )
+ assert switch.status_code == 200
+ assert switch.json()["tenant"]["id"] == second_id
+
+ current = await client.get("/api/v1/tenant/current", headers=headers)
+ assert current.json()["tenant"]["id"] == second_id
+
+
+async def test_tenant_switch_forbidden_for_non_member(client):
+ headers_owner, _ = await _login(client, "09121110008")
+ create = await client.post(
+ "/api/v1/onboarding/tenant",
+ json={"business_name": "Solo Co", "slug": "solo-co"},
+ headers=headers_owner,
+ )
+ tenant_id = create.json()["tenant"]["id"]
+
+ headers_other, _ = await _login(client, "09121110009")
+ resp = await client.post(
+ "/api/v1/tenant/switch", json={"tenant_id": tenant_id}, headers=headers_other
+ )
+ assert resp.status_code == 403
diff --git a/backend/core-service/app/tests/test_otp_auth.py b/backend/core-service/app/tests/test_otp_auth.py
new file mode 100644
index 0000000..4c9f9e0
--- /dev/null
+++ b/backend/core-service/app/tests/test_otp_auth.py
@@ -0,0 +1,128 @@
+"""تستهای OTP و ثبتنام خودکار."""
+from __future__ import annotations
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def enable_auth(monkeypatch):
+ monkeypatch.setenv("AUTH_REQUIRED", "true")
+ monkeypatch.setenv("JWT_SECRET", "test-jwt-secret")
+ from app.core.config import get_settings, settings
+
+ get_settings.cache_clear()
+ fresh = get_settings()
+ monkeypatch.setattr(settings, "auth_required", True)
+ monkeypatch.setattr(settings, "jwt_secret", fresh.jwt_secret)
+
+
+async def test_otp_request_and_verify_new_user(client):
+ mobile = "09123456789"
+ req = await client.post(
+ "/api/v1/auth/otp/request",
+ json={"mobile": mobile, "context": "admin"},
+ )
+ assert req.status_code == 200
+ assert req.json()["expires_in"] == 120
+
+ verify = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234", "context": "admin"},
+ )
+ assert verify.status_code == 200
+ data = verify.json()
+ assert data["is_new_user"] is True
+ assert data["role"] == "pending_tenant_admin"
+ assert "access_token" in data
+ assert "refresh_token" in data
+
+
+async def test_otp_verify_existing_user(client):
+ mobile = "09111111111"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ first = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ assert first.json()["is_new_user"] is True
+
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ second = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ assert second.status_code == 200
+ assert second.json()["is_new_user"] is False
+
+
+async def test_otp_verify_invalid_code(client):
+ mobile = "09222222222"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ resp = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "000000"},
+ )
+ assert resp.status_code == 401
+
+
+async def test_otp_request_skips_unexpired_code(client):
+ mobile = "09444444444"
+ first = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ assert first.status_code == 200
+ assert first.json()["skipped"] is False
+
+ second = await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ assert second.status_code == 200
+ body = second.json()
+ assert body["skipped"] is True
+ assert body["message"] == "کد قبلاً ارسال شده"
+
+ forced = await client.post(
+ "/api/v1/auth/otp/request",
+ json={"mobile": mobile, "force_sms": True},
+ )
+ assert forced.status_code == 200
+ assert forced.json()["skipped"] is False
+
+
+async def test_platform_admin_mobile_gets_admin_role(client, monkeypatch):
+ from app.core.config import get_settings, settings
+
+ monkeypatch.setenv("PLATFORM_ADMIN_MOBILES", "09155105404")
+ get_settings.cache_clear()
+ fresh = get_settings()
+ monkeypatch.setattr(settings, "platform_admin_mobiles", fresh.platform_admin_mobiles)
+
+ mobile = "09155105404"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ verify = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ assert verify.status_code == 200
+ assert verify.json()["role"] == "platform_admin"
+
+
+async def test_admin_tenant_with_otp_token(client):
+ mobile = "09333333333"
+ await client.post("/api/v1/auth/otp/request", json={"mobile": mobile})
+ auth = await client.post(
+ "/api/v1/auth/otp/verify",
+ json={"mobile": mobile, "code": "1234"},
+ )
+ token = auth.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ created = await client.post(
+ "/api/v1/admin/tenants",
+ json={"name": "My Shop", "slug": "my-shop"},
+ headers=headers,
+ )
+ assert created.status_code == 201
+ body = created.json()
+ assert body["slug"] == "my-shop"
+ assert body["owner_user_id"] == auth.json()["user_id"]
+
+ listed = await client.get("/api/v1/admin/tenants", headers=headers)
+ assert listed.status_code == 200
+ assert listed.json()["meta"]["total_items"] == 1
diff --git a/backend/core-service/app/tests/test_outbox.py b/backend/core-service/app/tests/test_outbox.py
new file mode 100644
index 0000000..fca16f9
--- /dev/null
+++ b/backend/core-service/app/tests/test_outbox.py
@@ -0,0 +1,35 @@
+"""تست پردازش رویدادهای Outbox."""
+from __future__ import annotations
+
+from sqlalchemy import select
+
+from app.models.events import OutboxEvent
+from app.services.tenant_service import TenantService
+from app.schemas.tenant import TenantCreate
+from app.workers.tasks import process_pending_outbox
+from shared.events import EventStatus
+
+
+async def test_outbox_event_recorded_on_tenant_create(session):
+ await TenantService(session).create(
+ TenantCreate(name="Evt Co", slug="evtco")
+ )
+ result = await session.execute(select(OutboxEvent))
+ events = result.scalars().all()
+ assert len(events) == 1
+ assert events[0].event_type == "tenant.created"
+ assert events[0].status == EventStatus.PENDING
+
+
+async def test_process_pending_outbox(session):
+ await TenantService(session).create(
+ TenantCreate(name="Proc Co", slug="procco")
+ )
+ summary = await process_pending_outbox(session)
+ assert summary["processed"] == 1
+ assert summary["failed"] == 0
+
+ result = await session.execute(select(OutboxEvent))
+ events = result.scalars().all()
+ assert all(e.status == EventStatus.PROCESSED for e in events)
+ assert all(e.processed_at is not None for e in events)
diff --git a/backend/core-service/app/tests/test_payamak_client.py b/backend/core-service/app/tests/test_payamak_client.py
new file mode 100644
index 0000000..500be1e
--- /dev/null
+++ b/backend/core-service/app/tests/test_payamak_client.py
@@ -0,0 +1,35 @@
+"""تست پارس و نرمالسازی Payamak — هممنطق torbatkar-back/SMSPanel/utils."""
+from __future__ import annotations
+
+from app.services.payamak_client import (
+ _normalize_phone_for_sms,
+ _parse_payamak_response_body,
+ _payamak_pattern_logical_success,
+)
+
+
+def test_normalize_phone_eleven_digits():
+ assert _normalize_phone_for_sms("09123456789") == "09123456789"
+ assert _normalize_phone_for_sms("9123456789") == "09123456789"
+ assert _normalize_phone_for_sms("+989123456789") == "09123456789"
+ assert _normalize_phone_for_sms("00989123456789") == "09123456789"
+ assert _normalize_phone_for_sms("invalid") is None
+
+
+def test_parse_asmx_success():
+ xml = """
+
+ 98765432101234567
+ """
+ data = _parse_payamak_response_body(xml)
+ ok, err = _payamak_pattern_logical_success(data)
+ assert ok is True
+ assert err is None
+
+
+def test_parse_asmx_error():
+ xml = "-4"
+ data = _parse_payamak_response_body(xml)
+ ok, err = _payamak_pattern_logical_success(data)
+ assert ok is False
+ assert err is not None
diff --git a/backend/core-service/app/tests/test_tenants.py b/backend/core-service/app/tests/test_tenants.py
new file mode 100644
index 0000000..91d8444
--- /dev/null
+++ b/backend/core-service/app/tests/test_tenants.py
@@ -0,0 +1,57 @@
+"""تستهای مدیریت Tenant."""
+from __future__ import annotations
+
+
+async def _create_tenant(client, slug="acme", name="Acme Inc"):
+ return await client.post("/api/v1/tenants", json={"name": name, "slug": slug})
+
+
+async def test_create_tenant(client):
+ resp = await _create_tenant(client)
+ assert resp.status_code == 201
+ data = resp.json()
+ assert data["slug"] == "acme"
+ assert data["status"] == "active"
+ assert "id" in data
+
+
+async def test_create_tenant_normalizes_slug(client):
+ resp = await _create_tenant(client, slug="Acme-Co")
+ assert resp.status_code == 201
+ assert resp.json()["slug"] == "acme-co"
+
+
+async def test_slug_must_be_unique(client):
+ first = await _create_tenant(client, slug="dup")
+ assert first.status_code == 201
+ second = await _create_tenant(client, slug="dup")
+ assert second.status_code == 409
+ assert second.json()["error"]["code"] == "slug_taken"
+
+
+async def test_get_and_list_tenants(client):
+ created = await _create_tenant(client, slug="listme")
+ tenant_id = created.json()["id"]
+
+ got = await client.get(f"/api/v1/tenants/{tenant_id}")
+ assert got.status_code == 200
+ assert got.json()["id"] == tenant_id
+
+ listed = await client.get("/api/v1/tenants")
+ assert listed.status_code == 200
+ body = listed.json()
+ assert body["meta"]["total_items"] >= 1
+ assert len(body["items"]) >= 1
+
+
+async def test_suspend_and_activate(client):
+ created = await _create_tenant(client, slug="susp")
+ tenant_id = created.json()["id"]
+
+ suspended = await client.post(f"/api/v1/tenants/{tenant_id}/suspend")
+ assert suspended.status_code == 200
+ assert suspended.json()["status"] == "suspended"
+
+ activated = await client.post(f"/api/v1/tenants/{tenant_id}/activate")
+ assert activated.status_code == 200
+ assert activated.json()["status"] == "active"
diff --git a/backend/core-service/app/utils/phone.py b/backend/core-service/app/utils/phone.py
new file mode 100644
index 0000000..6fcad5c
--- /dev/null
+++ b/backend/core-service/app/utils/phone.py
@@ -0,0 +1,12 @@
+"""نرمالسازی شماره موبایل و کد OTP (هممنطق torbatkar-back)."""
+from __future__ import annotations
+
+from shared.phone import normalize_mobile as _normalize_mobile
+from shared.phone import normalize_otp_digits
+
+
+def normalize_mobile(raw: str) -> str:
+ return _normalize_mobile(raw)
+
+
+__all__ = ["normalize_mobile", "normalize_otp_digits"]
diff --git a/backend/core-service/app/workers/__init__.py b/backend/core-service/app/workers/__init__.py
new file mode 100644
index 0000000..9e21e4f
--- /dev/null
+++ b/backend/core-service/app/workers/__init__.py
@@ -0,0 +1 @@
+"""Workerهای پسزمینه (Celery)."""
diff --git a/backend/core-service/app/workers/celery_app.py b/backend/core-service/app/workers/celery_app.py
new file mode 100644
index 0000000..23ce90e
--- /dev/null
+++ b/backend/core-service/app/workers/celery_app.py
@@ -0,0 +1,45 @@
+"""پیکربندی Celery برای پردازشهای async.
+
+صفهای اولیه:
+ default - وظایف عمومی
+ core_events - پردازش رویدادهای هسته (Outbox)
+ notifications - اعلانها (فازهای بعدی)
+ webhooks - ارسال webhook (فازهای بعدی)
+"""
+from __future__ import annotations
+
+from celery import Celery
+from kombu import Queue
+
+from app.core.config import settings
+
+celery_app = Celery(
+ "core_service",
+ broker=settings.celery_broker_url,
+ backend=settings.celery_result_backend,
+ include=["app.workers.tasks"],
+)
+
+celery_app.conf.update(
+ task_serializer="json",
+ result_serializer="json",
+ accept_content=["json"],
+ timezone="UTC",
+ enable_utc=True,
+ task_track_started=True,
+ task_default_queue="default",
+ task_queues=(
+ Queue("default"),
+ Queue("core_events"),
+ Queue("notifications"),
+ Queue("webhooks"),
+ ),
+ # زمانبندی دورهای برای پردازش Outbox.
+ beat_schedule={
+ "process-outbox-events": {
+ "task": "app.workers.tasks.process_outbox_events",
+ "schedule": 30.0, # هر ۳۰ ثانیه
+ "options": {"queue": "core_events"},
+ },
+ },
+)
diff --git a/backend/core-service/app/workers/tasks.py b/backend/core-service/app/workers/tasks.py
new file mode 100644
index 0000000..8b6892d
--- /dev/null
+++ b/backend/core-service/app/workers/tasks.py
@@ -0,0 +1,72 @@
+"""وظایف Celery.
+
+در فاز ۱ فقط یک وظیفه نمونه پیاده شده است: process_outbox_events که
+رویدادهای pending را میخواند و وضعیت آنها را به processed/failed تغییر
+میدهد. انتشار واقعی رویدادها (به message bus یا webhook) در فازهای بعد.
+"""
+from __future__ import annotations
+
+import asyncio
+from datetime import datetime, timezone
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
+
+from app.core.config import settings
+from app.core.logging import get_logger
+from app.repositories.registry import OutboxRepository
+from app.workers.celery_app import celery_app
+from shared.events import EventStatus
+
+logger = get_logger(__name__)
+
+
+async def process_pending_outbox(session: AsyncSession, *, batch_size: int = 100) -> dict:
+ """منطق اصلی پردازش Outbox روی یک session مشخص.
+
+ این تابع مستقل از Celery است تا هم توسط task و هم در تست قابل استفاده باشد.
+ برمیگرداند: تعداد پردازششده و ناموفق.
+ """
+ repo = OutboxRepository(session)
+ pending = await repo.list_pending(limit=batch_size)
+
+ processed = 0
+ failed = 0
+ now = datetime.now(timezone.utc)
+
+ for event in pending:
+ try:
+ # در فاز ۱ صرفاً بهعنوان پردازششده علامت میخورد.
+ # منطق انتشار واقعی رویداد در فازهای بعدی اینجا اضافه میشود.
+ event.status = EventStatus.PROCESSED
+ event.processed_at = now
+ processed += 1
+ except Exception as exc: # pragma: no cover - محافظ
+ event.status = EventStatus.FAILED
+ event.retry_count += 1
+ failed += 1
+ logger.warning(
+ "outbox_event_failed",
+ extra={"event_id": str(event.id), "error": str(exc)},
+ )
+
+ await session.commit()
+ return {"processed": processed, "failed": failed}
+
+
+async def _run_process_outbox() -> dict:
+ """ساخت engine/session مستقل برای اجرای task در حلقه رویداد جدید."""
+ engine = create_async_engine(settings.database_url, pool_pre_ping=True)
+ session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+ try:
+ async with session_factory() as session:
+ return await process_pending_outbox(session)
+ finally:
+ await engine.dispose()
+
+
+@celery_app.task(name="app.workers.tasks.process_outbox_events")
+def process_outbox_events() -> dict:
+ """Task دورهای پردازش رویدادهای Outbox."""
+ result = asyncio.run(_run_process_outbox())
+ logger.info("outbox_processed", extra=result)
+ return result
diff --git a/backend/core-service/pytest.ini b/backend/core-service/pytest.ini
new file mode 100644
index 0000000..82ac114
--- /dev/null
+++ b/backend/core-service/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+asyncio_mode = auto
+testpaths = app/tests
+python_files = test_*.py
+addopts = -q
diff --git a/backend/core-service/requirements.txt b/backend/core-service/requirements.txt
new file mode 100644
index 0000000..b0c423b
--- /dev/null
+++ b/backend/core-service/requirements.txt
@@ -0,0 +1,32 @@
+# ---- Core Framework ----
+fastapi==0.111.0
+uvicorn[standard]==0.30.1
+pydantic==2.7.4
+pydantic-settings==2.3.4
+
+# ---- Database ----
+sqlalchemy==2.0.31
+alembic==1.13.2
+asyncpg==0.29.0 # درایور async برای PostgreSQL
+psycopg[binary]==3.2.1 # درایور sync برای Alembic
+
+# ---- Cache / Redis ----
+redis==5.0.7
+
+# ---- Celery ----
+celery==5.4.0
+
+# ---- HTTP client (ارتباط بین سرویسها) ----
+httpx==0.27.0
+requests==2.32.3
+
+# ---- Auth / JWT ----
+pyjwt[crypto]==2.8.0
+
+# ---- کتابخانه مشترک پلتفرم ----
+-e ../shared-lib
+
+# ---- Testing ----
+pytest==8.2.2
+pytest-asyncio==0.23.7
+aiosqlite==0.20.0 # دیتابیس درونحافظهای برای تستها
diff --git a/backend/services/README.md b/backend/services/README.md
new file mode 100644
index 0000000..1c651a9
--- /dev/null
+++ b/backend/services/README.md
@@ -0,0 +1,13 @@
+# services/ — سرویسهای آینده (Placeholder)
+
+این پوشه شامل placeholder سرویسهای آینده پلتفرم است. در **فاز ۱** هیچکدام
+پیادهسازی نشدهاند و فقط README مسئولیتها موجود است.
+
+هر سرویس در فازهای بعدی طبق اصول معماری زیر ساخته میشود:
+
+- **Database-per-service:** هر سرویس دیتابیس مستقل خود را دارد.
+- همه جداول بیزینسی ستون `tenant_id` دارند.
+- ارتباط بین سرویسها فقط از طریق REST API، Webhook، Async Event و الگوی Outbox/Inbox.
+- ارتباط مستقیم بین دیتابیس سرویسها **ممنوع** است.
+
+جزئیات قراردادها در [`../../docs/services_contracts.md`](../../docs/services_contracts.md).
diff --git a/backend/services/accounting/README.md b/backend/services/accounting/README.md
new file mode 100644
index 0000000..4a132ef
--- /dev/null
+++ b/backend/services/accounting/README.md
@@ -0,0 +1,19 @@
+# Accounting Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- حسابداری ۴ سطحی
+- اسناد دوبل (Double-entry)
+- مرکز هزینه
+- پروژه
+- فاکتور
+- پرداخت
+- مالیات
+- سامانه مؤدیان
+- گزارشهای مالی
+
+## اصول
+- دیتابیس مستقل (`accounting_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`accounting.*`).
diff --git a/backend/services/ai_assistant/README.md b/backend/services/ai_assistant/README.md
new file mode 100644
index 0000000..ab306c5
--- /dev/null
+++ b/backend/services/ai_assistant/README.md
@@ -0,0 +1,15 @@
+# AI Assistant Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- پاسخگویی هوشمند چت
+- پشتیبانی فروشگاه
+- کمک به CRM
+- اتصال به knowledge base
+- routing به اپراتور انسانی
+
+## اصول
+- دیتابیس مستقل (`ai_assistant_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`ai_assistant.*`).
diff --git a/backend/services/crm/README.md b/backend/services/crm/README.md
new file mode 100644
index 0000000..cb95803
--- /dev/null
+++ b/backend/services/crm/README.md
@@ -0,0 +1,18 @@
+# CRM Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- مشتریان
+- سرنخها (Leads)
+- فرصتها (Opportunities)
+- pipeline
+- stageهای قابل تنظیم
+- task
+- activity
+- automation
+
+## اصول
+- دیتابیس مستقل (`crm_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`crm.*`).
diff --git a/backend/services/ecommerce/README.md b/backend/services/ecommerce/README.md
new file mode 100644
index 0000000..256caf7
--- /dev/null
+++ b/backend/services/ecommerce/README.md
@@ -0,0 +1,19 @@
+# Ecommerce / Online Store Builder Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- فروشگاهساز
+- محصولات
+- دستهبندی
+- موجودی
+- سفارش
+- پرداخت
+- ارسال
+- تخفیف
+- سبد خرید
+
+## اصول
+- دیتابیس مستقل (`ecommerce_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`ecommerce.*`).
diff --git a/backend/services/file_storage/README.md b/backend/services/file_storage/README.md
new file mode 100644
index 0000000..81ebf9d
--- /dev/null
+++ b/backend/services/file_storage/README.md
@@ -0,0 +1,14 @@
+# File Storage Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- آپلود فایل
+- مدیریت asset
+- دسترسی tenant-aware
+- اتصال به S3-compatible storage
+
+## اصول
+- دیتابیس مستقل (`file_storage_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`file_storage.*`).
diff --git a/backend/services/identity-access/Dockerfile b/backend/services/identity-access/Dockerfile
new file mode 100644
index 0000000..36e7838
--- /dev/null
+++ b/backend/services/identity-access/Dockerfile
@@ -0,0 +1,16 @@
+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/identity-access/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
+
+COPY backend/services/identity-access/ /app/
+EXPOSE 8001
+CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"]
diff --git a/backend/services/identity-access/Dockerfile.dev b/backend/services/identity-access/Dockerfile.dev
new file mode 100644
index 0000000..ded0ca5
--- /dev/null
+++ b/backend/services/identity-access/Dockerfile.dev
@@ -0,0 +1,22 @@
+# Dev image: فقط وابستگیها — کد با volume mount و 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/identity-access/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 8001
diff --git a/backend/services/identity-access/README.md b/backend/services/identity-access/README.md
new file mode 100644
index 0000000..ad6cf0e
--- /dev/null
+++ b/backend/services/identity-access/README.md
@@ -0,0 +1,42 @@
+# Identity & Access Service
+
+سرویس احراز هویت و دسترسی (فاز ۲) — SSO مرکزی با Keycloak.
+
+## مسئولیتها
+- مدیریت پروفایل کاربر و عضویت tenant
+- ارائه پیکربندی OIDC به frontend
+- تبدیل authorization code به token (BFF)
+- همگامسازی کاربران با Keycloak Admin API
+
+## دیتابیس
+`identity_access_db` (مستقل از Core — database-per-service)
+
+## APIهای اصلی
+| متد | مسیر | توضیح |
+| --- | --- | --- |
+| GET | `/health` | سلامت سرویس |
+| GET | `/api/v1/auth/config` | پیکربندی OIDC (عمومی) |
+| POST | `/api/v1/auth/token` | تبدیل code به token |
+| GET | `/api/v1/auth/me` | کاربر جاری |
+| POST | `/api/v1/users` | ثبت کاربر (admin) |
+| POST | `/api/v1/tenants/{id}/members` | افزودن عضو tenant |
+
+## اجرا
+```bash
+cd backend/services/identity-access
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload --port 8001
+```
+
+## تست
+```bash
+pytest -q
+```
+
+## SSO Flow
+1. Frontend از `/api/v1/auth/config` آدرس Keycloak را میگیرد.
+2. کاربر به Keycloak redirect میشود.
+3. پس از login، callback با `code` به frontend برمیگردد.
+4. Frontend کد را به `/api/v1/auth/token` میفرستد و token دریافت میکند.
+5. همه سرویسها (Core، CRM، ...) همان JWT را validate میکنند.
diff --git a/backend/services/identity-access/alembic.ini b/backend/services/identity-access/alembic.ini
new file mode 100644
index 0000000..d96553e
--- /dev/null
+++ b/backend/services/identity-access/alembic.ini
@@ -0,0 +1,29 @@
+[alembic]
+script_location = alembic
+prepend_sys_path = .
+version_path_separator = os
+sqlalchemy.url =
+[loggers]
+keys = root,sqlalchemy,alembic
+[handlers]
+keys = console
+[formatters]
+keys = generic
+[logger_root]
+level = WARNING
+handlers = console
+[logger_sqlalchemy]
+level = WARNING
+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
diff --git a/backend/services/identity-access/alembic/env.py b/backend/services/identity-access/alembic/env.py
new file mode 100644
index 0000000..a652497
--- /dev/null
+++ b/backend/services/identity-access/alembic/env.py
@@ -0,0 +1,34 @@
+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
+
+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/identity-access/alembic/versions/0001_initial.py b/backend/services/identity-access/alembic/versions/0001_initial.py
new file mode 100644
index 0000000..8a175f0
--- /dev/null
+++ b/backend/services/identity-access/alembic/versions/0001_initial.py
@@ -0,0 +1,17 @@
+"""initial identity schema"""
+from alembic import op
+from app.core.database import Base
+import app.models # noqa
+
+revision = "0001_initial"
+down_revision = 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/identity-access/alembic/versions/0002_user_mobile.py b/backend/services/identity-access/alembic/versions/0002_user_mobile.py
new file mode 100644
index 0000000..77b9ca6
--- /dev/null
+++ b/backend/services/identity-access/alembic/versions/0002_user_mobile.py
@@ -0,0 +1,40 @@
+"""user mobile fields for unified SSO
+
+Revision ID: 0002_user_mobile
+Revises: 0001_initial
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "0002_user_mobile"
+down_revision: Union[str, None] = "0001_initial"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column("user_profiles", sa.Column("mobile", sa.String(length=15), nullable=True))
+ op.add_column(
+ "user_profiles",
+ sa.Column("mobile_verified", sa.Boolean(), nullable=False, server_default=sa.text("false")),
+ )
+ op.add_column(
+ "user_profiles",
+ sa.Column("mobile_verified_at", sa.DateTime(timezone=True), nullable=True),
+ )
+ op.add_column("user_profiles", sa.Column("core_user_id", sa.UUID(), nullable=True))
+ op.create_index("ix_user_profiles_mobile", "user_profiles", ["mobile"], unique=True)
+ op.alter_column("user_profiles", "mobile_verified", server_default=None)
+
+
+def downgrade() -> None:
+ op.drop_index("ix_user_profiles_mobile", table_name="user_profiles")
+ op.drop_column("user_profiles", "core_user_id")
+ op.drop_column("user_profiles", "mobile_verified_at")
+ op.drop_column("user_profiles", "mobile_verified")
+ op.drop_column("user_profiles", "mobile")
diff --git a/backend/services/identity-access/alembic/versions/0003_fix_enum_values.py b/backend/services/identity-access/alembic/versions/0003_fix_enum_values.py
new file mode 100644
index 0000000..41499a3
--- /dev/null
+++ b/backend/services/identity-access/alembic/versions/0003_fix_enum_values.py
@@ -0,0 +1,32 @@
+"""align PostgreSQL enum labels with Python enum values (lowercase)
+
+Revision ID: 0003_fix_enum_values
+Revises: 0002_user_mobile
+Create Date: 2026-07-07
+"""
+from __future__ import annotations
+
+from typing import Sequence, Union
+
+from alembic import op
+
+revision: str = "0003_fix_enum_values"
+down_revision: Union[str, None] = "0002_user_mobile"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TYPE user_status RENAME VALUE 'ACTIVE' TO 'active'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'INACTIVE' TO 'inactive'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'SUSPENDED' TO 'suspended'")
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'TENANT_ADMIN' TO 'tenant_admin'")
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'TENANT_MEMBER' TO 'tenant_member'")
+
+
+def downgrade() -> None:
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'tenant_member' TO 'TENANT_MEMBER'")
+ op.execute("ALTER TYPE membership_role RENAME VALUE 'tenant_admin' TO 'TENANT_ADMIN'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'suspended' TO 'SUSPENDED'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'inactive' TO 'INACTIVE'")
+ op.execute("ALTER TYPE user_status RENAME VALUE 'active' TO 'ACTIVE'")
diff --git a/backend/services/identity-access/app/__init__.py b/backend/services/identity-access/app/__init__.py
new file mode 100644
index 0000000..d368261
--- /dev/null
+++ b/backend/services/identity-access/app/__init__.py
@@ -0,0 +1,3 @@
+"""Identity & Access Service — فاز ۲."""
+
+__version__ = "0.1.0"
diff --git a/backend/services/identity-access/app/api/v1/__init__.py b/backend/services/identity-access/app/api/v1/__init__.py
new file mode 100644
index 0000000..883cb26
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/__init__.py
@@ -0,0 +1,9 @@
+from fastapi import APIRouter
+
+from app.api.v1 import auth, health, users
+
+api_router = APIRouter()
+api_router.include_router(users.router)
+api_router.include_router(auth.router)
+
+__all__ = ["api_router", "health"]
diff --git a/backend/services/identity-access/app/api/v1/auth.py b/backend/services/identity-access/app/api/v1/auth.py
new file mode 100644
index 0000000..09ffa3a
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/auth.py
@@ -0,0 +1,194 @@
+from fastapi import APIRouter, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import require_authenticated, require_platform_admin
+from app.schemas.auth import (
+ AuthConfigResponse,
+ AuthSessionResponse,
+ HandoffRedeemRequest,
+ LoginUrlResponse,
+ MeResponse,
+ MobileAuthStartResponse,
+ MobileOtpRequest,
+ MobileOtpVerify,
+ MobileRegisterVerify,
+ OtpRequestResponse,
+ PasswordChangeRequest,
+ PasswordChangeResponse,
+ ProfileUpdateRequest,
+ RegisterCompleteResponse,
+ RegisterRequest,
+ TokenExchangeRequest,
+ TokenResponse,
+ VerifyMobileResponse,
+)
+from app.services.auth_service import AuthService
+from app.services.handoff_store import handoff_store
+from app.services.mobile_auth_service import MobileAuthService
+from app.services.registration_service import RegistrationService
+from shared.exceptions import NotFoundError, UnauthorizedError
+from shared.security import CurrentUser
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+
+
+@router.get("/config", response_model=AuthConfigResponse)
+async def auth_config(db: AsyncSession = Depends(get_db)) -> AuthConfigResponse:
+ """پیکربندی OIDC برای frontend — endpoint عمومی."""
+ return AuthService(db).get_oidc_config()
+
+
+@router.get("/login-url", response_model=LoginUrlResponse)
+async def login_url(db: AsyncSession = Depends(get_db)) -> LoginUrlResponse:
+ """آدرس ورود Keycloak با state و PKCE سمت سرور — endpoint عمومی."""
+ return AuthService(db).build_login_url()
+
+
+@router.post("/token", response_model=TokenResponse)
+async def exchange_token(
+ payload: TokenExchangeRequest, db: AsyncSession = Depends(get_db)
+) -> TokenResponse:
+ """تبدیل authorization code به access token (BFF)."""
+ return await AuthService(db).exchange_token(payload)
+
+
+@router.get("/me", response_model=MeResponse)
+async def get_me(
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> MeResponse:
+ return await AuthService(db).get_me(user)
+
+
+@router.patch("/me", response_model=MeResponse)
+async def update_me(
+ payload: ProfileUpdateRequest,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> MeResponse:
+ """بهروزرسانی پروفایل کاربر جاری (نام نمایشی و ایمیل)."""
+ return await AuthService(db).update_profile(user, payload)
+
+
+@router.post("/password", response_model=PasswordChangeResponse)
+async def change_password(
+ payload: PasswordChangeRequest,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> PasswordChangeResponse:
+ """تغییر یا تنظیم رمز عبور کاربر جاری."""
+ return await AuthService(db).change_password(user, payload)
+
+
+@router.post("/register", response_model=OtpRequestResponse, status_code=201)
+async def register_user(
+ payload: RegisterRequest,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ """ثبتنام با ایمیل/نام کاربری/رمز + ارسال OTP موبایل."""
+ return await RegistrationService(db).register_with_credentials(payload)
+
+
+@router.post("/register/verify-mobile", response_model=AuthSessionResponse)
+async def verify_registration_mobile(
+ payload: MobileOtpVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """تکمیل ثبتنام پس از تأیید OTP (پس از register)."""
+ return await RegistrationService(db).complete_registration_verify(payload)
+
+
+@router.post("/session/redeem", response_model=TokenResponse)
+async def redeem_session_handoff(payload: HandoffRedeemRequest) -> TokenResponse:
+ """تبدیل کد handoff (از Keycloak theme) به توکن SSO — یکبارمصرف."""
+ raw = handoff_store.pop(payload.handoff_code)
+ if raw is None:
+ raise UnauthorizedError("کد ورود منقضی یا نامعتبر است")
+ return TokenResponse(
+ access_token=raw["access_token"],
+ refresh_token=raw.get("refresh_token"),
+ expires_in=raw.get("expires_in", 3600),
+ token_type=raw.get("token_type", "Bearer"),
+ )
+
+
+@router.post("/mobile/start", response_model=MobileAuthStartResponse)
+async def mobile_auth_start(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+) -> MobileAuthStartResponse:
+ """ورود/ثبتنام یکپارچه — بکاند تشخیص میدهد کاربر عضو است یا نه."""
+ return await MobileAuthService(db).start(payload)
+
+
+@router.post("/mobile/complete", response_model=AuthSessionResponse)
+async def mobile_auth_complete(
+ payload: MobileRegisterVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """تأیید OTP و ورود یا ثبتنام + صدور توکن SSO."""
+ return await MobileAuthService(db).complete(payload)
+
+
+@router.post("/login/mobile", response_model=OtpRequestResponse)
+async def login_mobile_request_otp(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ """درخواست OTP برای ورود کاربر موجود."""
+ start = await MobileAuthService(db).start(payload)
+ if start.intent != "login":
+ raise NotFoundError("حسابی با این شماره یافت نشد. ابتدا ثبتنام کنید")
+ return OtpRequestResponse(
+ message=start.message,
+ expires_in=start.expires_in,
+ skipped=start.skipped,
+ )
+
+
+@router.post("/login/mobile/verify", response_model=AuthSessionResponse)
+async def login_mobile_verify(
+ payload: MobileOtpVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """ورود با OTP برای کاربر موجود."""
+ return await MobileAuthService(db).complete_login(payload)
+
+
+@router.post("/register/mobile", response_model=OtpRequestResponse)
+async def register_mobile_request_otp(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+) -> OtpRequestResponse:
+ """ثبتنام فقط با موبایل — مرحله ارسال OTP."""
+ return await RegistrationService(db).request_mobile_register_otp(payload)
+
+
+@router.post("/register/mobile/verify", response_model=AuthSessionResponse, status_code=201)
+async def register_mobile_verify(
+ payload: MobileRegisterVerify,
+ db: AsyncSession = Depends(get_db),
+) -> AuthSessionResponse:
+ """ثبتنام فقط با موبایل — تأیید OTP و ساخت حساب."""
+ return await RegistrationService(db).complete_mobile_register(payload)
+
+
+@router.post("/mobile/request", response_model=OtpRequestResponse)
+async def request_mobile_verification(
+ payload: MobileOtpRequest,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> OtpRequestResponse:
+ """ارسال OTP برای تأیید موبایل کاربر SSO (پس از لاگین)."""
+ return await RegistrationService(db).request_verify_mobile(user, payload.mobile)
+
+
+@router.post("/mobile/verify", response_model=VerifyMobileResponse)
+async def verify_mobile(
+ payload: MobileOtpVerify,
+ db: AsyncSession = Depends(get_db),
+ user: CurrentUser = Depends(require_authenticated),
+) -> VerifyMobileResponse:
+ """تأیید موبایل کاربر SSO."""
+ return await RegistrationService(db).verify_mobile_for_user(user, payload)
diff --git a/backend/services/identity-access/app/api/v1/health.py b/backend/services/identity-access/app/api/v1/health.py
new file mode 100644
index 0000000..cbbddca
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/health.py
@@ -0,0 +1,14 @@
+from fastapi import APIRouter
+
+from app import __version__
+from app.core.config import settings
+from app.schemas.common import HealthResponse
+
+router = APIRouter(tags=["health"])
+
+
+@router.get("/health", response_model=HealthResponse)
+async def health() -> HealthResponse:
+ return HealthResponse(
+ status="ok", service=settings.service_name, version=__version__
+ )
diff --git a/backend/services/identity-access/app/api/v1/users.py b/backend/services/identity-access/app/api/v1/users.py
new file mode 100644
index 0000000..ac49ff3
--- /dev/null
+++ b/backend/services/identity-access/app/api/v1/users.py
@@ -0,0 +1,70 @@
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Header, Query, status
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.database import get_db
+from app.core.security import require_platform_admin
+from app.schemas.user import MembershipCreate, MembershipRead, UserCreate, UserRead
+from app.services.user_service import UserService
+
+router = APIRouter(tags=["users"])
+
+
+@router.get("/users", response_model=list[UserRead])
+async def list_users(
+ offset: int = Query(default=0, ge=0),
+ limit: int = Query(default=50, ge=1, le=100),
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> list[UserRead]:
+ users = await UserService(db).list_users(offset=offset, limit=limit)
+ return [UserRead.model_validate(user) for user in users]
+
+
+@router.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
+async def register_user(
+ payload: UserCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> UserRead:
+ user = await UserService(db).register(payload)
+ return UserRead.model_validate(user)
+
+
+@router.get("/users/{user_id}", response_model=UserRead)
+async def get_user(
+ user_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> UserRead:
+ user = await UserService(db).get(user_id)
+ return UserRead.model_validate(user)
+
+
+@router.post(
+ "/tenants/{tenant_id}/members",
+ response_model=MembershipRead,
+ status_code=status.HTTP_201_CREATED,
+)
+async def add_tenant_member(
+ tenant_id: UUID,
+ payload: MembershipCreate,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+ authorization: str | None = Header(default=None),
+) -> MembershipRead:
+ membership = await UserService(db).add_member(
+ tenant_id, payload, authorization=authorization
+ )
+ return MembershipRead.model_validate(membership)
+
+
+@router.get("/tenants/{tenant_id}/members", response_model=list[MembershipRead])
+async def list_tenant_members(
+ tenant_id: UUID,
+ db: AsyncSession = Depends(get_db),
+ _user=Depends(require_platform_admin),
+) -> list[MembershipRead]:
+ rows = await UserService(db).list_members(tenant_id)
+ return [MembershipRead.model_validate(m) for m in rows]
diff --git a/backend/services/identity-access/app/core/config.py b/backend/services/identity-access/app/core/config.py
new file mode 100644
index 0000000..e3994cf
--- /dev/null
+++ b/backend/services/identity-access/app/core/config.py
@@ -0,0 +1,158 @@
+"""تنظیمات Identity & Access 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"
+ # نام سرویس با alias اختصاصی تا SERVICE_NAME مشترک (core-service) در .env
+ # مقدار این سرویس را بازنویسی نکند.
+ service_name: str = Field(
+ default="identity-access-service", validation_alias="IDENTITY_SERVICE_NAME"
+ )
+ api_v1_prefix: str = "/api/v1"
+
+ database_url: str = Field(
+ default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/identity_access_db",
+ validation_alias="DATABASE_URL",
+ )
+ database_url_sync: str = Field(
+ default="postgresql+psycopg://superapp:superapp_password@localhost:5432/identity_access_db",
+ validation_alias="DATABASE_URL_SYNC",
+ )
+
+ # Core Platform (برای اعتبارسنجی tenant هنگام عضویت)
+ core_service_url: str = Field(default="http://localhost:8000", validation_alias="CORE_SERVICE_URL")
+
+ # Keycloak / SSO
+ keycloak_enabled: bool = True
+ # آدرس داخلی (Docker network) برای Admin API و token exchange
+ keycloak_server_url: str = "http://localhost:8080"
+ # آدرس عمومی برای redirect مرورگر (معمولاً localhost:8080 در dev)
+ keycloak_public_url: str = Field(
+ default="", validation_alias="KEYCLOAK_PUBLIC_URL"
+ )
+ keycloak_realm: str = "superapp"
+ keycloak_admin_user: str = "admin"
+ keycloak_admin_password: str = "admin"
+ keycloak_client_id: str = Field(
+ default="identity-access-service",
+ validation_alias="IDENTITY_KEYCLOAK_CLIENT_ID",
+ )
+ keycloak_client_secret: str = Field(
+ default="change-me-identity",
+ validation_alias="KEYCLOAK_IDENTITY_CLIENT_SECRET",
+ )
+ keycloak_frontend_client_id: str = Field(
+ default="superapp-frontend", validation_alias="KEYCLOAK_FRONTEND_CLIENT_ID"
+ )
+
+ jwt_algorithm: str = "RS256"
+ jwt_audience: str = "account"
+ jwt_verify_signature: bool = True
+ auth_required: bool = True
+
+ # آدرس callback فرانتاند پس از لاگین Keycloak
+ frontend_callback_url: str = Field(
+ default="http://localhost:3000/auth/callback", validation_alias="FRONTEND_CALLBACK_URL"
+ )
+ platform_admin_mobiles: str = Field(
+ default="", validation_alias="PLATFORM_ADMIN_MOBILES"
+ )
+ cors_origins: str = Field(
+ default="http://torbatyar.xyz:3000,http://localhost:3000,http://127.0.0.1:3000",
+ validation_alias="CORS_ORIGINS",
+ )
+
+ @property
+ def platform_admin_mobile_set(self) -> set[str]:
+ from shared.phone import normalize_mobile
+
+ result: set[str] = set()
+ for raw in self.platform_admin_mobiles.split(","):
+ raw = raw.strip()
+ if not raw:
+ continue
+ try:
+ result.add(normalize_mobile(raw))
+ except ValueError:
+ continue
+ return result
+
+ @property
+ def cors_origin_list(self) -> list[str]:
+ origins = [origin.strip() for origin in self.cors_origins.split(",") if origin.strip()]
+ kc = self.keycloak_public_url.rstrip("/") if self.keycloak_public_url else ""
+ if kc and kc not in origins:
+ origins.append(kc)
+ return origins
+
+ @property
+ def keycloak_admin_url(self) -> str:
+ return f"{self.keycloak_server_url}/admin/realms/{self.keycloak_realm}"
+
+ @property
+ def keycloak_browser_url(self) -> str:
+ return self.keycloak_public_url or self.keycloak_server_url
+
+ @property
+ def keycloak_realm_url(self) -> str:
+ return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def keycloak_public_realm_url(self) -> str:
+ return f"{self.keycloak_browser_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def oidc_auth_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/auth"
+
+ @property
+ def oidc_public_auth_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/auth"
+
+ @property
+ def oidc_token_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/token"
+
+ @property
+ def oidc_public_token_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/token"
+
+ @property
+ def oidc_public_logout_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/logout"
+
+ @property
+ def oidc_logout_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/logout"
+
+ @property
+ def oidc_public_userinfo_url(self) -> str:
+ return f"{self.keycloak_public_realm_url}/protocol/openid-connect/userinfo"
+
+ @property
+ def oidc_userinfo_url(self) -> str:
+ return f"{self.keycloak_realm_url}/protocol/openid-connect/userinfo"
+
+
+@lru_cache
+def get_settings() -> Settings:
+ return Settings()
+
+
+settings = get_settings()
diff --git a/backend/services/identity-access/app/core/database.py b/backend/services/identity-access/app/core/database.py
new file mode 100644
index 0000000..b21f5a4
--- /dev/null
+++ b/backend/services/identity-access/app/core/database.py
@@ -0,0 +1,23 @@
+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(
+ bind=engine, class_=AsyncSession, expire_on_commit=False, autoflush=False
+)
+
+
+async def get_db():
+ async with AsyncSessionLocal() as session:
+ try:
+ yield session
+ except Exception:
+ await session.rollback()
+ raise
diff --git a/backend/services/identity-access/app/core/logging.py b/backend/services/identity-access/app/core/logging.py
new file mode 100644
index 0000000..f7980b0
--- /dev/null
+++ b/backend/services/identity-access/app/core/logging.py
@@ -0,0 +1,24 @@
+import logging
+import sys
+
+from app.core.config import settings
+
+_configured = False
+
+
+def configure_logging() -> None:
+ global _configured
+ if _configured:
+ return
+ handler = logging.StreamHandler(sys.stdout)
+ handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s"))
+ root = logging.getLogger()
+ root.handlers.clear()
+ root.addHandler(handler)
+ root.setLevel(settings.log_level.upper())
+ _configured = True
+
+
+def get_logger(name: str) -> logging.Logger:
+ configure_logging()
+ return logging.getLogger(name)
diff --git a/backend/services/identity-access/app/core/security.py b/backend/services/identity-access/app/core/security.py
new file mode 100644
index 0000000..73fd5c5
--- /dev/null
+++ b/backend/services/identity-access/app/core/security.py
@@ -0,0 +1,54 @@
+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, PlatformRole, has_any_role
+from shared.exceptions import ForbiddenError, UnauthorizedError
+from shared.security import CurrentUser
+
+bearer_scheme = 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_scheme),
+) -> CurrentUser:
+ if not settings.auth_required:
+ return CurrentUser(user_id="system", username="system", roles=["platform_admin"])
+ if credentials is None or not credentials.credentials:
+ raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
+ return await get_jwt_validator().validate(credentials.credentials)
+
+
+def require_roles(*roles: PlatformRole | str):
+ async def _checker(user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
+ if not settings.auth_required:
+ return user
+ if not has_any_role(user, *roles):
+ raise ForbiddenError("دسترسی کافی برای این عملیات ندارید")
+ return user
+
+ return _checker
+
+
+require_platform_admin = require_roles(PlatformRole.PLATFORM_ADMIN)
+require_tenant_admin = require_roles(
+ PlatformRole.PLATFORM_ADMIN, PlatformRole.TENANT_ADMIN
+)
+require_authenticated = get_current_user
diff --git a/backend/services/identity-access/app/main.py b/backend/services/identity-access/app/main.py
new file mode 100644
index 0000000..850f839
--- /dev/null
+++ b/backend/services/identity-access/app/main.py
@@ -0,0 +1,87 @@
+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, health
+from app.core.config import settings
+from app.core.logging import configure_logging, get_logger
+from shared.exceptions import AppError
+from shared.responses import ErrorDetail, ErrorResponse
+
+configure_logging()
+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="Identity & Access Service",
+ version=__version__,
+ description="سرویس احراز هویت و دسترسی (فاز ۲) — SSO با Keycloak",
+ 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=["*"],
+ )
+
+ def _cors_headers(request: Request) -> dict[str, str]:
+ """هدرهای CORS برای پاسخهای خطا.
+
+ هندلر خطای عمومی (۵۰۰) بیرون از CORSMiddleware اجرا میشود، بنابراین
+ هدر CORS را دستی اضافه میکنیم تا خطاهای سرور در مرورگر بهاشتباه
+ «خطای CORS» نمایش داده نشوند و پیام واقعی به کاربر برسد.
+ """
+ import re
+
+ origin = request.headers.get("origin")
+ if not origin:
+ return {}
+ if origin in settings.cors_origin_list or re.fullmatch(
+ r"https?://([a-z0-9-]+\.)*torbatyar\.ir", origin
+ ):
+ return {
+ "Access-Control-Allow-Origin": origin,
+ "Vary": "Origin",
+ }
+ return {}
+
+ @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(),
+ headers=_cors_headers(request),
+ )
+
+ @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(),
+ headers=_cors_headers(request),
+ )
+
+ 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/identity-access/app/models/__init__.py b/backend/services/identity-access/app/models/__init__.py
new file mode 100644
index 0000000..7f5f776
--- /dev/null
+++ b/backend/services/identity-access/app/models/__init__.py
@@ -0,0 +1,4 @@
+from app.models.membership import TenantMembership
+from app.models.user import UserProfile
+
+__all__ = ["UserProfile", "TenantMembership"]
diff --git a/backend/services/identity-access/app/models/membership.py b/backend/services/identity-access/app/models/membership.py
new file mode 100644
index 0000000..92fa0ac
--- /dev/null
+++ b/backend/services/identity-access/app/models/membership.py
@@ -0,0 +1,38 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Enum as SAEnum, ForeignKey, Index, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.types import GUID, MembershipRole
+
+
+class TenantMembership(Base):
+ """عضویت کاربر در یک tenant — tenant_id فقط UUID مرجع Core است (بدون FK بین DB)."""
+
+ __tablename__ = "tenant_memberships"
+
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
+ tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
+ user_id: Mapped[uuid.UUID] = mapped_column(
+ GUID(), ForeignKey("user_profiles.id", ondelete="CASCADE"), nullable=False
+ )
+ role: Mapped[MembershipRole] = mapped_column(
+ SAEnum(
+ MembershipRole,
+ name="membership_role",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=MembershipRole.TENANT_MEMBER,
+ )
+ is_active: Mapped[bool] = mapped_column(Boolean, default=True)
+ joined_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+
+ __table_args__ = (
+ UniqueConstraint("tenant_id", "user_id", name="uq_tenant_membership"),
+ Index("ix_tenant_memberships_tenant_id", "tenant_id"),
+ Index("ix_tenant_memberships_user_id", "user_id"),
+ )
diff --git a/backend/services/identity-access/app/models/types.py b/backend/services/identity-access/app/models/types.py
new file mode 100644
index 0000000..d433464
--- /dev/null
+++ b/backend/services/identity-access/app/models/types.py
@@ -0,0 +1,40 @@
+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 UserStatus(str, enum.Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ SUSPENDED = "suspended"
+
+
+class MembershipRole(str, enum.Enum):
+ TENANT_ADMIN = "tenant_admin"
+ TENANT_MEMBER = "tenant_member"
diff --git a/backend/services/identity-access/app/models/user.py b/backend/services/identity-access/app/models/user.py
new file mode 100644
index 0000000..f3b7674
--- /dev/null
+++ b/backend/services/identity-access/app/models/user.py
@@ -0,0 +1,46 @@
+import uuid
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, Enum as SAEnum, Index, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from app.core.database import Base
+from app.models.types import GUID, UserStatus
+
+
+class UserProfile(Base):
+ """پروفایل کاربر — شناسه Keycloak در keycloak_sub ذخیره میشود."""
+
+ __tablename__ = "user_profiles"
+
+ id: Mapped[uuid.UUID] = mapped_column(GUID(), primary_key=True, default=uuid.uuid4)
+ keycloak_sub: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ email: Mapped[str] = mapped_column(String(255), nullable=False)
+ username: Mapped[str | None] = mapped_column(String(150), nullable=True)
+ display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ mobile: Mapped[str | None] = mapped_column(String(15), nullable=True)
+ mobile_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ mobile_verified_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ core_user_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
+ status: Mapped[UserStatus] = mapped_column(
+ SAEnum(
+ UserStatus,
+ name="user_status",
+ values_callable=lambda enum_cls: [item.value for item in enum_cls],
+ ),
+ default=UserStatus.ACTIVE,
+ )
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now()
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
+ )
+
+ __table_args__ = (
+ Index("ix_user_profiles_keycloak_sub", "keycloak_sub", unique=True),
+ Index("ix_user_profiles_email", "email"),
+ Index("ix_user_profiles_mobile", "mobile", unique=True),
+ )
diff --git a/backend/services/identity-access/app/repositories/user.py b/backend/services/identity-access/app/repositories/user.py
new file mode 100644
index 0000000..fd26972
--- /dev/null
+++ b/backend/services/identity-access/app/repositories/user.py
@@ -0,0 +1,72 @@
+from uuid import UUID
+
+from sqlalchemy import select
+
+from app.models.membership import TenantMembership
+from app.models.user import UserProfile
+
+
+class UserRepository:
+ def __init__(self, session) -> None:
+ self.session = session
+
+ async def get(self, user_id: UUID) -> UserProfile | None:
+ return await self.session.get(UserProfile, user_id)
+
+ async def get_by_sub(self, keycloak_sub: str) -> UserProfile | None:
+ stmt = select(UserProfile).where(UserProfile.keycloak_sub == keycloak_sub)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def get_by_mobile(self, mobile: str) -> UserProfile | None:
+ stmt = select(UserProfile).where(UserProfile.mobile == mobile)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def get_by_username(self, username: str) -> UserProfile | None:
+ stmt = select(UserProfile).where(UserProfile.username == username)
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def add(self, user: UserProfile) -> UserProfile:
+ self.session.add(user)
+ await self.session.flush()
+ return user
+
+ async def list(self, offset: int = 0, limit: int = 20) -> list[UserProfile]:
+ stmt = (
+ select(UserProfile)
+ .order_by(UserProfile.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+
+class MembershipRepository:
+ def __init__(self, session) -> None:
+ self.session = session
+
+ async def get(self, tenant_id: UUID, user_id: UUID) -> TenantMembership | None:
+ stmt = select(TenantMembership).where(
+ TenantMembership.tenant_id == tenant_id,
+ TenantMembership.user_id == user_id,
+ )
+ result = await self.session.execute(stmt)
+ return result.scalar_one_or_none()
+
+ async def list_by_tenant(self, tenant_id: UUID) -> list[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.tenant_id == tenant_id)
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def list_by_user(self, user_id: UUID) -> list[TenantMembership]:
+ stmt = select(TenantMembership).where(TenantMembership.user_id == user_id)
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def add(self, membership: TenantMembership) -> TenantMembership:
+ self.session.add(membership)
+ await self.session.flush()
+ return membership
diff --git a/backend/services/identity-access/app/schemas/auth.py b/backend/services/identity-access/app/schemas/auth.py
new file mode 100644
index 0000000..5b071be
--- /dev/null
+++ b/backend/services/identity-access/app/schemas/auth.py
@@ -0,0 +1,168 @@
+from datetime import datetime
+from typing import Literal
+
+from pydantic import BaseModel, EmailStr, Field, field_validator, model_validator
+
+from shared.phone import normalize_mobile, normalize_otp_digits
+
+
+class AuthConfigResponse(BaseModel):
+ """پیکربندی OIDC برای frontend — بدون hardcode در کلاینت."""
+
+ issuer: str
+ client_id: str
+ authorization_endpoint: str
+ token_endpoint: str
+ logout_endpoint: str
+ userinfo_endpoint: str
+ callback_url: str
+
+
+class LoginUrlResponse(BaseModel):
+ """آدرس ورود Keycloak به همراه state (PKCE سمت سرور)."""
+
+ authorization_url: str
+ state: str
+
+
+class TokenExchangeRequest(BaseModel):
+ code: str = Field(..., min_length=1)
+ redirect_uri: str = Field(..., min_length=1)
+ state: str | None = Field(default=None)
+ code_verifier: str | None = Field(default=None, min_length=43, max_length=128)
+
+
+class TokenResponse(BaseModel):
+ access_token: str
+ refresh_token: str | None = None
+ expires_in: int
+ token_type: str = "Bearer"
+
+
+class MeResponse(BaseModel):
+ user_id: str
+ keycloak_sub: str
+ email: str | None
+ username: str | None
+ display_name: str | None
+ mobile: str | None
+ mobile_verified: bool
+ requires_mobile_verification: bool
+ roles: list[str]
+ tenant_memberships: list[dict]
+
+
+class ProfileUpdateRequest(BaseModel):
+ """بهروزرسانی پروفایل کاربر جاری (نام نمایشی و ایمیل)."""
+
+ display_name: str | None = Field(default=None, max_length=255)
+ email: EmailStr | None = None
+
+
+class PasswordChangeRequest(BaseModel):
+ """تغییر یا تنظیم رمز عبور کاربر جاری."""
+
+ current_password: str | None = Field(default=None, max_length=128)
+ new_password: str = Field(..., min_length=8, max_length=128)
+
+
+class PasswordChangeResponse(BaseModel):
+ message: str
+
+
+class RegisterRequest(BaseModel):
+ """ثبتنام با نام کاربری، رمز عبور و شماره موبایل."""
+
+ email: EmailStr
+ username: str = Field(..., min_length=3, max_length=150)
+ password: str = Field(..., min_length=8)
+ display_name: str | None = None
+ mobile: str = Field(..., min_length=10, max_length=15)
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class MobileOtpRequest(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ force_sms: bool = False
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class MobileOtpVerify(BaseModel):
+ mobile: str = Field(..., min_length=10, max_length=15)
+ code: str = Field(..., min_length=4, max_length=8)
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+ @field_validator("code")
+ @classmethod
+ def validate_code(cls, value: str) -> str:
+ normalized = normalize_otp_digits(value)
+ if not normalized:
+ raise ValueError("کد تأیید نامعتبر است")
+ return normalized
+
+
+class MobileRegisterVerify(MobileOtpVerify):
+ """تکمیل ورود/ثبتنام یکپارچه — فیلدهای پروفایل فقط برای ثبتنام جدید."""
+
+ display_name: str | None = None
+ email: EmailStr | None = None
+ username: str | None = Field(default=None, min_length=3, max_length=150)
+ password: str | None = Field(default=None, min_length=8)
+ create_handoff: bool = False
+
+ @model_validator(mode="after")
+ def validate_credentials_bundle(self) -> "MobileRegisterVerify":
+ fields = (self.email, self.username, self.password)
+ if any(fields) and not all(fields):
+ raise ValueError("ایمیل، نام کاربری و رمز عبور باید با هم ارسال شوند")
+ return self
+
+
+class HandoffRedeemRequest(BaseModel):
+ handoff_code: str = Field(..., min_length=16)
+
+
+class OtpRequestResponse(BaseModel):
+ message: str
+ expires_in: int
+ skipped: bool = False
+
+
+class RegisterCompleteResponse(BaseModel):
+ message: str
+ mobile_verified: bool
+ user_id: str | None = None
+
+
+class AuthSessionResponse(TokenResponse):
+ """پاسخ ورود/ثبتنام موفق — شامل توکن SSO برای redirect مستقیم به داشبورد."""
+
+ message: str
+ intent: Literal["login", "register"]
+ user_id: str | None = None
+ mobile_verified: bool = True
+ redirect_url: str | None = None
+
+
+class MobileAuthStartResponse(OtpRequestResponse):
+ """شروع ورود/ثبتنام یکپارچه با موبایل — بکاند نوع عملیات را تشخیص میدهد."""
+
+ intent: Literal["login", "register"]
+
+
+class VerifyMobileResponse(BaseModel):
+ message: str
+ mobile_verified: bool
+ mobile_verified_at: datetime | None = None
diff --git a/backend/services/identity-access/app/schemas/common.py b/backend/services/identity-access/app/schemas/common.py
new file mode 100644
index 0000000..865a132
--- /dev/null
+++ b/backend/services/identity-access/app/schemas/common.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel, ConfigDict
+
+
+class ORMModel(BaseModel):
+ model_config = ConfigDict(from_attributes=True)
+
+
+class HealthResponse(BaseModel):
+ status: str = "ok"
+ service: str
+ version: str
diff --git a/backend/services/identity-access/app/schemas/user.py b/backend/services/identity-access/app/schemas/user.py
new file mode 100644
index 0000000..602530e
--- /dev/null
+++ b/backend/services/identity-access/app/schemas/user.py
@@ -0,0 +1,49 @@
+from datetime import datetime
+from uuid import UUID
+
+from pydantic import BaseModel, EmailStr, Field, field_validator
+
+from app.models.types import MembershipRole, UserStatus
+from app.schemas.common import ORMModel
+from shared.phone import normalize_mobile
+
+
+class UserCreate(BaseModel):
+ email: EmailStr
+ username: str = Field(..., min_length=3, max_length=150)
+ display_name: str | None = None
+ password: str = Field(..., min_length=8)
+ mobile: str = Field(..., min_length=10, max_length=15)
+
+ @field_validator("mobile")
+ @classmethod
+ def validate_mobile(cls, value: str) -> str:
+ return normalize_mobile(value)
+
+
+class UserRead(ORMModel):
+ id: UUID
+ keycloak_sub: str
+ email: str
+ username: str | None
+ display_name: str | None
+ mobile: str | None
+ mobile_verified: bool
+ mobile_verified_at: datetime | None
+ core_user_id: UUID | None
+ status: UserStatus
+ created_at: datetime
+
+
+class MembershipCreate(BaseModel):
+ user_id: UUID
+ role: MembershipRole = MembershipRole.TENANT_MEMBER
+
+
+class MembershipRead(ORMModel):
+ id: UUID
+ tenant_id: UUID
+ user_id: UUID
+ role: MembershipRole
+ is_active: bool
+ joined_at: datetime
diff --git a/backend/services/identity-access/app/services/auth_service.py b/backend/services/identity-access/app/services/auth_service.py
new file mode 100644
index 0000000..a8073d0
--- /dev/null
+++ b/backend/services/identity-access/app/services/auth_service.py
@@ -0,0 +1,184 @@
+from urllib.parse import urlencode
+from typing import Literal
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.core.security import get_jwt_validator
+from app.repositories.user import MembershipRepository, UserRepository
+from app.schemas.auth import (
+ AuthConfigResponse,
+ AuthSessionResponse,
+ LoginUrlResponse,
+ MeResponse,
+ PasswordChangeRequest,
+ PasswordChangeResponse,
+ ProfileUpdateRequest,
+ TokenExchangeRequest,
+ TokenResponse,
+)
+from app.models.user import UserProfile
+from app.services.keycloak_client import KeycloakAdminClient
+from app.services.pkce_store import generate_pkce_pair, generate_state, pkce_store
+from app.services.user_service import UserService
+from shared.exceptions import NotFoundError, UnauthorizedError
+from shared.security import CurrentUser
+
+
+class AuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.memberships = MembershipRepository(session)
+ self.keycloak = KeycloakAdminClient()
+
+ def get_oidc_config(self) -> AuthConfigResponse:
+ return AuthConfigResponse(
+ issuer=settings.keycloak_public_realm_url,
+ client_id=settings.keycloak_frontend_client_id,
+ authorization_endpoint=settings.oidc_public_auth_url,
+ token_endpoint=settings.oidc_token_url,
+ logout_endpoint=settings.oidc_public_logout_url,
+ userinfo_endpoint=settings.oidc_public_userinfo_url,
+ callback_url=settings.frontend_callback_url,
+ )
+
+ def build_login_url(self) -> LoginUrlResponse:
+ """آدرس ورود Keycloak را با state و PKCE challenge سمت سرور میسازد."""
+ state = generate_state()
+ verifier, challenge = generate_pkce_pair()
+ pkce_store.put(state, verifier)
+ params = {
+ "client_id": settings.keycloak_frontend_client_id,
+ "redirect_uri": settings.frontend_callback_url,
+ "response_type": "code",
+ "scope": "openid profile email",
+ "state": state,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ }
+ url = f"{settings.oidc_public_auth_url}?{urlencode(params)}"
+ return LoginUrlResponse(authorization_url=url, state=state)
+
+ async def exchange_token(self, data: TokenExchangeRequest) -> TokenResponse:
+ verifier = data.code_verifier
+ if data.state:
+ stored = pkce_store.pop(data.state)
+ if stored:
+ verifier = stored
+ raw = await self.keycloak.exchange_code(
+ data.code, data.redirect_uri, verifier
+ )
+ # همگامسازی پروفایل کاربر پس از لاگین موفق
+ user_claims = await get_jwt_validator().validate(raw["access_token"])
+ await UserService(self.session).get_or_create_from_sub(
+ sub=user_claims.user_id,
+ email=user_claims.email,
+ username=user_claims.username,
+ )
+ return TokenResponse(
+ access_token=raw["access_token"],
+ refresh_token=raw.get("refresh_token"),
+ expires_in=raw.get("expires_in", 3600),
+ token_type=raw.get("token_type", "Bearer"),
+ )
+
+ async def issue_session_for_profile(
+ self,
+ profile: UserProfile,
+ *,
+ message: str,
+ intent: Literal["login", "register"],
+ password: str | None = None,
+ sync_mobile_password: bool = False,
+ ) -> AuthSessionResponse:
+ """صدور توکن SSO پس از تأیید OTP یا تکمیل ثبتنام."""
+ username = profile.username or profile.mobile or ""
+ raw = await self.keycloak.issue_tokens_for_user(
+ keycloak_sub=profile.keycloak_sub,
+ username=username,
+ mobile=profile.mobile,
+ password=password,
+ sync_mobile_password=sync_mobile_password,
+ )
+ return AuthSessionResponse(
+ access_token=raw["access_token"],
+ refresh_token=raw.get("refresh_token"),
+ expires_in=raw.get("expires_in", 3600),
+ token_type=raw.get("token_type", "Bearer"),
+ message=message,
+ intent=intent,
+ user_id=str(profile.id),
+ mobile_verified=profile.mobile_verified,
+ )
+
+ async def _build_me(self, current: CurrentUser, user: UserProfile | None) -> MeResponse:
+ memberships = []
+ if user:
+ rows = await self.memberships.list_by_user(user.id)
+ memberships = [
+ {"tenant_id": str(m.tenant_id), "role": m.role.value, "is_active": m.is_active}
+ for m in rows
+ ]
+ return MeResponse(
+ user_id=current.user_id,
+ keycloak_sub=current.user_id,
+ email=(user.email if user else None) or current.email,
+ username=(user.username if user else None) or current.username,
+ display_name=user.display_name if user else None,
+ mobile=user.mobile if user else None,
+ mobile_verified=user.mobile_verified if user else False,
+ requires_mobile_verification=bool(user and not user.mobile_verified),
+ roles=current.roles,
+ tenant_memberships=memberships,
+ )
+
+ async def get_me(self, current: CurrentUser) -> MeResponse:
+ user = await self.users.get_by_sub(current.user_id)
+ return await self._build_me(current, user)
+
+ async def update_profile(
+ self, current: CurrentUser, data: ProfileUpdateRequest
+ ) -> MeResponse:
+ """بهروزرسانی پروفایل کاربر جاری + همگامسازی با Keycloak."""
+ user = await self.users.get_by_sub(current.user_id)
+ if user is None:
+ raise NotFoundError("پروفایل کاربر یافت نشد")
+
+ new_email: str | None = None
+ if data.email is not None:
+ candidate = str(data.email)
+ if candidate != user.email:
+ user.email = candidate
+ new_email = candidate
+
+ if data.display_name is not None:
+ user.display_name = data.display_name.strip() or None
+
+ await self.session.flush()
+ await self.keycloak.update_user(
+ current.user_id,
+ email=new_email,
+ first_name=user.display_name if data.display_name is not None else None,
+ )
+ await self.session.commit()
+ await self.session.refresh(user)
+ return await self._build_me(current, user)
+
+ async def change_password(
+ self, current: CurrentUser, data: PasswordChangeRequest
+ ) -> PasswordChangeResponse:
+ """تغییر/تنظیم رمز عبور کاربر جاری در Keycloak."""
+ user = await self.users.get_by_sub(current.user_id)
+ if user is None:
+ raise NotFoundError("پروفایل کاربر یافت نشد")
+
+ if data.current_password:
+ login_name = user.username or user.mobile or current.username or ""
+ try:
+ await self.keycloak.password_login(login_name, data.current_password)
+ except UnauthorizedError:
+ raise UnauthorizedError("رمز عبور فعلی نادرست است")
+
+ await self.keycloak.set_user_password(current.user_id, data.new_password)
+ return PasswordChangeResponse(message="رمز عبور با موفقیت بهروزرسانی شد")
diff --git a/backend/services/identity-access/app/services/handoff_store.py b/backend/services/identity-access/app/services/handoff_store.py
new file mode 100644
index 0000000..754e62f
--- /dev/null
+++ b/backend/services/identity-access/app/services/handoff_store.py
@@ -0,0 +1,36 @@
+"""کد یکبارمصرف برای انتقال امن session از Keycloak theme به frontend."""
+from __future__ import annotations
+
+import secrets
+import time
+from threading import Lock
+from typing import Any
+
+
+class HandoffStore:
+ def __init__(self, ttl_seconds: int = 120) -> None:
+ self._data: dict[str, tuple[dict[str, Any], float]] = {}
+ self._ttl = ttl_seconds
+ self._lock = Lock()
+
+ def _prune_locked(self) -> None:
+ now = time.time()
+ expired = [key for key, (_, exp) in self._data.items() if exp < now]
+ for key in expired:
+ self._data.pop(key, None)
+
+ def create(self, session: dict[str, Any]) -> str:
+ code = secrets.token_urlsafe(32)
+ with self._lock:
+ self._prune_locked()
+ self._data[code] = (session, time.time() + self._ttl)
+ return code
+
+ def pop(self, code: str) -> dict[str, Any] | None:
+ with self._lock:
+ self._prune_locked()
+ item = self._data.pop(code, None)
+ return item[0] if item else None
+
+
+handoff_store = HandoffStore()
diff --git a/backend/services/identity-access/app/services/keycloak_client.py b/backend/services/identity-access/app/services/keycloak_client.py
new file mode 100644
index 0000000..8d74ae2
--- /dev/null
+++ b/backend/services/identity-access/app/services/keycloak_client.py
@@ -0,0 +1,321 @@
+"""کلاینت Admin API کیکلوک برای مدیریت کاربران."""
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+
+from app.core.config import settings
+from app.core.logging import get_logger
+from shared.exceptions import UnauthorizedError
+
+logger = get_logger(__name__)
+
+
+def mobile_keycloak_password(mobile: str) -> str:
+ """رمز داخلی Keycloak برای کاربران ثبتنام با موبایل (فقط سمت سرور)."""
+ return f"Ty!{mobile[-6:]}X"
+
+
+class KeycloakAdminClient:
+ async def _get_admin_token(self) -> str:
+ url = f"{settings.keycloak_server_url}/realms/master/protocol/openid-connect/token"
+ data = {
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": settings.keycloak_admin_user,
+ "password": settings.keycloak_admin_password,
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(url, data=data)
+ resp.raise_for_status()
+ return resp.json()["access_token"]
+
+ async def get_service_account_token(self) -> str:
+ """توکن client_credentials برای سرویس identity-access."""
+ data = {
+ "grant_type": "client_credentials",
+ "client_id": settings.keycloak_client_id,
+ "client_secret": settings.keycloak_client_secret,
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ raise UnauthorizedError("دریافت توکن سرویس از Keycloak ناموفق بود")
+ return resp.json()["access_token"]
+
+ async def exchange_token_for_user(self, keycloak_sub: str) -> dict[str, Any]:
+ """صدور access token کاربر پس از تأیید OTP (token exchange)."""
+ service_token = await self.get_service_account_token()
+ data = {
+ "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
+ "client_id": settings.keycloak_client_id,
+ "client_secret": settings.keycloak_client_secret,
+ "subject_token": service_token,
+ "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
+ "requested_subject": keycloak_sub,
+ "audience": settings.keycloak_frontend_client_id,
+ "scope": "openid profile email",
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ detail = resp.text
+ try:
+ payload = resp.json()
+ detail = payload.get("error_description") or payload.get("error") or detail
+ except Exception:
+ pass
+ logger.warning(
+ "keycloak_token_exchange_failed",
+ extra={"detail": detail, "sub": keycloak_sub},
+ )
+ raise UnauthorizedError(f"صدور توکن SSO ناموفق بود: {detail}")
+ return resp.json()
+
+ async def ensure_user_login_ready(self, keycloak_sub: str) -> None:
+ """حذف required actions و تکمیل فیلدهای اجباری پروفایل Keycloak."""
+ token = await self._get_admin_token()
+ headers = {"Authorization": f"Bearer {token}"}
+ url = f"{settings.keycloak_admin_url}/users/{keycloak_sub}"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url, headers=headers)
+ if resp.status_code >= 400:
+ return
+ user = resp.json()
+ user["requiredActions"] = []
+ user["emailVerified"] = True
+ user["enabled"] = True
+ if not user.get("firstName"):
+ user["firstName"] = user.get("username") or "User"
+ if not user.get("lastName"):
+ user["lastName"] = "-"
+ put = await client.put(url, json=user, headers=headers)
+ if put.status_code >= 400:
+ logger.warning(
+ "keycloak_user_setup_failed",
+ extra={"sub": keycloak_sub, "status": put.status_code},
+ )
+
+ async def update_user(
+ self,
+ keycloak_sub: str,
+ *,
+ email: str | None = None,
+ first_name: str | None = None,
+ ) -> None:
+ """بهروزرسانی فیلدهای پروفایل کاربر در Keycloak (ایمیل/نام)."""
+ if email is None and first_name is None:
+ return
+ token = await self._get_admin_token()
+ headers = {"Authorization": f"Bearer {token}"}
+ url = f"{settings.keycloak_admin_url}/users/{keycloak_sub}"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url, headers=headers)
+ if resp.status_code >= 400:
+ raise UnauthorizedError("دریافت اطلاعات کاربر از Keycloak ناموفق بود")
+ user = resp.json()
+ if email is not None:
+ user["email"] = email
+ user["emailVerified"] = True
+ if first_name is not None:
+ user["firstName"] = first_name or user.get("username") or "User"
+ put = await client.put(url, json=user, headers=headers)
+ if put.status_code >= 400:
+ detail = put.text
+ try:
+ detail = put.json().get("errorMessage") or detail
+ except Exception:
+ pass
+ raise UnauthorizedError(
+ f"بهروزرسانی پروفایل Keycloak ناموفق بود: {detail}"
+ )
+
+ async def set_user_password(
+ self, keycloak_sub: str, password: str, *, temporary: bool = False
+ ) -> None:
+ """تنظیم رمز کاربر از Admin API (پس از تأیید OTP)."""
+ token = await self._get_admin_token()
+ url = f"{settings.keycloak_admin_url}/users/{keycloak_sub}/reset-password"
+ payload = {"type": "password", "value": password, "temporary": temporary}
+ headers = {"Authorization": f"Bearer {token}"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.put(url, json=payload, headers=headers)
+ if resp.status_code not in (204, 200):
+ detail = resp.text
+ try:
+ detail = resp.json().get("errorMessage") or detail
+ except Exception:
+ pass
+ raise UnauthorizedError(f"تنظیم رمز Keycloak ناموفق بود: {detail}")
+
+ async def password_login(self, username: str, password: str) -> dict[str, Any]:
+ """ورود مستقیم (فقط از BFF، با کلاینت محرمانه identity-access)."""
+ data = {
+ "grant_type": "password",
+ "client_id": settings.keycloak_client_id,
+ "client_secret": settings.keycloak_client_secret,
+ "username": username,
+ "password": password,
+ "scope": "openid profile email",
+ }
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ detail = resp.text
+ try:
+ payload = resp.json()
+ detail = payload.get("error_description") or payload.get("error") or detail
+ except Exception:
+ pass
+ raise UnauthorizedError(f"ورود Keycloak ناموفق بود: {detail}")
+ return resp.json()
+
+ async def issue_tokens_for_user(
+ self,
+ *,
+ keycloak_sub: str,
+ username: str,
+ mobile: str | None = None,
+ password: str | None = None,
+ sync_mobile_password: bool = False,
+ ) -> dict[str, Any]:
+ """پس از تأیید هویت سمت سرور، توکن SSO کاربر را صادر میکند."""
+ errors: list[str] = []
+
+ if password:
+ try:
+ return await self.password_login(username, password)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ try:
+ return await self.exchange_token_for_user(keycloak_sub)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ if mobile:
+ internal = mobile_keycloak_password(mobile)
+ candidates = []
+ for candidate in (username, mobile):
+ if candidate and candidate not in candidates:
+ candidates.append(candidate)
+
+ await self.ensure_user_login_ready(keycloak_sub)
+
+ for candidate in candidates:
+ try:
+ return await self.password_login(candidate, internal)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ if sync_mobile_password:
+ await self.set_user_password(keycloak_sub, internal)
+ await self.ensure_user_login_ready(keycloak_sub)
+ for candidate in candidates:
+ try:
+ return await self.password_login(candidate, internal)
+ except UnauthorizedError as exc:
+ errors.append(str(exc))
+
+ detail = errors[-1] if errors else "خطای ناشناخته"
+ if "Unsupported grant_type" in detail or "unsupported_grant_type" in detail:
+ detail += " — Keycloak را با --features=token-exchange راهاندازی و scripts/enable_mobile_auth_client.py را اجرا کنید"
+ elif "direct access grants" in detail.lower() or "unauthorized_client" in detail.lower():
+ detail += " — scripts/enable_mobile_auth_client.py را اجرا کنید"
+ raise UnauthorizedError(f"صدور توکن SSO ناموفق بود: {detail}")
+
+ async def assign_realm_role(self, keycloak_sub: str, role_name: str) -> None:
+ """اختصاص نقش realm به کاربر (مثلاً platform_admin)."""
+ token = await self._get_admin_token()
+ headers = {"Authorization": f"Bearer {token}"}
+ roles_url = f"{settings.keycloak_admin_url}/roles"
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(roles_url, params={"search": role_name}, headers=headers)
+ resp.raise_for_status()
+ roles = [r for r in resp.json() if r.get("name") == role_name]
+ if not roles:
+ logger.warning("keycloak_role_not_found", extra={"role": role_name})
+ return
+ mapping_url = (
+ f"{settings.keycloak_admin_url}/users/{keycloak_sub}/role-mappings/realm"
+ )
+ map_resp = await client.post(mapping_url, json=[roles[0]], headers=headers)
+ if map_resp.status_code not in (204, 200):
+ logger.warning(
+ "keycloak_role_assign_failed",
+ extra={"role": role_name, "status": map_resp.status_code},
+ )
+
+ async def create_user(
+ self,
+ *,
+ email: str,
+ username: str,
+ password: str,
+ display_name: str | None,
+ mobile: str | None = None,
+ ) -> str:
+ """ساخت کاربر در Keycloak و برگرداندن sub (شناسه)."""
+ token = await self._get_admin_token()
+ url = f"{settings.keycloak_admin_url}/users"
+ payload: dict[str, Any] = {
+ "email": email,
+ "username": username,
+ "enabled": True,
+ "emailVerified": True,
+ "requiredActions": [],
+ "credentials": [{"type": "password", "value": password, "temporary": False}],
+ }
+ if display_name:
+ payload["firstName"] = display_name
+ payload["lastName"] = "-"
+ else:
+ payload["firstName"] = username
+ payload["lastName"] = "-"
+ if mobile:
+ payload["attributes"] = {"mobile": [mobile], "mobile_verified": ["false"]}
+ headers = {"Authorization": f"Bearer {token}"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(url, json=payload, headers=headers)
+ if resp.status_code == 409:
+ return await self._find_user_sub(username, token)
+ resp.raise_for_status()
+ location = resp.headers.get("Location", "")
+ return location.rsplit("/", 1)[-1]
+
+ async def _find_user_sub(self, username: str, token: str) -> str:
+ url = f"{settings.keycloak_admin_url}/users"
+ headers = {"Authorization": f"Bearer {token}"}
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.get(url, params={"username": username}, headers=headers)
+ resp.raise_for_status()
+ users = resp.json()
+ if not users:
+ raise ValueError(f"کاربر یافت نشد: {username}")
+ return users[0]["id"]
+
+ async def exchange_code(
+ self, code: str, redirect_uri: str, code_verifier: str | None = None
+ ) -> dict[str, Any]:
+ """تبدیل authorization code به token (BFF برای frontend)."""
+ data = {
+ "grant_type": "authorization_code",
+ "client_id": settings.keycloak_frontend_client_id,
+ "code": code,
+ "redirect_uri": redirect_uri,
+ }
+ if code_verifier:
+ data["code_verifier"] = code_verifier
+ async with httpx.AsyncClient(timeout=10.0) as client:
+ resp = await client.post(settings.oidc_token_url, data=data)
+ if resp.status_code >= 400:
+ detail = resp.text
+ try:
+ payload = resp.json()
+ detail = payload.get("error_description") or payload.get("error") or detail
+ except Exception:
+ pass
+ logger.warning("keycloak_token_exchange_failed", extra={"detail": detail})
+ raise UnauthorizedError(f"تبادل token با Keycloak ناموفق بود: {detail}")
+ return resp.json()
diff --git a/backend/services/identity-access/app/services/mobile_auth_service.py b/backend/services/identity-access/app/services/mobile_auth_service.py
new file mode 100644
index 0000000..127f48c
--- /dev/null
+++ b/backend/services/identity-access/app/services/mobile_auth_service.py
@@ -0,0 +1,163 @@
+"""ورود/ثبتنام یکپارچه با موبایل — تشخیص خودکار login vs register."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.core.config import settings
+from app.models.user import UserProfile
+from app.repositories.user import UserRepository
+from app.schemas.auth import (
+ AuthSessionResponse,
+ MobileAuthStartResponse,
+ MobileOtpRequest,
+ MobileOtpVerify,
+ MobileRegisterVerify,
+)
+from app.services.auth_service import AuthService
+from app.services.handoff_store import handoff_store
+from app.services.keycloak_client import KeycloakAdminClient, mobile_keycloak_password
+from app.services.otp_client import CoreOtpClient
+from shared.exceptions import ConflictError, UnauthorizedError
+
+
+class MobileAuthService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.keycloak = KeycloakAdminClient()
+ self.otp = CoreOtpClient()
+ self.auth = AuthService(session)
+
+ async def start(self, data: MobileOtpRequest) -> MobileAuthStartResponse:
+ existing = await self.users.get_by_mobile(data.mobile)
+ if existing and existing.mobile_verified:
+ intent = "login"
+ default_message = "کد ورود ارسال شد"
+ else:
+ intent = "register"
+ default_message = "کد ثبتنام ارسال شد"
+
+ result = await self.otp.request_otp(data.mobile, force_sms=data.force_sms)
+ return MobileAuthStartResponse(
+ intent=intent,
+ message=result.get("message", default_message),
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def complete(self, data: MobileRegisterVerify) -> AuthSessionResponse:
+ existing = await self.users.get_by_mobile(data.mobile)
+ if existing and existing.mobile_verified:
+ session = await self._complete_login(data, existing)
+ else:
+ session = await self._complete_register(data)
+ return self._maybe_handoff(session, data.create_handoff)
+
+ def _maybe_handoff(self, session: AuthSessionResponse, create_handoff: bool) -> AuthSessionResponse:
+ if not create_handoff:
+ return session
+ code = handoff_store.create(session.model_dump())
+ redirect = f"{settings.frontend_callback_url}?handoff={code}"
+ return AuthSessionResponse(
+ access_token="",
+ refresh_token=None,
+ expires_in=0,
+ token_type="Bearer",
+ message=session.message,
+ intent=session.intent,
+ user_id=session.user_id,
+ mobile_verified=session.mobile_verified,
+ redirect_url=redirect,
+ )
+
+ async def complete_login(self, data: MobileOtpVerify) -> AuthSessionResponse:
+ profile = await self.users.get_by_mobile(data.mobile)
+ if profile is None or not profile.mobile_verified:
+ raise UnauthorizedError("حسابی با این شماره یافت نشد. ابتدا ثبتنام کنید")
+ return await self._complete_login(data, profile)
+
+ async def _complete_login(
+ self, data: MobileOtpVerify, profile: UserProfile
+ ) -> AuthSessionResponse:
+ await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ keycloak_sub=profile.keycloak_sub,
+ email=profile.email,
+ )
+ await self._sync_platform_admin_role(profile)
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ورود با موفقیت انجام شد",
+ intent="login",
+ sync_mobile_password=True,
+ )
+
+ async def _complete_register(self, data: MobileRegisterVerify) -> AuthSessionResponse:
+ if await self.users.get_by_mobile(data.mobile):
+ raise ConflictError("این شماره موبایل قبلاً ثبت شده است", error_code="mobile_taken")
+
+ has_credentials = bool(data.email and data.username and data.password)
+ if has_credentials:
+ if await self.users.get_by_username(data.username):
+ raise ConflictError("نام کاربری تکراری است", error_code="username_taken")
+ username = data.username
+ email = str(data.email)
+ password = data.password
+ else:
+ username = data.mobile
+ email = f"{data.mobile[1:]}@mobile.torbatyar.local"
+ password = mobile_keycloak_password(data.mobile)
+
+ core_result = await self.otp.verify_otp(data.mobile, data.code, email=email)
+ sub = await self.keycloak.create_user(
+ email=email,
+ username=username,
+ password=password,
+ display_name=data.display_name or data.username or data.mobile,
+ mobile=data.mobile,
+ )
+ await self.keycloak.ensure_user_login_ready(sub)
+
+ profile = await self.users.get_by_sub(sub)
+ if profile is None:
+ profile = UserProfile(
+ keycloak_sub=sub,
+ email=email,
+ username=username,
+ display_name=data.display_name or data.username,
+ mobile=data.mobile,
+ mobile_verified=True,
+ mobile_verified_at=datetime.now(timezone.utc),
+ core_user_id=UUID(core_result["user_id"]),
+ )
+ await self.users.add(profile)
+ else:
+ profile.email = email
+ profile.username = username
+ profile.mobile = data.mobile
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ if data.display_name:
+ profile.display_name = data.display_name
+
+ await self.session.commit()
+ await self.session.refresh(profile)
+ await self._sync_platform_admin_role(profile)
+
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ثبتنام و ورود با موفقیت انجام شد",
+ intent="register",
+ password=password if has_credentials else None,
+ sync_mobile_password=not has_credentials,
+ )
+
+ async def _sync_platform_admin_role(self, profile: UserProfile) -> None:
+ if not profile.mobile or profile.mobile not in settings.platform_admin_mobile_set:
+ return
+ await self.keycloak.assign_realm_role(profile.keycloak_sub, "platform_admin")
diff --git a/backend/services/identity-access/app/services/otp_client.py b/backend/services/identity-access/app/services/otp_client.py
new file mode 100644
index 0000000..fbac8dd
--- /dev/null
+++ b/backend/services/identity-access/app/services/otp_client.py
@@ -0,0 +1,67 @@
+"""کلاینت OTP — فراخوانی Core Service برای ارسال/تأیید کد."""
+from __future__ import annotations
+
+import httpx
+
+from app.core.config import settings
+from shared.exceptions import AppError, UnauthorizedError
+
+
+class OtpProxyError(AppError):
+ status_code = 502
+ error_code = "otp_proxy_failed"
+
+
+class CoreOtpClient:
+ async def request_otp(self, mobile: str, *, force_sms: bool = False) -> dict:
+ payload = {"mobile": mobile, "force_sms": force_sms, "context": "public"}
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ f"{settings.core_service_url}/api/v1/auth/otp/request",
+ json=payload,
+ )
+ except httpx.RequestError as exc:
+ raise OtpProxyError(
+ "اتصال به سرویس ارسال کد برقرار نشد؛ لطفاً بعداً دوباره تلاش کنید"
+ ) from exc
+ if resp.status_code >= 400:
+ body = resp.json() if resp.content else {}
+ msg = body.get("error", {}).get("message") or "ارسال کد ناموفق بود"
+ raise OtpProxyError(msg)
+ return resp.json()
+
+ async def verify_otp(
+ self,
+ mobile: str,
+ code: str,
+ *,
+ keycloak_sub: str | None = None,
+ email: str | None = None,
+ ) -> dict:
+ payload: dict = {
+ "mobile": mobile,
+ "code": code,
+ "context": "public",
+ }
+ if keycloak_sub:
+ payload["keycloak_sub"] = keycloak_sub
+ if email:
+ payload["email"] = email
+ try:
+ async with httpx.AsyncClient(timeout=15.0) as client:
+ resp = await client.post(
+ f"{settings.core_service_url}/api/v1/auth/otp/verify",
+ json=payload,
+ )
+ except httpx.RequestError as exc:
+ raise OtpProxyError(
+ "اتصال به سرویس تأیید کد برقرار نشد؛ لطفاً بعداً دوباره تلاش کنید"
+ ) from exc
+ if resp.status_code == 401:
+ raise UnauthorizedError("کد تأیید نامعتبر یا منقضی شده است")
+ if resp.status_code >= 400:
+ body = resp.json() if resp.content else {}
+ msg = body.get("error", {}).get("message") or "تأیید کد ناموفق بود"
+ raise OtpProxyError(msg)
+ return resp.json()
diff --git a/backend/services/identity-access/app/services/pkce_store.py b/backend/services/identity-access/app/services/pkce_store.py
new file mode 100644
index 0000000..5bd0e96
--- /dev/null
+++ b/backend/services/identity-access/app/services/pkce_store.py
@@ -0,0 +1,53 @@
+"""مدیریت state و PKCE verifier سمت سرور برای جریان لاگین (BFF).
+
+verifier در حافظهی سرویس نگه داشته میشود و فقط با state بازیابی میگردد؛
+بنابراین به sessionStorage/origin مرورگر وابسته نیست.
+
+نکته production: برای چند worker/instance، این store باید به Redis منتقل شود.
+"""
+from __future__ import annotations
+
+import base64
+import hashlib
+import secrets
+import time
+from threading import Lock
+
+
+def generate_pkce_pair() -> tuple[str, str]:
+ """(verifier, challenge) با روش S256 تولید میکند."""
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+ return verifier, challenge
+
+
+def generate_state() -> str:
+ return secrets.token_urlsafe(24)
+
+
+class PKCEStore:
+ def __init__(self, ttl_seconds: int = 600) -> None:
+ self._data: dict[str, tuple[str, float]] = {}
+ self._ttl = ttl_seconds
+ self._lock = Lock()
+
+ def _prune_locked(self) -> None:
+ now = time.time()
+ expired = [key for key, (_, exp) in self._data.items() if exp < now]
+ for key in expired:
+ self._data.pop(key, None)
+
+ def put(self, state: str, verifier: str) -> None:
+ with self._lock:
+ self._prune_locked()
+ self._data[state] = (verifier, time.time() + self._ttl)
+
+ def pop(self, state: str) -> str | None:
+ with self._lock:
+ self._prune_locked()
+ item = self._data.pop(state, None)
+ return item[0] if item else None
+
+
+pkce_store = PKCEStore()
diff --git a/backend/services/identity-access/app/services/registration_service.py b/backend/services/identity-access/app/services/registration_service.py
new file mode 100644
index 0000000..1be5ff4
--- /dev/null
+++ b/backend/services/identity-access/app/services/registration_service.py
@@ -0,0 +1,216 @@
+"""ثبتنام یکپارچه SSO + تأیید موبایل."""
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from uuid import UUID
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.user import UserProfile
+from app.repositories.user import UserRepository
+from app.schemas.auth import (
+ AuthSessionResponse,
+ MobileOtpRequest,
+ MobileOtpVerify,
+ MobileRegisterVerify,
+ OtpRequestResponse,
+ RegisterCompleteResponse,
+ RegisterRequest,
+ VerifyMobileResponse,
+)
+from app.services.auth_service import AuthService
+from app.services.keycloak_client import KeycloakAdminClient, mobile_keycloak_password
+from app.services.otp_client import CoreOtpClient, OtpProxyError
+from shared.exceptions import ConflictError, UnauthorizedError
+from shared.security import CurrentUser
+
+
+class RegistrationService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.keycloak = KeycloakAdminClient()
+ self.otp = CoreOtpClient()
+ self.auth = AuthService(session)
+
+ async def register_with_credentials(self, data: RegisterRequest) -> OtpRequestResponse:
+ if await self.users.get_by_mobile(data.mobile):
+ raise ConflictError("این شماره موبایل قبلاً ثبت شده است", error_code="mobile_taken")
+ if await self.users.get_by_username(data.username):
+ raise ConflictError("نام کاربری تکراری است", error_code="username_taken")
+
+ sub = await self.keycloak.create_user(
+ email=data.email,
+ username=data.username,
+ password=data.password,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ )
+ if await self.users.get_by_sub(sub):
+ raise ConflictError("کاربر از قبل وجود دارد", error_code="user_exists")
+
+ profile = UserProfile(
+ keycloak_sub=sub,
+ email=data.email,
+ username=data.username,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ mobile_verified=False,
+ )
+ await self.users.add(profile)
+ await self.session.commit()
+
+ result = await self.otp.request_otp(data.mobile)
+ return OtpRequestResponse(
+ message="حساب ایجاد شد. کد تأیید موبایل ارسال شد",
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def complete_registration_verify(self, data: MobileOtpVerify) -> AuthSessionResponse:
+ profile = await self.users.get_by_mobile(data.mobile)
+ if profile is None:
+ raise UnauthorizedError("ثبتنام یافت نشد. ابتدا فرم ثبتنام را تکمیل کنید")
+ if profile.mobile_verified:
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="شماره موبایل قبلاً تأیید شده — وارد شدید",
+ intent="login",
+ )
+
+ core_result = await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ keycloak_sub=profile.keycloak_sub,
+ email=profile.email,
+ )
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ await self.session.commit()
+ await self.session.refresh(profile)
+
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ثبتنام با موفقیت تکمیل شد",
+ intent="register",
+ )
+
+ async def request_mobile_register_otp(self, data: MobileOtpRequest) -> OtpRequestResponse:
+ existing = await self.users.get_by_mobile(data.mobile)
+ if existing and existing.mobile_verified:
+ raise ConflictError(
+ "این شماره قبلاً ثبت شده است. از ورود با موبایل استفاده کنید",
+ error_code="mobile_exists_use_login",
+ )
+ result = await self.otp.request_otp(data.mobile, force_sms=data.force_sms)
+ return OtpRequestResponse(
+ message=result.get("message", "کد تأیید ارسال شد"),
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def complete_mobile_register(self, data: MobileRegisterVerify) -> AuthSessionResponse:
+ profile = await self.users.get_by_mobile(data.mobile)
+ if profile and profile.mobile_verified:
+ raise ConflictError(
+ "این شماره قبلاً ثبت شده است. از ورود با موبایل استفاده کنید",
+ error_code="mobile_exists_use_login",
+ )
+
+ username = data.mobile
+ email = f"{data.mobile[1:]}@mobile.torbatyar.local"
+ temp_password = mobile_keycloak_password(data.mobile)
+
+ core_result = await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ email=email,
+ )
+
+ sub = await self.keycloak.create_user(
+ email=email,
+ username=username,
+ password=temp_password,
+ display_name=data.display_name or data.mobile,
+ mobile=data.mobile,
+ )
+
+ profile = await self.users.get_by_sub(sub)
+ if profile is None:
+ profile = UserProfile(
+ keycloak_sub=sub,
+ email=email,
+ username=username,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ mobile_verified=True,
+ mobile_verified_at=datetime.now(timezone.utc),
+ core_user_id=UUID(core_result["user_id"]),
+ )
+ await self.users.add(profile)
+ else:
+ profile.mobile = data.mobile
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ if data.display_name:
+ profile.display_name = data.display_name
+
+ await self.session.commit()
+ await self.session.refresh(profile)
+
+ return await self.auth.issue_session_for_profile(
+ profile,
+ message="ثبتنام و ورود با موفقیت انجام شد",
+ intent="register",
+ )
+
+ async def request_verify_mobile(self, current: CurrentUser, mobile: str) -> OtpRequestResponse:
+ profile = await self.users.get_by_sub(current.user_id)
+ if profile is None:
+ raise UnauthorizedError("پروفایل کاربر یافت نشد")
+ if profile.mobile_verified and profile.mobile == mobile:
+ return OtpRequestResponse(message="شماره موبایل قبلاً تأیید شده", expires_in=0, skipped=True)
+
+ other = await self.users.get_by_mobile(mobile)
+ if other and other.keycloak_sub != current.user_id:
+ raise ConflictError("این شماره موبایل به کاربر دیگری تعلق دارد", error_code="mobile_taken")
+
+ profile.mobile = mobile
+ profile.mobile_verified = False
+ await self.session.commit()
+
+ result = await self.otp.request_otp(mobile)
+ return OtpRequestResponse(
+ message=result.get("message", "کد تأیید ارسال شد"),
+ expires_in=result.get("expires_in", 120),
+ skipped=result.get("skipped", False),
+ )
+
+ async def verify_mobile_for_user(
+ self, current: CurrentUser, data: MobileOtpVerify
+ ) -> VerifyMobileResponse:
+ profile = await self.users.get_by_sub(current.user_id)
+ if profile is None:
+ raise UnauthorizedError("پروفایل کاربر یافت نشد")
+ if profile.mobile and profile.mobile != data.mobile:
+ raise ConflictError("شماره موبایل با درخواست قبلی مطابقت ندارد")
+
+ core_result = await self.otp.verify_otp(
+ data.mobile,
+ data.code,
+ keycloak_sub=profile.keycloak_sub,
+ email=profile.email,
+ )
+ profile.mobile = data.mobile
+ profile.mobile_verified = True
+ profile.mobile_verified_at = datetime.now(timezone.utc)
+ profile.core_user_id = UUID(core_result["user_id"])
+ await self.session.commit()
+
+ return VerifyMobileResponse(
+ message="شماره موبایل تأیید شد",
+ mobile_verified=True,
+ mobile_verified_at=profile.mobile_verified_at,
+ )
diff --git a/backend/services/identity-access/app/services/user_service.py b/backend/services/identity-access/app/services/user_service.py
new file mode 100644
index 0000000..ca87313
--- /dev/null
+++ b/backend/services/identity-access/app/services/user_service.py
@@ -0,0 +1,106 @@
+from uuid import UUID
+
+import httpx
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.membership import TenantMembership
+from app.models.types import MembershipRole
+from app.models.user import UserProfile
+from app.repositories.user import MembershipRepository, UserRepository
+from app.schemas.user import MembershipCreate, UserCreate
+from app.services.keycloak_client import KeycloakAdminClient
+from shared.exceptions import ConflictError, NotFoundError
+
+
+class UserService:
+ def __init__(self, session: AsyncSession) -> None:
+ self.session = session
+ self.users = UserRepository(session)
+ self.memberships = MembershipRepository(session)
+ self.keycloak = KeycloakAdminClient()
+
+ async def register(self, data: UserCreate) -> UserProfile:
+ if data.mobile and await self.users.get_by_mobile(data.mobile):
+ raise ConflictError("این شماره موبایل قبلاً ثبت شده است", error_code="mobile_taken")
+ sub = await self.keycloak.create_user(
+ email=data.email,
+ username=data.username,
+ password=data.password,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ )
+ existing = await self.users.get_by_sub(sub)
+ if existing:
+ return existing
+ user = UserProfile(
+ keycloak_sub=sub,
+ email=data.email,
+ username=data.username,
+ display_name=data.display_name,
+ mobile=data.mobile,
+ mobile_verified=False,
+ )
+ await self.users.add(user)
+ await self.session.commit()
+ await self.session.refresh(user)
+ return user
+
+ async def get(self, user_id: UUID) -> UserProfile:
+ user = await self.users.get(user_id)
+ if user is None:
+ raise NotFoundError(f"کاربر یافت نشد: {user_id}")
+ return user
+
+ async def list_users(self, *, offset: int = 0, limit: int = 50) -> list[UserProfile]:
+ return await self.users.list(offset=offset, limit=min(limit, 100))
+
+ async def get_or_create_from_sub(
+ self, *, sub: str, email: str | None, username: str | None
+ ) -> UserProfile:
+ user = await self.users.get_by_sub(sub)
+ if user:
+ return user
+ user = UserProfile(
+ keycloak_sub=sub,
+ email=email or f"{sub}@unknown.local",
+ username=username,
+ )
+ await self.users.add(user)
+ await self.session.commit()
+ await self.session.refresh(user)
+ return user
+
+ async def add_member(
+ self, tenant_id: UUID, data: MembershipCreate, *, authorization: str | None = None
+ ) -> TenantMembership:
+ await self._assert_tenant_exists(tenant_id, authorization=authorization)
+ user = await self.get(data.user_id)
+ if await self.memberships.get(tenant_id, user.id):
+ raise ConflictError("کاربر قبلاً عضو این tenant است")
+ membership = TenantMembership(
+ tenant_id=tenant_id, user_id=user.id, role=data.role
+ )
+ await self.memberships.add(membership)
+ await self.session.commit()
+ await self.session.refresh(membership)
+ return membership
+
+ async def list_members(self, tenant_id: UUID) -> list[TenantMembership]:
+ return await self.memberships.list_by_tenant(tenant_id)
+
+ async def _assert_tenant_exists(
+ self, tenant_id: UUID, *, authorization: str | None = None
+ ) -> None:
+ from app.core.config import settings
+
+ headers = {}
+ if authorization:
+ headers["Authorization"] = authorization
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ resp = await client.get(
+ f"{settings.core_service_url}/api/v1/tenants/{tenant_id}",
+ headers=headers,
+ )
+ if resp.status_code == 404:
+ raise NotFoundError(f"tenant یافت نشد: {tenant_id}")
+ resp.raise_for_status()
diff --git a/backend/services/identity-access/app/tests/conftest.py b/backend/services/identity-access/app/tests/conftest.py
new file mode 100644
index 0000000..cb20817
--- /dev/null
+++ b/backend/services/identity-access/app/tests/conftest.py
@@ -0,0 +1,37 @@
+import os
+
+os.environ["ENVIRONMENT"] = "test"
+os.environ["AUTH_REQUIRED"] = "false"
+os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test_identity.db"
+os.environ["DATABASE_URL_SYNC"] = "sqlite:///./test_identity.db"
+os.environ["JWT_VERIFY_SIGNATURE"] = "false"
+os.environ["KEYCLOAK_ENABLED"] = "false"
+
+import pytest_asyncio
+from httpx import ASGITransport, AsyncClient
+
+import app.models # noqa
+from app.core.database import AsyncSessionLocal, Base, engine
+from app.main import app
+
+
+@pytest_asyncio.fixture
+async def db_setup():
+ async with engine.begin() as conn:
+ 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
+async def client(db_setup):
+ transport = ASGITransport(app=app)
+ async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
+ yield ac
+
+
+@pytest_asyncio.fixture
+async def session(db_setup):
+ async with AsyncSessionLocal() as s:
+ yield s
diff --git a/backend/services/identity-access/app/tests/test_auth.py b/backend/services/identity-access/app/tests/test_auth.py
new file mode 100644
index 0000000..67a4cbe
--- /dev/null
+++ b/backend/services/identity-access/app/tests/test_auth.py
@@ -0,0 +1,19 @@
+async def test_health(client):
+ resp = await client.get("/health")
+ assert resp.status_code == 200
+ assert resp.json()["service"] == "identity-access-service"
+
+
+async def test_auth_config(client):
+ resp = await client.get("/api/v1/auth/config")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert "authorization_endpoint" in body
+ assert body["client_id"] == "superapp-frontend"
+
+
+async def test_auth_me_without_token_when_auth_disabled(client):
+ # با AUTH_REQUIRED=false در conftest
+ resp = await client.get("/api/v1/auth/me")
+ assert resp.status_code == 200
+ assert resp.json()["roles"]
diff --git a/backend/services/identity-access/app/tests/test_users.py b/backend/services/identity-access/app/tests/test_users.py
new file mode 100644
index 0000000..5b1d134
--- /dev/null
+++ b/backend/services/identity-access/app/tests/test_users.py
@@ -0,0 +1,16 @@
+from app.models.user import UserProfile
+from app.repositories.user import UserRepository
+
+
+async def test_create_user_profile(session):
+ repo = UserRepository(session)
+ user = UserProfile(
+ keycloak_sub="sub-123",
+ email="user@example.com",
+ username="user1",
+ )
+ await repo.add(user)
+ await session.commit()
+ found = await repo.get_by_sub("sub-123")
+ assert found is not None
+ assert found.email == "user@example.com"
diff --git a/backend/services/identity-access/pytest.ini b/backend/services/identity-access/pytest.ini
new file mode 100644
index 0000000..7914318
--- /dev/null
+++ b/backend/services/identity-access/pytest.ini
@@ -0,0 +1,3 @@
+[pytest]
+asyncio_mode = auto
+testpaths = app/tests
diff --git a/backend/services/identity-access/requirements.txt b/backend/services/identity-access/requirements.txt
new file mode 100644
index 0000000..1647b01
--- /dev/null
+++ b/backend/services/identity-access/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/identity-access/scripts/apply_realm_theme.py b/backend/services/identity-access/scripts/apply_realm_theme.py
new file mode 100644
index 0000000..2ac7ffc
--- /dev/null
+++ b/backend/services/identity-access/scripts/apply_realm_theme.py
@@ -0,0 +1,57 @@
+"""اعمال تم فارسی torbatyar و locale پیشفرض fa روی realm superapp."""
+from __future__ import annotations
+
+import os
+import sys
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://localhost:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+
+
+def main() -> int:
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASS,
+ },
+ )
+ token_resp.raise_for_status()
+ headers = {"Authorization": f"Bearer {token_resp.json()['access_token']}"}
+
+ realm_resp = client.get(f"{KEYCLOAK_URL}/admin/realms/{REALM}", headers=headers)
+ realm_resp.raise_for_status()
+ data = realm_resp.json()
+
+ data["displayName"] = "Torbatyar"
+ data["displayNameHtml"] = "Torbatyar
"
+ data["internationalizationEnabled"] = True
+ data["supportedLocales"] = ["fa", "en"]
+ data["defaultLocale"] = "fa"
+ data["loginTheme"] = "torbatyar"
+ data["accountTheme"] = "torbatyar"
+ data["emailTheme"] = "torbatyar"
+
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}",
+ headers=headers,
+ json=data,
+ )
+ update_resp.raise_for_status()
+ print(f"Realm '{REALM}' updated: theme=torbatyar, locale=fa")
+ return 0
+
+
+if __name__ == "__main__":
+ try:
+ raise SystemExit(main())
+ except Exception as exc:
+ print(f"Error: {exc}", file=sys.stderr)
+ raise SystemExit(1) from exc
diff --git a/backend/services/identity-access/scripts/enable_mobile_auth_client.py b/backend/services/identity-access/scripts/enable_mobile_auth_client.py
new file mode 100644
index 0000000..8751915
--- /dev/null
+++ b/backend/services/identity-access/scripts/enable_mobile_auth_client.py
@@ -0,0 +1,116 @@
+"""فعالسازی direct grant و token exchange برای ورود موبایل (یکبار اجرا)."""
+from __future__ import annotations
+
+import os
+import sys
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://keycloak:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASSWORD = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+CLIENT_ID = os.getenv("IDENTITY_KEYCLOAK_CLIENT_ID", "identity-access-service")
+FRONTEND_CLIENT_ID = os.getenv("KEYCLOAK_FRONTEND_CLIENT_ID", "superapp-frontend")
+
+
+def main() -> None:
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token",
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASSWORD,
+ },
+ )
+ token_resp.raise_for_status()
+ token = token_resp.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ clients_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": CLIENT_ID},
+ headers=headers,
+ )
+ clients_resp.raise_for_status()
+ clients = clients_resp.json()
+ if not clients:
+ print(f"Client not found: {CLIENT_ID}", file=sys.stderr)
+ sys.exit(1)
+
+ internal_id = clients[0]["id"]
+ detail_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}",
+ headers=headers,
+ )
+ detail_resp.raise_for_status()
+ client_body = detail_resp.json()
+ client_body["directAccessGrantsEnabled"] = True
+ client_body["serviceAccountsEnabled"] = True
+ attrs = client_body.get("attributes") or {}
+ attrs["oauth2.token.exchange.grant.enabled"] = "true"
+ client_body["attributes"] = attrs
+
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}",
+ json=client_body,
+ headers=headers,
+ )
+ update_resp.raise_for_status()
+ print(f"Updated {CLIENT_ID}: directAccessGrants + token exchange enabled.")
+
+ sa_user_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}/service-account-user",
+ headers=headers,
+ )
+ if sa_user_resp.status_code != 200:
+ print("Service account user not found.", file=sys.stderr)
+ return
+
+ sa_user_id = sa_user_resp.json()["id"]
+ rm_clients = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": "realm-management"},
+ headers=headers,
+ ).json()
+ if not rm_clients:
+ print("realm-management client not found.", file=sys.stderr)
+ return
+
+ rm_id = rm_clients[0]["id"]
+ roles = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{rm_id}/roles",
+ headers=headers,
+ ).json()
+ role_names = {"impersonation", "token-exchange"}
+ for role in roles:
+ if role.get("name") not in role_names:
+ continue
+ map_resp = client.post(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/users/{sa_user_id}/role-mappings/clients/{rm_id}",
+ json=[role],
+ headers=headers,
+ )
+ if map_resp.status_code in (204, 200):
+ print(f"Granted {role['name']} to service account.")
+
+ frontend_clients = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": FRONTEND_CLIENT_ID},
+ headers=headers,
+ ).json()
+ if frontend_clients:
+ frontend_id = frontend_clients[0]["id"]
+ perm_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{internal_id}/management/permissions",
+ headers=headers,
+ )
+ print(f"Frontend client id: {frontend_id} (token-exchange audience target).")
+ if perm_resp.status_code == 200:
+ print("Client permissions endpoint available.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/services/identity-access/scripts/ensure_db.py b/backend/services/identity-access/scripts/ensure_db.py
new file mode 100644
index 0000000..643df23
--- /dev/null
+++ b/backend/services/identity-access/scripts/ensure_db.py
@@ -0,0 +1,45 @@
+"""اطمینان از وجود دیتابیس identity_access_db قبل از migration.
+
+اگر volume قبلی PostgreSQL قبل از init-dbs.sql ساخته شده باشد،
+این اسکریپت دیتابیس را در صورت نبود ایجاد میکند.
+"""
+from __future__ import annotations
+
+import os
+import sys
+from urllib.parse import urlparse
+
+
+def main() -> None:
+ sync_url = os.environ.get("DATABASE_URL_SYNC", "")
+ if not sync_url:
+ print("DATABASE_URL_SYNC not set", file=sys.stderr)
+ return
+
+ parsed = urlparse(sync_url.replace("+psycopg", ""))
+ db_name = (parsed.path or "").lstrip("/") or "identity_access_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/identity-access/scripts/patch_keycloak_client.py b/backend/services/identity-access/scripts/patch_keycloak_client.py
new file mode 100644
index 0000000..b2b745c
--- /dev/null
+++ b/backend/services/identity-access/scripts/patch_keycloak_client.py
@@ -0,0 +1,100 @@
+"""یکبار اجرا کنید تا redirect URI دامنه dev به کلاینت frontend در Keycloak اضافه شود."""
+from __future__ import annotations
+
+import os
+import sys
+from urllib.parse import urlparse
+
+import httpx
+
+KEYCLOAK_URL = os.getenv("KEYCLOAK_SERVER_URL", "http://localhost:8080")
+REALM = os.getenv("KEYCLOAK_REALM", "superapp")
+ADMIN_USER = os.getenv("KEYCLOAK_ADMIN", "admin")
+ADMIN_PASS = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin")
+CLIENT_ID = os.getenv("KEYCLOAK_FRONTEND_CLIENT_ID", "superapp-frontend")
+CALLBACK_URL = os.getenv(
+ "FRONTEND_CALLBACK_URL", "http://torbatyar.xyz:3000/auth/callback"
+)
+
+
+def _dev_origin(callback_url: str) -> str:
+ parsed = urlparse(callback_url)
+ if not parsed.scheme or not parsed.netloc:
+ raise ValueError(f"FRONTEND_CALLBACK_URL نامعتبر است: {callback_url}")
+ return f"{parsed.scheme}://{parsed.netloc}"
+
+
+def main() -> int:
+ origin = _dev_origin(CALLBACK_URL)
+ extra_redirect = f"{origin}/*"
+ extra_origin = origin
+
+ token_url = f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token"
+ with httpx.Client(timeout=15.0) as client:
+ token_resp = client.post(
+ token_url,
+ data={
+ "grant_type": "password",
+ "client_id": "admin-cli",
+ "username": ADMIN_USER,
+ "password": ADMIN_PASS,
+ },
+ )
+ token_resp.raise_for_status()
+ token = token_resp.json()["access_token"]
+ headers = {"Authorization": f"Bearer {token}"}
+
+ clients_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients",
+ params={"clientId": CLIENT_ID},
+ headers=headers,
+ )
+ clients_resp.raise_for_status()
+ clients = clients_resp.json()
+ if not clients:
+ print(f"Client not found: {CLIENT_ID}", file=sys.stderr)
+ return 1
+
+ client_uuid = clients[0]["id"]
+ detail_resp = client.get(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{client_uuid}",
+ headers=headers,
+ )
+ detail_resp.raise_for_status()
+ data = detail_resp.json()
+
+ redirects = list(data.get("redirectUris") or [])
+ origins = list(data.get("webOrigins") or [])
+ changed = False
+ if extra_redirect not in redirects:
+ redirects.append(extra_redirect)
+ changed = True
+ if extra_origin not in origins:
+ origins.append(extra_origin)
+ changed = True
+
+ # حذف اجبار PKCE (تبادل توکن سمت سرور توسط identity انجام میشود و
+ # crypto.subtle روی HTTP لوکال در دسترس نیست).
+ attributes = dict(data.get("attributes") or {})
+ if attributes.pop("pkce.code.challenge.method", None) is not None:
+ data["attributes"] = attributes
+ changed = True
+
+ if not changed:
+ print(f"Keycloak client already configured for {origin}.")
+ return 0
+
+ data["redirectUris"] = redirects
+ data["webOrigins"] = origins
+ update_resp = client.put(
+ f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients/{client_uuid}",
+ headers=headers,
+ json=data,
+ )
+ update_resp.raise_for_status()
+ print(f"Keycloak frontend client updated for {origin}.")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/backend/services/link_shortener/README.md b/backend/services/link_shortener/README.md
new file mode 100644
index 0000000..ba7a2a0
--- /dev/null
+++ b/backend/services/link_shortener/README.md
@@ -0,0 +1,15 @@
+# Link Shortener Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- کوتاهکننده لینک مستقل
+- دامنه اختصاصی
+- tracking
+- analytics
+- campaign attribution
+
+## اصول
+- دیتابیس مستقل (`link_shortener_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`link_shortener.*`).
diff --git a/backend/services/live_chat/README.md b/backend/services/live_chat/README.md
new file mode 100644
index 0000000..62a5761
--- /dev/null
+++ b/backend/services/live_chat/README.md
@@ -0,0 +1,15 @@
+# Live Chat Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- widget قابل embed
+- گفتگوی آنلاین
+- اتصال به CRM
+- اتصال به AI Assistant
+- history پیامها
+
+## اصول
+- دیتابیس مستقل (`live_chat_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`live_chat.*`).
diff --git a/backend/services/notification/README.md b/backend/services/notification/README.md
new file mode 100644
index 0000000..4c3681f
--- /dev/null
+++ b/backend/services/notification/README.md
@@ -0,0 +1,15 @@
+# Notification Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- ایمیل
+- پیامک
+- push
+- webhook
+- in-app notification
+
+## اصول
+- دیتابیس مستقل (`notification_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`notification.*`).
diff --git a/backend/services/restaurant/README.md b/backend/services/restaurant/README.md
new file mode 100644
index 0000000..199d500
--- /dev/null
+++ b/backend/services/restaurant/README.md
@@ -0,0 +1,16 @@
+# Restaurant / Cafe Management Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- منوی دیجیتال
+- میزها
+- سفارشها
+- آشپزخانه
+- پرداخت
+- باشگاه مشتریان
+
+## اصول
+- دیتابیس مستقل (`restaurant_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`restaurant.*`).
diff --git a/backend/services/smart_messenger/README.md b/backend/services/smart_messenger/README.md
new file mode 100644
index 0000000..b9c1334
--- /dev/null
+++ b/backend/services/smart_messenger/README.md
@@ -0,0 +1,14 @@
+# Smart Messenger Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- اتصال شبکههای اجتماعی
+- مدیریت پیامها
+- automation
+- inbox یکپارچه
+
+## اصول
+- دیتابیس مستقل (`smart_messenger_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`smart_messenger.*`).
diff --git a/backend/services/sms_panel/README.md b/backend/services/sms_panel/README.md
new file mode 100644
index 0000000..06ea45d
--- /dev/null
+++ b/backend/services/sms_panel/README.md
@@ -0,0 +1,16 @@
+# SMS Panel Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- ارسال پیامک
+- صف ارسال
+- template
+- providerهای مختلف
+- rate limit
+- retry
+
+## اصول
+- دیتابیس مستقل (`sms_panel_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`sms_panel.*`).
diff --git a/backend/services/website_builder/README.md b/backend/services/website_builder/README.md
new file mode 100644
index 0000000..445ef76
--- /dev/null
+++ b/backend/services/website_builder/README.md
@@ -0,0 +1,16 @@
+# Website Builder Service (Placeholder)
+
+> وضعیت: طراحیشده در معماری، پیادهسازی در فازهای بعدی.
+
+## مسئولیتها
+- سایت شرکتی
+- صفحهساز
+- فرمساز
+- منو
+- صفحات landing
+- مدیریت محتوا
+
+## اصول
+- دیتابیس مستقل (`website_builder_db`) با `tenant_id` در جداول بیزینسی.
+- ارتباط با سایر سرویسها فقط از طریق API/Event.
+- بررسی دسترسی قابلیتها از Core (`website_builder.*`).
diff --git a/backend/shared-lib/pyproject.toml b/backend/shared-lib/pyproject.toml
new file mode 100644
index 0000000..96f5d42
--- /dev/null
+++ b/backend/shared-lib/pyproject.toml
@@ -0,0 +1,17 @@
+[build-system]
+requires = ["setuptools>=68", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "superapp-shared"
+version = "0.1.0"
+description = "کتابخانه مشترک بین سرویسهای SuperApp SaaS Platform"
+requires-python = ">=3.11"
+dependencies = [
+ "pydantic>=2.5",
+ "httpx>=0.27",
+ "pyjwt[crypto]>=2.8",
+]
+
+[tool.setuptools.packages.find]
+include = ["shared*"]
diff --git a/backend/shared-lib/shared/__init__.py b/backend/shared-lib/shared/__init__.py
new file mode 100644
index 0000000..8c28866
--- /dev/null
+++ b/backend/shared-lib/shared/__init__.py
@@ -0,0 +1,14 @@
+"""shared: کتابخانه مشترک بین سرویسهای SuperApp.
+
+این پکیج شامل قراردادها و ابزارهای مشترک است تا بین همه سرویسها
+یکپارچگی حفظ شود (بدون وابستگی مستقیم به دیتابیس سرویس دیگر).
+"""
+
+__all__ = [
+ "tenant",
+ "security",
+ "events",
+ "pagination",
+ "exceptions",
+ "responses",
+]
diff --git a/backend/shared-lib/shared/auth/__init__.py b/backend/shared-lib/shared/auth/__init__.py
new file mode 100644
index 0000000..b9b293d
--- /dev/null
+++ b/backend/shared-lib/shared/auth/__init__.py
@@ -0,0 +1,12 @@
+"""ابزارهای مشترک احراز هویت و مجوزدهی بین سرویسهای backend."""
+
+from shared.auth.jwt import JWTSettings, JWTValidator
+from shared.auth.roles import PlatformRole, has_any_role, has_role
+
+__all__ = [
+ "JWTSettings",
+ "JWTValidator",
+ "PlatformRole",
+ "has_role",
+ "has_any_role",
+]
diff --git a/backend/shared-lib/shared/auth/jwt.py b/backend/shared-lib/shared/auth/jwt.py
new file mode 100644
index 0000000..a0aa801
--- /dev/null
+++ b/backend/shared-lib/shared/auth/jwt.py
@@ -0,0 +1,140 @@
+"""اعتبارسنجی JWT مشترک (Keycloak / OIDC) برای همه سرویسهای backend."""
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass
+from typing import Any
+
+import httpx
+import jwt
+
+from shared.exceptions import UnauthorizedError
+from shared.security import CurrentUser
+
+
+@dataclass(frozen=True)
+class JWTSettings:
+ keycloak_enabled: bool = False
+ keycloak_server_url: str = "http://localhost:8080"
+ keycloak_realm: str = "superapp"
+ jwt_algorithm: str = "RS256"
+ jwt_audience: str = "account"
+ jwt_verify_signature: bool = False
+ # issuer واقعی داخل JWT (ممکن است با keycloak_server_url داخلی Docker متفاوت باشد)
+ jwt_issuer: str | None = None
+
+ @property
+ def jwks_url(self) -> str:
+ return (
+ f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+ "/protocol/openid-connect/certs"
+ )
+
+ @property
+ def issuer(self) -> str:
+ if self.jwt_issuer:
+ return self.jwt_issuer
+ return f"{self.keycloak_server_url}/realms/{self.keycloak_realm}"
+
+ @property
+ def allowed_issuers(self) -> list[str]:
+ """Issuerهای مجاز — توکن ممکن است از URL داخلی Docker یا عمومی صادر شده باشد."""
+ issuers = [
+ self.issuer,
+ f"{self.keycloak_server_url}/realms/{self.keycloak_realm}",
+ ]
+ if self.jwt_issuer:
+ issuers.insert(0, self.jwt_issuer)
+ return list(dict.fromkeys(i for i in issuers if i))
+
+
+class JWTValidator:
+ """اعتبارسنج JWT با پشتیبانی JWKS و حالت development."""
+
+ def __init__(self, settings: JWTSettings) -> None:
+ self.settings = settings
+ self._jwks_cache: dict[str, Any] = {"keys": None, "fetched_at": 0.0}
+ self._jwks_ttl = 3600
+
+ async def validate(self, token: str) -> CurrentUser:
+ if not token:
+ raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
+ try:
+ if self.settings.jwt_verify_signature and self.settings.keycloak_enabled:
+ claims = await self._decode_verified(token)
+ else:
+ claims = self._decode_unverified(token)
+ except UnauthorizedError:
+ raise
+ except Exception as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+ return self._claims_to_user(claims)
+
+ def _decode_unverified(self, token: str) -> dict[str, Any]:
+ return jwt.decode(token, options={"verify_signature": False})
+
+ async def _decode_verified(self, token: str) -> dict[str, Any]:
+ jwks = await self._get_jwks()
+ if jwks is None:
+ raise UnauthorizedError("امکان دریافت کلید عمومی برای اعتبارسنجی توکن نبود")
+ try:
+ signing_key = jwt.PyJWKClient(self.settings.jwks_url).get_signing_key_from_jwt(token)
+ claims = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=[self.settings.jwt_algorithm],
+ options={"verify_aud": False, "verify_iss": False},
+ )
+ token_iss = claims.get("iss")
+ if not self._issuer_allowed(token_iss):
+ raise UnauthorizedError(
+ f"توکن نامعتبر است: issuer نامعتبر ({token_iss})"
+ )
+ return claims
+ except jwt.PyJWTError as exc:
+ raise UnauthorizedError(f"توکن نامعتبر است: {exc}") from exc
+
+ def _issuer_allowed(self, token_iss: str | None) -> bool:
+ if not token_iss:
+ return False
+ if token_iss in self.settings.allowed_issuers:
+ return True
+ realm_suffix = f"/realms/{self.settings.keycloak_realm}"
+ return token_iss.endswith(realm_suffix)
+
+ async def _get_jwks(self) -> dict[str, Any] | None:
+ now = time.time()
+ if (
+ self._jwks_cache["keys"] is not None
+ and now - self._jwks_cache["fetched_at"] < self._jwks_ttl
+ ):
+ return self._jwks_cache["keys"]
+ try:
+ async with httpx.AsyncClient(timeout=5.0) as client:
+ resp = await client.get(self.settings.jwks_url)
+ resp.raise_for_status()
+ keys = resp.json()
+ self._jwks_cache["keys"] = keys
+ self._jwks_cache["fetched_at"] = now
+ return keys
+ except Exception:
+ return None
+
+ @staticmethod
+ def _claims_to_user(claims: dict[str, Any]) -> CurrentUser:
+ realm_access = claims.get("realm_access") or {}
+ roles = list(realm_access.get("roles", [])) if isinstance(realm_access, dict) else []
+ # نقشهای client-specific (مثلاً superapp-frontend)
+ resource_access = claims.get("resource_access") or {}
+ if isinstance(resource_access, dict):
+ for client_roles in resource_access.values():
+ if isinstance(client_roles, dict):
+ roles.extend(client_roles.get("roles", []))
+ roles = list(dict.fromkeys(roles)) # یکتا
+ return CurrentUser(
+ user_id=str(claims.get("sub", "")),
+ username=claims.get("preferred_username"),
+ email=claims.get("email"),
+ roles=roles,
+ tenant_id=claims.get("tenant_id"),
+ )
diff --git a/backend/shared-lib/shared/auth/roles.py b/backend/shared-lib/shared/auth/roles.py
new file mode 100644
index 0000000..b1aeccd
--- /dev/null
+++ b/backend/shared-lib/shared/auth/roles.py
@@ -0,0 +1,24 @@
+"""نقشهای استاندارد پلتفرم (هماهنگ با Keycloak realm)."""
+from __future__ import annotations
+
+import enum
+
+from shared.security import CurrentUser
+
+
+class PlatformRole(str, enum.Enum):
+ PLATFORM_ADMIN = "platform_admin"
+ TENANT_ADMIN = "tenant_admin"
+ TENANT_MEMBER = "tenant_member"
+ PENDING_TENANT_ADMIN = "pending_tenant_admin"
+ USER = "user"
+ SERVICE_ACCOUNT = "service_account"
+
+
+def has_role(user: CurrentUser, role: str | PlatformRole) -> bool:
+ role_value = role.value if isinstance(role, PlatformRole) else role
+ return role_value in user.roles
+
+
+def has_any_role(user: CurrentUser, *roles: str | PlatformRole) -> bool:
+ return any(has_role(user, r) for r in roles)
diff --git a/backend/shared-lib/shared/events.py b/backend/shared-lib/shared/events.py
new file mode 100644
index 0000000..37b1df4
--- /dev/null
+++ b/backend/shared-lib/shared/events.py
@@ -0,0 +1,57 @@
+"""قرارداد مشترک رویدادها (Async Events) برای الگوی Outbox/Inbox.
+
+همه سرویسها رویدادها را با این ساختار منتشر و مصرف میکنند تا
+ارتباط بدون اتصال مستقیم دیتابیس ممکن شود.
+"""
+from __future__ import annotations
+
+import enum
+from datetime import datetime, timezone
+from typing import Any
+from uuid import UUID, uuid4
+
+from pydantic import BaseModel, Field
+
+
+class EventStatus(str, enum.Enum):
+ """وضعیت پردازش یک رویداد در Outbox."""
+
+ PENDING = "pending"
+ PROCESSED = "processed"
+ FAILED = "failed"
+
+
+# نمونه انواع رویدادهای شناختهشده پلتفرم (قابل توسعه).
+class CoreEventType(str, enum.Enum):
+ TENANT_CREATED = "tenant.created"
+ TENANT_SUSPENDED = "tenant.suspended"
+ TENANT_ACTIVATED = "tenant.activated"
+ SUBSCRIPTION_CREATED = "subscription.created"
+ SUBSCRIPTION_UPDATED = "subscription.updated"
+ DOMAIN_CREATED = "domain.created"
+ FEATURE_ACCESS_CHANGED = "feature_access.changed"
+
+
+class IdentityEventType(str, enum.Enum):
+ USER_REGISTERED = "user.registered"
+ USER_UPDATED = "user.updated"
+ USER_DISABLED = "user.disabled"
+ TENANT_MEMBER_ADDED = "tenant_member.added"
+ TENANT_MEMBER_REMOVED = "tenant_member.removed"
+ TENANT_MEMBER_ROLE_CHANGED = "tenant_member.role_changed"
+
+
+class EventEnvelope(BaseModel):
+ """پاکت استاندارد یک رویداد که بین سرویسها منتقل میشود."""
+
+ event_id: UUID = Field(default_factory=uuid4)
+ event_type: str
+ aggregate_type: str
+ aggregate_id: str | None = None
+ tenant_id: UUID | None = None
+ source_service: str
+ payload: dict[str, Any] = Field(default_factory=dict)
+ occurred_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+
+ def to_dict(self) -> dict[str, Any]:
+ return self.model_dump(mode="json")
diff --git a/backend/shared-lib/shared/exceptions.py b/backend/shared-lib/shared/exceptions.py
new file mode 100644
index 0000000..36578e6
--- /dev/null
+++ b/backend/shared-lib/shared/exceptions.py
@@ -0,0 +1,79 @@
+"""استثناهای مشترک دامنه (Domain Exceptions).
+
+این استثناها مستقل از فریمورک هستند تا در همه سرویسها قابل استفاده باشند.
+لایه API هر سرویس آنها را به پاسخ HTTP مناسب تبدیل میکند.
+"""
+from __future__ import annotations
+
+from typing import Any
+
+
+class AppError(Exception):
+ """پایه همه خطاهای دامنهای برنامه."""
+
+ status_code: int = 500
+ error_code: str = "internal_error"
+
+ def __init__(
+ self,
+ message: str | None = None,
+ *,
+ error_code: str | None = None,
+ status_code: int | None = None,
+ details: Any | None = None,
+ ) -> None:
+ self.message = message or self.__class__.__doc__ or "Application error"
+ if error_code is not None:
+ self.error_code = error_code
+ if status_code is not None:
+ self.status_code = status_code
+ self.details = details
+ super().__init__(self.message)
+
+
+class NotFoundError(AppError):
+ """منبع درخواستی یافت نشد."""
+
+ status_code = 404
+ error_code = "not_found"
+
+
+class ConflictError(AppError):
+ """تعارض داده (مثلاً مقدار تکراری)."""
+
+ status_code = 409
+ error_code = "conflict"
+
+
+class ValidationAppError(AppError):
+ """داده ورودی نامعتبر است."""
+
+ status_code = 422
+ error_code = "validation_error"
+
+
+class UnauthorizedError(AppError):
+ """احراز هویت انجام نشده است."""
+
+ status_code = 401
+ error_code = "unauthorized"
+
+
+class ForbiddenError(AppError):
+ """دسترسی مجاز نیست."""
+
+ status_code = 403
+ error_code = "forbidden"
+
+
+class TenantNotResolvedError(AppError):
+ """tenant قابل تشخیص نبود در حالی که endpoint نیازمند tenant است."""
+
+ status_code = 400
+ error_code = "tenant_not_resolved"
+
+
+class FeatureAccessDeniedError(ForbiddenError):
+ """tenant به این قابلیت دسترسی ندارد."""
+
+ error_code = "feature_access_denied"
diff --git a/backend/shared-lib/shared/pagination.py b/backend/shared-lib/shared/pagination.py
new file mode 100644
index 0000000..2cbf094
--- /dev/null
+++ b/backend/shared-lib/shared/pagination.py
@@ -0,0 +1,66 @@
+"""ابزارهای صفحهبندی مشترک (offset/limit).
+
+از این مدلها در همه سرویسها برای پاسخهای لیستی استفاده میشود.
+"""
+from __future__ import annotations
+
+from typing import Generic, Sequence, TypeVar
+
+from pydantic import BaseModel, Field
+
+T = TypeVar("T")
+
+DEFAULT_PAGE_SIZE = 20
+MAX_PAGE_SIZE = 100
+
+
+class PaginationParams(BaseModel):
+ """پارامترهای صفحهبندی ورودی."""
+
+ page: int = Field(default=1, ge=1, description="شماره صفحه از ۱")
+ page_size: int = Field(
+ default=DEFAULT_PAGE_SIZE,
+ ge=1,
+ le=MAX_PAGE_SIZE,
+ description="تعداد آیتم در هر صفحه",
+ )
+
+ @property
+ def offset(self) -> int:
+ return (self.page - 1) * self.page_size
+
+ @property
+ def limit(self) -> int:
+ return self.page_size
+
+
+class PageMeta(BaseModel):
+ page: int
+ page_size: int
+ total_items: int
+ total_pages: int
+
+
+class Page(BaseModel, Generic[T]):
+ """پاسخ لیستی صفحهبندیشده."""
+
+ items: Sequence[T]
+ meta: PageMeta
+
+ @classmethod
+ def create(
+ cls,
+ items: Sequence[T],
+ total_items: int,
+ params: PaginationParams,
+ ) -> "Page[T]":
+ total_pages = (total_items + params.page_size - 1) // params.page_size if params.page_size else 0
+ return cls(
+ items=items,
+ meta=PageMeta(
+ page=params.page,
+ page_size=params.page_size,
+ total_items=total_items,
+ total_pages=total_pages,
+ ),
+ )
diff --git a/backend/shared-lib/shared/phone.py b/backend/shared-lib/shared/phone.py
new file mode 100644
index 0000000..82071a2
--- /dev/null
+++ b/backend/shared-lib/shared/phone.py
@@ -0,0 +1,39 @@
+"""نرمالسازی شماره موبایل و کد OTP — مشترک بین Core و Identity."""
+from __future__ import annotations
+
+import re
+
+MOBILE_PATTERN = re.compile(r"^09\d{9}$")
+
+
+def normalize_otp_digits(value: str | None) -> str | None:
+ if value is None:
+ return None
+ s = str(value).strip()
+ if not s:
+ return None
+ persian = "۰۱۲۳۴۵۶۷۸۹"
+ arabic = "٠١٢٣٤٥٦٧٨٩"
+ latin = "0123456789"
+ trans = str.maketrans(persian + arabic, latin + latin)
+ s = s.translate(trans)
+ s = "".join(c for c in s if c.isdigit())
+ return s if s else None
+
+
+def normalize_mobile(raw: str) -> str:
+ """تبدیل به فرمت 09xxxxxxxxx."""
+ digits = normalize_otp_digits(raw)
+ if not digits:
+ raise ValueError("شماره موبایل نامعتبر است")
+ if digits.startswith("0098"):
+ digits = digits[4:]
+ elif digits.startswith("98") and len(digits) >= 12:
+ digits = digits[2:]
+ if len(digits) == 10 and not digits.startswith("0"):
+ digits = "0" + digits
+ if len(digits) == 11 and digits.startswith("0"):
+ if not MOBILE_PATTERN.match(digits):
+ raise ValueError("شماره موبایل باید با 09 شروع شود و ۱۱ رقم باشد")
+ return digits
+ raise ValueError("شماره موبایل باید با 09 شروع شود و ۱۱ رقم باشد")
diff --git a/backend/shared-lib/shared/responses.py b/backend/shared-lib/shared/responses.py
new file mode 100644
index 0000000..31faa02
--- /dev/null
+++ b/backend/shared-lib/shared/responses.py
@@ -0,0 +1,38 @@
+"""ساختار پاسخ استاندارد API بین همه سرویسها.
+
+هدف: یکپارچگی قالب پاسخها (موفق و خطا) در کل پلتفرم.
+"""
+from __future__ import annotations
+
+from typing import Any, Generic, TypeVar
+
+from pydantic import BaseModel, Field
+
+T = TypeVar("T")
+
+
+class ErrorDetail(BaseModel):
+ code: str = Field(..., description="کد ماشینخوان خطا")
+ message: str = Field(..., description="پیام قابل نمایش خطا")
+ details: Any | None = Field(default=None, description="جزئیات تکمیلی")
+
+
+class ErrorResponse(BaseModel):
+ """قالب استاندارد پاسخ خطا."""
+
+ success: bool = False
+ error: ErrorDetail
+
+
+class SuccessResponse(BaseModel, Generic[T]):
+ """قالب استاندارد پاسخ موفق (اختیاری برای wrap کردن داده)."""
+
+ success: bool = True
+ data: T | None = None
+
+
+class MessageResponse(BaseModel):
+ """پاسخ ساده مبتنی بر پیام."""
+
+ success: bool = True
+ message: str
diff --git a/backend/shared-lib/shared/security.py b/backend/shared-lib/shared/security.py
new file mode 100644
index 0000000..25abe85
--- /dev/null
+++ b/backend/shared-lib/shared/security.py
@@ -0,0 +1,55 @@
+"""ابزارهای امنیتی مشترک بین سرویسها.
+
+شامل ساختار کاربر جاری، اسکوپ توکنهای داخلی و توابع hash کردن توکن.
+منطق تولید/اعتبارسنجی JWT کاربر در هر سرویس (core.security) پیاده میشود.
+"""
+from __future__ import annotations
+
+import hashlib
+import hmac
+import secrets
+from dataclasses import dataclass, field
+
+
+def hash_token(token: str, *, secret: str) -> str:
+ """تولید hash امن برای ذخیره توکنهای داخلی سرویس.
+
+ توکن خام هرگز در دیتابیس ذخیره نمیشود؛ فقط hash آن نگهداری میشود.
+ """
+ return hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest()
+
+
+def verify_token(token: str, token_hash: str, *, secret: str) -> bool:
+ """مقایسه امن (constant-time) توکن خام با hash ذخیرهشده."""
+ expected = hash_token(token, secret=secret)
+ return hmac.compare_digest(expected, token_hash)
+
+
+def generate_token(length: int = 48) -> str:
+ """تولید توکن تصادفی امن بهصورت URL-safe."""
+ return secrets.token_urlsafe(length)
+
+
+@dataclass
+class CurrentUser:
+ """کاربر احراز هویتشده جاری (از JWT استخراج میشود)."""
+
+ user_id: str
+ username: str | None = None
+ email: str | None = None
+ roles: list[str] = field(default_factory=list)
+ tenant_id: str | None = None
+
+ def has_role(self, role: str) -> bool:
+ return role in self.roles
+
+
+@dataclass
+class InternalServiceIdentity:
+ """هویت یک سرویس داخلی که با InternalServiceToken احراز شده است."""
+
+ service_key: str
+ scopes: list[str] = field(default_factory=list)
+
+ def has_scope(self, scope: str) -> bool:
+ return "*" in self.scopes or scope in self.scopes
diff --git a/backend/shared-lib/shared/tenant.py b/backend/shared-lib/shared/tenant.py
new file mode 100644
index 0000000..ad8b571
--- /dev/null
+++ b/backend/shared-lib/shared/tenant.py
@@ -0,0 +1,29 @@
+"""قراردادهای مشترک مربوط به Tenant.
+
+هر سرویس برای تشخیص و انتقال tenant از این ثابتها و ساختارها استفاده میکند
+تا نام هدرها و کلیدها در همهجا یکسان باشد.
+"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+from uuid import UUID
+
+# نام هدرهای استاندارد تشخیص tenant (بین همه سرویسها مشترک)
+HEADER_TENANT_ID = "X-Tenant-ID"
+HEADER_TENANT_SLUG = "X-Tenant-Slug"
+
+# کلید ذخیره tenant در request.state
+STATE_TENANT_ID = "tenant_id"
+STATE_TENANT_SLUG = "tenant_slug"
+
+
+@dataclass(frozen=True)
+class TenantContext:
+ """اطلاعات tenant جاری که در طول یک request حمل میشود."""
+
+ tenant_id: UUID | None
+ tenant_slug: str | None
+
+ @property
+ def is_resolved(self) -> bool:
+ return self.tenant_id is not None
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..1e9478b
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,213 @@
+# ============================================================
+# SuperApp SaaS Platform - Docker Compose
+# postgres, redis, keycloak, core-service, identity-access,
+# celery-worker, celery-beat, frontend
+# ============================================================
+
+services:
+ postgres:
+ image: postgres:15-alpine
+ container_name: superapp_postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+ POSTGRES_DB: ${POSTGRES_DB}
+ ports:
+ - "${POSTGRES_PORT:-5432}:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./infrastructure/postgres/init-dbs.sql:/docker-entrypoint-initdb.d/02-init-dbs.sql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - superapp_net
+
+ redis:
+ image: redis:7-alpine
+ container_name: superapp_redis
+ restart: unless-stopped
+ ports:
+ - "${REDIS_PORT:-6379}:6379"
+ volumes:
+ - redis_data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - superapp_net
+
+ keycloak:
+ image: quay.io/keycloak/keycloak:24.0
+ container_name: superapp_keycloak
+ restart: unless-stopped
+ command: start-dev --import-realm --features=token-exchange
+ environment:
+ KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN:-admin}
+ KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
+ KC_DB: postgres
+ KC_DB_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
+ KC_DB_USERNAME: ${POSTGRES_USER}
+ KC_DB_PASSWORD: ${POSTGRES_PASSWORD}
+ KC_HTTP_ENABLED: "true"
+ KC_HOSTNAME_STRICT: "false"
+ KC_PROXY: edge
+ KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-auth.torbatyar.ir}
+ KC_HOSTNAME_STRICT_HTTPS: ${KEYCLOAK_HOSTNAME_STRICT_HTTPS:-false}
+ JAVA_OPTS_KC_HEAP: "-Xms256m -Xmx512m"
+ ports:
+ - "8080:8080"
+ volumes:
+ - ./infrastructure/keycloak/realm:/opt/keycloak/data/import
+ - ./infrastructure/keycloak/themes:/opt/keycloak/themes
+ depends_on:
+ postgres:
+ condition: service_healthy
+ networks:
+ - superapp_net
+
+ core-service:
+ build:
+ context: .
+ dockerfile: backend/core-service/Dockerfile.dev
+ container_name: superapp_core_service
+ restart: unless-stopped
+ env_file:
+ - .env
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./backend/core-service:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
+ command: >
+ sh -c "alembic upgrade 0001_initial &&
+ alembic stamp head &&
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
+ networks:
+ - superapp_net
+
+ identity-access-service:
+ build:
+ context: .
+ dockerfile: backend/services/identity-access/Dockerfile.dev
+ container_name: superapp_identity_service
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ DATABASE_URL: ${IDENTITY_DATABASE_URL}
+ DATABASE_URL_SYNC: ${IDENTITY_DATABASE_URL_SYNC}
+ IDENTITY_KEYCLOAK_CLIENT_ID: identity-access-service
+ KEYCLOAK_IDENTITY_CLIENT_SECRET: ${KEYCLOAK_IDENTITY_CLIENT_SECRET:-change-me-identity}
+ PLATFORM_ADMIN_MOBILES: ${PLATFORM_ADMIN_MOBILES:-}
+ KEYCLOAK_SERVER_URL: http://keycloak:8080
+ KEYCLOAK_PUBLIC_URL: ${KEYCLOAK_PUBLIC_URL:-http://localhost:8080}
+ CORE_SERVICE_URL: ${CORE_SERVICE_URL:-http://core-service:8000}
+ CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000}
+ ports:
+ - "8001:8001"
+ volumes:
+ - ./backend/services/identity-access:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ postgres:
+ condition: service_healthy
+ keycloak:
+ condition: service_started
+ core-service:
+ condition: service_started
+ command: >
+ sh -c "python scripts/ensure_db.py &&
+ alembic upgrade head &&
+ python scripts/enable_mobile_auth_client.py || true &&
+ uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload"
+ networks:
+ - superapp_net
+
+ frontend:
+ build:
+ context: ./frontend
+ dockerfile: Dockerfile.dev
+ container_name: superapp_frontend
+ restart: unless-stopped
+ ports:
+ - "${FRONTEND_PORT:-3000}:3000"
+ environment:
+ NEXT_PUBLIC_BACKEND_URL: ${NEXT_PUBLIC_BACKEND_URL:-http://localhost:8000}
+ NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:8000}
+ NEXT_PUBLIC_IDENTITY_API_URL: ${NEXT_PUBLIC_IDENTITY_API_URL:-http://localhost:8001}
+ NEXT_PUBLIC_KEYCLOAK_URL: ${NEXT_PUBLIC_KEYCLOAK_URL:-http://localhost:8080}
+ NEXT_PUBLIC_KEYCLOAK_REALM: ${NEXT_PUBLIC_KEYCLOAK_REALM:-superapp}
+ NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: ${NEXT_PUBLIC_KEYCLOAK_CLIENT_ID:-superapp-frontend}
+ # polling برای hot-reload پایدار روی volume mount ویندوز
+ WATCHPACK_POLLING: "true"
+ CHOKIDAR_USEPOLLING: "true"
+ volumes:
+ - ./frontend:/app
+ - frontend_node_modules:/app/node_modules
+ depends_on:
+ core-service:
+ condition: service_started
+ identity-access-service:
+ condition: service_started
+ networks:
+ - superapp_net
+
+ celery-worker:
+ build:
+ context: .
+ dockerfile: backend/core-service/Dockerfile.dev
+ container_name: superapp_celery_worker
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - ./backend/core-service:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ redis:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+ command: celery -A app.workers.celery_app.celery_app worker --loglevel=INFO -Q default,core_events,notifications,webhooks
+ networks:
+ - superapp_net
+
+ celery-beat:
+ build:
+ context: .
+ dockerfile: backend/core-service/Dockerfile.dev
+ container_name: superapp_celery_beat
+ restart: unless-stopped
+ env_file:
+ - .env
+ volumes:
+ - ./backend/core-service:/app
+ - ./backend/shared-lib:/shared-lib
+ depends_on:
+ redis:
+ condition: service_healthy
+ command: celery -A app.workers.celery_app.celery_app beat --loglevel=INFO
+ networks:
+ - superapp_net
+
+volumes:
+ postgres_data:
+ redis_data:
+ frontend_node_modules:
+
+networks:
+ superapp_net:
+ driver: bridge
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..628b83e
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,389 @@
+# معماری کلان پلتفرم SuperApp SaaS
+
+## ۱. اهداف
+ساخت یک SuperApp SaaS **چندمستأجری (Multi-tenant)**، **ماژولار**،
+**API-first** و **microservice-ready** که ابتدا روی VPS و در آینده روی
+زیرساخت مقیاسپذیر اجرا شود.
+
+فاز ۱ تنها **Core Platform** را میسازد؛ اما ساختار پروژه بهگونهای است که
+افزودن سرویسهای بعدی بدون بازنویسی معماری ممکن باشد.
+
+## ۲. سبک معماری
+- **Service-oriented / Microservice-ready:** هر قابلیت بزرگ یک سرویس مستقل است.
+- **Database-per-service:** هر سرویس فقط دیتابیس خودش را میشناسد.
+- **API-first:** همه تعاملها از طریق API/Event تعریفشده انجام میشوند.
+- **Multi-tenancy:** همه جداول بیزینسی ستون `tenant_id` دارند.
+
+### سرویسهای اصلی (فعلی و آینده)
+Core Platform (فاز ۱)، Identity & Access، Subscription & Entitlement،
+Accounting، CRM، Ecommerce، Website Builder، Live Chat، AI Assistant،
+Smart Messenger، SMS Panel، Link Shortener، Notification، File Storage،
+Restaurant و ماژولهای آینده Marketplace.
+
+> در فاز ۱ فقط Core Platform پیاده شده است. بقیه سرویسها در پوشه `backend/services/`
+> بهصورت placeholder با README مسئولیتها موجودند.
+
+## ۲.۱ Frontend & Backend Separation (اجباری)
+
+### قانون معماری (سختگیرانه)
+
+پروژه **باید** از معماری کاملاً جدا (decoupled) پیروی کند. Backend و Frontend
+دو اپلیکیشن کاملاً مستقل هستند و **هرگز نباید با هم مخلوط شوند.**
+
+### Backend
+
+تمام کد منبع backend فقط داخل پوشه `backend/` قرار میگیرد (Core Service و
+سرویسهای آینده).
+
+**مسئولیتها:**
+- FastAPI
+- SQLAlchemy
+- Alembic
+- Business Logic
+- Authentication
+- Authorization
+- Database
+- Background Workers
+- APIs
+- Event Processing
+
+**Backend هرگز نباید شامل موارد زیر باشد:**
+- React / Next.js
+- Tailwind
+- UI Components
+- صفحات HTML
+- Frontend assets
+
+### Frontend
+
+Frontend یک اپلیکیشن Next.js کاملاً جدا در پوشه `frontend/` است.
+
+**مسئولیتها:**
+- UI
+- Dashboard
+- Forms
+- Pages
+- Components
+- Layouts
+- State Management
+- API Client
+- Theme
+
+**Frontend هرگز نباید شامل موارد زیر باشد:**
+- کد FastAPI
+- دسترسی مستقیم به دیتابیس
+- مدلهای SQLAlchemy
+- Alembic
+- Business Logic
+- کوئری مستقیم دیتابیس
+
+### ارتباط (Communication)
+
+Frontend فقط از طریق **REST API نسخهدار** (و در آینده WebSocket در صورت نیاز)
+با backend ارتباط برقرار میکند.
+
+هیچ کد منبع مشترکی بین frontend و backend وجود ندارد، **بهجز:**
+- قراردادهای API
+- اسکیماهای مشترک
+- SDKهای تولیدشده
+- تعاریف نوع (type definitions) مشترک
+
+### ساختار پوشهها (اجباری)
+
+```
+superapp-platform/
+├── backend/
+│ ├── core-service/
+│ ├── shared-lib/
+│ └── services/
+│
+├── frontend/
+│ ├── app/
+│ ├── components/
+│ ├── lib/
+│ ├── hooks/
+│ ├── styles/
+│ └── public/
+│
+├── docs/
+├── docker-compose.yml
+├── .env.example
+└── README.md
+```
+
+> این جداسازی در کل پروژه اجباری است. فایلهای frontend هرگز نباید به پوشههای
+> backend منتقل شوند و بالعکس — حتی برای راحتی. هر قابلیت جدید باید این
+> معماری را حفظ کند.
+
+## ۳. تصمیم معماری دیتابیس
+- الگوی **Database-per-service**.
+- **ارتباط مستقیم بین دیتابیس سرویسها ممنوع است.**
+- ارتباط بین سرویسها فقط از طریق:
+ - REST API
+ - Webhook
+ - Async Event
+ - الگوی Outbox/Inbox
+- در فاز ۱ فقط دیتابیس `core_platform_db` ساخته میشود.
+- طراحی اولیه دیتابیس سرویسهای آینده در `database_schema.md` آمده اما
+ migration واقعی آنها ساخته نشده است.
+
+## ۴. Core Platform Service
+مسئول مفاهیم مشترک و مرکزی پلتفرم:
+- مدیریت Tenant و Domain
+- مدیریت Plan / Feature / Subscription و بررسی دسترسی (Entitlement)
+- Service Registry و Module Registry
+- توکنهای داخلی سرویس (Internal Service Tokens)
+- الگوی Outbox/Inbox و Audit Log
+
+### لایهبندی داخلی سرویس هسته
+```
+API (routers) → Services (business logic) → Repositories → Models (DB)
+ ↑
+ Schemas (Pydantic)
+```
+- **core/**: زیرساخت (config, database, cache, security, logging)
+- **middlewares/**: تشخیص tenant
+- **workers/**: Celery و taskها
+
+## ۵. Multi-tenancy و Tenant Resolution
+ترتیب تشخیص tenant در middleware:
+1. هدر `X-Tenant-ID`
+2. هدر `X-Tenant-Slug`
+3. Subdomain (از روی Host و `base_domain`)
+4. Custom domain (از روی Host)
+
+نتیجه در `request.state.tenant_id` و `request.state.tenant_slug` قرار میگیرد.
+برای endpointهای tenant-aware از dependency `require_tenant` استفاده میشود که
+در نبود tenant خطای `tenant_not_resolved` برمیگرداند.
+
+## ۶. Entitlement (بررسی دسترسی قابلیت)
+تابع مرکزی `EntitlementService.check_feature_access(tenant_id, feature_key)`:
+1. ابتدا Redis cache بررسی میشود.
+2. در نبود cache، از دیتابیس محاسبه میشود.
+3. نتیجه با TTL در Redis ذخیره میگردد.
+
+قواعد: اگر tenant غیرفعال باشد، اشتراک غیرفعال باشد، یا قابلیت در پلن نباشد →
+دسترسی false. دسترسی سفارشی (custom access) بالاترین اولویت را دارد.
+
+## ۷. رویدادها (Outbox/Inbox)
+- رویدادهای خروجی ابتدا در جدول `outbox_events` و در **همان تراکنش** عملیات
+ بیزینسی ذخیره میشوند (تضمین atomicity).
+- یک task دورهای Celery (`process_outbox_events`) رویدادهای pending را پردازش
+ و وضعیت آنها را بهروزرسانی میکند.
+- جدول `inbox_events` برای idempotency رویدادهای ورودی است.
+
+## ۸. امنیت و SSO (فاز ۲ و ۳)
+
+### اصل یکپارچگی: شماره موبایل الزامی است
+
+در سامانه یکپارچه TorbatYar، **شماره موبایل برای همه کاربران واجب** است و
+با **OTP پیامکی** تأیید میشود.
+
+### لایههای هویت (تفکیک اجباری)
+
+| لایه | چه کسی | SSO مرکزی Keycloak |
+|------|--------|-------------------|
+| **هسته TorbatYar** | مدیر پلتفرم، صاحب/کارمند tenant | **اجباری** |
+| **زیرسیستمها (staff)** | پنل کافه، CRM، … | **اجباری** — همان JWT |
+| **مشتری tenant** | سفارشدهنده منوی دیجیتال | **اختیاری** — auth محلی tenant |
+
+- `frontend/lib/optional-sso.ts` — برای UIs زیرسیستم: اگر session مرکزی هست، login دوباره لازم نیست.
+- مشتری end-user در `restaurant_db` / `crm_db` ذخیره میشود، نه Keycloak مرکزی.
+
+**ورود مرکزی فقط از Keycloak** (تم torbatyar) با دو تب «رمز عبور» و «موبایل»؛ frontend فقط redirect میکند.
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ F) ورود مرکزی Keycloak — تنها نقطه ورود کاربر │
+├─────────────────────────────────────────────────────────────────┤
+│ Keycloak /realms/superapp/.../auth (تم torbatyar) │
+│ تب «رمز عبور»: نام کاربری / ایمیل / موبایل + رمز │
+│ تب «موبایل»: OTP + ثبتنام inline (mobile-auth.js) │
+│ → Identity /auth/mobile/* → handoff → /auth/callback │
+│ Frontend /login و /register → redirect به Keycloak │
+│ Admin: همان SSO — /admin/login → Keycloak → /admin/tenants │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### احراز هویت OTP (Core Service)
+```
+کاربر → Frontend → POST /api/v1/auth/otp/request?context=public|admin
+ → Payamak (pattern 245189)
+ → POST /api/v1/auth/otp/verify
+ → JWT محلی (HS256)
+```
+
+- `context=public`: نقش پیشفرض `user` (ثبتنام عمومی)
+- `context=admin`: نقش `pending_tenant_admin` (پنل tenant)
+- `PLATFORM_ADMIN_MOBILES`: ارتقا به `platform_admin`
+
+جدول `users` در Core: `mobile` (unique)، `mobile_verified`، `keycloak_sub` (لینک SSO).
+
+### معماری SSO مرکزی
+```
+کاربر → Frontend → Keycloak (OIDC) → JWT
+ ↓
+ Identity /auth/me → requires_mobile_verification?
+ ↓
+ همه سرویسها JWT را validate میکنند
+```
+
+### Identity & Access Service
+- دیتابیس: `identity_access_db`
+- جدول `user_profiles`: `mobile`, `mobile_verified`, `core_user_id`
+- APIهای جدید:
+ - `POST /api/v1/auth/mobile/start` — تشخیص login/register + ارسال OTP
+ - `POST /api/v1/auth/mobile/complete` — تأیید OTP + صدور توکن SSO
+ - `POST /api/v1/auth/session/redeem` — handoff از Keycloak theme
+ - `POST /api/v1/auth/register` (legacy)
+ - `POST /api/v1/auth/register/verify-mobile`
+ - `POST /api/v1/auth/register/mobile`
+ - `POST /api/v1/auth/register/mobile/verify`
+ - `POST /api/v1/auth/mobile/request` (کاربر SSO لاگینشده)
+ - `POST /api/v1/auth/mobile/verify`
+- OTP از طریق Core Service (`CoreOtpClient`) — بدون تکرار منطق SMS
+
+### Keycloak
+- تم `torbatyar`: **ورود مرکزی** — تب رمز عبور + تب موبایل (OTP)
+- `theme.properties`: `identityApiUrl`, `frontendCallbackUrl`
+- attribute کاربر: `mobile` در Admin API
+- Realm: `superapp` — زبان پیشفرض `fa`
+
+### اعتبارسنجی JWT
+- کتابخانه مشترک: `shared/auth/jwt.py`
+- نرمالسازی موبایل مشترک: `shared/phone.py`
+
+## ۹. زیرساخت
+- Docker و docker-compose (postgres, redis, keycloak, core-service,
+ identity-access-service, frontend, celery-worker, celery-beat).
+- همه تنظیمات از `.env` خوانده میشوند؛ **هیچ چیز hardcode نمیشود**.
+- Nginx/Traefik در فازهای بعدی بهعنوان reverse proxy اضافه میشوند.
+
+## ۱۰. White-label
+برند (نام، رنگ، لوگو، ایمیل پشتیبانی) از `env`/config/دیتابیس خوانده میشود.
+Frontend در `frontend/` رنگها را از طریق **CSS Variables** و `public/theme.config.json`
+اعمال میکند تا تغییر برند بدون build مجدد ممکن باشد.
+
+## ۱۱. فاز ۴ — Tenant Onboarding و Workspace Activation
+
+> در بریف پروژه این کار با عنوان **«Phase 3: operationalizing tenant onboarding
+> and workspace activation»** معرفی شده است؛ چون در شمارهگذاری داخلی مستندات
+> پیشتر «فاز ۳» به OTP Login + Tenant Management اختصاص یافته بود (نگاه کنید
+> به `progress.md`)، این کار بهعنوان **فاز ۴** پروژه ثبت میشود تا تاریخچه
+> پیادهسازی واقعی حفظ شود. محتوای این فاز دقیقاً همان چیزی است که در بریف
+> «Phase 3» خوانده میشود.
+
+### هدف
+سیستم را از «هویت + لیست ادمین» به یک **workspace عملیاتی چندمستأجری واقعی**
+تبدیل میکند: کاربر OTP/SSO میزند، در نبود tenant به onboarding هدایت میشود،
+یک workspace (tenant) میسازد، مالک آن میشود، پلن پیشفرض و دامنه اولیه
+میگیرد، برندینگ را تنظیم میکند و در پایان وارد یک داشبورد واقعی tenant
+میشود (نه فقط صفحات لیست ادمین).
+
+### مدل عضویت Tenant (Tenant Membership)
+جدول جدید `tenant_memberships` در **`core_platform_db`** رابطهٔ واقعی
+کاربر↔tenant را نگه میدارد (مستقل از جدول همنام و سادهتر
+`tenant_memberships` در `identity_access_db` که برای مدیریت اعضای هویتی
+استفاده میشود؛ این دو جدول در دو دیتابیس/سرویس متفاوتاند و هیچ ارتباط
+مستقیمی ندارند).
+
+نقشها: `platform_admin`, `tenant_owner`, `tenant_admin`, `tenant_editor`,
+`tenant_viewer`. وضعیت عضویت: `active` / `invited` / `disabled`. هر tenant
+باید حداقل یک `tenant_owner` فعال داشته باشد (بررسی در زمان
+`POST /onboarding/tenant/{id}/complete`). یک کاربر میتواند در آینده عضو چند
+tenant باشد (`current_tenant_id` روی `users` مشخص میکند کدام tenant «جاری»
+است).
+
+### چرخهٔ عمر Tenant (Activation Lifecycle)
+`tenant.status` اکنون این مقادیر عملیاتی را پشتیبانی میکند:
+
+```
+draft → pending_activation → active → suspended / archived
+```
+
+- tenant تازهساختهشده از مسیر onboarding با `pending_activation` شروع میشود.
+- با تکمیل onboarding (`POST .../complete`) و اعتبارسنجی (نام/slug موجود و
+ حداقل یک owner فعال) به `active` تغییر میکند.
+- `suspended`/`archived` فقط توسط پنل ادمین (فازهای قبلی، `PATCH
+ /admin/tenants/{id}`) قابل تنظیماند.
+- مقادیر قدیمی `inactive`/`deleted` برای سازگاری با دادههای فاز ۱ حفظ شدهاند.
+
+### پروفایل و برندینگ Tenant
+ستونهای برندینگ/تنظیمات مستقیماً روی جدول `tenants` اضافه شدهاند (بدون
+جدول جدا، چون حجم کم و همیشه ۱به۱ با tenant است): `business_type`,
+`default_locale`, `timezone`, `primary_color`, `secondary_color`,
+`logo_url`, `favicon_url`, `onboarding_completed`.
+
+### پلن / اشتراک (بدون درگاه پرداخت)
+ساختار `plans` و `tenant_subscriptions` از فاز ۱ موجود بود؛ در این فاز:
+- یک پلن پیشفرض «FREE» (و «STARTER» برای آینده) از طریق migration seed
+ میشود (`PlanService.ensure_default_plan` idempotent است).
+- هنگام `POST /onboarding/tenant`، بهصورت خودکار یک `tenant_subscriptions`
+ با `plan=FREE` و `status=active` ساخته میشود.
+- درگاه پرداخت پیادهسازی **نشده** — فقط ساختار provisioning است.
+
+### نگاشت دامنه (Tenant Domain Mapping)
+جدول `domains` (فاز ۱) با دو ستون جدید تقویت شده: `is_primary` (دامنهٔ
+اصلی/پیشفرض tenant) و `verification_status` (`pending`/`verified`/`failed`).
+هنگام `POST /onboarding/tenant`، اگر `PLATFORM_BASE_DOMAIN` تنظیم شده باشد،
+زیردامنهٔ `{slug}.{PLATFORM_BASE_DOMAIN}` بهصورت خودکار و از پیش
+verified ساخته میشود. دامنهٔ اختصاصی (custom) از طریق
+`PATCH /onboarding/tenant/{id}/domain` با وضعیت `pending` اضافه میشود و
+تأیید واقعی آن به فازهای بعدی موکول شده است.
+
+### Tenant Resolution و Current Tenant Context
+علاوه بر middleware قبلی (`X-Tenant-ID` → `X-Tenant-Slug` → subdomain →
+custom domain)، این dependencyهای جدید اضافه شدند:
+- `get_current_core_user` / `get_optional_core_user`: رکورد واقعی
+ `users` را چه برای JWT محلی (OTP) و چه برای JWT کیکلوک (SSO، بر اساس
+ `keycloak_sub`) resolve میکنند.
+- `get_tenant_resolution`: اول هدر/دامنه (middleware)، در نبود آن
+ `current_tenant_id` کاربر جاری را برمیگرداند؛ endpointهای platform-admin
+ فعلی تحت تأثیر قرار نمیگیرند.
+- `TenantContextService.resolve_current_tenant`: برای `GET /tenant/current`
+ استفاده میشود (بر اساس `current_tenant_id` یا اولین عضویت کاربر).
+
+### رابطهٔ ورود OTP/SSO با provisioning workspace
+هر دو مسیر احراز هویت (JWT محلی OTP فاز ۱ صادرشده از Core، و JWT کیکلوک SSO
+فاز ۲ صادرشده از Identity) در نهایت باید به یک رکورد `users` در
+`core_platform_db` متصل شوند تا onboarding معنا پیدا کند:
+
+```
+JWT محلی (HS256) → users.id ──┐
+ ├─→ UserService.resolve_current() → User
+JWT کیکلوک (RS256) → users.keycloak_sub ──┘
+```
+
+اگر کاربر SSO هنوز به هیچ رکورد Core لینک نشده باشد (یعنی هرگز از مسیر OTP
+محلی Core عبور نکرده)، `resolve_current` خطای `forbidden` برمیگرداند — به
+این معنا که JIT provisioning کامل کاربر Core از JWT کیکلوک فعلاً در محدودهٔ
+این فاز پیاده نشده و به فاز بعدی موکول شده (نگاه کنید به `last_step.md`).
+
+### APIهای Onboarding
+جزئیات کامل در `services_contracts.md`؛ خلاصه:
+`GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`,
+`PATCH /onboarding/tenant/{id}/branding`,
+`PATCH /onboarding/tenant/{id}/domain`,
+`POST /onboarding/tenant/{id}/complete`, `GET /tenant/current`,
+`POST /tenant/switch`.
+
+### Authorization
+`MembershipService.ensure_role` بررسی میکند که کاربر جاری روی tenant مقصد
+نقش `tenant_owner` یا `tenant_admin` داشته باشد (پیشفرض برای مدیریت
+برندینگ/دامنه/تکمیل onboarding)؛ `platform_admin` همیشه bypass میشود.
+endpointهای onboarding فقط نیاز به کاربر احرازهویتشده دارند (بدون بررسی
+نقش برای ساخت tenant جدید، چون هر کاربر میتواند workspace خودش را بسازد).
+
+### Frontend
+- **Bootstrap:** پس از ورود، `hooks/useMe.ts` نتیجهٔ `GET /api/v1/me` را
+ میگیرد. صفحهٔ `/dashboard` اگر `onboarding_required=true` باشد کاربر را
+ به `/onboarding` هدایت میکند.
+- **Onboarding Wizard:** صفحهٔ تکفایلی `app/onboarding/page.tsx` با ۴ گام
+ (اطلاعات کسبوکار → برندینگ → دامنه → بازبینی) که بهترتیب APIهای
+ onboarding را صدا میزند و در پایان به `/dashboard` هدایت میکند.
+- **Tenant Dashboard:** `app/dashboard/page.tsx` اطلاعات tenant جاری
+ (`GET /tenant/current`) را نمایش میدهد: نام، وضعیت، پلن، دامنه، وضعیت
+ onboarding و نقش کاربر.
+- **Tenant Switcher:** `components/TenantSwitcher.tsx` — فقط وقتی کاربر
+ بیش از یک عضویت داشته باشد نمایش داده میشود و از `POST /tenant/switch`
+ استفاده میکند.
diff --git a/docs/database_schema.md b/docs/database_schema.md
new file mode 100644
index 0000000..919d990
--- /dev/null
+++ b/docs/database_schema.md
@@ -0,0 +1,224 @@
+# طرح دیتابیس (Database Schema)
+
+## بخش ۱ — دیتابیس Core Platform (`core_platform_db`) — فاز ۱
+
+همه مدلها با SQLAlchemy 2.x و Alembic پیاده شدهاند. شناسهها از نوع UUID و
+زمانها timezone-aware هستند.
+
+### جدول `tenants`
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| name | varchar(255) | نام مستأجر (`business_name` در onboarding) |
+| slug | varchar(100) unique | شناسه یکتا برای resolve |
+| status | enum | `draft` / `pending_activation` / `active` / `suspended` / `archived` (+ مقادیر قدیمی `inactive`/`deleted` برای سازگاری) |
+| owner_user_id | UUID null | مالک (از Identity Service) |
+| business_type | varchar(100) null | نوع کسبوکار (فاز ۴ — onboarding) |
+| default_locale | varchar(20) | پیشفرض `fa-IR` |
+| timezone | varchar(50) | پیشفرض `Asia/Tehran` |
+| primary_color | varchar(20) null | برندینگ |
+| secondary_color | varchar(20) null | برندینگ |
+| logo_url | varchar(500) null | برندینگ |
+| favicon_url | varchar(500) null | برندینگ |
+| onboarding_completed | bool | پیشفرض `false`؛ با `POST .../complete` به `true` تغییر میکند |
+| created_at, updated_at | timestamptz | |
+
+ایندکسها: `slug` (unique)، `status`.
+
+> فیلدهای `business_type` تا `onboarding_completed` در migration `0005_tenant_onboarding`
+> اضافه شدهاند (فاز ۴ — Tenant Onboarding).
+
+### جدول `domains`
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| tenant_id | UUID (FK→tenants) | |
+| domain | varchar(255) unique | |
+| domain_type | enum | subdomain / custom_domain |
+| is_verified | bool | |
+| verified_at | timestamptz null | |
+| is_primary | bool | دامنهٔ اصلی/پیشفرض tenant (زیردامنهٔ خودکار onboarding) — فاز ۴ |
+| verification_status | enum | `pending` / `verified` / `failed` — فاز ۴ |
+| created_at | timestamptz | |
+
+ایندکسها: `domain` (unique)، `tenant_id`.
+
+### جدول `plans`
+id, code(unique), name, description, is_active, created_at. ایندکس: `code`.
+
+### جدول `features`
+id, feature_key(unique), name, description, service_key, is_active.
+ایندکسها: `feature_key` (unique)، `service_key`.
+مثال feature_key: `accounting.invoice.create`، `crm.lead.create`،
+`ecommerce.product.create`، `live_chat.widget.enable`، `ai_assistant.chat.reply`.
+
+### جدول `plan_features`
+id, plan_id(FK), feature_id(FK), limit_value(null=نامحدود),
+limit_period(enum: day/month/year/total), is_enabled.
+محدودیت یکتا: (plan_id, feature_id). ایندکسها روی هر دو کلید خارجی.
+
+### جدول `tenant_subscriptions`
+id, tenant_id(FK), plan_id(FK), status(enum: trialing/active/past_due/
+canceled/expired), starts_at, ends_at, trial_ends_at, created_at.
+ایندکسها: `tenant_id`، `status`.
+
+### جدول `tenant_feature_access`
+override سفارشی دسترسی: id, tenant_id(FK), feature_id(FK), is_enabled,
+custom_limit_value(null=نامحدود), used_value, reset_at.
+محدودیت یکتا: (tenant_id, feature_id). ایندکسها روی هر دو کلید خارجی.
+
+### جدول `service_registry`
+id, service_key(unique), name, base_url, status(enum), health_check_url,
+created_at. ایندکس: `service_key`.
+
+### جدول `module_registry`
+id, module_key(unique), name, description, is_system_module, is_active.
+ایندکس: `module_key`.
+
+### جدول `tenant_module_access`
+id, tenant_id(FK), module_id(FK), is_enabled, enabled_at, disabled_at.
+محدودیت یکتا: (tenant_id, module_id). ایندکس: `tenant_id`.
+
+### جدول `internal_service_tokens`
+id, service_key, token_hash(unique), scopes, expires_at, is_active, created_at.
+فقط hash توکن ذخیره میشود. ایندکسها: `service_key`، `token_hash`.
+
+### جدول `outbox_events`
+id, event_type, aggregate_type, aggregate_id, tenant_id, payload(JSON),
+status(enum: pending/processed/failed), retry_count, created_at, processed_at.
+ایندکسها: `status`، `tenant_id`.
+
+### جدول `inbox_events`
+id, event_id(unique), source_service, event_type, tenant_id, payload(JSON),
+processed_at. ایندکسها: `event_id`، `tenant_id`.
+
+### جدول `audit_logs`
+id, tenant_id, actor_user_id, action, resource_type, resource_id,
+metadata(JSON), ip_address, user_agent, created_at.
+ایندکسها: `tenant_id`، `created_at`.
+
+### جدول `users` (OTP + لینک SSO — فاز ۳)
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| mobile | varchar(15) unique | شماره موبایل (09xxxxxxxxx) — **الزامی** |
+| keycloak_sub | varchar(100) null unique | لینک به کاربر Keycloak (SSO) |
+| email | varchar(255) null | ایمیل (اختیاری، برای لینک SSO) |
+| mobile_verified | bool | تأیید OTP — true پس از verify موفق |
+| role | enum | user / pending_tenant_admin / tenant_admin / platform_admin |
+| status | enum | active / inactive / suspended |
+| current_tenant_id | UUID null (FK→tenants, `ON DELETE SET NULL`) | tenant انتخابشده جاری کاربر — فاز ۴ |
+| created_at, updated_at | timestamptz | |
+
+ایندکسها: `mobile` (unique)، `keycloak_sub` (unique)، `status`.
+
+**نقشها بر اساس context OTP:**
+- `public` → `user` (ثبتنام عمومی)
+- `admin` → `pending_tenant_admin` (پنل ادمین tenant)
+- `PLATFORM_ADMIN_MOBILES` → `platform_admin`
+
+### جدول `tenant_memberships` (فاز ۴ — Tenant Onboarding)
+
+> **توجه:** این جدول در `core_platform_db` است و با جدول همنام در
+> `identity_access_db` (بخش ۳ همین سند) **کاملاً متفاوت و بدون ارتباط
+> مستقیم** است. جدول Core عضویت واقعی کاربر↔workspace را برای جریان
+> onboarding/authorization مدیریت میکند.
+
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| tenant_id | UUID (FK→tenants, cascade) | |
+| user_id | UUID (FK→users, cascade) | |
+| role | enum | `platform_admin` / `tenant_owner` / `tenant_admin` / `tenant_editor` / `tenant_viewer` |
+| status | enum | `active` / `invited` / `disabled` |
+| is_owner | bool | فقط یک یا چند owner فعال در هر tenant (حداقل یکی الزامی) |
+| created_at, updated_at | timestamptz | |
+
+محدودیت یکتا: `(tenant_id, user_id)`. ایندکسها: `tenant_id`، `user_id`، `role`، `status`.
+
+**قواعد:**
+- هنگام `POST /onboarding/tenant`، کاربر سازنده بهصورت خودکار عضویت
+ `tenant_owner` / `is_owner=true` / `status=active` میگیرد.
+- `POST /onboarding/tenant/{id}/complete` بررسی میکند که حداقل یک owner
+ فعال وجود داشته باشد؛ در غیر این صورت خطای `owner_required`.
+- کاربر میتواند در آینده چند عضویت (چند tenant) داشته باشد؛ `users.current_tenant_id`
+ مشخص میکند کدامیک «جاری» است.
+
+### جدول `plans` و `tenant_subscriptions` (seed پیشفرض — فاز ۴)
+ساختار جدولها از فاز ۱ بدون تغییر باقی مانده (نگاه کنید به پایین همین
+بخش)؛ در فاز ۴ موارد زیر اضافه شد:
+- Migration `0005_tenant_onboarding` دو پلن `FREE` و `STARTER` را با
+ `ON CONFLICT (code) DO NOTHING` seed میکند (idempotent).
+- `PlanService.ensure_default_plan()` همان پلن `FREE` را در زمان اجرا نیز
+ بهصورت idempotent تضمین میکند (برای محیطهای SQLite تست که migration
+ اجرا نمیشود).
+- `POST /onboarding/tenant` بهصورت خودکار یک `tenant_subscriptions` با
+ `plan=FREE`، `status=active` میسازد.
+
+## بخش ۲ — طراحی اولیه دیتابیس سرویسهای آینده (بدون migration)
+
+> اینها فقط طراحی مفهومیاند؛ در فازهای بعدی هر سرویس دیتابیس مستقل خود را
+> با ستون `tenant_id` در جداول بیزینسی خواهد داشت.
+
+- **accounting_db:** accounts (۴ سطحی)، journal_entries، journal_lines،
+ cost_centers، projects، invoices، payments، taxes، reports.
+- **crm_db:** customers، leads، opportunities، pipelines، stages، tasks،
+ activities، automations.
+- **ecommerce_db:** products، categories، inventory، orders، order_items،
+ payments، shipments، discounts، carts.
+- **website_builder_db:** sites، pages، blocks، forms، menus، media، content.
+- **live_chat_db:** widgets، conversations، messages، agents، routing_rules.
+- **ai_assistant_db:** assistants، knowledge_bases، documents، chat_sessions،
+ messages، handoffs.
+- **smart_messenger_db:** channels، accounts، threads، messages، automations.
+- **sms_panel_db:** providers، templates، campaigns، messages، send_queue.
+- **link_shortener_db:** links، domains، clicks، campaigns.
+- **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.
+
+هر سرویس دسترسی قابلیتها را از Core (`check_feature_access`) استعلام میکند و
+هرگز مستقیماً به `core_platform_db` متصل نمیشود.
+
+## بخش ۳ — دیتابیس Identity & Access (`identity_access_db`) — فاز ۲
+
+### جدول `user_profiles`
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| keycloak_sub | varchar unique | شناسه کاربر در Keycloak |
+| email | varchar | |
+| username | varchar null | |
+| display_name | varchar null | |
+| mobile | varchar(15) null unique | شماره موبایل — **الزامی برای کاربران جدید** |
+| mobile_verified | bool | تأیید OTP |
+| mobile_verified_at | timestamptz null | زمان تأیید |
+| core_user_id | UUID null | لینک به `core_platform_db.users` |
+| status | enum | active / inactive / suspended |
+| created_at, updated_at | timestamptz | |
+
+ایندکسها: `keycloak_sub` (unique)، `email`، `mobile` (unique).
+
+**قانون:** کاربر تازهثبتنامشده تا پیش از `mobile_verified=true` کامل محسوب نمیشود.
+کاربران SSO قدیمی بدون موبایل باید از `/auth/verify-mobile` تکمیل کنند.
+
+### جدول `tenant_memberships`
+
+> **توجه:** این جدول مربوط به `identity_access_db` (لایهٔ هویت SSO) است و
+> **جدول متفاوتی** نسبت به `tenant_memberships` در `core_platform_db` است که
+> در بخش ۱ (فاز ۴ — Tenant Onboarding) مستند شده. این دو جدول در دو سرویس/
+> دیتابیس جدا زندگی میکنند و sync خودکار بین آنها وجود ندارد؛ منبع حقیقت
+> (source of truth) برای نقش عملیاتی کاربر در یک workspace، جدول Core است.
+
+| ستون | نوع | توضیح |
+| --- | --- | --- |
+| id | UUID (PK) | |
+| tenant_id | UUID | مرجع به tenant در Core (بدون FK بین DB) |
+| user_id | UUID (FK→user_profiles) | |
+| role | enum | tenant_admin / tenant_member |
+| is_active | bool | |
+| joined_at | timestamptz | |
+
+محدودیت یکتا: (tenant_id, user_id). ایندکسها: `tenant_id`, `user_id`.
+
diff --git a/docs/developer_guide.md b/docs/developer_guide.md
new file mode 100644
index 0000000..85abdc1
--- /dev/null
+++ b/docs/developer_guide.md
@@ -0,0 +1,110 @@
+# راهنمای توسعهدهنده (Developer Guide)
+
+## ۱. پیشنیازها
+- Python 3.11+ (backend)
+- Node.js 18+ (frontend)
+- Docker و Docker Compose (برای اجرای کامل)
+- PostgreSQL 15+ و Redis (در صورت اجرای بدون Docker)
+
+## ۲. قانون جداسازی Frontend/Backend (اجباری)
+- تمام کد backend فقط در `backend/` قرار دارد.
+- تمام کد frontend فقط در `frontend/` قرار دارد.
+- ارتباط فقط از طریق REST API نسخهدار (`lib/api-client.ts` در frontend).
+- جزئیات در [`architecture.md`](./architecture.md) بخش ۲.۱.
+
+## ۳. راهاندازی با Docker
+```bash
+cp .env.example .env
+docker compose up -d --build
+```
+سرویسها: Core API روی `:8000`، Identity روی `:8001`، Frontend روی `:3000`،
+Keycloak روی `:8080`. همه با `docker compose up -d --build` بالا میآیند.
+
+## ۴. راهاندازی محلی — Backend
+```bash
+cd backend/core-service
+python -m venv .venv
+# Windows: .venv\Scripts\Activate.ps1 | Linux/macOS: source .venv/bin/activate
+pip install -r requirements.txt
+alembic upgrade head
+uvicorn app.main:app --reload
+```
+
+## ۵. راهاندازی محلی — Frontend
+```bash
+cd frontend
+npm install
+npm run dev
+```
+Frontend: http://localhost:3000
+
+## ۶. متغیرهای محیطی
+همه تنظیمات در `.env` هستند (نمونه: `.env.example`). هیچ مقداری در کد
+hardcode نمیشود.
+
+**Backend:** `DATABASE_URL`, `DATABASE_URL_SYNC`, `REDIS_URL`, `CELERY_BROKER_URL`,
+`KEYCLOAK_*`, `INTERNAL_TOKEN_SECRET`, `PLATFORM_*`.
+
+**Frontend:** `NEXT_PUBLIC_API_BASE_URL` (پیشفرض: `http://localhost:8000`).
+
+## ۷. ساختار backend
+```
+backend/
+├── core-service/app/
+│ ├── api/v1/ # routerها
+│ ├── core/ # config, database, cache, security, logging
+│ ├── models/ # مدلهای SQLAlchemy
+│ ├── schemas/ # اسکیماهای Pydantic
+│ ├── services/ # منطق کسبوکار
+│ ├── repositories/ # دسترسی به داده
+│ ├── middlewares/ # تشخیص tenant
+│ ├── workers/ # Celery
+│ └── tests/ # تستها
+├── shared-lib/ # کتابخانه مشترک backend
+└── services/ # placeholder سرویسهای آینده
+```
+
+## ۸. ساختار frontend
+```
+frontend/
+├── app/ # صفحات Next.js (App Router)
+├── components/ # کامپوننتهای UI
+├── hooks/ # React hooks
+├── lib/ # API client، theme
+├── styles/ # CSS global
+└── public/ # فایلهای استاتیک
+```
+
+## ۹. Migrations (Alembic)
+```bash
+cd backend/core-service
+alembic revision --autogenerate -m "توضیح"
+alembic upgrade head
+```
+
+## ۱۰. اجرای تستها (Backend)
+```bash
+cd backend/core-service
+pytest -q
+```
+
+## ۱۱. Celery
+```bash
+cd backend/core-service
+celery -A app.workers.celery_app.celery_app worker -Q default,core_events,notifications,webhooks
+celery -A app.workers.celery_app.celery_app beat
+```
+
+## ۱۲. الگوهای کدنویسی
+- نام فایلها/کلاسها/توابع/routeها/migrationها انگلیسی استاندارد.
+- کامنتها میتوانند فارسی باشند.
+- منطق کسبوکار فقط در backend (`services/`).
+- UI فقط در frontend (`components/`, `app/`).
+- خطاها از `shared.exceptions` استفاده کنند.
+
+## ۱۳. افزودن سرویس جدید (فازهای بعدی)
+1. پوشه سرویس را در `backend/services/` توسعه دهید.
+2. دیتابیس مستقل با `tenant_id` بسازید.
+3. قابلیتها را در Core ثبت کنید.
+4. UI مربوطه را در `frontend/` بسازید (فقط از API استفاده کند).
+5. ارتباط را فقط از طریق API/Event برقرار کنید.
diff --git a/docs/last_step.md b/docs/last_step.md
new file mode 100644
index 0000000..898a57a
--- /dev/null
+++ b/docs/last_step.md
@@ -0,0 +1,86 @@
+# آخرین گام انجامشده
+
+## فاز ۴ — Tenant Onboarding و Workspace Activation
+
+> در بریف پروژه این کار «Phase 3: operationalizing tenant onboarding and
+> workspace activation» نامیده شده؛ در شمارهگذاری داخلی مستندات، چون «فاز
+> ۳» قبلاً به OTP Login + Tenant Management اختصاص داشت، این تحویل **فاز ۴**
+> ثبت شده است (نگاه کنید به `progress.md`).
+
+### مسئله
+تا پیش از این فاز، سیستم فقط «هویت + لیست ادمین» بود: کاربر با OTP/SSO وارد
+میشد و از پنل ادمین tenantها را میدید/میساخت، اما هیچ جریان واقعی
+self-service برای اینکه یک کاربر عادی workspace خودش را بسازد، مالک آن شود،
+پلن/دامنه/برندینگ بگیرد و وارد یک داشبورد عملیاتی شود، وجود نداشت.
+
+### راهحل
+
+**Backend (`core-service`):**
+- جدول جدید `tenant_memberships` (نقش `tenant_owner/tenant_admin/tenant_editor/tenant_viewer/platform_admin`،
+ وضعیت `active/invited/disabled`، `is_owner`).
+- چرخهٔ عمر tenant: `draft → pending_activation → active → suspended/archived`.
+- ستونهای برندینگ/پروفایل روی `tenants` + `is_primary`/`verification_status`
+ روی `domains` + `current_tenant_id` روی `users`.
+- Seed پلنهای `FREE`/`STARTER` و اتصال خودکار اشتراک `FREE` هنگام ساخت tenant.
+- سرویسهای جدید: `UserService` (یکپارچهسازی resolve کاربر از JWT محلی یا
+ کیکلوک)، `MembershipService`، `OnboardingService`، `TenantContextService`.
+- APIهای جدید: `GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`,
+ `PATCH .../branding`, `PATCH .../domain`, `POST .../complete`,
+ `GET /tenant/current`, `POST /tenant/switch`.
+- Migration `0005_tenant_onboarding` + تستهای `test_onboarding.py` (۷ تست،
+ جریان کامل + حالات forbidden/duplicate/owner-required).
+
+**Frontend (`frontend`):**
+- `lib/api.ts` با `api.me` / `api.onboarding` / `api.tenantContext`.
+- `hooks/useMe.ts` برای بارگذاری `/api/v1/me`.
+- `app/onboarding/page.tsx` — ویزارد ۴ مرحلهای (کسبوکار → برندینگ → دامنه
+ → بازبینی) با قابلیت ازسرگیری onboarding ناتمام.
+- `app/dashboard/page.tsx` — داشبورد واقعی workspace + redirect خودکار به
+ `/onboarding` وقتی `onboarding_required=true`.
+- `components/TenantSwitcher.tsx` — سوییچر ساده چند-workspace.
+
+### محدودیتهای عمدی این فاز (خارج از scope)
+- بدون درگاه پرداخت — فقط ساختار provisioning پلن/اشتراک.
+- بدون تأیید واقعی DNS برای دامنهٔ اختصاصی (فقط رکورد `pending`).
+- مسیر قدیمی `POST /admin/tenants` (فاز ۱/۲) بازنویسی نشده و هنوز عضویت/پلن
+ خودکار نمیسازد — عمداً دستنخورده ماند تا فازهای قبلی بازسازی نشوند.
+- JIT provisioning کامل کاربر Core از JWT کیکلوک پیاده نشده (کاربر SSO بدون
+ رکورد Core فعلاً `403 forbidden` میگیرد؛ نگاه کنید به بخش بعد).
+
+---
+
+## فاز پیشنهادی بعدی: White-label Runtime Rendering
+
+### چرا این فاز و نه یک ماژول بیزینسی؟
+در همین فاز، فیلدهای برندینگ واقعی (`primary_color`, `secondary_color`,
+`logo_url`, `favicon_url`) روی هر tenant ذخیره و در onboarding از کاربر
+گرفته میشوند — اما frontend فعلاً رنگها را فقط از یک فایل استاتیک
+(`public/theme.config.json`) میخواند و **هیچ ارتباطی با tenant واقعی
+resolveشده ندارد**. یعنی خروجی onboarding (برندینگ tenant) هنوز در UI
+اعمال نمیشود. تکمیل این حلقه (ذخیره برند → نمایش برند) از نظر ارزش محصول
+و آمادگی فنی، اولویت بالاتری نسبت به شروع اولین ماژول بیزینسی (Accounting/
+CRM/...) دارد؛ چون:
+1. زیرساخت لازم (فیلدهای دیتابیس، APIهای `TenantContextRead`، CSS
+ Variables موجود در frontend) از قبل آماده است — فقط باید به هم وصل شوند.
+2. بدون این حلقه، ادعای «SaaS چندمستأجری white-label» ناقص میماند، در حالی
+ که هر ماژول بیزینسی جدید (Accounting/CRM/...) به خودی خود به این زیرساخت
+ وابسته نیست.
+3. حل تشخیص tenant از دامنه (subdomain/custom domain) که برای white-label
+ لازم است، پیشنیاز طبیعی معماری چندمستأجری برای هر ماژول آینده نیز هست.
+
+### پیشنهاد Scope فاز بعدی
+1. **Backend:** endpoint عمومی (بدون نیاز به auth کاربر) برای resolve
+ theme بر اساس Host/X-Tenant-Slug، مثلاً
+ `GET /api/v1/public/tenant-theme` که `primary_color`, `secondary_color`,
+ `logo_url`, `favicon_url`, `business_name` را برمیگرداند (از همان
+ middleware تشخیص tenant موجود استفاده کند).
+2. **Frontend:** بارگذاری theme از این endpoint در `ThemeProvider` هنگام
+ دسترسی از subdomain/custom domain یک tenant (بهجای/علاوهبر
+ `theme.config.json` استاتیک فعلی که برای برند اصلی پلتفرم باقی میماند).
+3. مسیر عمومی مشتری/ویزیتور tenant (بدون نیاز به لاگین) که برند tenant را
+ میبیند — زیرساخت لازم برای هر ماژول بیزینسی آینده (مثلاً منوی دیجیتال
+ رستوران) که باید در دامنهٔ tenant با ظاهر آن tenant نمایش داده شود.
+4. بعد از این فاز، اولین ماژول بیزینسی واقعی (پیشنهاد: «رستوران / منوی
+ دیجیتال» چون در `docs/architecture.md` بهعنوان سرویس فعلی نامبرده شده و
+ دیتابیس مفهومی آن (`restaurant_db`) از قبل طراحی شده) روی همین زیرساخت
+ white-label ساخته شود.
diff --git a/docs/progress.md b/docs/progress.md
new file mode 100644
index 0000000..b34595a
--- /dev/null
+++ b/docs/progress.md
@@ -0,0 +1,174 @@
+# پیشرفت پروژه (Progress)
+
+## فاز ۱ — Core Platform ✅
+
+### زیرساخت و ساختار
+- [x] ساختار کامل پروژه با جداسازی اجباری `backend/` و `frontend/`
+- [x] کتابخانه مشترک `backend/shared-lib`
+- [x] بخش ۲.۱ معماری: Frontend & Backend Separation
+
+### سرویس هسته (core-service)
+- [x] پیکربندی، دیتابیس، cache، logging، security پایه
+- [x] مدلها، APIها، Entitlement، Celery، Outbox
+- [x] تستها و مستندات
+
+### Frontend
+- [x] Next.js مستقل، API Client، White-label theme
+
+---
+
+## فاز ۲ — Identity & Access + SSO ✅
+
+### shared-lib
+- [x] `shared/auth/jwt.py` — JWTValidator مشترک
+- [x] `shared/auth/roles.py` — نقشهای استاندارد پلتفرم
+- [x] رویدادهای Identity در `shared/events.py`
+
+### Identity & Access Service
+- [x] سرویس مستقل در `backend/services/identity-access/`
+- [x] دیتابیس `identity_access_db` (database-per-service)
+- [x] مدلها: UserProfile, TenantMembership
+- [x] Keycloak Admin Client + token exchange (BFF)
+- [x] APIها: `/auth/config`, `/auth/token`, `/auth/me`, `/users`, `/tenants/{id}/members`
+- [x] Alembic migration + Dockerfile
+- [x] تستهای پایه
+
+### Keycloak
+- [x] Realm import: `infrastructure/keycloak/realm/superapp-realm.json`
+- [x] Clients: superapp-frontend, core-service, identity-access-service
+- [x] نقشها و کاربر admin نمونه
+- [x] docker-compose با `--import-realm`
+
+### محافظت APIهای Core
+- [x] `AUTH_REQUIRED` قابل تنظیم از env
+- [x] `require_platform_admin`, `require_tenant_admin`, `require_authenticated`
+- [x] اعمال روی همه endpointهای مدیریتی
+
+### Frontend SSO
+- [x] `lib/auth.ts` — OIDC flow
+- [x] صفحات `/login`, `/auth/callback`, `/dashboard`
+- [x] `AuthGuard` و `useAuth` hook
+- [x] API Client با Bearer token
+
+### زیرساخت
+- [x] `identity-access-service` در docker-compose (پورت 8001)
+- [x] `frontend` در docker-compose (پورت 3000، dev + hot-reload)
+- [x] ارتقای Next.js به `15.5.18` (پچ امنیتی — نسخه 14 دیگر پشتیبانی امنیتی ندارد)
+- [x] `infrastructure/postgres/init-dbs.sql` برای ساخت identity_access_db
+- [x] بهروزرسانی `.env.example`
+
+### مستندات
+- [x] architecture.md (بخش SSO فاز ۲)
+- [x] database_schema.md (identity_access_db)
+- [x] services_contracts.md (APIهای Identity)
+- [x] progress.md, last_step.md
+
+---
+
+## فاز ۳ — OTP Login + Tenant Management ✅
+
+### Backend (core-service)
+- [x] مدل `users` و migration Alembic (`0002_users`)
+- [x] OTP request/verify: `/api/v1/auth/otp/request`, `/api/v1/auth/otp/verify`
+- [x] ثبتنام خودکار کاربر + audit log
+- [x] یکپارچگی Payamak-Panel SendOtp
+- [x] JWT محلی (HS256) برای کاربران OTP
+- [x] Admin Tenant CRUD: `/api/v1/admin/tenants` با owner mapping
+- [x] فیلتر لیست tenant بر اساس مالک (Platform Admin: همه / Tenant Admin: فقط مالک)
+
+### Frontend
+- [x] `lib/api.ts` — API Client یکپارچه با Bearer token
+- [x] `/admin/login` — جریان OTP دو مرحلهای با countdown
+- [x] `/admin/tenants` — داشبورد لیست tenant
+- [x] `/admin/tenants/new` — فرم ایجاد tenant
+
+### تستها
+- [x] `test_otp_auth.py` — OTP flow و tenant با JWT
+
+### مستندات
+- [x] architecture.md (بخش OTP)
+- [x] progress.md, last_step.md
+- [x] `.env.example` (Payamak + NEXT_PUBLIC_BACKEND_URL)
+
+---
+
+## فاز ۴ — Tenant Onboarding و Workspace Activation ✅
+
+> در بریف پروژه این فاز با عنوان **«Phase 3: operationalizing tenant
+> onboarding and workspace activation»** معرفی شده است. چون در شمارهگذاری
+> داخلی این سند «فاز ۳» قبلاً به OTP Login + Tenant Management اختصاص
+> یافته بود، این تحویل بهعنوان فاز ۴ ثبت میشود (محتوا دقیقاً همان
+> «Phase 3» بریف است).
+
+### Backend (core-service)
+- [x] مدل `TenantMembership` + جدول `tenant_memberships` (نقش/وضعیت/owner)
+- [x] چرخهٔ عمر tenant: `draft` / `pending_activation` / `active` /
+ `suspended` / `archived`
+- [x] پروفایل/برندینگ روی `tenants`: `business_type`, `default_locale`,
+ `timezone`, `primary_color`, `secondary_color`, `logo_url`,
+ `favicon_url`, `onboarding_completed`
+- [x] `domains.is_primary` و `domains.verification_status`
+- [x] `users.current_tenant_id` (tenant انتخابشده جاری)
+- [x] Seed پلنهای پیشفرض `FREE`/`STARTER` (migration + `ensure_default_plan`)
+- [x] `UserService.resolve_current` — یکپارچهسازی resolve کاربر Core از JWT
+ محلی (OTP) یا JWT کیکلوک (SSO، بر اساس `keycloak_sub`)
+- [x] `MembershipService` — عضویت owner، لیست عضویتها، `ensure_role`
+ (authorization سبک با bypass برای `platform_admin`)
+- [x] `OnboardingService` — ایجاد tenant، برندینگ، دامنه، تکمیل/فعالسازی
+- [x] `TenantContextService` — ساخت `TenantContextRead` کامل برای frontend
+- [x] APIهای جدید: `GET /me`, `GET /me/tenants`, `POST /onboarding/tenant`,
+ `PATCH /onboarding/tenant/{id}/branding`,
+ `PATCH /onboarding/tenant/{id}/domain`,
+ `POST /onboarding/tenant/{id}/complete`, `GET /tenant/current`,
+ `POST /tenant/switch`
+- [x] Migration `0005_tenant_onboarding` (ستونها، جدول جدید، seed پلنها)
+- [x] `PLATFORM_BASE_DOMAIN` در config برای تخصیص خودکار زیردامنه
+
+### تستها
+- [x] `app/tests/test_onboarding.py` — جریان کامل onboarding (ساخت tenant،
+ برندینگ، دامنه، تکمیل، `/me`, `/tenant/current`, `/tenant/switch`) +
+ حالات forbidden/duplicate/owner-required
+- [x] کل test suite موجود (`test_otp_auth`, `test_tenants`, `test_domains`, ...)
+ بدون رگرسیون اجرا شد
+
+### Frontend
+- [x] `lib/api.ts` — انواع و متدهای `api.me`, `api.onboarding`,
+ `api.tenantContext` (بدون تغییر ساختار قبلی `api.tenants`/`api.auth`)
+- [x] `hooks/useMe.ts` — بارگذاری `/api/v1/me`
+- [x] `app/onboarding/page.tsx` — ویزارد تکصفحهای ۴ مرحلهای (کسبوکار →
+ برندینگ → دامنه → بازبینی) با قابلیت ازسرگیری onboarding ناتمام
+- [x] `app/dashboard/page.tsx` — داشبورد واقعی workspace (نام، وضعیت، پلن،
+ دامنه، وضعیت onboarding، نقش کاربر) + redirect خودکار به `/onboarding`
+ در صورت نیاز
+- [x] `components/TenantSwitcher.tsx` — سوییچر ساده (فقط با بیش از یک
+ عضویت نمایش داده میشود)
+- [x] Type-check تمیز (`tsc --noEmit`)
+
+### زیرساخت
+- [x] `PLATFORM_BASE_DOMAIN` در `.env` و `.env.example`
+
+### مستندات
+- [x] architecture.md (بخش ۱۱ — فاز ۴)
+- [x] database_schema.md (`tenant_memberships` جدید Core، ستونهای جدید
+ `tenants`/`domains`/`users`، توضیح تمایز با جدول همنام Identity)
+- [x] services_contracts.md (بخش ۷ — Onboarding و Tenant Context)
+- [x] progress.md, last_step.md
+
+### محدودیتهای شناختهشده (عمداً خارج از این فاز)
+- [ ] درگاه پرداخت واقعی برای اشتراک (فقط ساختار provisioning پیاده شده)
+- [ ] تأیید واقعی دامنهٔ اختصاصی (DNS/TXT) — فعلاً فقط `pending`
+- [ ] tenantهای ساختهشده از مسیر قدیمی `POST /admin/tenants` عضویت/پلن
+ خودکار نمیگیرند (آن مسیر به فاز ۱/۲ تعلق دارد و در این فاز بازنویسی
+ نشده)
+- [ ] JIT provisioning کامل کاربر Core از JWT کیکلوک (کاربر SSO بدون رکورد
+ Core فعلاً خطای `forbidden` میگیرد)
+
+## فازهای بعدی (Backlog)
+- [ ] پیادهسازی اولین ماژول بیزینسی واقعی (نگاه کنید به `last_step.md`)
+- [ ] White-label runtime rendering (تشخیص tenant از دامنه + اعمال برند در frontend)
+- [ ] JIT provisioning کامل کاربر Core از SSO کیکلوک
+- [ ] Permission management پیشرفته (نقشهای سفارشی، دعوت اعضا)
+- [ ] تأیید واقعی دامنهٔ اختصاصی (DNS/TXT) و درگاه پرداخت
+- [ ] Subscription & Entitlement بهعنوان سرویس مستقل
+- [ ] Message bus واقعی برای انتشار رویدادها
+- [ ] Reverse proxy (Nginx/Traefik) و TLS
diff --git a/docs/services_contracts.md b/docs/services_contracts.md
new file mode 100644
index 0000000..86f3ec1
--- /dev/null
+++ b/docs/services_contracts.md
@@ -0,0 +1,270 @@
+# قراردادهای ارتباطی سرویسها (Service Contracts)
+
+## ۱. قوانین بنیادی
+1. **ارتباط مستقیم بین دیتابیس سرویسها ممنوع است.** هیچ سرویسی نباید به
+ دیتابیس سرویس دیگر کوئری بزند.
+2. هر سرویس فقط دیتابیس خودش را میشناسد (Database-per-service).
+3. ارتباط بین سرویسها فقط از این چهار راه:
+ - **REST API** (همزمان)
+ - **Webhook** (اعلان به بیرون)
+ - **Async Event** (message bus)
+ - **Outbox/Inbox Pattern** (تحویل قابل اعتماد رویداد)
+4. همه درخواستهای tenant-aware باید `tenant_id` را حمل کنند
+ (هدر `X-Tenant-ID` یا داخل توکن/رویداد).
+
+## ۲. احراز هویت بین سرویسها
+- سرویسها با **Internal Service Token** یکدیگر را احراز میکنند.
+- توکن بهصورت hash در `internal_service_tokens` ذخیره میشود (خود توکن ذخیره نمیشود).
+- هر توکن دارای `scopes` است؛ سرویس فراخوان باید scope لازم را داشته باشد.
+- کاربر نهایی با JWT کیکلوک احراز میشود.
+
+## ۳. قرارداد رویداد (Event Envelope)
+همه رویدادها با ساختار مشترک `shared.events.EventEnvelope` منتشر میشوند:
+```json
+{
+ "event_id": "uuid",
+ "event_type": "tenant.created",
+ "aggregate_type": "tenant",
+ "aggregate_id": "uuid",
+ "tenant_id": "uuid|null",
+ "source_service": "core-service",
+ "payload": { },
+ "occurred_at": "ISO-8601"
+}
+```
+
+### جریان Outbox → Inbox
+1. سرویس مبدأ رویداد را در `outbox_events` (در همان تراکنش) ذخیره میکند.
+2. worker رویدادهای `pending` را منتشر و `processed` میکند.
+3. سرویس مقصد رویداد را در `inbox_events` ثبت میکند (بر اساس `event_id`
+ برای idempotency) و سپس پردازش میکند.
+
+### رویدادهای شناختهشده Core (نمونه)
+| event_type | aggregate | توضیح |
+| --- | --- | --- |
+| tenant.created | tenant | ساخت مستأجر جدید |
+| tenant.suspended | tenant | تعلیق مستأجر |
+| tenant.activated | tenant | فعالسازی مستأجر |
+| domain.created | domain | افزودن دامنه |
+| subscription.created | subscription | ایجاد اشتراک |
+| subscription.updated | subscription | تغییر اشتراک |
+| feature_access.changed | feature_access | تغییر دسترسی قابلیت |
+
+## ۴. قرارداد Entitlement (بررسی دسترسی)
+سرویسهای دیگر پیش از اجرای یک قابلیت، دسترسی را از Core استعلام میکنند:
+
+```
+POST /api/v1/tenants/{tenant_id}/features/check
+{ "feature_key": "accounting.invoice.create" }
+
+→ { "tenant_id": "...", "feature_key": "...", "has_access": true, "reason": "plan_enabled" }
+```
+
+قرارداد نامگذاری `feature_key`: `{service_key}.{resource}.{action}`
+مثال: `crm.lead.create`, `ecommerce.product.create`.
+
+## ۵. APIهای Core Platform (فاز ۱)
+| گروه | متد و مسیر |
+| --- | --- |
+| Health | `GET /health` |
+| Tenants | `POST/GET /api/v1/tenants`، `GET/PATCH /api/v1/tenants/{id}`، `POST .../suspend`، `POST .../activate` |
+| Domains | `POST/GET /api/v1/tenants/{id}/domains`، `POST /api/v1/domains/resolve` |
+| Plans & Features | `POST/GET /api/v1/plans`، `POST/GET /api/v1/features`، `POST /api/v1/plans/{id}/features` |
+| Subscription | `POST/GET /api/v1/tenants/{id}/subscription`، `POST /api/v1/tenants/{id}/features/check` |
+| Service Registry | `POST/GET /api/v1/services`، `PATCH /api/v1/services/{id}/status` |
+
+## ۶. قالب پاسخ استاندارد
+- خطاها با قالب `shared.responses.ErrorResponse` برگردانده میشوند:
+```json
+{ "success": false, "error": { "code": "not_found", "message": "...", "details": null } }
+```
+- لیستها با قالب صفحهبندی `Page` (شامل `items` و `meta`).
+
+## ۷. Onboarding و Tenant Context (فاز ۴ — Tenant Onboarding و Workspace Activation)
+
+> در بریف پروژه این مجموعه API با عنوان «Phase 3» شناخته میشود؛ در
+> شمارهگذاری داخلی مستندات فاز ۴ است (نگاه کنید به `progress.md`).
+
+همهٔ endpointهای این بخش زیر `API_V1_PREFIX` (پیشفرض `/api/v1`) هستند و به
+احراز هویت نیاز دارند (`Authorization: Bearer ` — چه JWT محلی OTP و
+چه JWT کیکلوک SSO؛ هر دو با `get_current_core_user` resolve میشوند).
+
+### `GET /api/v1/me`
+وضعیت کاربر جاری، عضویتها و نیاز به onboarding.
+
+**پاسخ ۲۰۰:**
+```json
+{
+ "user_id": "uuid",
+ "mobile": "09xxxxxxxxx",
+ "email": null,
+ "platform_role": "user",
+ "onboarding_required": true,
+ "current_tenant_id": null,
+ "memberships": [
+ {
+ "tenant_id": "uuid", "tenant_name": "...", "tenant_slug": "...",
+ "tenant_status": "active", "onboarding_completed": true,
+ "role": "tenant_owner", "status": "active", "is_owner": true
+ }
+ ]
+}
+```
+`onboarding_required=true` اگر کاربر هیچ عضویتی نداشته باشد یا tenant جاری
+هنوز `onboarding_completed=false` باشد.
+
+خطاها: `401 unauthorized` (بدون توکن)، `403 forbidden` (JWT کیکلوک بدون
+لینک به رکورد Core — نگاه کنید به `architecture.md`).
+
+### `GET /api/v1/me/tenants`
+آرایهای از `MembershipSummary` (همان ساختار `memberships` بالا) برای همهٔ
+tenantهای در دسترس کاربر جاری.
+
+### `POST /api/v1/onboarding/tenant`
+ساخت tenant جدید توسط کاربر جاری (بدون نیاز به عضویت قبلی).
+
+**بدنه درخواست:**
+```json
+{
+ "business_name": "کافه تربت",
+ "slug": "cafe-torbat",
+ "business_type": "cafe",
+ "default_locale": "fa-IR",
+ "timezone": "Asia/Tehran"
+}
+```
+`business_name` و `slug` الزامی؛ بقیه اختیاری (پیشفرضها بالا). `slug` باید
+فقط حروف کوچک انگلیسی/اعداد/خطتیره باشد.
+
+**رفتار:** ساخت tenant با `status=pending_activation` → ساخت عضویت
+`tenant_owner`/`is_owner=true` برای کاربر جاری → اتصال پلن پیشفرض `FREE`
+با `tenant_subscriptions.status=active` → (در صورت تنظیم
+`PLATFORM_BASE_DOMAIN`) ساخت زیردامنهٔ `{slug}.{base_domain}` با
+`verification_status=verified` → اگر کاربر `current_tenant_id` نداشت، همین
+tenant بهعنوان جاری تنظیم میشود.
+
+**پاسخ ۲۰۱:** شیء `TenantContextRead` (نگاه کنید پایین).
+
+خطاها: `409 slug_taken` (slug تکراری در کل پلتفرم)، `401`/`403` مشابه بالا.
+
+### `PATCH /api/v1/onboarding/tenant/{tenant_id}/branding`
+```json
+{ "primary_color": "#112233", "secondary_color": "#445566", "logo_url": null, "favicon_url": null }
+```
+همهٔ فیلدها اختیاریاند؛ فقط مقادیر ارسالشده بهروزرسانی میشوند.
+نیازمند نقش `tenant_owner`/`tenant_admin` روی tenant مقصد (یا
+`platform_admin`). پاسخ ۲۰۰: `TenantContextRead`.
+
+خطا: `403 forbidden` (بدون نقش کافی).
+
+### `PATCH /api/v1/onboarding/tenant/{tenant_id}/domain`
+```json
+{ "custom_domain": "cafe.example.com" }
+```
+اگر `custom_domain` ارسال شود، رکورد `domains` جدید با
+`domain_type=custom_domain`، `is_primary=false`،
+`verification_status=pending` ساخته میشود. پاسخ ۲۰۰: `TenantContextRead`.
+
+خطاها: `409 domain_taken` (دامنه تکراری در کل پلتفرم)، `403 forbidden`.
+
+### `POST /api/v1/onboarding/tenant/{tenant_id}/complete`
+بدون بدنه. اعتبارسنجی نهایی: نام/slug موجود باشد و حداقل یک owner فعال
+داشته باشد؛ در صورت موفقیت `onboarding_completed=true` و
+`status=active` میشود. پاسخ ۲۰۰: `TenantContextRead`.
+
+خطاها: `422 onboarding_incomplete` (نام/slug ناقص)، `422 owner_required`
+(بدون owner فعال)، `403 forbidden` (بدون نقش کافی).
+
+### `GET /api/v1/tenant/current`
+tenant جاری کاربر را برمیگرداند (بر اساس `current_tenant_id` یا اولین
+عضویت در نبود آن). پاسخ ۲۰۰: `TenantContextRead`.
+
+خطا: `404 not_found` (کاربر هیچ عضویتی ندارد).
+
+### `POST /api/v1/tenant/switch`
+```json
+{ "tenant_id": "uuid" }
+```
+`current_tenant_id` کاربر را به این tenant تغییر میدهد (فقط اگر عضویت
+`active` روی آن داشته باشد). پاسخ ۲۰۰: `TenantContextRead`.
+
+خطا: `403 forbidden` (عضو نیست یا عضویت غیرفعال).
+
+### ساختار مشترک `TenantContextRead`
+```json
+{
+ "tenant": { "id": "...", "name": "...", "slug": "...", "status": "active",
+ "business_type": null, "default_locale": "fa-IR", "timezone": "Asia/Tehran",
+ "primary_color": "#0284c7", "secondary_color": "#0f172a",
+ "logo_url": null, "favicon_url": null, "onboarding_completed": true,
+ "created_at": "...", "updated_at": "..." },
+ "role": "tenant_owner",
+ "is_owner": true,
+ "plan_code": "FREE",
+ "plan_name": "رایگان",
+ "subscription_status": "active",
+ "domains": [ { "id": "...", "tenant_id": "...", "domain": "cafe-torbat.example.com",
+ "domain_type": "subdomain", "is_verified": true, "verified_at": null,
+ "created_at": "..." } ],
+ "primary_domain": "cafe-torbat.example.com"
+}
+```
+
+## ۸. Identity & Access Service (فاز ۲)
+
+Base URL: `http://identity-access-service:8001` (Docker) یا `http://localhost:8001`
+
+| گروه | متد و مسیر | احراز هویت |
+| --- | --- | --- |
+| Health | `GET /health` | عمومی |
+| Auth Config | `GET /api/v1/auth/config` | عمومی |
+| Login URL | `GET /api/v1/auth/login-url` | عمومی |
+| Token Exchange | `POST /api/v1/auth/token` | عمومی (code) |
+| Current User | `GET /api/v1/auth/me` | JWT |
+| Register (SSO+mobile) | `POST /api/v1/auth/register` | عمومی |
+| Verify register mobile | `POST /api/v1/auth/register/verify-mobile` | عمومی |
+| Register mobile only | `POST /api/v1/auth/register/mobile` | عمومی |
+| Complete mobile register | `POST /api/v1/auth/register/mobile/verify` | عمومی |
+| **Unified mobile start** | `POST /api/v1/auth/mobile/start` | عمومی |
+| **Unified mobile complete** | `POST /api/v1/auth/mobile/complete` | عمومی |
+| **Session handoff redeem** | `POST /api/v1/auth/session/redeem` | عمومی (یکبارمصرف) |
+| SSO mobile verify request | `POST /api/v1/auth/mobile/request` | JWT |
+| SSO mobile verify | `POST /api/v1/auth/mobile/verify` | JWT |
+| Users | `POST /api/v1/users` | platform_admin |
+| Members | `POST/GET /api/v1/tenants/{id}/members` | platform_admin |
+
+### جریان SSO (Frontend)
+1. `GET /api/v1/auth/login-url` → redirect به Keycloak
+2. Callback با `code` → `POST /api/v1/auth/token`
+3. `GET /api/v1/auth/me` → اگر `requires_mobile_verification=true` → `/auth/verify-mobile`
+4. استفاده از `access_token` در هدر `Authorization: Bearer ...`
+
+### ورود مرکزی (Keycloak — تنها UX کاربر)
+1. همه لینکهای «ورود» → `GET /auth/login-url` → Keycloak
+2. تب موبایل در Keycloak → `mobile/start` + `mobile/complete?create_handoff=true`
+3. `GET /auth/callback?handoff=...` → `POST /session/redeem` → dashboard
+
+### ورود/ثبتنام یکپارچه (API)
+1. `POST /auth/mobile/start` با `{ mobile }` → `{ intent: "login"|"register", expires_in }`
+2. کاربر OTP را وارد میکند
+3. `POST /auth/mobile/complete` با `{ mobile, code, display_name?, email?, username?, password? }`
+ - اگر `intent=login`: فقط mobile + code
+ - اگر `intent=register`: فیلدهای پروفایل الزامی (یا ثبتنام ساده بدون آنها برای موبایل-only)
+4. پاسخ: `AuthSessionResponse` (access_token + refresh_token) → redirect به `/dashboard`
+
+### ثبتنام legacy
+- **SSO + موبایل:** `POST /register` → OTP → `POST /register/verify-mobile`
+- **فقط موبایل:** `POST /register/mobile` → OTP → `POST /register/mobile/verify`
+- Identity برای OTP به Core متصل میشود: `POST core /api/v1/auth/otp/*` با `context=public`
+
+### رویدادهای Identity
+| event_type | توضیح |
+| --- | --- |
+| user.registered | ثبت کاربر جدید |
+| tenant_member.added | افزودن عضو به tenant |
+| tenant_member.removed | حذف عضو از tenant |
+
+## ۹. Service Registry
+سرویسهای داخلی آینده باید خود را در `service_registry` ثبت کنند
+(`service_key`, `base_url`, `health_check_url`, `status`) تا discovery و
+health-check ممکن باشد.
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..b696be5
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,6 @@
+node_modules
+.next
+.git
+*.log
+.env
+.env.local
diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev
new file mode 100644
index 0000000..9d36ffc
--- /dev/null
+++ b/frontend/Dockerfile.dev
@@ -0,0 +1,15 @@
+# Frontend — Development image (hot-reload)
+FROM node:20-alpine
+
+WORKDIR /app
+
+# وابستگیها ابتدا کپی میشوند تا لایه cache بهتر کار کند.
+COPY package.json ./
+RUN npm install
+
+COPY . .
+
+EXPOSE 3000
+
+# در Docker باید روی 0.0.0.0 گوش دهد تا از host قابل دسترسی باشد.
+CMD ["npm", "run", "dev"]
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..be6065e
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,43 @@
+# Frontend (Next.js)
+
+اپلیکیشن frontend کاملاً جدا از backend است.
+
+## مسئولیتها
+- UI، Dashboard، Forms، Pages، Components
+- State Management، API Client، Theme (White-label)
+
+## قوانین
+- **هیچ** کد FastAPI، SQLAlchemy، Alembic یا Business Logic در این پوشه نیست.
+- ارتباط با backend **فقط** از طریق REST API (`lib/api-client.ts`).
+- برند از `public/theme.config.json` یا API خوانده میشود (بدون hardcode).
+
+## راهاندازی
+
+**پیشنهادی (همراه کل پلتفرم):**
+```bash
+# از ریشه پروژه
+docker compose up -d --build
+```
+
+**اجرای مستقیم (دیباگ UI):**
+```bash
+cd frontend
+npm install
+npm run dev
+```
+
+Frontend: http://localhost:3000
+
+## تکنولوژی
+- Next.js **15.5.18** (پچ امنیتی — نسخه 14 دیگر پشتیبانی CVE ندارد)
+- React 19، TypeScript، TailwindCSS
+## ساختار
+```
+frontend/
+├── app/ # صفحات و layout (Next.js App Router)
+├── components/ # کامپوننتهای UI
+├── hooks/ # React hooks
+├── lib/ # API client، theme، utilities
+├── styles/ # CSS global و Tailwind
+└── public/ # فایلهای استاتیک و theme.config.json
+```
diff --git a/frontend/app/admin/features/page.tsx b/frontend/app/admin/features/page.tsx
new file mode 100644
index 0000000..81a28dc
--- /dev/null
+++ b/frontend/app/admin/features/page.tsx
@@ -0,0 +1,179 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { api, ApiError, type Feature } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ StatusBadge,
+ TextField,
+ TextareaField,
+} from "@/components/admin/controls";
+
+export default function AdminFeaturesPage() {
+ const [features, setFeatures] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [open, setOpen] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const page = await api.features.list(1, 100);
+ setFeatures(page.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری قابلیتها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
setOpen(true)}>قابلیت جدید}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : features.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ کلید |
+ سرویس |
+ وضعیت |
+
+
+
+ {features.map((f) => (
+
+ | {f.name} |
+
+ {f.feature_key}
+ |
+
+ {f.service_key}
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setOpen(false)}
+ onCreated={() => {
+ setOpen(false);
+ void load();
+ }}
+ />
+
+ );
+}
+
+function CreateFeatureModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [featureKey, setFeatureKey] = useState("");
+ const [name, setName] = useState("");
+ const [serviceKey, setServiceKey] = useState("");
+ const [description, setDescription] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSaving(true);
+ try {
+ await api.features.create({
+ feature_key: featureKey.trim(),
+ name: name.trim(),
+ service_key: serviceKey.trim(),
+ description: description.trim() || null,
+ });
+ setFeatureKey("");
+ setName("");
+ setServiceKey("");
+ setDescription("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ایجاد قابلیت ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx
new file mode 100644
index 0000000..6fc8bbe
--- /dev/null
+++ b/frontend/app/admin/layout.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { ReactNode } from "react";
+import { usePathname } from "next/navigation";
+import { AdminAuthGuard } from "@/components/AdminAuthGuard";
+import { AdminShell } from "@/components/admin/AdminShell";
+
+export default function AdminLayout({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+
+ // صفحه ورود ادمین فقط redirect است و نباید داخل shell/guard قرار گیرد.
+ if (pathname === "/admin/login") {
+ return <>{children}>;
+ }
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/app/admin/login/page.tsx b/frontend/app/admin/login/page.tsx
new file mode 100644
index 0000000..96a0c56
--- /dev/null
+++ b/frontend/app/admin/login/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { useEffect } from "react";
+import { loginWithRedirect } from "@/lib/auth";
+
+/** پنل ادمین — ورود اجباری از SSO مرکزی Keycloak */
+export default function AdminLoginPage() {
+ useEffect(() => {
+ void loginWithRedirect("/admin/tenants");
+ }, []);
+
+ return (
+
+
در حال انتقال به ورود مرکزی...
+
+ );
+}
diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx
new file mode 100644
index 0000000..fe71815
--- /dev/null
+++ b/frontend/app/admin/page.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import Link from "next/link";
+import { api } from "@/lib/api";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi } from "@/lib/identity";
+import { AdminPageHeader, Banner, cardClass } from "@/components/admin/controls";
+
+interface Stats {
+ tenants: number | null;
+ users: number | null;
+ plans: number | null;
+ features: number | null;
+ services: number | null;
+}
+
+const QUICK_LINKS = [
+ { href: "/admin/tenants", title: "مدیریت Tenantها", desc: "ایجاد، ویرایش، تعلیق و فعالسازی" },
+ { href: "/admin/users", title: "مدیریت کاربران", desc: "لیست و ایجاد کاربر جدید" },
+ { href: "/admin/plans", title: "پلنها", desc: "تعریف پلن و اتصال قابلیتها" },
+ { href: "/admin/features", title: "قابلیتها", desc: "تعریف قابلیتهای سرویسها" },
+ { href: "/admin/services", title: "سرویسها", desc: "ثبت و مدیریت وضعیت سرویسها" },
+];
+
+export default function AdminOverviewPage() {
+ const [stats, setStats] = useState({
+ tenants: null,
+ users: null,
+ plans: null,
+ features: null,
+ services: null,
+ });
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ const token = getStoredToken();
+ (async () => {
+ const [tenants, plans, features, services, users] = await Promise.allSettled([
+ api.platformTenants.list(1, 1),
+ api.plans.list(1, 1),
+ api.features.list(1, 1),
+ api.services.list(1, 1),
+ token ? identityApi.listUsers(token, { limit: 100 }) : Promise.resolve([]),
+ ]);
+ setStats({
+ tenants: tenants.status === "fulfilled" ? tenants.value.meta.total_items : null,
+ plans: plans.status === "fulfilled" ? plans.value.meta.total_items : null,
+ features: features.status === "fulfilled" ? features.value.meta.total_items : null,
+ services: services.status === "fulfilled" ? services.value.meta.total_items : null,
+ users: users.status === "fulfilled" ? users.value.length : null,
+ });
+ if (tenants.status === "rejected") {
+ setError(
+ tenants.reason instanceof Error ? tenants.reason.message : "خطا در بارگذاری آمار"
+ );
+ }
+ setLoading(false);
+ })();
+ }, []);
+
+ const cards = [
+ { label: "Tenantها", value: stats.tenants, href: "/admin/tenants" },
+ { label: "کاربران", value: stats.users, href: "/admin/users" },
+ { label: "پلنها", value: stats.plans, href: "/admin/plans" },
+ { label: "قابلیتها", value: stats.features, href: "/admin/features" },
+ { label: "سرویسها", value: stats.services, href: "/admin/services" },
+ ];
+
+ return (
+
+
+
+ {error &&
{error}}
+
+
+ {cards.map((c) => (
+
+
{c.label}
+
+ {loading ? "…" : c.value ?? "—"}
+
+
+ ))}
+
+
+
+
دسترسی سریع
+
+ {QUICK_LINKS.map((link) => (
+
+
{link.title}
+
{link.desc}
+
+ ))}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/plans/page.tsx b/frontend/app/admin/plans/page.tsx
new file mode 100644
index 0000000..416e4b1
--- /dev/null
+++ b/frontend/app/admin/plans/page.tsx
@@ -0,0 +1,304 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { api, ApiError, type Feature, type Plan } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ SelectField,
+ StatusBadge,
+ TextField,
+ TextareaField,
+} from "@/components/admin/controls";
+
+export default function AdminPlansPage() {
+ const [plans, setPlans] = useState([]);
+ const [features, setFeatures] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [createOpen, setCreateOpen] = useState(false);
+ const [attachPlan, setAttachPlan] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const [plansPage, featuresPage] = await Promise.all([
+ api.plans.list(1, 100),
+ api.features.list(1, 100),
+ ]);
+ setPlans(plansPage.items);
+ setFeatures(featuresPage.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری پلنها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
setCreateOpen(true)}>پلن جدید}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : plans.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ کد |
+ وضعیت |
+ عملیات |
+
+
+
+ {plans.map((p) => (
+
+ | {p.name} |
+
+ {p.code}
+ |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setCreateOpen(false)}
+ onCreated={() => {
+ setCreateOpen(false);
+ void load();
+ }}
+ />
+
+ setAttachPlan(null)}
+ onDone={() => setAttachPlan(null)}
+ />
+
+ );
+}
+
+function CreatePlanModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [code, setCode] = useState("");
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [isActive, setIsActive] = useState("true");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSaving(true);
+ try {
+ await api.plans.create({
+ code: code.trim(),
+ name: name.trim(),
+ description: description.trim() || null,
+ is_active: isActive === "true",
+ });
+ setCode("");
+ setName("");
+ setDescription("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ایجاد پلن ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+function AttachFeatureModal({
+ plan,
+ features,
+ onClose,
+ onDone,
+}: {
+ plan: Plan | null;
+ features: Feature[];
+ onClose: () => void;
+ onDone: () => void;
+}) {
+ const [featureId, setFeatureId] = useState("");
+ const [limitValue, setLimitValue] = useState("");
+ const [limitPeriod, setLimitPeriod] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!plan) return;
+ setError("");
+ setSuccess("");
+ const fid = featureId || features[0]?.id;
+ if (!fid) {
+ setError("قابلیتی برای اتصال وجود ندارد.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await api.plans.attachFeature(plan.id, {
+ feature_id: fid,
+ limit_value: limitValue.trim() ? Number(limitValue) : null,
+ limit_period: (limitPeriod || null) as
+ | "day"
+ | "month"
+ | "year"
+ | "total"
+ | null,
+ });
+ setSuccess("قابلیت با موفقیت متصل شد.");
+ setLimitValue("");
+ setLimitPeriod("");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "اتصال قابلیت ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ {features.length === 0 ? (
+
+ ابتدا از بخش «قابلیتها» یک قابلیت بسازید.
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/frontend/app/admin/services/page.tsx b/frontend/app/admin/services/page.tsx
new file mode 100644
index 0000000..e9a7e16
--- /dev/null
+++ b/frontend/app/admin/services/page.tsx
@@ -0,0 +1,226 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { api, ApiError, type ServiceEntry, type ServiceStatus } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ SelectField,
+ StatusBadge,
+ TextField,
+} from "@/components/admin/controls";
+
+const STATUS_OPTIONS: { value: ServiceStatus; label: string }[] = [
+ { value: "active", label: "فعال" },
+ { value: "inactive", label: "غیرفعال" },
+ { value: "maintenance", label: "در حال تعمیر" },
+];
+
+export default function AdminServicesPage() {
+ const [services, setServices] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [open, setOpen] = useState(false);
+ const [busyId, setBusyId] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const page = await api.services.list(1, 100);
+ setServices(page.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری سرویسها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const changeStatus = async (svc: ServiceEntry, status: ServiceStatus) => {
+ if (status === svc.status) return;
+ setBusyId(svc.id);
+ setError("");
+ try {
+ await api.services.updateStatus(svc.id, status);
+ await load();
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "تغییر وضعیت ناموفق بود");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ return (
+
+
setOpen(true)}>ثبت سرویس}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : services.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ کلید |
+ آدرس |
+ وضعیت |
+ تغییر وضعیت |
+
+
+
+ {services.map((s) => (
+
+ | {s.name} |
+
+ {s.service_key}
+ |
+
+ {s.base_url || "—"}
+ |
+
+
+ |
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setOpen(false)}
+ onCreated={() => {
+ setOpen(false);
+ void load();
+ }}
+ />
+
+ );
+}
+
+function CreateServiceModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [serviceKey, setServiceKey] = useState("");
+ const [name, setName] = useState("");
+ const [baseUrl, setBaseUrl] = useState("");
+ const [healthUrl, setHealthUrl] = useState("");
+ const [status, setStatus] = useState("active");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSaving(true);
+ try {
+ await api.services.create({
+ service_key: serviceKey.trim(),
+ name: name.trim(),
+ base_url: baseUrl.trim() || null,
+ health_check_url: healthUrl.trim() || null,
+ status,
+ });
+ setServiceKey("");
+ setName("");
+ setBaseUrl("");
+ setHealthUrl("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ثبت سرویس ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/admin/settings/page.tsx b/frontend/app/admin/settings/page.tsx
new file mode 100644
index 0000000..a97c230
--- /dev/null
+++ b/frontend/app/admin/settings/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import { AccountSettings } from "@/components/settings/AccountSettings";
+
+export default function AdminSettingsPage() {
+ return ;
+}
diff --git a/frontend/app/admin/tenants/[id]/page.tsx b/frontend/app/admin/tenants/[id]/page.tsx
new file mode 100644
index 0000000..74f6827
--- /dev/null
+++ b/frontend/app/admin/tenants/[id]/page.tsx
@@ -0,0 +1,492 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { useParams } from "next/navigation";
+import {
+ api,
+ ApiError,
+ type Tenant,
+ type TenantDomain,
+ type Subscription,
+ type Plan,
+} from "@/lib/api";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi, type AdminUser, type TenantMember } from "@/lib/identity";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ Field,
+ SelectField,
+ StatusBadge,
+ TextField,
+ cardClass,
+ inputClass,
+} from "@/components/admin/controls";
+
+const TENANT_STATUSES = [
+ "draft",
+ "pending_activation",
+ "active",
+ "suspended",
+ "archived",
+ "inactive",
+].map((s) => ({ value: s, label: s }));
+
+const SUBSCRIPTION_STATUSES = [
+ "trialing",
+ "active",
+ "past_due",
+ "canceled",
+ "expired",
+].map((s) => ({ value: s, label: s }));
+
+export default function TenantDetailPage() {
+ const params = useParams<{ id: string }>();
+ const tenantId = params.id;
+
+ const [tenant, setTenant] = useState(null);
+ const [domains, setDomains] = useState([]);
+ const [subscription, setSubscription] = useState(null);
+ const [members, setMembers] = useState([]);
+ const [plans, setPlans] = useState([]);
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ const token = getStoredToken();
+ try {
+ const t = await api.platformTenants.get(tenantId);
+ setTenant(t);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "دریافت Tenant ناموفق بود");
+ setLoading(false);
+ return;
+ }
+ const [d, sub, plansPage, mem, usersRes] = await Promise.allSettled([
+ api.domains.listForTenant(tenantId),
+ api.subscriptions.get(tenantId),
+ api.plans.list(1, 100),
+ token ? identityApi.listMembers(token, tenantId) : Promise.resolve([]),
+ token ? identityApi.listUsers(token, { limit: 100 }) : Promise.resolve([]),
+ ]);
+ if (d.status === "fulfilled") setDomains(d.value);
+ setSubscription(sub.status === "fulfilled" ? sub.value : null);
+ if (plansPage.status === "fulfilled") setPlans(plansPage.value.items);
+ if (mem.status === "fulfilled") setMembers(mem.value);
+ if (usersRes.status === "fulfilled") setUsers(usersRes.value);
+ setLoading(false);
+ }, [tenantId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ if (loading) {
+ return در حال بارگذاری...
;
+ }
+
+ if (!tenant) {
+ return (
+
+ {error || "Tenant یافت نشد"}
+
+ ← بازگشت به لیست
+
+
+ );
+ }
+
+ return (
+
+
+
+ ← بازگشت به لیست
+
+
+
}
+ />
+
+ {error &&
{error}}
+
+
+
+
+
+
+
+
+ );
+}
+
+function TenantEditCard({
+ tenant,
+ onUpdated,
+ onError,
+}: {
+ tenant: Tenant;
+ onUpdated: (t: Tenant) => void;
+ onError: (m: string) => void;
+}) {
+ const [name, setName] = useState(tenant.name);
+ const [status, setStatus] = useState(tenant.status);
+ const [ownerUserId, setOwnerUserId] = useState(tenant.owner_user_id || "");
+ const [saving, setSaving] = useState(false);
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setSuccess("");
+ onError("");
+ setSaving(true);
+ try {
+ const updated = await api.platformTenants.update(tenant.id, {
+ name,
+ status,
+ owner_user_id: ownerUserId.trim() || null,
+ });
+ onUpdated(updated);
+ setSuccess("تغییرات ذخیره شد.");
+ } catch (err) {
+ onError(err instanceof ApiError ? err.message : "ذخیره ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function DomainsCard({
+ tenantId,
+ domains,
+ onChanged,
+}: {
+ tenantId: string;
+ domains: TenantDomain[];
+ onChanged: () => void;
+}) {
+ const [domain, setDomain] = useState("");
+ const [type, setType] = useState<"subdomain" | "custom_domain">("custom_domain");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleAdd = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ if (!domain.trim()) return;
+ setSaving(true);
+ try {
+ await api.domains.create(tenantId, { domain: domain.trim(), domain_type: type });
+ setDomain("");
+ onChanged();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
دامنهها
+ {domains.length === 0 ? (
+
دامنهای ثبت نشده است.
+ ) : (
+
+ {domains.map((d) => (
+ -
+
+ {d.domain}
+
+
+ {d.domain_type}
+
+
+
+ ))}
+
+ )}
+
+ {error &&
{error}}
+
+
+
+ );
+}
+
+function SubscriptionCard({
+ tenantId,
+ subscription,
+ plans,
+ onChanged,
+}: {
+ tenantId: string;
+ subscription: Subscription | null;
+ plans: Plan[];
+ onChanged: () => void;
+}) {
+ const [planId, setPlanId] = useState(plans[0]?.id || "");
+ const [status, setStatus] = useState("active");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ useEffect(() => {
+ if (!planId && plans[0]) setPlanId(plans[0].id);
+ }, [plans, planId]);
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ if (!planId) {
+ setError("ابتدا یک پلن انتخاب کنید.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await api.subscriptions.create(tenantId, {
+ plan_id: planId,
+ status: status as Subscription["status"],
+ });
+ onChanged();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ثبت اشتراک ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const planName = subscription
+ ? plans.find((p) => p.id === subscription.plan_id)?.name || subscription.plan_id
+ : null;
+
+ return (
+
+
اشتراک
+ {subscription ? (
+
+
+ پلن
+ {planName}
+
+
+ وضعیت
+
+
+
+ ) : (
+
اشتراک فعالی ثبت نشده است.
+ )}
+
+ {error &&
{error}}
+
+
+
+ );
+}
+
+function MembersCard({
+ tenantId,
+ members,
+ users,
+ onChanged,
+}: {
+ tenantId: string;
+ members: TenantMember[];
+ users: AdminUser[];
+ onChanged: () => void;
+}) {
+ const [userId, setUserId] = useState("");
+ const [role, setRole] = useState<"tenant_admin" | "tenant_member">("tenant_member");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const userLabel = (id: string) => {
+ const u = users.find((x) => x.id === id);
+ return u ? u.display_name || u.username || u.mobile || u.email : id;
+ };
+
+ const handleAdd = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن یافت نشد.");
+ return;
+ }
+ if (!userId) {
+ setError("یک کاربر انتخاب کنید.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await identityApi.addMember(token, tenantId, { user_id: userId, role });
+ setUserId("");
+ onChanged();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "افزودن عضو ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
اعضا
+ {members.length === 0 ? (
+
عضوی ثبت نشده است.
+ ) : (
+
+ {members.map((m) => (
+ -
+ {userLabel(m.user_id)}
+
+
+ {m.role === "tenant_admin" ? "مدیر" : "عضو"}
+
+
+
+
+ ))}
+
+ )}
+
+ {error &&
{error}}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/tenants/new/page.tsx b/frontend/app/admin/tenants/new/page.tsx
new file mode 100644
index 0000000..aa554e1
--- /dev/null
+++ b/frontend/app/admin/tenants/new/page.tsx
@@ -0,0 +1,113 @@
+"use client";
+
+import { FormEvent, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { api, ApiError } from "@/lib/api";
+import { AdminPageHeader, Banner, Button, TextField, cardClass } from "@/components/admin/controls";
+
+function slugify(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+export default function NewTenantPage() {
+ const router = useRouter();
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [ownerUserId, setOwnerUserId] = useState("");
+ const [customDomain, setCustomDomain] = useState("");
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleNameChange = (value: string) => {
+ setName(value);
+ if (!slugTouched) setSlug(slugify(value));
+ };
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ let keepLoading = false;
+ try {
+ await api.platformTenants.create({
+ name,
+ slug,
+ owner_user_id: ownerUserId.trim() || null,
+ custom_domain: customDomain.trim() || null,
+ });
+ keepLoading = true;
+ router.push("/admin/tenants");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ایجاد Tenant ناموفق بود");
+ } finally {
+ if (!keepLoading) setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ ← بازگشت به لیست
+
+
+
+
+ {error &&
{error}}
+
+
+
+ );
+}
diff --git a/frontend/app/admin/tenants/page.tsx b/frontend/app/admin/tenants/page.tsx
new file mode 100644
index 0000000..3ef3d18
--- /dev/null
+++ b/frontend/app/admin/tenants/page.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { api, ApiError, type Tenant } from "@/lib/api";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ StatusBadge,
+} from "@/components/admin/controls";
+
+export default function AdminTenantsPage() {
+ const [tenants, setTenants] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [busyId, setBusyId] = useState(null);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError("");
+ try {
+ const page = await api.platformTenants.list(1, 100);
+ setTenants(page.items);
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "خطا در بارگذاری Tenantها");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const toggleStatus = async (t: Tenant) => {
+ setBusyId(t.id);
+ setError("");
+ try {
+ if (t.status === "suspended") {
+ await api.platformTenants.activate(t.id);
+ } else {
+ await api.platformTenants.suspend(t.id);
+ }
+ await load();
+ } catch (e) {
+ setError(e instanceof ApiError ? e.message : "تغییر وضعیت ناموفق بود");
+ } finally {
+ setBusyId(null);
+ }
+ };
+
+ return (
+
+
+
+
+ }
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : tenants.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ Slug |
+ وضعیت |
+ Onboarding |
+ عملیات |
+
+
+
+ {tenants.map((t) => (
+
+ | {t.name} |
+
+ {t.slug}
+ |
+
+
+ |
+
+ {t.onboarding_completed ? "تکمیلشده" : "ناتمام"}
+ |
+
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ );
+}
diff --git a/frontend/app/admin/users/page.tsx b/frontend/app/admin/users/page.tsx
new file mode 100644
index 0000000..2c28baa
--- /dev/null
+++ b/frontend/app/admin/users/page.tsx
@@ -0,0 +1,223 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi, type AdminUser } from "@/lib/identity";
+import { normalizePhone } from "@/lib/phone";
+import {
+ AdminPageHeader,
+ Banner,
+ Button,
+ EmptyState,
+ Modal,
+ StatusBadge,
+ TextField,
+} from "@/components/admin/controls";
+
+export default function AdminUsersPage() {
+ const [users, setUsers] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const load = useCallback(async () => {
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن یافت نشد.");
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ const rows = await identityApi.listUsers(token, { limit: 100 });
+ setUsers(rows);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "خطا در بارگذاری کاربران");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
setModalOpen(true)}>کاربر جدید}
+ />
+
+ {error && {error}}
+
+
+ {loading ? (
+
+ ) : users.length === 0 ? (
+
+ ) : (
+
+
+
+
+ | نام |
+ نام کاربری |
+ موبایل |
+ ایمیل |
+ وضعیت |
+
+
+
+ {users.map((u) => (
+
+ |
+ {u.display_name || u.username || "بدون نام"}
+ |
+
+ {u.username || "—"}
+ |
+
+ {u.mobile || "—"}
+ |
+
+ {u.email}
+ |
+
+
+
+
+
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ setModalOpen(false)}
+ onCreated={() => {
+ setModalOpen(false);
+ void load();
+ }}
+ />
+
+ );
+}
+
+function CreateUserModal({
+ open,
+ onClose,
+ onCreated,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onCreated: () => void;
+}) {
+ const [displayName, setDisplayName] = useState("");
+ const [username, setUsername] = useState("");
+ const [email, setEmail] = useState("");
+ const [mobile, setMobile] = useState("");
+ const [password, setPassword] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن یافت نشد.");
+ return;
+ }
+ if (password.length < 8) {
+ setError("رمز عبور باید حداقل ۸ کاراکتر باشد.");
+ return;
+ }
+ setSaving(true);
+ try {
+ await identityApi.createUser(token, {
+ email: email.trim(),
+ username: username.trim(),
+ password,
+ mobile: normalizePhone(mobile),
+ display_name: displayName.trim() || undefined,
+ });
+ setDisplayName("");
+ setUsername("");
+ setEmail("");
+ setMobile("");
+ setPassword("");
+ onCreated();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ایجاد کاربر ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/auth/callback/page.tsx b/frontend/app/auth/callback/page.tsx
new file mode 100644
index 0000000..075e1b7
--- /dev/null
+++ b/frontend/app/auth/callback/page.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import {
+ exchangeCode,
+ consumePostLoginRedirect,
+ fetchAuthConfig,
+ fetchMe,
+ redeemHandoff,
+ storeTokens,
+} from "@/lib/auth";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Suspense, useEffect, useRef, useState } from "react";
+
+function CallbackInner() {
+ const params = useSearchParams();
+ const router = useRouter();
+ const [error, setError] = useState(null);
+ const exchangeStarted = useRef(false);
+
+ useEffect(() => {
+ if (exchangeStarted.current) return;
+
+ const handoff = params.get("handoff");
+ const code = params.get("code");
+
+ if (!handoff && !code) {
+ setError("کد احراز هویت دریافت نشد");
+ return;
+ }
+
+ exchangeStarted.current = true;
+ (async () => {
+ try {
+ let tokens;
+ if (handoff) {
+ tokens = await redeemHandoff(handoff);
+ } else {
+ const config = await fetchAuthConfig();
+ tokens = await exchangeCode(code!, config.callback_url, params.get("state"));
+ }
+ storeTokens(tokens);
+ const me = await fetchMe(tokens.access_token);
+ if (me.requires_mobile_verification) {
+ router.replace("/auth/verify-mobile");
+ return;
+ }
+ router.replace(consumePostLoginRedirect("/dashboard"));
+ } catch (e) {
+ exchangeStarted.current = false;
+ setError(e instanceof Error ? e.message : "خطای ناشناخته");
+ }
+ })();
+ }, [params, router]);
+
+ if (error) {
+ return {error}
;
+ }
+
+ return در حال تکمیل ورود...
;
+}
+
+export default function AuthCallbackPage() {
+ return (
+ ...
}>
+
+
+ );
+}
diff --git a/frontend/app/auth/verify-mobile/page.tsx b/frontend/app/auth/verify-mobile/page.tsx
new file mode 100644
index 0000000..2aa8bc6
--- /dev/null
+++ b/frontend/app/auth/verify-mobile/page.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button, Container } from "@/components/ui";
+import { getStoredToken, loginWithRedirect } from "@/lib/auth";
+import { identityApi } from "@/lib/identity";
+import {
+ isValidMobile,
+ normalizeOtpCode,
+ normalizePhone,
+ persianToEnglish,
+} from "@/lib/phone";
+
+export default function VerifyMobilePage() {
+ const router = useRouter();
+ const [mobile, setMobile] = useState("");
+ const [code, setCode] = useState("");
+ const [countdown, setCountdown] = useState(0);
+ const [step, setStep] = useState<"mobile" | "code">("mobile");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const verifyingRef = useRef(false);
+
+ useEffect(() => {
+ if (!getStoredToken()) {
+ void loginWithRedirect();
+ }
+ }, []);
+
+ useEffect(() => {
+ if (countdown <= 0) return;
+ const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+ return () => clearTimeout(timer);
+ }, [countdown]);
+
+ const handleRequestOtp = async () => {
+ const token = getStoredToken();
+ if (!token) return;
+ setError("");
+ const normalized = normalizePhone(mobile);
+ if (!isValidMobile(normalized)) {
+ setError("شماره موبایل صحیح نیست");
+ return;
+ }
+ setMobile(normalized);
+ setLoading(true);
+ try {
+ const res = await identityApi.requestMobileVerify(normalized, token);
+ setCountdown(res.expires_in);
+ setStep("code");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "ارسال کد ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVerify = useCallback(async () => {
+ const token = getStoredToken();
+ if (!token || verifyingRef.current) return;
+ const normalizedCode = normalizeOtpCode(code);
+ if (normalizedCode.length !== 4) {
+ setError("کد تأیید باید ۴ رقم باشد");
+ return;
+ }
+ verifyingRef.current = true;
+ setLoading(true);
+ let keepLoading = false;
+ try {
+ await identityApi.verifyMobile(mobile, normalizedCode, token);
+ keepLoading = true;
+ router.replace("/dashboard");
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "تأیید ناموفق بود");
+ } finally {
+ if (!keepLoading) {
+ setLoading(false);
+ verifyingRef.current = false;
+ }
+ }
+ }, [code, mobile, router]);
+
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/dashboard/page.tsx b/frontend/app/dashboard/page.tsx
new file mode 100644
index 0000000..39d32ec
--- /dev/null
+++ b/frontend/app/dashboard/page.tsx
@@ -0,0 +1,206 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { AuthGuard } from "@/components/AuthGuard";
+import { TenantSwitcher } from "@/components/TenantSwitcher";
+import { useAuth } from "@/hooks/useAuth";
+import { useMe } from "@/hooks/useMe";
+import { api, ApiError, type TenantContext } from "@/lib/api";
+import { Badge, Button, Container, SectionTitle } from "@/components/ui";
+
+const STATUS_LABELS: Record = {
+ draft: "پیشنویس",
+ pending_activation: "در انتظار فعالسازی",
+ active: "فعال",
+ suspended: "معلق",
+ archived: "بایگانیشده",
+};
+
+const ROLE_LABELS: Record = {
+ platform_admin: "مدیر پلتفرم",
+ tenant_owner: "مالک workspace",
+ tenant_admin: "مدیر workspace",
+ tenant_editor: "ویرایشگر",
+ tenant_viewer: "بیننده",
+};
+
+const QUICK_LINKS = [
+ { title: "کاربران و دسترسیها", desc: "مدیریت اعضای workspace (بهزودی)" },
+ { title: "برندینگ و ظاهر", desc: "شخصیسازی رنگ و لوگو (بهزودی)" },
+ { title: "دامنهها", desc: "مدیریت زیردامنه و دامنه اختصاصی (بهزودی)" },
+ { title: "اشتراک و پلن", desc: "مشاهده و ارتقاء پلن (بهزودی)" },
+];
+
+function DashboardContent() {
+ const router = useRouter();
+ const { user, signOut } = useAuth();
+ const { me, loading: meLoading, error: meError, reload: reloadMe } = useMe();
+
+ const [ctx, setCtx] = useState(null);
+ const [loadingCtx, setLoadingCtx] = useState(true);
+ const [ctxError, setCtxError] = useState("");
+
+ const loadContext = useCallback(async () => {
+ setLoadingCtx(true);
+ setCtxError("");
+ try {
+ const data = await api.tenantContext.current();
+ setCtx(data);
+ } catch (err) {
+ setCtxError(
+ err instanceof ApiError ? err.message : "دریافت اطلاعات workspace ناموفق بود"
+ );
+ setCtx(null);
+ } finally {
+ setLoadingCtx(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (meLoading) return;
+ if (!me) return;
+ if (me.onboarding_required) {
+ router.replace("/onboarding");
+ return;
+ }
+ void loadContext();
+ }, [me, meLoading, router, loadContext]);
+
+ const handleSwitched = async () => {
+ await reloadMe();
+ await loadContext();
+ };
+
+ if (meLoading || (me && me.onboarding_required)) {
+ return (
+
+ در حال بارگذاری...
+
+ );
+ }
+
+ return (
+
+
+
+
داشبورد workspace
+
+ خوش آمدید {user?.username || user?.email || "کاربر"}
+
+
+
+ {me && (
+
+ )}
+
+
+
+
+
+
+
+ {(meError || ctxError) && (
+
+ {meError || ctxError}
+
+ )}
+
+ {loadingCtx ? (
+ در حال بارگذاری workspace...
+ ) : ctx ? (
+
+
+
+
+
{ctx.tenant.name}
+
+ {ctx.primary_domain || ctx.tenant.slug}
+
+
+
+ {STATUS_LABELS[ctx.tenant.status] || ctx.tenant.status}
+
+
+
+
+
+
- پلن فعلی
+ -
+ {ctx.plan_name || "—"}
+
+
+
+
- وضعیت اشتراک
+ -
+ {ctx.subscription_status || "—"}
+
+
+
+
- نقش شما
+ -
+ {(ctx.role && ROLE_LABELS[ctx.role]) || ctx.role || "—"}
+
+
+
+
- وضعیت onboarding
+ -
+ {ctx.tenant.onboarding_completed ? "تکمیلشده" : "ناتمام"}
+
+
+
+
+ {ctx.domains.length > 0 && (
+
+
دامنهها
+
+ {ctx.domains.map((d) => (
+
+
+ {d.domain}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+
+ {QUICK_LINKS.map((link) => (
+
+
{link.title}
+
{link.desc}
+
+ ))}
+
+
+
+ ) : (
+ !ctxError && (
+ اطلاعات workspace در دسترس نیست.
+ )
+ )}
+
+ );
+}
+
+export default function DashboardPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/dashboard/settings/page.tsx b/frontend/app/dashboard/settings/page.tsx
new file mode 100644
index 0000000..c6bf59b
--- /dev/null
+++ b/frontend/app/dashboard/settings/page.tsx
@@ -0,0 +1,12 @@
+"use client";
+
+import { AuthGuard } from "@/components/AuthGuard";
+import { AccountSettings } from "@/components/settings/AccountSettings";
+
+export default function DashboardSettingsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
new file mode 100644
index 0000000..fc26c20
--- /dev/null
+++ b/frontend/app/layout.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import "@/styles/globals.css";
+import { SiteHeader } from "@/components/SiteHeader";
+import { ThemeProvider } from "@/components/ThemeProvider";
+
+export const metadata: Metadata = {
+ title: "Torbatyar — SuperApp Platform",
+ description: "پلتفرم SaaS چندسرویسی با SSO یکپارچه",
+};
+
+export default function RootLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {children}
+
+
+
+ );
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
new file mode 100644
index 0000000..5b893cc
--- /dev/null
+++ b/frontend/app/login/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Suspense, useEffect } from "react";
+import { useSearchParams } from "next/navigation";
+import { AuthPanel } from "@/components/auth/AuthPanel";
+import { setPostLoginRedirect } from "@/lib/auth";
+
+function LoginInner() {
+ const params = useSearchParams();
+ const redirectParam = params.get("redirect");
+
+ useEffect(() => {
+ if (redirectParam) setPostLoginRedirect(redirectParam);
+ }, [redirectParam]);
+
+ return ;
+}
+
+export default function LoginPage() {
+ return (
+
+ در حال بارگذاری...
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/app/onboarding/page.tsx b/frontend/app/onboarding/page.tsx
new file mode 100644
index 0000000..6568455
--- /dev/null
+++ b/frontend/app/onboarding/page.tsx
@@ -0,0 +1,438 @@
+"use client";
+
+import { FormEvent, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { AuthGuard } from "@/components/AuthGuard";
+import { Button, Container } from "@/components/ui";
+import { api, ApiError, type TenantContext } from "@/lib/api";
+import { useMe } from "@/hooks/useMe";
+
+type Step = "business" | "branding" | "domain" | "review";
+
+const STEPS: { id: Step; label: string }[] = [
+ { id: "business", label: "اطلاعات کسبوکار" },
+ { id: "branding", label: "برندینگ" },
+ { id: "domain", label: "دامنه" },
+ { id: "review", label: "بازبینی و تکمیل" },
+];
+
+function slugify(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9-]/g, "")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+const inputClass =
+ "mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
+
+function StepIndicator({ current }: { current: Step }) {
+ const currentIndex = STEPS.findIndex((s) => s.id === current);
+ return (
+
+ {STEPS.map((step, index) => {
+ const isActive = step.id === current;
+ const isDone = index < currentIndex;
+ return (
+ -
+
+ {index + 1}
+
+
+ {step.label}
+
+ {index < STEPS.length - 1 && /}
+
+ );
+ })}
+
+ );
+}
+
+function OnboardingWizard() {
+ const router = useRouter();
+ const { me, loading: meLoading } = useMe();
+
+ const [step, setStep] = useState("business");
+ const [ctx, setCtx] = useState(null);
+ const [bootstrapping, setBootstrapping] = useState(true);
+ const [error, setError] = useState("");
+ const [loading, setLoading] = useState(false);
+
+ // فرم گام ۱
+ const [businessName, setBusinessName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [businessType, setBusinessType] = useState("");
+
+ // فرم گام ۲
+ const [primaryColor, setPrimaryColor] = useState("#0284c7");
+ const [secondaryColor, setSecondaryColor] = useState("#0f172a");
+ const [logoUrl, setLogoUrl] = useState("");
+
+ // فرم گام ۳
+ const [customDomain, setCustomDomain] = useState("");
+
+ // اگر کاربر tenant ناتمام داشته باشد، از همانجا ادامه بده؛ اگر onboarding کامل است برو به داشبورد.
+ useEffect(() => {
+ if (meLoading) return;
+ if (!me) {
+ setBootstrapping(false);
+ return;
+ }
+ if (!me.onboarding_required) {
+ router.replace("/dashboard");
+ return;
+ }
+ if (me.current_tenant_id) {
+ api.tenantContext
+ .current()
+ .then((tenantCtx) => {
+ setCtx(tenantCtx);
+ setStep("branding");
+ })
+ .catch(() => {
+ /* اگر دریافت نشد، از ابتدا شروع کن */
+ })
+ .finally(() => setBootstrapping(false));
+ } else {
+ setBootstrapping(false);
+ }
+ }, [me, meLoading, router]);
+
+ const handleBusinessNameChange = (value: string) => {
+ setBusinessName(value);
+ if (!slugTouched) setSlug(slugify(value));
+ };
+
+ const handleCreateTenant = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setLoading(true);
+ try {
+ const result = await api.onboarding.createTenant({
+ business_name: businessName,
+ slug,
+ business_type: businessType.trim() || undefined,
+ });
+ setCtx(result);
+ setStep("branding");
+ } catch (err) {
+ setError(
+ err instanceof ApiError ? err.message : "ایجاد workspace ناموفق بود"
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleBranding = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!ctx) return;
+ setError("");
+ setLoading(true);
+ try {
+ const result = await api.onboarding.updateBranding(ctx.tenant.id, {
+ primary_color: primaryColor || undefined,
+ secondary_color: secondaryColor || undefined,
+ logo_url: logoUrl.trim() || undefined,
+ });
+ setCtx(result);
+ setStep("domain");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ذخیره برندینگ ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDomain = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!ctx) return;
+ setError("");
+ setLoading(true);
+ try {
+ let result = ctx;
+ if (customDomain.trim()) {
+ result = await api.onboarding.updateDomain(ctx.tenant.id, {
+ custom_domain: customDomain.trim(),
+ });
+ setCtx(result);
+ }
+ setStep("review");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "ثبت دامنه ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleComplete = async () => {
+ if (!ctx) return;
+ setError("");
+ setLoading(true);
+ try {
+ await api.onboarding.complete(ctx.tenant.id);
+ router.push("/dashboard");
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "تکمیل onboarding ناموفق بود");
+ setLoading(false);
+ }
+ };
+
+ if (meLoading || bootstrapping) {
+ return (
+
+ در حال بارگذاری...
+
+ );
+ }
+
+ return (
+
+
+
راهاندازی workspace
+
+ چند مرحله ساده تا آمادهشدن workspace شما باقی مانده است.
+
+
+
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {step === "business" && (
+
+ )}
+
+ {step === "branding" && ctx && (
+
+ )}
+
+ {step === "domain" && ctx && (
+
+ )}
+
+ {step === "review" && ctx && (
+
+
+
+
+
- نام کسبوکار
+ - {ctx.tenant.name}
+
+
+
- آدرس
+ -
+ {ctx.tenant.slug}
+
+
+
+
- زیردامنه
+ -
+ {ctx.primary_domain || "—"}
+
+
+
+
- پلن
+ -
+ {ctx.plan_name || "—"}
+
+
+
+
- رنگ اصلی
+ -
+
+
+ {ctx.tenant.primary_color || "—"}
+
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+export default function OnboardingPage() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
new file mode 100644
index 0000000..2b30bb0
--- /dev/null
+++ b/frontend/app/page.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import Link from "next/link";
+import { AppStoreGrid } from "@/components/AppStoreGrid";
+import { Badge, Button, Container } from "@/components/ui";
+import { useAuth } from "@/hooks/useAuth";
+import { useTheme } from "@/hooks/useTheme";
+
+export default function HomePage() {
+ const { theme } = useTheme();
+ const { isAuthenticated, login, loading } = useAuth();
+
+ return (
+
+ {/* Hero */}
+
+
+
+
+ نسل جدید SuperApp
+
+
+ {theme?.site_name ?? "Torbatyar"}
+
+
+ یک پلتفرم، دهها سرویس — حسابداری، CRM، فروشگاه، پیامک و بیشتر.
+ همه با یک ورود واحد (SSO).
+
+
+ {isAuthenticated ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {/* App Store Grid */}
+
+
+
+
+ {/* Footer strip */}
+
+
+ );
+}
diff --git a/frontend/app/register/page.tsx b/frontend/app/register/page.tsx
new file mode 100644
index 0000000..66b400b
--- /dev/null
+++ b/frontend/app/register/page.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { Suspense, useEffect } from "react";
+import { useSearchParams } from "next/navigation";
+import { AuthPanel } from "@/components/auth/AuthPanel";
+import { setPostLoginRedirect } from "@/lib/auth";
+
+function RegisterInner() {
+ const params = useSearchParams();
+ const redirectParam = params.get("redirect");
+
+ useEffect(() => {
+ if (redirectParam) setPostLoginRedirect(redirectParam);
+ }, [redirectParam]);
+
+ return ;
+}
+
+export default function RegisterPage() {
+ return (
+
+ در حال بارگذاری...
+
+ }
+ >
+
+
+ );
+}
diff --git a/frontend/components/AdminAuthGuard.tsx b/frontend/components/AdminAuthGuard.tsx
new file mode 100644
index 0000000..3436988
--- /dev/null
+++ b/frontend/components/AdminAuthGuard.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import { useAuth } from "@/hooks/useAuth";
+import { getStoredToken, loginWithRedirect } from "@/lib/auth";
+import { useEffect } from "react";
+
+const ADMIN_ROLES = ["platform_admin", "tenant_admin"];
+
+/** محافظ پنل ادمین — SSO مرکزی + نقش platform_admin یا tenant_admin. */
+export function AdminAuthGuard({ children }: { children: React.ReactNode }) {
+ const { loading, user } = useAuth();
+ const hasAdminRole = ADMIN_ROLES.some((r) => user?.roles?.includes(r));
+
+ useEffect(() => {
+ if (loading) return;
+ if (!getStoredToken()) {
+ void loginWithRedirect("/admin/tenants");
+ }
+ }, [loading]);
+
+ if (loading) {
+ return در حال بررسی ورود...
;
+ }
+
+ if (!getStoredToken()) {
+ return در حال هدایت به ورود مرکزی...
;
+ }
+
+ if (!hasAdminRole) {
+ return (
+
+
دسترسی به پنل ادمین ندارید
+
+ فقط مدیر پلتفرم یا مدیر tenant میتوانند وارد این بخش شوند.
+
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/components/AppStoreGrid.tsx b/frontend/components/AppStoreGrid.tsx
new file mode 100644
index 0000000..28f4b0e
--- /dev/null
+++ b/frontend/components/AppStoreGrid.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { PLATFORM_APPS } from "@/lib/apps-catalog";
+import { AppTile, SectionTitle } from "@/components/ui";
+
+export function AppStoreGrid() {
+ return (
+
+
+
+ {PLATFORM_APPS.map((app) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/components/AuthGuard.tsx b/frontend/components/AuthGuard.tsx
new file mode 100644
index 0000000..a0e2b92
--- /dev/null
+++ b/frontend/components/AuthGuard.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { useAuth } from "@/hooks/useAuth";
+import { useRouter } from "next/navigation";
+import { useEffect } from "react";
+
+export function AuthGuard({ children }: { children: React.ReactNode }) {
+ const { isAuthenticated, loading, login } = useAuth();
+ const router = useRouter();
+
+ useEffect(() => {
+ if (!loading && !isAuthenticated) {
+ login();
+ }
+ }, [loading, isAuthenticated, login]);
+
+ if (loading) {
+ return در حال بررسی احراز هویت...
;
+ }
+
+ if (!isAuthenticated) {
+ return در حال هدایت به صفحه ورود...
;
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/components/HealthStatus.tsx b/frontend/components/HealthStatus.tsx
new file mode 100644
index 0000000..1ca5196
--- /dev/null
+++ b/frontend/components/HealthStatus.tsx
@@ -0,0 +1,21 @@
+"use client";
+
+import { useTheme } from "@/hooks/useTheme";
+
+/** نمایش وضعیت سلامت backend از طریق API Client. */
+export function HealthStatus() {
+ const { theme, loading } = useTheme();
+
+ if (loading) return در حال بارگذاری تم...
;
+
+ return (
+
+
+ {theme?.site_name ?? "SuperApp"}
+
+
+ Frontend جدا از Backend — ارتباط فقط از طریق REST API
+
+
+ );
+}
diff --git a/frontend/components/SiteHeader.tsx b/frontend/components/SiteHeader.tsx
new file mode 100644
index 0000000..67dc3d8
--- /dev/null
+++ b/frontend/components/SiteHeader.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import Link from "next/link";
+import { useAuth } from "@/hooks/useAuth";
+import { useHeaderSession } from "@/hooks/useHeaderSession";
+import { useTheme } from "@/hooks/useTheme";
+import { Button, Container } from "@/components/ui";
+
+export function SiteHeader() {
+ const { theme } = useTheme();
+ const { login, loading, isAuthenticated, user } = useAuth();
+ const { isSsoAuth, isLoggedIn } = useHeaderSession();
+ const isPlatformAdmin =
+ user?.roles?.includes("platform_admin") || user?.roles?.includes("tenant_admin");
+
+ return (
+
+ );
+}
diff --git a/frontend/components/TenantSwitcher.tsx b/frontend/components/TenantSwitcher.tsx
new file mode 100644
index 0000000..f3a7946
--- /dev/null
+++ b/frontend/components/TenantSwitcher.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useState } from "react";
+import { api, ApiError, type MembershipSummary } from "@/lib/api";
+
+export interface TenantSwitcherProps {
+ memberships: MembershipSummary[];
+ currentTenantId: string | null;
+ onSwitched: () => void;
+}
+
+/** انتخابگر ساده tenant جاری — فقط زمانی نمایش داده میشود که کاربر بیش از یک workspace داشته باشد. */
+export function TenantSwitcher({
+ memberships,
+ currentTenantId,
+ onSwitched,
+}: TenantSwitcherProps) {
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ if (memberships.length <= 1) return null;
+
+ const handleChange = async (tenantId: string) => {
+ if (tenantId === currentTenantId) return;
+ setError("");
+ setLoading(true);
+ try {
+ await api.tenantContext.switchTenant(tenantId);
+ onSwitched();
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "تغییر workspace ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+ {error &&
{error}
}
+
+ );
+}
diff --git a/frontend/components/ThemeProvider.tsx b/frontend/components/ThemeProvider.tsx
new file mode 100644
index 0000000..4363b2f
--- /dev/null
+++ b/frontend/components/ThemeProvider.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import type { ReactNode } from "react";
+import { useTheme } from "@/hooks/useTheme";
+
+export function ThemeProvider({ children }: { children: ReactNode }) {
+ const { loading } = useTheme();
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return <>{children}>;
+}
diff --git a/frontend/components/admin/AdminShell.tsx b/frontend/components/admin/AdminShell.tsx
new file mode 100644
index 0000000..f00844d
--- /dev/null
+++ b/frontend/components/admin/AdminShell.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { ReactNode } from "react";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { clearTokens, loginWithRedirect } from "@/lib/auth";
+
+const NAV_ITEMS: { href: string; label: string; icon: string }[] = [
+ { href: "/admin", label: "نمای کلی", icon: "▦" },
+ { href: "/admin/tenants", label: "Tenantها", icon: "🏢" },
+ { href: "/admin/users", label: "کاربران", icon: "👤" },
+ { href: "/admin/plans", label: "پلنها", icon: "📦" },
+ { href: "/admin/features", label: "قابلیتها", icon: "🧩" },
+ { href: "/admin/services", label: "سرویسها", icon: "🛠" },
+ { href: "/admin/settings", label: "حساب کاربری", icon: "⚙" },
+];
+
+function isActive(pathname: string, href: string): boolean {
+ if (href === "/admin") return pathname === "/admin";
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
+
+export function AdminShell({ children }: { children: ReactNode }) {
+ const pathname = usePathname();
+
+ const handleLogout = () => {
+ clearTokens();
+ void loginWithRedirect("/admin");
+ };
+
+ return (
+
+
+
+ {/* ناوبری موبایل */}
+
+
+ {NAV_ITEMS.map((item) => {
+ const active = isActive(pathname, item.href);
+ return (
+
+ {item.label}
+
+ );
+ })}
+
+
{children}
+
+
+ );
+}
diff --git a/frontend/components/admin/controls.tsx b/frontend/components/admin/controls.tsx
new file mode 100644
index 0000000..ca0c459
--- /dev/null
+++ b/frontend/components/admin/controls.tsx
@@ -0,0 +1,238 @@
+"use client";
+
+import {
+ ChangeEvent,
+ ReactNode,
+ SelectHTMLAttributes,
+ TextareaHTMLAttributes,
+ InputHTMLAttributes,
+} from "react";
+import { Button } from "@/components/ui";
+
+export const inputClass =
+ "mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
+
+export const labelClass = "block text-sm font-medium text-secondary";
+
+export const cardClass =
+ "rounded-2xl border border-gray-100 bg-white p-6 shadow-sm";
+
+export function Field({
+ label,
+ hint,
+ children,
+ required,
+}: {
+ label: string;
+ hint?: string;
+ required?: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ );
+}
+
+interface TextFieldProps extends InputHTMLAttributes {
+ label: string;
+ hint?: string;
+ onValue?: (v: string) => void;
+}
+
+export function TextField({ label, hint, required, onValue, onChange, className, ...props }: TextFieldProps) {
+ return (
+
+ ) => {
+ onValue?.(e.target.value);
+ onChange?.(e);
+ }}
+ {...props}
+ />
+
+ );
+}
+
+interface TextareaFieldProps extends TextareaHTMLAttributes {
+ label: string;
+ hint?: string;
+ onValue?: (v: string) => void;
+}
+
+export function TextareaField({ label, hint, required, onValue, onChange, ...props }: TextareaFieldProps) {
+ return (
+
+
+ );
+}
+
+interface SelectFieldProps extends SelectHTMLAttributes {
+ label: string;
+ hint?: string;
+ options: { value: string; label: string }[];
+ onValue?: (v: string) => void;
+}
+
+export function SelectField({ label, hint, required, options, onValue, onChange, ...props }: SelectFieldProps) {
+ return (
+
+
+
+ );
+}
+
+export function Banner({
+ variant,
+ children,
+}: {
+ variant: "error" | "success" | "info";
+ children: ReactNode;
+}) {
+ if (!children) return null;
+ const styles = {
+ error: "bg-red-50 text-red-600",
+ success: "bg-green-50 text-green-700",
+ info: "bg-blue-50 text-blue-700",
+ }[variant];
+ return (
+ {children}
+ );
+}
+
+export function Modal({
+ open,
+ title,
+ onClose,
+ children,
+ footer,
+}: {
+ open: boolean;
+ title: string;
+ onClose: () => void;
+ children: ReactNode;
+ footer?: ReactNode;
+}) {
+ if (!open) return null;
+ return (
+
+
e.stopPropagation()}
+ >
+
+
{title}
+
+
+ {children}
+ {footer &&
{footer}
}
+
+
+ );
+}
+
+const STATUS_STYLES: Record = {
+ active: "bg-green-100 text-green-700",
+ trialing: "bg-blue-100 text-blue-700",
+ pending_activation: "bg-amber-100 text-amber-700",
+ draft: "bg-gray-100 text-gray-600",
+ suspended: "bg-red-100 text-red-700",
+ inactive: "bg-gray-100 text-gray-600",
+ maintenance: "bg-amber-100 text-amber-700",
+ archived: "bg-gray-100 text-gray-500",
+ deleted: "bg-red-100 text-red-700",
+ canceled: "bg-red-100 text-red-700",
+ expired: "bg-gray-100 text-gray-500",
+ past_due: "bg-amber-100 text-amber-700",
+};
+
+const STATUS_LABELS: Record = {
+ active: "فعال",
+ trialing: "آزمایشی",
+ pending_activation: "در انتظار فعالسازی",
+ draft: "پیشنویس",
+ suspended: "معلق",
+ inactive: "غیرفعال",
+ maintenance: "در حال تعمیر",
+ archived: "بایگانیشده",
+ deleted: "حذفشده",
+ canceled: "لغوشده",
+ expired: "منقضی",
+ past_due: "معوق",
+};
+
+export function StatusBadge({ status }: { status: string }) {
+ const style = STATUS_STYLES[status] || "bg-gray-100 text-gray-600";
+ return (
+
+ {STATUS_LABELS[status] || status}
+
+ );
+}
+
+export function EmptyState({ message }: { message: string }) {
+ return {message}
;
+}
+
+export function AdminPageHeader({
+ title,
+ subtitle,
+ action,
+}: {
+ title: string;
+ subtitle?: string;
+ action?: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {subtitle &&
{subtitle}
}
+
+ {action &&
{action}
}
+
+ );
+}
+
+export { Button };
diff --git a/frontend/components/auth/AuthPanel.tsx b/frontend/components/auth/AuthPanel.tsx
new file mode 100644
index 0000000..fc9e860
--- /dev/null
+++ b/frontend/components/auth/AuthPanel.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import { storeTokens, consumePostLoginRedirect, fetchLoginUrl } from "@/lib/auth";
+import { identityApi } from "@/lib/identity";
+import {
+ isValidMobile,
+ normalizeOtpCode,
+ normalizePhone,
+ persianToEnglish,
+} from "@/lib/phone";
+import { useAuth } from "@/hooks/useAuth";
+import { Button } from "@/components/ui";
+
+type Step = "phone" | "code";
+
+export interface AuthPanelProps {
+ /** true = نمایش فیلدهای ثبتنام از ابتدا */
+ initialSignup?: boolean;
+ /** مسیر بعد از ورود؛ اگر نباشد از sessionStorage خوانده میشود */
+ redirectTo?: string;
+}
+
+function ProfileFields({
+ displayName,
+ setDisplayName,
+ email,
+ setEmail,
+ username,
+ setUsername,
+ password,
+ setPassword,
+ disabled,
+}: {
+ displayName: string;
+ setDisplayName: (v: string) => void;
+ email: string;
+ setEmail: (v: string) => void;
+ username: string;
+ setUsername: (v: string) => void;
+ password: string;
+ setPassword: (v: string) => void;
+ disabled?: boolean;
+}) {
+ return (
+
+ );
+}
+
+export function AuthPanel({ initialSignup = false, redirectTo }: AuthPanelProps) {
+ const router = useRouter();
+ const { isAuthenticated, reload } = useAuth();
+ const [passwordLoading, setPasswordLoading] = useState(false);
+
+ const [step, setStep] = useState("phone");
+ const [signupMode, setSignupMode] = useState(initialSignup);
+ const [intent, setIntent] = useState<"login" | "register">("login");
+ const [mobile, setMobile] = useState("");
+ const [displayName, setDisplayName] = useState("");
+ const [email, setEmail] = useState("");
+ const [username, setUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [code, setCode] = useState("");
+ const [countdown, setCountdown] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const verifyingRef = useRef(false);
+
+ const showProfileFields =
+ signupMode || (step === "code" && intent === "register");
+
+ const profileComplete =
+ displayName.trim().length > 0 &&
+ email.trim().length > 0 &&
+ username.trim().length >= 3 &&
+ password.length >= 8;
+
+ useEffect(() => {
+ const params = new URLSearchParams(window.location.search);
+ if (params.get("signup") === "1") setSignupMode(true);
+ const prefilled = params.get("mobile");
+ if (prefilled) setMobile(prefilled);
+ }, []);
+
+ useEffect(() => {
+ if (countdown <= 0) return;
+ const timer = setTimeout(() => setCountdown((c) => c - 1), 1000);
+ return () => clearTimeout(timer);
+ }, [countdown]);
+
+ const finishSession = useCallback(
+ async (tokens: {
+ access_token: string;
+ refresh_token?: string;
+ expires_in: number;
+ token_type: string;
+ }) => {
+ storeTokens(tokens);
+ await reload();
+ router.replace(redirectTo ?? consumePostLoginRedirect("/dashboard"));
+ },
+ [redirectTo, reload, router]
+ );
+
+ const validateProfile = useCallback(() => {
+ if (!profileComplete) {
+ setError("نام، ایمیل، نام کاربری (حداقل ۳ کاراکتر) و رمز (حداقل ۸) الزامی است");
+ return false;
+ }
+ return true;
+ }, [profileComplete]);
+
+ const handleRequestOtp = async (e?: FormEvent) => {
+ e?.preventDefault();
+ setError("");
+ const normalized = normalizePhone(mobile);
+ if (!isValidMobile(normalized)) {
+ setError("شماره موبایل صحیح نیست");
+ return;
+ }
+ if (signupMode && !validateProfile()) return;
+
+ setMobile(normalized);
+ setLoading(true);
+ try {
+ const res = await identityApi.mobileStart(normalized);
+ setIntent(res.intent);
+ if (res.intent === "login" && signupMode) {
+ setSignupMode(false);
+ setError("این شماره قبلاً ثبت شده — کد ورود ارسال شد");
+ }
+ setCountdown(res.expires_in);
+ setStep("code");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ارسال کد ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleVerify = useCallback(
+ async (codeOverride?: string) => {
+ if (verifyingRef.current) return;
+ const normalizedCode = normalizeOtpCode(codeOverride ?? code);
+ if (normalizedCode.length !== 4) {
+ setError("کد تأیید باید ۴ رقم باشد");
+ return;
+ }
+ if (intent === "register" && !validateProfile()) return;
+
+ verifyingRef.current = true;
+ setLoading(true);
+ let keepLoading = false;
+ try {
+ const session = await identityApi.mobileComplete({
+ mobile,
+ code: normalizedCode,
+ display_name: displayName.trim() || undefined,
+ email: showProfileFields && profileComplete ? email.trim() : undefined,
+ username: showProfileFields && profileComplete ? username.trim() : undefined,
+ password: showProfileFields && profileComplete ? password : undefined,
+ });
+ keepLoading = true;
+ await finishSession(session);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "تأیید کد ناموفق بود");
+ } finally {
+ if (!keepLoading) {
+ setLoading(false);
+ verifyingRef.current = false;
+ }
+ }
+ },
+ [
+ code,
+ displayName,
+ email,
+ finishSession,
+ intent,
+ mobile,
+ password,
+ profileComplete,
+ showProfileFields,
+ username,
+ validateProfile,
+ ]
+ );
+
+ const handleCodeChange = (value: string) => {
+ const next = normalizeOtpCode(value);
+ setCode(next);
+ const canAutoVerify =
+ intent === "login" || (intent === "register" && profileComplete);
+ if (next.length === 4 && canAutoVerify && !verifyingRef.current && !loading) {
+ void handleVerify(next);
+ }
+ };
+
+ if (isAuthenticated) {
+ return (
+
+
+
شما وارد شدهاید.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {signupMode ? "ثبتنام" : "ورود"}
+
+
+ {step === "phone"
+ ? "شماره موبایل خود را وارد کنید"
+ : intent === "login"
+ ? "کد ورود را وارد کنید"
+ : "کد تأیید و اطلاعات حساب را تکمیل کنید"}
+
+
+
+ {error && (
+ {error}
+ )}
+
+ {step === "phone" ? (
+
+ ) : (
+
+
+ کد به{" "}
+
+ {mobile}
+ {" "}
+ ارسال شد
+
+
+ {countdown > 0 && (
+
ارسال مجدد تا {countdown} ثانیه
+ )}
+
+
handleCodeChange(e.target.value)}
+ className="w-full rounded-xl border border-gray-200 px-4 py-3 text-center font-mono text-lg tracking-[0.4em]"
+ placeholder="••••"
+ />
+
+ {intent === "register" && !signupMode && (
+
+ )}
+
+
+
+
+
+ )}
+
+
+ بازگشت به صفحه اصلی
+
+
+
+ );
+}
diff --git a/frontend/components/settings/AccountSettings.tsx b/frontend/components/settings/AccountSettings.tsx
new file mode 100644
index 0000000..eeece2c
--- /dev/null
+++ b/frontend/components/settings/AccountSettings.tsx
@@ -0,0 +1,331 @@
+"use client";
+
+import { FormEvent, useCallback, useEffect, useState } from "react";
+import Link from "next/link";
+import { getStoredToken } from "@/lib/auth";
+import { identityApi, type AccountProfile } from "@/lib/identity";
+import { Button, Container } from "@/components/ui";
+
+const inputClass =
+ "mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
+
+const labelClass = "block text-sm font-medium text-secondary";
+
+interface AccountSettingsProps {
+ backHref?: string;
+ backLabel?: string;
+}
+
+export function AccountSettings({ backHref, backLabel }: AccountSettingsProps) {
+ const [profile, setProfile] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [loadError, setLoadError] = useState("");
+
+ const load = useCallback(async () => {
+ const token = getStoredToken();
+ if (!token) {
+ setLoadError("توکن احراز هویت یافت نشد. لطفاً دوباره وارد شوید.");
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setLoadError("");
+ try {
+ const data = await identityApi.profile(token);
+ setProfile(data);
+ } catch (err) {
+ setLoadError(err instanceof Error ? err.message : "دریافت پروفایل ناموفق بود");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return (
+
+
+
+
تنظیمات حساب
+
+ مدیریت پروفایل و رمز عبور حساب کاربری شما
+
+
+ {backHref && (
+
+
+
+ )}
+
+
+ {loadError && (
+
+ {loadError}
+
+ )}
+
+ {loading ? (
+ در حال بارگذاری...
+ ) : profile ? (
+
+ ) : null}
+
+ );
+}
+
+function ProfileForm({
+ profile,
+ onUpdated,
+}: {
+ profile: AccountProfile;
+ onUpdated: (p: AccountProfile) => void;
+}) {
+ const [displayName, setDisplayName] = useState(profile.display_name || "");
+ const [email, setEmail] = useState(profile.email || "");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSuccess("");
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن احراز هویت یافت نشد.");
+ return;
+ }
+ setSaving(true);
+ try {
+ const updated = await identityApi.updateProfile(token, {
+ display_name: displayName.trim() || null,
+ email: email.trim() || undefined,
+ });
+ onUpdated(updated);
+ setSuccess("پروفایل با موفقیت ذخیره شد.");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "ذخیره پروفایل ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+function PasswordForm() {
+ const [currentPassword, setCurrentPassword] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const [success, setSuccess] = useState("");
+
+ const handleSubmit = async (e: FormEvent) => {
+ e.preventDefault();
+ setError("");
+ setSuccess("");
+
+ if (newPassword.length < 8) {
+ setError("رمز عبور جدید باید حداقل ۸ کاراکتر باشد.");
+ return;
+ }
+ if (newPassword !== confirmPassword) {
+ setError("رمز عبور جدید و تکرار آن یکسان نیستند.");
+ return;
+ }
+
+ const token = getStoredToken();
+ if (!token) {
+ setError("توکن احراز هویت یافت نشد.");
+ return;
+ }
+
+ setSaving(true);
+ try {
+ await identityApi.changePassword(token, {
+ current_password: currentPassword.trim() || undefined,
+ new_password: newPassword,
+ });
+ setSuccess("رمز عبور با موفقیت بهروزرسانی شد.");
+ setCurrentPassword("");
+ setNewPassword("");
+ setConfirmPassword("");
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "تغییر رمز عبور ناموفق بود");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/AppTile.tsx b/frontend/components/ui/AppTile.tsx
new file mode 100644
index 0000000..885ce21
--- /dev/null
+++ b/frontend/components/ui/AppTile.tsx
@@ -0,0 +1,37 @@
+import type { PlatformApp } from "@/lib/apps-catalog";
+import { Badge } from "./Badge";
+
+export interface AppTileProps {
+ app: PlatformApp;
+ onClick?: () => void;
+}
+
+export function AppTile({ app, onClick }: AppTileProps) {
+ const isAvailable = app.status === "available";
+
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/Badge.tsx b/frontend/components/ui/Badge.tsx
new file mode 100644
index 0000000..f393c47
--- /dev/null
+++ b/frontend/components/ui/Badge.tsx
@@ -0,0 +1,23 @@
+import type { ReactNode } from "react";
+
+export interface BadgeProps {
+ children: ReactNode;
+ variant?: "default" | "primary" | "muted";
+ className?: string;
+}
+
+const variants = {
+ default: "bg-gray-100 text-gray-700",
+ primary: "bg-[var(--color-primary-muted)] text-primary",
+ muted: "bg-[var(--color-secondary-muted)] text-secondary/70",
+};
+
+export function Badge({ children, variant = "default", className = "" }: BadgeProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/ui/Button.tsx b/frontend/components/ui/Button.tsx
new file mode 100644
index 0000000..23f997f
--- /dev/null
+++ b/frontend/components/ui/Button.tsx
@@ -0,0 +1,59 @@
+import type { ButtonHTMLAttributes, ReactNode } from "react";
+
+type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
+type ButtonSize = "sm" | "md" | "lg";
+
+export interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: ButtonVariant;
+ size?: ButtonSize;
+ children: ReactNode;
+ loading?: boolean;
+ loadingText?: string;
+}
+
+const variantClasses: Record = {
+ primary: "bg-primary text-white hover:opacity-90 shadow-sm",
+ secondary: "bg-secondary text-white hover:opacity-90",
+ ghost: "bg-transparent text-secondary hover:bg-[var(--color-secondary-muted)]",
+ outline:
+ "border border-gray-200 bg-white text-secondary hover:border-primary hover:text-primary",
+};
+
+const sizeClasses: Record = {
+ sm: "px-3 py-1.5 text-sm",
+ md: "px-5 py-2.5 text-sm",
+ lg: "px-6 py-3 text-base",
+};
+
+export function Button({
+ variant = "primary",
+ size = "md",
+ className = "",
+ children,
+ loading = false,
+ loadingText,
+ disabled,
+ ...props
+}: ButtonProps) {
+ return (
+
+ );
+}
diff --git a/frontend/components/ui/Container.tsx b/frontend/components/ui/Container.tsx
new file mode 100644
index 0000000..23edaaa
--- /dev/null
+++ b/frontend/components/ui/Container.tsx
@@ -0,0 +1,14 @@
+import type { ReactNode } from "react";
+
+export interface ContainerProps {
+ children: ReactNode;
+ className?: string;
+}
+
+export function Container({ children, className = "" }: ContainerProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/frontend/components/ui/SectionTitle.tsx b/frontend/components/ui/SectionTitle.tsx
new file mode 100644
index 0000000..2f9bd3e
--- /dev/null
+++ b/frontend/components/ui/SectionTitle.tsx
@@ -0,0 +1,19 @@
+import type { ReactNode } from "react";
+
+export interface SectionTitleProps {
+ title: string;
+ subtitle?: string;
+ action?: ReactNode;
+}
+
+export function SectionTitle({ title, subtitle, action }: SectionTitleProps) {
+ return (
+
+
+
{title}
+ {subtitle ?
{subtitle}
: null}
+
+ {action}
+
+ );
+}
diff --git a/frontend/components/ui/index.ts b/frontend/components/ui/index.ts
new file mode 100644
index 0000000..b37828a
--- /dev/null
+++ b/frontend/components/ui/index.ts
@@ -0,0 +1,10 @@
+export { AppTile } from "./AppTile";
+export type { AppTileProps } from "./AppTile";
+export { Badge } from "./Badge";
+export type { BadgeProps } from "./Badge";
+export { Button } from "./Button";
+export type { ButtonProps } from "./Button";
+export { Container } from "./Container";
+export type { ContainerProps } from "./Container";
+export { SectionTitle } from "./SectionTitle";
+export type { SectionTitleProps } from "./SectionTitle";
diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts
new file mode 100644
index 0000000..04cd315
--- /dev/null
+++ b/frontend/hooks/useAuth.ts
@@ -0,0 +1,71 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import {
+ fetchAuthConfig,
+ fetchLoginUrl,
+ loginWithRedirect,
+ fetchMe,
+ getStoredToken,
+ clearTokens,
+ logout,
+ type AuthConfig,
+} from "@/lib/auth";
+
+interface AuthUser {
+ user_id: string;
+ email: string | null;
+ username: string | null;
+ roles: string[];
+}
+
+export function useAuth() {
+ const [config, setConfig] = useState(null);
+ const [user, setUser] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [isAuthenticated, setIsAuthenticated] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ try {
+ const cfg = await fetchAuthConfig();
+ setConfig(cfg);
+ const token = getStoredToken();
+ if (token) {
+ try {
+ const me = await fetchMe(token);
+ setUser(me);
+ setIsAuthenticated(true);
+ } catch {
+ clearTokens();
+ setUser(null);
+ setIsAuthenticated(false);
+ }
+ } else {
+ setUser(null);
+ setIsAuthenticated(false);
+ }
+ } catch {
+ clearTokens();
+ setUser(null);
+ setIsAuthenticated(false);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ const login = async (redirectPath?: string) => {
+ await loginWithRedirect(redirectPath);
+ };
+
+ const signOut = async () => {
+ const cfg = config || (await fetchAuthConfig());
+ await logout(cfg);
+ };
+
+ return { user, config, loading, isAuthenticated, login, signOut, reload: load };
+}
diff --git a/frontend/hooks/useHeaderSession.ts b/frontend/hooks/useHeaderSession.ts
new file mode 100644
index 0000000..0f5de3b
--- /dev/null
+++ b/frontend/hooks/useHeaderSession.ts
@@ -0,0 +1,30 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePathname } from "next/navigation";
+import { getStoredToken } from "@/lib/auth";
+
+/** تشخیص ورود SSO مرکزی. */
+export function useHeaderSession() {
+ const pathname = usePathname();
+ const [isSsoAuth, setIsSsoAuth] = useState(false);
+
+ useEffect(() => {
+ const sync = () => {
+ setIsSsoAuth(!!getStoredToken());
+ };
+ sync();
+ window.addEventListener("focus", sync);
+ window.addEventListener("storage", sync);
+ return () => {
+ window.removeEventListener("focus", sync);
+ window.removeEventListener("storage", sync);
+ };
+ }, [pathname]);
+
+ return {
+ isAdminAuth: false,
+ isSsoAuth,
+ isLoggedIn: isSsoAuth,
+ };
+}
diff --git a/frontend/hooks/useMe.ts b/frontend/hooks/useMe.ts
new file mode 100644
index 0000000..60cc0c8
--- /dev/null
+++ b/frontend/hooks/useMe.ts
@@ -0,0 +1,37 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { api, type MeResponse } from "@/lib/api";
+import { getStoredToken } from "@/lib/auth";
+
+/** بارگذاری وضعیت کاربر جاری (عضویتها و نیاز به onboarding) از `/api/v1/me`. */
+export function useMe() {
+ const [me, setMe] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState("");
+
+ const load = useCallback(async () => {
+ if (!getStoredToken()) {
+ setMe(null);
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ setError("");
+ try {
+ const data = await api.me.get();
+ setMe(data);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "خطا در دریافت اطلاعات کاربر");
+ setMe(null);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ load();
+ }, [load]);
+
+ return { me, loading, error, reload: load };
+}
diff --git a/frontend/hooks/useTheme.ts b/frontend/hooks/useTheme.ts
new file mode 100644
index 0000000..f9f1f20
--- /dev/null
+++ b/frontend/hooks/useTheme.ts
@@ -0,0 +1,20 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { applyTheme, loadTheme, type ThemeConfig } from "@/lib/theme";
+
+/** بارگذاری و اعمال تم White-label در زمان mount. */
+export function useTheme() {
+ const [theme, setTheme] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ loadTheme().then((t) => {
+ applyTheme(t);
+ setTheme(t);
+ setLoading(false);
+ });
+ }, []);
+
+ return { theme, loading };
+}
diff --git a/frontend/lib/api-client.ts b/frontend/lib/api-client.ts
new file mode 100644
index 0000000..05edad1
--- /dev/null
+++ b/frontend/lib/api-client.ts
@@ -0,0 +1,61 @@
+/**
+ * API Client — تنها راه ارتباط frontend با backend.
+ * توکن SSO از sessionStorage به هدر Authorization اضافه میشود.
+ */
+
+import { getStoredToken } from "./auth";
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+interface RequestOptions extends RequestInit {
+ tenantId?: string;
+ tenantSlug?: string;
+}
+
+async function request(path: string, options: RequestOptions = {}): Promise {
+ const { tenantId, tenantSlug, headers: customHeaders, ...init } = options;
+
+ const headers = new Headers(customHeaders);
+ headers.set("Content-Type", "application/json");
+ const token = getStoredToken();
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ if (tenantId) headers.set("X-Tenant-ID", tenantId);
+ if (tenantSlug) headers.set("X-Tenant-Slug", tenantSlug);
+
+ const res = await fetch(`${API_BASE}${path}`, { ...init, headers });
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new ApiError(
+ res.status,
+ body?.error?.code || "unknown_error",
+ body?.error?.message || res.statusText
+ );
+ }
+
+ return res.json() as Promise;
+}
+
+export const api = {
+ health: () => request<{ status: string; service: string }>("/health"),
+
+ tenants: {
+ list: (page = 1, pageSize = 20) =>
+ request(`/api/v1/tenants?page=${page}&page_size=${pageSize}`),
+ get: (id: string) => request(`/api/v1/tenants/${id}`),
+ create: (data: { name: string; slug: string }) =>
+ request("/api/v1/tenants", { method: "POST", body: JSON.stringify(data) }),
+ },
+};
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
new file mode 100644
index 0000000..9361a22
--- /dev/null
+++ b/frontend/lib/api.ts
@@ -0,0 +1,464 @@
+/**
+ * API Client — ارتباط frontend با Core Backend (پنل ادمین / tenant).
+ * توکن SSO مرکزی (Keycloak) از sessionStorage استفاده میشود.
+ */
+
+import { getStoredToken } from "@/lib/auth";
+
+const API_BASE =
+ process.env.NEXT_PUBLIC_API_BASE_URL ||
+ process.env.NEXT_PUBLIC_BACKEND_URL ||
+ "http://localhost:8000";
+
+export class ApiError extends Error {
+ constructor(
+ public status: number,
+ public code: string,
+ message: string
+ ) {
+ super(message);
+ this.name = "ApiError";
+ }
+}
+
+interface RequestOptions extends RequestInit {
+ auth?: boolean;
+}
+
+async function request(
+ path: string,
+ options: RequestOptions = {}
+): Promise {
+ const { auth = true, headers: customHeaders, ...init } = options;
+
+ const headers = new Headers(customHeaders);
+ headers.set("Content-Type", "application/json");
+
+ if (auth) {
+ const token = getStoredToken();
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(`${API_BASE}${path}`, { ...init, headers });
+ } catch {
+ throw new Error(`اتصال به سرور برقرار نشد (${API_BASE})`);
+ }
+
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ throw new ApiError(
+ res.status,
+ body?.error?.code || "unknown_error",
+ body?.error?.message || res.statusText
+ );
+ }
+
+ if (res.status === 204) return undefined as T;
+ return res.json() as Promise;
+}
+
+export interface OtpRequestResult {
+ message: string;
+ expires_in: number;
+ skipped?: boolean;
+}
+
+type OtpContext = "public" | "admin";
+
+export interface OtpVerifyResult {
+ access_token: string;
+ refresh_token: string;
+ token_type: string;
+ expires_in: number;
+ is_new_user: boolean;
+ user_id: string;
+ role: string;
+}
+
+export interface Tenant {
+ id: string;
+ name: string;
+ slug: string;
+ status: string;
+ owner_user_id: string | null;
+ business_type?: string | null;
+ default_locale?: string;
+ timezone?: string;
+ primary_color?: string | null;
+ secondary_color?: string | null;
+ logo_url?: string | null;
+ favicon_url?: string | null;
+ onboarding_completed?: boolean;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Page {
+ items: T[];
+ meta: {
+ page: number;
+ page_size: number;
+ total_items: number;
+ total_pages: number;
+ };
+}
+
+// ---- فاز ۳: Onboarding و Tenant Context ----
+
+export interface MembershipSummary {
+ tenant_id: string;
+ tenant_name: string;
+ tenant_slug: string;
+ tenant_status: string;
+ onboarding_completed: boolean;
+ role: string;
+ status: string;
+ is_owner: boolean;
+}
+
+export interface MeResponse {
+ user_id: string;
+ mobile: string;
+ email: string | null;
+ platform_role: string;
+ onboarding_required: boolean;
+ current_tenant_id: string | null;
+ memberships: MembershipSummary[];
+}
+
+export interface TenantDomain {
+ id: string;
+ tenant_id: string;
+ domain: string;
+ domain_type: string;
+ is_verified: boolean;
+ verified_at: string | null;
+ created_at: string;
+}
+
+export interface TenantContext {
+ tenant: Tenant;
+ role: string | null;
+ is_owner: boolean;
+ plan_code: string | null;
+ plan_name: string | null;
+ subscription_status: string | null;
+ domains: TenantDomain[];
+ primary_domain: string | null;
+}
+
+export interface OnboardingTenantCreateInput {
+ business_name: string;
+ slug: string;
+ business_type?: string;
+ default_locale?: string;
+ timezone?: string;
+}
+
+export interface OnboardingBrandingInput {
+ primary_color?: string;
+ secondary_color?: string;
+ logo_url?: string;
+ favicon_url?: string;
+}
+
+export interface OnboardingDomainInput {
+ custom_domain?: string;
+}
+
+// ---- مدیریت پلتفرم (پنل ادمین): پلن، قابلیت، اشتراک، سرویس ----
+
+export interface Plan {
+ id: string;
+ code: string;
+ name: string;
+ description: string | null;
+ is_active: boolean;
+ created_at: string;
+}
+
+export interface Feature {
+ id: string;
+ feature_key: string;
+ name: string;
+ description: string | null;
+ service_key: string;
+ is_active: boolean;
+}
+
+export type LimitPeriod = "day" | "month" | "year" | "total";
+
+export interface PlanFeature {
+ id: string;
+ plan_id: string;
+ feature_id: string;
+ limit_value: number | null;
+ limit_period: LimitPeriod | null;
+ is_enabled: boolean;
+}
+
+export type SubscriptionStatus =
+ | "trialing"
+ | "active"
+ | "past_due"
+ | "canceled"
+ | "expired";
+
+export interface Subscription {
+ id: string;
+ tenant_id: string;
+ plan_id: string;
+ status: SubscriptionStatus;
+ starts_at: string | null;
+ ends_at: string | null;
+ trial_ends_at: string | null;
+ created_at: string;
+}
+
+export type ServiceStatus = "active" | "inactive" | "maintenance";
+
+export interface ServiceEntry {
+ id: string;
+ service_key: string;
+ name: string;
+ base_url: string | null;
+ status: ServiceStatus;
+ health_check_url: string | null;
+ created_at: string;
+}
+
+export interface FeatureCheckResult {
+ tenant_id: string;
+ feature_key: string;
+ has_access: boolean;
+ reason: string | null;
+}
+
+export const api = {
+ auth: {
+ requestOtp: (mobile: string, options?: { forceSms?: boolean; context?: OtpContext }) =>
+ request("/api/v1/auth/otp/request", {
+ method: "POST",
+ auth: false,
+ body: JSON.stringify({
+ mobile,
+ context: options?.context ?? "admin",
+ ...(options?.forceSms ? { force_sms: true } : {}),
+ }),
+ }),
+
+ verifyOtp: (mobile: string, code: string, context: OtpContext = "admin") =>
+ request("/api/v1/auth/otp/verify", {
+ method: "POST",
+ auth: false,
+ body: JSON.stringify({ mobile, code, context }),
+ }),
+ },
+
+ tenants: {
+ list: (page = 1, pageSize = 20) =>
+ request>(
+ `/api/v1/admin/tenants?page=${page}&page_size=${pageSize}`
+ ),
+
+ get: (id: string) => request(`/api/v1/admin/tenants/${id}`),
+
+ create: (data: { name: string; slug: string; custom_domain?: string }) =>
+ request("/api/v1/admin/tenants", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ update: (id: string, data: { name?: string }) =>
+ request(`/api/v1/admin/tenants/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ me: {
+ get: () => request("/api/v1/me"),
+ tenants: () => request("/api/v1/me/tenants"),
+ },
+
+ onboarding: {
+ createTenant: (data: OnboardingTenantCreateInput) =>
+ request("/api/v1/onboarding/tenant", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ updateBranding: (tenantId: string, data: OnboardingBrandingInput) =>
+ request(`/api/v1/onboarding/tenant/${tenantId}/branding`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+
+ updateDomain: (tenantId: string, data: OnboardingDomainInput) =>
+ request(`/api/v1/onboarding/tenant/${tenantId}/domain`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+
+ complete: (tenantId: string) =>
+ request(`/api/v1/onboarding/tenant/${tenantId}/complete`, {
+ method: "POST",
+ }),
+ },
+
+ tenantContext: {
+ current: () => request("/api/v1/tenant/current"),
+
+ switchTenant: (tenantId: string) =>
+ request("/api/v1/tenant/switch", {
+ method: "POST",
+ body: JSON.stringify({ tenant_id: tenantId }),
+ }),
+ },
+
+ // مدیریت پلتفرمی tenantها (نیازمند نقش platform_admin)
+ platformTenants: {
+ list: (page = 1, pageSize = 50) =>
+ request>(`/api/v1/tenants?page=${page}&page_size=${pageSize}`),
+
+ get: (id: string) => request(`/api/v1/tenants/${id}`),
+
+ create: (data: {
+ name: string;
+ slug: string;
+ owner_user_id?: string | null;
+ custom_domain?: string | null;
+ }) =>
+ request("/api/v1/tenants", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ update: (
+ id: string,
+ data: { name?: string; status?: string; owner_user_id?: string | null }
+ ) =>
+ request(`/api/v1/tenants/${id}`, {
+ method: "PATCH",
+ body: JSON.stringify(data),
+ }),
+
+ suspend: (id: string) =>
+ request(`/api/v1/tenants/${id}/suspend`, { method: "POST" }),
+
+ activate: (id: string) =>
+ request(`/api/v1/tenants/${id}/activate`, { method: "POST" }),
+ },
+
+ plans: {
+ list: (page = 1, pageSize = 50) =>
+ request>(`/api/v1/plans?page=${page}&page_size=${pageSize}`),
+
+ create: (data: {
+ code: string;
+ name: string;
+ description?: string | null;
+ is_active?: boolean;
+ }) =>
+ request("/api/v1/plans", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ attachFeature: (
+ planId: string,
+ data: {
+ feature_id: string;
+ limit_value?: number | null;
+ limit_period?: LimitPeriod | null;
+ is_enabled?: boolean;
+ }
+ ) =>
+ request(`/api/v1/plans/${planId}/features`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ features: {
+ list: (page = 1, pageSize = 100) =>
+ request>(`/api/v1/features?page=${page}&page_size=${pageSize}`),
+
+ create: (data: {
+ feature_key: string;
+ name: string;
+ description?: string | null;
+ service_key: string;
+ is_active?: boolean;
+ }) =>
+ request("/api/v1/features", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ domains: {
+ listForTenant: (tenantId: string) =>
+ request(`/api/v1/tenants/${tenantId}/domains`),
+
+ create: (
+ tenantId: string,
+ data: { domain: string; domain_type?: "subdomain" | "custom_domain" }
+ ) =>
+ request(`/api/v1/tenants/${tenantId}/domains`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+ },
+
+ subscriptions: {
+ get: (tenantId: string) =>
+ request(`/api/v1/tenants/${tenantId}/subscription`),
+
+ create: (
+ tenantId: string,
+ data: {
+ plan_id: string;
+ status?: SubscriptionStatus;
+ starts_at?: string | null;
+ ends_at?: string | null;
+ trial_ends_at?: string | null;
+ }
+ ) =>
+ request(`/api/v1/tenants/${tenantId}/subscription`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ checkFeature: (tenantId: string, featureKey: string) =>
+ request(`/api/v1/tenants/${tenantId}/features/check`, {
+ method: "POST",
+ body: JSON.stringify({ feature_key: featureKey }),
+ }),
+ },
+
+ services: {
+ list: (page = 1, pageSize = 100) =>
+ request>(`/api/v1/services?page=${page}&page_size=${pageSize}`),
+
+ create: (data: {
+ service_key: string;
+ name: string;
+ base_url?: string | null;
+ health_check_url?: string | null;
+ status?: ServiceStatus;
+ }) =>
+ request("/api/v1/services", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ updateStatus: (serviceId: string, status: ServiceStatus) =>
+ request(`/api/v1/services/${serviceId}/status`, {
+ method: "PATCH",
+ body: JSON.stringify({ status }),
+ }),
+ },
+};
diff --git a/frontend/lib/apps-catalog.ts b/frontend/lib/apps-catalog.ts
new file mode 100644
index 0000000..c78b15f
--- /dev/null
+++ b/frontend/lib/apps-catalog.ts
@@ -0,0 +1,112 @@
+/** کاتالوگ اپهای پلتفرم — منبع واحد برای صفحهٔ اصلی و منوها. */
+
+export type AppStatus = "available" | "coming_soon";
+
+export interface PlatformApp {
+ id: string;
+ name: string;
+ description: string;
+ icon: string;
+ gradient: string;
+ status: AppStatus;
+ href?: string;
+}
+
+export const PLATFORM_APPS: PlatformApp[] = [
+ {
+ id: "accounting",
+ name: "حسابداری آنلاین",
+ description: "فاکتور، دفتر کل، گزارش مالی",
+ icon: "📊",
+ gradient: "from-sky-500 to-blue-600",
+ status: "coming_soon",
+ },
+ {
+ id: "crm",
+ name: "سیستم CRM",
+ description: "مدیریت مشتری و فروش",
+ icon: "🤝",
+ gradient: "from-violet-500 to-purple-600",
+ status: "coming_soon",
+ },
+ {
+ id: "ecommerce",
+ name: "فروشگاه آنلاین",
+ description: "کاتالوگ، سبد خرید، پرداخت",
+ icon: "🛒",
+ gradient: "from-emerald-500 to-teal-600",
+ status: "coming_soon",
+ },
+ {
+ id: "website_builder",
+ name: "سایتساز",
+ description: "ساخت صفحات و لندینگ",
+ icon: "🌐",
+ gradient: "from-cyan-500 to-sky-600",
+ status: "coming_soon",
+ },
+ {
+ id: "restaurant",
+ name: "رستوران و کافه",
+ description: "منو، سفارش، آشپزخانه",
+ icon: "🍽️",
+ gradient: "from-orange-500 to-amber-600",
+ status: "coming_soon",
+ },
+ {
+ id: "live_chat",
+ name: "گفتگوی زنده",
+ description: "پشتیبانی آنلاین real-time",
+ icon: "💬",
+ gradient: "from-indigo-500 to-blue-600",
+ status: "coming_soon",
+ },
+ {
+ id: "smart_messenger",
+ name: "پیامرسان هوشمند",
+ description: "چت تیم و کانالها",
+ icon: "📱",
+ gradient: "from-fuchsia-500 to-pink-600",
+ status: "coming_soon",
+ },
+ {
+ id: "sms_panel",
+ name: "پنل پیامک",
+ description: "ارسال انبوه و قالب پیام",
+ icon: "📨",
+ gradient: "from-lime-500 to-green-600",
+ status: "coming_soon",
+ },
+ {
+ id: "notification",
+ name: "اعلانها",
+ description: "Push، ایمیل، SMS",
+ icon: "🔔",
+ gradient: "from-yellow-500 to-orange-500",
+ status: "coming_soon",
+ },
+ {
+ id: "file_storage",
+ name: "فضای ابری",
+ description: "آپلود، اشتراک، مدیریت فایل",
+ icon: "☁️",
+ gradient: "from-slate-500 to-gray-600",
+ status: "coming_soon",
+ },
+ {
+ id: "link_shortener",
+ name: "کوتاهکننده لینک",
+ description: "لینک کوتاه و آمار کلیک",
+ icon: "🔗",
+ gradient: "from-rose-500 to-red-600",
+ status: "coming_soon",
+ },
+ {
+ id: "ai_assistant",
+ name: "دستیار هوشمند",
+ description: "AI برای کسبوکار شما",
+ icon: "✨",
+ gradient: "from-purple-500 to-indigo-600",
+ status: "coming_soon",
+ },
+];
diff --git a/frontend/lib/auth.ts b/frontend/lib/auth.ts
new file mode 100644
index 0000000..3ba57e7
--- /dev/null
+++ b/frontend/lib/auth.ts
@@ -0,0 +1,170 @@
+/**
+ * مدیریت SSO — ارتباط با Identity Service و Keycloak.
+ * PKCE کاملاً سمت سرور (Identity Service) مدیریت میشود تا به origin/مرورگر
+ * وابسته نباشد. توکنها فقط در sessionStorage نگهداری میشوند.
+ */
+
+const IDENTITY_API =
+ process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001";
+
+const TOKEN_KEY = "superapp_access_token";
+const REFRESH_KEY = "superapp_refresh_token";
+
+export interface AuthConfig {
+ issuer: string;
+ client_id: string;
+ authorization_endpoint: string;
+ token_endpoint: string;
+ logout_endpoint: string;
+ callback_url: string;
+}
+
+export interface LoginUrlResponse {
+ authorization_url: string;
+ state: string;
+}
+
+export interface TokenBundle {
+ access_token: string;
+ refresh_token?: string;
+ expires_in: number;
+ token_type: string;
+}
+
+export function getStoredToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return sessionStorage.getItem(TOKEN_KEY);
+}
+
+export function storeTokens(tokens: TokenBundle): void {
+ sessionStorage.setItem(TOKEN_KEY, tokens.access_token);
+ if (tokens.refresh_token) {
+ sessionStorage.setItem(REFRESH_KEY, tokens.refresh_token);
+ }
+}
+
+export function clearTokens(): void {
+ sessionStorage.removeItem(TOKEN_KEY);
+ sessionStorage.removeItem(REFRESH_KEY);
+}
+
+export async function fetchAuthConfig(): Promise {
+ let res: Response;
+ try {
+ res = await fetch(`${IDENTITY_API}/api/v1/auth/config`);
+ } catch {
+ throw new Error(
+ `اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
+ );
+ }
+ if (!res.ok) throw new Error("دریافت پیکربندی SSO ناموفق بود");
+ return res.json();
+}
+
+const POST_LOGIN_REDIRECT_KEY = "superapp_post_login_redirect";
+
+export function setPostLoginRedirect(path: string): void {
+ if (typeof window === "undefined") return;
+ sessionStorage.setItem(POST_LOGIN_REDIRECT_KEY, path);
+}
+
+export function consumePostLoginRedirect(fallback = "/dashboard"): string {
+ if (typeof window === "undefined") return fallback;
+ const path = sessionStorage.getItem(POST_LOGIN_REDIRECT_KEY) || fallback;
+ sessionStorage.removeItem(POST_LOGIN_REDIRECT_KEY);
+ return path;
+}
+
+export async function loginWithRedirect(redirectPath?: string): Promise {
+ if (redirectPath) setPostLoginRedirect(redirectPath);
+ const qs = redirectPath ? `?redirect=${encodeURIComponent(redirectPath)}` : "";
+ window.location.href = `/login${qs}`;
+}
+
+/** آدرس ورود (شامل state و PKCE challenge) را از سرور میگیرد. */
+export async function fetchLoginUrl(): Promise {
+ let res: Response;
+ try {
+ res = await fetch(`${IDENTITY_API}/api/v1/auth/login-url`);
+ } catch {
+ throw new Error(
+ `اتصال به سرویس احراز هویت برقرار نشد (${IDENTITY_API}). مطمئن شوید identity-access-service در حال اجراست.`
+ );
+ }
+ if (!res.ok) throw new Error("ساخت آدرس ورود ناموفق بود");
+ const data: LoginUrlResponse = await res.json();
+ return data.authorization_url;
+}
+
+export async function redeemHandoff(handoffCode: string): Promise {
+ const res = await fetch(`${IDENTITY_API}/api/v1/auth/session/redeem`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ handoff_code: handoffCode }),
+ });
+ if (!res.ok) {
+ let detail = "";
+ try {
+ const body = await res.json();
+ detail = body?.error?.message || body?.detail || "";
+ } catch {
+ /* ignore */
+ }
+ throw new Error(detail || "تبدیل session ناموفق بود");
+ }
+ return res.json();
+}
+
+export async function exchangeCode(
+ code: string,
+ redirectUri: string,
+ state: string | null
+): Promise {
+ const res = await fetch(`${IDENTITY_API}/api/v1/auth/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ code,
+ redirect_uri: redirectUri,
+ state,
+ }),
+ });
+ if (!res.ok) {
+ let detail = "";
+ try {
+ const body = await res.json();
+ detail = body?.error?.message || body?.detail || "";
+ } catch {
+ /* ignore */
+ }
+ throw new Error(
+ detail ? `تبدیل code به token ناموفق بود: ${detail}` : "تبدیل code به token ناموفق بود"
+ );
+ }
+ return res.json();
+}
+
+export async function fetchMe(token: string) {
+ const res = await fetch(`${IDENTITY_API}/api/v1/auth/me`, {
+ headers: { Authorization: `Bearer ${token}` },
+ });
+ if (!res.ok) throw new Error("دریافت اطلاعات کاربر ناموفق بود");
+ return res.json() as Promise<{
+ user_id: string;
+ email: string | null;
+ username: string | null;
+ mobile: string | null;
+ mobile_verified: boolean;
+ requires_mobile_verification: boolean;
+ roles: string[];
+ }>;
+}
+
+export async function logout(config: AuthConfig): Promise {
+ clearTokens();
+ const params = new URLSearchParams({
+ client_id: config.client_id,
+ post_logout_redirect_uri: window.location.origin,
+ });
+ window.location.href = `${config.logout_endpoint}?${params.toString()}`;
+}
diff --git a/frontend/lib/identity.ts b/frontend/lib/identity.ts
new file mode 100644
index 0000000..209902c
--- /dev/null
+++ b/frontend/lib/identity.ts
@@ -0,0 +1,247 @@
+/**
+ * API ثبتنام و تأیید موبایل — Identity Service
+ */
+
+import type { TokenBundle } from "@/lib/auth";
+
+const IDENTITY_API =
+ process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001";
+
+export interface OtpRequestResult {
+ message: string;
+ expires_in: number;
+ skipped?: boolean;
+}
+
+export interface MobileAuthStartResult extends OtpRequestResult {
+ intent: "login" | "register";
+}
+
+export interface AuthSessionResult extends TokenBundle {
+ message: string;
+ intent: "login" | "register";
+ user_id?: string;
+ mobile_verified: boolean;
+}
+
+export interface RegisterCompleteResult {
+ message: string;
+ mobile_verified: boolean;
+ user_id?: string;
+}
+
+export interface MeProfile {
+ user_id: string;
+ mobile: string | null;
+ mobile_verified: boolean;
+ requires_mobile_verification: boolean;
+}
+
+export interface TenantMembershipSummary {
+ tenant_id: string;
+ role: string;
+ is_active: boolean;
+}
+
+export interface AccountProfile {
+ user_id: string;
+ keycloak_sub: string;
+ email: string | null;
+ username: string | null;
+ display_name: string | null;
+ mobile: string | null;
+ mobile_verified: boolean;
+ requires_mobile_verification: boolean;
+ roles: string[];
+ tenant_memberships: TenantMembershipSummary[];
+}
+
+export interface AdminUser {
+ id: string;
+ keycloak_sub: string;
+ email: string;
+ username: string | null;
+ display_name: string | null;
+ mobile: string | null;
+ mobile_verified: boolean;
+ mobile_verified_at: string | null;
+ core_user_id: string | null;
+ status: string;
+ created_at: string;
+}
+
+export type MembershipRole = "tenant_admin" | "tenant_member";
+
+export interface TenantMember {
+ id: string;
+ tenant_id: string;
+ user_id: string;
+ role: MembershipRole;
+ is_active: boolean;
+ joined_at: string;
+}
+
+function parseIdentityError(body: Record, fallback: string): string {
+ const err = body?.error as { message?: string } | undefined;
+ if (err?.message) return err.message;
+ if (typeof body?.detail === "string") return body.detail;
+ if (Array.isArray(body?.detail) && body.detail[0]?.msg) {
+ return String(body.detail[0].msg);
+ }
+ return fallback;
+}
+
+async function identityRequest(
+ path: string,
+ options: RequestInit & { token?: string | null } = {}
+): Promise {
+ const { token, headers: customHeaders, ...init } = options;
+ const headers = new Headers(customHeaders);
+ headers.set("Content-Type", "application/json");
+ if (token) headers.set("Authorization", `Bearer ${token}`);
+
+ let res: Response;
+ try {
+ res = await fetch(`${IDENTITY_API}${path}`, { ...init, headers });
+ } catch {
+ throw new Error(`اتصال به سرویس هویت برقرار نشد (${IDENTITY_API})`);
+ }
+
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as Record;
+ throw new Error(parseIdentityError(body, res.statusText));
+ }
+ return res.json() as Promise;
+}
+
+export const identityApi = {
+ mobileStart: (mobile: string, forceSms = false) =>
+ identityRequest("/api/v1/auth/mobile/start", {
+ method: "POST",
+ body: JSON.stringify({ mobile, force_sms: forceSms }),
+ }),
+
+ mobileComplete: (payload: {
+ mobile: string;
+ code: string;
+ display_name?: string;
+ email?: string;
+ username?: string;
+ password?: string;
+ }) =>
+ identityRequest("/api/v1/auth/mobile/complete", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ }),
+
+ register: (data: {
+ email: string;
+ username: string;
+ password: string;
+ mobile: string;
+ display_name?: string;
+ }) =>
+ identityRequest("/api/v1/auth/register", {
+ method: "POST",
+ body: JSON.stringify(data),
+ }),
+
+ verifyRegistrationMobile: (mobile: string, code: string) =>
+ identityRequest("/api/v1/auth/register/verify-mobile", {
+ method: "POST",
+ body: JSON.stringify({ mobile, code }),
+ }),
+
+ registerMobileRequest: (mobile: string, forceSms = false) =>
+ identityRequest("/api/v1/auth/register/mobile", {
+ method: "POST",
+ body: JSON.stringify({ mobile, force_sms: forceSms }),
+ }),
+
+ registerMobileVerify: (mobile: string, code: string, display_name?: string) =>
+ identityRequest("/api/v1/auth/register/mobile/verify", {
+ method: "POST",
+ body: JSON.stringify({ mobile, code, display_name }),
+ }),
+
+ requestMobileVerify: (mobile: string, token: string) =>
+ identityRequest("/api/v1/auth/mobile/request", {
+ method: "POST",
+ token,
+ body: JSON.stringify({ mobile }),
+ }),
+
+ verifyMobile: (mobile: string, code: string, token: string) =>
+ identityRequest("/api/v1/auth/mobile/verify", {
+ method: "POST",
+ token,
+ body: JSON.stringify({ mobile, code }),
+ }),
+
+ me: (token: string) =>
+ identityRequest("/api/v1/auth/me", { token }),
+
+ profile: (token: string) =>
+ identityRequest("/api/v1/auth/me", { token }),
+
+ updateProfile: (
+ token: string,
+ data: { display_name?: string | null; email?: string }
+ ) =>
+ identityRequest("/api/v1/auth/me", {
+ method: "PATCH",
+ token,
+ body: JSON.stringify(data),
+ }),
+
+ changePassword: (
+ token: string,
+ data: { current_password?: string; new_password: string }
+ ) =>
+ identityRequest<{ message: string }>("/api/v1/auth/password", {
+ method: "POST",
+ token,
+ body: JSON.stringify(data),
+ }),
+
+ listUsers: (token: string, options?: { offset?: number; limit?: number }) => {
+ const params = new URLSearchParams({
+ offset: String(options?.offset ?? 0),
+ limit: String(options?.limit ?? 50),
+ });
+ return identityRequest(`/api/v1/users?${params.toString()}`, { token });
+ },
+
+ getUser: (token: string, userId: string) =>
+ identityRequest(`/api/v1/users/${userId}`, { token }),
+
+ createUser: (
+ token: string,
+ data: {
+ email: string;
+ username: string;
+ password: string;
+ mobile: string;
+ display_name?: string;
+ }
+ ) =>
+ identityRequest("/api/v1/users", {
+ method: "POST",
+ token,
+ body: JSON.stringify(data),
+ }),
+
+ listMembers: (token: string, tenantId: string) =>
+ identityRequest(`/api/v1/tenants/${tenantId}/members`, { token }),
+
+ addMember: (
+ token: string,
+ tenantId: string,
+ data: { user_id: string; role?: MembershipRole }
+ ) =>
+ identityRequest(`/api/v1/tenants/${tenantId}/members`, {
+ method: "POST",
+ token,
+ body: JSON.stringify(data),
+ }),
+};
diff --git a/frontend/lib/optional-sso.ts b/frontend/lib/optional-sso.ts
new file mode 100644
index 0000000..4769c98
--- /dev/null
+++ b/frontend/lib/optional-sso.ts
@@ -0,0 +1,29 @@
+/**
+ * SSO اختیاری برای زیرسیستمها (مثلاً منوی دیجیتال کافه).
+ * اگر کاربر قبلاً در هسته مرکزی login کرده، دوباره login لازم نیست.
+ */
+
+import { getStoredToken, loginWithRedirect } from "@/lib/auth";
+
+/** آیا کاربر در SSO مرکزی احراز شده؟ */
+export function hasCentralSsoSession(): boolean {
+ return !!getStoredToken();
+}
+
+/**
+ * اگر SSO لازم باشد و session نباشد، به Keycloak هدایت میکند.
+ * @returns true اگر session موجود است (میتوان ادامه داد)
+ */
+export async function ensureCentralSsoOrRedirect(returnPath: string): Promise {
+ if (hasCentralSsoSession()) return true;
+ await loginWithRedirect(returnPath);
+ return false;
+}
+
+/**
+ * برای API زیرسیستم — هدر Authorization با توکن مرکزی (در صورت وجود).
+ */
+export function optionalSsoAuthHeader(): Record {
+ const token = getStoredToken();
+ return token ? { Authorization: `Bearer ${token}` } : {};
+}
diff --git a/frontend/lib/phone.ts b/frontend/lib/phone.ts
new file mode 100644
index 0000000..ab872d6
--- /dev/null
+++ b/frontend/lib/phone.ts
@@ -0,0 +1,47 @@
+/** نرمالسازی شماره موبایل — هممنطق torbatkar MinimalLogin */
+
+const PERSIAN = "۰۱۲۳۴۵۶۷۸۹";
+const ARABIC = "٠١٢٣٤٥٦٧٨٩";
+const LATIN = "0123456789";
+
+export function persianToEnglish(str: string): string {
+ if (!str) return "";
+ let result = str;
+ for (let i = 0; i < PERSIAN.length; i++) {
+ result = result.replace(new RegExp(PERSIAN[i], "g"), LATIN[i]);
+ result = result.replace(new RegExp(ARABIC[i], "g"), LATIN[i]);
+ }
+ return result;
+}
+
+export function normalizePhone(phone: string): string {
+ if (!phone?.trim()) return "";
+
+ let normalized = persianToEnglish(phone)
+ .trim()
+ .replace(/\s/g, "")
+ .replace(/-/g, "")
+ .replace(/\D/g, "");
+
+ if (!normalized) return "";
+
+ if (normalized.startsWith("0098")) normalized = normalized.slice(4);
+ else if (normalized.startsWith("98")) normalized = normalized.slice(2);
+
+ if (!normalized.startsWith("0")) {
+ if (normalized.length > 10) normalized = normalized.slice(-10);
+ normalized = "0" + normalized;
+ } else if (normalized.length > 11) {
+ normalized = normalized.slice(0, 11);
+ }
+
+ return normalized;
+}
+
+export function isValidMobile(phone: string): boolean {
+ return phone.startsWith("09") && phone.length === 11;
+}
+
+export function normalizeOtpCode(value: string): string {
+ return persianToEnglish(value).replace(/\D/g, "").slice(0, 4);
+}
diff --git a/frontend/lib/theme.ts b/frontend/lib/theme.ts
new file mode 100644
index 0000000..bd6a63f
--- /dev/null
+++ b/frontend/lib/theme.ts
@@ -0,0 +1,50 @@
+/**
+ * ابزار بارگذاری و اعمال تم White-label.
+ * برند از theme.config.json یا API تنظیمات خوانده میشود.
+ */
+
+export interface ThemeConfig {
+ site_name: string;
+ primary_color: string;
+ secondary_color: string;
+ logo_url: string;
+ support_email: string;
+}
+
+export const DEFAULT_THEME: ThemeConfig = {
+ site_name: "SuperApp",
+ primary_color: "#0284c7",
+ secondary_color: "#0f172a",
+ logo_url: "/assets/logo.png",
+ support_email: "support@example.com",
+};
+
+export function applyTheme(
+ theme: ThemeConfig,
+ root: HTMLElement = document.documentElement
+): void {
+ root.style.setProperty("--color-primary", theme.primary_color);
+ root.style.setProperty("--color-secondary", theme.secondary_color);
+ root.style.setProperty("--color-primary-muted", hexWithAlpha(theme.primary_color, 0.12));
+ root.style.setProperty("--color-primary-soft", hexWithAlpha(theme.primary_color, 0.08));
+ root.style.setProperty("--color-secondary-muted", hexWithAlpha(theme.secondary_color, 0.06));
+}
+
+function hexWithAlpha(hex: string, alpha: number): string {
+ const normalized = hex.replace("#", "");
+ if (normalized.length !== 6) return hex;
+ const r = parseInt(normalized.slice(0, 2), 16);
+ const g = parseInt(normalized.slice(2, 4), 16);
+ const b = parseInt(normalized.slice(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
+}
+
+export async function loadTheme(url = "/theme.config.json"): Promise {
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return DEFAULT_THEME;
+ return { ...DEFAULT_THEME, ...(await res.json()) };
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts
new file mode 100644
index 0000000..2a4d166
--- /dev/null
+++ b/frontend/next-env.d.ts
@@ -0,0 +1,6 @@
+///
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
new file mode 100644
index 0000000..3440d92
--- /dev/null
+++ b/frontend/next.config.mjs
@@ -0,0 +1,20 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {
+ // متغیرهای NEXT_PUBLIC_* از env کانتینر/محیط خوانده میشوند.
+ env: {
+ NEXT_PUBLIC_BACKEND_URL:
+ process.env.NEXT_PUBLIC_BACKEND_URL || "http://localhost:8000",
+ NEXT_PUBLIC_API_BASE_URL:
+ process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000",
+ NEXT_PUBLIC_IDENTITY_API_URL:
+ process.env.NEXT_PUBLIC_IDENTITY_API_URL || "http://localhost:8001",
+ NEXT_PUBLIC_KEYCLOAK_URL:
+ process.env.NEXT_PUBLIC_KEYCLOAK_URL || "http://localhost:8080",
+ NEXT_PUBLIC_KEYCLOAK_REALM:
+ process.env.NEXT_PUBLIC_KEYCLOAK_REALM || "superapp",
+ NEXT_PUBLIC_KEYCLOAK_CLIENT_ID:
+ process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID || "superapp-frontend",
+ },
+};
+
+export default nextConfig;
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000..03dae11
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,1641 @@
+{
+ "name": "superapp-frontend",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "superapp-frontend",
+ "version": "0.1.0",
+ "dependencies": {
+ "next": "^14.2.0",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.4.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://mirror.abrha.net/repository/npm/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@next/env": {
+ "version": "14.2.35",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/env/-/env-14.2.35.tgz",
+ "integrity": "sha512-DuhvCtj4t9Gwrx80dmz2F4t/zKQ4ktN8WrMwOuVzkJfBilwAwGr6v16M5eI8yCuZ63H9TTuEU09Iu2HqkzFPVQ==",
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.33.tgz",
+ "integrity": "sha512-HqYnb6pxlsshoSTubdXKu15g3iivcbsMXg4bYpjL2iS/V6aQot+iyF4BUc2qA/J/n55YtvE4PHMKWBKGCF/+wA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.33.tgz",
+ "integrity": "sha512-8HGBeAE5rX3jzKvF593XTTFg3gxeU4f+UWnswa6JPhzaR6+zblO5+fjltJWIZc4aUalqTclvN2QtTC37LxvZAA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.33.tgz",
+ "integrity": "sha512-JXMBka6lNNmqbkvcTtaX8Gu5by9547bukHQvPoLe9VRBx1gHwzf5tdt4AaezW85HAB3pikcvyqBToRTDA4DeLw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.33.tgz",
+ "integrity": "sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.33.tgz",
+ "integrity": "sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.33.tgz",
+ "integrity": "sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-ia32-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.33.tgz",
+ "integrity": "sha512-pc9LpGNKhJ0dXQhZ5QMmYxtARwwmWLpeocFmVG5Z0DzWq5Uf0izcI8tLc+qOpqxO1PWqZ5A7J1blrUIKrIFc7Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "14.2.33",
+ "resolved": "https://mirror.abrha.net/repository/npm/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.33.tgz",
+ "integrity": "sha512-nOjfZMy8B94MdisuzZo9/57xuFVLHJaDj5e/xrduJp9CV2/HrfxTRH2fbyLe+K9QT41WBLUd4iXX3R7jBp0EUg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://mirror.abrha.net/repository/npm/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@swc/counter": {
+ "version": "0.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/@swc/counter/-/counter-0.1.3.tgz",
+ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@swc/helpers": {
+ "version": "0.5.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/@swc/helpers/-/helpers-0.5.5.tgz",
+ "integrity": "sha512-KGYxvIOXcceOAbEk4bi/dVLEK9z8sZ0uBB3Il5b1rhfClSpcX0yfRO0KmTkqR2cnQDymwLB+25ZyMzICg/cm/A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@swc/counter": "^0.1.3",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/prop-types": {
+ "version": "15.7.15",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/prop-types/-/prop-types-15.7.15.tgz",
+ "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "18.3.31",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/react/-/react-18.3.31.tgz",
+ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@types/prop-types": "*",
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/autoprefixer/-/autoprefixer-10.5.2.tgz",
+ "integrity": "sha512-rD5t5DwOjJdmSORcTq64j8MawTC+tbQ+HHqjR4NDumamy/ambn1UJrlKL+KdwujWxMkFjPM3pPHOEA9tl4767Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.4",
+ "caniuse-lite": "^1.0.30001799",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.42",
+ "resolved": "https://mirror.abrha.net/repository/npm/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
+ "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/browserslist/-/browserslist-4.28.4.tgz",
+ "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.38",
+ "caniuse-lite": "^1.0.30001799",
+ "electron-to-chromium": "^1.5.376",
+ "node-releases": "^2.0.48",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/busboy": {
+ "version": "1.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/busboy/-/busboy-1.6.0.tgz",
+ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
+ "dependencies": {
+ "streamsearch": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=10.16.0"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001800",
+ "resolved": "https://mirror.abrha.net/repository/npm/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz",
+ "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.387",
+ "resolved": "https://mirror.abrha.net/repository/npm/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz",
+ "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://mirror.abrha.net/repository/npm/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "license": "MIT"
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/loose-envify": {
+ "version": "1.4.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/loose-envify/-/loose-envify-1.4.0.tgz",
+ "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^3.0.0 || ^4.0.0"
+ },
+ "bin": {
+ "loose-envify": "cli.js"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://mirror.abrha.net/repository/npm/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.15",
+ "resolved": "https://mirror.abrha.net/repository/npm/nanoid/-/nanoid-3.3.15.tgz",
+ "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/next": {
+ "version": "14.2.35",
+ "resolved": "https://mirror.abrha.net/repository/npm/next/-/next-14.2.35.tgz",
+ "integrity": "sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==",
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "14.2.35",
+ "@swc/helpers": "0.5.5",
+ "busboy": "1.6.0",
+ "caniuse-lite": "^1.0.30001579",
+ "graceful-fs": "^4.2.11",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.1"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "14.2.33",
+ "@next/swc-darwin-x64": "14.2.33",
+ "@next/swc-linux-arm64-gnu": "14.2.33",
+ "@next/swc-linux-arm64-musl": "14.2.33",
+ "@next/swc-linux-x64-gnu": "14.2.33",
+ "@next/swc-linux-x64-musl": "14.2.33",
+ "@next/swc-win32-arm64-msvc": "14.2.33",
+ "@next/swc-win32-ia32-msvc": "14.2.33",
+ "@next/swc-win32-x64-msvc": "14.2.33"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.41.2",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.50",
+ "resolved": "https://mirror.abrha.net/repository/npm/node-releases/-/node-releases-2.0.50.tgz",
+ "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://mirror.abrha.net/repository/npm/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.16",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss/-/postcss-8.5.16.tgz",
+ "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.4",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
+ "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "18.3.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/react/-/react-18.3.1.tgz",
+ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "18.3.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/react-dom/-/react-dom-18.3.1.tgz",
+ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "loose-envify": "^1.1.0",
+ "scheduler": "^0.23.2"
+ },
+ "peerDependencies": {
+ "react": "^18.3.1"
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://mirror.abrha.net/repository/npm/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.23.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/scheduler/-/scheduler-0.23.2.tgz",
+ "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "loose-envify": "^1.1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/streamsearch": {
+ "version": "1.1.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/streamsearch/-/streamsearch-1.1.0.tgz",
+ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/styled-jsx": {
+ "version": "5.1.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/styled-jsx/-/styled-jsx-5.1.1.tgz",
+ "integrity": "sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==",
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://mirror.abrha.net/repository/npm/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://mirror.abrha.net/repository/npm/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyglobby/node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/tinyglobby/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://mirror.abrha.net/repository/npm/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://mirror.abrha.net/repository/npm/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://mirror.abrha.net/repository/npm/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://mirror.abrha.net/repository/npm/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://mirror.abrha.net/repository/npm/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://mirror.abrha.net/repository/npm/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..8f9b93e
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "superapp-frontend",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "next dev --hostname 0.0.0.0 --port 3000",
+ "build": "next build",
+ "start": "next start --hostname 0.0.0.0 --port 3000",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "next": "15.5.18",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "autoprefixer": "^10.4.0",
+ "postcss": "^8.4.0",
+ "tailwindcss": "^3.4.0",
+ "typescript": "^5.4.0"
+ }
+}
diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs
new file mode 100644
index 0000000..7455d3e
--- /dev/null
+++ b/frontend/postcss.config.mjs
@@ -0,0 +1,9 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+
+export default config;
diff --git a/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Bold.woff b/frontend/public/fonts/yekan-bakh/YekanBakhFaNum-Bold.woff
new file mode 100644
index 0000000000000000000000000000000000000000..d1c239f1d17be09ff1203529c08956f4a30844dd
GIT binary patch
literal 46504
zcmZs>1CS+6urPYYwr$(?j&^LDJGO0e$F{L!W5>2_+kUg(y)XX%MZ6Q8eIl#US=H6m
zm9BDPVsgsLasWVMDF7A#008|Y{l5SI{zSyY2mt`NaR9*k1^}44E;s@|7yltD1^`6%
zd^2=@Gb0*x8(4_T%P9c>QGNgbt_1)fLQ0S>aV)N+A`AeOo&x}|?EnC}@e-v78bE
zGXMbgO$Yd<2k4jY*hv^!8`uE=klz3%0RTWqE76`0nwuCHf6M-77wb2$QyV$Fn}0Wc
z)98QWgx^34IRjp7ZtdjuO{We3fI{Co%F&dPsam-B7q9XPJ*{_1M2(@5=SeE7*byjQDU~%
zG_m=8!{c0QvId`QvR*s%XufK1JrB$FVX5B?f8L!EsHcWuT5$Q@GTBN@c#HKcV5kK`
zIE&hI9>m%BhP5TSw}*T@^@c=$-G);
zhwMWa`&h@hUpMTRa*vrM-#N=9gGl$(wXI2M$s@U=uP5=7GpZGNw(^9JPQ4+WDTU!!eHmS|NBjqkU!jX-d+X~sMz)kS;!86yJrzgUj7j88ve+~3I
z>SHH#GP3yCWtQdg8Q1gSvrEx6+jX=TLd{-t&)WXvG?U1+O;1Xr?#KtT=X2(>)}}PU
zCrp;&vo-7{X{Hb&2v@p1m>v>xUQ>enpz^}qq
z#9Ps}X?o)8$58!eVGml_k8l9%L^@zgues_o!-_dT=q}rFK9LoH$jv
zc>Ae{RH^S&)M!f1JhW6i`-p16*?YMdMGBu`oJg&t?W=`qF)!7=Ag%mp3~C=+l~*bp
zOM);jhnkXFibL<2-Dk&qATfF`I-)^Z&jx0RdV87YtrAp&-Q9ue`#^(Zvs?!
zlz}K@ZS(?Y?`U6XJusok^I0VaU%3NcDv?@s)>9>$Lmu4%p3?+#ngO3$Te9$s%52go
zqMdi7cLXr!ItvadaVEu)Fu$gZPmosp6F{~K7@(d5Oa;FSR-UA6DE
z?{yFQ;yoUmcEit)Z4tZV^k4i;Gff#r^rT%yKkY4hu4t)9?hln<;lZdPn#&}aF4@*R
zMNu}(`*0r%#B^o9aR}!LmBQnR-xM*Zyl<*1>Ym8g!{4jVO@U>K#1e>bQ#ZuB_Gy5V
zHw^vbg4r@3ZV<+mzN$96KwIKUU_a4Z$s%!@%6s5>qPc3T@T)s~@@AGzQE+QdUEmq-
zC6MF24c4BV^2k0Jn=zn~tKdRdZt|0%2Zl2IVKr)89L%-;2{{V~11MW-#
zHR2Hd|N0GXjaK`5Mtgb-2MYuD5`NS?~=R4W9@6K(R4VQz;lJO@Y#|34KakTL}7q
z!GphiQ}y0E2YPzmfeT}MB&YRFd|`P7;=7Q`FJjJ2T?3~7tqK6v#q1r#wileko%RZI|5oGKo=->2o
zCUDOx|MYoMndJ3Jz=A0F{sbKwC*?FfL`hy7U_S2p%obDqeW@U{wy_+}7eg^;Y}{C7
zT0AB_fO+np#7oqhF?PoCh^6M``QuZc_BY-+k&%#9ZHchZ}|^y$}$~kcn9*`dUqMmr!%kM(7ctJhNgQ`
zQON7yh800QN>k3)J>B50@N@`FYeIC7oeCcsVZ(SfVg-^npm$r`at&Z~j8DCV)p-$F8`27oe2&?j8gexyz}fDTHD<
zBI)L1wzSVN6!-&Yf9qvJuPb%ID-n(HIX$Gg5#utwD7pzY1sc@u5^iRic6E;bJ|zzm;B
zjdte@BjJou;*6DQ@#k?1?nV`wQ4UB~kiwQN_(e^ZztG^%K7+n06M9UESHj}tPd|g!
zCiZj!=3OjZN2!0$0*)CN6+h@^RL8_-rL!DgTW@gGR~_OF_lEz-Gj7Tu?#PU>r>Gr6
zV^3Gz_t1*vx_!wTGL=lH_Ndu9d(ie9N6E%8I4r`9n!oJ1!LX_Imd7}sHr}L8Sak$@
zg}h`xhBuiPihJ*)>O2FESN$>6UI>>{2j=d_dK
z8$6Y++MnUB97k!6D5(CWVHGVY0kEIhCbx+K0wgs#Bn9b#=@xb9z~+Azfz7Mff&CUO
z-=;RKeZoS(DF%Zs4;
zdG<929#JbCvpMVhWR#rWg{nFU7UWPPm7DwI0#zr{u7fP`1GFYQ?OfybvX0q9ycF~~
z`vr)j`5n}ym*rIx>E@;}?kDXAvmD+FX5RA@cYlu2XAD!j!8BPj3|Xv0WESc87;Tvo
zlK0Vr`z>l>H07+*l~PBQDR>U3Cd@tNa&{Wb7?m`Jjf+_lTgT1#GBidURO@rrI+pOj
zIKz{K?SKC;P*yTgQ4nQQ_h!@nVAIrDP<&X>v|f;xSqa}atjv6eQ>uRPk8;2sPFpMK(Ex_Tp*
z`Iuz`9$8K^daRKj1`+a6`Nk9wt-~ju<*)~oQjVI_5y`(xfRjok@Jb`
zM18>-!~E}y5k8KkJh`XCV_;1o`!GkPP5;OOK`YyVDPabyxCJX|7O8QY$cve%lB*RY
zpDDQ%27t!p+HBFTr0d@bk?%b~;o%M{k~GZJq`O+zT_%CA_2!d7-J@
zpvhG&3_4qFb2ll8v#G%>H6~fE4ku+6W2IMHub!U9%z;KXk0%ezYJrcLYuKMfDHoDQ
zpO)iGN*1T37jdP_2qVX*(HfIfcvK*?j)?HzCgcF4<`Y)^65&~$O`~@zpxHPPV(?(j
zhLSc$j@0<8nEWRT@7$20eWj28(IJ%tcaL(2tCvdFA)O_2kM^jwhm8Ixf{LR60My#<
znKst{-}B6xXUgA?5I_{AU8NA|QFXUuIn!X*Ih&94RGWr?1uy9N!3Z)AG1jA53w913+9PQPqz&=ZgL92VOlbfp=mllu8i#f4
zHLRtrgu>}zuVpz$2<`nQT8DM*HM*wlfXewBdkwKmi5wA#TyRgxK^-%ir&)@18sw3m
zXHJ|RnVbt}j==3dS+I7@^%~`45MHqD#z{39cDI|*0$d%awN2l`fgeM$_u_`N8T7QH
z^i13pskdA6j4(rrEh(6gI8G`+B|{zWQHXXT_L>lHOsyfI8b`Me;1Z!veA{PxktJ$m
z1(*4uG+Ws~NGDY>4c-8EA@nvkSG35FZbceF2ZKSts<2};E<+I&As66(m}{B}$#@-QyL;ALW$
z$i3aV7KUs|N~2*X<`i0Tnh}q>vUAF}(Rhb|OcJ$WI)`RndWNyL+qCz>+&b#IhziB>
zO6pkZyz)};^48PJ*NPd7g!HoH@@R`1?tJ=Ep3`8D8XwD|g40^rVwMxHw$kd-v(x0Z
z!0cR(W0==ApUSPXt&ns`a;t>22J#Xr3+i<2;_wsw*VJCqjx_7!3}@36E>rrnjKi`Q
zbN>VOglHqB<$LxFI^(ew&89qSOV8Cjm#8d*lw~-Vc0Q}AwYA51Z!=%66JQJnNgc72
zJ~VqYZM>!BM%RsXLXKG(XUBEu=FJP2&~+}?1wJQ+b&RJq8iL+XZ{eIpvl}okd%cyV
z+X*kNtvQ4Tln?FhS=(#RcLU!|y}Q_Vsjr1EC?`ZZ2}Du^W{8)-S)rm_-D%ci%zHR7
zaFhV9-UxlES{_b(ZTL@QP=sm2zVGe+Rz@dT^oGm-S(dEV-cv8yOGY-y-=KB@kem`g
zb$-CPoTad*fs45)#}FX_;rjIIQ0#tu+n_DLHi1Xme9v%3e+%XRDl?Ld!$|dD*D{^M
zdiEOF5o<#(_3&MDcEA(#x?Y#0^z`XMGt{ds(;O|_X?pN=5^bW|Cb`b@>}_0MdQNr3
zZX%%KkO{+*iKi*hE5oXY(kXDT0!NF_O=~xRR*7Xy%Q^w@#E+&`oFMqbYNoaB!9E6=
z?15n!`_N1JvFuzj5ikZ!>`b&_IQr@A
zJl|uC=#PXjFcd{hAu*ODd_oQ?k%@$gVrT>5g(LxUf;0)tFm_$iISIuGnnO+pF~JA{
zb}V^OkVSfCae>A!e9hG4e6(M1nmOzR*1s@TGvM<{PC&V{CQE3J#oAJ`3)N0Uu!Xwo
zFlnJsXvxM#q3h}@$t_2@>$f8GRT-o}~jzPM58Msw_5
zxH0fY$?e`fQ+GuZ>>j=7d?xtr`~L9q4`!GR=gluHB}xykHmpr0MGv1aZ04k*4?Ek<
zcB9@5d)m!-rZF1(fhFvkby~_#zfvZV)-*8y}0$n0z4z!v`fR%B_r{$&D~-%gMd9m#$D>35X@&gx@RD4yxiTB}RihT+rd++%{bl|Tz7eRw^6JHn!zP+8SFRvtVmo-LAaN9x%7l)BH
zxmG`Dyc^L=>LrAN7i6!p3ak(OZGS{-+idz0rLUXYhC+Aqr?uv0lQ$EejH#675@$?d
z9MwW1NGb_Ia0humWaV6NICCKg561O)S(;+|^vj&lTElemgogi`f5Yk%aDw|&val3^rc)1|o2>x8T
z#D9J5i$a70lJ-13r2jgLIt3#Vca!0PsbnLxRw4g!amfD)7Za?PrAvsImdWSqO;s=oJgR
zc2sUI&|iS`VufP20OheDUZzCbEiD7XQ3;5~?iCByFA%O(AYU#;J^!QC8zMJ?01Y3tM*IqtPfiLT^PbsZBV)YuEIdTr|NS|I~Rkr`4)hw_T
z_)mrX68k@w@aK4u{(0^)@n2^H>OTZN?!S213QnDh>4Qx|ud`2q{$nMP`xlELN2mWH
zb0bBgV
zdg;FqOY!_ej8;wl3vT-VT7vPPC3OFE#h$R%wINzLst#Y^{udILnE#UeHjDi~Vr2h0
zE#>*20f40ci6G4~+`KSuQ7fXjH`Q_}lGVR#fZ;d?L__zACFvJvh82{fS}8+4+E$=g
z)o9mdls>Bkysd8{wU0L-SuGL%OPV>QU-y2&HpyGt#Zs%>e_peq&|ZKM{K0P4OHutV
zVetQ1(%zV$HF#q0v7T-4FKE2QD}?_Nc)<;jnj?G;TWI04`8QhJq$@Q4nc99O_8+qk
z$vAH+>X1L>yIQGRnge_i9>bCRPQE^y5ys%570B4Ykf9h3>RETjoF1uNG~md=pSuw>mr*9U++oEj5q1TMY)Dkh2uqbr6amb7Ju!quyOcDAi0bd3$Qj(rw
zQ|B2e#v#zSch{OR?>4PeKkU*bJ2Cb=Dwz=IfxY(m^0sEr;l$u9P}&s8)uGIOks;(e
z^Z5GYzI9xTdkEEF=c~2706cck*XXJLfi5y6<`@|~aO)pysmZV_%o~RApPT)0pb6OF
zxM46ig#4s_L2;1CBFGZ2teHc1&!@a6k(WReeC6(#DH=9MpI)Ht{rG?6a1lfMk?f^2
z5lfOuLYK)N!@PI>I7wr|jCO-Cy$NUx#*MhPkH2(y5yTg8G||KUzZ9H}xLp18f5gvG
zAo_{yf8e4}^dG;e6B?ZZuNb76G_zOgUGX?K7?c$5fZ5x)ozBl=u9kiDwv{Mc9J%z=
zJss`I#wxwRdMXxN@_evraWqZAonIEo3nbVvyHD54^vwa-gok!=JzIuwsG?f93fYm9
z;$TG-hj){;fkxg87Jai-N@Ppy-K=Zp=8U)9=PecscA9+KwxDefNsiSJ8)$Aw$DN6Y
zvU8IL!CrL!<})Uk#CdU^__+FTTJ7@nh_vIvGsmi(`n7o{-Q!Cu
z{#TZ0DCZ8m0`03PaY}?nVu4QS74fYb?TuYF7<5TE82k`AXQt+Mt)+io9vJ)^Iw!71
zu3a(~lvn*3GZwr)iyRPlm6VL1oHmy}_4h?3&r&mX=Il;4mdLAL?7;1K;71!v5}P)9
zf}0G|^ezyi_{Ctm_P*tQ@dc+@V=`j3c8x_3u^73-qQsST=AggPY52``cF@ztYo?oW
zM0w;#JLPRkUBY4jUpc08=-k>)?`r+xr0Cgs5#p`p{cwZB2X7k7b-#M-g_k=~`mh#F
zmD@?k+*or#zYy1Ds|ovXaVz|mhx00)amouHvuCR_L=`eOacJa&>4N?)@$mz
zrd_jiF*iMRQTlq}!)e2zv1C0y{EOvOG8W?_Me)7|MgdKbMggry>ib_x0jp1F?576i
zGFA{~KjuD`vVY{y3(Npa2dr`|Y%Fr-aeeik+TR9jc1R~wA$PEHnEXudrp5+NrqYJv
zM%q1Yem9hRT!R%+99RSZjEF8HL&IGIWP=35Iui#2XwwKFdSp7|a(xS8~{R)PXd#RJb%9AkbcrI6Ni*V7i@L;H7;;bH-h}9dF_Lt6<>&`^o5{3xYb(
zBNyu!vMu0KpAi$m)W1j{ZW$Cl;BK4w-av90vMHc*`v)gXb>P~Lt{bRs2*(b{Gj3NT
zTDVM@b!1i~U(AOnsELrMg5*4EqmcKEJS)M4VCam76AGW0?F^bjypz%T<7Nj*IGBzS;uLG2Xq#
ztNllvPl_=y5S8?3qF5mgmb7Ucn=ucUs7>O~K8s8)IvOj_gw)t{c>}t&G|ybN6Cs{#
z$*j8*+lK7Z+}=HJr|i`%#y!i2*|8Y9QHA}={P^G@?p@o<;Lqk!EuN|rmxzsJ8?%~v
z0q*s(U0TP*){wPDowG~ln${`aiTVyNU6Y!arFqMP=xdkPa34wE+U_M^6k|}Cz^>kX
zeGfY_H{^F{$KZ#)o1M*T<7bLde6|=5@rr_rneb!l`*^Z??MfiaLCyLbD;%Dl>_0WU
z;YZs=Zc_X~Q@dj?20p>vNP?u{nfXEHq-~LshGokX^kEpgk*p?^^&<&&*;XX&V@wY5
z+7zy1d$$o@K*w6eTPnTb-
z&$23FsvWM{{o2PnBc{}q!o-Yf5t05hg9;4KLz^MGH
zA!Wlc+XoaJGJPHeA}QuEu-$+>AcrzV636ux1laq9%Wjqt7N8akZ%4rhz}t*hjVllW
zRTAU*!AQF+61hCR8+b0c4C8Ef0S$I4|Klu-5t=t0MN@(fAw2H4!Yc#t$f<<7il
z1xn(Ar@82D1PZokM7toKUGIDR6+%ipNxP~Uy7?j90F?LyDXWLOVn_1tE-#T}r1jVA
zqH;|h{9bu^FW+JnCx1bl#Rk-=;?U^Z3=|gmKEe~rWF}D`twULhWFmPQnqI7RuPMd+
z10=6VSSZ}9vKS(a?_(A2WjO0!wGW~qpV*3E`>iYrgJ09pE=#2l
z9S9XUFG#Sl324`1U}aEw{(B-DjoIe#tx>r&^POti{+W{jP7jM>V|0Ihmydzu#-vXw9MQ)c!9ckp>4rxOX_CRtA
z$vt_AjEzx$jHdVOWA#`bJ{lLTNOcuN^Sc6S5scs$4B89u
zsg>tS&+wGzTSE^H(tHP@TPdOrAz4G^#Fy61n~b8cVi(7IjHU{`BEmJG+}H1gy=9u&
zQQbp$FBAgQ_n7}d=$(&-9tOxRDy*kyJFWNSicV=YFkNca(cEgyQ5}|z$>wOc+`?R-
zu+Aj>#;?(#MC>su|58|;{*jXF{tkJR-)VUmExBaQqv!8&kN*i@Wm|Z0^eJuXPK
zO+CE=sU=XCOf?_N4g}}jNNnFFCbJC+(vQM&_pZF!*(B}ncr4E(FdrAS+tODo+!dH+
zuITjYGJ!2|uu5pJ8Dx_FGj>J#g?^@YXdERM$n_HZ5)@mSm;%d3mTZnVzog9Uc#3w{
znjQgCy4lu$%y>4jN8HJr!nB_BMsV?w&!lzkwJgx9e<%5#8P4+7=M@N7c~(hjc2*IE
zHu?F*-u_0fr~>@>=QeTDvC1?gcn-x;Pbpf2V26!@+3IzlEGpbodIN=WFcB3CR0xEV
zzvAC(;Qk!_TyW16L{n>TDu`U;QmMqaE7Y-V32V(E+v5F9^x1A{tX0D3P
zI3>ST2UB5T;?ES4;erb>l;kQ~M7!5)9yNZ{b3!Q%t4t86Q@=~k~cMVazdxoy|q4bKTf-yYkDDwxLh=ed8o{Nf}N6da%k)M
zzO2PLWYLJp`(TgOHMPS1GAAerRfV8SemV8>1OkP!P5;X6r1K;)rNcyQ#QA7nOOX->qqpE*?&nsGttTPHSJj`xeMme|zueuFb$
zKKFdCr(h2rtv(>9vW%u88CI)@05%_y1-%OGGI;P8q0n$drLF07@6Kp%xSVFyo-E3Z
z*T=_G_syf%2bcpj`Zralx#L<7Bn7M$jgylV4J>q3aB)!!^%a#47o}{|$>m=wMjo09
zH%gMbpDmu)Nq>XpV~-T+I`!}(J{G+
zsgVVJN)aUr|7x*L&8g$ft>srz9tkv>!`6C35*yf@8LOUoI*_MBD_NNtW{%2j-1U{|rtbIsJUTM5=b^2w2QmW(gin0O7z>%TWOhh4-WnOkMc
z$>ro7;!UrJ+Sy^a$~6JgxNcW00aX<#l=C?Lm$!dF#HAq|O2A%2$FcKw)PAS9*vKLt
z({MVQPEyUe7$S=ECm&E~oonbCYWSq52?=}(W1SP+CqsW`;@)3{mL?u)HPAa7pdwhN
z`ao188t!>V=g~tx;}8$%etcpV%g{5+R;%WIMpDH3js;=8S)QNh`yPp1o-GOJ8-HO{
z^f~-hhQ+J!1xLVnT)t_k2G3@PD_(18lWm%ufxWS0rypi1T6xZ<<2HW_b0toq%%c0P9xDC<8X&o-x
zfKpAcXWLt{biJeXJ`73e>P+r}4J-p^I%8r3xy*j`IEt1g+iPW?Cz
zbXP<^N`9*%V6h}Y&Fr*zl;h16Lk~@72^rD)p#`QvX)3Q|T9mV$&vk*yR6=G`iUptQ
z<6VSr-f_3A@*RYyeqs-6*}!)4mb2d9wZ|IVU!D8RWF~|n?n+J%q)e&EtC8*m_=zhq
zrQ!=H#dilNhnm4LiOW#h@4)1enCC~g
zPMvb>30~(5btJZn4BRs&7TCY!>}(rc<^oI0=TGEtV@3G8VQ>0)Fpf-`*9oIoL~02B6hY(Lb)}sl+R;{v{ZGIebvkT(H07(
z^nTdN5Bk=h#$!k>-JZ5TCrxGOu@e4pl&?3KfTsDY*R9oHY1VsroICULxxD$N%LYQ~
z+ntR*ULV`qizaKUP})k$emvSZ{*2Q30!ffQ>P&^DuUtMHcDe7FNf~BWFF-RxGtZLP-#T0&^hFpU>23^q1A+Q{d1UZN
zRSo=Y(?u((bKUAQkzLsKwLl6sWss>zv!we{*8JeQIQEw|y)%n(QT!ohcBFUPPl(Cc
zRP9kOCg~vf*IEE=&z4+OAQjLkEi;G;8v(fcFBE{n8f6;<)Ma
zy4m#(zLgA=J?KVa_5>yut5MLzx3}ynGqSX9!?3mR9z@HE!yiy3)?lyvqPh884P}B_
zwOSw%xi*C36aGzqYi!r+1Ai!s;^dYA=$5O1OqjHwEL)-u&o@b7E3I>{caGQ%!0ntF
z8;pE~r@I<#J{S-iB!IJeWxmp_!k3f~wibF2uwkan4117P?`@T+8Rv4eRwE0hG|U^x
zmaI(V5#7OUqM*iNR}o)^W@;l0^p!5p7hQY8=bSp(Fv*2b3`LNkJb146h;d(j4>z+T
zzU*o+vquo2Lp;5LifEy~(Hgvk9;PH69Hktfk&0a`{>+96&WVwMVw@=^aG!p~HqB$j
z0^OidPI!Qk2!4PX!V3erhbr3*HxFgILkZX+W!>q0dK%*f|J{aRJvuygiDb+3%eUsj
zxVCS~#A!LCmxbBo2LaoKxP0?F?J?VPH1~|&z=>&c!%!a9PFWE)tyzugs-AN*3A$YF
z=rx7#GerTiX?lqGe$whXBLh>l#MDJh*kzCA8r#0r(aXQG}l5L&Qy9UnSR=aEy>)eLmCQ%O=%kCvLQ{{MdXdO{^x;8TrS_+s;UUS;YMP#E&3{p
zt`AstrXS7JD2)%b?DtWu;9Ch4UDaEH@z+e-iC)PuuMQV>4rA1H@obx@B}Q?XB?9s|
z-aR-$o1vwXw`Y2gGX-WkQlJAd56M^Fjg#i;FE)@uqOkmU|M>wHi{d2=Y-?Iw+40na
zUxoBsbxkdd|b(GFU;h{hE`AcD2OCKNP)l&e%jAfK-PpM?n
zEz_8tZC>HgA{%yQ_z6ozNg%T|S!5x)c>81eOu#nrm1J<X|c;QHfseBwECzL2(KCFM_+wLK3B*E`cOZfH8bRhRTBd!X4*v
zbFLY5pzM&VePD4*xsF-U*<_4N&-tC6@cUII9o*x$gLrP%jCzi{5;^uqWN~r@c#&v^
z3*CCR?On%f`&C6wv-e^34BkTk`S0Z4$*3vG=yI^>j7*dgEVvZPL#Lw_nYqPTNoeoCJU
zs9n&jBEjlWcO>=cgS3&ZV?vN8u|py2y12u>-XRvsAHCsk!%jY3-R0mvrS)-ny4CiF
zySr6>8GamDHdc*r{W_~Z69JuJMEz5c8BIP^>|13Z+9itdbr`CCstGF_#%FLw`582m
zlWU7YotM)2C#9SxVRI^VQHDfyqD>}`!_rnLOUQ1{LyD178M!rgRe}A_{792TGy$_9
z$;;52G!%mCt?zN@9u-(9tF(xl?*u$RF93%X05zv>$&n1(z0hZKt#piQ;e81cuaAZc
zn?1*N4az~yqud&b@Wb@E!ZR^pvRa`gyusdhA9rbf{mVe=4ZZZmkx?A@_1lE;LLvNTE>Gv*E`2YuN20~2
zQ-8wI=KL_>vk0F#;m`-RBh|7bLlu|J7o(T@cV+2dRN>&mD6GdOcvTB$R{3LU&6T2j
z`PwcT!{^i(v^rs>-TY`%DX+MnFjjmZ?;aU7l!GMryjeYIL-46kC~yIjDM-06%KAH_
zv=sH|JH&@rHWAK9!;O(~KsN!5Ztmv<=oUFcK1fDP4$u8kN@4YT7`!32lGb;KCQ=N#
z%ezH;kPJdE&{j;LPDiwc+uN_m8eEm-A=xzHd)a}{*iBuh8C|aOyEW&e
zU#hTL)aMj`Xw%`D)W7=EI0IokS&UbNDck+6P>ftGU%4q`QW(5G#$2~;x|XNSs;8I0
zTdcq|FgUy4PcfU^4Hkz-$W;pP*NPZfw!_9&&hCq;BSq5+bHe9=z7K6`vQ=R)ry~eD
zOr6-?*E~GztE@+K9d4K#w6}8$lE>v_a}F4BRh}LrA6S%_hy!?>Ax@vA9%9l(X`$j6
zxx$7~AcOacw-VXtB`T-~i7>Y`)U>kFJ~D?s;;_>_#YLQGAd&~1$sk^bSvmH)7-`sC
z;#W?Gj3a+d_3*Wn)dB1G^=l0RvGoq@KME
zbs4e}a4WP5MGK(yLn~AYQX%96Ed=ET0r95?O$VOv&lj}M#i~UvL`Vg_2gHM)VC0cb
zLm10PbrF&n>2u^Z0AW+TbfO~bGYSGh$N2)A`jORQsYB2LzhK0HH4smu@A45NGWJ39
zXxsIH2mnJ81#l)v6kz_$haD|IrY@i&H-s)Wa}#hb<$)iQgN3*eANxNT#pQRBf(w)<7UN4A)UqnU!YNxozI}+ea3a_n;O`
zDx_AbDzyo4WeeAow#F!|keZh|#i`13JOSv$*vPsT56!_GN8bzFqrGD3P%c0ft9bun
zI59l2KLH8Rzn2c9nwU4kX8A}(8YeZSuTSI}R0y
zy0oio;`H?|`HU`~AfGOuUZ<+Jpts0}_J<6~5+<9EjG~gpoZy@&i@KxWorV+)NU*M!
zvX;!6PF$wXEdE^AD7qO>aCCm5KKnk#|Rse8yvz)R790hL9W
zC6?8S)rw_nc21Tab9a$GxDJL>F8U(>;>u#I6?%nXqZR#`K8aPPOkL8lrj=zA2T!oh
z*rn;0=~V60N;CUxakGXMkd}fL%9{S#k2Taahc)`OoF-qZB~XO0x}#Ny6*-=c7=;+L
z7?~J7ydm5tysONgC1WS94wFGFd$ebjhgQc{dzpvYd->YlIz!rK+88il;_NW*!0*uS
z%9YEcL}Q)+KhU`uLiUGtK*MU-ee4z!Sunwfx{t}
zq0cCtD4p1iXub%(NVGu}d)LfZjL4KIwTR|u*GRcA+lZVayF8i(wkEJvSo@^5mE~h(GuP%uT;*A&Q#8P&NR*(
z55x~-D#MX(Xj8WaU8H)!HB+t!;&*c|1Mldc9G{TB!M=ZeWj$vHW|L+gW;0v}=%q>F72*M}%ZV3c#yXJrY`2$9h@9)F1GSlqh4ZkWw(n_5$4rK&O3tf?+i
z#kDAQ*7ATv3eF`+!w75zzScuui-8L&-eYeEoe74~V{(nj3sKX<^Ien*wAIUSjWHNx
zukWRfn;deWFNzt!-ybIg#a0QO@fW?8%?iTvFMq87E@_o=I_zZmktG8$H)to`I~LNtcvXnhlUkBZioinXxOR
znxv&It;?!gn>Q-&xti11C)<~;ziEBJe`oA6uID5sB{n7UB>qU84A&0hWrE_UZ@;#n
zT}D9cuDIcCMm6tkxDoJ2#qOMu;!}yH$H5x|r-{VG!R(X0$Ry5Mp~}mwO%pj`ZHORD
zliag^jPKS3RRY5>^{`qFc+?wM(XS1|{~0^y_U!q*;pC;88l$=4?!Y6M#JI73XZ4-b
z3&$kR8m5RVB|D7(&kw~ULywU8SFngOB3)PeU7RRMRxzqt7kEw%Fj7HyHjU3OXe#N8xY0(1N`{dl!4
z5x)%M_~Ny&t7_}Gdj@qzduCRF;x$Lq($9hPSI@|Uv&YKQ)jFTWy(d
zS%H&}Q>|4X^2+~u@XG0(wDT3h1a7`;%4ybOW*oH<F>!%&LK08;}t7u?tv7lL^VN}Ucb!9DLeP-2sQR!XHw_;bevm{s8!M^1VAIMWHL!7VKIb9dxd
z5e!4&jicuA&jP|N@gS#})I+%2H1~dmOa9sq#(k>71eq>mX%Kh?U75_1uV|d5%$twb
zr}iDEP0ZzP-*wcQ6jaX95=OZOt(@4?eXyf_;wHzg;P#u7!iF(wpefYe
zAWj%wOt@Fmy}UwI^y$~{?k6`4Ki{2dQ>Um8jo5J$?P)iO@->h?%sSuEuZkQld5<8p
zS9`!hg*R9Y7>x;f@v1BzGrH4%(CI3Z
z$fc9>x%(mJ0jw!5qBbkHTkG(2#}zbhNTo^Yky_y*St>?@wmO{}4;jr@{|Y1UU36TR
zq@7S)v<>|XFc66%kUg)+4b@OR=hYL;5m7vaK(M6(z&wA54uo_XT!~pJ1cIa1ilh7?
zA{uTzt90K=)6!Q6zoPlNddfC9yytSf+-!H9$M=J{4^0rLu9a#tX3V-11Gv8M+6LbH)y9;CEajlkGiS{Tp%P`Ite5|v5KT0@$T^~yCYQ9%JwtS1{HE7
zo*ib(N>_*w%DZn!VuJ=b{U9h#t?cWG=FHdG1PPVLtMt
zeRK*SoIRtWzPX_JfwbftJGiUdRrOj#kIc7#BFvKNge(tUzhMWwWZ}j78Q2s7;q(tZ
zTL`(tN8ro86{lbR3$1{ZdcamCtDi$R;=t)CuqVO6;dT1#`q}B`{{u=uwZFg^CSpaB
z7v#cv0m5A~F`L8C?#v~EqFJQ$pcg@DFsdqro2snPpDk^diLkwRp
z1O^f_A1IPGU5}2HV^^Af02ikm7Ynl3U}jkYUh_rF1PZr)=&WK#u^$nXhexkazXstY
zopb#OEGdLlQDJg_9J3R0OJbvbW7qDoad1#vML%Tbq05aMk?du!n&$z
z-e6?{Up8WBN7aHufVHsL&hTPmt`td|-Pf4pWUS#S$xvO`|eAR=`Vu
zF+@9V>m=3nL9y$}zGNaY5A3+23dF^0czTZF9wKW6BKt(+6h5hdtj>2Ui}QF_v-neC
z)+>lA3Ic@*#J5Bq$ErlHIw?>XCG5BX+iun%-#X_mM8h;d6$QWtqDd8D+f-$dzlbb!
zgNf!;QH<>X+gd9kJ37y67~%STU{K_s|3{&rE|BA76dKr;9e`~-SP=_f-e9WGwrmZ;
zD=R{ALkfk04|$API*$s^f;y7}@D4dF9|k>ePWu?mN1`W4^za9A5K#wj5JY7m3K!C!
z3{4L^k)6>Sbz){dqj|p*`ltr;hWAA%;QR-fFt$JGoBsKp?2IrpVrU+rd80>74jq@1=LTqJhkvWVSR5LFZe+B2TK5YS)dF9t2~Sae-CMOxq2)CBb3LCq;|Z#6-x5*_^O*5
z*DqkyIg!yDUl8+h9+5=Mm@fo6W=18a(zc|-3iv>Nb7_Ikd4*f_g*1qNY)z}%%tW0~
zA>W$LKW}W}mM+EX-ki4O+7Dw%s>1(N3g)d9{%pa2jep+6)I1!mxP?|^3oAY%qCP=<
zW*IvW_$r7^bbd-zI1p-b#|xTggNmV}L3XRGXZ>F&&nxQR)lb=;30OW60j`i6Pq!Wg}I-G8884
zzp~*92I+O^LboA1hGRU2DcMR&~_NImf+e2aa$F-JZ3F{f%5h9
zqjbuLEm7xh+e$&KT2O(|oZ=mswb3!LBBA$vaTp*6InqnGd3g#V*B6H_PlY1j;aR2y
z{KO+VI;*rar|_{{gg)eH*nhVXITsXijuH=ol3QzL?h^xqnMd_!I%or4=UcO5kF5y^
z7JzTTs|&j=O|+9y!8pb9#*Ef%$IJ0{(npyidSf6l3u)3^`H1U%ahr(+D@_GI#(kl*
zd`ppL2+QTU
zi2I1>;8QT(Z_U>IB#|cilTk{7s|AesobVGaDMwB{pc1rWsW6UVGN5wIynI@mjY`=|j$gRxA!j3a
z%h?D`oxfV;qzq*2iMGqqbazo$kvgmW;6dVNteCpYd(-@cxaI3gLj`mV5BZqzi2+PG
zW*e+PPiN3eO_=&=NQfFh=B)79RrX6RZ7Rfps7ujKDG;0?PXOtE~>KBl;y!h%~zI_
zzC@82Je7{yQ4-W-igFa9J)EJahO($U7g4!r^;6Yqsfyt2g$9pABHm4EDKE7Zqq%Qj
zqmB$I6$xA%#SJ`v1QXtZ8vF|!O>`PEkK#7oN?!)FUj|M27KIDLj4PJUnzPLIy-6!g
zJNET~zVY5PWy8L>ljl8X7Wd~
z**}+7b^-lq<(|kq7{;?`@Zvha_ZqYgc+qgk*m0QAUA%WYKPG*^ci9Q=2EIh2YEA^z
z;1b7ixkF3f_AD*Yia;f~YzM(|`5H}(O1+_R-{4cB%->Mjhrmp9fz$>bTaos_CmrHD
zW2#w;);|8{T7!Gejmw`;^X&jJv?^6
zY43<(pG^w&>^?4ji_LsMm0eMM9|tNF&_stPMsLA}b84dLZ_xMG_02yYw;e)#2AViJ
zVf3o8@$=WjuAQ~MbkWG6>CT?KcQN`Mp7n`t3=yfQ`hCRgs9QMwgPCEU_!x_2@Bz5#
zXAm>Qxy&~*xUzJmILX(-r--^*f|xJKk$^cOP-
z&6m2#U+>~xL2YCj%HjDJ%f*~-jLfA3%3M)uW9Bn&OraHt+cr8mYA!4iDdqUEOB{9o
zQc}Q|k|HVJRf$W&cM)D^RlzGiMeqBZ$#qv&3>i;AOtsW8U7Etyz054X!8Anm>5g~UQ%SD8&a1ttXMWj5!wRjUcxjKT1D4e7z
zJR%fsW67?R?TA{(_Fy&>%ZkltXKt;)-wLil5?!JR9o#!Jp(7C3u1aHw1y|fSbae
zCryxA*&3NAWi~?x9|B`Ga@?+t$Rd{A)+~Fb?8^+nll&S_J0c?{iHB&VL{r=ynj$*o
ze86uCf?c!K^j5@kzE
zi62O6iK@)@{9P!;>teP$eAt_r>%=Iw=~D1^5KhdKz(nSO7kvRHMqjLC@Qu{gP8OVE
z!uaFhwE=ZGp)#ZY6cm7dM&!!RfyAZilRAgPPj)gVW+Sn{G{J^Gmi{Y~XpYQUCuS+L
zpiwi?n4EC&2=~3@WhcTunl6k>N8=8laSV;q{IVZ8nNX_w1(jy~S4w3@p;Gk&TtsDT
z5)2lP!|J{*2P1tcQuR4*$xGBeSbFLsv?GZR`2~!-*0MoTeIMBX`N29CVR&%z%GUf>
z2FZfZAXz`!v;d4BpcnFBymZuu!Hpiw0z(+4LM{O}gktIZA4dAmaHLRsFVKIa{@^=|
z(Y(b{4LgAgZHNWz1Yd~G#Vvd>1Szjbfp*dX;i*veC-9ns@Vu|lW2rF7PQp+_8n~$%
zvcQOo;6`5tgN)QU1*sIVXo#>7ZNW<{LEP?DTmFN#ylBqoe^fJGNNJ^xF=2*GZzl<0
zjDs>C3_-Fp(s>`&LBXIcYrwb^F=$HOImzb3!W127jjAKBh&BIAK|+*Xq`FZUwhx9g
zaBBnGIv~$W#jfF`Ia4gUE!B{3=`X6tR>{O=@fGZ+=!Ta=2vm>)HGBtwHVD%?!nRL@
z`@UvS5hiKhtIV?4v?G5DC#
z+Ys>l4pd!c%dAT1F`j!tcerOYh0!aArPsn1r1nFe;Mx5a`%}>Axms
zm|QT6)F@oId3!0NCQ)Q$AznTVb<}F0A;JUzZZ{_C97f>_ECOEORl5VD`CnrY!w?=}
zCos7PB9du{W8!T<8SrYrq61N$I=kAKh$*a(CSq%oz$+<~t&!5~M0&3P6wV}_qbG`p
zCQub6C0z9YpFmq#R3+FQ%$p&3pardYb%7Z_rTZ_
z&E~kCm+-0MoDYM^Z$L*f4Sno8`AvKSrEV|mDelO3vf0PLcq~+gq7?<8a0dAf{wB6j
za?cL*TmTrG6`0W1`K~{@^i>?{S8>P0`-~5Sq%lD(Or6|qOmHUeF08sNESDOjd;`{g
zDKty%@Y0zS?e9YKLFIt&(MP!&Hx;P{kc7
z#gt>xtbcV!>@`MH6hCAM!LJ2hAW@2f&%Q{Nuc~|M=ookr?V93V|0H5KBIIym;tG;C
z&M^@Tu9s%lHFgXn6_Lfn^q4wxy-)=_OoMCJs{#hk%ft*m2Z@;<1~+Erry+ou1+<&v
zWkrSYWy;eMmi?DudPbHH4Ytz<#LDqB;VruV{W_B5v(Up#BJH+
zO(TcOg=Gkx2kzGHBi9_XC7SlGN!aVX!nikKRDWB9sn4j9-M!8Ipev)}q!YNMC~iE8
zOTcpLme;riOZMyI{b0ChXyl|XyubBj>x`$jiEq?&f5giDV}JELc^p;Q=r~0c+QT>F
z>GrWT5k0W1lJ4(5a%A@&Bk^N57QcobU6Xj!+Z-m8?T77BZL1<3xr>(%mTT9*-4?6b
z<2{{QH0ds~70IIPm*Gx5VDbU8MLW7m!JF0{QCeJQm^N>`FWD#v)8R0ZRZA6_WDv
z*a4zKu2Wq3E-d&-3AW5;-;yT4xf0Y0+$K%&XjO6Z%at0)wLuap!y
z1)553zWhflkkUvUN|efa`m*WnmmY-&&sV{Vmy!>kABw~0Ts(Y!s2o1iJjL-%2jO{i
z*MbA+3*hB1pFuy&BQ*DwGw6rP88pq4x2283i5_YThTowygE236*>OXZc_5LiZ<`a>
z&{3=ugsx|_7CsfeWpWUj#eU9XG^?OZ2+b*I=0meWiMdrSGl$k9P$G)Z!z~%TQJ>Mn
z{`fZtaZ33(Jk1pKEuZ<3#kdkjLqea=mOiOS=-<9L3?QM~NUK9c%B;wW5dQB&na5Irg-eM9K2`Zz+aTR|#?%!60BkiG(pOHuku)chZD
zEa4;bS7t7_V^OI3c2s?aBY!bM?Kev(#I0(P-G}3qdV5*r4Yf3X9HGuTIE7Ey6-J#(
zR~2-RAdf=ZQ5AO99?R9_~%(O3$agxk`dgw|$iFE&IRDIt7OqP`gU
zi&2nDm>JccX!~r0VLDvH_RLLSLEmhb4)+yH6L52FHk1N`Xky0qVV`~b+{o?P^pkjXQ8IN)>P+N|Uf9N{HALl~0feZQO|aZJt_-?7SSK?(^xC7~JW-Qi8hEBj=2X
z9p{Y^S)S0n(-PLsO!Pd$XePJtMqS8tnX?~Lxt2*e-)IaM1;b{AVG}`p%TV2B^vFep
zwe4&Y_J}S1ok5iyq-`{OAWf2M_eCEnH+@fXLaL3ADv1OF!2lx}o$w8#2h$mi{29k;
zvyX$XHv6E5CErp=-+-9Rip+b-zgtjf#aqx=d@=4x=o20w)xJYy(JJ<PQA;YXDJ9^xU+~3o0QpfI
z3;v#If$v4GC2;t`x|16XA8$N5@R8^Gs$_(C9@!aX>jeD`+}Fr|;Kv@-F@Qz7QBlo(
z3{X7=AEPL!4Q{%0z(zdq?qi^O*d*1#wkEYP2ef5k*V6-SUybfRuJ_opyhQnoq+fRL
z+gAGfgdIQp;+bWH(pN4%J~8fK&*L`U1HP1uC?)7XGc@8;J2DCPlJ0NFXnHM1_xm$v9V0%xO=74FK0pTf)~WGy
za<)Ptjbxb0dIMRXAnOg86{DZ
zh>|XC!E>Z2XD8`%@c|s=OK=uIpb?ZKX?_HZDCqzy%RpsEWMyB$aHKqhghv7hAJ~Fi
z^drrb+^w0N2#;++_W6-d6a#o?sib-dVX}nl&hQITennR7_B@eFRg_%}&t7~ir>lL)
zU?&dMwdhg?GGV&2Is9jM@nXh6+2M+q@s4^pik8z43R?#77O-j+fle=b;FddO!LV{5
zb1Xc`OuSEnO##s8S3^zkWM&jzi&$VKP(_Wjqi7FW=9!kioOn84_=&T8(55l@5Tb|sF*tKJrYhPk4dGGkvb`)KS2=HK{1~|L}#y}-}
zFo=cCQQXgY-ycIprs~K91$e&}q-ih1-=52>#t0O~X}lrC2!
z7`MJSQ{xn={Ybsn;uLDhfqJmGK%M*6Kz;j12$W%FIh7JmOXm^u?KuPoJfaV~1eWg^
zV77~$-F6NEL(XCrLHKCrQ#*sD6^Psy7ims{7m`tq`Hr_=8e1?VO9n5bJnDQ}0V)4v
zw|5dD`l;i)iVVf`KjFz6b2gD?tTc1DpmFwj^2XM(OP%jwOo5xyme+#nXJC}j`g<77
zZ^dJKp$S9~lvO%<3W6U6L2XAfk<(rOCZV>=QJdAfg`a7@0UX`
zY&Q^ipAF3+MVi`FB?T>Q5ZezbG58VYReVjS0u_oKDI4@EiV!h*xh+3As$zrTWjhjyV}WiMl#Ca6ZFN1QsJuOpu_v%{w+_RRoy
z9mJtfanI>4Kbup%SE)5H&T#5$fIl0~*?-mZ-f|JZoii)a33$
z$`0;sGDAB&3xs@l--k#(;?@5q@U`;wY7^#AG-H6NIfHFRD5Z2y9Ez$2itdR+y!my=
zw@OBM-)S_O9mf;NuNlx4hPgHbNXgg*ZUO0yGtX!205{P>l#Y)7hH;C#(&bTpNc5(A
zE`gcRz43AvNTg62LE7k^ZC<6u@@ZDJRK?k{k97pRZ=`$T>k>qcm=cZ8=ttuHr7C@y
zv#-;{v^g_?hfw~ev~I@47`MoEE?N=OeLcf(TniULIU4rT6kGMLXS7UhZo~2UmOLH
zh6O4Q8DCC5Cp;Z~*YUb=Lk}NvukNfX4DZfR
z;4s~wO#L7j*q8AE>SS?W79;X7dFk^{e#3U63!SrX>;MaIV#Oc#w{(b}X_
z^l-)tXFOWH_qe>0F_`)Cg$xJ}wQ`?i#D=Hx0YQdlCcyv7IR0*PEBN4jt@Ea45JX}G
zbt)30sO0c87vdN}3!zBd8E=A{nj`Ts+|Zhks^>MgEg`&o!Hj>j1aWt8-WYD_@jrbI
zutmHc(j1YYH3hU1zjOfCQt$0eanT(adopyqdWQa4y!F(PF{@Xv?C~>rZhD!IR8eYBTd*1q{5E;7CA@lgJU;$%W%*a3}=zPF8vUfpZ;Lc`F6on!WTif6qAKQ8!jzAa&qf%hC~T|IZz@-?1|7S5b8-`1_Z
z_39?W=ow=tjP#72GADK_=J4qXE-NXr@M4<|Wu9kRI*KAcdME1MFNB|xs{<=5kUsp2
zy-fM~{0oc0;H!ogfu2``($+RddtAJ5v6V*7Ge|n_47)abdkTlslm|!Y$hH
zxAkztlo?U;7kR*YPr*pUCC|S^?yd55zxzD48oJ5OvK_a85HN}RU_aIx`m?pm$Hn;Na~CZ0j-4`p>cT19*rz;lj~hQ@YNYqH
zh0_*JSpbGZ<->ndyfLhQSC3TpmFSI;BgU^->kUD@t#e}L%$nmpd%huVUfiO1&jO`3
zO=%ak>yh$_Rp+jq$e+;aNAZ1pu;QOd!TSF9r?D7(1Eb^hZ|OU_LsiC;AVAmRvVIm%
z`iTU<2+`r4>$%Uw!fvtVy0AqT0=xAgb=xsBp9a%(yo!9zq~}%K!AfE-pGwhvBn|~X
zdTI^Z*YAWloIBBPUszbbeqr7z?ytAo=OGY+&7@$W9=|?WHw8eS>*3GvLO-nS`(bM@
zICKYFP2>HWnUJ`I
zJVIniv2r_X(Sx0r6lFkLunb66ueP8Hu|f#<;vpvUtG9tB0Yj=;ND7=0AQUbnah>&j~2zpN7Z&X|!Le#rQ6KGjPdj_Pj%D}pgo%@R1nn@bOkXb{?thuDokA!~@
zz7VW?0uA)A9IE^~F+y-GcR{d7A6oe$-q{45BsVMX#1|T1p$RTI7g^Qz2sj_@mK{s7x3laGEL%1JR-y&cr)WVD78|mheT^j@G4CQK
zX(;HPn%Ptw(1d6M4qfAk>Td7|3g@{-ova;@YE!Hgfj
zODdltVBJm5O((Y=vj$L#&Dz0cK{?h4N-+NYo`PN7V})SefCrn*E?k^UC((~t-J)!J
zrjx2W7|ajKy_>rO|LHFMeK!|8mFC7$!fVZq?~yllGB&pjfJ%}irfxvapH|G
zJcWlR2~R~dq5`-L-07-Ex!}XN7c^r$wVw7P5_A>#{5p)t;BaWf!bN*OXd&%@Ve%ZB
z?*jA@7h%8&lk-NbOI`3SBn^`r)ob$4DJ@Fg;ffpSxp5%Y9z{&iP=lK*d85IlOYW
z*-+i!Yn;ui^NPoZRRsUuA9IVr4D(})hEEPNOAlUwZ71dz4P##mvqRenJcP~#KlPB2
zf`Rs_2sAVI%0(krxCRI`97Y5<{4Ww7EyBZ54#NXy{2?wh9^LMyza)NuBOcr!9^*eq
z179eLx!|smK#3Toi@)-Jy>tQhhY!{MgL?o@+$wNdi)@xOdpQ#pIsDKf4{&OGv`gD4
z!LZ{Yfe!BU>_ZgeAvku)3rQ4gR(cj{F@fHJf6dSwY(?VRQ7)fF%6%1Kg7`dHhC9#e
zKy_|G@7LiJ>hS7df$D&-vm%$1$RB`0phkV^2l*!pkUkLV0DXlokRwm{c&Qb(Y;1+s
z&J8PAc{y(7)<3F4Iq^SU?*4sV4y-92v}!J-~1c=rxf9Ga5YgF#4R88^99`
zChjSD6^sw?lwZ;M8sySOVTi@q!+5WhIxq`3+>xeQ`*Jtzp~@}L()e`^1Fyg>*~ai1
zU>a}$tax?>7XGPm(>;SoO7u4--a_>Y!O|7nR^y40h-#?^{ppg#tc!SA!?K-Q7H;&U
zi4U>kLvKcZg6f2R#Un~B;$;kRJGU%Sa4iMyE5xA@W6)U=6Ru@2#>#tv1u~pHIK>mN
zF;WL~4aUOIXI8%B8SHpA5X$oZa9?azZUXVQkmeE`hZ3^;aoR&KO@Zab@Hju<{=YJf
zQrjc+$Xa-SBH$(N9JB_D`0$WY(J)vCd#$IF2KL-#>oI7+z)qfhh9;gnyCw0U4J@U2
znOY6F7iV`HK$)j!PF*-+YjfmG=u=N9bN4zh@Tzs4m-(n;Xq%q43q3oY`p6R!dARk+
zuB6?kJr8Y(=+J&>MDI=mb{`#NGxMr&4N5$Q5~x^*fAp86k=CHg9e%%i`Rt>|pPZ>x
z^OKI1y+P-m7$2XwVdc1ph_T~_M~sV4^fvQx>4c91J1ZD;{!02?EZ~-iLih;}*S1&9
ztmS?ClA&{2a8R)1}sV(xoa1evkT
z2je9$>7~`~j`#RJ6ED-}D;~)GlfHzqHogXo(!5K~c^WlRUIHU(1CtB~Bdjq(*`)2@
z4}pEQ@iI1!U9G0Y+}n6B?Ox(N%cXlO?)`@UXg3%pjekJsbY9cxgMWwcc-R6pm{b~S
z;~(>9)1|s-US04fIEFIN8s0^1?*hKNLl{2+WvmEm!&{ro-$qL9khUGvVK_$G&jhXV
z7hzDw{nE~@=g#jO64H9`kaq0{Z@uVk4j(9uL7ot3%apNX1%eY!_&zJIeiy1^*I@30
ziL;nRcua1`9~(#zf?1#E!FXPVObcS%r)ojJerVf>A%*B6(h!WIqfXXGjOJuZBjF=L
zvqY-6b*hk2fw)K&X|V!i83WtKC_EW1tvY<#vfHqYTVxEXxWQC-GMkOCyeEW8P?ym>
zOh`H*n=on+g1Qp!GoBQ+LvB*xb02y45+N6=fmb&T3nk?)4Y(YO22Vlw9VjWMz`ee1
zA*Vl?nEn{N_=g$7mRz(YPuP+x+d?J5>&Ge&65yUlew2uRZ(P6H=)^^b+yKR=1^L^L
z^i_-u?}WTil0mY3hKQS^d42+1UxkasV?P8>Am8Jw6hzRxj~EXH#$LDnb-BVNXGsn=
z(yh=hsFvf;mgvMuJ
zjFjQ`5uS79mEf>fATEN1ZxCXc58DVjoXr-HII4EW*J)fI<25c|f@dllCexUJCxulJ@M!h)@v(IhnBA!pv0ox^UIdN(8tE&?N*xg^_6cMahRRx`%PaD`t$bubUw>zD
z2dU1kudVrYz39RWl8Qm~Mz3ihg*0ZKHrHWQdPdC^XB$?z!PrCyVrCu=1}0aUi`MR1
zrMdj`9$7BGq(|?!BCxQOOQpFWFQ(F5=9X6B2DB0xSW@l6N^_{r3X9BTx*SbQ@nzJl;N2h*QNlI+L#4DF
z3^;Vo$6<(48ea2v3wS8yyogj{G=!SC1FGDUUr%7r_d2R3|NTZgM
z_W}nlDjXLTcY)DfvJ>e>-kT5Cs>r2SD9fZ}^~j6By38zv?k7^n>0+S*P@5<}LuA(Q
z+BCEtaopKM%u>8mr~tto@RG_jB7DC9g%pD7
zF$EMm6T=GuIkky%xl|YJU#3CoG8Ga!ll?6KjWm)F6;My3h*b~M3yqRXOP0BU(J&Pp
zs)7rRlffabU^Gw#$2KKU4vdc4XyNZtrVonMAZVci32jP#7Jx_GEIO6!|fo{>Ir0XD?dh
zF_A>ij-EZmn~zt1T&Fj|pVNs#EgIL`ehm6;fkxaLRQZw0-UNS^5=lc8
z(qex;`fdELM}$`I!RL%1uSZ5v^y?8)fF6tJ*CM4TI(9XY>Tj3&Y8Mu4h5mLXt?I-i
zJs42uPN|RhZN-dd47Ni*M1`&|^0UNge@H_j;9ni17ud3*6r=N4{x(9|gGuaxbmlEgr^_vD92A*gSj;85MEdLKqPM1N$-NdN9))=@2U~g@Kl`fA_&C
zhy%fBVURum-mJWUbuM7_e7*Cq9Yz+j?)bPd=K!|C%ZPh+x#r>a17}ryLrcgVp6!IR
zqzM_5Ep4MuLDXexS$Yh{{pI$gGYvwG=;It{q0z6@5nkI4Llp4>w!qjR;c_OS7Nbj;
zMaz*z-b_w!*1Mdxz`Nc@5UHy1TSdKJW^ib1?Pq83sDjCeB)bg%Tes0()q=?63KDvK
zk}xP{iX(5h64#lVT6xKO(jsOuBrXv~-3_FTuP_PHM{qg?M3Iw2H=YT92VbCyBUT>I
z+S1_U#lVFP8te)q?a+1zA`ajk4{ZkGeRJEw!xg|Gn8#~cK<+cn;mU%k-iK0o5?#G*
zojE&it)*sKZ%%y9fUutd?P`tAuD3Z@**KGHy`7JR8~JN*bK)g5^T6<$rwCK41cY^Y
zM#E`%7p6F6F!Q6Db{MW`N1HtUZVX9kkU-LeF@Am%Dt2(s18n2oEpG43W?IQxS23V-g
z`yUe5rZ5Jbqg04fvOfRF96oJke?^?C?a*B%iJ|{S*NhQkT|3~-TF2;aUT%Nsh#4g4I_m(-P+KlwLK#C|qh({CiR>mM5HdCZf0YF+gtNj+R$ZDe4>(qR~^H~I4L
z)G@{IceJiqaN`{OI&!<~>dKl}7emg540d%*Yk(5xFt28OoHOEmosTX&2GeyKLYMTU
z^h~D3@S(*Vy6`jvmoOODIbvmRTFHsibx%xJ$2i492rVI>kRZE#yyAG5lMXw|ud@H9T()`6SXG>5f7Yc2+aP(sT`1d`
zckC~@Nx@2gq_J%y6LX*}&8*|4qLl}^_$?*Y(-xRdUV%kDBjAk@SIDeLJEK3IH!E3%
zmAnP9vmFLFVbEtgBZ{?Tnf%yYVQX~~V~}}-e|eQ7t3sLkgi9Me{_Dw>;Hi5rsHMXB
zUHO*^4rSU8D9(pmPVkiF6D|M3SaS~)VyLflV1d?3CabV1Ic9Yf{5*odh!>na>fEzM
zl~PttG+)AvGhYRF=D+ry-&MsefN3qv&86m^I9&Q;t
z{hQ-|R&n++3-O>Uw$*e2%Y%ADTd+-*YP6w(
z^t&G~U7tUZ+cP7=8Nwv_5I%}@>u~YvjoZ{e34^A^1y3BeY~4sD?M*JN{H*