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 ( + +
+ + + + + {error && {error}} +
+ + +
+ +
+ ); +} 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 ( + +
+ + + + + {error && {error}} +
+ + +
+ +
+ ); +} + +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 ? ( +

+ ابتدا از بخش «قابلیت‌ها» یک قابلیت بسازید. +

+ ) : ( +
+ ({ + value: f.id, + label: `${f.name} (${f.feature_key})`, + }))} + disabled={saving} + /> + + + {error && {error}} + {success && {success}} +
+ + +
+ + )} +
+ ); +} 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 ( + +
+ + + + + setStatus(v as ServiceStatus)} + options={STATUS_OPTIONS} + disabled={saving} + /> + {error && {error}} +
+ + +
+ +
+ ); +} 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 ( +
+

اطلاعات پایه

+ + + + {success && {success}} + + + ); +} + +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}} + +
+ + setType(v as "subdomain" | "custom_domain")} + options={[ + { value: "custom_domain", label: "دامنه اختصاصی" }, + { value: "subdomain", label: "زیردامنه" }, + ]} + disabled={saving} + /> + + +
+ ); +} + +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}} + +
+

+ {subscription ? "تغییر/ثبت اشتراک جدید" : "ثبت اشتراک"} +

+ {plans.length === 0 ? ( +

+ ابتدا از بخش «پلن‌ها» یک پلن بسازید. +

+ ) : ( + <> + ({ value: p.id, label: `${p.name} (${p.code})` }))} + disabled={saving} + /> + + + + )} + +
+ ); +} + +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}} + +
+

افزودن عضو

+ {users.length === 0 ? ( +

+ کاربری برای افزودن یافت نشد. ابتدا از بخش «کاربران» کاربر بسازید. +

+ ) : ( + <> + + + + setRole(v as "tenant_admin" | "tenant_member")} + options={[ + { value: "tenant_member", label: "عضو" }, + { value: "tenant_admin", label: "مدیر" }, + ]} + disabled={saving} + /> + + + )} + +
+ ); +} 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}} + +
+ + { + setSlugTouched(true); + setSlug(v); + }} + disabled={loading} + placeholder="sample-shop" + /> + + + + +
+ ); +} 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 ? ( + + ) : ( +
+ + + + + + + + + + + + {tenants.map((t) => ( + + + + + + + + ))} + +
نامSlugوضعیتOnboardingعملیات
{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 ( + +
+ + + + + + {error && {error}} +
+ + +
+ +
+ ); +} 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 ( + +
+

تأیید شماره موبایل

+

+ برای استفاده از سامانه یکپارچه، تأیید شماره موبایل الزامی است. +

+ + {error && ( +

{error}

+ )} + + {step === "mobile" ? ( +
+ setMobile(persianToEnglish(e.target.value))} + className="w-full rounded-xl border border-gray-200 px-4 py-2.5" + /> + +
+ ) : ( +
+ setCode(normalizeOtpCode(e.target.value))} + className="w-full rounded-xl border border-gray-200 px-4 py-2.5 text-center font-mono tracking-widest" + /> + +
+ )} +
+
+ ); +} 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 ( +
  1. + + {index + 1} + + + {step.label} + + {index < STEPS.length - 1 && /} +
  2. + ); + })} +
+ ); +} + +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 && ( +
+
+

زیردامنه اختصاصی

+

+ {ctx.primary_domain || `${ctx.tenant.slug}.your-domain`} +

+
+ + + + +
+ )} + + {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 */} +
+ +

+ © {new Date().getFullYear()} {theme?.site_name ?? "Torbatyar"} — White-label SaaS Platform +

+

{theme?.support_email ?? "support@example.com"}

+
+
+
+ ); +} 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 ( +
+ + +
+ {(theme?.site_name ?? "T").charAt(0)} +
+
+

{theme?.site_name ?? "Torbatyar"}

+

پلتفرم SaaS چندسرویسی

+
+ + + +
+
+ ); +} 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 ( + +