From 203671a7bf914753ed58aaee306a78fb6f035e83 Mon Sep 17 00:00:00 2001 From: Mortezakoohjani Date: Mon, 27 Jul 2026 11:43:10 +0330 Subject: [PATCH] feat(experience): ship Experience Platform phases 11.0-11.10 Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete. Co-authored-by: Cursor --- backend/services/experience/Dockerfile.dev | 22 + backend/services/experience/README.md | 42 + backend/services/experience/alembic.ini | 41 + backend/services/experience/alembic/env.py | 42 + .../alembic/versions/0001_initial.py | 19 + .../versions/0002_phase_111_sites_pages.py | 25 + .../versions/0003_phase_112_components.py | 25 + .../versions/0004_phase_113_themes_layouts.py | 28 + .../versions/0005_phase_114_templates.py | 25 + .../0006_phase_115_localization_media.py | 25 + ...07_phase_116_forms_surveys_appointments.py | 28 + .../0008_phase_117_publishing_seo_pwa.py | 28 + .../0009_phase_118_bundles_toggles.py | 26 + .../0010_phase_119_consumer_widgets.py | 27 + .../0011_phase_1110_analytics_ai_hooks.py | 26 + backend/services/experience/app/__init__.py | 1 + backend/services/experience/app/api/deps.py | 39 + .../experience/app/api/permissions.py | 48 + .../experience/app/api/v1/__init__.py | 194 +++ .../app/api/v1/ai_content_dispatches.py | 118 ++ .../api/v1/ai_content_hook_registrations.py | 112 ++ .../api/v1/analytics_report_definitions.py | 108 ++ .../app/api/v1/analytics_snapshots.py | 106 ++ .../app/api/v1/appointment_booking_refs.py | 102 ++ .../app/api/v1/appointment_pages.py | 120 ++ .../services/experience/app/api/v1/audit.py | 30 + .../app/api/v1/bundle_definitions.py | 49 + .../app/api/v1/component_versions.py | 133 ++ .../experience/app/api/v1/components.py | 174 +++ .../experience/app/api/v1/configurations.py | 83 ++ .../api/v1/consumer_connector_dispatches.py | 127 ++ .../v1/consumer_connector_registrations.py | 114 ++ .../app/api/v1/domain_edge_bindings.py | 43 + .../app/api/v1/external_providers.py | 83 ++ .../experience/app/api/v1/feature_toggles.py | 50 + .../experience/app/api/v1/form_submissions.py | 96 ++ .../services/experience/app/api/v1/forms.py | 109 ++ .../services/experience/app/api/v1/health.py | 226 +++ .../experience/app/api/v1/layout_versions.py | 124 ++ .../services/experience/app/api/v1/layouts.py | 157 ++ .../experience/app/api/v1/license_bindings.py | 49 + .../experience/app/api/v1/locale_profiles.py | 70 + .../experience/app/api/v1/localizations.py | 137 ++ .../experience/app/api/v1/media_refs.py | 125 ++ .../app/api/v1/notify_dispatches.py | 116 ++ .../app/api/v1/page_component_placements.py | 120 ++ .../app/api/v1/page_layout_bindings.py | 100 ++ .../services/experience/app/api/v1/pages.py | 112 ++ .../experience/app/api/v1/publish_releases.py | 50 + .../experience/app/api/v1/pwa_manifests.py | 33 + .../experience/app/api/v1/render_engines.py | 83 ++ .../experience/app/api/v1/robots_shells.py | 33 + .../experience/app/api/v1/seo_metadata.py | 43 + .../experience/app/api/v1/settings.py | 39 + .../experience/app/api/v1/site_domains.py | 86 ++ .../app/api/v1/site_locale_bindings.py | 127 ++ .../app/api/v1/site_theme_bindings.py | 98 ++ .../experience/app/api/v1/sitemap_shells.py | 33 + .../services/experience/app/api/v1/sites.py | 115 ++ .../experience/app/api/v1/survey_responses.py | 96 ++ .../services/experience/app/api/v1/surveys.py | 113 ++ .../app/api/v1/template_instantiations.py | 94 ++ .../app/api/v1/template_versions.py | 129 ++ .../experience/app/api/v1/templates.py | 155 ++ .../experience/app/api/v1/tenant_bundles.py | 50 + .../experience/app/api/v1/theme_versions.py | 124 ++ .../services/experience/app/api/v1/themes.py | 146 ++ .../app/api/v1/widget_definitions.py | 105 ++ .../experience/app/api/v1/widget_instances.py | 117 ++ .../experience/app/api/v1/workspaces.py | 83 ++ .../experience/app/commands/__init__.py | 16 + .../experience/app/commands/ai_hooks.py | 30 + .../experience/app/commands/analytics.py | 27 + .../experience/app/commands/bundles.py | 43 + .../experience/app/commands/components.py | 126 ++ .../services/experience/app/commands/forms.py | 169 +++ .../experience/app/commands/integrations.py | 68 + .../experience/app/commands/localization.py | 108 ++ .../experience/app/commands/publishing.py | 64 + .../experience/app/commands/templates.py | 102 ++ .../experience/app/commands/themes.py | 240 +++ .../services/experience/app/core/config.py | 75 + .../services/experience/app/core/database.py | 27 + .../services/experience/app/core/logging.py | 14 + .../services/experience/app/core/security.py | 49 + .../experience/app/events/publisher.py | 63 + .../services/experience/app/events/types.py | 158 ++ backend/services/experience/app/main.py | 69 + .../experience/app/middlewares/tenant.py | 24 + .../experience/app/models/__init__.py | 77 + .../experience/app/models/ai_hooks.py | 119 ++ .../experience/app/models/analytics.py | 109 ++ .../services/experience/app/models/base.py | 51 + .../services/experience/app/models/bundles.py | 208 +++ .../experience/app/models/components.py | 162 ++ .../services/experience/app/models/forms.py | 276 ++++ .../experience/app/models/foundation.py | 303 ++++ .../experience/app/models/integrations.py | 257 ++++ .../experience/app/models/localization.py | 177 +++ .../experience/app/models/publishing.py | 257 ++++ .../services/experience/app/models/sites.py | 153 ++ .../experience/app/models/templates.py | 159 ++ .../services/experience/app/models/themes.py | 293 ++++ .../services/experience/app/models/types.py | 485 ++++++ .../experience/app/permissions/definitions.py | 333 +++++ .../experience/app/policies/__init__.py | 70 + .../experience/app/policies/ai_hooks.py | 28 + .../experience/app/policies/analytics.py | 25 + .../experience/app/policies/bundles.py | 50 + .../experience/app/policies/components.py | 85 ++ .../services/experience/app/policies/forms.py | 112 ++ .../experience/app/policies/integrations.py | 66 + .../experience/app/policies/localization.py | 61 + .../experience/app/policies/publishing.py | 64 + .../experience/app/policies/templates.py | 74 + .../experience/app/policies/themes.py | 77 + .../experience/app/providers/__init__.py | 18 + .../experience/app/providers/contracts.py | 136 ++ .../experience/app/providers/mocks.py | 93 ++ .../experience/app/queries/__init__.py | 16 + .../experience/app/queries/ai_hooks.py | 19 + .../experience/app/queries/analytics.py | 19 + .../experience/app/queries/bundles.py | 31 + .../experience/app/queries/components.py | 63 + .../services/experience/app/queries/forms.py | 111 ++ .../experience/app/queries/integrations.py | 40 + .../experience/app/queries/localization.py | 63 + .../experience/app/queries/publishing.py | 49 + .../experience/app/queries/templates.py | 63 + .../services/experience/app/queries/themes.py | 123 ++ .../experience/app/repositories/ai_hooks.py | 49 + .../experience/app/repositories/analytics.py | 49 + .../experience/app/repositories/base.py | 78 + .../experience/app/repositories/bundles.py | 42 + .../experience/app/repositories/components.py | 177 +++ .../experience/app/repositories/forms.py | 190 +++ .../experience/app/repositories/foundation.py | 142 ++ .../app/repositories/integrations.py | 66 + .../app/repositories/localization.py | 158 ++ .../experience/app/repositories/publishing.py | 139 ++ .../experience/app/repositories/sites.py | 117 ++ .../experience/app/repositories/templates.py | 132 ++ .../experience/app/repositories/themes.py | 341 +++++ .../experience/app/schemas/ai_hooks.py | 121 ++ .../experience/app/schemas/analytics.py | 118 ++ .../experience/app/schemas/bundles.py | 178 +++ .../services/experience/app/schemas/common.py | 18 + .../experience/app/schemas/components.py | 177 +++ .../services/experience/app/schemas/forms.py | 339 +++++ .../experience/app/schemas/foundation.py | 294 ++++ .../experience/app/schemas/integrations.py | 256 ++++ .../experience/app/schemas/localization.py | 195 +++ .../experience/app/schemas/publishing.py | 322 ++++ .../services/experience/app/schemas/sites.py | 179 +++ .../experience/app/schemas/templates.py | 172 +++ .../services/experience/app/schemas/themes.py | 341 +++++ .../experience/app/services/ai_hooks.py | 312 ++++ .../experience/app/services/analytics.py | 298 ++++ .../experience/app/services/audit_service.py | 51 + .../experience/app/services/bundles.py | 240 +++ .../experience/app/services/components.py | 760 ++++++++++ .../services/experience/app/services/forms.py | 978 ++++++++++++ .../experience/app/services/foundation.py | 779 ++++++++++ .../experience/app/services/integrations.py | 604 ++++++++ .../experience/app/services/localization.py | 696 +++++++++ .../experience/app/services/publishing.py | 879 +++++++++++ .../services/experience/app/services/sites.py | 745 ++++++++++ .../experience/app/services/templates.py | 770 ++++++++++ .../experience/app/services/themes.py | 1305 +++++++++++++++++ .../experience/app/specifications/__init__.py | 72 + .../experience/app/specifications/ai_hooks.py | 76 + .../app/specifications/analytics.py | 69 + .../experience/app/specifications/bundles.py | 80 + .../app/specifications/components.py | 155 ++ .../experience/app/specifications/forms.py | 312 ++++ .../app/specifications/integrations.py | 117 ++ .../app/specifications/localization.py | 239 +++ .../app/specifications/publishing.py | 270 ++++ .../app/specifications/templates.py | 168 +++ .../experience/app/specifications/themes.py | 289 ++++ .../services/experience/app/tests/__init__.py | 0 .../services/experience/app/tests/conftest.py | 62 + .../experience/app/tests/test_ai_hooks.py | 140 ++ .../experience/app/tests/test_analytics.py | 98 ++ .../services/experience/app/tests/test_api.py | 153 ++ .../experience/app/tests/test_architecture.py | 463 ++++++ .../experience/app/tests/test_bundles.py | 83 ++ .../app/tests/test_component_policies.py | 61 + .../experience/app/tests/test_components.py | 262 ++++ .../experience/app/tests/test_dependency.py | 19 + .../experience/app/tests/test_docs.py | 56 + .../experience/app/tests/test_forms.py | 206 +++ .../experience/app/tests/test_integrations.py | 226 +++ .../experience/app/tests/test_localization.py | 259 ++++ .../experience/app/tests/test_migration.py | 76 + .../experience/app/tests/test_permissions.py | 145 ++ .../experience/app/tests/test_publishing.py | 56 + .../experience/app/tests/test_security.py | 25 + .../experience/app/tests/test_sites.py | 169 +++ .../experience/app/tests/test_templates.py | 247 ++++ .../app/tests/test_theme_policies.py | 142 ++ .../experience/app/tests/test_themes.py | 374 +++++ .../experience/app/validators/__init__.py | 98 ++ .../experience/app/validators/ai_hooks.py | 48 + .../experience/app/validators/analytics.py | 20 + .../experience/app/validators/bundles.py | 46 + .../experience/app/validators/components.py | 113 ++ .../experience/app/validators/forms.py | 209 +++ .../experience/app/validators/foundation.py | 55 + .../experience/app/validators/integrations.py | 76 + .../experience/app/validators/localization.py | 183 +++ .../experience/app/validators/publishing.py | 186 +++ .../experience/app/validators/templates.py | 108 ++ .../experience/app/validators/themes.py | 270 ++++ backend/services/experience/pytest.ini | 8 + backend/services/experience/requirements.txt | 14 + .../services/experience/scripts/ensure_db.py | 41 + docs/ai-framework/phase-manifest.yaml | 26 +- docs/ai-framework/project-index.yaml | 8 +- docs/ai-framework/service-manifest.yaml | 52 +- docs/architecture/adr/ADR-016.md | 64 + docs/experience-phase-11-0.md | 83 ++ docs/experience-phase-11-1.md | 77 + docs/experience-phase-11-10-audit.md | 59 + docs/experience-phase-11-10.md | 62 + docs/experience-phase-11-2.md | 109 ++ docs/experience-phase-11-3.md | 126 ++ docs/experience-phase-11-4.md | 110 ++ docs/experience-phase-11-5.md | 104 ++ docs/experience-phase-11-6.md | 60 + docs/experience-phase-11-7.md | 11 + docs/experience-phase-11-8.md | 15 + docs/experience-phase-11-9.md | 19 + docs/experience-phase-xp-reg.md | 132 ++ docs/experience-roadmap.md | 191 +++ docs/phase-handover/phase-11-0.md | 104 ++ docs/phase-handover/phase-11-1.md | 88 ++ docs/phase-handover/phase-11-10.md | 26 + docs/phase-handover/phase-11-2.md | 103 ++ docs/phase-handover/phase-11-3.md | 109 ++ docs/phase-handover/phase-11-4.md | 104 ++ docs/phase-handover/phase-11-5.md | 94 ++ docs/phase-handover/phase-11-6.md | 59 + docs/phase-handover/phase-11-7.md | 9 + docs/phase-handover/phase-11-8.md | 9 + docs/phase-handover/phase-11-9.md | 9 + docs/phase-handover/phase-xp-reg.md | 108 ++ docs/phases/Experience/README.md | 52 + docs/service-snapshots/experience.yaml | 83 ++ 249 files changed, 33903 insertions(+), 16 deletions(-) create mode 100644 backend/services/experience/Dockerfile.dev create mode 100644 backend/services/experience/README.md create mode 100644 backend/services/experience/alembic.ini create mode 100644 backend/services/experience/alembic/env.py create mode 100644 backend/services/experience/alembic/versions/0001_initial.py create mode 100644 backend/services/experience/alembic/versions/0002_phase_111_sites_pages.py create mode 100644 backend/services/experience/alembic/versions/0003_phase_112_components.py create mode 100644 backend/services/experience/alembic/versions/0004_phase_113_themes_layouts.py create mode 100644 backend/services/experience/alembic/versions/0005_phase_114_templates.py create mode 100644 backend/services/experience/alembic/versions/0006_phase_115_localization_media.py create mode 100644 backend/services/experience/alembic/versions/0007_phase_116_forms_surveys_appointments.py create mode 100644 backend/services/experience/alembic/versions/0008_phase_117_publishing_seo_pwa.py create mode 100644 backend/services/experience/alembic/versions/0009_phase_118_bundles_toggles.py create mode 100644 backend/services/experience/alembic/versions/0010_phase_119_consumer_widgets.py create mode 100644 backend/services/experience/alembic/versions/0011_phase_1110_analytics_ai_hooks.py create mode 100644 backend/services/experience/app/__init__.py create mode 100644 backend/services/experience/app/api/deps.py create mode 100644 backend/services/experience/app/api/permissions.py create mode 100644 backend/services/experience/app/api/v1/__init__.py create mode 100644 backend/services/experience/app/api/v1/ai_content_dispatches.py create mode 100644 backend/services/experience/app/api/v1/ai_content_hook_registrations.py create mode 100644 backend/services/experience/app/api/v1/analytics_report_definitions.py create mode 100644 backend/services/experience/app/api/v1/analytics_snapshots.py create mode 100644 backend/services/experience/app/api/v1/appointment_booking_refs.py create mode 100644 backend/services/experience/app/api/v1/appointment_pages.py create mode 100644 backend/services/experience/app/api/v1/audit.py create mode 100644 backend/services/experience/app/api/v1/bundle_definitions.py create mode 100644 backend/services/experience/app/api/v1/component_versions.py create mode 100644 backend/services/experience/app/api/v1/components.py create mode 100644 backend/services/experience/app/api/v1/configurations.py create mode 100644 backend/services/experience/app/api/v1/consumer_connector_dispatches.py create mode 100644 backend/services/experience/app/api/v1/consumer_connector_registrations.py create mode 100644 backend/services/experience/app/api/v1/domain_edge_bindings.py create mode 100644 backend/services/experience/app/api/v1/external_providers.py create mode 100644 backend/services/experience/app/api/v1/feature_toggles.py create mode 100644 backend/services/experience/app/api/v1/form_submissions.py create mode 100644 backend/services/experience/app/api/v1/forms.py create mode 100644 backend/services/experience/app/api/v1/health.py create mode 100644 backend/services/experience/app/api/v1/layout_versions.py create mode 100644 backend/services/experience/app/api/v1/layouts.py create mode 100644 backend/services/experience/app/api/v1/license_bindings.py create mode 100644 backend/services/experience/app/api/v1/locale_profiles.py create mode 100644 backend/services/experience/app/api/v1/localizations.py create mode 100644 backend/services/experience/app/api/v1/media_refs.py create mode 100644 backend/services/experience/app/api/v1/notify_dispatches.py create mode 100644 backend/services/experience/app/api/v1/page_component_placements.py create mode 100644 backend/services/experience/app/api/v1/page_layout_bindings.py create mode 100644 backend/services/experience/app/api/v1/pages.py create mode 100644 backend/services/experience/app/api/v1/publish_releases.py create mode 100644 backend/services/experience/app/api/v1/pwa_manifests.py create mode 100644 backend/services/experience/app/api/v1/render_engines.py create mode 100644 backend/services/experience/app/api/v1/robots_shells.py create mode 100644 backend/services/experience/app/api/v1/seo_metadata.py create mode 100644 backend/services/experience/app/api/v1/settings.py create mode 100644 backend/services/experience/app/api/v1/site_domains.py create mode 100644 backend/services/experience/app/api/v1/site_locale_bindings.py create mode 100644 backend/services/experience/app/api/v1/site_theme_bindings.py create mode 100644 backend/services/experience/app/api/v1/sitemap_shells.py create mode 100644 backend/services/experience/app/api/v1/sites.py create mode 100644 backend/services/experience/app/api/v1/survey_responses.py create mode 100644 backend/services/experience/app/api/v1/surveys.py create mode 100644 backend/services/experience/app/api/v1/template_instantiations.py create mode 100644 backend/services/experience/app/api/v1/template_versions.py create mode 100644 backend/services/experience/app/api/v1/templates.py create mode 100644 backend/services/experience/app/api/v1/tenant_bundles.py create mode 100644 backend/services/experience/app/api/v1/theme_versions.py create mode 100644 backend/services/experience/app/api/v1/themes.py create mode 100644 backend/services/experience/app/api/v1/widget_definitions.py create mode 100644 backend/services/experience/app/api/v1/widget_instances.py create mode 100644 backend/services/experience/app/api/v1/workspaces.py create mode 100644 backend/services/experience/app/commands/__init__.py create mode 100644 backend/services/experience/app/commands/ai_hooks.py create mode 100644 backend/services/experience/app/commands/analytics.py create mode 100644 backend/services/experience/app/commands/bundles.py create mode 100644 backend/services/experience/app/commands/components.py create mode 100644 backend/services/experience/app/commands/forms.py create mode 100644 backend/services/experience/app/commands/integrations.py create mode 100644 backend/services/experience/app/commands/localization.py create mode 100644 backend/services/experience/app/commands/publishing.py create mode 100644 backend/services/experience/app/commands/templates.py create mode 100644 backend/services/experience/app/commands/themes.py create mode 100644 backend/services/experience/app/core/config.py create mode 100644 backend/services/experience/app/core/database.py create mode 100644 backend/services/experience/app/core/logging.py create mode 100644 backend/services/experience/app/core/security.py create mode 100644 backend/services/experience/app/events/publisher.py create mode 100644 backend/services/experience/app/events/types.py create mode 100644 backend/services/experience/app/main.py create mode 100644 backend/services/experience/app/middlewares/tenant.py create mode 100644 backend/services/experience/app/models/__init__.py create mode 100644 backend/services/experience/app/models/ai_hooks.py create mode 100644 backend/services/experience/app/models/analytics.py create mode 100644 backend/services/experience/app/models/base.py create mode 100644 backend/services/experience/app/models/bundles.py create mode 100644 backend/services/experience/app/models/components.py create mode 100644 backend/services/experience/app/models/forms.py create mode 100644 backend/services/experience/app/models/foundation.py create mode 100644 backend/services/experience/app/models/integrations.py create mode 100644 backend/services/experience/app/models/localization.py create mode 100644 backend/services/experience/app/models/publishing.py create mode 100644 backend/services/experience/app/models/sites.py create mode 100644 backend/services/experience/app/models/templates.py create mode 100644 backend/services/experience/app/models/themes.py create mode 100644 backend/services/experience/app/models/types.py create mode 100644 backend/services/experience/app/permissions/definitions.py create mode 100644 backend/services/experience/app/policies/__init__.py create mode 100644 backend/services/experience/app/policies/ai_hooks.py create mode 100644 backend/services/experience/app/policies/analytics.py create mode 100644 backend/services/experience/app/policies/bundles.py create mode 100644 backend/services/experience/app/policies/components.py create mode 100644 backend/services/experience/app/policies/forms.py create mode 100644 backend/services/experience/app/policies/integrations.py create mode 100644 backend/services/experience/app/policies/localization.py create mode 100644 backend/services/experience/app/policies/publishing.py create mode 100644 backend/services/experience/app/policies/templates.py create mode 100644 backend/services/experience/app/policies/themes.py create mode 100644 backend/services/experience/app/providers/__init__.py create mode 100644 backend/services/experience/app/providers/contracts.py create mode 100644 backend/services/experience/app/providers/mocks.py create mode 100644 backend/services/experience/app/queries/__init__.py create mode 100644 backend/services/experience/app/queries/ai_hooks.py create mode 100644 backend/services/experience/app/queries/analytics.py create mode 100644 backend/services/experience/app/queries/bundles.py create mode 100644 backend/services/experience/app/queries/components.py create mode 100644 backend/services/experience/app/queries/forms.py create mode 100644 backend/services/experience/app/queries/integrations.py create mode 100644 backend/services/experience/app/queries/localization.py create mode 100644 backend/services/experience/app/queries/publishing.py create mode 100644 backend/services/experience/app/queries/templates.py create mode 100644 backend/services/experience/app/queries/themes.py create mode 100644 backend/services/experience/app/repositories/ai_hooks.py create mode 100644 backend/services/experience/app/repositories/analytics.py create mode 100644 backend/services/experience/app/repositories/base.py create mode 100644 backend/services/experience/app/repositories/bundles.py create mode 100644 backend/services/experience/app/repositories/components.py create mode 100644 backend/services/experience/app/repositories/forms.py create mode 100644 backend/services/experience/app/repositories/foundation.py create mode 100644 backend/services/experience/app/repositories/integrations.py create mode 100644 backend/services/experience/app/repositories/localization.py create mode 100644 backend/services/experience/app/repositories/publishing.py create mode 100644 backend/services/experience/app/repositories/sites.py create mode 100644 backend/services/experience/app/repositories/templates.py create mode 100644 backend/services/experience/app/repositories/themes.py create mode 100644 backend/services/experience/app/schemas/ai_hooks.py create mode 100644 backend/services/experience/app/schemas/analytics.py create mode 100644 backend/services/experience/app/schemas/bundles.py create mode 100644 backend/services/experience/app/schemas/common.py create mode 100644 backend/services/experience/app/schemas/components.py create mode 100644 backend/services/experience/app/schemas/forms.py create mode 100644 backend/services/experience/app/schemas/foundation.py create mode 100644 backend/services/experience/app/schemas/integrations.py create mode 100644 backend/services/experience/app/schemas/localization.py create mode 100644 backend/services/experience/app/schemas/publishing.py create mode 100644 backend/services/experience/app/schemas/sites.py create mode 100644 backend/services/experience/app/schemas/templates.py create mode 100644 backend/services/experience/app/schemas/themes.py create mode 100644 backend/services/experience/app/services/ai_hooks.py create mode 100644 backend/services/experience/app/services/analytics.py create mode 100644 backend/services/experience/app/services/audit_service.py create mode 100644 backend/services/experience/app/services/bundles.py create mode 100644 backend/services/experience/app/services/components.py create mode 100644 backend/services/experience/app/services/forms.py create mode 100644 backend/services/experience/app/services/foundation.py create mode 100644 backend/services/experience/app/services/integrations.py create mode 100644 backend/services/experience/app/services/localization.py create mode 100644 backend/services/experience/app/services/publishing.py create mode 100644 backend/services/experience/app/services/sites.py create mode 100644 backend/services/experience/app/services/templates.py create mode 100644 backend/services/experience/app/services/themes.py create mode 100644 backend/services/experience/app/specifications/__init__.py create mode 100644 backend/services/experience/app/specifications/ai_hooks.py create mode 100644 backend/services/experience/app/specifications/analytics.py create mode 100644 backend/services/experience/app/specifications/bundles.py create mode 100644 backend/services/experience/app/specifications/components.py create mode 100644 backend/services/experience/app/specifications/forms.py create mode 100644 backend/services/experience/app/specifications/integrations.py create mode 100644 backend/services/experience/app/specifications/localization.py create mode 100644 backend/services/experience/app/specifications/publishing.py create mode 100644 backend/services/experience/app/specifications/templates.py create mode 100644 backend/services/experience/app/specifications/themes.py create mode 100644 backend/services/experience/app/tests/__init__.py create mode 100644 backend/services/experience/app/tests/conftest.py create mode 100644 backend/services/experience/app/tests/test_ai_hooks.py create mode 100644 backend/services/experience/app/tests/test_analytics.py create mode 100644 backend/services/experience/app/tests/test_api.py create mode 100644 backend/services/experience/app/tests/test_architecture.py create mode 100644 backend/services/experience/app/tests/test_bundles.py create mode 100644 backend/services/experience/app/tests/test_component_policies.py create mode 100644 backend/services/experience/app/tests/test_components.py create mode 100644 backend/services/experience/app/tests/test_dependency.py create mode 100644 backend/services/experience/app/tests/test_docs.py create mode 100644 backend/services/experience/app/tests/test_forms.py create mode 100644 backend/services/experience/app/tests/test_integrations.py create mode 100644 backend/services/experience/app/tests/test_localization.py create mode 100644 backend/services/experience/app/tests/test_migration.py create mode 100644 backend/services/experience/app/tests/test_permissions.py create mode 100644 backend/services/experience/app/tests/test_publishing.py create mode 100644 backend/services/experience/app/tests/test_security.py create mode 100644 backend/services/experience/app/tests/test_sites.py create mode 100644 backend/services/experience/app/tests/test_templates.py create mode 100644 backend/services/experience/app/tests/test_theme_policies.py create mode 100644 backend/services/experience/app/tests/test_themes.py create mode 100644 backend/services/experience/app/validators/__init__.py create mode 100644 backend/services/experience/app/validators/ai_hooks.py create mode 100644 backend/services/experience/app/validators/analytics.py create mode 100644 backend/services/experience/app/validators/bundles.py create mode 100644 backend/services/experience/app/validators/components.py create mode 100644 backend/services/experience/app/validators/forms.py create mode 100644 backend/services/experience/app/validators/foundation.py create mode 100644 backend/services/experience/app/validators/integrations.py create mode 100644 backend/services/experience/app/validators/localization.py create mode 100644 backend/services/experience/app/validators/publishing.py create mode 100644 backend/services/experience/app/validators/templates.py create mode 100644 backend/services/experience/app/validators/themes.py create mode 100644 backend/services/experience/pytest.ini create mode 100644 backend/services/experience/requirements.txt create mode 100644 backend/services/experience/scripts/ensure_db.py create mode 100644 docs/architecture/adr/ADR-016.md create mode 100644 docs/experience-phase-11-0.md create mode 100644 docs/experience-phase-11-1.md create mode 100644 docs/experience-phase-11-10-audit.md create mode 100644 docs/experience-phase-11-10.md create mode 100644 docs/experience-phase-11-2.md create mode 100644 docs/experience-phase-11-3.md create mode 100644 docs/experience-phase-11-4.md create mode 100644 docs/experience-phase-11-5.md create mode 100644 docs/experience-phase-11-6.md create mode 100644 docs/experience-phase-11-7.md create mode 100644 docs/experience-phase-11-8.md create mode 100644 docs/experience-phase-11-9.md create mode 100644 docs/experience-phase-xp-reg.md create mode 100644 docs/experience-roadmap.md create mode 100644 docs/phase-handover/phase-11-0.md create mode 100644 docs/phase-handover/phase-11-1.md create mode 100644 docs/phase-handover/phase-11-10.md create mode 100644 docs/phase-handover/phase-11-2.md create mode 100644 docs/phase-handover/phase-11-3.md create mode 100644 docs/phase-handover/phase-11-4.md create mode 100644 docs/phase-handover/phase-11-5.md create mode 100644 docs/phase-handover/phase-11-6.md create mode 100644 docs/phase-handover/phase-11-7.md create mode 100644 docs/phase-handover/phase-11-8.md create mode 100644 docs/phase-handover/phase-11-9.md create mode 100644 docs/phase-handover/phase-xp-reg.md create mode 100644 docs/phases/Experience/README.md create mode 100644 docs/service-snapshots/experience.yaml diff --git a/backend/services/experience/Dockerfile.dev b/backend/services/experience/Dockerfile.dev new file mode 100644 index 0000000..ddce1a2 --- /dev/null +++ b/backend/services/experience/Dockerfile.dev @@ -0,0 +1,22 @@ +# Dev image: dependencies only — code mounted with uvicorn --reload +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends build-essential libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +COPY backend/shared-lib/ /shared-lib/ +COPY backend/services/experience/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 8008 diff --git a/backend/services/experience/README.md b/backend/services/experience/README.md new file mode 100644 index 0000000..9e04d60 --- /dev/null +++ b/backend/services/experience/README.md @@ -0,0 +1,42 @@ +# Experience Platform (Torbat Pages) + +Independent multi-tenant Experience Platform service. + +| Field | Value | +| --- | --- | +| Service | `experience` | +| Commercial product | Torbat Pages | +| Database | `experience_db` | +| Port | 8008 | +| Phase | 11.10 Analytics, AI Content & Enterprise Validation (track complete) | +| Version | 0.11.10.0 | +| Permissions | `experience.*` | + +## Endpoints + +- `GET /health` · `/capabilities` · `/metrics` +- Foundation / sites / components / themes / templates / locales / media (prior phases) +- `/api/v1/forms` · `/api/v1/form-submissions` +- `/api/v1/surveys` · `/api/v1/survey-responses` +- `/api/v1/appointment-pages` · `/api/v1/appointment-booking-refs` +- `/api/v1/publish-releases` · `/api/v1/domain-edge-bindings` +- `/api/v1/seo-metadata` · `/api/v1/sitemap-shells` · `/api/v1/robots-shells` +- `/api/v1/pwa-manifests` +- `/api/v1/bundle-definitions` · `/api/v1/tenant-bundles` +- `/api/v1/license-bindings` · `/api/v1/feature-toggles` +- `/api/v1/consumer-connector-registrations` · `/api/v1/consumer-connector-dispatches` +- `/api/v1/widget-definitions` · `/api/v1/widget-instances` +- `/api/v1/notify-dispatches` +- `/api/v1/analytics-report-definitions` · `/api/v1/analytics-snapshots` (+ `POST /refresh`) +- `/api/v1/ai-content-hook-registrations` · `/api/v1/ai-content-dispatches` (+ `POST /{id}/generate`) + +## Architecture + +API → Commands/Queries → Service → Repository → Model. Tenant via `X-Tenant-ID`. Integrations via API/Events only ([ADR-016](../../../docs/architecture/adr/ADR-016.md)). Analytics uses local COUNT on `experience_db` only. AI content uses `AIProvider` contracts with mock adapter; no model inference ownership. + +## Docs + +- [Experience Phase 11.10](../../../docs/experience-phase-11-10.md) +- [Phase handover 11.10](../../../docs/phase-handover/phase-11-10.md) +- [Enterprise audit 11.10](../../../docs/experience-phase-11-10-audit.md) +- [Experience roadmap](../../../docs/experience-roadmap.md) diff --git a/backend/services/experience/alembic.ini b/backend/services/experience/alembic.ini new file mode 100644 index 0000000..545b0df --- /dev/null +++ b/backend/services/experience/alembic.ini @@ -0,0 +1,41 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +path_separator = os +version_path_separator = os + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/services/experience/alembic/env.py b/backend/services/experience/alembic/env.py new file mode 100644 index 0000000..ba24b6c --- /dev/null +++ b/backend/services/experience/alembic/env.py @@ -0,0 +1,42 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.core.config import settings +from app.core.database import Base +import app.models # noqa: F401 + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url_sync) +if config.config_file_name: + fileConfig(config.config_file_name) +target_metadata = Base.metadata + + +def run_migrations_offline(): + context.configure( + url=settings.database_url_sync, + target_metadata=target_metadata, + literal_binds=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/services/experience/alembic/versions/0001_initial.py b/backend/services/experience/alembic/versions/0001_initial.py new file mode 100644 index 0000000..a4f99c1 --- /dev/null +++ b/backend/services/experience/alembic/versions/0001_initial.py @@ -0,0 +1,19 @@ +"""Initial Experience schema — Phase 11.0 foundation.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0001_initial" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + Base.metadata.drop_all(bind=bind) diff --git a/backend/services/experience/alembic/versions/0002_phase_111_sites_pages.py b/backend/services/experience/alembic/versions/0002_phase_111_sites_pages.py new file mode 100644 index 0000000..bd041ec --- /dev/null +++ b/backend/services/experience/alembic/versions/0002_phase_111_sites_pages.py @@ -0,0 +1,25 @@ +"""Phase 11.1 — Sites & Page Resources schema.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0002_phase_111_sites_pages" +down_revision = "0001_initial" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_site_domains", + "experience_pages", + "experience_sites", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0003_phase_112_components.py b/backend/services/experience/alembic/versions/0003_phase_112_components.py new file mode 100644 index 0000000..d82b345 --- /dev/null +++ b/backend/services/experience/alembic/versions/0003_phase_112_components.py @@ -0,0 +1,25 @@ +"""Phase 11.2 — Component Library & Versioning schema.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0003_phase_112_components" +down_revision = "0002_phase_111_sites_pages" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_page_component_placements", + "experience_component_versions", + "experience_components", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0004_phase_113_themes_layouts.py b/backend/services/experience/alembic/versions/0004_phase_113_themes_layouts.py new file mode 100644 index 0000000..8bf423f --- /dev/null +++ b/backend/services/experience/alembic/versions/0004_phase_113_themes_layouts.py @@ -0,0 +1,28 @@ +"""Phase 11.3 — Theme & Layout Engine schema.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0004_phase_113_themes_layouts" +down_revision = "0003_phase_112_components" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_page_layout_bindings", + "experience_site_theme_bindings", + "experience_layout_versions", + "experience_theme_versions", + "experience_layouts", + "experience_themes", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0005_phase_114_templates.py b/backend/services/experience/alembic/versions/0005_phase_114_templates.py new file mode 100644 index 0000000..acf3bea --- /dev/null +++ b/backend/services/experience/alembic/versions/0005_phase_114_templates.py @@ -0,0 +1,25 @@ +"""Phase 11.4 — Template System schema.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0005_phase_114_templates" +down_revision = "0004_phase_113_themes_layouts" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_template_instantiations", + "experience_template_versions", + "experience_templates", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0006_phase_115_localization_media.py b/backend/services/experience/alembic/versions/0006_phase_115_localization_media.py new file mode 100644 index 0000000..8fc2eed --- /dev/null +++ b/backend/services/experience/alembic/versions/0006_phase_115_localization_media.py @@ -0,0 +1,25 @@ +"""Phase 11.5 — Localization, RTL/LTR & Media schema.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0006_phase_115_localization_media" +down_revision = "0005_phase_114_templates" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_media_refs", + "experience_localizations", + "experience_site_locale_bindings", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0007_phase_116_forms_surveys_appointments.py b/backend/services/experience/alembic/versions/0007_phase_116_forms_surveys_appointments.py new file mode 100644 index 0000000..2d99428 --- /dev/null +++ b/backend/services/experience/alembic/versions/0007_phase_116_forms_surveys_appointments.py @@ -0,0 +1,28 @@ +"""Phase 11.6 — Forms, Surveys & Appointments schema.""" +from alembic import op +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0007_phase_116_forms_surveys_appointments" +down_revision = "0006_phase_115_localization_media" +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + Base.metadata.create_all(bind=bind) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_appointment_booking_refs", + "experience_appointment_pages", + "experience_survey_responses", + "experience_surveys", + "experience_form_submissions", + "experience_forms", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0008_phase_117_publishing_seo_pwa.py b/backend/services/experience/alembic/versions/0008_phase_117_publishing_seo_pwa.py new file mode 100644 index 0000000..20dbde8 --- /dev/null +++ b/backend/services/experience/alembic/versions/0008_phase_117_publishing_seo_pwa.py @@ -0,0 +1,28 @@ +"""Phase 11.7 — Publishing, SEO and PWA schema.""" +from alembic import op + +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0008_phase_117_publishing_seo_pwa" +down_revision = "0007_phase_116_forms_surveys_appointments" +branch_labels = None +depends_on = None + + +def upgrade(): + Base.metadata.create_all(bind=op.get_bind()) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_pwa_manifests", + "experience_robots_shells", + "experience_sitemap_shells", + "experience_seo_metadata", + "experience_domain_edge_bindings", + "experience_publish_releases", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0009_phase_118_bundles_toggles.py b/backend/services/experience/alembic/versions/0009_phase_118_bundles_toggles.py new file mode 100644 index 0000000..9472ef6 --- /dev/null +++ b/backend/services/experience/alembic/versions/0009_phase_118_bundles_toggles.py @@ -0,0 +1,26 @@ +"""Phase 11.8 — bundles, licensing refs and feature toggles.""" +from alembic import op + +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0009_phase_118_bundles_toggles" +down_revision = "0008_phase_117_publishing_seo_pwa" +branch_labels = None +depends_on = None + + +def upgrade(): + Base.metadata.create_all(bind=op.get_bind()) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_feature_toggles", + "experience_license_bindings", + "experience_tenant_bundles", + "experience_bundle_definitions", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0010_phase_119_consumer_widgets.py b/backend/services/experience/alembic/versions/0010_phase_119_consumer_widgets.py new file mode 100644 index 0000000..8c6341d --- /dev/null +++ b/backend/services/experience/alembic/versions/0010_phase_119_consumer_widgets.py @@ -0,0 +1,27 @@ +"""Phase 11.9 — consumer connectors, widgets and notify dispatches.""" +from alembic import op + +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0010_phase_119_consumer_widgets" +down_revision = "0009_phase_118_bundles_toggles" +branch_labels = None +depends_on = None + + +def upgrade(): + Base.metadata.create_all(bind=op.get_bind()) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_notify_dispatches", + "experience_widget_instances", + "experience_widget_definitions", + "experience_consumer_connector_dispatches", + "experience_consumer_connector_registrations", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/alembic/versions/0011_phase_1110_analytics_ai_hooks.py b/backend/services/experience/alembic/versions/0011_phase_1110_analytics_ai_hooks.py new file mode 100644 index 0000000..0f19da4 --- /dev/null +++ b/backend/services/experience/alembic/versions/0011_phase_1110_analytics_ai_hooks.py @@ -0,0 +1,26 @@ +"""Phase 11.10 — analytics and AI content hooks.""" +from alembic import op + +from app.core.database import Base +import app.models # noqa: F401 + +revision = "0011_phase_1110_analytics_ai_hooks" +down_revision = "0010_phase_119_consumer_widgets" +branch_labels = None +depends_on = None + + +def upgrade(): + Base.metadata.create_all(bind=op.get_bind()) + + +def downgrade(): + bind = op.get_bind() + for table in ( + "experience_ai_content_dispatches", + "experience_ai_content_hook_registrations", + "experience_analytics_snapshots", + "experience_analytics_report_definitions", + ): + if table in Base.metadata.tables: + Base.metadata.tables[table].drop(bind=bind, checkfirst=True) diff --git a/backend/services/experience/app/__init__.py b/backend/services/experience/app/__init__.py new file mode 100644 index 0000000..0cf7f24 --- /dev/null +++ b/backend/services/experience/app/__init__.py @@ -0,0 +1 @@ +__version__ = "0.11.10.0" diff --git a/backend/services/experience/app/api/deps.py b/backend/services/experience/app/api/deps.py new file mode 100644 index 0000000..56d4194 --- /dev/null +++ b/backend/services/experience/app/api/deps.py @@ -0,0 +1,39 @@ +"""Common API dependencies.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import get_db +from app.core.security import get_current_user +from shared.exceptions import TenantNotResolvedError +from shared.pagination import PaginationParams +from shared.security import CurrentUser +from shared.tenant import STATE_TENANT_ID + +__all__ = [ + "get_db", + "get_pagination", + "require_tenant", + "get_current_user", + "AsyncSession", + "CurrentUser", +] + + +def get_pagination( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=500), +) -> PaginationParams: + return PaginationParams(page=page, page_size=page_size) + + +def require_tenant(request: Request) -> UUID: + tenant_id = getattr(request.state, STATE_TENANT_ID, None) + if tenant_id is None: + raise TenantNotResolvedError( + "tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید." + ) + return tenant_id diff --git a/backend/services/experience/app/api/permissions.py b/backend/services/experience/app/api/permissions.py new file mode 100644 index 0000000..6c514ce --- /dev/null +++ b/backend/services/experience/app/api/permissions.py @@ -0,0 +1,48 @@ +"""Permission enforcement helpers for Experience APIs.""" +from __future__ import annotations + +from collections.abc import Callable + +from fastapi import Depends + +from app.core.config import settings +from app.core.security import get_current_user +from shared.exceptions import ForbiddenError +from shared.security import CurrentUser + +_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"}) + + +def user_has_permission(user: CurrentUser, permission: str) -> bool: + if any(role in _ADMIN_ROLES for role in user.roles): + return True + roles = set(user.roles) + if "experience.manage" in roles or permission in roles: + return True + if "experience.view" in roles and permission.endswith(".view"): + return True + parts = permission.split(".") + if len(parts) >= 3: + manage = f"{parts[0]}.{parts[1]}.manage" + if manage in roles: + return True + return False + + +def require_permissions(*permissions: str) -> Callable: + """Deny unless AUTH is off, user is admin, or any listed permission is present.""" + + async def _dependency( + user: CurrentUser = Depends(get_current_user), + ) -> CurrentUser: + if not settings.auth_required: + return user + if any(user_has_permission(user, perm) for perm in permissions): + return user + raise ForbiddenError( + "دسترسی مجاز نیست", + error_code="permission_denied", + details={"required": list(permissions)}, + ) + + return _dependency diff --git a/backend/services/experience/app/api/v1/__init__.py b/backend/services/experience/app/api/v1/__init__.py new file mode 100644 index 0000000..e4a6b8f --- /dev/null +++ b/backend/services/experience/app/api/v1/__init__.py @@ -0,0 +1,194 @@ +from fastapi import APIRouter + +from app.api.v1 import ( + ai_content_dispatches, + ai_content_hook_registrations, + analytics_report_definitions, + analytics_snapshots, + appointment_booking_refs, + appointment_pages, + audit, + bundle_definitions, + component_versions, + components, + configurations, + consumer_connector_dispatches, + consumer_connector_registrations, + domain_edge_bindings, + external_providers, + feature_toggles, + form_submissions, + forms, + layout_versions, + layouts, + license_bindings, + locale_profiles, + localizations, + media_refs, + notify_dispatches, + page_component_placements, + page_layout_bindings, + pages, + publish_releases, + pwa_manifests, + render_engines, + settings, + seo_metadata, + site_domains, + site_locale_bindings, + site_theme_bindings, + sites, + sitemap_shells, + robots_shells, + survey_responses, + surveys, + template_instantiations, + template_versions, + templates, + tenant_bundles, + theme_versions, + themes, + widget_definitions, + widget_instances, + workspaces, +) + +api_router = APIRouter() +api_router.include_router( + workspaces.router, prefix="/workspaces", tags=["workspaces"] +) +api_router.include_router( + locale_profiles.router, prefix="/locale-profiles", tags=["locale_profiles"] +) +api_router.include_router( + external_providers.router, + prefix="/external-providers", + tags=["external-providers"], +) +api_router.include_router( + render_engines.router, prefix="/render-engines", tags=["render-engines"] +) +api_router.include_router( + configurations.router, prefix="/configurations", tags=["configurations"] +) +api_router.include_router(settings.router, prefix="/settings", tags=["settings"]) +api_router.include_router(audit.router, prefix="/audit", tags=["audit"]) +api_router.include_router(sites.router, prefix="/sites", tags=["sites"]) +api_router.include_router(pages.router, prefix="/pages", tags=["pages"]) +api_router.include_router( + site_domains.router, prefix="/site-domains", tags=["site-domains"] +) +api_router.include_router( + components.router, prefix="/components", tags=["components"] +) +api_router.include_router( + component_versions.router, + prefix="/component-versions", + tags=["component-versions"], +) +api_router.include_router( + page_component_placements.router, + prefix="/page-component-placements", + tags=["page-component-placements"], +) +api_router.include_router(themes.router, prefix="/themes", tags=["themes"]) +api_router.include_router( + theme_versions.router, prefix="/theme-versions", tags=["theme-versions"] +) +api_router.include_router(layouts.router, prefix="/layouts", tags=["layouts"]) +api_router.include_router( + layout_versions.router, prefix="/layout-versions", tags=["layout-versions"] +) +api_router.include_router( + site_theme_bindings.router, + prefix="/site-theme-bindings", + tags=["site-theme-bindings"], +) +api_router.include_router( + page_layout_bindings.router, + prefix="/page-layout-bindings", + tags=["page-layout-bindings"], +) +api_router.include_router(templates.router, prefix="/templates", tags=["templates"]) +api_router.include_router( + template_versions.router, + prefix="/template-versions", + tags=["template-versions"], +) +api_router.include_router( + template_instantiations.router, + prefix="/template-instantiations", + tags=["template-instantiations"], +) +api_router.include_router( + site_locale_bindings.router, + prefix="/site-locale-bindings", + tags=["site-locale-bindings"], +) +api_router.include_router( + localizations.router, prefix="/localizations", tags=["localizations"] +) +api_router.include_router( + media_refs.router, prefix="/media-refs", tags=["media-refs"] +) +api_router.include_router(forms.router, prefix="/forms", tags=["forms"]) +api_router.include_router( + form_submissions.router, prefix="/form-submissions", tags=["form-submissions"] +) +api_router.include_router(surveys.router, prefix="/surveys", tags=["surveys"]) +api_router.include_router( + survey_responses.router, prefix="/survey-responses", tags=["survey-responses"] +) +api_router.include_router( + appointment_pages.router, + prefix="/appointment-pages", + tags=["appointment-pages"], +) +api_router.include_router( + appointment_booking_refs.router, + prefix="/appointment-booking-refs", + tags=["appointment-booking-refs"], +) +api_router.include_router(publish_releases.router, prefix="/publish-releases", tags=["publish-releases"]) +api_router.include_router(domain_edge_bindings.router, prefix="/domain-edge-bindings", tags=["domain-edge-bindings"]) +api_router.include_router(seo_metadata.router, prefix="/seo-metadata", tags=["seo-metadata"]) +api_router.include_router(sitemap_shells.router, prefix="/sitemap-shells", tags=["sitemap-shells"]) +api_router.include_router(robots_shells.router, prefix="/robots-shells", tags=["robots-shells"]) +api_router.include_router(pwa_manifests.router, prefix="/pwa-manifests", tags=["pwa-manifests"]) +api_router.include_router(bundle_definitions.router, prefix="/bundle-definitions", tags=["bundle-definitions"]) +api_router.include_router(tenant_bundles.router, prefix="/tenant-bundles", tags=["tenant-bundles"]) +api_router.include_router(license_bindings.router, prefix="/license-bindings", tags=["license-bindings"]) +api_router.include_router(feature_toggles.router, prefix="/feature-toggles", tags=["feature-toggles"]) +api_router.include_router( + consumer_connector_registrations.router, + prefix="/consumer-connector-registrations", + tags=["consumer-connector-registrations"], +) +api_router.include_router( + consumer_connector_dispatches.router, + prefix="/consumer-connector-dispatches", + tags=["consumer-connector-dispatches"], +) +api_router.include_router(widget_definitions.router, prefix="/widget-definitions", tags=["widget-definitions"]) +api_router.include_router(widget_instances.router, prefix="/widget-instances", tags=["widget-instances"]) +api_router.include_router(notify_dispatches.router, prefix="/notify-dispatches", tags=["notify-dispatches"]) +api_router.include_router( + analytics_report_definitions.router, + prefix="/analytics-report-definitions", + tags=["analytics-report-definitions"], +) +api_router.include_router( + analytics_snapshots.router, + prefix="/analytics-snapshots", + tags=["analytics-snapshots"], +) +api_router.include_router( + ai_content_hook_registrations.router, + prefix="/ai-content-hook-registrations", + tags=["ai-content-hook-registrations"], +) +api_router.include_router( + ai_content_dispatches.router, + prefix="/ai-content-dispatches", + tags=["ai-content-dispatches"], +) diff --git a/backend/services/experience/app/api/v1/ai_content_dispatches.py b/backend/services/experience/app/api/v1/ai_content_dispatches.py new file mode 100644 index 0000000..d3f6e62 --- /dev/null +++ b/backend/services/experience/app/api/v1/ai_content_dispatches.py @@ -0,0 +1,118 @@ +"""AI content dispatch APIs — Phase 11.10.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.ai_hooks import AiHooksCommands +from app.permissions.definitions import ( + AI_HOOKS_CREATE, + AI_HOOKS_DELETE, + AI_HOOKS_MANAGE, + AI_HOOKS_UPDATE, + AI_HOOKS_VIEW, +) +from app.queries.ai_hooks import AiHooksQueries +from app.schemas.ai_hooks import ( + AiContentDispatchCreate, + AiContentDispatchListResponse, + AiContentDispatchRead, + AiContentDispatchUpdate, +) +from app.specifications.ai_hooks import DISPATCH_SORT, AiContentDispatchListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + page_id: UUID | None = None, + registration_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return AiContentDispatchListSpec( + workspace_id=workspace_id, + site_id=site_id, + page_id=page_id, + registration_id=registration_id, + status=status_filter, + q=q, + sort_by=sort_by if sort_by in DISPATCH_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=AiContentDispatchRead, status_code=201) +async def create( + body: AiContentDispatchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_CREATE)), +): + return await AiHooksCommands(db).create_dispatch(tenant_id, body, actor=user) + + +@router.get("", response_model=AiContentDispatchListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AiContentDispatchListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)), +): + items, total = await AiHooksQueries(db).list_dispatches( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AiContentDispatchListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=AiContentDispatchRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)), +): + return await AiHooksQueries(db).get_dispatch(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=AiContentDispatchRead) +async def update( + entity_id: UUID, + body: AiContentDispatchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_UPDATE)), +): + return await AiHooksCommands(db).update_dispatch(tenant_id, entity_id, body, actor=user) + + +@router.post("/{entity_id}/generate", response_model=AiContentDispatchRead) +async def generate( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_MANAGE)), +): + return await AiHooksCommands(db).generate_dispatch(tenant_id, entity_id, actor=user) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_DELETE)), +): + await AiHooksCommands(db).delete_dispatch(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/ai_content_hook_registrations.py b/backend/services/experience/app/api/v1/ai_content_hook_registrations.py new file mode 100644 index 0000000..717fd0a --- /dev/null +++ b/backend/services/experience/app/api/v1/ai_content_hook_registrations.py @@ -0,0 +1,112 @@ +"""AI content hook registration APIs — Phase 11.10.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.ai_hooks import AiHooksCommands +from app.permissions.definitions import ( + AI_HOOKS_CREATE, + AI_HOOKS_DELETE, + AI_HOOKS_UPDATE, + AI_HOOKS_VIEW, +) +from app.queries.ai_hooks import AiHooksQueries +from app.schemas.ai_hooks import ( + AiContentHookRegistrationCreate, + AiContentHookRegistrationListResponse, + AiContentHookRegistrationRead, + AiContentHookRegistrationUpdate, +) +from app.specifications.ai_hooks import ( + HOOK_REGISTRATION_SORT, + AiContentHookRegistrationListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + page_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + task_kind: str | None = None, + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return AiContentHookRegistrationListSpec( + workspace_id=workspace_id, + site_id=site_id, + page_id=page_id, + status=status_filter, + task_kind=task_kind, + q=q, + sort_by=sort_by if sort_by in HOOK_REGISTRATION_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=AiContentHookRegistrationRead, status_code=201) +async def create( + body: AiContentHookRegistrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_CREATE)), +): + return await AiHooksCommands(db).create_hook_registration(tenant_id, body, actor=user) + + +@router.get("", response_model=AiContentHookRegistrationListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AiContentHookRegistrationListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)), +): + items, total = await AiHooksQueries(db).list_hook_registrations( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AiContentHookRegistrationListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=AiContentHookRegistrationRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)), +): + return await AiHooksQueries(db).get_hook_registration(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=AiContentHookRegistrationRead) +async def update( + entity_id: UUID, + body: AiContentHookRegistrationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_UPDATE)), +): + return await AiHooksCommands(db).update_hook_registration( + tenant_id, entity_id, body, actor=user + ) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(AI_HOOKS_DELETE)), +): + await AiHooksCommands(db).delete_hook_registration(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/analytics_report_definitions.py b/backend/services/experience/app/api/v1/analytics_report_definitions.py new file mode 100644 index 0000000..310de81 --- /dev/null +++ b/backend/services/experience/app/api/v1/analytics_report_definitions.py @@ -0,0 +1,108 @@ +"""Analytics report definition APIs — Phase 11.10.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.analytics import AnalyticsCommands +from app.permissions.definitions import ( + ANALYTICS_CREATE, + ANALYTICS_DELETE, + ANALYTICS_UPDATE, + ANALYTICS_VIEW, +) +from app.queries.analytics import AnalyticsQueries +from app.schemas.analytics import ( + AnalyticsReportDefinitionCreate, + AnalyticsReportDefinitionListResponse, + AnalyticsReportDefinitionRead, + AnalyticsReportDefinitionUpdate, +) +from app.specifications.analytics import ( + REPORT_DEFINITION_SORT, + AnalyticsReportDefinitionListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return AnalyticsReportDefinitionListSpec( + workspace_id=workspace_id, + site_id=site_id, + status=status_filter, + q=q, + sort_by=sort_by if sort_by in REPORT_DEFINITION_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=AnalyticsReportDefinitionRead, status_code=201) +async def create( + body: AnalyticsReportDefinitionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ANALYTICS_CREATE)), +): + return await AnalyticsCommands(db).create_report_definition(tenant_id, body, actor=user) + + +@router.get("", response_model=AnalyticsReportDefinitionListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AnalyticsReportDefinitionListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)), +): + items, total = await AnalyticsQueries(db).list_report_definitions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AnalyticsReportDefinitionListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=AnalyticsReportDefinitionRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)), +): + return await AnalyticsQueries(db).get_report_definition(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=AnalyticsReportDefinitionRead) +async def update( + entity_id: UUID, + body: AnalyticsReportDefinitionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ANALYTICS_UPDATE)), +): + return await AnalyticsCommands(db).update_report_definition( + tenant_id, entity_id, body, actor=user + ) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ANALYTICS_DELETE)), +): + await AnalyticsCommands(db).delete_report_definition(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/analytics_snapshots.py b/backend/services/experience/app/api/v1/analytics_snapshots.py new file mode 100644 index 0000000..d63e4a9 --- /dev/null +++ b/backend/services/experience/app/api/v1/analytics_snapshots.py @@ -0,0 +1,106 @@ +"""Analytics snapshot APIs — Phase 11.10.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.analytics import AnalyticsCommands +from app.permissions.definitions import ( + ANALYTICS_CREATE, + ANALYTICS_DELETE, + ANALYTICS_MANAGE, + ANALYTICS_UPDATE, + ANALYTICS_VIEW, +) +from app.queries.analytics import AnalyticsQueries +from app.schemas.analytics import ( + AnalyticsSnapshotListResponse, + AnalyticsSnapshotRead, + AnalyticsSnapshotRefresh, + AnalyticsSnapshotUpdate, +) +from app.specifications.analytics import SNAPSHOT_SORT, AnalyticsSnapshotListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + report_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return AnalyticsSnapshotListSpec( + workspace_id=workspace_id, + site_id=site_id, + report_id=report_id, + status=status_filter, + q=q, + sort_by=sort_by if sort_by in SNAPSHOT_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("/refresh", response_model=AnalyticsSnapshotRead, status_code=201) +async def refresh( + body: AnalyticsSnapshotRefresh, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ANALYTICS_MANAGE)), +): + return await AnalyticsCommands(db).refresh_snapshot(tenant_id, body, actor=user) + + +@router.get("", response_model=AnalyticsSnapshotListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AnalyticsSnapshotListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)), +): + items, total = await AnalyticsQueries(db).list_snapshots( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AnalyticsSnapshotListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=AnalyticsSnapshotRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)), +): + return await AnalyticsQueries(db).get_snapshot(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=AnalyticsSnapshotRead) +async def update( + entity_id: UUID, + body: AnalyticsSnapshotUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ANALYTICS_UPDATE)), +): + return await AnalyticsCommands(db).update_snapshot(tenant_id, entity_id, body, actor=user) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(ANALYTICS_DELETE)), +): + await AnalyticsCommands(db).delete_snapshot(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/appointment_booking_refs.py b/backend/services/experience/app/api/v1/appointment_booking_refs.py new file mode 100644 index 0000000..2cef9c3 --- /dev/null +++ b/backend/services/experience/app/api/v1/appointment_booking_refs.py @@ -0,0 +1,102 @@ +"""Appointment booking-ref APIs — Phase 11.6 (external refs only).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.forms import FormsCommands +from app.permissions.definitions import ( + APPOINTMENTS_CREATE, + APPOINTMENTS_UPDATE, + APPOINTMENTS_VIEW, +) +from app.queries.forms import FormsQueries +from app.schemas.forms import ( + AppointmentBookingRefCreate, + AppointmentBookingRefListResponse, + AppointmentBookingRefRead, + AppointmentBookingRefUpdate, +) +from app.specifications.forms import BOOKING_SORT_FIELDS, AppointmentBookingListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +_STATUSES = {"pending", "confirmed", "cancelled", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + appointment_page_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> AppointmentBookingListSpec: + if sort_by not in BOOKING_SORT_FIELDS: + sort_by = "created_at" + return AppointmentBookingListSpec( + workspace_id=workspace_id, + appointment_page_id=appointment_page_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=AppointmentBookingRefRead, + status_code=status.HTTP_201_CREATED, +) +async def create_booking_ref( + body: AppointmentBookingRefCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CREATE)), +): + return await FormsCommands(db).create_booking_ref(tenant_id, body, actor=user) + + +@router.get("", response_model=AppointmentBookingRefListResponse) +async def list_booking_refs( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AppointmentBookingListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + items, total = await FormsQueries(db).list_booking_refs( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return AppointmentBookingRefListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{booking_id}", response_model=AppointmentBookingRefRead) +async def get_booking_ref( + booking_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await FormsQueries(db).get_booking_ref(tenant_id, booking_id) + + +@router.patch("/{booking_id}", response_model=AppointmentBookingRefRead) +async def update_booking_ref( + booking_id: UUID, + body: AppointmentBookingRefUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_UPDATE)), +): + return await FormsCommands(db).update_booking_ref( + tenant_id, booking_id, body, actor=user + ) diff --git a/backend/services/experience/app/api/v1/appointment_pages.py b/backend/services/experience/app/api/v1/appointment_pages.py new file mode 100644 index 0000000..7eeac49 --- /dev/null +++ b/backend/services/experience/app/api/v1/appointment_pages.py @@ -0,0 +1,120 @@ +"""Appointment page APIs — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.forms import FormsCommands +from app.permissions.definitions import ( + APPOINTMENTS_CREATE, + APPOINTMENTS_DELETE, + APPOINTMENTS_UPDATE, + APPOINTMENTS_VIEW, +) +from app.queries.forms import FormsQueries +from app.schemas.forms import ( + ExperienceAppointmentPageCreate, + ExperienceAppointmentPageListResponse, + ExperienceAppointmentPageRead, + ExperienceAppointmentPageUpdate, +) +from app.specifications.forms import ( + APPOINTMENT_PAGE_SORT_FIELDS, + AppointmentPageListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +_STATUSES = {"draft", "active", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + page_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> AppointmentPageListSpec: + if sort_by not in APPOINTMENT_PAGE_SORT_FIELDS: + sort_by = "created_at" + return AppointmentPageListSpec( + workspace_id=workspace_id, + site_id=site_id, + page_id=page_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=ExperienceAppointmentPageRead, + status_code=status.HTTP_201_CREATED, +) +async def create_appointment_page( + body: ExperienceAppointmentPageCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CREATE)), +): + return await FormsCommands(db).create_appointment_page(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceAppointmentPageListResponse) +async def list_appointment_pages( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: AppointmentPageListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + items, total = await FormsQueries(db).list_appointment_pages( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceAppointmentPageListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{page_id}", response_model=ExperienceAppointmentPageRead) +async def get_appointment_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)), +): + return await FormsQueries(db).get_appointment_page(tenant_id, page_id) + + +@router.patch("/{page_id}", response_model=ExperienceAppointmentPageRead) +async def update_appointment_page( + page_id: UUID, + body: ExperienceAppointmentPageUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_UPDATE)), +): + return await FormsCommands(db).update_appointment_page( + tenant_id, page_id, body, actor=user + ) + + +@router.delete("/{page_id}", response_model=ExperienceAppointmentPageRead) +async def delete_appointment_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(APPOINTMENTS_DELETE)), +): + return await FormsCommands(db).soft_delete_appointment_page( + tenant_id, page_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/audit.py b/backend/services/experience/app/api/v1/audit.py new file mode 100644 index 0000000..8dd371b --- /dev/null +++ b/backend/services/experience/app/api/v1/audit.py @@ -0,0 +1,30 @@ +"""Experience audit APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import AUDIT_VIEW +from app.schemas.foundation import ExperienceAuditLogRead +from app.services.audit_service import AuditService +from shared.security import CurrentUser + +router = APIRouter() + + +@router.get("", response_model=list[ExperienceAuditLogRead]) +async def list_audit( + entity_type: str = Query(...), + entity_id: UUID = Query(...), + limit: int = Query(default=100, ge=1, le=500), + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)), +): + return await AuditService(db).list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/experience/app/api/v1/bundle_definitions.py b/backend/services/experience/app/api/v1/bundle_definitions.py new file mode 100644 index 0000000..d817839 --- /dev/null +++ b/backend/services/experience/app/api/v1/bundle_definitions.py @@ -0,0 +1,49 @@ +"""Bundle definition APIs — Phase 11.8.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.bundles import BundlesCommands +from app.permissions.definitions import BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_UPDATE, BUNDLES_VIEW +from app.queries.bundles import BundlesQueries +from app.schemas.bundles import BundleDefinitionCreate, BundleDefinitionListResponse, BundleDefinitionRead, BundleDefinitionUpdate +from app.specifications.bundles import BUNDLE_DEFINITION_SORT, BundleDefinitionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +def _spec(workspace_id: UUID | None = None, status_filter: str | None = Query(None, alias="status"), + bundle_key: str | None = None, q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")): + return BundleDefinitionListSpec(workspace_id=workspace_id, status=status_filter, bundle_key=bundle_key, q=q, + sort_by=sort_by if sort_by in BUNDLE_DEFINITION_SORT else "created_at", sort_dir=sort_dir) + +@router.post("", response_model=BundleDefinitionRead, status_code=201) +async def create(body: BundleDefinitionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE))): + return await BundlesCommands(db).create_bundle_definition(tenant_id, body, actor=user) + +@router.get("", response_model=BundleDefinitionListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: BundleDefinitionListSpec = Depends(_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))): + items, total = await BundlesQueries(db).list_bundle_definitions(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return BundleDefinitionListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + +@router.get("/{entity_id}", response_model=BundleDefinitionRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))): + return await BundlesQueries(db).get_bundle_definition(tenant_id, entity_id) + +@router.patch("/{entity_id}", response_model=BundleDefinitionRead) +async def update(entity_id: UUID, body: BundleDefinitionUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE))): + return await BundlesCommands(db).update_bundle_definition(tenant_id, entity_id, body, actor=user) + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE))): + await BundlesCommands(db).delete_bundle_definition(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/component_versions.py b/backend/services/experience/app/api/v1/component_versions.py new file mode 100644 index 0000000..d80e965 --- /dev/null +++ b/backend/services/experience/app/api/v1/component_versions.py @@ -0,0 +1,133 @@ +"""Component version APIs — Phase 11.2.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.components import ComponentCommands +from app.permissions.definitions import ( + COMPONENTS_CREATE, + COMPONENTS_MANAGE, + COMPONENTS_UPDATE, + COMPONENTS_VIEW, +) +from app.queries.components import ComponentQueries +from app.schemas.components import ( + ExperienceComponentVersionCreate, + ExperienceComponentVersionListResponse, + ExperienceComponentVersionRead, + ExperienceComponentVersionUpdate, +) +from app.specifications.components import VERSION_SORT_FIELDS, ComponentVersionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_VERSION_STATUSES = {"draft", "published", "retired"} + + +def _list_spec( + component_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="version_number"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> ComponentVersionListSpec: + if sort_by not in VERSION_SORT_FIELDS: + sort_by = "version_number" + status_value = status_filter if status_filter in _VERSION_STATUSES else None + return ComponentVersionListSpec( + component_id=component_id, + status=status_value, # type: ignore[arg-type] + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=ExperienceComponentVersionRead, + status_code=status.HTTP_201_CREATED, +) +async def create_version( + body: ExperienceComponentVersionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_CREATE)), +): + return await ComponentCommands(db).create_version(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceComponentVersionListResponse) +async def list_versions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: ComponentVersionListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)), +): + items, total = await ComponentQueries(db).list_versions( + tenant_id, + offset=pagination.offset, + limit=pagination.page_size, + spec=spec, + ) + return ExperienceComponentVersionListResponse( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + +@router.get("/{version_id}", response_model=ExperienceComponentVersionRead) +async def get_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)), +): + return await ComponentQueries(db).get_version(tenant_id, version_id) + + +@router.patch("/{version_id}", response_model=ExperienceComponentVersionRead) +async def update_version( + version_id: UUID, + body: ExperienceComponentVersionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_UPDATE)), +): + return await ComponentCommands(db).update_version( + tenant_id, version_id, body, actor=user + ) + + +@router.post("/{version_id}/publish", response_model=ExperienceComponentVersionRead) +async def publish_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)), +): + return await ComponentCommands(db).publish_version( + tenant_id, version_id, actor=user + ) + + +@router.post("/{version_id}/retire", response_model=ExperienceComponentVersionRead) +async def retire_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)), +): + return await ComponentCommands(db).retire_version( + tenant_id, version_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/components.py b/backend/services/experience/app/api/v1/components.py new file mode 100644 index 0000000..ac460e7 --- /dev/null +++ b/backend/services/experience/app/api/v1/components.py @@ -0,0 +1,174 @@ +"""Component library APIs — Phase 11.2.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.components import ComponentCommands +from app.permissions.definitions import ( + COMPONENTS_CREATE, + COMPONENTS_DELETE, + COMPONENTS_MANAGE, + COMPONENTS_UPDATE, + COMPONENTS_VIEW, +) +from app.queries.components import ComponentQueries +from app.schemas.components import ( + ExperienceComponentCreate, + ExperienceComponentListResponse, + ExperienceComponentRead, + ExperienceComponentUpdate, +) +from app.specifications.components import COMPONENT_SORT_FIELDS, ComponentListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_COMPONENT_KINDS = { + "layout_block", + "content", + "media", + "navigation", + "form_shell", + "widget_shell", + "cta", + "hero", + "menu_ref", + "catalog_ref", + "custom", +} +_COMPONENT_STATUSES = {"draft", "active", "deprecated", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + kind: str | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + category: str | None = Query(default=None, max_length=100), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> ComponentListSpec: + if sort_by not in COMPONENT_SORT_FIELDS: + sort_by = "created_at" + kind_value = kind if kind in _COMPONENT_KINDS else None + status_value = status_filter if status_filter in _COMPONENT_STATUSES else None + return ComponentListSpec( + workspace_id=workspace_id, + kind=kind_value, # type: ignore[arg-type] + status=status_value, # type: ignore[arg-type] + category=category, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceComponentRead, status_code=status.HTTP_201_CREATED +) +async def create_component( + body: ExperienceComponentCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_CREATE)), +): + return await ComponentCommands(db).create_component(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceComponentListResponse) +async def list_components( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: ComponentListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)), +): + items, total = await ComponentQueries(db).list_components( + tenant_id, + offset=pagination.offset, + limit=pagination.page_size, + spec=spec, + ) + return ExperienceComponentListResponse( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + +@router.get("/{component_id}", response_model=ExperienceComponentRead) +async def get_component( + component_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)), +): + return await ComponentQueries(db).get_component(tenant_id, component_id) + + +@router.patch("/{component_id}", response_model=ExperienceComponentRead) +async def update_component( + component_id: UUID, + body: ExperienceComponentUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_UPDATE)), +): + return await ComponentCommands(db).update_component( + tenant_id, component_id, body, actor=user + ) + + +@router.post("/{component_id}/activate", response_model=ExperienceComponentRead) +async def activate_component( + component_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)), +): + return await ComponentCommands(db).activate_component( + tenant_id, component_id, actor=user + ) + + +@router.post("/{component_id}/deprecate", response_model=ExperienceComponentRead) +async def deprecate_component( + component_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)), +): + return await ComponentCommands(db).deprecate_component( + tenant_id, component_id, actor=user + ) + + +@router.post("/{component_id}/archive", response_model=ExperienceComponentRead) +async def archive_component( + component_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)), +): + return await ComponentCommands(db).archive_component( + tenant_id, component_id, actor=user + ) + + +@router.post("/{component_id}/delete", response_model=ExperienceComponentRead) +async def soft_delete_component( + component_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_DELETE)), +): + return await ComponentCommands(db).soft_delete_component( + tenant_id, component_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/configurations.py b/backend/services/experience/app/api/v1/configurations.py new file mode 100644 index 0000000..d306acf --- /dev/null +++ b/backend/services/experience/app/api/v1/configurations.py @@ -0,0 +1,83 @@ +"""Experience configuration APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + CONFIGURATIONS_CREATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_VIEW, +) +from app.schemas.foundation import ( + ExperienceConfigurationCreate, + ExperienceConfigurationRead, + ExperienceConfigurationUpdate, +) +from app.services.foundation import ExperienceConfigurationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExperienceConfigurationRead, status_code=status.HTTP_201_CREATED) +async def create_configuration( + body: ExperienceConfigurationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)), +): + return await ExperienceConfigurationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperienceConfigurationRead]) +async def list_configurations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await ExperienceConfigurationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{configuration_id}", response_model=ExperienceConfigurationRead) +async def get_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)), +): + return await ExperienceConfigurationService(db).get(tenant_id, configuration_id) + + +@router.patch("/{configuration_id}", response_model=ExperienceConfigurationRead) +async def update_configuration( + configuration_id: UUID, + body: ExperienceConfigurationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)), +): + return await ExperienceConfigurationService(db).update( + tenant_id, configuration_id, body, actor=user + ) + + +@router.post("/{configuration_id}/delete", response_model=ExperienceConfigurationRead) +async def soft_delete_configuration( + configuration_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)), +): + return await ExperienceConfigurationService(db).soft_delete( + tenant_id, configuration_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/consumer_connector_dispatches.py b/backend/services/experience/app/api/v1/consumer_connector_dispatches.py new file mode 100644 index 0000000..4979521 --- /dev/null +++ b/backend/services/experience/app/api/v1/consumer_connector_dispatches.py @@ -0,0 +1,127 @@ +"""Consumer connector dispatch APIs — Phase 11.9.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.integrations import IntegrationsCommands +from app.permissions.definitions import ( + CONSUMER_CONNECTORS_CREATE, + CONSUMER_CONNECTORS_DELETE, + CONSUMER_CONNECTORS_MANAGE, + CONSUMER_CONNECTORS_UPDATE, + CONSUMER_CONNECTORS_VIEW, +) +from app.queries.integrations import IntegrationsQueries +from app.schemas.integrations import ( + ConsumerConnectorDispatchCreate, + ConsumerConnectorDispatchListResponse, + ConsumerConnectorDispatchRead, + ConsumerConnectorDispatchUpdate, +) +from app.specifications.integrations import ( + CONNECTOR_DISPATCH_SORT, + ConsumerConnectorDispatchListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + registration_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return ConsumerConnectorDispatchListSpec( + workspace_id=workspace_id, + site_id=site_id, + registration_id=registration_id, + status=status_filter, + q=q, + sort_by=sort_by if sort_by in CONNECTOR_DISPATCH_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=ConsumerConnectorDispatchRead, status_code=201) +async def create( + body: ConsumerConnectorDispatchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_CREATE)), +): + return await IntegrationsCommands(db).create_consumer_connector_dispatch( + tenant_id, body, actor=user + ) + + +@router.get("", response_model=ConsumerConnectorDispatchListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: ConsumerConnectorDispatchListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)), +): + items, total = await IntegrationsQueries(db).list_consumer_connector_dispatches( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ConsumerConnectorDispatchListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=ConsumerConnectorDispatchRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)), +): + return await IntegrationsQueries(db).get_consumer_connector_dispatch(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=ConsumerConnectorDispatchRead) +async def update( + entity_id: UUID, + body: ConsumerConnectorDispatchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_UPDATE)), +): + return await IntegrationsCommands(db).update_consumer_connector_dispatch( + tenant_id, entity_id, body, actor=user + ) + + +@router.post("/{entity_id}/execute", response_model=ConsumerConnectorDispatchRead) +async def execute( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_MANAGE)), +): + return await IntegrationsCommands(db).execute_consumer_connector_dispatch( + tenant_id, entity_id, actor=user + ) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_DELETE)), +): + await IntegrationsCommands(db).delete_consumer_connector_dispatch( + tenant_id, entity_id, actor=user + ) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/consumer_connector_registrations.py b/backend/services/experience/app/api/v1/consumer_connector_registrations.py new file mode 100644 index 0000000..2d4ea43 --- /dev/null +++ b/backend/services/experience/app/api/v1/consumer_connector_registrations.py @@ -0,0 +1,114 @@ +"""Consumer connector registration APIs — Phase 11.9.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.integrations import IntegrationsCommands +from app.permissions.definitions import ( + CONSUMER_CONNECTORS_CREATE, + CONSUMER_CONNECTORS_DELETE, + CONSUMER_CONNECTORS_UPDATE, + CONSUMER_CONNECTORS_VIEW, +) +from app.queries.integrations import IntegrationsQueries +from app.schemas.integrations import ( + ConsumerConnectorRegistrationCreate, + ConsumerConnectorRegistrationListResponse, + ConsumerConnectorRegistrationRead, + ConsumerConnectorRegistrationUpdate, +) +from app.specifications.integrations import ( + CONNECTOR_REGISTRATION_SORT, + ConsumerConnectorRegistrationListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + kind: str | None = None, + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return ConsumerConnectorRegistrationListSpec( + workspace_id=workspace_id, + site_id=site_id, + status=status_filter, + kind=kind, + q=q, + sort_by=sort_by if sort_by in CONNECTOR_REGISTRATION_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=ConsumerConnectorRegistrationRead, status_code=201) +async def create( + body: ConsumerConnectorRegistrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_CREATE)), +): + return await IntegrationsCommands(db).create_consumer_connector_registration( + tenant_id, body, actor=user + ) + + +@router.get("", response_model=ConsumerConnectorRegistrationListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: ConsumerConnectorRegistrationListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)), +): + items, total = await IntegrationsQueries(db).list_consumer_connector_registrations( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ConsumerConnectorRegistrationListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=ConsumerConnectorRegistrationRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)), +): + return await IntegrationsQueries(db).get_consumer_connector_registration(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=ConsumerConnectorRegistrationRead) +async def update( + entity_id: UUID, + body: ConsumerConnectorRegistrationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_UPDATE)), +): + return await IntegrationsCommands(db).update_consumer_connector_registration( + tenant_id, entity_id, body, actor=user + ) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_DELETE)), +): + await IntegrationsCommands(db).delete_consumer_connector_registration( + tenant_id, entity_id, actor=user + ) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/domain_edge_bindings.py b/backend/services/experience/app/api/v1/domain_edge_bindings.py new file mode 100644 index 0000000..334a0f2 --- /dev/null +++ b/backend/services/experience/app/api/v1/domain_edge_bindings.py @@ -0,0 +1,43 @@ +"""Domain edge binding APIs — Phase 11.7.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.publishing import PublishingCommands +from app.permissions.definitions import PUBLISHING_CREATE, PUBLISHING_UPDATE, PUBLISHING_VIEW +from app.queries.publishing import PublishingQueries +from app.schemas.publishing import DomainEdgeBindingCreate, DomainEdgeBindingListResponse, DomainEdgeBindingRead, DomainEdgeBindingUpdate +from app.specifications.publishing import DomainEdgeBindingListSpec, EDGE_SORT +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, site_domain_id: UUID | None = None, + status_filter: str | None = Query(default=None, alias="status"), q: str | None = Query(default=None, max_length=100), + sort_by: str = "created_at", sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")): + return DomainEdgeBindingListSpec(workspace_id=workspace_id, site_id=site_id, site_domain_id=site_domain_id, + status=status_filter, q=q, sort_by=sort_by if sort_by in EDGE_SORT else "created_at", sort_dir=sort_dir) + +@router.post("", response_model=DomainEdgeBindingRead, status_code=status.HTTP_201_CREATED) +async def create(body: DomainEdgeBindingCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PUBLISHING_CREATE))): + return await PublishingCommands(db).create_domain_edge_binding(tenant_id, body, actor=user) + +@router.get("", response_model=DomainEdgeBindingListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: DomainEdgeBindingListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))): + items, total = await PublishingQueries(db).list_domain_edge_bindings(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return DomainEdgeBindingListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + +@router.get("/{entity_id}", response_model=DomainEdgeBindingRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))): + return await PublishingQueries(db).get_domain_edge_binding(tenant_id, entity_id) + +@router.patch("/{entity_id}", response_model=DomainEdgeBindingRead) +async def update(entity_id: UUID, body: DomainEdgeBindingUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PUBLISHING_UPDATE))): + return await PublishingCommands(db).update_domain_edge_binding(tenant_id, entity_id, body, actor=user) diff --git a/backend/services/experience/app/api/v1/external_providers.py b/backend/services/experience/app/api/v1/external_providers.py new file mode 100644 index 0000000..ffe114a --- /dev/null +++ b/backend/services/experience/app/api/v1/external_providers.py @@ -0,0 +1,83 @@ +"""External provider config APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_VIEW, +) +from app.schemas.foundation import ( + ExternalProviderConfigCreate, + ExternalProviderConfigRead, + ExternalProviderConfigUpdate, +) +from app.services.foundation import ExternalProviderConfigService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED) +async def create_provider( + body: ExternalProviderConfigCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)), +): + return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExternalProviderConfigRead]) +async def list_providers( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{provider_id}", response_model=ExternalProviderConfigRead) +async def get_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)), +): + return await ExternalProviderConfigService(db).get(tenant_id, provider_id) + + +@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead) +async def update_provider( + provider_id: UUID, + body: ExternalProviderConfigUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)), +): + return await ExternalProviderConfigService(db).update( + tenant_id, provider_id, body, actor=user + ) + + +@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead) +async def soft_delete_provider( + provider_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)), +): + return await ExternalProviderConfigService(db).soft_delete( + tenant_id, provider_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/feature_toggles.py b/backend/services/experience/app/api/v1/feature_toggles.py new file mode 100644 index 0000000..86ef57a --- /dev/null +++ b/backend/services/experience/app/api/v1/feature_toggles.py @@ -0,0 +1,50 @@ +"""Feature toggle APIs — Phase 11.8.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.bundles import BundlesCommands +from app.permissions.definitions import FEATURE_TOGGLES_CREATE, FEATURE_TOGGLES_DELETE, FEATURE_TOGGLES_UPDATE, FEATURE_TOGGLES_VIEW +from app.queries.bundles import BundlesQueries +from app.schemas.bundles import FeatureToggleCreate, FeatureToggleListResponse, FeatureToggleRead, FeatureToggleUpdate +from app.specifications.bundles import FEATURE_TOGGLE_SORT, FeatureToggleListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +def _spec(workspace_id: UUID | None = None, site_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), bundle_key: str | None = None, + q: str | None = Query(None, max_length=100), sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")): + return FeatureToggleListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, bundle_key=bundle_key, + q=q, sort_by=sort_by if sort_by in FEATURE_TOGGLE_SORT else "created_at", sort_dir=sort_dir) + +@router.post("", response_model=FeatureToggleRead, status_code=201) +async def create(body: FeatureToggleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_CREATE))): + return await BundlesCommands(db).create_feature_toggle(tenant_id, body, actor=user) + +@router.get("", response_model=FeatureToggleListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: FeatureToggleListSpec = Depends(_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_VIEW))): + items, total = await BundlesQueries(db).list_feature_toggles(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return FeatureToggleListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + +@router.get("/{entity_id}", response_model=FeatureToggleRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_VIEW))): + return await BundlesQueries(db).get_feature_toggle(tenant_id, entity_id) + +@router.patch("/{entity_id}", response_model=FeatureToggleRead) +async def update(entity_id: UUID, body: FeatureToggleUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_UPDATE))): + return await BundlesCommands(db).update_feature_toggle(tenant_id, entity_id, body, actor=user) + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_DELETE))): + await BundlesCommands(db).delete_feature_toggle(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/form_submissions.py b/backend/services/experience/app/api/v1/form_submissions.py new file mode 100644 index 0000000..0a1680b --- /dev/null +++ b/backend/services/experience/app/api/v1/form_submissions.py @@ -0,0 +1,96 @@ +"""Form submission APIs — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.forms import FormsCommands +from app.permissions.definitions import FORMS_CREATE, FORMS_UPDATE, FORMS_VIEW +from app.queries.forms import FormsQueries +from app.schemas.forms import ( + FormSubmissionCreate, + FormSubmissionListResponse, + FormSubmissionRead, + FormSubmissionUpdate, +) +from app.specifications.forms import SUBMISSION_SORT_FIELDS, FormSubmissionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +_STATUSES = {"received", "processed", "failed", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + form_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> FormSubmissionListSpec: + if sort_by not in SUBMISSION_SORT_FIELDS: + sort_by = "created_at" + return FormSubmissionListSpec( + workspace_id=workspace_id, + form_id=form_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=FormSubmissionRead, status_code=status.HTTP_201_CREATED +) +async def create_submission( + body: FormSubmissionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FORMS_CREATE)), +): + return await FormsCommands(db).create_submission(tenant_id, body, actor=user) + + +@router.get("", response_model=FormSubmissionListResponse) +async def list_submissions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: FormSubmissionListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(FORMS_VIEW)), +): + items, total = await FormsQueries(db).list_submissions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return FormSubmissionListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{submission_id}", response_model=FormSubmissionRead) +async def get_submission( + submission_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(FORMS_VIEW)), +): + return await FormsQueries(db).get_submission(tenant_id, submission_id) + + +@router.patch("/{submission_id}", response_model=FormSubmissionRead) +async def update_submission( + submission_id: UUID, + body: FormSubmissionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FORMS_UPDATE)), +): + return await FormsCommands(db).update_submission( + tenant_id, submission_id, body, actor=user + ) diff --git a/backend/services/experience/app/api/v1/forms.py b/backend/services/experience/app/api/v1/forms.py new file mode 100644 index 0000000..7e3f072 --- /dev/null +++ b/backend/services/experience/app/api/v1/forms.py @@ -0,0 +1,109 @@ +"""Form definition APIs — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.forms import FormsCommands +from app.permissions.definitions import ( + FORMS_CREATE, + FORMS_DELETE, + FORMS_UPDATE, + FORMS_VIEW, +) +from app.queries.forms import FormsQueries +from app.schemas.forms import ( + ExperienceFormCreate, + ExperienceFormListResponse, + ExperienceFormRead, + ExperienceFormUpdate, +) +from app.specifications.forms import FORM_SORT_FIELDS, FormListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +_STATUSES = {"draft", "active", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + page_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> FormListSpec: + if sort_by not in FORM_SORT_FIELDS: + sort_by = "created_at" + return FormListSpec( + workspace_id=workspace_id, + site_id=site_id, + page_id=page_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post("", response_model=ExperienceFormRead, status_code=status.HTTP_201_CREATED) +async def create_form( + body: ExperienceFormCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FORMS_CREATE)), +): + return await FormsCommands(db).create_form(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceFormListResponse) +async def list_forms( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: FormListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(FORMS_VIEW)), +): + items, total = await FormsQueries(db).list_forms( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceFormListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{form_id}", response_model=ExperienceFormRead) +async def get_form( + form_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(FORMS_VIEW)), +): + return await FormsQueries(db).get_form(tenant_id, form_id) + + +@router.patch("/{form_id}", response_model=ExperienceFormRead) +async def update_form( + form_id: UUID, + body: ExperienceFormUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FORMS_UPDATE)), +): + return await FormsCommands(db).update_form(tenant_id, form_id, body, actor=user) + + +@router.delete("/{form_id}", response_model=ExperienceFormRead) +async def delete_form( + form_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(FORMS_DELETE)), +): + return await FormsCommands(db).soft_delete_form(tenant_id, form_id, actor=user) diff --git a/backend/services/experience/app/api/v1/health.py b/backend/services/experience/app/api/v1/health.py new file mode 100644 index 0000000..785a2e2 --- /dev/null +++ b/backend/services/experience/app/api/v1/health.py @@ -0,0 +1,226 @@ +from fastapi import APIRouter + +from app import __version__ +from app.core.config import settings + +router = APIRouter() + +_PAGE_TYPES = [ + "landing", + "mini_site", + "business_website", + "portfolio", + "restaurant", + "cafe", + "company", + "business_profile", + "digital_menu", + "digital_catalog", + "product_catalog", + "qr", + "link_in_bio", + "appointment", + "form", + "blog", + "campaign", + "knowledge", + "documentation", + "media", + "seo", + "public_profile", + "event", + "custom", +] + +_COMPONENT_KINDS = [ + "layout_block", + "content", + "media", + "navigation", + "form_shell", + "widget_shell", + "cta", + "hero", + "menu_ref", + "catalog_ref", + "custom", +] + +_DIRECTION_MODES = ["rtl", "ltr", "both"] + + +@router.get("/health") +async def health_check(): + return { + "status": "ok", + "service": settings.service_name, + "version": __version__, + } + + +@router.get("/capabilities") +async def capabilities(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "11.10", + "commercial_product": "Torbat Pages", + "platform": "Experience Platform", + "features": { + "foundation": True, + "health": True, + "capabilities": True, + "metrics": True, + "workspaces": True, + "locale_profiles": True, + "external_providers_ready": True, + "future_render_engines_ready": True, + "feature_toggle_shell": True, + "bundle_licensing_shell": True, + "sites": True, + "pages": True, + "page_types": True, + "site_domains": True, + "publishing_draft_archive": True, + "components": True, + "component_versions": True, + "component_version_immutability": True, + "page_component_placements": True, + "themes": True, + "theme_versions": True, + "layouts": True, + "layout_versions": True, + "directionality_ready_shells": True, + "site_theme_bindings": True, + "page_layout_bindings": True, + "page_builder": False, + "templates": True, + "template_versions": True, + "template_instantiation": True, + "site_locale_bindings": True, + "localizations": True, + "rtl_ltr": True, + "media_refs": True, + "storage_media_refs_only": True, + "forms": True, + "form_submissions": True, + "surveys": True, + "survey_responses": True, + "appointment_pages": True, + "appointment_booking_refs": True, + "publishing": True, + "publish_releases": True, + "domain_edge_bindings": True, + "seo_metadata": True, + "sitemap_shells": True, + "robots_shells": True, + "pwa_manifests": True, + "bundles": True, + "bundle_definitions": True, + "tenant_bundles": True, + "license_bindings": True, + "feature_toggles": True, + "widgets": True, + "consumer_connectors": True, + "analytics": True, + "ai_content": True, + "cms": False, + "seo_engine": True, + }, + "page_types": list(_PAGE_TYPES), + "component_kinds": list(_COMPONENT_KINDS), + "direction_modes": list(_DIRECTION_MODES), + "text_directions": ["rtl", "ltr"], + "localization_targets": [ + "site", + "page", + "component_placement", + "template", + "theme", + "layout", + "media_ref", + ], + "media_kinds": ["image", "video", "document", "audio", "icon", "other"], + "independence": { + "standalone": True, + "superapp": True, + "integrations": [ + "core", + "storage", + "crm", + "hospitality", + "marketplace", + "sports_center", + "communication", + "loyalty", + "accounting", + "delivery", + "ai", + "automation", + ], + "integration_mode": "api_and_events_only", + }, + } + + +@router.get("/metrics") +async def metrics(): + return { + "service": settings.service_name, + "version": __version__, + "phase": "11.10", + "metrics": { + "workspaces": "tenant_scoped", + "sites": "tenant_scoped", + "pages": "tenant_scoped", + "site_domains": "tenant_scoped", + "components": "tenant_scoped", + "component_versions": "tenant_scoped", + "page_component_placements": "tenant_scoped", + "themes": "tenant_scoped", + "theme_versions": "tenant_scoped", + "layouts": "tenant_scoped", + "layout_versions": "tenant_scoped", + "site_theme_bindings": "tenant_scoped", + "page_layout_bindings": "tenant_scoped", + "templates": "tenant_scoped", + "template_versions": "tenant_scoped", + "template_instantiations": "tenant_scoped", + "site_locale_bindings": "tenant_scoped", + "localizations": "tenant_scoped", + "media_refs": "tenant_scoped", + "forms": "tenant_scoped", + "form_submissions": "tenant_scoped", + "surveys": "tenant_scoped", + "survey_responses": "tenant_scoped", + "appointment_pages": "tenant_scoped", + "appointment_booking_refs": "tenant_scoped", + "publish_releases": "tenant_scoped", + "domain_edge_bindings": "tenant_scoped", + "seo_metadata": "tenant_scoped", + "sitemap_shells": "tenant_scoped", + "robots_shells": "tenant_scoped", + "pwa_manifests": "tenant_scoped", + "bundle_definitions": "tenant_scoped", + "tenant_bundles": "tenant_scoped", + "license_bindings": "tenant_scoped", + "feature_toggles": "tenant_scoped", + "consumer_connector_registrations": "tenant_scoped", + "consumer_connector_dispatches": "tenant_scoped", + "widget_definitions": "tenant_scoped", + "widget_instances": "tenant_scoped", + "notify_dispatches": "tenant_scoped", + "analytics_report_definitions": "tenant_scoped", + "analytics_snapshots": "tenant_scoped", + "ai_content_hook_registrations": "tenant_scoped", + "ai_content_dispatches": "tenant_scoped", + "published_component_versions": "tenant_scoped", + "locale_profiles": "tenant_scoped", + "external_providers": "tenant_scoped", + "render_engines": "tenant_scoped", + "configurations": "tenant_scoped", + "published_pages": "tenant_scoped", + "active_sites": "tenant_scoped", + }, + "note": "Operational counters reserved for later phases; foundation exposes discovery only", + } diff --git a/backend/services/experience/app/api/v1/layout_versions.py b/backend/services/experience/app/api/v1/layout_versions.py new file mode 100644 index 0000000..2bbf5ef --- /dev/null +++ b/backend/services/experience/app/api/v1/layout_versions.py @@ -0,0 +1,124 @@ +"""Layout version APIs — Phase 11.3 (versioned layouts).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.themes import ThemeLayoutCommands +from app.permissions.definitions import ( + LAYOUTS_CREATE, + LAYOUTS_MANAGE, + LAYOUTS_UPDATE, + LAYOUTS_VIEW, +) +from app.queries.themes import ThemeLayoutQueries +from app.schemas.themes import ( + ExperienceLayoutVersionCreate, + ExperienceLayoutVersionListResponse, + ExperienceLayoutVersionRead, + ExperienceLayoutVersionUpdate, +) +from app.specifications.themes import LAYOUT_VERSION_SORT_FIELDS, LayoutVersionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "published", "retired"} + + +def _list_spec( + layout_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + sort_by: str = Query(default="version_number"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> LayoutVersionListSpec: + if sort_by not in LAYOUT_VERSION_SORT_FIELDS: + sort_by = "version_number" + return LayoutVersionListSpec( + layout_id=layout_id, + status=status_filter if status_filter in _STATUSES else None, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceLayoutVersionRead, status_code=status.HTTP_201_CREATED +) +async def create_layout_version( + body: ExperienceLayoutVersionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_CREATE)), +): + return await ThemeLayoutCommands(db).create_layout_version( + tenant_id, body, actor=user + ) + + +@router.get("", response_model=ExperienceLayoutVersionListResponse) +async def list_layout_versions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: LayoutVersionListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)), +): + items, total = await ThemeLayoutQueries(db).list_layout_versions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceLayoutVersionListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{version_id}", response_model=ExperienceLayoutVersionRead) +async def get_layout_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)), +): + return await ThemeLayoutQueries(db).get_layout_version(tenant_id, version_id) + + +@router.patch("/{version_id}", response_model=ExperienceLayoutVersionRead) +async def update_layout_version( + version_id: UUID, + body: ExperienceLayoutVersionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_UPDATE)), +): + return await ThemeLayoutCommands(db).update_layout_version( + tenant_id, version_id, body, actor=user + ) + + +@router.post("/{version_id}/publish", response_model=ExperienceLayoutVersionRead) +async def publish_layout_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)), +): + return await ThemeLayoutCommands(db).publish_layout_version( + tenant_id, version_id, actor=user + ) + + +@router.post("/{version_id}/retire", response_model=ExperienceLayoutVersionRead) +async def retire_layout_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)), +): + return await ThemeLayoutCommands(db).retire_layout_version( + tenant_id, version_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/layouts.py b/backend/services/experience/app/api/v1/layouts.py new file mode 100644 index 0000000..d7afb3b --- /dev/null +++ b/backend/services/experience/app/api/v1/layouts.py @@ -0,0 +1,157 @@ +"""Layout APIs — Phase 11.3.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.themes import ThemeLayoutCommands +from app.permissions.definitions import ( + LAYOUTS_CREATE, + LAYOUTS_DELETE, + LAYOUTS_MANAGE, + LAYOUTS_UPDATE, + LAYOUTS_VIEW, +) +from app.queries.themes import ThemeLayoutQueries +from app.schemas.themes import ( + ExperienceLayoutCreate, + ExperienceLayoutListResponse, + ExperienceLayoutRead, + ExperienceLayoutUpdate, +) +from app.specifications.themes import LAYOUT_SORT_FIELDS, LayoutListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "active", "deprecated", "archived"} +_DIRECTIONS = {"rtl", "ltr", "both"} + + +_KINDS = {"page_shell", "app_shell", "sidebar", "split", "custom"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + direction_mode: str | None = Query(default=None), + kind: str | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> LayoutListSpec: + if sort_by not in LAYOUT_SORT_FIELDS: + sort_by = "created_at" + return LayoutListSpec( + workspace_id=workspace_id, + status=status_filter if status_filter in _STATUSES else None, + direction_mode=direction_mode if direction_mode in _DIRECTIONS else None, + kind=kind if kind in _KINDS else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceLayoutRead, status_code=status.HTTP_201_CREATED +) +async def create_layout( + body: ExperienceLayoutCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_CREATE)), +): + return await ThemeLayoutCommands(db).create_layout(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceLayoutListResponse) +async def list_layouts( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: LayoutListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)), +): + items, total = await ThemeLayoutQueries(db).list_layouts( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceLayoutListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{layout_id}", response_model=ExperienceLayoutRead) +async def get_layout( + layout_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)), +): + return await ThemeLayoutQueries(db).get_layout(tenant_id, layout_id) + + +@router.patch("/{layout_id}", response_model=ExperienceLayoutRead) +async def update_layout( + layout_id: UUID, + body: ExperienceLayoutUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_UPDATE)), +): + return await ThemeLayoutCommands(db).update_layout( + tenant_id, layout_id, body, actor=user + ) + + +@router.post("/{layout_id}/activate", response_model=ExperienceLayoutRead) +async def activate_layout( + layout_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)), +): + return await ThemeLayoutCommands(db).activate_layout( + tenant_id, layout_id, actor=user + ) + + +@router.post("/{layout_id}/deprecate", response_model=ExperienceLayoutRead) +async def deprecate_layout( + layout_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)), +): + return await ThemeLayoutCommands(db).deprecate_layout( + tenant_id, layout_id, actor=user + ) + + +@router.post("/{layout_id}/archive", response_model=ExperienceLayoutRead) +async def archive_layout( + layout_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)), +): + return await ThemeLayoutCommands(db).archive_layout( + tenant_id, layout_id, actor=user + ) + + +@router.post("/{layout_id}/delete", response_model=ExperienceLayoutRead) +async def soft_delete_layout( + layout_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_DELETE)), +): + return await ThemeLayoutCommands(db).soft_delete_layout( + tenant_id, layout_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/license_bindings.py b/backend/services/experience/app/api/v1/license_bindings.py new file mode 100644 index 0000000..b88d0f4 --- /dev/null +++ b/backend/services/experience/app/api/v1/license_bindings.py @@ -0,0 +1,49 @@ +"""Core entitlement reference binding APIs — Phase 11.8.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.bundles import BundlesCommands +from app.permissions.definitions import BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_UPDATE, BUNDLES_VIEW +from app.queries.bundles import BundlesQueries +from app.schemas.bundles import LicenseBindingCreate, LicenseBindingListResponse, LicenseBindingRead, LicenseBindingUpdate +from app.specifications.bundles import LICENSE_BINDING_SORT, LicenseBindingListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +def _spec(workspace_id: UUID | None = None, status_filter: str | None = Query(None, alias="status"), + bundle_key: str | None = None, q: str | None = Query(None, max_length=100), sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")): + return LicenseBindingListSpec(workspace_id=workspace_id, status=status_filter, bundle_key=bundle_key, q=q, + sort_by=sort_by if sort_by in LICENSE_BINDING_SORT else "created_at", sort_dir=sort_dir) + +@router.post("", response_model=LicenseBindingRead, status_code=201) +async def create(body: LicenseBindingCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE))): + return await BundlesCommands(db).create_license_binding(tenant_id, body, actor=user) + +@router.get("", response_model=LicenseBindingListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: LicenseBindingListSpec = Depends(_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))): + items, total = await BundlesQueries(db).list_license_bindings(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return LicenseBindingListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + +@router.get("/{entity_id}", response_model=LicenseBindingRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))): + return await BundlesQueries(db).get_license_binding(tenant_id, entity_id) + +@router.patch("/{entity_id}", response_model=LicenseBindingRead) +async def update(entity_id: UUID, body: LicenseBindingUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE))): + return await BundlesCommands(db).update_license_binding(tenant_id, entity_id, body, actor=user) + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE))): + await BundlesCommands(db).delete_license_binding(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/locale_profiles.py b/backend/services/experience/app/api/v1/locale_profiles.py new file mode 100644 index 0000000..97102b2 --- /dev/null +++ b/backend/services/experience/app/api/v1/locale_profiles.py @@ -0,0 +1,70 @@ +"""Experience hub APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import LOCALE_PROFILES_CREATE, LOCALE_PROFILES_DELETE, LOCALE_PROFILES_UPDATE, LOCALE_PROFILES_VIEW +from app.schemas.foundation import ExperienceLocaleProfileCreate, ExperienceLocaleProfileRead, ExperienceLocaleProfileUpdate +from app.services.foundation import ExperienceLocaleProfileService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExperienceLocaleProfileRead, status_code=status.HTTP_201_CREATED) +async def create_locale_profile( + body: ExperienceLocaleProfileCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_CREATE)), +): + return await ExperienceLocaleProfileService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperienceLocaleProfileRead]) +async def list_locale_profiles( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_VIEW)), +): + return await ExperienceLocaleProfileService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{locale_profile_id}", response_model=ExperienceLocaleProfileRead) +async def get_locale_profile( + locale_profile_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_VIEW)), +): + return await ExperienceLocaleProfileService(db).get(tenant_id, locale_profile_id) + + +@router.patch("/{locale_profile_id}", response_model=ExperienceLocaleProfileRead) +async def update_locale_profile( + locale_profile_id: UUID, + body: ExperienceLocaleProfileUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_UPDATE)), +): + return await ExperienceLocaleProfileService(db).update(tenant_id, locale_profile_id, body, actor=user) + + +@router.post("/{locale_profile_id}/delete", response_model=ExperienceLocaleProfileRead) +async def soft_delete_locale_profile( + locale_profile_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_DELETE)), +): + return await ExperienceLocaleProfileService(db).soft_delete(tenant_id, locale_profile_id, actor=user) diff --git a/backend/services/experience/app/api/v1/localizations.py b/backend/services/experience/app/api/v1/localizations.py new file mode 100644 index 0000000..f799fdf --- /dev/null +++ b/backend/services/experience/app/api/v1/localizations.py @@ -0,0 +1,137 @@ +"""Localization content APIs — Phase 11.5.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.localization import LocalizationCommands +from app.permissions.definitions import ( + LOCALES_CREATE, + LOCALES_DELETE, + LOCALES_UPDATE, + LOCALES_VIEW, +) +from app.queries.localization import LocalizationQueries +from app.schemas.localization import ( + ExperienceLocalizationListResponse, + ExperienceLocalizationRead, + ExperienceLocalizationUpdate, + ExperienceLocalizationUpsert, +) +from app.specifications.localization import ( + LOCALIZATION_SORT_FIELDS, + LocalizationListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "active", "archived"} +_TARGETS = { + "site", + "page", + "component_placement", + "template", + "theme", + "layout", + "media_ref", +} +_DIRECTIONS = {"rtl", "ltr"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + target_type: str | None = Query(default=None, max_length=50), + target_id: UUID | None = Query(default=None), + locale: str | None = Query(default=None, max_length=16), + text_direction: str | None = Query(default=None, max_length=3), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> LocalizationListSpec: + if sort_by not in LOCALIZATION_SORT_FIELDS: + sort_by = "created_at" + return LocalizationListSpec( + workspace_id=workspace_id, + target_type=target_type if target_type in _TARGETS else None, + target_id=target_id, + locale=locale, + text_direction=text_direction if text_direction in _DIRECTIONS else None, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=ExperienceLocalizationRead, + status_code=status.HTTP_201_CREATED, +) +async def upsert_localization( + body: ExperienceLocalizationUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALES_CREATE)), +): + return await LocalizationCommands(db).upsert_localization( + tenant_id, body, actor=user + ) + + +@router.get("", response_model=ExperienceLocalizationListResponse) +async def list_localizations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: LocalizationListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)), +): + items, total = await LocalizationQueries(db).list_localizations( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceLocalizationListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{localization_id}", response_model=ExperienceLocalizationRead) +async def get_localization( + localization_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)), +): + return await LocalizationQueries(db).get_localization(tenant_id, localization_id) + + +@router.patch("/{localization_id}", response_model=ExperienceLocalizationRead) +async def update_localization( + localization_id: UUID, + body: ExperienceLocalizationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALES_UPDATE)), +): + return await LocalizationCommands(db).update_localization( + tenant_id, localization_id, body, actor=user + ) + + +@router.delete("/{localization_id}", response_model=ExperienceLocalizationRead) +async def delete_localization( + localization_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALES_DELETE)), +): + return await LocalizationCommands(db).soft_delete_localization( + tenant_id, localization_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/media_refs.py b/backend/services/experience/app/api/v1/media_refs.py new file mode 100644 index 0000000..39b8620 --- /dev/null +++ b/backend/services/experience/app/api/v1/media_refs.py @@ -0,0 +1,125 @@ +"""Media reference APIs — Phase 11.5 (Storage refs only).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.localization import LocalizationCommands +from app.permissions.definitions import ( + MEDIA_REFS_CREATE, + MEDIA_REFS_DELETE, + MEDIA_REFS_UPDATE, + MEDIA_REFS_VIEW, +) +from app.queries.localization import LocalizationQueries +from app.schemas.localization import ( + ExperienceMediaRefCreate, + ExperienceMediaRefListResponse, + ExperienceMediaRefRead, + ExperienceMediaRefUpdate, +) +from app.specifications.localization import MEDIA_REF_SORT_FIELDS, MediaRefListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_KINDS = {"image", "video", "document", "audio", "icon", "other"} +_STATUSES = {"draft", "active", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + page_id: UUID | None = Query(default=None), + owner_type: str | None = Query(default=None, max_length=50), + owner_id: UUID | None = Query(default=None), + kind: str | None = Query(default=None, max_length=20), + locale: str | None = Query(default=None, max_length=16), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="sort_order"), + sort_dir: str = Query(default="asc", pattern="^(?i)(asc|desc)$"), +) -> MediaRefListSpec: + if sort_by not in MEDIA_REF_SORT_FIELDS: + sort_by = "sort_order" + return MediaRefListSpec( + workspace_id=workspace_id, + site_id=site_id, + page_id=page_id, + owner_type=owner_type, + owner_id=owner_id, + kind=kind if kind in _KINDS else None, + locale=locale, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceMediaRefRead, status_code=status.HTTP_201_CREATED +) +async def create_media_ref( + body: ExperienceMediaRefCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDIA_REFS_CREATE)), +): + return await LocalizationCommands(db).create_media_ref(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceMediaRefListResponse) +async def list_media_refs( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: MediaRefListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(MEDIA_REFS_VIEW)), +): + items, total = await LocalizationQueries(db).list_media_refs( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceMediaRefListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{media_ref_id}", response_model=ExperienceMediaRefRead) +async def get_media_ref( + media_ref_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(MEDIA_REFS_VIEW)), +): + return await LocalizationQueries(db).get_media_ref(tenant_id, media_ref_id) + + +@router.patch("/{media_ref_id}", response_model=ExperienceMediaRefRead) +async def update_media_ref( + media_ref_id: UUID, + body: ExperienceMediaRefUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDIA_REFS_UPDATE)), +): + return await LocalizationCommands(db).update_media_ref( + tenant_id, media_ref_id, body, actor=user + ) + + +@router.delete("/{media_ref_id}", response_model=ExperienceMediaRefRead) +async def delete_media_ref( + media_ref_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(MEDIA_REFS_DELETE)), +): + return await LocalizationCommands(db).soft_delete_media_ref( + tenant_id, media_ref_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/notify_dispatches.py b/backend/services/experience/app/api/v1/notify_dispatches.py new file mode 100644 index 0000000..be325c1 --- /dev/null +++ b/backend/services/experience/app/api/v1/notify_dispatches.py @@ -0,0 +1,116 @@ +"""Notify dispatch APIs — Phase 11.9.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.integrations import IntegrationsCommands +from app.permissions.definitions import ( + NOTIFY_CREATE, + NOTIFY_DELETE, + NOTIFY_MANAGE, + NOTIFY_UPDATE, + NOTIFY_VIEW, +) +from app.queries.integrations import IntegrationsQueries +from app.schemas.integrations import ( + NotifyDispatchCreate, + NotifyDispatchListResponse, + NotifyDispatchRead, + NotifyDispatchUpdate, +) +from app.specifications.integrations import NOTIFY_DISPATCH_SORT, NotifyDispatchListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return NotifyDispatchListSpec( + workspace_id=workspace_id, + site_id=site_id, + status=status_filter, + q=q, + sort_by=sort_by if sort_by in NOTIFY_DISPATCH_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=NotifyDispatchRead, status_code=201) +async def create( + body: NotifyDispatchCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(NOTIFY_CREATE)), +): + return await IntegrationsCommands(db).create_notify_dispatch(tenant_id, body, actor=user) + + +@router.get("", response_model=NotifyDispatchListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: NotifyDispatchListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(NOTIFY_VIEW)), +): + items, total = await IntegrationsQueries(db).list_notify_dispatches( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return NotifyDispatchListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=NotifyDispatchRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(NOTIFY_VIEW)), +): + return await IntegrationsQueries(db).get_notify_dispatch(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=NotifyDispatchRead) +async def update( + entity_id: UUID, + body: NotifyDispatchUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(NOTIFY_UPDATE)), +): + return await IntegrationsCommands(db).update_notify_dispatch( + tenant_id, entity_id, body, actor=user + ) + + +@router.post("/{entity_id}/send", response_model=NotifyDispatchRead) +async def send( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(NOTIFY_MANAGE)), +): + return await IntegrationsCommands(db).send_notify_dispatch(tenant_id, entity_id, actor=user) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(NOTIFY_DELETE)), +): + await IntegrationsCommands(db).delete_notify_dispatch(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/page_component_placements.py b/backend/services/experience/app/api/v1/page_component_placements.py new file mode 100644 index 0000000..ad9e629 --- /dev/null +++ b/backend/services/experience/app/api/v1/page_component_placements.py @@ -0,0 +1,120 @@ +"""Page component placement APIs — Phase 11.2 composition shells.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.components import ComponentCommands +from app.permissions.definitions import ( + COMPONENTS_CREATE, + COMPONENTS_DELETE, + COMPONENTS_UPDATE, + COMPONENTS_VIEW, +) +from app.queries.components import ComponentQueries +from app.schemas.components import ( + PageComponentPlacementCreate, + PageComponentPlacementListResponse, + PageComponentPlacementRead, + PageComponentPlacementUpdate, +) +from app.specifications.components import PLACEMENT_SORT_FIELDS, PlacementListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _list_spec( + page_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + component_id: UUID | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="sort_order"), + sort_dir: str = Query(default="asc", pattern="^(?i)(asc|desc)$"), +) -> PlacementListSpec: + if sort_by not in PLACEMENT_SORT_FIELDS: + sort_by = "sort_order" + return PlacementListSpec( + page_id=page_id, + site_id=site_id, + component_id=component_id, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=PageComponentPlacementRead, + status_code=status.HTTP_201_CREATED, +) +async def create_placement( + body: PageComponentPlacementCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_CREATE)), +): + return await ComponentCommands(db).create_placement(tenant_id, body, actor=user) + + +@router.get("", response_model=PageComponentPlacementListResponse) +async def list_placements( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: PlacementListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)), +): + items, total = await ComponentQueries(db).list_placements( + tenant_id, + offset=pagination.offset, + limit=pagination.page_size, + spec=spec, + ) + return PageComponentPlacementListResponse( + items=items, + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + +@router.get("/{placement_id}", response_model=PageComponentPlacementRead) +async def get_placement( + placement_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)), +): + return await ComponentQueries(db).get_placement(tenant_id, placement_id) + + +@router.patch("/{placement_id}", response_model=PageComponentPlacementRead) +async def update_placement( + placement_id: UUID, + body: PageComponentPlacementUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_UPDATE)), +): + return await ComponentCommands(db).update_placement( + tenant_id, placement_id, body, actor=user + ) + + +@router.post("/{placement_id}/delete", response_model=PageComponentPlacementRead) +async def soft_delete_placement( + placement_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(COMPONENTS_DELETE)), +): + return await ComponentCommands(db).soft_delete_placement( + tenant_id, placement_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/page_layout_bindings.py b/backend/services/experience/app/api/v1/page_layout_bindings.py new file mode 100644 index 0000000..f5f5ffe --- /dev/null +++ b/backend/services/experience/app/api/v1/page_layout_bindings.py @@ -0,0 +1,100 @@ +"""Page layout binding APIs — Phase 11.3 (versioned bindings).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.themes import ThemeLayoutCommands +from app.permissions.definitions import LAYOUTS_CREATE, LAYOUTS_DELETE, LAYOUTS_VIEW +from app.queries.themes import ThemeLayoutQueries +from app.schemas.themes import ( + PageLayoutBindingCreate, + PageLayoutBindingListResponse, + PageLayoutBindingRead, +) +from app.specifications.themes import ( + PAGE_LAYOUT_BINDING_SORT_FIELDS, + PageLayoutBindingListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _list_spec( + page_id: UUID | None = Query(default=None), + layout_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + workspace_id: UUID | None = Query(default=None), + is_active: bool | None = Query(default=None), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> PageLayoutBindingListSpec: + if sort_by not in PAGE_LAYOUT_BINDING_SORT_FIELDS: + sort_by = "created_at" + return PageLayoutBindingListSpec( + page_id=page_id, + layout_id=layout_id, + site_id=site_id, + workspace_id=workspace_id, + is_active=is_active, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=PageLayoutBindingRead, + status_code=status.HTTP_201_CREATED, +) +async def assign_page_layout( + body: PageLayoutBindingCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_CREATE)), +): + return await ThemeLayoutCommands(db).assign_page_layout(tenant_id, body, actor=user) + + +@router.get("", response_model=PageLayoutBindingListResponse) +async def list_page_layout_bindings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: PageLayoutBindingListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)), +): + items, total = await ThemeLayoutQueries(db).list_page_layout_bindings( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return PageLayoutBindingListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{binding_id}", response_model=PageLayoutBindingRead) +async def get_page_layout_binding( + binding_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)), +): + return await ThemeLayoutQueries(db).get_page_layout_binding(tenant_id, binding_id) + + +@router.post("/{binding_id}/unassign", response_model=PageLayoutBindingRead) +async def unassign_page_layout( + binding_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LAYOUTS_DELETE)), +): + return await ThemeLayoutCommands(db).unassign_page_layout( + tenant_id, binding_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/pages.py b/backend/services/experience/app/api/v1/pages.py new file mode 100644 index 0000000..fa78133 --- /dev/null +++ b/backend/services/experience/app/api/v1/pages.py @@ -0,0 +1,112 @@ +"""Pages APIs — Phase 11.1.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + PAGES_CREATE, + PAGES_DELETE, + PAGES_MANAGE, + PAGES_UPDATE, + PAGES_VIEW, +) +from app.schemas.sites import ExperiencePageCreate, ExperiencePageRead, ExperiencePageUpdate +from app.services.sites import ExperiencePageService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExperiencePageRead, status_code=status.HTTP_201_CREATED) +async def create_page( + body: ExperiencePageCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PAGES_CREATE)), +): + return await ExperiencePageService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperiencePageRead]) +async def list_pages( + site_id: UUID | None = Query(default=None), + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PAGES_VIEW)), +): + svc = ExperiencePageService(db) + if site_id is not None: + return await svc.list_by_site( + tenant_id, site_id, offset=pagination.offset, limit=pagination.page_size + ) + return await svc.list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{page_id}", response_model=ExperiencePageRead) +async def get_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PAGES_VIEW)), +): + return await ExperiencePageService(db).get(tenant_id, page_id) + + +@router.patch("/{page_id}", response_model=ExperiencePageRead) +async def update_page( + page_id: UUID, + body: ExperiencePageUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PAGES_UPDATE)), +): + return await ExperiencePageService(db).update(tenant_id, page_id, body, actor=user) + + +@router.post("/{page_id}/publish", response_model=ExperiencePageRead) +async def publish_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PAGES_MANAGE)), +): + return await ExperiencePageService(db).publish(tenant_id, page_id, actor=user) + + +@router.post("/{page_id}/unpublish", response_model=ExperiencePageRead) +async def unpublish_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PAGES_MANAGE)), +): + return await ExperiencePageService(db).unpublish(tenant_id, page_id, actor=user) + + +@router.post("/{page_id}/archive", response_model=ExperiencePageRead) +async def archive_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PAGES_MANAGE)), +): + return await ExperiencePageService(db).archive(tenant_id, page_id, actor=user) + + +@router.post("/{page_id}/delete", response_model=ExperiencePageRead) +async def soft_delete_page( + page_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PAGES_DELETE)), +): + return await ExperiencePageService(db).soft_delete(tenant_id, page_id, actor=user) diff --git a/backend/services/experience/app/api/v1/publish_releases.py b/backend/services/experience/app/api/v1/publish_releases.py new file mode 100644 index 0000000..3ccdab0 --- /dev/null +++ b/backend/services/experience/app/api/v1/publish_releases.py @@ -0,0 +1,50 @@ +"""Publish release APIs — Phase 11.7.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.publishing import PublishingCommands +from app.permissions.definitions import PUBLISHING_CREATE, PUBLISHING_UPDATE, PUBLISHING_VIEW +from app.queries.publishing import PublishingQueries +from app.schemas.publishing import PublishReleaseCreate, PublishReleaseListResponse, PublishReleaseRead, PublishReleaseUpdate +from app.specifications.publishing import PUBLISH_SORT, PublishReleaseListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _list_spec(workspace_id: UUID | None = None, target_type: str | None = None, target_id: UUID | None = None, + status_filter: str | None = Query(default=None, alias="status"), q: str | None = Query(default=None, max_length=100), + sort_by: str = "created_at", sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")): + return PublishReleaseListSpec(workspace_id=workspace_id, target_type=target_type, target_id=target_id, status=status_filter, + q=q, sort_by=sort_by if sort_by in PUBLISH_SORT else "created_at", sort_dir=sort_dir) + + +@router.post("", response_model=PublishReleaseRead, status_code=status.HTTP_201_CREATED) +async def create(body: PublishReleaseCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(PUBLISHING_CREATE))): + return await PublishingCommands(db).create_publish_release(tenant_id, body, actor=user) + + +@router.get("", response_model=PublishReleaseListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: PublishReleaseListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))): + items, total = await PublishingQueries(db).list_publish_releases(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return PublishReleaseListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + + +@router.get("/{entity_id}", response_model=PublishReleaseRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))): + return await PublishingQueries(db).get_publish_release(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=PublishReleaseRead) +async def update(entity_id: UUID, body: PublishReleaseUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PUBLISHING_UPDATE))): + return await PublishingCommands(db).update_publish_release(tenant_id, entity_id, body, actor=user) diff --git a/backend/services/experience/app/api/v1/pwa_manifests.py b/backend/services/experience/app/api/v1/pwa_manifests.py new file mode 100644 index 0000000..eed0894 --- /dev/null +++ b/backend/services/experience/app/api/v1/pwa_manifests.py @@ -0,0 +1,33 @@ +"""PWA manifest APIs — Phase 11.7.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.publishing import PublishingCommands +from app.permissions.definitions import PWA_CREATE, PWA_UPDATE, PWA_VIEW +from app.queries.publishing import PublishingQueries +from app.schemas.publishing import PwaManifestListResponse, PwaManifestRead, PwaManifestUpdate, PwaManifestUpsert +from app.specifications.publishing import PWA_SORT, PwaManifestListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), sort_by: str = "created_at", + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")): + return PwaManifestListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, q=q, + sort_by=sort_by if sort_by in PWA_SORT else "created_at", sort_dir=sort_dir) +@router.post("", response_model=PwaManifestRead, status_code=status.HTTP_201_CREATED) +async def upsert(body: PwaManifestUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PWA_CREATE))): + return await PublishingCommands(db).upsert_pwa(tenant_id, body, actor=user) +@router.get("", response_model=PwaManifestListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), spec: PwaManifestListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(PWA_VIEW))): + items, total = await PublishingQueries(db).list_pwa(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return PwaManifestListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) +@router.get("/{entity_id}", response_model=PwaManifestRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(PWA_VIEW))): + return await PublishingQueries(db).get_pwa(tenant_id, entity_id) +@router.patch("/{entity_id}", response_model=PwaManifestRead) +async def update(entity_id: UUID, body: PwaManifestUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PWA_UPDATE))): + return await PublishingCommands(db).update_pwa(tenant_id, entity_id, body, actor=user) diff --git a/backend/services/experience/app/api/v1/render_engines.py b/backend/services/experience/app/api/v1/render_engines.py new file mode 100644 index 0000000..d08b737 --- /dev/null +++ b/backend/services/experience/app/api/v1/render_engines.py @@ -0,0 +1,83 @@ +"""Routing engine registration APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + RENDER_ENGINES_CREATE, + RENDER_ENGINES_DELETE, + RENDER_ENGINES_UPDATE, + RENDER_ENGINES_VIEW, +) +from app.schemas.foundation import ( + RenderEngineRegistrationCreate, + RenderEngineRegistrationRead, + RenderEngineRegistrationUpdate, +) +from app.services.foundation import RenderEngineRegistrationService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=RenderEngineRegistrationRead, status_code=status.HTTP_201_CREATED) +async def create_engine( + body: RenderEngineRegistrationCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_CREATE)), +): + return await RenderEngineRegistrationService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[RenderEngineRegistrationRead]) +async def list_engines( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_VIEW)), +): + return await RenderEngineRegistrationService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{engine_id}", response_model=RenderEngineRegistrationRead) +async def get_engine( + engine_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_VIEW)), +): + return await RenderEngineRegistrationService(db).get(tenant_id, engine_id) + + +@router.patch("/{engine_id}", response_model=RenderEngineRegistrationRead) +async def update_engine( + engine_id: UUID, + body: RenderEngineRegistrationUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_UPDATE)), +): + return await RenderEngineRegistrationService(db).update( + tenant_id, engine_id, body, actor=user + ) + + +@router.post("/{engine_id}/delete", response_model=RenderEngineRegistrationRead) +async def soft_delete_engine( + engine_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_DELETE)), +): + return await RenderEngineRegistrationService(db).soft_delete( + tenant_id, engine_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/robots_shells.py b/backend/services/experience/app/api/v1/robots_shells.py new file mode 100644 index 0000000..f322b7c --- /dev/null +++ b/backend/services/experience/app/api/v1/robots_shells.py @@ -0,0 +1,33 @@ +"""Robots shell APIs — Phase 11.7.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.publishing import PublishingCommands +from app.permissions.definitions import SEO_CREATE, SEO_UPDATE, SEO_VIEW +from app.queries.publishing import PublishingQueries +from app.schemas.publishing import RobotsShellListResponse, RobotsShellRead, RobotsShellUpdate, RobotsShellUpsert +from app.specifications.publishing import ROBOTS_SORT, RobotsShellListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), sort_by: str = "created_at", + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")): + return RobotsShellListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, q=q, + sort_by=sort_by if sort_by in ROBOTS_SORT else "created_at", sort_dir=sort_dir) +@router.post("", response_model=RobotsShellRead, status_code=status.HTTP_201_CREATED) +async def upsert(body: RobotsShellUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_CREATE))): + return await PublishingCommands(db).upsert_robots(tenant_id, body, actor=user) +@router.get("", response_model=RobotsShellListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), spec: RobotsShellListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))): + items, total = await PublishingQueries(db).list_robots(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return RobotsShellListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) +@router.get("/{entity_id}", response_model=RobotsShellRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))): + return await PublishingQueries(db).get_robots(tenant_id, entity_id) +@router.patch("/{entity_id}", response_model=RobotsShellRead) +async def update(entity_id: UUID, body: RobotsShellUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_UPDATE))): + return await PublishingCommands(db).update_robots(tenant_id, entity_id, body, actor=user) diff --git a/backend/services/experience/app/api/v1/seo_metadata.py b/backend/services/experience/app/api/v1/seo_metadata.py new file mode 100644 index 0000000..8bae19c --- /dev/null +++ b/backend/services/experience/app/api/v1/seo_metadata.py @@ -0,0 +1,43 @@ +"""SEO metadata APIs — Phase 11.7.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.publishing import PublishingCommands +from app.permissions.definitions import SEO_CREATE, SEO_UPDATE, SEO_VIEW +from app.queries.publishing import PublishingQueries +from app.schemas.publishing import SeoMetadataListResponse, SeoMetadataRead, SeoMetadataUpdate, SeoMetadataUpsert +from app.specifications.publishing import SEO_SORT, SeoMetadataListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +def _list_spec(workspace_id: UUID | None = None, target_type: str | None = None, target_id: UUID | None = None, + status_filter: str | None = Query(default=None, alias="status"), q: str | None = Query(default=None, max_length=100), + sort_by: str = "created_at", sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")): + return SeoMetadataListSpec(workspace_id=workspace_id, target_type=target_type, target_id=target_id, status=status_filter, + q=q, sort_by=sort_by if sort_by in SEO_SORT else "created_at", sort_dir=sort_dir) + +@router.post("", response_model=SeoMetadataRead, status_code=status.HTTP_201_CREATED) +async def upsert(body: SeoMetadataUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SEO_CREATE))): + return await PublishingCommands(db).upsert_seo(tenant_id, body, actor=user) + +@router.get("", response_model=SeoMetadataListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: SeoMetadataListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SEO_VIEW))): + items, total = await PublishingQueries(db).list_seo(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return SeoMetadataListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + +@router.get("/{entity_id}", response_model=SeoMetadataRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SEO_VIEW))): + return await PublishingQueries(db).get_seo(tenant_id, entity_id) + +@router.patch("/{entity_id}", response_model=SeoMetadataRead) +async def update(entity_id: UUID, body: SeoMetadataUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_UPDATE))): + return await PublishingCommands(db).update_seo(tenant_id, entity_id, body, actor=user) diff --git a/backend/services/experience/app/api/v1/settings.py b/backend/services/experience/app/api/v1/settings.py new file mode 100644 index 0000000..1a11fb3 --- /dev/null +++ b/backend/services/experience/app/api/v1/settings.py @@ -0,0 +1,39 @@ +"""Experience settings APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW +from app.schemas.foundation import ExperienceSettingRead, ExperienceSettingUpsert +from app.services.foundation import ExperienceSettingService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.put("", response_model=ExperienceSettingRead, status_code=status.HTTP_200_OK) +async def upsert_setting( + body: ExperienceSettingUpsert, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)), +): + return await ExperienceSettingService(db).upsert(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperienceSettingRead]) +async def list_settings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)), +): + return await ExperienceSettingService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) diff --git a/backend/services/experience/app/api/v1/site_domains.py b/backend/services/experience/app/api/v1/site_domains.py new file mode 100644 index 0000000..52e1d16 --- /dev/null +++ b/backend/services/experience/app/api/v1/site_domains.py @@ -0,0 +1,86 @@ +"""Site domain APIs — Phase 11.1.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + DOMAINS_CREATE, + DOMAINS_DELETE, + DOMAINS_UPDATE, + DOMAINS_VIEW, +) +from app.schemas.sites import ( + ExperienceSiteDomainCreate, + ExperienceSiteDomainRead, + ExperienceSiteDomainUpdate, +) +from app.services.sites import ExperienceSiteDomainService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post( + "", response_model=ExperienceSiteDomainRead, status_code=status.HTTP_201_CREATED +) +async def create_domain( + body: ExperienceSiteDomainCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOMAINS_CREATE)), +): + return await ExperienceSiteDomainService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperienceSiteDomainRead]) +async def list_domains( + site_id: UUID = Query(...), + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOMAINS_VIEW)), +): + return await ExperienceSiteDomainService(db).list_by_site( + tenant_id, site_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{domain_id}", response_model=ExperienceSiteDomainRead) +async def get_domain( + domain_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(DOMAINS_VIEW)), +): + return await ExperienceSiteDomainService(db).get(tenant_id, domain_id) + + +@router.patch("/{domain_id}", response_model=ExperienceSiteDomainRead) +async def update_domain( + domain_id: UUID, + body: ExperienceSiteDomainUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOMAINS_UPDATE)), +): + return await ExperienceSiteDomainService(db).update( + tenant_id, domain_id, body, actor=user + ) + + +@router.post("/{domain_id}/delete", response_model=ExperienceSiteDomainRead) +async def soft_delete_domain( + domain_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(DOMAINS_DELETE)), +): + return await ExperienceSiteDomainService(db).soft_delete( + tenant_id, domain_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/site_locale_bindings.py b/backend/services/experience/app/api/v1/site_locale_bindings.py new file mode 100644 index 0000000..2027184 --- /dev/null +++ b/backend/services/experience/app/api/v1/site_locale_bindings.py @@ -0,0 +1,127 @@ +"""Site locale binding APIs — Phase 11.5.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.localization import LocalizationCommands +from app.permissions.definitions import ( + LOCALES_CREATE, + LOCALES_DELETE, + LOCALES_UPDATE, + LOCALES_VIEW, +) +from app.queries.localization import LocalizationQueries +from app.schemas.localization import ( + SiteLocaleBindingCreate, + SiteLocaleBindingListResponse, + SiteLocaleBindingRead, + SiteLocaleBindingUpdate, +) +from app.specifications.localization import ( + SITE_LOCALE_BINDING_SORT_FIELDS, + SiteLocaleBindingListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_DIRECTIONS = {"rtl", "ltr"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + locale_profile_id: UUID | None = Query(default=None), + locale_code: str | None = Query(default=None, max_length=16), + text_direction: str | None = Query(default=None, max_length=3), + is_default: bool | None = Query(default=None), + is_active: bool | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="sort_order"), + sort_dir: str = Query(default="asc", pattern="^(?i)(asc|desc)$"), +) -> SiteLocaleBindingListSpec: + if sort_by not in SITE_LOCALE_BINDING_SORT_FIELDS: + sort_by = "sort_order" + return SiteLocaleBindingListSpec( + workspace_id=workspace_id, + site_id=site_id, + locale_profile_id=locale_profile_id, + locale_code=locale_code, + text_direction=text_direction if text_direction in _DIRECTIONS else None, + is_default=is_default, + is_active=is_active, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=SiteLocaleBindingRead, status_code=status.HTTP_201_CREATED +) +async def create_site_locale_binding( + body: SiteLocaleBindingCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALES_CREATE)), +): + return await LocalizationCommands(db).create_site_locale_binding( + tenant_id, body, actor=user + ) + + +@router.get("", response_model=SiteLocaleBindingListResponse) +async def list_site_locale_bindings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: SiteLocaleBindingListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)), +): + items, total = await LocalizationQueries(db).list_site_locale_bindings( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return SiteLocaleBindingListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{binding_id}", response_model=SiteLocaleBindingRead) +async def get_site_locale_binding( + binding_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)), +): + return await LocalizationQueries(db).get_site_locale_binding(tenant_id, binding_id) + + +@router.patch("/{binding_id}", response_model=SiteLocaleBindingRead) +async def update_site_locale_binding( + binding_id: UUID, + body: SiteLocaleBindingUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALES_UPDATE)), +): + return await LocalizationCommands(db).update_site_locale_binding( + tenant_id, binding_id, body, actor=user + ) + + +@router.delete("/{binding_id}", response_model=SiteLocaleBindingRead) +async def delete_site_locale_binding( + binding_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(LOCALES_DELETE)), +): + return await LocalizationCommands(db).soft_delete_site_locale_binding( + tenant_id, binding_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/site_theme_bindings.py b/backend/services/experience/app/api/v1/site_theme_bindings.py new file mode 100644 index 0000000..4e9068b --- /dev/null +++ b/backend/services/experience/app/api/v1/site_theme_bindings.py @@ -0,0 +1,98 @@ +"""Site theme binding APIs — Phase 11.3 (versioned bindings).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.themes import ThemeLayoutCommands +from app.permissions.definitions import THEMES_CREATE, THEMES_DELETE, THEMES_VIEW +from app.queries.themes import ThemeLayoutQueries +from app.schemas.themes import ( + SiteThemeBindingCreate, + SiteThemeBindingListResponse, + SiteThemeBindingRead, +) +from app.specifications.themes import ( + SITE_THEME_BINDING_SORT_FIELDS, + SiteThemeBindingListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _list_spec( + site_id: UUID | None = Query(default=None), + theme_id: UUID | None = Query(default=None), + workspace_id: UUID | None = Query(default=None), + is_active: bool | None = Query(default=None), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> SiteThemeBindingListSpec: + if sort_by not in SITE_THEME_BINDING_SORT_FIELDS: + sort_by = "created_at" + return SiteThemeBindingListSpec( + site_id=site_id, + theme_id=theme_id, + workspace_id=workspace_id, + is_active=is_active, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=SiteThemeBindingRead, + status_code=status.HTTP_201_CREATED, +) +async def assign_site_theme( + body: SiteThemeBindingCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_CREATE)), +): + return await ThemeLayoutCommands(db).assign_site_theme(tenant_id, body, actor=user) + + +@router.get("", response_model=SiteThemeBindingListResponse) +async def list_site_theme_bindings( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: SiteThemeBindingListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(THEMES_VIEW)), +): + items, total = await ThemeLayoutQueries(db).list_site_theme_bindings( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return SiteThemeBindingListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{binding_id}", response_model=SiteThemeBindingRead) +async def get_site_theme_binding( + binding_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(THEMES_VIEW)), +): + return await ThemeLayoutQueries(db).get_site_theme_binding(tenant_id, binding_id) + + +@router.post("/{binding_id}/unassign", response_model=SiteThemeBindingRead) +async def unassign_site_theme( + binding_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_DELETE)), +): + return await ThemeLayoutCommands(db).unassign_site_theme( + tenant_id, binding_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/sitemap_shells.py b/backend/services/experience/app/api/v1/sitemap_shells.py new file mode 100644 index 0000000..e0c4710 --- /dev/null +++ b/backend/services/experience/app/api/v1/sitemap_shells.py @@ -0,0 +1,33 @@ +"""Sitemap shell APIs — Phase 11.7.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.publishing import PublishingCommands +from app.permissions.definitions import SEO_CREATE, SEO_UPDATE, SEO_VIEW +from app.queries.publishing import PublishingQueries +from app.schemas.publishing import SitemapShellListResponse, SitemapShellRead, SitemapShellUpdate, SitemapShellUpsert +from app.specifications.publishing import SITEMAP_SORT, SitemapShellListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), sort_by: str = "created_at", + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")): + return SitemapShellListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, q=q, + sort_by=sort_by if sort_by in SITEMAP_SORT else "created_at", sort_dir=sort_dir) +@router.post("", response_model=SitemapShellRead, status_code=status.HTTP_201_CREATED) +async def upsert(body: SitemapShellUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_CREATE))): + return await PublishingCommands(db).upsert_sitemap(tenant_id, body, actor=user) +@router.get("", response_model=SitemapShellListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), spec: SitemapShellListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))): + items, total = await PublishingQueries(db).list_sitemaps(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return SitemapShellListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) +@router.get("/{entity_id}", response_model=SitemapShellRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))): + return await PublishingQueries(db).get_sitemap(tenant_id, entity_id) +@router.patch("/{entity_id}", response_model=SitemapShellRead) +async def update(entity_id: UUID, body: SitemapShellUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_UPDATE))): + return await PublishingCommands(db).update_sitemap(tenant_id, entity_id, body, actor=user) diff --git a/backend/services/experience/app/api/v1/sites.py b/backend/services/experience/app/api/v1/sites.py new file mode 100644 index 0000000..a48ef2e --- /dev/null +++ b/backend/services/experience/app/api/v1/sites.py @@ -0,0 +1,115 @@ +"""Sites APIs — Phase 11.1.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + SITES_CREATE, + SITES_DELETE, + SITES_MANAGE, + SITES_UPDATE, + SITES_VIEW, +) +from app.schemas.sites import ExperienceSiteCreate, ExperienceSiteRead, ExperienceSiteUpdate +from app.services.sites import ExperienceSiteService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExperienceSiteRead, status_code=status.HTTP_201_CREATED) +async def create_site( + body: ExperienceSiteCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SITES_CREATE)), +): + return await ExperienceSiteService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperienceSiteRead]) +async def list_sites( + workspace_id: UUID | None = Query(default=None), + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SITES_VIEW)), +): + svc = ExperienceSiteService(db) + if workspace_id is not None: + return await svc.list_by_workspace( + tenant_id, + workspace_id, + offset=pagination.offset, + limit=pagination.page_size, + ) + return await svc.list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{site_id}", response_model=ExperienceSiteRead) +async def get_site( + site_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SITES_VIEW)), +): + return await ExperienceSiteService(db).get(tenant_id, site_id) + + +@router.patch("/{site_id}", response_model=ExperienceSiteRead) +async def update_site( + site_id: UUID, + body: ExperienceSiteUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SITES_UPDATE)), +): + return await ExperienceSiteService(db).update(tenant_id, site_id, body, actor=user) + + +@router.post("/{site_id}/publish", response_model=ExperienceSiteRead) +async def publish_site( + site_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SITES_MANAGE)), +): + return await ExperienceSiteService(db).publish(tenant_id, site_id, actor=user) + + +@router.post("/{site_id}/unpublish", response_model=ExperienceSiteRead) +async def unpublish_site( + site_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SITES_MANAGE)), +): + return await ExperienceSiteService(db).unpublish(tenant_id, site_id, actor=user) + + +@router.post("/{site_id}/archive", response_model=ExperienceSiteRead) +async def archive_site( + site_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SITES_MANAGE)), +): + return await ExperienceSiteService(db).archive(tenant_id, site_id, actor=user) + + +@router.post("/{site_id}/delete", response_model=ExperienceSiteRead) +async def soft_delete_site( + site_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SITES_DELETE)), +): + return await ExperienceSiteService(db).soft_delete(tenant_id, site_id, actor=user) diff --git a/backend/services/experience/app/api/v1/survey_responses.py b/backend/services/experience/app/api/v1/survey_responses.py new file mode 100644 index 0000000..e84bca8 --- /dev/null +++ b/backend/services/experience/app/api/v1/survey_responses.py @@ -0,0 +1,96 @@ +"""Survey response APIs — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.forms import FormsCommands +from app.permissions.definitions import SURVEYS_CREATE, SURVEYS_UPDATE, SURVEYS_VIEW +from app.queries.forms import FormsQueries +from app.schemas.forms import ( + SurveyResponseCreate, + SurveyResponseListResponse, + SurveyResponseRead, + SurveyResponseUpdate, +) +from app.specifications.forms import RESPONSE_SORT_FIELDS, SurveyResponseListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +_STATUSES = {"received", "processed", "failed", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + survey_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> SurveyResponseListSpec: + if sort_by not in RESPONSE_SORT_FIELDS: + sort_by = "created_at" + return SurveyResponseListSpec( + workspace_id=workspace_id, + survey_id=survey_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=SurveyResponseRead, status_code=status.HTTP_201_CREATED +) +async def create_response( + body: SurveyResponseCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SURVEYS_CREATE)), +): + return await FormsCommands(db).create_survey_response(tenant_id, body, actor=user) + + +@router.get("", response_model=SurveyResponseListResponse) +async def list_responses( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: SurveyResponseListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)), +): + items, total = await FormsQueries(db).list_survey_responses( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return SurveyResponseListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{response_id}", response_model=SurveyResponseRead) +async def get_response( + response_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)), +): + return await FormsQueries(db).get_survey_response(tenant_id, response_id) + + +@router.patch("/{response_id}", response_model=SurveyResponseRead) +async def update_response( + response_id: UUID, + body: SurveyResponseUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SURVEYS_UPDATE)), +): + return await FormsCommands(db).update_survey_response( + tenant_id, response_id, body, actor=user + ) diff --git a/backend/services/experience/app/api/v1/surveys.py b/backend/services/experience/app/api/v1/surveys.py new file mode 100644 index 0000000..16c7053 --- /dev/null +++ b/backend/services/experience/app/api/v1/surveys.py @@ -0,0 +1,113 @@ +"""Survey definition APIs — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.forms import FormsCommands +from app.permissions.definitions import ( + SURVEYS_CREATE, + SURVEYS_DELETE, + SURVEYS_UPDATE, + SURVEYS_VIEW, +) +from app.queries.forms import FormsQueries +from app.schemas.forms import ( + ExperienceSurveyCreate, + ExperienceSurveyListResponse, + ExperienceSurveyRead, + ExperienceSurveyUpdate, +) +from app.specifications.forms import SURVEY_SORT_FIELDS, SurveyListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() +_STATUSES = {"draft", "active", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + page_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> SurveyListSpec: + if sort_by not in SURVEY_SORT_FIELDS: + sort_by = "created_at" + return SurveyListSpec( + workspace_id=workspace_id, + site_id=site_id, + page_id=page_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceSurveyRead, status_code=status.HTTP_201_CREATED +) +async def create_survey( + body: ExperienceSurveyCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SURVEYS_CREATE)), +): + return await FormsCommands(db).create_survey(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceSurveyListResponse) +async def list_surveys( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: SurveyListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)), +): + items, total = await FormsQueries(db).list_surveys( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceSurveyListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{survey_id}", response_model=ExperienceSurveyRead) +async def get_survey( + survey_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)), +): + return await FormsQueries(db).get_survey(tenant_id, survey_id) + + +@router.patch("/{survey_id}", response_model=ExperienceSurveyRead) +async def update_survey( + survey_id: UUID, + body: ExperienceSurveyUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SURVEYS_UPDATE)), +): + return await FormsCommands(db).update_survey( + tenant_id, survey_id, body, actor=user + ) + + +@router.delete("/{survey_id}", response_model=ExperienceSurveyRead) +async def delete_survey( + survey_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(SURVEYS_DELETE)), +): + return await FormsCommands(db).soft_delete_survey(tenant_id, survey_id, actor=user) diff --git a/backend/services/experience/app/api/v1/template_instantiations.py b/backend/services/experience/app/api/v1/template_instantiations.py new file mode 100644 index 0000000..ee66a3c --- /dev/null +++ b/backend/services/experience/app/api/v1/template_instantiations.py @@ -0,0 +1,94 @@ +"""Template instantiation APIs — Phase 11.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.templates import TemplateCommands +from app.permissions.definitions import TEMPLATES_CREATE, TEMPLATES_VIEW +from app.queries.templates import TemplateQueries +from app.schemas.templates import ( + TemplateInstantiateRequest, + TemplateInstantiateResponse, + TemplateInstantiationListResponse, + TemplateInstantiationRead, +) +from app.specifications.templates import INSTANTIATION_SORT_FIELDS, InstantiationListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"pending", "completed", "failed"} + + +def _list_spec( + template_id: UUID | None = Query(default=None), + site_id: UUID | None = Query(default=None), + page_id: UUID | None = Query(default=None), + workspace_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> InstantiationListSpec: + if sort_by not in INSTANTIATION_SORT_FIELDS: + sort_by = "created_at" + return InstantiationListSpec( + template_id=template_id, + site_id=site_id, + page_id=page_id, + workspace_id=workspace_id, + status=status_filter if status_filter in _STATUSES else None, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=TemplateInstantiateResponse, + status_code=status.HTTP_201_CREATED, +) +async def instantiate_template( + body: TemplateInstantiateRequest, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_CREATE)), +): + record, page = await TemplateCommands(db).instantiate( + tenant_id, body, actor=user + ) + return TemplateInstantiateResponse( + instantiation=record, + page_id=page.id if page is not None else record.page_id, + ) + + +@router.get("", response_model=TemplateInstantiationListResponse) +async def list_instantiations( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: InstantiationListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)), +): + items, total = await TemplateQueries(db).list_instantiations( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return TemplateInstantiationListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{instantiation_id}", response_model=TemplateInstantiationRead) +async def get_instantiation( + instantiation_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)), +): + return await TemplateQueries(db).get_instantiation(tenant_id, instantiation_id) diff --git a/backend/services/experience/app/api/v1/template_versions.py b/backend/services/experience/app/api/v1/template_versions.py new file mode 100644 index 0000000..7d4956a --- /dev/null +++ b/backend/services/experience/app/api/v1/template_versions.py @@ -0,0 +1,129 @@ +"""Template version APIs — Phase 11.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.templates import TemplateCommands +from app.permissions.definitions import ( + TEMPLATES_CREATE, + TEMPLATES_MANAGE, + TEMPLATES_UPDATE, + TEMPLATES_VIEW, +) +from app.queries.templates import TemplateQueries +from app.schemas.templates import ( + ExperienceTemplateVersionCreate, + ExperienceTemplateVersionListResponse, + ExperienceTemplateVersionRead, + ExperienceTemplateVersionUpdate, +) +from app.specifications.templates import ( + TEMPLATE_VERSION_SORT_FIELDS, + TemplateVersionListSpec, +) +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "published", "retired"} + + +def _list_spec( + template_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="version_number"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> TemplateVersionListSpec: + if sort_by not in TEMPLATE_VERSION_SORT_FIELDS: + sort_by = "version_number" + return TemplateVersionListSpec( + template_id=template_id, + status=status_filter if status_filter in _STATUSES else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", + response_model=ExperienceTemplateVersionRead, + status_code=status.HTTP_201_CREATED, +) +async def create_version( + body: ExperienceTemplateVersionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_CREATE)), +): + return await TemplateCommands(db).create_version(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceTemplateVersionListResponse) +async def list_versions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: TemplateVersionListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)), +): + items, total = await TemplateQueries(db).list_versions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceTemplateVersionListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{version_id}", response_model=ExperienceTemplateVersionRead) +async def get_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)), +): + return await TemplateQueries(db).get_version(tenant_id, version_id) + + +@router.patch("/{version_id}", response_model=ExperienceTemplateVersionRead) +async def update_version( + version_id: UUID, + body: ExperienceTemplateVersionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_UPDATE)), +): + return await TemplateCommands(db).update_version( + tenant_id, version_id, body, actor=user + ) + + +@router.post("/{version_id}/publish", response_model=ExperienceTemplateVersionRead) +async def publish_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)), +): + return await TemplateCommands(db).publish_version( + tenant_id, version_id, actor=user + ) + + +@router.post("/{version_id}/retire", response_model=ExperienceTemplateVersionRead) +async def retire_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)), +): + return await TemplateCommands(db).retire_version( + tenant_id, version_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/templates.py b/backend/services/experience/app/api/v1/templates.py new file mode 100644 index 0000000..03931e6 --- /dev/null +++ b/backend/services/experience/app/api/v1/templates.py @@ -0,0 +1,155 @@ +"""Template catalog APIs — Phase 11.4.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.templates import TemplateCommands +from app.permissions.definitions import ( + TEMPLATES_CREATE, + TEMPLATES_DELETE, + TEMPLATES_MANAGE, + TEMPLATES_UPDATE, + TEMPLATES_VIEW, +) +from app.queries.templates import TemplateQueries +from app.schemas.templates import ( + ExperienceTemplateCreate, + ExperienceTemplateListResponse, + ExperienceTemplateRead, + ExperienceTemplateUpdate, +) +from app.specifications.templates import TEMPLATE_SORT_FIELDS, TemplateListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "active", "deprecated", "archived"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + page_type: str | None = Query(default=None, max_length=50), + is_starter_pack: bool | None = Query(default=None), + category: str | None = Query(default=None, max_length=100), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> TemplateListSpec: + if sort_by not in TEMPLATE_SORT_FIELDS: + sort_by = "created_at" + return TemplateListSpec( + workspace_id=workspace_id, + status=status_filter if status_filter in _STATUSES else None, + page_type=page_type, + is_starter_pack=is_starter_pack, + category=category, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceTemplateRead, status_code=status.HTTP_201_CREATED +) +async def create_template( + body: ExperienceTemplateCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_CREATE)), +): + return await TemplateCommands(db).create_template(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceTemplateListResponse) +async def list_templates( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: TemplateListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)), +): + items, total = await TemplateQueries(db).list_templates( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceTemplateListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{template_id}", response_model=ExperienceTemplateRead) +async def get_template( + template_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)), +): + return await TemplateQueries(db).get_template(tenant_id, template_id) + + +@router.patch("/{template_id}", response_model=ExperienceTemplateRead) +async def update_template( + template_id: UUID, + body: ExperienceTemplateUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_UPDATE)), +): + return await TemplateCommands(db).update_template( + tenant_id, template_id, body, actor=user + ) + + +@router.post("/{template_id}/activate", response_model=ExperienceTemplateRead) +async def activate_template( + template_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)), +): + return await TemplateCommands(db).activate_template( + tenant_id, template_id, actor=user + ) + + +@router.post("/{template_id}/deprecate", response_model=ExperienceTemplateRead) +async def deprecate_template( + template_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)), +): + return await TemplateCommands(db).deprecate_template( + tenant_id, template_id, actor=user + ) + + +@router.post("/{template_id}/archive", response_model=ExperienceTemplateRead) +async def archive_template( + template_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)), +): + return await TemplateCommands(db).archive_template( + tenant_id, template_id, actor=user + ) + + +@router.post("/{template_id}/delete", response_model=ExperienceTemplateRead) +async def soft_delete_template( + template_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(TEMPLATES_DELETE)), +): + return await TemplateCommands(db).soft_delete_template( + tenant_id, template_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/tenant_bundles.py b/backend/services/experience/app/api/v1/tenant_bundles.py new file mode 100644 index 0000000..3c7ae2f --- /dev/null +++ b/backend/services/experience/app/api/v1/tenant_bundles.py @@ -0,0 +1,50 @@ +"""Tenant bundle APIs — Phase 11.8.""" +from uuid import UUID +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.bundles import BundlesCommands +from app.permissions.definitions import BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_UPDATE, BUNDLES_VIEW +from app.queries.bundles import BundlesQueries +from app.schemas.bundles import TenantBundleCreate, TenantBundleListResponse, TenantBundleRead, TenantBundleUpdate +from app.specifications.bundles import TENANT_BUNDLE_SORT, TenantBundleListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +def _spec(workspace_id: UUID | None = None, site_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), bundle_key: str | None = None, + q: str | None = Query(None, max_length=100), sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")): + return TenantBundleListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, bundle_key=bundle_key, + q=q, sort_by=sort_by if sort_by in TENANT_BUNDLE_SORT else "created_at", sort_dir=sort_dir) + +@router.post("", response_model=TenantBundleRead, status_code=201) +async def create(body: TenantBundleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE))): + return await BundlesCommands(db).create_tenant_bundle(tenant_id, body, actor=user) + +@router.get("", response_model=TenantBundleListResponse) +async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), + spec: TenantBundleListSpec = Depends(_spec), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))): + items, total = await BundlesQueries(db).list_tenant_bundles(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec) + return TenantBundleListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size) + +@router.get("/{entity_id}", response_model=TenantBundleRead) +async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))): + return await BundlesQueries(db).get_tenant_bundle(tenant_id, entity_id) + +@router.patch("/{entity_id}", response_model=TenantBundleRead) +async def update(entity_id: UUID, body: TenantBundleUpdate, tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE))): + return await BundlesCommands(db).update_tenant_bundle(tenant_id, entity_id, body, actor=user) + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE))): + await BundlesCommands(db).delete_tenant_bundle(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/theme_versions.py b/backend/services/experience/app/api/v1/theme_versions.py new file mode 100644 index 0000000..6c82ba6 --- /dev/null +++ b/backend/services/experience/app/api/v1/theme_versions.py @@ -0,0 +1,124 @@ +"""Theme version APIs — Phase 11.3 (versioned themes).""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.themes import ThemeLayoutCommands +from app.permissions.definitions import ( + THEMES_CREATE, + THEMES_MANAGE, + THEMES_UPDATE, + THEMES_VIEW, +) +from app.queries.themes import ThemeLayoutQueries +from app.schemas.themes import ( + ExperienceThemeVersionCreate, + ExperienceThemeVersionListResponse, + ExperienceThemeVersionRead, + ExperienceThemeVersionUpdate, +) +from app.specifications.themes import THEME_VERSION_SORT_FIELDS, ThemeVersionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "published", "retired"} + + +def _list_spec( + theme_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + sort_by: str = Query(default="version_number"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> ThemeVersionListSpec: + if sort_by not in THEME_VERSION_SORT_FIELDS: + sort_by = "version_number" + return ThemeVersionListSpec( + theme_id=theme_id, + status=status_filter if status_filter in _STATUSES else None, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post( + "", response_model=ExperienceThemeVersionRead, status_code=status.HTTP_201_CREATED +) +async def create_theme_version( + body: ExperienceThemeVersionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_CREATE)), +): + return await ThemeLayoutCommands(db).create_theme_version( + tenant_id, body, actor=user + ) + + +@router.get("", response_model=ExperienceThemeVersionListResponse) +async def list_theme_versions( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: ThemeVersionListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(THEMES_VIEW)), +): + items, total = await ThemeLayoutQueries(db).list_theme_versions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceThemeVersionListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{version_id}", response_model=ExperienceThemeVersionRead) +async def get_theme_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(THEMES_VIEW)), +): + return await ThemeLayoutQueries(db).get_theme_version(tenant_id, version_id) + + +@router.patch("/{version_id}", response_model=ExperienceThemeVersionRead) +async def update_theme_version( + version_id: UUID, + body: ExperienceThemeVersionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_UPDATE)), +): + return await ThemeLayoutCommands(db).update_theme_version( + tenant_id, version_id, body, actor=user + ) + + +@router.post("/{version_id}/publish", response_model=ExperienceThemeVersionRead) +async def publish_theme_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)), +): + return await ThemeLayoutCommands(db).publish_theme_version( + tenant_id, version_id, actor=user + ) + + +@router.post("/{version_id}/retire", response_model=ExperienceThemeVersionRead) +async def retire_theme_version( + version_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)), +): + return await ThemeLayoutCommands(db).retire_theme_version( + tenant_id, version_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/themes.py b/backend/services/experience/app/api/v1/themes.py new file mode 100644 index 0000000..7bca8a1 --- /dev/null +++ b/backend/services/experience/app/api/v1/themes.py @@ -0,0 +1,146 @@ +"""Theme APIs — Phase 11.3.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.themes import ThemeLayoutCommands +from app.permissions.definitions import ( + THEMES_CREATE, + THEMES_DELETE, + THEMES_MANAGE, + THEMES_UPDATE, + THEMES_VIEW, +) +from app.queries.themes import ThemeLayoutQueries +from app.schemas.themes import ( + ExperienceThemeCreate, + ExperienceThemeListResponse, + ExperienceThemeRead, + ExperienceThemeUpdate, +) +from app.specifications.themes import THEME_SORT_FIELDS, ThemeListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + +_STATUSES = {"draft", "active", "deprecated", "archived"} +_DIRECTIONS = {"rtl", "ltr", "both"} + + +def _list_spec( + workspace_id: UUID | None = Query(default=None), + status_filter: str | None = Query(default=None, alias="status"), + direction_mode: str | None = Query(default=None), + q: str | None = Query(default=None, max_length=100), + sort_by: str = Query(default="created_at"), + sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"), +) -> ThemeListSpec: + if sort_by not in THEME_SORT_FIELDS: + sort_by = "created_at" + return ThemeListSpec( + workspace_id=workspace_id, + status=status_filter if status_filter in _STATUSES else None, + direction_mode=direction_mode if direction_mode in _DIRECTIONS else None, + q=q, + sort_by=sort_by, + sort_dir=sort_dir.lower(), + ) + + +@router.post("", response_model=ExperienceThemeRead, status_code=status.HTTP_201_CREATED) +async def create_theme( + body: ExperienceThemeCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_CREATE)), +): + return await ThemeLayoutCommands(db).create_theme(tenant_id, body, actor=user) + + +@router.get("", response_model=ExperienceThemeListResponse) +async def list_themes( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: ThemeListSpec = Depends(_list_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(THEMES_VIEW)), +): + items, total = await ThemeLayoutQueries(db).list_themes( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return ExperienceThemeListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{theme_id}", response_model=ExperienceThemeRead) +async def get_theme( + theme_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(THEMES_VIEW)), +): + return await ThemeLayoutQueries(db).get_theme(tenant_id, theme_id) + + +@router.patch("/{theme_id}", response_model=ExperienceThemeRead) +async def update_theme( + theme_id: UUID, + body: ExperienceThemeUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_UPDATE)), +): + return await ThemeLayoutCommands(db).update_theme( + tenant_id, theme_id, body, actor=user + ) + + +@router.post("/{theme_id}/activate", response_model=ExperienceThemeRead) +async def activate_theme( + theme_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)), +): + return await ThemeLayoutCommands(db).activate_theme(tenant_id, theme_id, actor=user) + + +@router.post("/{theme_id}/deprecate", response_model=ExperienceThemeRead) +async def deprecate_theme( + theme_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)), +): + return await ThemeLayoutCommands(db).deprecate_theme( + tenant_id, theme_id, actor=user + ) + + +@router.post("/{theme_id}/archive", response_model=ExperienceThemeRead) +async def archive_theme( + theme_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)), +): + return await ThemeLayoutCommands(db).archive_theme(tenant_id, theme_id, actor=user) + + +@router.post("/{theme_id}/delete", response_model=ExperienceThemeRead) +async def soft_delete_theme( + theme_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(THEMES_DELETE)), +): + return await ThemeLayoutCommands(db).soft_delete_theme( + tenant_id, theme_id, actor=user + ) diff --git a/backend/services/experience/app/api/v1/widget_definitions.py b/backend/services/experience/app/api/v1/widget_definitions.py new file mode 100644 index 0000000..fecaadb --- /dev/null +++ b/backend/services/experience/app/api/v1/widget_definitions.py @@ -0,0 +1,105 @@ +"""Widget definition APIs — Phase 11.9.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.integrations import IntegrationsCommands +from app.permissions.definitions import ( + WIDGETS_CREATE, + WIDGETS_DELETE, + WIDGETS_UPDATE, + WIDGETS_VIEW, +) +from app.queries.integrations import IntegrationsQueries +from app.schemas.integrations import ( + WidgetDefinitionCreate, + WidgetDefinitionListResponse, + WidgetDefinitionRead, + WidgetDefinitionUpdate, +) +from app.specifications.integrations import WIDGET_DEFINITION_SORT, WidgetDefinitionListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + widget_kind: str | None = None, + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return WidgetDefinitionListSpec( + workspace_id=workspace_id, + status=status_filter, + widget_kind=widget_kind, + q=q, + sort_by=sort_by if sort_by in WIDGET_DEFINITION_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=WidgetDefinitionRead, status_code=201) +async def create( + body: WidgetDefinitionCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WIDGETS_CREATE)), +): + return await IntegrationsCommands(db).create_widget_definition(tenant_id, body, actor=user) + + +@router.get("", response_model=WidgetDefinitionListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: WidgetDefinitionListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)), +): + items, total = await IntegrationsQueries(db).list_widget_definitions( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return WidgetDefinitionListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=WidgetDefinitionRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)), +): + return await IntegrationsQueries(db).get_widget_definition(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=WidgetDefinitionRead) +async def update( + entity_id: UUID, + body: WidgetDefinitionUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WIDGETS_UPDATE)), +): + return await IntegrationsCommands(db).update_widget_definition( + tenant_id, entity_id, body, actor=user + ) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WIDGETS_DELETE)), +): + await IntegrationsCommands(db).delete_widget_definition(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/widget_instances.py b/backend/services/experience/app/api/v1/widget_instances.py new file mode 100644 index 0000000..7eec498 --- /dev/null +++ b/backend/services/experience/app/api/v1/widget_instances.py @@ -0,0 +1,117 @@ +"""Widget instance APIs — Phase 11.9.""" +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, Response, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.commands.integrations import IntegrationsCommands +from app.permissions.definitions import ( + WIDGETS_CREATE, + WIDGETS_DELETE, + WIDGETS_UPDATE, + WIDGETS_VIEW, +) +from app.queries.integrations import IntegrationsQueries +from app.schemas.integrations import ( + WidgetInstanceCreate, + WidgetInstanceListResponse, + WidgetInstanceRead, + WidgetInstanceUpdate, +) +from app.specifications.integrations import WIDGET_INSTANCE_SORT, WidgetInstanceListSpec +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +def _spec( + workspace_id: UUID | None = None, + site_id: UUID | None = None, + status_filter: str | None = Query(None, alias="status"), + widget_kind: str | None = None, + q: str | None = Query(None, max_length=100), + sort_by: str = "created_at", + sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"), +): + return WidgetInstanceListSpec( + workspace_id=workspace_id, + site_id=site_id, + status=status_filter, + widget_kind=widget_kind, + q=q, + sort_by=sort_by if sort_by in WIDGET_INSTANCE_SORT else "created_at", + sort_dir=sort_dir, + ) + + +@router.post("", response_model=WidgetInstanceRead, status_code=201) +async def create( + body: WidgetInstanceCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WIDGETS_CREATE)), +): + return await IntegrationsCommands(db).create_widget_instance(tenant_id, body, actor=user) + + +@router.get("/by-embed/{embed_ref}", response_model=WidgetInstanceRead) +async def get_by_embed( + embed_ref: str, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)), +): + return await IntegrationsQueries(db).get_widget_instance_by_embed(tenant_id, embed_ref) + + +@router.get("", response_model=WidgetInstanceListResponse) +async def list_items( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + spec: WidgetInstanceListSpec = Depends(_spec), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)), +): + items, total = await IntegrationsQueries(db).list_widget_instances( + tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec + ) + return WidgetInstanceListResponse( + items=items, total=total, page=pagination.page, page_size=pagination.page_size + ) + + +@router.get("/{entity_id}", response_model=WidgetInstanceRead) +async def get( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)), +): + return await IntegrationsQueries(db).get_widget_instance(tenant_id, entity_id) + + +@router.patch("/{entity_id}", response_model=WidgetInstanceRead) +async def update( + entity_id: UUID, + body: WidgetInstanceUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WIDGETS_UPDATE)), +): + return await IntegrationsCommands(db).update_widget_instance( + tenant_id, entity_id, body, actor=user + ) + + +@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete( + entity_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WIDGETS_DELETE)), +): + await IntegrationsCommands(db).delete_widget_instance(tenant_id, entity_id, actor=user) + return Response(status_code=204) diff --git a/backend/services/experience/app/api/v1/workspaces.py b/backend/services/experience/app/api/v1/workspaces.py new file mode 100644 index 0000000..3422198 --- /dev/null +++ b/backend/services/experience/app/api/v1/workspaces.py @@ -0,0 +1,83 @@ +"""Experience organization APIs — Phase 11.0.""" +from __future__ import annotations + +from uuid import UUID + +from fastapi import APIRouter, Depends, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_db, get_pagination, require_tenant +from app.api.permissions import require_permissions +from app.permissions.definitions import ( + WORKSPACES_CREATE, + WORKSPACES_DELETE, + WORKSPACES_UPDATE, + WORKSPACES_VIEW, +) +from app.schemas.foundation import ( + ExperienceWorkspaceCreate, + ExperienceWorkspaceRead, + ExperienceWorkspaceUpdate, +) +from app.services.foundation import ExperienceWorkspaceService +from shared.pagination import PaginationParams +from shared.security import CurrentUser + +router = APIRouter() + + +@router.post("", response_model=ExperienceWorkspaceRead, status_code=status.HTTP_201_CREATED) +async def create_workspace( + body: ExperienceWorkspaceCreate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WORKSPACES_CREATE)), +): + return await ExperienceWorkspaceService(db).create(tenant_id, body, actor=user) + + +@router.get("", response_model=list[ExperienceWorkspaceRead]) +async def list_workspaces( + tenant_id: UUID = Depends(require_tenant), + pagination: PaginationParams = Depends(get_pagination), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WORKSPACES_VIEW)), +): + return await ExperienceWorkspaceService(db).list( + tenant_id, offset=pagination.offset, limit=pagination.page_size + ) + + +@router.get("/{workspace_id}", response_model=ExperienceWorkspaceRead) +async def get_workspace( + workspace_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + _user: CurrentUser = Depends(require_permissions(WORKSPACES_VIEW)), +): + return await ExperienceWorkspaceService(db).get(tenant_id, workspace_id) + + +@router.patch("/{workspace_id}", response_model=ExperienceWorkspaceRead) +async def update_workspace( + workspace_id: UUID, + body: ExperienceWorkspaceUpdate, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WORKSPACES_UPDATE)), +): + return await ExperienceWorkspaceService(db).update( + tenant_id, workspace_id, body, actor=user + ) + + +@router.post("/{workspace_id}/delete", response_model=ExperienceWorkspaceRead) +async def soft_delete_workspace( + workspace_id: UUID, + tenant_id: UUID = Depends(require_tenant), + db: AsyncSession = Depends(get_db), + user: CurrentUser = Depends(require_permissions(WORKSPACES_DELETE)), +): + return await ExperienceWorkspaceService(db).soft_delete( + tenant_id, workspace_id, actor=user + ) diff --git a/backend/services/experience/app/commands/__init__.py b/backend/services/experience/app/commands/__init__.py new file mode 100644 index 0000000..87a8296 --- /dev/null +++ b/backend/services/experience/app/commands/__init__.py @@ -0,0 +1,16 @@ +"""Experience commands package.""" +from app.commands.components import ComponentCommands +from app.commands.forms import FormsCommands +from app.commands.localization import LocalizationCommands +from app.commands.publishing import PublishingCommands +from app.commands.templates import TemplateCommands +from app.commands.themes import ThemeLayoutCommands + +__all__ = [ + "ComponentCommands", + "ThemeLayoutCommands", + "TemplateCommands", + "LocalizationCommands", + "FormsCommands", + "PublishingCommands", +] diff --git a/backend/services/experience/app/commands/ai_hooks.py b/backend/services/experience/app/commands/ai_hooks.py new file mode 100644 index 0000000..895dfab --- /dev/null +++ b/backend/services/experience/app/commands/ai_hooks.py @@ -0,0 +1,30 @@ +"""Command handlers for Phase 11.10 AI content hooks.""" +from app.services.ai_hooks import ExperienceAiHooksService + + +class AiHooksCommands: + def __init__(self, session): + self.service = ExperienceAiHooksService(session) + + async def create_hook_registration(self, tenant_id, body, *, actor=None): + return await self.service.create_hook_registration(tenant_id, body, actor=actor) + + async def update_hook_registration(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_hook_registration( + tenant_id, entity_id, body, actor=actor + ) + + async def delete_hook_registration(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_hook_registration(tenant_id, entity_id, actor=actor) + + async def create_dispatch(self, tenant_id, body, *, actor=None): + return await self.service.create_dispatch(tenant_id, body, actor=actor) + + async def update_dispatch(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_dispatch(tenant_id, entity_id, body, actor=actor) + + async def delete_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_dispatch(tenant_id, entity_id, actor=actor) + + async def generate_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self.service.generate_dispatch(tenant_id, entity_id, actor=actor) diff --git a/backend/services/experience/app/commands/analytics.py b/backend/services/experience/app/commands/analytics.py new file mode 100644 index 0000000..fb8619c --- /dev/null +++ b/backend/services/experience/app/commands/analytics.py @@ -0,0 +1,27 @@ +"""Command handlers for Phase 11.10 analytics.""" +from app.services.analytics import ExperienceAnalyticsService + + +class AnalyticsCommands: + def __init__(self, session): + self.service = ExperienceAnalyticsService(session) + + async def create_report_definition(self, tenant_id, body, *, actor=None): + return await self.service.create_report_definition(tenant_id, body, actor=actor) + + async def update_report_definition(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_report_definition( + tenant_id, entity_id, body, actor=actor + ) + + async def delete_report_definition(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_report_definition(tenant_id, entity_id, actor=actor) + + async def refresh_snapshot(self, tenant_id, body, *, actor=None): + return await self.service.refresh_snapshot(tenant_id, body, actor=actor) + + async def update_snapshot(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_snapshot(tenant_id, entity_id, body, actor=actor) + + async def delete_snapshot(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_snapshot(tenant_id, entity_id, actor=actor) diff --git a/backend/services/experience/app/commands/bundles.py b/backend/services/experience/app/commands/bundles.py new file mode 100644 index 0000000..4feaa99 --- /dev/null +++ b/backend/services/experience/app/commands/bundles.py @@ -0,0 +1,43 @@ +"""Command handlers for Phase 11.8.""" +from app.services.bundles import ExperienceBundlesService + + +class BundlesCommands: + def __init__(self, session): + self.service = ExperienceBundlesService(session) + + async def create_bundle_definition(self, tenant_id, body, *, actor=None): + return await self.service.create_bundle_definition(tenant_id, body, actor=actor) + + async def update_bundle_definition(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_bundle_definition(tenant_id, entity_id, body, actor=actor) + + async def delete_bundle_definition(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_bundle_definition(tenant_id, entity_id, actor=actor) + + async def create_tenant_bundle(self, tenant_id, body, *, actor=None): + return await self.service.create_tenant_bundle(tenant_id, body, actor=actor) + + async def update_tenant_bundle(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_tenant_bundle(tenant_id, entity_id, body, actor=actor) + + async def delete_tenant_bundle(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_tenant_bundle(tenant_id, entity_id, actor=actor) + + async def create_license_binding(self, tenant_id, body, *, actor=None): + return await self.service.create_license_binding(tenant_id, body, actor=actor) + + async def update_license_binding(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_license_binding(tenant_id, entity_id, body, actor=actor) + + async def delete_license_binding(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_license_binding(tenant_id, entity_id, actor=actor) + + async def create_feature_toggle(self, tenant_id, body, *, actor=None): + return await self.service.create_feature_toggle(tenant_id, body, actor=actor) + + async def update_feature_toggle(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_feature_toggle(tenant_id, entity_id, body, actor=actor) + + async def delete_feature_toggle(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_feature_toggle(tenant_id, entity_id, actor=actor) diff --git a/backend/services/experience/app/commands/components.py b/backend/services/experience/app/commands/components.py new file mode 100644 index 0000000..752707a --- /dev/null +++ b/backend/services/experience/app/commands/components.py @@ -0,0 +1,126 @@ +"""Component command handlers — Phase 11.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.components import ( + ExperienceComponentCreate, + ExperienceComponentUpdate, + ExperienceComponentVersionCreate, + ExperienceComponentVersionUpdate, + PageComponentPlacementCreate, + PageComponentPlacementUpdate, +) +from app.services.components import ExperienceComponentService +from shared.security import CurrentUser + + +class ComponentCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceComponentService(session) + + async def create_component( + self, tenant_id: UUID, body: ExperienceComponentCreate, *, actor: CurrentUser | None + ): + return await self.service.create_component(tenant_id, body, actor=actor) + + async def update_component( + self, + tenant_id: UUID, + component_id: UUID, + body: ExperienceComponentUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_component( + tenant_id, component_id, body, actor=actor + ) + + async def activate_component( + self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.activate_component( + tenant_id, component_id, actor=actor + ) + + async def deprecate_component( + self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.deprecate_component( + tenant_id, component_id, actor=actor + ) + + async def archive_component( + self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.archive_component( + tenant_id, component_id, actor=actor + ) + + async def soft_delete_component( + self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_component( + tenant_id, component_id, actor=actor + ) + + async def create_version( + self, + tenant_id: UUID, + body: ExperienceComponentVersionCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_version(tenant_id, body, actor=actor) + + async def update_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceComponentVersionUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_version( + tenant_id, version_id, body, actor=actor + ) + + async def publish_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.publish_version(tenant_id, version_id, actor=actor) + + async def retire_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.retire_version(tenant_id, version_id, actor=actor) + + async def create_placement( + self, + tenant_id: UUID, + body: PageComponentPlacementCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_placement(tenant_id, body, actor=actor) + + async def update_placement( + self, + tenant_id: UUID, + placement_id: UUID, + body: PageComponentPlacementUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_placement( + tenant_id, placement_id, body, actor=actor + ) + + async def soft_delete_placement( + self, tenant_id: UUID, placement_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_placement( + tenant_id, placement_id, actor=actor + ) diff --git a/backend/services/experience/app/commands/forms.py b/backend/services/experience/app/commands/forms.py new file mode 100644 index 0000000..aa25058 --- /dev/null +++ b/backend/services/experience/app/commands/forms.py @@ -0,0 +1,169 @@ +"""Forms / surveys / appointments command handlers — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.forms import ( + AppointmentBookingRefCreate, + AppointmentBookingRefUpdate, + ExperienceAppointmentPageCreate, + ExperienceAppointmentPageUpdate, + ExperienceFormCreate, + ExperienceFormUpdate, + ExperienceSurveyCreate, + ExperienceSurveyUpdate, + FormSubmissionCreate, + FormSubmissionUpdate, + SurveyResponseCreate, + SurveyResponseUpdate, +) +from app.services.forms import ExperienceFormsService +from shared.security import CurrentUser + + +class FormsCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceFormsService(session) + + async def create_form( + self, + tenant_id: UUID, + body: ExperienceFormCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_form(tenant_id, body, actor=actor) + + async def update_form( + self, + tenant_id: UUID, + form_id: UUID, + body: ExperienceFormUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_form(tenant_id, form_id, body, actor=actor) + + async def soft_delete_form( + self, tenant_id: UUID, form_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_form(tenant_id, form_id, actor=actor) + + async def create_submission( + self, + tenant_id: UUID, + body: FormSubmissionCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_submission(tenant_id, body, actor=actor) + + async def update_submission( + self, + tenant_id: UUID, + submission_id: UUID, + body: FormSubmissionUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_submission( + tenant_id, submission_id, body, actor=actor + ) + + async def create_survey( + self, + tenant_id: UUID, + body: ExperienceSurveyCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_survey(tenant_id, body, actor=actor) + + async def update_survey( + self, + tenant_id: UUID, + survey_id: UUID, + body: ExperienceSurveyUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_survey( + tenant_id, survey_id, body, actor=actor + ) + + async def soft_delete_survey( + self, tenant_id: UUID, survey_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_survey(tenant_id, survey_id, actor=actor) + + async def create_survey_response( + self, + tenant_id: UUID, + body: SurveyResponseCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_survey_response(tenant_id, body, actor=actor) + + async def update_survey_response( + self, + tenant_id: UUID, + response_id: UUID, + body: SurveyResponseUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_survey_response( + tenant_id, response_id, body, actor=actor + ) + + async def create_appointment_page( + self, + tenant_id: UUID, + body: ExperienceAppointmentPageCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_appointment_page(tenant_id, body, actor=actor) + + async def update_appointment_page( + self, + tenant_id: UUID, + page_id: UUID, + body: ExperienceAppointmentPageUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_appointment_page( + tenant_id, page_id, body, actor=actor + ) + + async def soft_delete_appointment_page( + self, tenant_id: UUID, page_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_appointment_page( + tenant_id, page_id, actor=actor + ) + + async def create_booking_ref( + self, + tenant_id: UUID, + body: AppointmentBookingRefCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_booking_ref(tenant_id, body, actor=actor) + + async def update_booking_ref( + self, + tenant_id: UUID, + booking_id: UUID, + body: AppointmentBookingRefUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_booking_ref( + tenant_id, booking_id, body, actor=actor + ) diff --git a/backend/services/experience/app/commands/integrations.py b/backend/services/experience/app/commands/integrations.py new file mode 100644 index 0000000..5af7c0b --- /dev/null +++ b/backend/services/experience/app/commands/integrations.py @@ -0,0 +1,68 @@ +"""Command handlers for Phase 11.9.""" +from app.services.integrations import ExperienceIntegrationsService + + +class IntegrationsCommands: + def __init__(self, session): + self.service = ExperienceIntegrationsService(session) + + async def create_consumer_connector_registration(self, tenant_id, body, *, actor=None): + return await self.service.create_consumer_connector_registration(tenant_id, body, actor=actor) + + async def update_consumer_connector_registration(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_consumer_connector_registration( + tenant_id, entity_id, body, actor=actor + ) + + async def delete_consumer_connector_registration(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_consumer_connector_registration( + tenant_id, entity_id, actor=actor + ) + + async def create_consumer_connector_dispatch(self, tenant_id, body, *, actor=None): + return await self.service.create_consumer_connector_dispatch(tenant_id, body, actor=actor) + + async def update_consumer_connector_dispatch(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_consumer_connector_dispatch( + tenant_id, entity_id, body, actor=actor + ) + + async def delete_consumer_connector_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_consumer_connector_dispatch( + tenant_id, entity_id, actor=actor + ) + + async def execute_consumer_connector_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self.service.execute_consumer_connector_dispatch( + tenant_id, entity_id, actor=actor + ) + + async def create_widget_definition(self, tenant_id, body, *, actor=None): + return await self.service.create_widget_definition(tenant_id, body, actor=actor) + + async def update_widget_definition(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_widget_definition(tenant_id, entity_id, body, actor=actor) + + async def delete_widget_definition(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_widget_definition(tenant_id, entity_id, actor=actor) + + async def create_widget_instance(self, tenant_id, body, *, actor=None): + return await self.service.create_widget_instance(tenant_id, body, actor=actor) + + async def update_widget_instance(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_widget_instance(tenant_id, entity_id, body, actor=actor) + + async def delete_widget_instance(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_widget_instance(tenant_id, entity_id, actor=actor) + + async def create_notify_dispatch(self, tenant_id, body, *, actor=None): + return await self.service.create_notify_dispatch(tenant_id, body, actor=actor) + + async def update_notify_dispatch(self, tenant_id, entity_id, body, *, actor=None): + return await self.service.update_notify_dispatch(tenant_id, entity_id, body, actor=actor) + + async def delete_notify_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self.service.delete_notify_dispatch(tenant_id, entity_id, actor=actor) + + async def send_notify_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self.service.send_notify_dispatch(tenant_id, entity_id, actor=actor) diff --git a/backend/services/experience/app/commands/localization.py b/backend/services/experience/app/commands/localization.py new file mode 100644 index 0000000..b79825e --- /dev/null +++ b/backend/services/experience/app/commands/localization.py @@ -0,0 +1,108 @@ +"""Localization & media-ref command handlers — Phase 11.5.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.localization import ( + ExperienceLocalizationUpdate, + ExperienceLocalizationUpsert, + ExperienceMediaRefCreate, + ExperienceMediaRefUpdate, + SiteLocaleBindingCreate, + SiteLocaleBindingUpdate, +) +from app.services.localization import ExperienceLocalizationService +from shared.security import CurrentUser + + +class LocalizationCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceLocalizationService(session) + + async def create_site_locale_binding( + self, + tenant_id: UUID, + body: SiteLocaleBindingCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_site_locale_binding( + tenant_id, body, actor=actor + ) + + async def update_site_locale_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: SiteLocaleBindingUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_site_locale_binding( + tenant_id, binding_id, body, actor=actor + ) + + async def soft_delete_site_locale_binding( + self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_site_locale_binding( + tenant_id, binding_id, actor=actor + ) + + async def upsert_localization( + self, + tenant_id: UUID, + body: ExperienceLocalizationUpsert, + *, + actor: CurrentUser | None, + ): + return await self.service.upsert_localization(tenant_id, body, actor=actor) + + async def update_localization( + self, + tenant_id: UUID, + localization_id: UUID, + body: ExperienceLocalizationUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_localization( + tenant_id, localization_id, body, actor=actor + ) + + async def soft_delete_localization( + self, tenant_id: UUID, localization_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_localization( + tenant_id, localization_id, actor=actor + ) + + async def create_media_ref( + self, + tenant_id: UUID, + body: ExperienceMediaRefCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_media_ref(tenant_id, body, actor=actor) + + async def update_media_ref( + self, + tenant_id: UUID, + media_ref_id: UUID, + body: ExperienceMediaRefUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_media_ref( + tenant_id, media_ref_id, body, actor=actor + ) + + async def soft_delete_media_ref( + self, tenant_id: UUID, media_ref_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_media_ref( + tenant_id, media_ref_id, actor=actor + ) diff --git a/backend/services/experience/app/commands/publishing.py b/backend/services/experience/app/commands/publishing.py new file mode 100644 index 0000000..51070ba --- /dev/null +++ b/backend/services/experience/app/commands/publishing.py @@ -0,0 +1,64 @@ +"""Publishing, SEO and PWA command handlers — Phase 11.7.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.publishing import ( + DomainEdgeBindingCreate, + DomainEdgeBindingUpdate, + PublishReleaseCreate, + PublishReleaseUpdate, + PwaManifestUpdate, + PwaManifestUpsert, + RobotsShellUpdate, + RobotsShellUpsert, + SeoMetadataUpdate, + SeoMetadataUpsert, + SitemapShellUpdate, + SitemapShellUpsert, +) +from app.services.publishing import ExperiencePublishingService +from shared.security import CurrentUser + + +class PublishingCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperiencePublishingService(session) + + async def create_publish_release(self, tenant_id: UUID, body: PublishReleaseCreate, *, actor: CurrentUser | None): + return await self.service.create_publish_release(tenant_id, body, actor=actor) + + async def update_publish_release(self, tenant_id: UUID, entity_id: UUID, body: PublishReleaseUpdate, *, actor: CurrentUser | None): + return await self.service.update_publish_release(tenant_id, entity_id, body, actor=actor) + + async def create_domain_edge_binding(self, tenant_id: UUID, body: DomainEdgeBindingCreate, *, actor: CurrentUser | None): + return await self.service.create_domain_edge_binding(tenant_id, body, actor=actor) + + async def update_domain_edge_binding(self, tenant_id: UUID, entity_id: UUID, body: DomainEdgeBindingUpdate, *, actor: CurrentUser | None): + return await self.service.update_domain_edge_binding(tenant_id, entity_id, body, actor=actor) + + async def upsert_seo(self, tenant_id: UUID, body: SeoMetadataUpsert, *, actor: CurrentUser | None): + return await self.service.upsert_seo(tenant_id, body, actor=actor) + + async def update_seo(self, tenant_id: UUID, entity_id: UUID, body: SeoMetadataUpdate, *, actor: CurrentUser | None): + return await self.service.update_seo(tenant_id, entity_id, body, actor=actor) + + async def upsert_sitemap(self, tenant_id: UUID, body: SitemapShellUpsert, *, actor: CurrentUser | None): + return await self.service.upsert_sitemap(tenant_id, body, actor=actor) + + async def update_sitemap(self, tenant_id: UUID, entity_id: UUID, body: SitemapShellUpdate, *, actor: CurrentUser | None): + return await self.service.update_sitemap(tenant_id, entity_id, body, actor=actor) + + async def upsert_robots(self, tenant_id: UUID, body: RobotsShellUpsert, *, actor: CurrentUser | None): + return await self.service.upsert_robots(tenant_id, body, actor=actor) + + async def update_robots(self, tenant_id: UUID, entity_id: UUID, body: RobotsShellUpdate, *, actor: CurrentUser | None): + return await self.service.update_robots(tenant_id, entity_id, body, actor=actor) + + async def upsert_pwa(self, tenant_id: UUID, body: PwaManifestUpsert, *, actor: CurrentUser | None): + return await self.service.upsert_pwa(tenant_id, body, actor=actor) + + async def update_pwa(self, tenant_id: UUID, entity_id: UUID, body: PwaManifestUpdate, *, actor: CurrentUser | None): + return await self.service.update_pwa(tenant_id, entity_id, body, actor=actor) diff --git a/backend/services/experience/app/commands/templates.py b/backend/services/experience/app/commands/templates.py new file mode 100644 index 0000000..7a2df54 --- /dev/null +++ b/backend/services/experience/app/commands/templates.py @@ -0,0 +1,102 @@ +"""Template command handlers — Phase 11.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.templates import ( + ExperienceTemplateCreate, + ExperienceTemplateUpdate, + ExperienceTemplateVersionCreate, + ExperienceTemplateVersionUpdate, + TemplateInstantiateRequest, +) +from app.services.templates import ExperienceTemplateService +from shared.security import CurrentUser + + +class TemplateCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceTemplateService(session) + + async def create_template( + self, tenant_id: UUID, body: ExperienceTemplateCreate, *, actor: CurrentUser | None + ): + return await self.service.create_template(tenant_id, body, actor=actor) + + async def update_template( + self, + tenant_id: UUID, + template_id: UUID, + body: ExperienceTemplateUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_template( + tenant_id, template_id, body, actor=actor + ) + + async def activate_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.activate_template(tenant_id, template_id, actor=actor) + + async def deprecate_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.deprecate_template( + tenant_id, template_id, actor=actor + ) + + async def archive_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.archive_template(tenant_id, template_id, actor=actor) + + async def soft_delete_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_template( + tenant_id, template_id, actor=actor + ) + + async def create_version( + self, + tenant_id: UUID, + body: ExperienceTemplateVersionCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_version(tenant_id, body, actor=actor) + + async def update_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceTemplateVersionUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_version( + tenant_id, version_id, body, actor=actor + ) + + async def publish_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.publish_version(tenant_id, version_id, actor=actor) + + async def retire_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.retire_version(tenant_id, version_id, actor=actor) + + async def instantiate( + self, + tenant_id: UUID, + body: TemplateInstantiateRequest, + *, + actor: CurrentUser | None, + ): + return await self.service.instantiate(tenant_id, body, actor=actor) diff --git a/backend/services/experience/app/commands/themes.py b/backend/services/experience/app/commands/themes.py new file mode 100644 index 0000000..5341b43 --- /dev/null +++ b/backend/services/experience/app/commands/themes.py @@ -0,0 +1,240 @@ +"""Theme & layout command handlers — Phase 11.3 (versioned).""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.schemas.themes import ( + ExperienceLayoutCreate, + ExperienceLayoutUpdate, + ExperienceLayoutVersionCreate, + ExperienceLayoutVersionUpdate, + ExperienceThemeCreate, + ExperienceThemeUpdate, + ExperienceThemeVersionCreate, + ExperienceThemeVersionUpdate, + PageLayoutBindingCreate, + PageLayoutBindingUpdate, + SiteThemeBindingCreate, + SiteThemeBindingUpdate, +) +from app.services.themes import ExperienceThemeService +from shared.security import CurrentUser + + +class ThemeLayoutCommands: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceThemeService(session) + + # --- Themes --- + + async def create_theme( + self, tenant_id: UUID, body: ExperienceThemeCreate, *, actor: CurrentUser | None + ): + return await self.service.create_theme(tenant_id, body, actor=actor) + + async def update_theme( + self, + tenant_id: UUID, + theme_id: UUID, + body: ExperienceThemeUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_theme(tenant_id, theme_id, body, actor=actor) + + async def activate_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.activate_theme(tenant_id, theme_id, actor=actor) + + async def deprecate_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.deprecate_theme(tenant_id, theme_id, actor=actor) + + async def archive_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.archive_theme(tenant_id, theme_id, actor=actor) + + async def soft_delete_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_theme(tenant_id, theme_id, actor=actor) + + # --- Theme Versions --- + + async def create_theme_version( + self, + tenant_id: UUID, + body: ExperienceThemeVersionCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_theme_version(tenant_id, body, actor=actor) + + async def update_theme_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceThemeVersionUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_theme_version( + tenant_id, version_id, body, actor=actor + ) + + async def publish_theme_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.publish_theme_version( + tenant_id, version_id, actor=actor + ) + + async def retire_theme_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.retire_theme_version( + tenant_id, version_id, actor=actor + ) + + # --- Layouts --- + + async def create_layout( + self, tenant_id: UUID, body: ExperienceLayoutCreate, *, actor: CurrentUser | None + ): + return await self.service.create_layout(tenant_id, body, actor=actor) + + async def update_layout( + self, + tenant_id: UUID, + layout_id: UUID, + body: ExperienceLayoutUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_layout( + tenant_id, layout_id, body, actor=actor + ) + + async def activate_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.activate_layout(tenant_id, layout_id, actor=actor) + + async def deprecate_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.deprecate_layout(tenant_id, layout_id, actor=actor) + + async def archive_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.archive_layout(tenant_id, layout_id, actor=actor) + + async def soft_delete_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_layout( + tenant_id, layout_id, actor=actor + ) + + # --- Layout Versions --- + + async def create_layout_version( + self, + tenant_id: UUID, + body: ExperienceLayoutVersionCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.create_layout_version(tenant_id, body, actor=actor) + + async def update_layout_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceLayoutVersionUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_layout_version( + tenant_id, version_id, body, actor=actor + ) + + async def publish_layout_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.publish_layout_version( + tenant_id, version_id, actor=actor + ) + + async def retire_layout_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.retire_layout_version( + tenant_id, version_id, actor=actor + ) + + # --- Site Theme Bindings --- + + async def assign_site_theme( + self, + tenant_id: UUID, + body: SiteThemeBindingCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.assign_site_theme(tenant_id, body, actor=actor) + + async def update_site_theme_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: SiteThemeBindingUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_site_theme_binding( + tenant_id, binding_id, body, actor=actor + ) + + async def unassign_site_theme( + self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_site_theme_binding( + tenant_id, binding_id, actor=actor + ) + + # --- Page Layout Bindings --- + + async def assign_page_layout( + self, + tenant_id: UUID, + body: PageLayoutBindingCreate, + *, + actor: CurrentUser | None, + ): + return await self.service.assign_page_layout(tenant_id, body, actor=actor) + + async def update_page_layout_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: PageLayoutBindingUpdate, + *, + actor: CurrentUser | None, + ): + return await self.service.update_page_layout_binding( + tenant_id, binding_id, body, actor=actor + ) + + async def unassign_page_layout( + self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None + ): + return await self.service.soft_delete_page_layout_binding( + tenant_id, binding_id, actor=actor + ) diff --git a/backend/services/experience/app/core/config.py b/backend/services/experience/app/core/config.py new file mode 100644 index 0000000..68825c0 --- /dev/null +++ b/backend/services/experience/app/core/config.py @@ -0,0 +1,75 @@ +"""Experience Service settings.""" +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore", + ) + + environment: Literal["development", "staging", "production", "test"] = "development" + debug: bool = True + log_level: str = "INFO" + service_name: str = Field( + default="experience-service", validation_alias="EXPERIENCE_SERVICE_NAME" + ) + api_v1_prefix: str = "/api/v1" + + database_url: str = Field( + default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/experience_db", + validation_alias="EXPERIENCE_DATABASE_URL", + ) + database_url_sync: str = Field( + default="postgresql+psycopg://superapp:superapp_password@localhost:5432/experience_db", + validation_alias="EXPERIENCE_DATABASE_URL_SYNC", + ) + + core_service_url: str = Field( + default="http://localhost:8000", validation_alias="CORE_SERVICE_URL" + ) + + keycloak_enabled: bool = True + keycloak_server_url: str = Field( + default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL" + ) + keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL") + keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM") + + jwt_algorithm: str = "RS256" + jwt_audience: str = "account" + jwt_verify_signature: bool = True + auth_required: bool = True + + cors_origins: str = Field( + default="http://localhost:3000,http://127.0.0.1:3000", + validation_alias="CORS_ORIGINS", + ) + + @property + def keycloak_public_base(self) -> str: + return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/") + + @property + def keycloak_public_realm_url(self) -> str: + return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}" + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() diff --git a/backend/services/experience/app/core/database.py b/backend/services/experience/app/core/database.py new file mode 100644 index 0000000..e44c490 --- /dev/null +++ b/backend/services/experience/app/core/database.py @@ -0,0 +1,27 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.pool import StaticPool + +from app.core.config import settings + + +class Base(DeclarativeBase): + pass + + +_engine_kwargs: dict = {"pool_pre_ping": True, "future": True} +if settings.database_url.startswith("sqlite"): + _engine_kwargs["poolclass"] = StaticPool + _engine_kwargs["connect_args"] = {"check_same_thread": False} + +engine = create_async_engine(settings.database_url, **_engine_kwargs) +AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db(): + async with AsyncSessionLocal() as session: + try: + yield session + except Exception: + await session.rollback() + raise diff --git a/backend/services/experience/app/core/logging.py b/backend/services/experience/app/core/logging.py new file mode 100644 index 0000000..cc6f49a --- /dev/null +++ b/backend/services/experience/app/core/logging.py @@ -0,0 +1,14 @@ +import logging +import sys + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig( + level=getattr(logging, level.upper(), logging.INFO), + format="%(asctime)s %(levelname)s [%(name)s] %(message)s", + stream=sys.stdout, + ) + + +def get_logger(name: str) -> logging.Logger: + return logging.getLogger(name) diff --git a/backend/services/experience/app/core/security.py b/backend/services/experience/app/core/security.py new file mode 100644 index 0000000..f2271cd --- /dev/null +++ b/backend/services/experience/app/core/security.py @@ -0,0 +1,49 @@ +"""JWT authentication dependencies for Experience service.""" +from __future__ import annotations + +from functools import lru_cache + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from app.core.config import settings +from shared.auth import JWTSettings, JWTValidator +from shared.exceptions import UnauthorizedError +from shared.security import CurrentUser + +_bearer = HTTPBearer(auto_error=False) + + +@lru_cache +def get_jwt_validator() -> JWTValidator: + return JWTValidator( + JWTSettings( + keycloak_enabled=settings.keycloak_enabled, + keycloak_server_url=settings.keycloak_server_url, + keycloak_realm=settings.keycloak_realm, + jwt_algorithm=settings.jwt_algorithm, + jwt_audience=settings.jwt_audience, + jwt_verify_signature=settings.jwt_verify_signature, + jwt_issuer=settings.keycloak_public_realm_url, + ) + ) + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + raise UnauthorizedError("توکن احراز هویت ارائه نشده است") + return await get_jwt_validator().validate(credentials.credentials) + + +async def get_optional_user( + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser | None: + if not settings.auth_required: + return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"]) + if credentials is None or not credentials.credentials: + return None + return await get_jwt_validator().validate(credentials.credentials) diff --git a/backend/services/experience/app/events/publisher.py b/backend/services/experience/app/events/publisher.py new file mode 100644 index 0000000..876f45d --- /dev/null +++ b/backend/services/experience/app/events/publisher.py @@ -0,0 +1,63 @@ +"""Experience event publisher — builds EventEnvelope contracts; no bus consumers yet.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID, uuid4 + +from shared.events import EventEnvelope + +from app.core.config import settings +from app.events.types import ExperienceEventType + + +class EventPublisher(Protocol): + def publish( + self, + *, + event_type: ExperienceEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: ... + + +class InMemoryEventPublisher: + """Records published envelopes for tests and local verification.""" + + def __init__(self) -> None: + self.published: list[EventEnvelope] = [] + + def publish( + self, + *, + event_type: ExperienceEventType, + aggregate_type: str, + aggregate_id: UUID, + tenant_id: UUID, + payload: dict[str, Any] | None = None, + ) -> EventEnvelope: + envelope = EventEnvelope( + event_id=uuid4(), + event_type=event_type.value, + aggregate_type=aggregate_type, + aggregate_id=str(aggregate_id), + tenant_id=tenant_id, + source_service=settings.service_name, + payload=payload or {}, + ) + self.published.append(envelope) + return envelope + + +_default_publisher = InMemoryEventPublisher() + + +def get_event_publisher() -> InMemoryEventPublisher: + return _default_publisher + + +def reset_event_publisher() -> InMemoryEventPublisher: + global _default_publisher + _default_publisher = InMemoryEventPublisher() + return _default_publisher diff --git a/backend/services/experience/app/events/types.py b/backend/services/experience/app/events/types.py new file mode 100644 index 0000000..05aa1e1 --- /dev/null +++ b/backend/services/experience/app/events/types.py @@ -0,0 +1,158 @@ +"""Experience event type contracts (publish-only).""" +from __future__ import annotations + +import enum + + +class ExperienceEventType(str, enum.Enum): + WORKSPACE_CREATED = "experience.workspace.created" + WORKSPACE_UPDATED = "experience.workspace.updated" + LOCALE_PROFILE_CREATED = "experience.locale_profile.created" + LOCALE_PROFILE_UPDATED = "experience.locale_profile.updated" + EXTERNAL_PROVIDER_REGISTERED = "experience.external_provider.registered" + EXTERNAL_PROVIDER_UPDATED = "experience.external_provider.updated" + RENDER_ENGINE_REGISTERED = "experience.render_engine.registered" + RENDER_ENGINE_UPDATED = "experience.render_engine.updated" + CONFIGURATION_CREATED = "experience.configuration.created" + CONFIGURATION_UPDATED = "experience.configuration.updated" + SETTING_UPSERTED = "experience.setting.upserted" + SITE_CREATED = "experience.site.created" + SITE_UPDATED = "experience.site.updated" + SITE_PUBLISHED = "experience.site.published" + SITE_UNPUBLISHED = "experience.site.unpublished" + SITE_ARCHIVED = "experience.site.archived" + PAGE_CREATED = "experience.page.created" + PAGE_UPDATED = "experience.page.updated" + PAGE_PUBLISHED = "experience.page.published" + PAGE_UNPUBLISHED = "experience.page.unpublished" + PAGE_ARCHIVED = "experience.page.archived" + SITE_DOMAIN_BOUND = "experience.site_domain.bound" + SITE_DOMAIN_UPDATED = "experience.site_domain.updated" + COMPONENT_CREATED = "experience.component.created" + COMPONENT_UPDATED = "experience.component.updated" + COMPONENT_ACTIVATED = "experience.component.activated" + COMPONENT_DEPRECATED = "experience.component.deprecated" + COMPONENT_ARCHIVED = "experience.component.archived" + COMPONENT_VERSION_CREATED = "experience.component_version.created" + COMPONENT_VERSION_UPDATED = "experience.component_version.updated" + COMPONENT_VERSION_PUBLISHED = "experience.component_version.published" + COMPONENT_VERSION_RETIRED = "experience.component_version.retired" + PAGE_COMPONENT_PLACED = "experience.page_component.placed" + PAGE_COMPONENT_UPDATED = "experience.page_component.updated" + PAGE_COMPONENT_REMOVED = "experience.page_component.removed" + THEME_CREATED = "experience.theme.created" + THEME_UPDATED = "experience.theme.updated" + THEME_ACTIVATED = "experience.theme.activated" + THEME_DEPRECATED = "experience.theme.deprecated" + THEME_ARCHIVED = "experience.theme.archived" + THEME_VERSION_CREATED = "experience.theme_version.created" + THEME_VERSION_UPDATED = "experience.theme_version.updated" + THEME_VERSION_PUBLISHED = "experience.theme_version.published" + THEME_VERSION_RETIRED = "experience.theme_version.retired" + LAYOUT_CREATED = "experience.layout.created" + LAYOUT_UPDATED = "experience.layout.updated" + LAYOUT_ACTIVATED = "experience.layout.activated" + LAYOUT_DEPRECATED = "experience.layout.deprecated" + LAYOUT_ARCHIVED = "experience.layout.archived" + LAYOUT_VERSION_CREATED = "experience.layout_version.created" + LAYOUT_VERSION_UPDATED = "experience.layout_version.updated" + LAYOUT_VERSION_PUBLISHED = "experience.layout_version.published" + LAYOUT_VERSION_RETIRED = "experience.layout_version.retired" + SITE_THEME_ASSIGNED = "experience.site_theme.assigned" + SITE_THEME_REPLACED = "experience.site_theme.replaced" + SITE_THEME_UNASSIGNED = "experience.site_theme.unassigned" + SITE_THEME_CLEARED = "experience.site_theme.cleared" + PAGE_LAYOUT_ASSIGNED = "experience.page_layout.assigned" + PAGE_LAYOUT_REPLACED = "experience.page_layout.replaced" + PAGE_LAYOUT_UNASSIGNED = "experience.page_layout.unassigned" + PAGE_LAYOUT_CLEARED = "experience.page_layout.cleared" + TEMPLATE_CREATED = "experience.template.created" + TEMPLATE_UPDATED = "experience.template.updated" + TEMPLATE_ACTIVATED = "experience.template.activated" + TEMPLATE_DEPRECATED = "experience.template.deprecated" + TEMPLATE_ARCHIVED = "experience.template.archived" + TEMPLATE_VERSION_CREATED = "experience.template_version.created" + TEMPLATE_VERSION_UPDATED = "experience.template_version.updated" + TEMPLATE_VERSION_PUBLISHED = "experience.template_version.published" + TEMPLATE_VERSION_RETIRED = "experience.template_version.retired" + TEMPLATE_INSTANTIATED = "experience.template.instantiated" + SITE_LOCALE_ASSIGNED = "experience.site_locale.assigned" + SITE_LOCALE_UPDATED = "experience.site_locale.updated" + SITE_LOCALE_REPLACED = "experience.site_locale.replaced" + SITE_LOCALE_UNASSIGNED = "experience.site_locale.unassigned" + LOCALIZATION_UPSERTED = "experience.localization.upserted" + LOCALIZATION_UPDATED = "experience.localization.updated" + LOCALIZATION_ARCHIVED = "experience.localization.archived" + MEDIA_REF_CREATED = "experience.media_ref.created" + MEDIA_REF_UPDATED = "experience.media_ref.updated" + MEDIA_REF_ARCHIVED = "experience.media_ref.archived" + FORM_CREATED = "experience.form.created" + FORM_UPDATED = "experience.form.updated" + FORM_ARCHIVED = "experience.form.archived" + FORM_SUBMISSION_RECEIVED = "experience.form_submission.received" + FORM_SUBMISSION_UPDATED = "experience.form_submission.updated" + SURVEY_CREATED = "experience.survey.created" + SURVEY_UPDATED = "experience.survey.updated" + SURVEY_ARCHIVED = "experience.survey.archived" + SURVEY_RESPONSE_RECEIVED = "experience.survey_response.received" + SURVEY_RESPONSE_UPDATED = "experience.survey_response.updated" + APPOINTMENT_PAGE_CREATED = "experience.appointment_page.created" + APPOINTMENT_PAGE_UPDATED = "experience.appointment_page.updated" + APPOINTMENT_PAGE_ARCHIVED = "experience.appointment_page.archived" + APPOINTMENT_BOOKING_REF_CREATED = "experience.appointment_booking_ref.created" + APPOINTMENT_BOOKING_REF_UPDATED = "experience.appointment_booking_ref.updated" + PUBLISH_RELEASE_COMPLETED = "experience.publish_release.completed" + PUBLISH_RELEASE_UPDATED = "experience.publish_release.updated" + DOMAIN_EDGE_BOUND = "experience.domain_edge.bound" + DOMAIN_EDGE_UPDATED = "experience.domain_edge.updated" + SEO_METADATA_UPSERTED = "experience.seo_metadata.upserted" + SEO_METADATA_UPDATED = "experience.seo_metadata.updated" + SITEMAP_UPSERTED = "experience.sitemap.upserted" + SITEMAP_UPDATED = "experience.sitemap.updated" + ROBOTS_UPSERTED = "experience.robots.upserted" + ROBOTS_UPDATED = "experience.robots.updated" + PWA_MANIFEST_UPSERTED = "experience.pwa_manifest.upserted" + PWA_MANIFEST_UPDATED = "experience.pwa_manifest.updated" + BUNDLE_DEFINITION_CREATED = "experience.bundle_definition.created" + BUNDLE_DEFINITION_UPDATED = "experience.bundle_definition.updated" + BUNDLE_DEFINITION_DELETED = "experience.bundle_definition.deleted" + TENANT_BUNDLE_CREATED = "experience.tenant_bundle.created" + TENANT_BUNDLE_UPDATED = "experience.tenant_bundle.updated" + TENANT_BUNDLE_DELETED = "experience.tenant_bundle.deleted" + LICENSE_BINDING_CREATED = "experience.license_binding.created" + LICENSE_BINDING_UPDATED = "experience.license_binding.updated" + LICENSE_BINDING_DELETED = "experience.license_binding.deleted" + FEATURE_TOGGLE_CREATED = "experience.feature_toggle.created" + FEATURE_TOGGLE_UPDATED = "experience.feature_toggle.updated" + FEATURE_TOGGLE_DELETED = "experience.feature_toggle.deleted" + CONSUMER_CONNECTOR_REGISTRATION_CREATED = "experience.consumer_connector_registration.created" + CONSUMER_CONNECTOR_REGISTRATION_UPDATED = "experience.consumer_connector_registration.updated" + CONSUMER_CONNECTOR_REGISTRATION_DELETED = "experience.consumer_connector_registration.deleted" + CONSUMER_CONNECTOR_DISPATCH_CREATED = "experience.consumer_connector_dispatch.created" + CONSUMER_CONNECTOR_DISPATCH_UPDATED = "experience.consumer_connector_dispatch.updated" + CONSUMER_CONNECTOR_DISPATCH_DELETED = "experience.consumer_connector_dispatch.deleted" + CONSUMER_CONNECTOR_DISPATCH_EXECUTED = "experience.consumer_connector_dispatch.executed" + WIDGET_DEFINITION_CREATED = "experience.widget_definition.created" + WIDGET_DEFINITION_UPDATED = "experience.widget_definition.updated" + WIDGET_DEFINITION_DELETED = "experience.widget_definition.deleted" + WIDGET_INSTANCE_CREATED = "experience.widget_instance.created" + WIDGET_INSTANCE_UPDATED = "experience.widget_instance.updated" + WIDGET_INSTANCE_DELETED = "experience.widget_instance.deleted" + NOTIFY_DISPATCH_CREATED = "experience.notify_dispatch.created" + NOTIFY_DISPATCH_UPDATED = "experience.notify_dispatch.updated" + NOTIFY_DISPATCH_DELETED = "experience.notify_dispatch.deleted" + NOTIFY_DISPATCH_SENT = "experience.notify_dispatch.sent" + ANALYTICS_REPORT_DEFINITION_CREATED = "experience.analytics_report_definition.created" + ANALYTICS_REPORT_DEFINITION_UPDATED = "experience.analytics_report_definition.updated" + ANALYTICS_REPORT_DEFINITION_DELETED = "experience.analytics_report_definition.deleted" + ANALYTICS_SNAPSHOT_CREATED = "experience.analytics_snapshot.created" + ANALYTICS_SNAPSHOT_UPDATED = "experience.analytics_snapshot.updated" + ANALYTICS_SNAPSHOT_DELETED = "experience.analytics_snapshot.deleted" + ANALYTICS_SNAPSHOT_REFRESHED = "experience.analytics_snapshot.refreshed" + AI_CONTENT_HOOK_REGISTRATION_CREATED = "experience.ai_content_hook_registration.created" + AI_CONTENT_HOOK_REGISTRATION_UPDATED = "experience.ai_content_hook_registration.updated" + AI_CONTENT_HOOK_REGISTRATION_DELETED = "experience.ai_content_hook_registration.deleted" + AI_CONTENT_DISPATCH_CREATED = "experience.ai_content_dispatch.created" + AI_CONTENT_DISPATCH_UPDATED = "experience.ai_content_dispatch.updated" + AI_CONTENT_DISPATCH_DELETED = "experience.ai_content_dispatch.deleted" + AI_CONTENT_DISPATCH_GENERATED = "experience.ai_content_dispatch.generated" diff --git a/backend/services/experience/app/main.py b/backend/services/experience/app/main.py new file mode 100644 index 0000000..e361f8d --- /dev/null +++ b/backend/services/experience/app/main.py @@ -0,0 +1,69 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse + +from app import __version__ +from app.api.v1 import api_router +from app.api.v1 import health +from app.core.config import settings +from app.core.logging import configure_logging, get_logger +from app.middlewares.tenant import TenantHeaderMiddleware +from shared.exceptions import AppError +from shared.responses import ErrorDetail, ErrorResponse + +configure_logging(settings.log_level) +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logger.info("service_starting", extra={"service": settings.service_name}) + yield + + +def create_app() -> FastAPI: + app = FastAPI( + title="Experience Platform", + version=__version__, + description="Torbat Pages — Experience Platform (Phase 11.9 Consumer Integrations & Widgets)", + lifespan=lifespan, + ) + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir", + allow_credentials=False, + allow_methods=["*"], + allow_headers=["*"], + ) + app.add_middleware(TenantHeaderMiddleware) + + @app.exception_handler(AppError) + async def app_error_handler(request: Request, exc: AppError) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content=ErrorResponse( + error=ErrorDetail( + code=exc.error_code, message=exc.message, details=exc.details + ) + ).model_dump(), + ) + + @app.exception_handler(Exception) + async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse: + logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc) + return JSONResponse( + status_code=500, + content=ErrorResponse( + error=ErrorDetail(code="internal_error", message="خطای داخلی سرور") + ).model_dump(), + ) + + app.include_router(health.router) + app.include_router(api_router, prefix=settings.api_v1_prefix) + return app + + +app = create_app() diff --git a/backend/services/experience/app/middlewares/tenant.py b/backend/services/experience/app/middlewares/tenant.py new file mode 100644 index 0000000..4b08726 --- /dev/null +++ b/backend/services/experience/app/middlewares/tenant.py @@ -0,0 +1,24 @@ +"""Tenant header resolution middleware.""" +from __future__ import annotations + +from uuid import UUID + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID + + +class TenantHeaderMiddleware(BaseHTTPMiddleware): + """Resolve tenant from X-Tenant-ID header only (microservice pattern).""" + + async def dispatch(self, request: Request, call_next) -> Response: + setattr(request.state, STATE_TENANT_ID, None) + raw = request.headers.get(HEADER_TENANT_ID) + if raw: + try: + setattr(request.state, STATE_TENANT_ID, UUID(raw)) + except ValueError: + pass + return await call_next(request) diff --git a/backend/services/experience/app/models/__init__.py b/backend/services/experience/app/models/__init__.py new file mode 100644 index 0000000..9202964 --- /dev/null +++ b/backend/services/experience/app/models/__init__.py @@ -0,0 +1,77 @@ +"""Import all models for Alembic metadata discovery.""" +from app.models.foundation import ( # noqa: F401 + ExperienceAuditLog, + ExperienceConfiguration, + ExperienceLocaleProfile, + ExperienceWorkspace, + ExperiencePermission, + ExperienceRole, + ExperienceSetting, + ExternalProviderConfig, + RenderEngineRegistration, +) +from app.models.sites import ( # noqa: F401 + ExperiencePage, + ExperienceSite, + ExperienceSiteDomain, +) +from app.models.components import ( # noqa: F401 + ExperienceComponent, + ExperienceComponentVersion, + PageComponentPlacement, +) +from app.models.themes import ( # noqa: F401 + ExperienceLayout, + ExperienceLayoutVersion, + ExperienceTheme, + ExperienceThemeVersion, + PageLayoutBinding, + SiteThemeBinding, +) +from app.models.templates import ( # noqa: F401 + ExperienceTemplate, + ExperienceTemplateVersion, + TemplateInstantiation, +) +from app.models.localization import ( # noqa: F401 + ExperienceLocalization, + ExperienceMediaRef, + SiteLocaleBinding, +) +from app.models.forms import ( # noqa: F401 + AppointmentBookingRef, + ExperienceAppointmentPage, + ExperienceForm, + ExperienceSurvey, + FormSubmission, + SurveyResponse, +) +from app.models.publishing import ( # noqa: F401 + DomainEdgeBinding, + PublishRelease, + PwaManifest, + RobotsShell, + SeoMetadata, + SitemapShell, +) +from app.models.bundles import ( # noqa: F401 + ExperienceBundleDefinition, + ExperienceFeatureToggle, + ExperienceLicenseBinding, + ExperienceTenantBundle, +) +from app.models.integrations import ( # noqa: F401 + ExperienceConsumerConnectorDispatch, + ExperienceConsumerConnectorRegistration, + ExperienceNotifyDispatch, + ExperienceWidgetDefinition, + ExperienceWidgetInstance, +) +from app.models.analytics import ( # noqa: F401 + ExperienceAnalyticsReportDefinition, + ExperienceAnalyticsSnapshot, +) +from app.models.ai_hooks import ( # noqa: F401 + ExperienceAiContentDispatch, + ExperienceAiContentHookRegistration, +) diff --git a/backend/services/experience/app/models/ai_hooks.py b/backend/services/experience/app/models/ai_hooks.py new file mode 100644 index 0000000..a242199 --- /dev/null +++ b/backend/services/experience/app/models/ai_hooks.py @@ -0,0 +1,119 @@ +"""AI content hook aggregates — Phase 11.10. + +Experience stores hook registrations and dispatches only. Model inference +remains on the Enterprise AI Platform via AIProvider contracts. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + AiContentDispatchStatus, + AiContentHookStatus, + AiContentTaskKind, +) + + +class ExperienceAiContentHookRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_ai_content_hook_registrations" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + task_kind: Mapped[AiContentTaskKind] = mapped_column(nullable=False) + external_ai_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[AiContentHookStatus] = mapped_column( + default=AiContentHookStatus.DRAFT, nullable=False + ) + config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "code", + name="uq_experience_ai_content_hook_registrations_tenant_code", + ), + Index( + "ix_experience_ai_content_hook_registrations_workspace", + "tenant_id", + "workspace_id", + ), + Index( + "ix_experience_ai_content_hook_registrations_status", + "tenant_id", + "status", + ), + Index( + "ix_experience_ai_content_hook_registrations_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceAiContentDispatch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_ai_content_dispatches" + + registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + task: Mapped[str] = mapped_column(String(100), nullable=False) + context_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[AiContentDispatchStatus] = mapped_column( + default=AiContentDispatchStatus.PENDING, nullable=False + ) + response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + generated_content_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + + __table_args__ = ( + Index( + "ix_experience_ai_content_dispatches_registration", + "tenant_id", + "registration_id", + ), + Index( + "ix_experience_ai_content_dispatches_status", + "tenant_id", + "status", + ), + Index( + "ix_experience_ai_content_dispatches_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) diff --git a/backend/services/experience/app/models/analytics.py b/backend/services/experience/app/models/analytics.py new file mode 100644 index 0000000..ee75af4 --- /dev/null +++ b/backend/services/experience/app/models/analytics.py @@ -0,0 +1,109 @@ +"""Analytics aggregates — Phase 11.10. + +Report definitions describe which local metric keys to compute; snapshots +store point-in-time metrics payloads from simple local SQL counts only. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import AnalyticsReportStatus, AnalyticsSnapshotStatus, GUID + + +class ExperienceAnalyticsReportDefinition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_analytics_report_definitions" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + metric_keys: Mapped[list] = mapped_column(JSON, nullable=False) + filters: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[AnalyticsReportStatus] = mapped_column( + default=AnalyticsReportStatus.DRAFT, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "code", + name="uq_experience_analytics_report_definitions_tenant_code", + ), + Index( + "ix_experience_analytics_report_definitions_workspace", + "tenant_id", + "workspace_id", + ), + Index( + "ix_experience_analytics_report_definitions_status", + "tenant_id", + "status", + ), + Index( + "ix_experience_analytics_report_definitions_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceAnalyticsSnapshot( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_analytics_snapshots" + + report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + period_start: Mapped[str | None] = mapped_column(String(32), nullable=True) + period_end: Mapped[str | None] = mapped_column(String(32), nullable=True) + metrics: Mapped[dict] = mapped_column(JSON, nullable=False) + status: Mapped[AnalyticsSnapshotStatus] = mapped_column( + default=AnalyticsSnapshotStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + Index( + "ix_experience_analytics_snapshots_report", + "tenant_id", + "report_id", + ), + Index( + "ix_experience_analytics_snapshots_workspace", + "tenant_id", + "workspace_id", + ), + Index( + "ix_experience_analytics_snapshots_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) diff --git a/backend/services/experience/app/models/base.py b/backend/services/experience/app/models/base.py new file mode 100644 index 0000000..873bc66 --- /dev/null +++ b/backend/services/experience/app/models/base.py @@ -0,0 +1,51 @@ +"""Shared model mixins.""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Integer, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.types import GUID + + +class UUIDPrimaryKeyMixin: + id: Mapped[uuid.UUID] = mapped_column( + GUID(), primary_key=True, default=uuid.uuid4 + ) + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class TenantMixin: + """Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded.""" + + tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + + +class SoftDeleteMixin: + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class ActorAuditMixin: + created_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + + +class OptimisticLockMixin: + version: Mapped[int] = mapped_column(Integer, default=1, nullable=False) diff --git a/backend/services/experience/app/models/bundles.py b/backend/services/experience/app/models/bundles.py new file mode 100644 index 0000000..176a2f9 --- /dev/null +++ b/backend/services/experience/app/models/bundles.py @@ -0,0 +1,208 @@ +"""Capability bundles, licensing refs & feature toggles — Phase 11.8. + +ExperienceBundleDefinition = catalog of commercial capability packs. +ExperienceTenantBundle = activated pack for a workspace (optionally site). +ExperienceLicenseBinding = Core entitlement refs only (no plan-engine ownership). +ExperienceFeatureToggle = per-workspace/site feature flags coordinated with bundles. +No widgets/connectors in this phase. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + BundleDefinitionStatus, + BundleKey, + FeatureToggleStatus, + LicenseBindingStatus, + TenantBundleStatus, +) + + +class ExperienceBundleDefinition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Catalog entry for a licensable Experience capability bundle.""" + + __tablename__ = "experience_bundle_definitions" + + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + bundle_key: Mapped[BundleKey] = mapped_column(nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[BundleDefinitionStatus] = mapped_column( + default=BundleDefinitionStatus.DRAFT, nullable=False + ) + feature_keys: Mapped[list | None] = mapped_column(JSON, nullable=True) + permission_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True) + api_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "code", + name="uq_experience_bundle_definitions_tenant_code", + ), + UniqueConstraint( + "tenant_id", + "bundle_key", + name="uq_experience_bundle_definitions_tenant_key", + ), + Index("ix_experience_bundle_definitions_status", "tenant_id", "status"), + Index( + "ix_experience_bundle_definitions_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceTenantBundle( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Activated bundle assignment for a workspace (optional site scope).""" + + __tablename__ = "experience_tenant_bundles" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + bundle_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + bundle_key: Mapped[BundleKey] = mapped_column(nullable=False) + status: Mapped[TenantBundleStatus] = mapped_column( + default=TenantBundleStatus.PENDING, nullable=False + ) + core_entitlement_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + activated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + expires_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + activated_by: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "site_id", + "bundle_key", + name="uq_experience_tenant_bundles_scope_key", + ), + Index("ix_experience_tenant_bundles_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_tenant_bundles_status", "tenant_id", "status"), + Index( + "ix_experience_tenant_bundles_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ExperienceLicenseBinding( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Licensing shell — stores Core entitlement refs only (no plan engine).""" + + __tablename__ = "experience_license_bindings" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + tenant_bundle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + bundle_key: Mapped[BundleKey | None] = mapped_column(nullable=True) + core_entitlement_ref: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[LicenseBindingStatus] = mapped_column( + default=LicenseBindingStatus.PENDING, 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 + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "core_entitlement_ref", + name="uq_experience_license_bindings_entitlement", + ), + Index("ix_experience_license_bindings_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_license_bindings_status", "tenant_id", "status"), + Index( + "ix_experience_license_bindings_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ExperienceFeatureToggle( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Per-workspace/site feature toggle for capability discovery.""" + + __tablename__ = "experience_feature_toggles" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + feature_key: Mapped[str] = mapped_column(String(100), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + status: Mapped[FeatureToggleStatus] = mapped_column( + default=FeatureToggleStatus.ACTIVE, nullable=False + ) + bundle_key: Mapped[BundleKey | None] = mapped_column(nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "site_id", + "feature_key", + name="uq_experience_feature_toggles_scope_key", + ), + Index("ix_experience_feature_toggles_enabled", "tenant_id", "enabled"), + Index("ix_experience_feature_toggles_status", "tenant_id", "status"), + Index( + "ix_experience_feature_toggles_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/experience/app/models/components.py b/backend/services/experience/app/models/components.py new file mode 100644 index 0000000..c4dedfc --- /dev/null +++ b/backend/services/experience/app/models/components.py @@ -0,0 +1,162 @@ +"""Component library aggregates — Phase 11.2. + +ExperienceComponent = mutable catalog entry. +ExperienceComponentVersion = immutable once published. +PageComponentPlacement = composition shell pinning a version onto a page. +No theme/layout engines in this phase. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + ComponentKind, + ComponentStatus, + ComponentVersionStatus, + GUID, +) + + +class ExperienceComponent( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Reusable component definition in the tenant library.""" + + __tablename__ = "experience_components" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + kind: Mapped[ComponentKind] = mapped_column( + default=ComponentKind.CONTENT, nullable=False + ) + status: Mapped[ComponentStatus] = mapped_column( + default=ComponentStatus.DRAFT, nullable=False + ) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + tags: Mapped[list | None] = mapped_column(JSON, nullable=True) + latest_version_number: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + published_version_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_components_tenant_code", + ), + Index("ix_experience_components_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_components_kind", "tenant_id", "kind"), + Index("ix_experience_components_status", "tenant_id", "status"), + Index("ix_experience_components_category", "tenant_id", "category"), + Index("ix_experience_components_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceComponentVersion( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Immutable published revision of a component.""" + + __tablename__ = "experience_component_versions" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + component_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[ComponentVersionStatus] = mapped_column( + default=ComponentVersionStatus.DRAFT, nullable=False + ) + definition_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True) + props_defaults: Mapped[dict | None] = mapped_column(JSON, nullable=True) + render_hint: Mapped[dict | None] = mapped_column(JSON, nullable=True) + changelog: Mapped[str | None] = mapped_column(Text, nullable=True) + is_immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + retired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "component_id", + "version_number", + name="uq_experience_component_versions_num", + ), + Index("ix_experience_component_versions_component", "tenant_id", "component_id"), + Index("ix_experience_component_versions_status", "tenant_id", "status"), + Index( + "ix_experience_component_versions_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class PageComponentPlacement( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Composition shell: pin a component version onto a page (no layout engine).""" + + __tablename__ = "experience_page_component_placements" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + page_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + component_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + component_version_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + slot_key: Mapped[str] = mapped_column(String(100), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + props_override: Mapped[dict | None] = mapped_column(JSON, nullable=True) + is_visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "page_id", + "slot_key", + name="uq_experience_page_component_placements_slot", + ), + Index("ix_experience_page_placements_page", "tenant_id", "page_id"), + Index("ix_experience_page_placements_component", "tenant_id", "component_id"), + Index( + "ix_experience_page_placements_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/experience/app/models/forms.py b/backend/services/experience/app/models/forms.py new file mode 100644 index 0000000..28d8c0c --- /dev/null +++ b/backend/services/experience/app/models/forms.py @@ -0,0 +1,276 @@ +"""Forms, surveys & appointment page shells — Phase 11.6. + +ExperienceForm / FormSubmission = form definitions + submission shells (CRM/Communication refs only). +ExperienceSurvey / SurveyResponse = survey definitions + response shells. +ExperienceAppointmentPage / AppointmentBookingRef = appointment page shells + external booking refs. +No deep CRM/scheduling ownership; no SEO/PWA in this phase. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + AppointmentBookingStatus, + AppointmentPageStatus, + FormStatus, + FormSubmissionStatus, + SurveyResponseStatus, + SurveyStatus, +) + + +class ExperienceForm( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Form definition shell bound to a workspace (optionally a site/page).""" + + __tablename__ = "experience_forms" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[FormStatus] = mapped_column(default=FormStatus.DRAFT, nullable=False) + form_schema_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + submit_action_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_forms_tenant_code", + ), + Index("ix_experience_forms_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_forms_site", "tenant_id", "site_id"), + Index("ix_experience_forms_page", "tenant_id", "page_id"), + Index("ix_experience_forms_status", "tenant_id", "status"), + Index("ix_experience_forms_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class FormSubmission( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Form submission shell — payload + external refs only (no CRM ownership).""" + + __tablename__ = "experience_form_submissions" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + form_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[FormSubmissionStatus] = mapped_column( + default=FormSubmissionStatus.RECEIVED, nullable=False + ) + payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + crm_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + communication_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + external_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + submitted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_form_submissions_form", "tenant_id", "form_id"), + Index("ix_experience_form_submissions_status", "tenant_id", "status"), + Index( + "ix_experience_form_submissions_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ExperienceSurvey( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Survey definition shell.""" + + __tablename__ = "experience_surveys" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[SurveyStatus] = mapped_column( + default=SurveyStatus.DRAFT, nullable=False + ) + questions_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_surveys_tenant_code", + ), + Index("ix_experience_surveys_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_surveys_status", "tenant_id", "status"), + Index("ix_experience_surveys_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class SurveyResponse( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Survey response shell — answers + external refs only.""" + + __tablename__ = "experience_survey_responses" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + survey_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[SurveyResponseStatus] = mapped_column( + default=SurveyResponseStatus.RECEIVED, nullable=False + ) + answers_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + crm_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + external_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + submitted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_survey_responses_survey", "tenant_id", "survey_id"), + Index("ix_experience_survey_responses_status", "tenant_id", "status"), + Index( + "ix_experience_survey_responses_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ExperienceAppointmentPage( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Appointment page shell — booking provider/service refs only (no scheduler).""" + + __tablename__ = "experience_appointment_pages" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[AppointmentPageStatus] = mapped_column( + default=AppointmentPageStatus.DRAFT, nullable=False + ) + booking_provider_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + service_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + timezone: Mapped[str | None] = mapped_column(String(64), nullable=True) + availability_shell_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_appointment_pages_tenant_code", + ), + Index("ix_experience_appointment_pages_site", "tenant_id", "site_id"), + Index("ix_experience_appointment_pages_status", "tenant_id", "status"), + Index( + "ix_experience_appointment_pages_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class AppointmentBookingRef( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """External booking reference shell — no scheduling engine ownership.""" + + __tablename__ = "experience_appointment_booking_refs" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + appointment_page_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + external_booking_ref: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[AppointmentBookingStatus] = mapped_column( + default=AppointmentBookingStatus.PENDING, nullable=False + ) + crm_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + slot_start: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + slot_end: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "appointment_page_id", + "external_booking_ref", + name="uq_experience_appointment_booking_refs_ext", + ), + Index( + "ix_experience_appointment_booking_refs_page", + "tenant_id", + "appointment_page_id", + ), + Index( + "ix_experience_appointment_booking_refs_status", "tenant_id", "status" + ), + Index( + "ix_experience_appointment_booking_refs_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) diff --git a/backend/services/experience/app/models/foundation.py b/backend/services/experience/app/models/foundation.py new file mode 100644 index 0000000..5aa84e1 --- /dev/null +++ b/backend/services/experience/app/models/foundation.py @@ -0,0 +1,303 @@ +"""Experience foundation aggregates — Phase 11.0. + +Independent aggregates use UUID references within experience_db only. +No SQLAlchemy relationship graphs between aggregates. +No dispatch/routing/tracking engines — shells and contracts only. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + AuditAction, + GUID, + LifecycleStatus, + ProviderKind, + ProviderStatus, + RenderEngineStatus, +) + + +class ExperienceWorkspace( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Root delivery organization / fleet operator shell for a tenant (Torbat Pages).""" + + __tablename__ = "experience_workspaces" + + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.DRAFT, nullable=False + ) + legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_experience_workspaces_tenant_code"), + Index("ix_experience_workspaces_tenant_status", "tenant_id", "status"), + Index("ix_experience_workspaces_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceLocaleProfile( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Locale / directionality profile under an experience workspace.""" + + __tablename__ = "experience_locale_profiles" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + text_direction: Mapped[str] = mapped_column(String(3), default="rtl", nullable=False) + timezone: Mapped[str | None] = mapped_column(String(64), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "workspace_id", "code", name="uq_experience_locale_profiles_tenant_code" + ), + Index("ix_experience_locale_profiles_org", "tenant_id", "workspace_id"), + Index("ix_experience_locale_profiles_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceRole( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local experience RBAC role catalog shell.""" + + __tablename__ = "experience_roles" + + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[LifecycleStatus] = mapped_column( + default=LifecycleStatus.ACTIVE, nullable=False + ) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_experience_roles_tenant_code"), + Index("ix_experience_roles_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperiencePermission( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Local delivery permission catalog shell (complements platform experience.* tree).""" + + __tablename__ = "experience_permissions" + + role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint("tenant_id", "code", name="uq_experience_permissions_tenant_code"), + Index("ix_experience_permissions_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExternalProviderConfig( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """External theme/CDN/component provider registration shell — credentials stay here.""" + + __tablename__ = "external_provider_configs" + + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + provider_kind: Mapped[ProviderKind] = mapped_column( + default=ProviderKind.CUSTOM, nullable=False + ) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[ProviderStatus] = mapped_column( + default=ProviderStatus.REGISTERED, nullable=False + ) + priority: Mapped[int] = mapped_column(default=100, nullable=False) + credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_external_provider_configs_tenant_code" + ), + Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"), + Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class RenderEngineRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Future render/SSG engine registration — adapter ready, no engine implementation.""" + + __tablename__ = "render_engine_registrations" + + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + adapter_key: Mapped[str] = mapped_column(String(100), nullable=False) + status: Mapped[RenderEngineStatus] = mapped_column( + default=RenderEngineStatus.REGISTERED, nullable=False + ) + is_default: Mapped[bool] = mapped_column(default=False, nullable=False) + config: Mapped[dict | None] = mapped_column(JSON, nullable=True) + capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "code", name="uq_render_engine_registrations_tenant_code" + ), + Index("ix_render_engine_registrations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceConfiguration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant experience configuration — publish/SEO/analytics policy shells.""" + + __tablename__ = "experience_configurations" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + locale_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True) + publish_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + seo_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + analytics_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True) + custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True) + timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False) + language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_configurations_tenant_code", + ), + Index("ix_experience_configurations_org", "tenant_id", "workspace_id"), + Index("ix_experience_configurations_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceSetting( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Key/value experience settings shell.""" + + __tablename__ = "experience_settings" + + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + locale_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + key: Mapped[str] = mapped_column(String(100), nullable=False) + value: Mapped[dict | None] = mapped_column(JSON, nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "locale_profile_id", + "key", + name="uq_experience_settings_tenant_key", + ), + Index("ix_experience_settings_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin): + """Append-only experience audit trail.""" + + __tablename__ = "experience_audit_logs" + + entity_type: Mapped[str] = mapped_column(String(50), nullable=False) + entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + action: Mapped[AuditAction] = mapped_column(nullable=False) + actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True) + changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + message: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index( + "ix_experience_audit_entity", + "tenant_id", + "entity_type", + "entity_id", + ), + ) diff --git a/backend/services/experience/app/models/integrations.py b/backend/services/experience/app/models/integrations.py new file mode 100644 index 0000000..4018826 --- /dev/null +++ b/backend/services/experience/app/models/integrations.py @@ -0,0 +1,257 @@ +"""Consumer connectors, widgets & notify dispatches — Phase 11.9. + +Experience stores connector registration shells, widget definitions/instances, +and Communication notify client dispatches. No SMS provider ownership (ADR-012/016). +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + ConnectorDispatchDirection, + ConnectorDispatchStatus, + ConsumerConnectorKind, + ConsumerConnectorStatus, + NotifyDispatchStatus, + WidgetDefinitionStatus, + WidgetInstanceStatus, + WidgetKind, +) + + +class ExperienceConsumerConnectorRegistration( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_consumer_connector_registrations" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + kind: Mapped[ConsumerConnectorKind] = mapped_column(nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[ConsumerConnectorStatus] = mapped_column( + default=ConsumerConnectorStatus.DRAFT, nullable=False + ) + external_service_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + entity_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "code", + name="uq_experience_consumer_connector_registrations_tenant_code", + ), + Index( + "ix_experience_consumer_connector_registrations_workspace", + "tenant_id", + "workspace_id", + ), + Index( + "ix_experience_consumer_connector_registrations_status", + "tenant_id", + "status", + ), + Index( + "ix_experience_consumer_connector_registrations_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceConsumerConnectorDispatch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_consumer_connector_dispatches" + + registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + direction: Mapped[ConnectorDispatchDirection] = mapped_column(nullable=False) + event_type: Mapped[str] = mapped_column(String(100), nullable=False) + payload_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[ConnectorDispatchStatus] = mapped_column( + default=ConnectorDispatchStatus.PENDING, nullable=False + ) + response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = ( + Index( + "ix_experience_consumer_connector_dispatches_registration", + "tenant_id", + "registration_id", + ), + Index( + "ix_experience_consumer_connector_dispatches_status", + "tenant_id", + "status", + ), + Index( + "ix_experience_consumer_connector_dispatches_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceWidgetDefinition( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_widget_definitions" + + workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + code: Mapped[str] = mapped_column(String(50), nullable=False) + widget_kind: Mapped[WidgetKind] = mapped_column(nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[WidgetDefinitionStatus] = mapped_column( + default=WidgetDefinitionStatus.DRAFT, nullable=False + ) + connector_kind: Mapped[ConsumerConnectorKind | None] = mapped_column(nullable=True) + default_config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + permission_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "code", + name="uq_experience_widget_definitions_tenant_code", + ), + Index("ix_experience_widget_definitions_status", "tenant_id", "status"), + Index( + "ix_experience_widget_definitions_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceWidgetInstance( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_widget_instances" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + widget_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + widget_kind: Mapped[WidgetKind] = mapped_column(nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[WidgetInstanceStatus] = mapped_column( + default=WidgetInstanceStatus.DRAFT, nullable=False + ) + connector_registration_id: Mapped[uuid.UUID | None] = mapped_column( + GUID(), nullable=True + ) + entity_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + embed_ref: Mapped[str] = mapped_column(String(255), nullable=False) + config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "site_id", + "code", + name="uq_experience_widget_instances_site_code", + ), + UniqueConstraint( + "tenant_id", + "embed_ref", + name="uq_experience_widget_instances_embed_ref", + ), + Index("ix_experience_widget_instances_site", "tenant_id", "site_id"), + Index("ix_experience_widget_instances_status", "tenant_id", "status"), + Index( + "ix_experience_widget_instances_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceNotifyDispatch( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + __tablename__ = "experience_notify_dispatches" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + widget_instance_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + connector_registration_id: Mapped[uuid.UUID | None] = mapped_column( + GUID(), nullable=True + ) + channel: Mapped[str] = mapped_column(String(50), nullable=False) + template_key: Mapped[str] = mapped_column(String(100), nullable=False) + communication_request_ref: Mapped[str | None] = mapped_column( + String(255), nullable=True + ) + to_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[NotifyDispatchStatus] = mapped_column( + default=NotifyDispatchStatus.PENDING, nullable=False + ) + response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_notify_dispatches_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_notify_dispatches_status", "tenant_id", "status"), + Index( + "ix_experience_notify_dispatches_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) diff --git a/backend/services/experience/app/models/localization.py b/backend/services/experience/app/models/localization.py new file mode 100644 index 0000000..0f99f27 --- /dev/null +++ b/backend/services/experience/app/models/localization.py @@ -0,0 +1,177 @@ +"""Localization, directionality & media-ref aggregates — Phase 11.5. + +SiteLocaleBinding = bind a locale profile (+ concrete RTL/LTR) to a site. +ExperienceLocalization = multi-locale content shells for experience targets. +ExperienceMediaRef = Storage file references only (no binaries in experience_db). +No forms/surveys/appointments engine in this phase. +""" +from __future__ import annotations + +import uuid + +from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + LocalizationStatus, + LocalizationTarget, + MediaKind, + MediaRefStatus, + TextDirection, +) + + +class SiteLocaleBinding( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Pin a locale profile (+ concrete direction) onto a site.""" + + __tablename__ = "experience_site_locale_bindings" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + locale_profile_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + locale_code: Mapped[str] = mapped_column(String(16), nullable=False) + text_direction: Mapped[TextDirection] = mapped_column( + default=TextDirection.RTL, nullable=False + ) + is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "site_id", + "locale_profile_id", + name="uq_experience_site_locale_bindings_profile", + ), + Index("ix_experience_site_locale_bindings_site", "tenant_id", "site_id"), + Index( + "ix_experience_site_locale_bindings_profile", + "tenant_id", + "locale_profile_id", + ), + Index( + "ix_experience_site_locale_bindings_locale", + "tenant_id", + "locale_code", + ), + Index( + "ix_experience_site_locale_bindings_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class ExperienceLocalization( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Multi-locale content shell for experience targets (RTL/LTR first-class).""" + + __tablename__ = "experience_localizations" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + target_type: Mapped[LocalizationTarget] = mapped_column(nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + locale: Mapped[str] = mapped_column(String(16), nullable=False) + text_direction: Mapped[TextDirection] = mapped_column( + default=TextDirection.RTL, nullable=False + ) + title: Mapped[str | None] = mapped_column(String(255), nullable=True) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + content_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + status: Mapped[LocalizationStatus] = mapped_column( + default=LocalizationStatus.DRAFT, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "target_type", + "target_id", + "locale", + name="uq_experience_localizations_locale", + ), + Index( + "ix_experience_localizations_target", + "tenant_id", + "target_type", + "target_id", + ), + Index("ix_experience_localizations_locale", "tenant_id", "locale"), + Index("ix_experience_localizations_status", "tenant_id", "status"), + Index( + "ix_experience_localizations_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ExperienceMediaRef( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Media reference shell — Storage file refs only (no binaries).""" + + __tablename__ = "experience_media_refs" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + owner_type: Mapped[str | None] = mapped_column(String(50), nullable=True) + owner_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + kind: Mapped[MediaKind] = mapped_column(default=MediaKind.IMAGE, nullable=False) + storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False) + alt_text: Mapped[str | None] = mapped_column(String(255), nullable=True) + caption: Mapped[str | None] = mapped_column(String(500), nullable=True) + locale: Mapped[str | None] = mapped_column(String(16), nullable=True) + text_direction: Mapped[TextDirection | None] = mapped_column(nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + status: Mapped[MediaRefStatus] = mapped_column( + default=MediaRefStatus.ACTIVE, nullable=False + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_media_refs_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_media_refs_site", "tenant_id", "site_id"), + Index("ix_experience_media_refs_page", "tenant_id", "page_id"), + Index("ix_experience_media_refs_owner", "tenant_id", "owner_type", "owner_id"), + Index("ix_experience_media_refs_kind", "tenant_id", "kind"), + Index("ix_experience_media_refs_locale", "tenant_id", "locale"), + Index("ix_experience_media_refs_status", "tenant_id", "status"), + Index( + "ix_experience_media_refs_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/experience/app/models/publishing.py b/backend/services/experience/app/models/publishing.py new file mode 100644 index 0000000..67d0639 --- /dev/null +++ b/backend/services/experience/app/models/publishing.py @@ -0,0 +1,257 @@ +"""Publishing, domain-edge refs, SEO & PWA shells — Phase 11.7. + +PublishRelease = publish workflow audit (site/page) beyond simple status flips. +DomainEdgeBinding = DNS/SSL/edge map refs (ADR-009); edge ownership stays Core/Nginx. +SeoMetadata / SitemapShell / RobotsShell = SEO document shells. +PwaManifest = PWA readiness shell (manifest + service-worker ref). +No bundles/feature toggles in this phase. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + DomainEdgeStatus, + PublishReleaseStatus, + PublishTarget, + PwaStatus, + RobotsStatus, + SeoStatus, + SeoTarget, + SitemapStatus, +) + + +class PublishRelease( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Publish workflow shell for a site or page.""" + + __tablename__ = "experience_publish_releases" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + target_type: Mapped[PublishTarget] = mapped_column(nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[PublishReleaseStatus] = mapped_column( + default=PublishReleaseStatus.PENDING, nullable=False + ) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index( + "ix_experience_publish_releases_target", + "tenant_id", + "target_type", + "target_id", + ), + Index("ix_experience_publish_releases_status", "tenant_id", "status"), + Index( + "ix_experience_publish_releases_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class DomainEdgeBinding( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Edge DNS/SSL binding refs for a site domain (ADR-009) — no Nginx ownership.""" + + __tablename__ = "experience_domain_edge_bindings" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_domain_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + host: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[DomainEdgeStatus] = mapped_column( + default=DomainEdgeStatus.PENDING, nullable=False + ) + dns_status_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + ssl_cert_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + edge_map_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "site_domain_id", + name="uq_experience_domain_edge_bindings_domain", + ), + Index("ix_experience_domain_edge_bindings_site", "tenant_id", "site_id"), + Index("ix_experience_domain_edge_bindings_host", "tenant_id", "host"), + Index("ix_experience_domain_edge_bindings_status", "tenant_id", "status"), + Index( + "ix_experience_domain_edge_bindings_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class SeoMetadata( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """SEO metadata shell for a site or page.""" + + __tablename__ = "experience_seo_metadata" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + target_type: Mapped[SeoTarget] = mapped_column(nullable=False) + target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[SeoStatus] = mapped_column(default=SeoStatus.DRAFT, nullable=False) + title: Mapped[str | None] = mapped_column(String(255), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + canonical_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + robots_directive: Mapped[str | None] = mapped_column(String(100), nullable=True) + og_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "target_type", + "target_id", + name="uq_experience_seo_metadata_target", + ), + Index("ix_experience_seo_metadata_status", "tenant_id", "status"), + Index( + "ix_experience_seo_metadata_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class SitemapShell( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Sitemap document shell for a site.""" + + __tablename__ = "experience_sitemap_shells" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[SitemapStatus] = mapped_column( + default=SitemapStatus.DRAFT, nullable=False + ) + entries_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "site_id", name="uq_experience_sitemap_shells_site" + ), + Index("ix_experience_sitemap_shells_status", "tenant_id", "status"), + Index( + "ix_experience_sitemap_shells_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class RobotsShell( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Robots.txt shell for a site.""" + + __tablename__ = "experience_robots_shells" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[RobotsStatus] = mapped_column( + default=RobotsStatus.DRAFT, nullable=False + ) + content_text: Mapped[str | None] = mapped_column(Text, nullable=True) + rules_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "site_id", name="uq_experience_robots_shells_site" + ), + Index("ix_experience_robots_shells_status", "tenant_id", "status"), + Index( + "ix_experience_robots_shells_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class PwaManifest( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """PWA readiness shell — manifest JSON + service-worker Storage ref.""" + + __tablename__ = "experience_pwa_manifests" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + status: Mapped[PwaStatus] = mapped_column(default=PwaStatus.DRAFT, nullable=False) + name: Mapped[str | None] = mapped_column(String(255), nullable=True) + short_name: Mapped[str | None] = mapped_column(String(100), nullable=True) + start_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + manifest_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + service_worker_ref: Mapped[str | None] = mapped_column(String(255), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "site_id", name="uq_experience_pwa_manifests_site" + ), + Index("ix_experience_pwa_manifests_status", "tenant_id", "status"), + Index( + "ix_experience_pwa_manifests_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/experience/app/models/sites.py b/backend/services/experience/app/models/sites.py new file mode 100644 index 0000000..59507bf --- /dev/null +++ b/backend/services/experience/app/models/sites.py @@ -0,0 +1,153 @@ +"""Sites & page resources — Phase 11.1. + +Independent aggregates use UUID refs within experience_db only. +No SQLAlchemy relationships. No page-builder / theme / template engines. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import DomainKind, GUID, PageType, PublishStatus + + +class ExperienceSite( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Tenant site under a workspace — multiple sites per workspace.""" + + __tablename__ = "experience_sites" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + publish_status: Mapped[PublishStatus] = mapped_column( + default=PublishStatus.DRAFT, nullable=False + ) + default_locale: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + default_direction: Mapped[str] = mapped_column(String(3), default="rtl", nullable=False) + primary_domain: Mapped[str | None] = mapped_column(String(255), nullable=True) + home_page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + archived_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + settings: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "workspace_id", "code", name="uq_experience_sites_tenant_code" + ), + Index("ix_experience_sites_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_sites_publish", "tenant_id", "publish_status"), + Index("ix_experience_sites_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperiencePage( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Page-as-resource — typed, publishable; layout/components come in later phases.""" + + __tablename__ = "experience_pages" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + slug: Mapped[str] = mapped_column(String(200), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) + page_type: Mapped[PageType] = mapped_column(default=PageType.LANDING, nullable=False) + publish_status: Mapped[PublishStatus] = mapped_column( + default=PublishStatus.DRAFT, nullable=False + ) + locale: Mapped[str] = mapped_column(String(16), default="fa", nullable=False) + text_direction: Mapped[str] = mapped_column(String(3), default="rtl", nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + is_home: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + archived_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + # Content shell only — no builder tree in 11.1 + content_shell: Mapped[dict | None] = mapped_column(JSON, nullable=True) + seo_shell: Mapped[dict | None] = mapped_column(JSON, nullable=True) + external_refs: Mapped[dict | None] = mapped_column(JSON, nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "site_id", "code", name="uq_experience_pages_tenant_code" + ), + UniqueConstraint( + "tenant_id", "site_id", "slug", name="uq_experience_pages_tenant_slug" + ), + Index("ix_experience_pages_site", "tenant_id", "site_id"), + Index("ix_experience_pages_type", "tenant_id", "page_type"), + Index("ix_experience_pages_publish", "tenant_id", "publish_status"), + Index("ix_experience_pages_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceSiteDomain( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, +): + """Domain / subdomain binding refs for a site — DNS/SSL remain edge/Core.""" + + __tablename__ = "experience_site_domains" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + host: Mapped[str] = mapped_column(String(255), nullable=False) + domain_kind: Mapped[DomainKind] = mapped_column( + default=DomainKind.SUBDOMAIN, nullable=False + ) + is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + verification_token: Mapped[str | None] = mapped_column(String(100), nullable=True) + path_prefix: Mapped[str | None] = mapped_column(String(100), nullable=True) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", "host", name="uq_experience_site_domains_tenant_host" + ), + Index("ix_experience_site_domains_site", "tenant_id", "site_id"), + Index("ix_experience_site_domains_tenant_deleted", "tenant_id", "is_deleted"), + ) diff --git a/backend/services/experience/app/models/templates.py b/backend/services/experience/app/models/templates.py new file mode 100644 index 0000000..028bb35 --- /dev/null +++ b/backend/services/experience/app/models/templates.py @@ -0,0 +1,159 @@ +"""Template system aggregates — Phase 11.4. + +ExperienceTemplate = catalog entry (page-type starter packs). +ExperienceTemplateVersion = immutable once published (blueprint shell). +TemplateInstantiation = audit trail of instantiate → page creation. +No forms/surveys engine in this phase. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + GUID, + TemplateInstantiationStatus, + TemplateStatus, + TemplateVersionStatus, +) + + +class ExperienceTemplate( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Reusable page-type template in the tenant catalog.""" + + __tablename__ = "experience_templates" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + page_type: Mapped[str] = mapped_column(String(50), nullable=False) + status: Mapped[TemplateStatus] = mapped_column( + default=TemplateStatus.DRAFT, nullable=False + ) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + tags: Mapped[list | None] = mapped_column(JSON, nullable=True) + latest_version_number: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + published_version_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + is_starter_pack: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_templates_tenant_code", + ), + Index("ix_experience_templates_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_templates_page_type", "tenant_id", "page_type"), + Index("ix_experience_templates_status", "tenant_id", "status"), + Index("ix_experience_templates_starter", "tenant_id", "is_starter_pack"), + Index("ix_experience_templates_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceTemplateVersion( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Immutable published blueprint revision of a template.""" + + __tablename__ = "experience_template_versions" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + template_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[TemplateVersionStatus] = mapped_column( + default=TemplateVersionStatus.DRAFT, nullable=False + ) + blueprint_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + changelog: Mapped[str | None] = mapped_column(Text, nullable=True) + is_immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + retired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "template_id", + "version_number", + name="uq_experience_template_versions_num", + ), + Index("ix_experience_template_versions_template", "tenant_id", "template_id"), + Index("ix_experience_template_versions_status", "tenant_id", "status"), + Index( + "ix_experience_template_versions_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class TemplateInstantiation( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Record of instantiating a published template into a site page.""" + + __tablename__ = "experience_template_instantiations" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + template_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + template_version_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + status: Mapped[TemplateInstantiationStatus] = mapped_column( + default=TemplateInstantiationStatus.PENDING, nullable=False + ) + result_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_template_inst_template", "tenant_id", "template_id"), + Index("ix_experience_template_inst_site", "tenant_id", "site_id"), + Index("ix_experience_template_inst_page", "tenant_id", "page_id"), + Index("ix_experience_template_inst_status", "tenant_id", "status"), + Index( + "ix_experience_template_inst_tenant_deleted", "tenant_id", "is_deleted" + ), + ) diff --git a/backend/services/experience/app/models/themes.py b/backend/services/experience/app/models/themes.py new file mode 100644 index 0000000..75d0c56 --- /dev/null +++ b/backend/services/experience/app/models/themes.py @@ -0,0 +1,293 @@ +"""Theme & layout engine aggregates — Phase 11.4 (versioned rework). + +ExperienceTheme = catalog entry (mutable metadata, replaceable pack). +ExperienceThemeVersion = immutable once published (tokens / brand shell). +ExperienceLayout = catalog entry (mutable metadata, dynamic layout shell). +ExperienceLayoutVersion = immutable once published (regions / structure shell). +SiteThemeBinding = pins a published theme version onto a site (replaceable). +PageLayoutBinding = pins a published layout version onto a page (replaceable). +No page-builder / CMS / forms engine in this phase. +""" +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column +from sqlalchemy.types import JSON + +from app.core.database import Base +from app.models.base import ( + ActorAuditMixin, + OptimisticLockMixin, + SoftDeleteMixin, + TenantMixin, + TimestampMixin, + UUIDPrimaryKeyMixin, +) +from app.models.types import ( + DirectionMode, + GUID, + LayoutKind, + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) + + +class ExperienceTheme( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Replaceable theme pack catalog entry (ADR-008 compatible).""" + + __tablename__ = "experience_themes" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[ThemeStatus] = mapped_column( + default=ThemeStatus.DRAFT, nullable=False + ) + direction_mode: Mapped[DirectionMode] = mapped_column( + default=DirectionMode.BOTH, nullable=False + ) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + tags: Mapped[list | None] = mapped_column(JSON, nullable=True) + latest_version_number: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + published_version_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_themes_tenant_code", + ), + Index("ix_experience_themes_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_themes_status", "tenant_id", "status"), + Index("ix_experience_themes_direction", "tenant_id", "direction_mode"), + Index("ix_experience_themes_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceThemeVersion( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Immutable published revision of a theme (CSS tokens + brand shell).""" + + __tablename__ = "experience_theme_versions" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + theme_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[ThemeVersionStatus] = mapped_column( + default=ThemeVersionStatus.DRAFT, nullable=False + ) + tokens: Mapped[dict | None] = mapped_column(JSON, nullable=True) + brand_shell: Mapped[dict | None] = mapped_column(JSON, nullable=True) + direction_mode: Mapped[DirectionMode | None] = mapped_column(nullable=True) + changelog: Mapped[str | None] = mapped_column(Text, nullable=True) + is_immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + retired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "theme_id", + "version_number", + name="uq_experience_theme_versions_num", + ), + Index("ix_experience_theme_versions_theme", "tenant_id", "theme_id"), + Index("ix_experience_theme_versions_status", "tenant_id", "status"), + Index( + "ix_experience_theme_versions_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class ExperienceLayout( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Dynamic layout catalog entry — regions/slots shells, no template catalog.""" + + __tablename__ = "experience_layouts" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + code: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + kind: Mapped[LayoutKind] = mapped_column( + default=LayoutKind.PAGE_SHELL, nullable=False + ) + status: Mapped[LayoutStatus] = mapped_column( + default=LayoutStatus.DRAFT, nullable=False + ) + direction_mode: Mapped[DirectionMode] = mapped_column( + default=DirectionMode.BOTH, nullable=False + ) + category: Mapped[str | None] = mapped_column(String(100), nullable=True) + tags: Mapped[list | None] = mapped_column(JSON, nullable=True) + latest_version_number: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + published_version_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True) + is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "workspace_id", + "code", + name="uq_experience_layouts_tenant_code", + ), + Index("ix_experience_layouts_workspace", "tenant_id", "workspace_id"), + Index("ix_experience_layouts_status", "tenant_id", "status"), + Index("ix_experience_layouts_direction", "tenant_id", "direction_mode"), + Index("ix_experience_layouts_kind", "tenant_id", "kind"), + Index("ix_experience_layouts_tenant_deleted", "tenant_id", "is_deleted"), + ) + + +class ExperienceLayoutVersion( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Immutable published revision of a layout (regions + structure shell).""" + + __tablename__ = "experience_layout_versions" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + layout_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + status: Mapped[LayoutVersionStatus] = mapped_column( + default=LayoutVersionStatus.DRAFT, nullable=False + ) + regions: Mapped[list | None] = mapped_column(JSON, nullable=True) + structure_shell: Mapped[dict | None] = mapped_column(JSON, nullable=True) + changelog: Mapped[str | None] = mapped_column(Text, nullable=True) + is_immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + published_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + retired_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + UniqueConstraint( + "tenant_id", + "layout_id", + "version_number", + name="uq_experience_layout_versions_num", + ), + Index("ix_experience_layout_versions_layout", "tenant_id", "layout_id"), + Index("ix_experience_layout_versions_status", "tenant_id", "status"), + Index( + "ix_experience_layout_versions_tenant_deleted", "tenant_id", "is_deleted" + ), + ) + + +class SiteThemeBinding( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Pin a published theme version onto a site (replaceable without rewriting pages).""" + + __tablename__ = "experience_site_theme_bindings" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + theme_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + theme_version_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_site_theme_bindings_site", "tenant_id", "site_id"), + Index("ix_experience_site_theme_bindings_theme", "tenant_id", "theme_id"), + Index( + "ix_experience_site_theme_bindings_version", "tenant_id", "theme_version_id" + ), + Index( + "ix_experience_site_theme_bindings_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) + + +class PageLayoutBinding( + Base, + UUIDPrimaryKeyMixin, + TenantMixin, + TimestampMixin, + SoftDeleteMixin, + ActorAuditMixin, + OptimisticLockMixin, +): + """Pin a published layout version onto a page (dynamic composition shell).""" + + __tablename__ = "experience_page_layout_bindings" + + workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + page_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + layout_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + layout_version_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + __table_args__ = ( + Index("ix_experience_page_layout_bindings_page", "tenant_id", "page_id"), + Index("ix_experience_page_layout_bindings_layout", "tenant_id", "layout_id"), + Index( + "ix_experience_page_layout_bindings_version", + "tenant_id", + "layout_version_id", + ), + Index( + "ix_experience_page_layout_bindings_tenant_deleted", + "tenant_id", + "is_deleted", + ), + ) diff --git a/backend/services/experience/app/models/types.py b/backend/services/experience/app/models/types.py new file mode 100644 index 0000000..41ded4d --- /dev/null +++ b/backend/services/experience/app/models/types.py @@ -0,0 +1,485 @@ +"""Experience domain enums and dialect-safe GUID type.""" +from __future__ import annotations + +import enum +import uuid + +from sqlalchemy.dialects.postgresql import UUID as PG_UUID +from sqlalchemy.types import CHAR, TypeDecorator + + +class GUID(TypeDecorator): + impl = CHAR + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "postgresql": + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + return dialect.type_descriptor(CHAR(36)) + + def process_bind_param(self, value, dialect): + if value is None: + return value + if dialect.name == "postgresql": + return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value)) + return str(value) + + def process_result_value(self, value, dialect): + if value is None: + return value + if isinstance(value, uuid.UUID): + return value + return uuid.UUID(str(value)) + + +class LifecycleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + INACTIVE = "inactive" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class ProviderKind(str, enum.Enum): + THEME_MARKETPLACE = "theme_marketplace" + COMPONENT_MARKETPLACE = "component_marketplace" + CDN = "cdn" + STORAGE = "storage" + RENDER = "render" + CUSTOM = "custom" + + +class ProviderStatus(str, enum.Enum): + REGISTERED = "registered" + ACTIVE = "active" + INACTIVE = "inactive" + FAILED = "failed" + RETIRED = "retired" + + +class RenderEngineStatus(str, enum.Enum): + REGISTERED = "registered" + READY = "ready" + ACTIVE = "active" + DISABLED = "disabled" + + +class AuditAction(str, enum.Enum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + RESTORE = "restore" + STATUS_CHANGE = "status_change" + REGISTER = "register" + ENABLE = "enable" + DISABLE = "disable" + PUBLISH = "publish" + UNPUBLISH = "unpublish" + ARCHIVE = "archive" + ACTIVATE = "activate" + DEPRECATE = "deprecate" + ASSIGN = "assign" + REPLACE = "replace" + + +class PublishStatus(str, enum.Enum): + DRAFT = "draft" + PUBLISHED = "published" + ARCHIVED = "archived" + + +class PageType(str, enum.Enum): + LANDING = "landing" + MINI_SITE = "mini_site" + BUSINESS_WEBSITE = "business_website" + PORTFOLIO = "portfolio" + RESTAURANT = "restaurant" + CAFE = "cafe" + COMPANY = "company" + BUSINESS_PROFILE = "business_profile" + DIGITAL_MENU = "digital_menu" + DIGITAL_CATALOG = "digital_catalog" + PRODUCT_CATALOG = "product_catalog" + QR = "qr" + LINK_IN_BIO = "link_in_bio" + APPOINTMENT = "appointment" + FORM = "form" + BLOG = "blog" + CAMPAIGN = "campaign" + KNOWLEDGE = "knowledge" + DOCUMENTATION = "documentation" + MEDIA = "media" + SEO = "seo" + PUBLIC_PROFILE = "public_profile" + EVENT = "event" + CUSTOM = "custom" + + +class DomainKind(str, enum.Enum): + SUBDOMAIN = "subdomain" + CUSTOM = "custom" + PATH = "path" + + +class ComponentKind(str, enum.Enum): + LAYOUT_BLOCK = "layout_block" + CONTENT = "content" + MEDIA = "media" + NAVIGATION = "navigation" + FORM_SHELL = "form_shell" + WIDGET_SHELL = "widget_shell" + CTA = "cta" + HERO = "hero" + MENU_REF = "menu_ref" + CATALOG_REF = "catalog_ref" + CUSTOM = "custom" + + +class ComponentStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + DEPRECATED = "deprecated" + ARCHIVED = "archived" + + +class ComponentVersionStatus(str, enum.Enum): + DRAFT = "draft" + PUBLISHED = "published" + RETIRED = "retired" + + +class ThemeStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + DEPRECATED = "deprecated" + ARCHIVED = "archived" + + +class LayoutStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + DEPRECATED = "deprecated" + ARCHIVED = "archived" + + +class DirectionMode(str, enum.Enum): + RTL = "rtl" + LTR = "ltr" + BOTH = "both" + + +class ThemeVersionStatus(str, enum.Enum): + DRAFT = "draft" + PUBLISHED = "published" + RETIRED = "retired" + + +class LayoutVersionStatus(str, enum.Enum): + DRAFT = "draft" + PUBLISHED = "published" + RETIRED = "retired" + + +class LayoutKind(str, enum.Enum): + PAGE_SHELL = "page_shell" + APP_SHELL = "app_shell" + SIDEBAR = "sidebar" + SPLIT = "split" + CUSTOM = "custom" + + +class TemplateStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + DEPRECATED = "deprecated" + ARCHIVED = "archived" + + +class TemplateVersionStatus(str, enum.Enum): + DRAFT = "draft" + PUBLISHED = "published" + RETIRED = "retired" + + +class TemplateInstantiationStatus(str, enum.Enum): + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" + + +# Phase 11.5 — Localization, RTL/LTR & Media +class TextDirection(str, enum.Enum): + RTL = "rtl" + LTR = "ltr" + + +class LocalizationTarget(str, enum.Enum): + SITE = "site" + PAGE = "page" + COMPONENT_PLACEMENT = "component_placement" + TEMPLATE = "template" + THEME = "theme" + LAYOUT = "layout" + MEDIA_REF = "media_ref" + + +class LocalizationStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class MediaKind(str, enum.Enum): + IMAGE = "image" + VIDEO = "video" + DOCUMENT = "document" + AUDIO = "audio" + ICON = "icon" + OTHER = "other" + + +class MediaRefStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +# Phase 11.6 — Forms, Surveys & Appointments +class FormStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class FormSubmissionStatus(str, enum.Enum): + RECEIVED = "received" + PROCESSED = "processed" + FAILED = "failed" + ARCHIVED = "archived" + + +class SurveyStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class SurveyResponseStatus(str, enum.Enum): + RECEIVED = "received" + PROCESSED = "processed" + FAILED = "failed" + ARCHIVED = "archived" + + +class AppointmentPageStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class AppointmentBookingStatus(str, enum.Enum): + PENDING = "pending" + CONFIRMED = "confirmed" + CANCELLED = "cancelled" + ARCHIVED = "archived" + + +# Phase 11.7 — Publishing, Domains, SEO & PWA +class PublishTarget(str, enum.Enum): + SITE = "site" + PAGE = "page" + + +class PublishReleaseStatus(str, enum.Enum): + PENDING = "pending" + PUBLISHED = "published" + FAILED = "failed" + ROLLED_BACK = "rolled_back" + + +class DomainEdgeStatus(str, enum.Enum): + PENDING = "pending" + PROVISIONING = "provisioning" + READY = "ready" + FAILED = "failed" + RETIRED = "retired" + + +class SeoTarget(str, enum.Enum): + SITE = "site" + PAGE = "page" + + +class SeoStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class SitemapStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class RobotsStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class PwaStatus(str, enum.Enum): + DRAFT = "draft" + READY = "ready" + ARCHIVED = "archived" + + +# Phase 11.8 — Bundles, Licensing & Feature Toggles +class BundleKey(str, enum.Enum): + MINI_SITE = "mini_site" + BUSINESS_WEBSITE = "business_website" + LANDING_BUILDER = "landing_builder" + DIGITAL_MENU = "digital_menu" + DIGITAL_CATALOG = "digital_catalog" + PORTFOLIO = "portfolio" + BLOG = "blog" + APPOINTMENT = "appointment" + CAMPAIGN = "campaign" + QR_LANDING = "qr_landing" + BIO_LINK = "bio_link" + CUSTOM_DOMAIN = "custom_domain" + SEO = "seo" + ANALYTICS = "analytics" + AI_CONTENT = "ai_content" + FORMS = "forms" + WIDGETS = "widgets" + CUSTOM = "custom" + + +class BundleDefinitionStatus(str, enum.Enum): + DRAFT = "draft" + AVAILABLE = "available" + RETIRED = "retired" + + +class TenantBundleStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + SUSPENDED = "suspended" + EXPIRED = "expired" + REVOKED = "revoked" + + +class LicenseBindingStatus(str, enum.Enum): + PENDING = "pending" + ACTIVE = "active" + EXPIRED = "expired" + REVOKED = "revoked" + + +class FeatureToggleStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +# Phase 11.9 — Consumer Integrations & Widgets +class ConsumerConnectorKind(str, enum.Enum): + HOSPITALITY = "hospitality" + MARKETPLACE = "marketplace" + SPORTS_CENTER = "sports_center" + CRM = "crm" + ACCOUNTING = "accounting" + LOYALTY = "loyalty" + COMMUNICATION = "communication" + DELIVERY = "delivery" + AUTOMATION = "automation" + ACADEMY = "academy" + CLINIC = "clinic" + HOTEL = "hotel" + TOURISM = "tourism" + CUSTOM = "custom" + + +class ConsumerConnectorStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + SUSPENDED = "suspended" + RETIRED = "retired" + + +class ConnectorDispatchDirection(str, enum.Enum): + OUTBOUND = "outbound" + INBOUND = "inbound" + + +class ConnectorDispatchStatus(str, enum.Enum): + PENDING = "pending" + ACKNOWLEDGED = "acknowledged" + FAILED = "failed" + CANCELLED = "cancelled" + + +class WidgetKind(str, enum.Enum): + MENU_EMBED = "menu_embed" + CATALOG_EMBED = "catalog_embed" + BOOKING_EMBED = "booking_embed" + FORM_EMBED = "form_embed" + LOYALTY_EMBED = "loyalty_embed" + CRM_LEAD_CAPTURE = "crm_lead_capture" + CUSTOM = "custom" + + +class WidgetDefinitionStatus(str, enum.Enum): + DRAFT = "draft" + AVAILABLE = "available" + RETIRED = "retired" + + +class WidgetInstanceStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + SUSPENDED = "suspended" + ARCHIVED = "archived" + + +class NotifyDispatchStatus(str, enum.Enum): + PENDING = "pending" + SENT = "sent" + FAILED = "failed" + CANCELLED = "cancelled" + + +# Phase 11.10 — Analytics & AI Content Hooks +class AnalyticsReportStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + ARCHIVED = "archived" + + +class AnalyticsSnapshotStatus(str, enum.Enum): + ACTIVE = "active" + ARCHIVED = "archived" + + +class AiContentTaskKind(str, enum.Enum): + PAGE_COPY = "page_copy" + SEO_META = "seo_meta" + COMPONENT_TEXT = "component_text" + FORM_COPY = "form_copy" + CAMPAIGN_COPY = "campaign_copy" + CUSTOM = "custom" + + +class AiContentHookStatus(str, enum.Enum): + DRAFT = "draft" + ACTIVE = "active" + SUSPENDED = "suspended" + RETIRED = "retired" + + +class AiContentDispatchStatus(str, enum.Enum): + PENDING = "pending" + GENERATED = "generated" + FAILED = "failed" + CANCELLED = "cancelled" diff --git a/backend/services/experience/app/permissions/definitions.py b/backend/services/experience/app/permissions/definitions.py new file mode 100644 index 0000000..dd586f3 --- /dev/null +++ b/backend/services/experience/app/permissions/definitions.py @@ -0,0 +1,333 @@ +"""Experience permission definitions — Phase 11.0 foundation.""" +from __future__ import annotations + +EXPERIENCE_VIEW = "experience.view" +EXPERIENCE_MANAGE = "experience.manage" + +WORKSPACES_VIEW = "experience.workspaces.view" +WORKSPACES_CREATE = "experience.workspaces.create" +WORKSPACES_UPDATE = "experience.workspaces.update" +WORKSPACES_DELETE = "experience.workspaces.delete" +WORKSPACES_MANAGE = "experience.workspaces.manage" + +LOCALE_PROFILES_VIEW = "experience.locale_profiles.view" +LOCALE_PROFILES_CREATE = "experience.locale_profiles.create" +LOCALE_PROFILES_UPDATE = "experience.locale_profiles.update" +LOCALE_PROFILES_DELETE = "experience.locale_profiles.delete" +LOCALE_PROFILES_MANAGE = "experience.locale_profiles.manage" + +EXTERNAL_PROVIDERS_VIEW = "experience.external_providers.view" +EXTERNAL_PROVIDERS_CREATE = "experience.external_providers.create" +EXTERNAL_PROVIDERS_UPDATE = "experience.external_providers.update" +EXTERNAL_PROVIDERS_DELETE = "experience.external_providers.delete" +EXTERNAL_PROVIDERS_MANAGE = "experience.external_providers.manage" + +RENDER_ENGINES_VIEW = "experience.render_engines.view" +RENDER_ENGINES_CREATE = "experience.render_engines.create" +RENDER_ENGINES_UPDATE = "experience.render_engines.update" +RENDER_ENGINES_DELETE = "experience.render_engines.delete" +RENDER_ENGINES_MANAGE = "experience.render_engines.manage" + +CONFIGURATIONS_VIEW = "experience.configurations.view" +CONFIGURATIONS_CREATE = "experience.configurations.create" +CONFIGURATIONS_UPDATE = "experience.configurations.update" +CONFIGURATIONS_DELETE = "experience.configurations.delete" +CONFIGURATIONS_MANAGE = "experience.configurations.manage" + +SETTINGS_VIEW = "experience.settings.view" +SETTINGS_MANAGE = "experience.settings.manage" + +AUDIT_VIEW = "experience.audit.view" + +# Planned trees (registered for discovery; enforced in later phases) +SITES_VIEW = "experience.sites.view" +SITES_CREATE = "experience.sites.create" +SITES_UPDATE = "experience.sites.update" +SITES_DELETE = "experience.sites.delete" +SITES_MANAGE = "experience.sites.manage" + +PAGES_VIEW = "experience.pages.view" +PAGES_CREATE = "experience.pages.create" +PAGES_UPDATE = "experience.pages.update" +PAGES_DELETE = "experience.pages.delete" +PAGES_MANAGE = "experience.pages.manage" + +DOMAINS_VIEW = "experience.domains.view" +DOMAINS_CREATE = "experience.domains.create" +DOMAINS_UPDATE = "experience.domains.update" +DOMAINS_DELETE = "experience.domains.delete" +DOMAINS_MANAGE = "experience.domains.manage" + +COMPONENTS_VIEW = "experience.components.view" +COMPONENTS_CREATE = "experience.components.create" +COMPONENTS_UPDATE = "experience.components.update" +COMPONENTS_DELETE = "experience.components.delete" +COMPONENTS_MANAGE = "experience.components.manage" +THEMES_VIEW = "experience.themes.view" +THEMES_CREATE = "experience.themes.create" +THEMES_UPDATE = "experience.themes.update" +THEMES_DELETE = "experience.themes.delete" +THEMES_MANAGE = "experience.themes.manage" +LAYOUTS_VIEW = "experience.layouts.view" +LAYOUTS_CREATE = "experience.layouts.create" +LAYOUTS_UPDATE = "experience.layouts.update" +LAYOUTS_DELETE = "experience.layouts.delete" +LAYOUTS_MANAGE = "experience.layouts.manage" +TEMPLATES_VIEW = "experience.templates.view" +TEMPLATES_CREATE = "experience.templates.create" +TEMPLATES_UPDATE = "experience.templates.update" +TEMPLATES_DELETE = "experience.templates.delete" +TEMPLATES_MANAGE = "experience.templates.manage" +LOCALES_VIEW = "experience.locales.view" +LOCALES_CREATE = "experience.locales.create" +LOCALES_UPDATE = "experience.locales.update" +LOCALES_DELETE = "experience.locales.delete" +LOCALES_MANAGE = "experience.locales.manage" +MEDIA_REFS_VIEW = "experience.media_refs.view" +MEDIA_REFS_CREATE = "experience.media_refs.create" +MEDIA_REFS_UPDATE = "experience.media_refs.update" +MEDIA_REFS_DELETE = "experience.media_refs.delete" +MEDIA_REFS_MANAGE = "experience.media_refs.manage" +FORMS_VIEW = "experience.forms.view" +FORMS_CREATE = "experience.forms.create" +FORMS_UPDATE = "experience.forms.update" +FORMS_DELETE = "experience.forms.delete" +FORMS_MANAGE = "experience.forms.manage" +SURVEYS_VIEW = "experience.surveys.view" +SURVEYS_CREATE = "experience.surveys.create" +SURVEYS_UPDATE = "experience.surveys.update" +SURVEYS_DELETE = "experience.surveys.delete" +SURVEYS_MANAGE = "experience.surveys.manage" +APPOINTMENTS_VIEW = "experience.appointments.view" +APPOINTMENTS_CREATE = "experience.appointments.create" +APPOINTMENTS_UPDATE = "experience.appointments.update" +APPOINTMENTS_DELETE = "experience.appointments.delete" +APPOINTMENTS_MANAGE = "experience.appointments.manage" +PUBLISHING_VIEW = "experience.publishing.view" +PUBLISHING_CREATE = "experience.publishing.create" +PUBLISHING_UPDATE = "experience.publishing.update" +PUBLISHING_DELETE = "experience.publishing.delete" +PUBLISHING_MANAGE = "experience.publishing.manage" +SEO_VIEW = "experience.seo.view" +SEO_CREATE = "experience.seo.create" +SEO_UPDATE = "experience.seo.update" +SEO_DELETE = "experience.seo.delete" +SEO_MANAGE = "experience.seo.manage" +PWA_VIEW = "experience.pwa.view" +PWA_CREATE = "experience.pwa.create" +PWA_UPDATE = "experience.pwa.update" +PWA_DELETE = "experience.pwa.delete" +PWA_MANAGE = "experience.pwa.manage" +BUNDLES_VIEW = "experience.bundles.view" +BUNDLES_CREATE = "experience.bundles.create" +BUNDLES_UPDATE = "experience.bundles.update" +BUNDLES_DELETE = "experience.bundles.delete" +BUNDLES_MANAGE = "experience.bundles.manage" +FEATURE_TOGGLES_VIEW = "experience.feature_toggles.view" +FEATURE_TOGGLES_CREATE = "experience.feature_toggles.create" +FEATURE_TOGGLES_UPDATE = "experience.feature_toggles.update" +FEATURE_TOGGLES_DELETE = "experience.feature_toggles.delete" +FEATURE_TOGGLES_MANAGE = "experience.feature_toggles.manage" +CONSUMER_CONNECTORS_VIEW = "experience.consumer_connectors.view" +CONSUMER_CONNECTORS_CREATE = "experience.consumer_connectors.create" +CONSUMER_CONNECTORS_UPDATE = "experience.consumer_connectors.update" +CONSUMER_CONNECTORS_DELETE = "experience.consumer_connectors.delete" +CONSUMER_CONNECTORS_MANAGE = "experience.consumer_connectors.manage" +WIDGETS_VIEW = "experience.widgets.view" +WIDGETS_CREATE = "experience.widgets.create" +WIDGETS_UPDATE = "experience.widgets.update" +WIDGETS_DELETE = "experience.widgets.delete" +WIDGETS_MANAGE = "experience.widgets.manage" +NOTIFY_VIEW = "experience.notify.view" +NOTIFY_CREATE = "experience.notify.create" +NOTIFY_UPDATE = "experience.notify.update" +NOTIFY_DELETE = "experience.notify.delete" +NOTIFY_MANAGE = "experience.notify.manage" + +ANALYTICS_VIEW = "experience.analytics.view" +ANALYTICS_CREATE = "experience.analytics.create" +ANALYTICS_UPDATE = "experience.analytics.update" +ANALYTICS_DELETE = "experience.analytics.delete" +ANALYTICS_MANAGE = "experience.analytics.manage" + +AI_HOOKS_VIEW = "experience.ai_hooks.view" +AI_HOOKS_CREATE = "experience.ai_hooks.create" +AI_HOOKS_UPDATE = "experience.ai_hooks.update" +AI_HOOKS_DELETE = "experience.ai_hooks.delete" +AI_HOOKS_MANAGE = "experience.ai_hooks.manage" + +ALL_PERMISSIONS: list[str] = [ + EXPERIENCE_VIEW, + EXPERIENCE_MANAGE, + WORKSPACES_VIEW, + WORKSPACES_CREATE, + WORKSPACES_UPDATE, + WORKSPACES_DELETE, + WORKSPACES_MANAGE, + LOCALE_PROFILES_VIEW, + LOCALE_PROFILES_CREATE, + LOCALE_PROFILES_UPDATE, + LOCALE_PROFILES_DELETE, + LOCALE_PROFILES_MANAGE, + EXTERNAL_PROVIDERS_VIEW, + EXTERNAL_PROVIDERS_CREATE, + EXTERNAL_PROVIDERS_UPDATE, + EXTERNAL_PROVIDERS_DELETE, + EXTERNAL_PROVIDERS_MANAGE, + RENDER_ENGINES_VIEW, + RENDER_ENGINES_CREATE, + RENDER_ENGINES_UPDATE, + RENDER_ENGINES_DELETE, + RENDER_ENGINES_MANAGE, + CONFIGURATIONS_VIEW, + CONFIGURATIONS_CREATE, + CONFIGURATIONS_UPDATE, + CONFIGURATIONS_DELETE, + CONFIGURATIONS_MANAGE, + SETTINGS_VIEW, + SETTINGS_MANAGE, + AUDIT_VIEW, + SITES_VIEW, + SITES_CREATE, + SITES_UPDATE, + SITES_DELETE, + SITES_MANAGE, + PAGES_VIEW, + PAGES_CREATE, + PAGES_UPDATE, + PAGES_DELETE, + PAGES_MANAGE, + DOMAINS_VIEW, + DOMAINS_CREATE, + DOMAINS_UPDATE, + DOMAINS_DELETE, + DOMAINS_MANAGE, + COMPONENTS_VIEW, + COMPONENTS_CREATE, + COMPONENTS_UPDATE, + COMPONENTS_DELETE, + COMPONENTS_MANAGE, + THEMES_VIEW, + THEMES_CREATE, + THEMES_UPDATE, + THEMES_DELETE, + THEMES_MANAGE, + LAYOUTS_VIEW, + LAYOUTS_CREATE, + LAYOUTS_UPDATE, + LAYOUTS_DELETE, + LAYOUTS_MANAGE, + TEMPLATES_VIEW, + TEMPLATES_CREATE, + TEMPLATES_UPDATE, + TEMPLATES_DELETE, + TEMPLATES_MANAGE, + LOCALES_VIEW, + LOCALES_CREATE, + LOCALES_UPDATE, + LOCALES_DELETE, + LOCALES_MANAGE, + MEDIA_REFS_VIEW, + MEDIA_REFS_CREATE, + MEDIA_REFS_UPDATE, + MEDIA_REFS_DELETE, + MEDIA_REFS_MANAGE, + FORMS_VIEW, + FORMS_CREATE, + FORMS_UPDATE, + FORMS_DELETE, + FORMS_MANAGE, + SURVEYS_VIEW, + SURVEYS_CREATE, + SURVEYS_UPDATE, + SURVEYS_DELETE, + SURVEYS_MANAGE, + APPOINTMENTS_VIEW, + APPOINTMENTS_CREATE, + APPOINTMENTS_UPDATE, + APPOINTMENTS_DELETE, + APPOINTMENTS_MANAGE, + PUBLISHING_VIEW, + PUBLISHING_CREATE, + PUBLISHING_UPDATE, + PUBLISHING_DELETE, + PUBLISHING_MANAGE, + SEO_VIEW, + SEO_CREATE, + SEO_UPDATE, + SEO_DELETE, + SEO_MANAGE, + PWA_VIEW, + PWA_CREATE, + PWA_UPDATE, + PWA_DELETE, + PWA_MANAGE, + BUNDLES_VIEW, + BUNDLES_CREATE, + BUNDLES_UPDATE, + BUNDLES_DELETE, + BUNDLES_MANAGE, + FEATURE_TOGGLES_VIEW, + FEATURE_TOGGLES_CREATE, + FEATURE_TOGGLES_UPDATE, + FEATURE_TOGGLES_DELETE, + FEATURE_TOGGLES_MANAGE, + CONSUMER_CONNECTORS_VIEW, + CONSUMER_CONNECTORS_CREATE, + CONSUMER_CONNECTORS_UPDATE, + CONSUMER_CONNECTORS_DELETE, + CONSUMER_CONNECTORS_MANAGE, + WIDGETS_VIEW, + WIDGETS_CREATE, + WIDGETS_UPDATE, + WIDGETS_DELETE, + WIDGETS_MANAGE, + NOTIFY_VIEW, + NOTIFY_CREATE, + NOTIFY_UPDATE, + NOTIFY_DELETE, + NOTIFY_MANAGE, + ANALYTICS_VIEW, + ANALYTICS_CREATE, + ANALYTICS_UPDATE, + ANALYTICS_DELETE, + ANALYTICS_MANAGE, + AI_HOOKS_VIEW, + AI_HOOKS_CREATE, + AI_HOOKS_UPDATE, + AI_HOOKS_DELETE, + AI_HOOKS_MANAGE, +] + +PERMISSION_PREFIXES: tuple[str, ...] = ( + "experience.", + "experience.workspaces.", + "experience.locale_profiles.", + "experience.external_providers.", + "experience.render_engines.", + "experience.configurations.", + "experience.settings.", + "experience.audit.", + "experience.sites.", + "experience.pages.", + "experience.domains.", + "experience.components.", + "experience.themes.", + "experience.layouts.", + "experience.templates.", + "experience.locales.", + "experience.media_refs.", + "experience.forms.", + "experience.surveys.", + "experience.appointments.", + "experience.publishing.", + "experience.seo.", + "experience.pwa.", + "experience.bundles.", + "experience.feature_toggles.", + "experience.consumer_connectors.", + "experience.widgets.", + "experience.notify.", + "experience.analytics.", + "experience.ai_hooks.", +) diff --git a/backend/services/experience/app/policies/__init__.py b/backend/services/experience/app/policies/__init__.py new file mode 100644 index 0000000..cbddf0b --- /dev/null +++ b/backend/services/experience/app/policies/__init__.py @@ -0,0 +1,70 @@ +"""Experience policies package.""" +from app.policies.components import ( + ComponentLifecyclePolicy, + ComponentVersionPolicy, + PagePlacementPolicy, +) +from app.policies.themes import ( + LayoutLifecyclePolicy, + LayoutVersionPolicy, + PageLayoutBindingPolicy, + SiteThemeBindingPolicy, + ThemeLifecyclePolicy, + ThemeVersionPolicy, +) +from app.policies.templates import ( + TemplateInstantiationPolicy, + TemplateLifecyclePolicy, + TemplateVersionPolicy, +) +from app.policies.localization import ( + LocalizationPolicy, + MediaRefPolicy, + SiteLocaleBindingPolicy, +) +from app.policies.forms import ( + AppointmentBookingPolicy, + AppointmentPagePolicy, + FormLifecyclePolicy, + FormSubmissionPolicy, + SurveyLifecyclePolicy, + SurveyResponsePolicy, +) +from app.policies.publishing import ( + DomainEdgeBindingPolicy, + PublishReleasePolicy, + PwaPolicy, + RobotsPolicy, + SeoPolicy, + SitemapPolicy, +) + +__all__ = [ + "ComponentLifecyclePolicy", + "ComponentVersionPolicy", + "PagePlacementPolicy", + "ThemeLifecyclePolicy", + "ThemeVersionPolicy", + "LayoutLifecyclePolicy", + "LayoutVersionPolicy", + "SiteThemeBindingPolicy", + "PageLayoutBindingPolicy", + "TemplateLifecyclePolicy", + "TemplateVersionPolicy", + "TemplateInstantiationPolicy", + "SiteLocaleBindingPolicy", + "LocalizationPolicy", + "MediaRefPolicy", + "FormLifecyclePolicy", + "FormSubmissionPolicy", + "SurveyLifecyclePolicy", + "SurveyResponsePolicy", + "AppointmentPagePolicy", + "AppointmentBookingPolicy", + "PublishReleasePolicy", + "DomainEdgeBindingPolicy", + "SeoPolicy", + "SitemapPolicy", + "RobotsPolicy", + "PwaPolicy", +] diff --git a/backend/services/experience/app/policies/ai_hooks.py b/backend/services/experience/app/policies/ai_hooks.py new file mode 100644 index 0000000..f22ea55 --- /dev/null +++ b/backend/services/experience/app/policies/ai_hooks.py @@ -0,0 +1,28 @@ +"""Lifecycle policies for Phase 11.10 AI content hook aggregates.""" +from app.models.types import AiContentDispatchStatus as D, AiContentHookStatus as H +from app.validators.integrations import ensure_transition + +_TRANSITIONS = { + H: { + H.DRAFT: frozenset({H.ACTIVE, H.RETIRED}), + H.ACTIVE: frozenset({H.SUSPENDED, H.RETIRED}), + H.SUSPENDED: frozenset({H.ACTIVE, H.RETIRED}), + H.RETIRED: frozenset(), + }, + D: { + D.PENDING: frozenset({D.GENERATED, D.FAILED, D.CANCELLED}), + D.GENERATED: frozenset(), + D.FAILED: frozenset({D.PENDING, D.CANCELLED}), + D.CANCELLED: frozenset(), + }, +} + + +class AiContentHookRegistrationPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[H], "ai_content_hook_registration") + + +class AiContentDispatchPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[D], "ai_content_dispatch") diff --git a/backend/services/experience/app/policies/analytics.py b/backend/services/experience/app/policies/analytics.py new file mode 100644 index 0000000..87fd587 --- /dev/null +++ b/backend/services/experience/app/policies/analytics.py @@ -0,0 +1,25 @@ +"""Lifecycle policies for Phase 11.10 analytics aggregates.""" +from app.models.types import AnalyticsReportStatus as R, AnalyticsSnapshotStatus as S +from app.validators.integrations import ensure_transition + +_TRANSITIONS = { + R: { + R.DRAFT: frozenset({R.ACTIVE, R.ARCHIVED}), + R.ACTIVE: frozenset({R.ARCHIVED}), + R.ARCHIVED: frozenset(), + }, + S: { + S.ACTIVE: frozenset({S.ARCHIVED}), + S.ARCHIVED: frozenset(), + }, +} + + +class AnalyticsReportDefinitionPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[R], "analytics_report_definition") + + +class AnalyticsSnapshotPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[S], "analytics_snapshot") diff --git a/backend/services/experience/app/policies/bundles.py b/backend/services/experience/app/policies/bundles.py new file mode 100644 index 0000000..8f1d161 --- /dev/null +++ b/backend/services/experience/app/policies/bundles.py @@ -0,0 +1,50 @@ +"""Lifecycle policies for Phase 11.8 aggregates.""" +from app.models.types import ( + BundleDefinitionStatus as D, + FeatureToggleStatus as F, + LicenseBindingStatus as L, + TenantBundleStatus as T, +) +from app.validators.bundles import ensure_transition + +_TRANSITIONS = { + D: {D.DRAFT: frozenset({D.AVAILABLE, D.RETIRED}), D.AVAILABLE: frozenset({D.RETIRED}), D.RETIRED: frozenset()}, + T: { + T.PENDING: frozenset({T.ACTIVE, T.REVOKED}), + T.ACTIVE: frozenset({T.SUSPENDED, T.EXPIRED, T.REVOKED}), + T.SUSPENDED: frozenset({T.ACTIVE, T.REVOKED}), + T.EXPIRED: frozenset(), + T.REVOKED: frozenset(), + }, + L: { + L.PENDING: frozenset({L.ACTIVE, L.REVOKED}), + L.ACTIVE: frozenset({L.EXPIRED, L.REVOKED}), + L.EXPIRED: frozenset(), + L.REVOKED: frozenset(), + }, + F: { + F.DRAFT: frozenset({F.ACTIVE, F.ARCHIVED}), + F.ACTIVE: frozenset({F.DRAFT, F.ARCHIVED}), + F.ARCHIVED: frozenset(), + }, +} + + +class BundleDefinitionPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[D], "bundle_definition") + + +class TenantBundlePolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[T], "tenant_bundle") + + +class LicenseBindingPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[L], "license_binding") + + +class FeatureTogglePolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[F], "feature_toggle") diff --git a/backend/services/experience/app/policies/components.py b/backend/services/experience/app/policies/components.py new file mode 100644 index 0000000..ba2fe32 --- /dev/null +++ b/backend/services/experience/app/policies/components.py @@ -0,0 +1,85 @@ +"""Component domain policies — Phase 11.2.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + ComponentStatus, + ComponentVersionStatus, +) +from app.validators.components import ( + ensure_component_status_transition, + ensure_version_mutable, + ensure_version_status_transition, +) +from shared.exceptions import AppError + + +class ComponentLifecyclePolicy: + """Enforces component catalog status transitions.""" + + def assert_status_change( + self, *, current: ComponentStatus, target: ComponentStatus + ) -> ComponentStatus: + ensure_component_status_transition(current, target) + return target + + def assert_can_soft_delete(self, *, status: ComponentStatus) -> None: + if status == ComponentStatus.ARCHIVED: + return + # Soft-delete allowed from any status; archive is preferred path. + return + + +class ComponentVersionPolicy: + """Enforces immutability and publish/retire rules.""" + + def assert_can_update(self, version: Any) -> None: + ensure_version_mutable(version) + + def assert_publish( + self, *, current: ComponentVersionStatus + ) -> ComponentVersionStatus: + ensure_version_status_transition(current, ComponentVersionStatus.PUBLISHED) + return ComponentVersionStatus.PUBLISHED + + def assert_retire( + self, *, current: ComponentVersionStatus + ) -> ComponentVersionStatus: + ensure_version_status_transition(current, ComponentVersionStatus.RETIRED) + return ComponentVersionStatus.RETIRED + + +class PagePlacementPolicy: + """Composition shells may only pin published (non-retired) versions.""" + + def assert_version_placeable(self, version: Any) -> None: + status = getattr(version, "status", None) + if status != ComponentVersionStatus.PUBLISHED: + raise AppError( + "فقط نسخه منتشرشده قابل جایگذاری است", + error_code="component_version_not_published", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + if getattr(version, "is_deleted", False): + raise AppError( + "نسخه حذف‌شده قابل جایگذاری نیست", + error_code="component_version_deleted", + status_code=409, + ) + + def assert_component_placeable(self, component: Any) -> None: + status = getattr(component, "status", None) + if status in (ComponentStatus.ARCHIVED,): + raise AppError( + "کامپوننت بایگانی‌شده قابل جایگذاری نیست", + error_code="component_archived", + status_code=409, + ) + if getattr(component, "is_deleted", False): + raise AppError( + "کامپوننت حذف‌شده قابل جایگذاری نیست", + error_code="component_deleted", + status_code=409, + ) diff --git a/backend/services/experience/app/policies/forms.py b/backend/services/experience/app/policies/forms.py new file mode 100644 index 0000000..fd4bcbe --- /dev/null +++ b/backend/services/experience/app/policies/forms.py @@ -0,0 +1,112 @@ +"""Forms / surveys / appointments policies — Phase 11.6.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + AppointmentBookingStatus, + AppointmentPageStatus, + FormStatus, + FormSubmissionStatus, + SurveyResponseStatus, + SurveyStatus, +) +from app.validators.forms import ( + ensure_appointment_page_status_transition, + ensure_booking_status_transition, + ensure_form_status_transition, + ensure_response_status_transition, + ensure_submission_status_transition, + ensure_survey_status_transition, +) +from shared.exceptions import AppError + + +class FormLifecyclePolicy: + def assert_status_change( + self, *, current: FormStatus, target: FormStatus + ) -> FormStatus: + ensure_form_status_transition(current, target) + return target + + def assert_accepts_submissions(self, form: Any) -> None: + if getattr(form, "is_deleted", False): + raise AppError( + "فرم حذف‌شده قابل ارسال نیست", + error_code="form_deleted", + status_code=409, + ) + if getattr(form, "status", None) != FormStatus.ACTIVE: + raise AppError( + "فقط فرم فعال قابل ارسال است", + error_code="form_not_active", + status_code=409, + ) + + +class FormSubmissionPolicy: + def assert_status_change( + self, *, current: FormSubmissionStatus, target: FormSubmissionStatus + ) -> FormSubmissionStatus: + ensure_submission_status_transition(current, target) + return target + + +class SurveyLifecyclePolicy: + def assert_status_change( + self, *, current: SurveyStatus, target: SurveyStatus + ) -> SurveyStatus: + ensure_survey_status_transition(current, target) + return target + + def assert_accepts_responses(self, survey: Any) -> None: + if getattr(survey, "is_deleted", False): + raise AppError( + "نظرسنجی حذف‌شده قابل پاسخ نیست", + error_code="survey_deleted", + status_code=409, + ) + if getattr(survey, "status", None) != SurveyStatus.ACTIVE: + raise AppError( + "فقط نظرسنجی فعال قابل پاسخ است", + error_code="survey_not_active", + status_code=409, + ) + + +class SurveyResponsePolicy: + def assert_status_change( + self, *, current: SurveyResponseStatus, target: SurveyResponseStatus + ) -> SurveyResponseStatus: + ensure_response_status_transition(current, target) + return target + + +class AppointmentPagePolicy: + def assert_status_change( + self, *, current: AppointmentPageStatus, target: AppointmentPageStatus + ) -> AppointmentPageStatus: + ensure_appointment_page_status_transition(current, target) + return target + + def assert_accepts_bookings(self, page: Any) -> None: + if getattr(page, "is_deleted", False): + raise AppError( + "صفحه نوبت حذف‌شده قابل رزرو نیست", + error_code="appointment_page_deleted", + status_code=409, + ) + if getattr(page, "status", None) != AppointmentPageStatus.ACTIVE: + raise AppError( + "فقط صفحه نوبت فعال قابل رزرو است", + error_code="appointment_page_not_active", + status_code=409, + ) + + +class AppointmentBookingPolicy: + def assert_status_change( + self, *, current: AppointmentBookingStatus, target: AppointmentBookingStatus + ) -> AppointmentBookingStatus: + ensure_booking_status_transition(current, target) + return target diff --git a/backend/services/experience/app/policies/integrations.py b/backend/services/experience/app/policies/integrations.py new file mode 100644 index 0000000..185470b --- /dev/null +++ b/backend/services/experience/app/policies/integrations.py @@ -0,0 +1,66 @@ +"""Lifecycle policies for Phase 11.9 aggregates.""" +from app.models.types import ( + ConnectorDispatchStatus as D, + ConsumerConnectorStatus as C, + NotifyDispatchStatus as N, + WidgetDefinitionStatus as WD, + WidgetInstanceStatus as WI, +) +from app.validators.integrations import ensure_transition + +_TRANSITIONS = { + C: { + C.DRAFT: frozenset({C.ACTIVE, C.RETIRED}), + C.ACTIVE: frozenset({C.SUSPENDED, C.RETIRED}), + C.SUSPENDED: frozenset({C.ACTIVE, C.RETIRED}), + C.RETIRED: frozenset(), + }, + D: { + D.PENDING: frozenset({D.ACKNOWLEDGED, D.FAILED, D.CANCELLED}), + D.ACKNOWLEDGED: frozenset(), + D.FAILED: frozenset({D.PENDING, D.CANCELLED}), + D.CANCELLED: frozenset(), + }, + WD: { + WD.DRAFT: frozenset({WD.AVAILABLE, WD.RETIRED}), + WD.AVAILABLE: frozenset({WD.RETIRED}), + WD.RETIRED: frozenset(), + }, + WI: { + WI.DRAFT: frozenset({WI.ACTIVE, WI.ARCHIVED}), + WI.ACTIVE: frozenset({WI.SUSPENDED, WI.ARCHIVED}), + WI.SUSPENDED: frozenset({WI.ACTIVE, WI.ARCHIVED}), + WI.ARCHIVED: frozenset(), + }, + N: { + N.PENDING: frozenset({N.SENT, N.FAILED, N.CANCELLED}), + N.SENT: frozenset(), + N.FAILED: frozenset({N.PENDING, N.CANCELLED}), + N.CANCELLED: frozenset(), + }, +} + + +class ConsumerConnectorRegistrationPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[C], "consumer_connector_registration") + + +class ConsumerConnectorDispatchPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[D], "consumer_connector_dispatch") + + +class WidgetDefinitionPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[WD], "widget_definition") + + +class WidgetInstancePolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[WI], "widget_instance") + + +class NotifyDispatchPolicy: + def assert_status_change(self, *, current, target): + ensure_transition(current, target, _TRANSITIONS[N], "notify_dispatch") diff --git a/backend/services/experience/app/policies/localization.py b/backend/services/experience/app/policies/localization.py new file mode 100644 index 0000000..95c032e --- /dev/null +++ b/backend/services/experience/app/policies/localization.py @@ -0,0 +1,61 @@ +"""Localization & media-ref domain policies — Phase 11.5.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import LocalizationStatus, MediaRefStatus +from app.validators.localization import ( + ensure_localization_status_transition, + ensure_media_ref_status_transition, + ensure_no_binary_media_payload, + validate_storage_file_ref, +) +from shared.exceptions import AppError + + +class SiteLocaleBindingPolicy: + def assert_profile_usable(self, profile: Any) -> None: + if profile is None: + raise AppError( + "پروفایل locale یافت نشد", + error_code="locale_profile_not_found", + status_code=404, + ) + if getattr(profile, "is_deleted", False): + raise AppError( + "پروفایل locale حذف‌شده قابل اتصال نیست", + error_code="locale_profile_deleted", + status_code=409, + ) + status = getattr(profile, "status", None) + status_value = getattr(status, "value", status) + if status_value in {"archived", "suspended", "inactive"}: + raise AppError( + "پروفایل locale غیرفعال قابل اتصال نیست", + error_code="locale_profile_inactive", + status_code=409, + details={"status": status_value}, + ) + + +class LocalizationPolicy: + def assert_status_change( + self, *, current: LocalizationStatus, target: LocalizationStatus + ) -> LocalizationStatus: + ensure_localization_status_transition(current, target) + return target + + +class MediaRefPolicy: + def assert_storage_ref_only( + self, *, storage_file_ref: str, metadata_json: dict | None = None + ) -> str: + cleaned = validate_storage_file_ref(storage_file_ref) + ensure_no_binary_media_payload(metadata_json) + return cleaned + + def assert_status_change( + self, *, current: MediaRefStatus, target: MediaRefStatus + ) -> MediaRefStatus: + ensure_media_ref_status_transition(current, target) + return target diff --git a/backend/services/experience/app/policies/publishing.py b/backend/services/experience/app/policies/publishing.py new file mode 100644 index 0000000..5c989b0 --- /dev/null +++ b/backend/services/experience/app/policies/publishing.py @@ -0,0 +1,64 @@ +"""Publishing / SEO / PWA policies — Phase 11.7.""" +from __future__ import annotations + +from app.models.types import ( + DomainEdgeStatus, + PublishReleaseStatus, + PwaStatus, + RobotsStatus, + SeoStatus, + SitemapStatus, +) +from app.validators.publishing import ( + ensure_edge_status_transition, + ensure_publish_status_transition, + ensure_simple_status_transition, +) + + +class PublishReleasePolicy: + def assert_status_change( + self, *, current: PublishReleaseStatus, target: PublishReleaseStatus + ) -> PublishReleaseStatus: + ensure_publish_status_transition(current, target) + return target + + +class DomainEdgeBindingPolicy: + def assert_status_change( + self, *, current: DomainEdgeStatus, target: DomainEdgeStatus + ) -> DomainEdgeStatus: + ensure_edge_status_transition(current, target) + return target + + +class SeoPolicy: + def assert_status_change( + self, *, current: SeoStatus, target: SeoStatus + ) -> SeoStatus: + ensure_simple_status_transition(current, target, label="seo") + return target + + +class SitemapPolicy: + def assert_status_change( + self, *, current: SitemapStatus, target: SitemapStatus + ) -> SitemapStatus: + ensure_simple_status_transition(current, target, label="sitemap") + return target + + +class RobotsPolicy: + def assert_status_change( + self, *, current: RobotsStatus, target: RobotsStatus + ) -> RobotsStatus: + ensure_simple_status_transition(current, target, label="robots") + return target + + +class PwaPolicy: + def assert_status_change( + self, *, current: PwaStatus, target: PwaStatus + ) -> PwaStatus: + ensure_simple_status_transition(current, target, label="pwa") + return target diff --git a/backend/services/experience/app/policies/templates.py b/backend/services/experience/app/policies/templates.py new file mode 100644 index 0000000..4afec98 --- /dev/null +++ b/backend/services/experience/app/policies/templates.py @@ -0,0 +1,74 @@ +"""Template domain policies — Phase 11.4.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import TemplateStatus, TemplateVersionStatus +from app.validators.templates import ( + ensure_template_status_transition, + ensure_template_version_mutable, + ensure_template_version_status_transition, +) +from shared.exceptions import AppError + + +class TemplateLifecyclePolicy: + def assert_status_change( + self, *, current: TemplateStatus, target: TemplateStatus + ) -> TemplateStatus: + ensure_template_status_transition(current, target) + return target + + +class TemplateVersionPolicy: + def assert_can_update(self, version: Any) -> None: + ensure_template_version_mutable(version) + + def assert_publish( + self, *, current: TemplateVersionStatus + ) -> TemplateVersionStatus: + ensure_template_version_status_transition( + current, TemplateVersionStatus.PUBLISHED + ) + return TemplateVersionStatus.PUBLISHED + + def assert_retire( + self, *, current: TemplateVersionStatus + ) -> TemplateVersionStatus: + ensure_template_version_status_transition( + current, TemplateVersionStatus.RETIRED + ) + return TemplateVersionStatus.RETIRED + + +class TemplateInstantiationPolicy: + def assert_template_instantiable(self, template: Any) -> None: + status = getattr(template, "status", None) + if status == TemplateStatus.ARCHIVED: + raise AppError( + "قالب بایگانی‌شده قابل نمونه‌سازی نیست", + error_code="template_archived", + status_code=409, + ) + if getattr(template, "is_deleted", False): + raise AppError( + "قالب حذف‌شده قابل نمونه‌سازی نیست", + error_code="template_deleted", + status_code=409, + ) + + def assert_version_instantiable(self, version: Any) -> None: + status = getattr(version, "status", None) + if status != TemplateVersionStatus.PUBLISHED: + raise AppError( + "فقط نسخه منتشرشده قالب قابل نمونه‌سازی است", + error_code="template_version_not_published", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + if getattr(version, "is_deleted", False): + raise AppError( + "نسخه حذف‌شده قالب قابل نمونه‌سازی نیست", + error_code="template_version_deleted", + status_code=409, + ) diff --git a/backend/services/experience/app/policies/themes.py b/backend/services/experience/app/policies/themes.py new file mode 100644 index 0000000..b93afba --- /dev/null +++ b/backend/services/experience/app/policies/themes.py @@ -0,0 +1,77 @@ +"""Theme & layout domain policies — Phase 11.3 (versioned).""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) +from app.validators.themes import ( + ensure_assignable_layout, + ensure_assignable_layout_version, + ensure_assignable_theme, + ensure_assignable_theme_version, + ensure_layout_status_transition, + ensure_layout_version_mutable, + ensure_layout_version_status_transition, + ensure_theme_status_transition, + ensure_theme_version_mutable, + ensure_theme_version_status_transition, +) + + +class ThemeLifecyclePolicy: + def assert_status_change( + self, *, current: ThemeStatus, target: ThemeStatus + ) -> ThemeStatus: + ensure_theme_status_transition(current, target) + return target + + +class ThemeVersionPolicy: + def assert_can_update(self, version: Any) -> None: + ensure_theme_version_mutable(version) + + def assert_publish(self, *, current: ThemeVersionStatus) -> None: + ensure_theme_version_status_transition(current, ThemeVersionStatus.PUBLISHED) + + def assert_retire(self, *, current: ThemeVersionStatus) -> None: + ensure_theme_version_status_transition(current, ThemeVersionStatus.RETIRED) + + +class LayoutLifecyclePolicy: + def assert_status_change( + self, *, current: LayoutStatus, target: LayoutStatus + ) -> LayoutStatus: + ensure_layout_status_transition(current, target) + return target + + +class LayoutVersionPolicy: + def assert_can_update(self, version: Any) -> None: + ensure_layout_version_mutable(version) + + def assert_publish(self, *, current: LayoutVersionStatus) -> None: + ensure_layout_version_status_transition(current, LayoutVersionStatus.PUBLISHED) + + def assert_retire(self, *, current: LayoutVersionStatus) -> None: + ensure_layout_version_status_transition(current, LayoutVersionStatus.RETIRED) + + +class SiteThemeBindingPolicy: + def assert_theme_assignable(self, theme: Any) -> None: + ensure_assignable_theme(theme) + + def assert_version_assignable(self, version: Any) -> None: + ensure_assignable_theme_version(version) + + +class PageLayoutBindingPolicy: + def assert_layout_assignable(self, layout: Any) -> None: + ensure_assignable_layout(layout) + + def assert_version_assignable(self, version: Any) -> None: + ensure_assignable_layout_version(version) diff --git a/backend/services/experience/app/providers/__init__.py b/backend/services/experience/app/providers/__init__.py new file mode 100644 index 0000000..5d34358 --- /dev/null +++ b/backend/services/experience/app/providers/__init__.py @@ -0,0 +1,18 @@ +"""Platform provider contracts package.""" +from app.providers.contracts import ( # noqa: F401 + AIProvider, + AccountingProvider, + AutomationProvider, + CRMProvider, + CommunicationProvider, + DeliveryProvider, + HospitalityProvider, + IdentityProvider, + LoyaltyProvider, + MarketplaceProvider, + NotificationProvider, + RenderEngineProvider, + SportsCenterProvider, + StorageProvider, + ThemeMarketplaceProvider, +) diff --git a/backend/services/experience/app/providers/contracts.py b/backend/services/experience/app/providers/contracts.py new file mode 100644 index 0000000..cdfef64 --- /dev/null +++ b/backend/services/experience/app/providers/contracts.py @@ -0,0 +1,136 @@ +"""Platform capability contracts — interfaces only; no implementations in Experience.""" +from __future__ import annotations + +from typing import Any, Protocol +from uuid import UUID + + +class AccountingProvider(Protocol): + """Contract for Accounting. Experience must not post journals.""" + + async def resolve_account_ref( + self, *, tenant_id: UUID, reference: str + ) -> dict[str, Any]: ... + + +class CRMProvider(Protocol): + """Contract for CRM. Experience stores external contact/org refs only.""" + + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: ... + + +class LoyaltyProvider(Protocol): + """Contract for Loyalty. Experience must not own points/ledger.""" + + async def resolve_member( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class CommunicationProvider(Protocol): + """Contract for Communication. Experience must not own SMS providers.""" + + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> None: ... + + +class NotificationProvider(Protocol): + """Contract for Notification fanout.""" + + async def notify( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + payload: dict[str, Any], + ) -> None: ... + + +class StorageProvider(Protocol): + """Contract for File Storage. Experience stores media refs only.""" + + async def resolve_file( + self, *, tenant_id: UUID, file_ref: str + ) -> dict[str, Any]: ... + + +class AIProvider(Protocol): + """Contract for AI Platform. Experience must not own model inference.""" + + async def generate_content( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: ... + + +class IdentityProvider(Protocol): + """Contract for Identity. Experience stores external_user_ref only.""" + + async def resolve_user( + self, *, tenant_id: UUID, user_ref: str + ) -> dict[str, Any]: ... + + +class RenderEngineProvider(Protocol): + """Contract for pluggable render/SSG engines — adapters live in Experience.""" + + async def render_preview( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: ... + + +class ThemeMarketplaceProvider(Protocol): + """Contract for external theme packs — adapters live in Experience.""" + + async def resolve_theme_pack( + self, *, tenant_id: UUID, request: dict[str, Any] + ) -> dict[str, Any]: ... + + +class HospitalityProvider(Protocol): + """Contract for Hospitality menu/venue refs — Experience must not own menus.""" + + async def resolve_menu_ref( + self, *, tenant_id: UUID, menu_ref: str + ) -> dict[str, Any]: ... + + +class MarketplaceProvider(Protocol): + """Contract for Marketplace catalog refs — Experience must not own products.""" + + async def resolve_product_ref( + self, *, tenant_id: UUID, product_ref: str + ) -> dict[str, Any]: ... + + +class SportsCenterProvider(Protocol): + """Contract for Sports Center entity refs.""" + + async def resolve_member_ref( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: ... + + +class DeliveryProvider(Protocol): + """Contract for Delivery job refs on campaign pages.""" + + async def resolve_job_ref( + self, *, tenant_id: UUID, job_ref: str + ) -> dict[str, Any]: ... + + +class AutomationProvider(Protocol): + """Contract for Automation triggers from form/publish events.""" + + async def emit_trigger( + self, *, tenant_id: UUID, trigger_key: str, payload: dict[str, Any] + ) -> None: ... diff --git a/backend/services/experience/app/providers/mocks.py b/backend/services/experience/app/providers/mocks.py new file mode 100644 index 0000000..62782a4 --- /dev/null +++ b/backend/services/experience/app/providers/mocks.py @@ -0,0 +1,93 @@ +"""No-op mock provider implementations — Phase 11.9. + +Used by consumer connector dispatches and notify send flows. No real network +calls or cross-service imports. +""" +from __future__ import annotations + +from typing import Any +from uuid import UUID, uuid4 + + +class MockCommunicationProvider: + async def send( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + to: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return { + "ok": True, + "provider": "mock_communication", + "communication_request_ref": f"communication:request:{uuid4()}", + "channel": channel, + "template_key": template_key, + "to": to, + "tenant_id": str(tenant_id), + "payload": payload, + } + + +class MockNotificationProvider: + async def notify( + self, + *, + tenant_id: UUID, + channel: str, + template_key: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + return { + "ok": True, + "provider": "mock_notification", + "communication_request_ref": f"communication:request:{uuid4()}", + "channel": channel, + "template_key": template_key, + "tenant_id": str(tenant_id), + "payload": payload, + } + + +class MockHospitalityProvider: + async def resolve_menu_ref( + self, *, tenant_id: UUID, menu_ref: str + ) -> dict[str, Any]: + return {"ok": True, "menu_ref": menu_ref, "provider": "mock_hospitality"} + + +class MockMarketplaceProvider: + async def resolve_product_ref( + self, *, tenant_id: UUID, product_ref: str + ) -> dict[str, Any]: + return {"ok": True, "product_ref": product_ref, "provider": "mock_marketplace"} + + +class MockSportsCenterProvider: + async def resolve_member_ref( + self, *, tenant_id: UUID, member_ref: str + ) -> dict[str, Any]: + return {"ok": True, "member_ref": member_ref, "provider": "mock_sports_center"} + + +class MockCRMProvider: + async def resolve_contact( + self, *, tenant_id: UUID, contact_ref: str + ) -> dict[str, Any]: + return {"ok": True, "contact_ref": contact_ref, "provider": "mock_crm"} + + +class MockAIProvider: + async def generate_content( + self, *, tenant_id: UUID, task: str, context: dict[str, Any] + ) -> dict[str, Any]: + return { + "ok": True, + "provider": "mock_ai", + "generated_content_ref": f"ai:content:{uuid4()}", + "task": task, + "tenant_id": str(tenant_id), + "context": context, + } diff --git a/backend/services/experience/app/queries/__init__.py b/backend/services/experience/app/queries/__init__.py new file mode 100644 index 0000000..0458653 --- /dev/null +++ b/backend/services/experience/app/queries/__init__.py @@ -0,0 +1,16 @@ +"""Experience queries package.""" +from app.queries.components import ComponentQueries +from app.queries.forms import FormsQueries +from app.queries.localization import LocalizationQueries +from app.queries.publishing import PublishingQueries +from app.queries.templates import TemplateQueries +from app.queries.themes import ThemeLayoutQueries + +__all__ = [ + "ComponentQueries", + "ThemeLayoutQueries", + "TemplateQueries", + "LocalizationQueries", + "FormsQueries", + "PublishingQueries", +] diff --git a/backend/services/experience/app/queries/ai_hooks.py b/backend/services/experience/app/queries/ai_hooks.py new file mode 100644 index 0000000..4a25df5 --- /dev/null +++ b/backend/services/experience/app/queries/ai_hooks.py @@ -0,0 +1,19 @@ +"""Query handlers for Phase 11.10 AI content hooks.""" +from app.services.ai_hooks import ExperienceAiHooksService + + +class AiHooksQueries: + def __init__(self, session): + self.service = ExperienceAiHooksService(session) + + async def get_hook_registration(self, tenant_id, entity_id): + return await self.service.get_hook_registration(tenant_id, entity_id) + + async def list_hook_registrations(self, tenant_id, **kwargs): + return await self.service.list_hook_registrations(tenant_id, **kwargs) + + async def get_dispatch(self, tenant_id, entity_id): + return await self.service.get_dispatch(tenant_id, entity_id) + + async def list_dispatches(self, tenant_id, **kwargs): + return await self.service.list_dispatches(tenant_id, **kwargs) diff --git a/backend/services/experience/app/queries/analytics.py b/backend/services/experience/app/queries/analytics.py new file mode 100644 index 0000000..7db2b60 --- /dev/null +++ b/backend/services/experience/app/queries/analytics.py @@ -0,0 +1,19 @@ +"""Query handlers for Phase 11.10 analytics.""" +from app.services.analytics import ExperienceAnalyticsService + + +class AnalyticsQueries: + def __init__(self, session): + self.service = ExperienceAnalyticsService(session) + + async def get_report_definition(self, tenant_id, entity_id): + return await self.service.get_report_definition(tenant_id, entity_id) + + async def list_report_definitions(self, tenant_id, **kwargs): + return await self.service.list_report_definitions(tenant_id, **kwargs) + + async def get_snapshot(self, tenant_id, entity_id): + return await self.service.get_snapshot(tenant_id, entity_id) + + async def list_snapshots(self, tenant_id, **kwargs): + return await self.service.list_snapshots(tenant_id, **kwargs) diff --git a/backend/services/experience/app/queries/bundles.py b/backend/services/experience/app/queries/bundles.py new file mode 100644 index 0000000..42667a8 --- /dev/null +++ b/backend/services/experience/app/queries/bundles.py @@ -0,0 +1,31 @@ +"""Query handlers for Phase 11.8.""" +from app.services.bundles import ExperienceBundlesService + + +class BundlesQueries: + def __init__(self, session): + self.service = ExperienceBundlesService(session) + + async def get_bundle_definition(self, tenant_id, entity_id): + return await self.service.get_bundle_definition(tenant_id, entity_id) + + async def list_bundle_definitions(self, tenant_id, **kwargs): + return await self.service.list_bundle_definitions(tenant_id, **kwargs) + + async def get_tenant_bundle(self, tenant_id, entity_id): + return await self.service.get_tenant_bundle(tenant_id, entity_id) + + async def list_tenant_bundles(self, tenant_id, **kwargs): + return await self.service.list_tenant_bundles(tenant_id, **kwargs) + + async def get_license_binding(self, tenant_id, entity_id): + return await self.service.get_license_binding(tenant_id, entity_id) + + async def list_license_bindings(self, tenant_id, **kwargs): + return await self.service.list_license_bindings(tenant_id, **kwargs) + + async def get_feature_toggle(self, tenant_id, entity_id): + return await self.service.get_feature_toggle(tenant_id, entity_id) + + async def list_feature_toggles(self, tenant_id, **kwargs): + return await self.service.list_feature_toggles(tenant_id, **kwargs) diff --git a/backend/services/experience/app/queries/components.py b/backend/services/experience/app/queries/components.py new file mode 100644 index 0000000..0b9186d --- /dev/null +++ b/backend/services/experience/app/queries/components.py @@ -0,0 +1,63 @@ +"""Component query handlers — Phase 11.2.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.components import ExperienceComponentService +from app.specifications.components import ( + ComponentListSpec, + ComponentVersionListSpec, + PlacementListSpec, +) + + +class ComponentQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceComponentService(session) + + async def get_component(self, tenant_id: UUID, component_id: UUID): + return await self.service.get_component(tenant_id, component_id) + + async def list_components( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ComponentListSpec | None, + ): + return await self.service.list_components( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_version(self, tenant_id: UUID, version_id: UUID): + return await self.service.get_version(tenant_id, version_id) + + async def list_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ComponentVersionListSpec | None, + ): + return await self.service.list_versions( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_placement(self, tenant_id: UUID, placement_id: UUID): + return await self.service.get_placement(tenant_id, placement_id) + + async def list_placements( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: PlacementListSpec | None, + ): + return await self.service.list_placements( + tenant_id, offset=offset, limit=limit, spec=spec + ) diff --git a/backend/services/experience/app/queries/forms.py b/backend/services/experience/app/queries/forms.py new file mode 100644 index 0000000..c193f31 --- /dev/null +++ b/backend/services/experience/app/queries/forms.py @@ -0,0 +1,111 @@ +"""Forms / surveys / appointments query handlers — Phase 11.6.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.forms import ExperienceFormsService +from app.specifications.forms import ( + AppointmentBookingListSpec, + AppointmentPageListSpec, + FormListSpec, + FormSubmissionListSpec, + SurveyListSpec, + SurveyResponseListSpec, +) + + +class FormsQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceFormsService(session) + + async def get_form(self, tenant_id: UUID, form_id: UUID): + return await self.service.get_form(tenant_id, form_id) + + async def list_forms( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: FormListSpec | None, + ): + return await self.service.list_forms( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_submission(self, tenant_id: UUID, submission_id: UUID): + return await self.service.get_submission(tenant_id, submission_id) + + async def list_submissions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: FormSubmissionListSpec | None, + ): + return await self.service.list_submissions( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_survey(self, tenant_id: UUID, survey_id: UUID): + return await self.service.get_survey(tenant_id, survey_id) + + async def list_surveys( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SurveyListSpec | None, + ): + return await self.service.list_surveys( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_survey_response(self, tenant_id: UUID, response_id: UUID): + return await self.service.get_survey_response(tenant_id, response_id) + + async def list_survey_responses( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SurveyResponseListSpec | None, + ): + return await self.service.list_survey_responses( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_appointment_page(self, tenant_id: UUID, page_id: UUID): + return await self.service.get_appointment_page(tenant_id, page_id) + + async def list_appointment_pages( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: AppointmentPageListSpec | None, + ): + return await self.service.list_appointment_pages( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_booking_ref(self, tenant_id: UUID, booking_id: UUID): + return await self.service.get_booking_ref(tenant_id, booking_id) + + async def list_booking_refs( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: AppointmentBookingListSpec | None, + ): + return await self.service.list_booking_refs( + tenant_id, offset=offset, limit=limit, spec=spec + ) diff --git a/backend/services/experience/app/queries/integrations.py b/backend/services/experience/app/queries/integrations.py new file mode 100644 index 0000000..0b98d00 --- /dev/null +++ b/backend/services/experience/app/queries/integrations.py @@ -0,0 +1,40 @@ +"""Query handlers for Phase 11.9.""" +from app.services.integrations import ExperienceIntegrationsService + + +class IntegrationsQueries: + def __init__(self, session): + self.service = ExperienceIntegrationsService(session) + + async def get_consumer_connector_registration(self, tenant_id, entity_id): + return await self.service.get_consumer_connector_registration(tenant_id, entity_id) + + async def list_consumer_connector_registrations(self, tenant_id, **kwargs): + return await self.service.list_consumer_connector_registrations(tenant_id, **kwargs) + + async def get_consumer_connector_dispatch(self, tenant_id, entity_id): + return await self.service.get_consumer_connector_dispatch(tenant_id, entity_id) + + async def list_consumer_connector_dispatches(self, tenant_id, **kwargs): + return await self.service.list_consumer_connector_dispatches(tenant_id, **kwargs) + + async def get_widget_definition(self, tenant_id, entity_id): + return await self.service.get_widget_definition(tenant_id, entity_id) + + async def list_widget_definitions(self, tenant_id, **kwargs): + return await self.service.list_widget_definitions(tenant_id, **kwargs) + + async def get_widget_instance(self, tenant_id, entity_id): + return await self.service.get_widget_instance(tenant_id, entity_id) + + async def get_widget_instance_by_embed(self, tenant_id, embed_ref): + return await self.service.get_widget_instance_by_embed(tenant_id, embed_ref) + + async def list_widget_instances(self, tenant_id, **kwargs): + return await self.service.list_widget_instances(tenant_id, **kwargs) + + async def get_notify_dispatch(self, tenant_id, entity_id): + return await self.service.get_notify_dispatch(tenant_id, entity_id) + + async def list_notify_dispatches(self, tenant_id, **kwargs): + return await self.service.list_notify_dispatches(tenant_id, **kwargs) diff --git a/backend/services/experience/app/queries/localization.py b/backend/services/experience/app/queries/localization.py new file mode 100644 index 0000000..da319ce --- /dev/null +++ b/backend/services/experience/app/queries/localization.py @@ -0,0 +1,63 @@ +"""Localization & media-ref query handlers — Phase 11.5.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.localization import ExperienceLocalizationService +from app.specifications.localization import ( + LocalizationListSpec, + MediaRefListSpec, + SiteLocaleBindingListSpec, +) + + +class LocalizationQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceLocalizationService(session) + + async def get_site_locale_binding(self, tenant_id: UUID, binding_id: UUID): + return await self.service.get_site_locale_binding(tenant_id, binding_id) + + async def list_site_locale_bindings( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SiteLocaleBindingListSpec | None, + ): + return await self.service.list_site_locale_bindings( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_localization(self, tenant_id: UUID, localization_id: UUID): + return await self.service.get_localization(tenant_id, localization_id) + + async def list_localizations( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LocalizationListSpec | None, + ): + return await self.service.list_localizations( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_media_ref(self, tenant_id: UUID, media_ref_id: UUID): + return await self.service.get_media_ref(tenant_id, media_ref_id) + + async def list_media_refs( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: MediaRefListSpec | None, + ): + return await self.service.list_media_refs( + tenant_id, offset=offset, limit=limit, spec=spec + ) diff --git a/backend/services/experience/app/queries/publishing.py b/backend/services/experience/app/queries/publishing.py new file mode 100644 index 0000000..59b4055 --- /dev/null +++ b/backend/services/experience/app/queries/publishing.py @@ -0,0 +1,49 @@ +"""Publishing, SEO and PWA query handlers — Phase 11.7.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.publishing import ExperiencePublishingService + + +class PublishingQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperiencePublishingService(session) + + async def get_publish_release(self, tenant_id: UUID, entity_id: UUID): + return await self.service.get_publish_release(tenant_id, entity_id) + + async def list_publish_releases(self, tenant_id: UUID, *, offset: int, limit: int, spec=None): + return await self.service.list_publish_releases(tenant_id, offset=offset, limit=limit, spec=spec) + + async def get_domain_edge_binding(self, tenant_id: UUID, entity_id: UUID): + return await self.service.get_domain_edge_binding(tenant_id, entity_id) + + async def list_domain_edge_bindings(self, tenant_id: UUID, *, offset: int, limit: int, spec=None): + return await self.service.list_domain_edge_bindings(tenant_id, offset=offset, limit=limit, spec=spec) + + async def get_seo(self, tenant_id: UUID, entity_id: UUID): + return await self.service.get_seo(tenant_id, entity_id) + + async def list_seo(self, tenant_id: UUID, *, offset: int, limit: int, spec=None): + return await self.service.list_seo(tenant_id, offset=offset, limit=limit, spec=spec) + + async def get_sitemap(self, tenant_id: UUID, entity_id: UUID): + return await self.service.get_sitemap(tenant_id, entity_id) + + async def list_sitemaps(self, tenant_id: UUID, *, offset: int, limit: int, spec=None): + return await self.service.list_sitemaps(tenant_id, offset=offset, limit=limit, spec=spec) + + async def get_robots(self, tenant_id: UUID, entity_id: UUID): + return await self.service.get_robots(tenant_id, entity_id) + + async def list_robots(self, tenant_id: UUID, *, offset: int, limit: int, spec=None): + return await self.service.list_robots(tenant_id, offset=offset, limit=limit, spec=spec) + + async def get_pwa(self, tenant_id: UUID, entity_id: UUID): + return await self.service.get_pwa(tenant_id, entity_id) + + async def list_pwa(self, tenant_id: UUID, *, offset: int, limit: int, spec=None): + return await self.service.list_pwa(tenant_id, offset=offset, limit=limit, spec=spec) diff --git a/backend/services/experience/app/queries/templates.py b/backend/services/experience/app/queries/templates.py new file mode 100644 index 0000000..f924993 --- /dev/null +++ b/backend/services/experience/app/queries/templates.py @@ -0,0 +1,63 @@ +"""Template query handlers — Phase 11.4.""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.templates import ExperienceTemplateService +from app.specifications.templates import ( + InstantiationListSpec, + TemplateListSpec, + TemplateVersionListSpec, +) + + +class TemplateQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceTemplateService(session) + + async def get_template(self, tenant_id: UUID, template_id: UUID): + return await self.service.get_template(tenant_id, template_id) + + async def list_templates( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: TemplateListSpec | None, + ): + return await self.service.list_templates( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_version(self, tenant_id: UUID, version_id: UUID): + return await self.service.get_version(tenant_id, version_id) + + async def list_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: TemplateVersionListSpec | None, + ): + return await self.service.list_versions( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def get_instantiation(self, tenant_id: UUID, instantiation_id: UUID): + return await self.service.get_instantiation(tenant_id, instantiation_id) + + async def list_instantiations( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: InstantiationListSpec | None, + ): + return await self.service.list_instantiations( + tenant_id, offset=offset, limit=limit, spec=spec + ) diff --git a/backend/services/experience/app/queries/themes.py b/backend/services/experience/app/queries/themes.py new file mode 100644 index 0000000..4e5b01b --- /dev/null +++ b/backend/services/experience/app/queries/themes.py @@ -0,0 +1,123 @@ +"""Theme & layout query handlers — Phase 11.3 (versioned).""" +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.themes import ExperienceThemeService +from app.specifications.themes import ( + LayoutListSpec, + LayoutVersionListSpec, + PageLayoutBindingListSpec, + SiteThemeBindingListSpec, + ThemeListSpec, + ThemeVersionListSpec, +) + + +class ThemeLayoutQueries: + def __init__(self, session: AsyncSession) -> None: + self.service = ExperienceThemeService(session) + + # --- Themes --- + + async def get_theme(self, tenant_id: UUID, theme_id: UUID): + return await self.service.get_theme(tenant_id, theme_id) + + async def list_themes( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ThemeListSpec | None, + ): + return await self.service.list_themes( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + # --- Theme Versions --- + + async def get_theme_version(self, tenant_id: UUID, version_id: UUID): + return await self.service.get_theme_version(tenant_id, version_id) + + async def list_theme_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ThemeVersionListSpec | None, + ): + return await self.service.list_theme_versions( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + # --- Layouts --- + + async def get_layout(self, tenant_id: UUID, layout_id: UUID): + return await self.service.get_layout(tenant_id, layout_id) + + async def list_layouts( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LayoutListSpec | None, + ): + return await self.service.list_layouts( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + # --- Layout Versions --- + + async def get_layout_version(self, tenant_id: UUID, version_id: UUID): + return await self.service.get_layout_version(tenant_id, version_id) + + async def list_layout_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LayoutVersionListSpec | None, + ): + return await self.service.list_layout_versions( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + # --- Site Theme Bindings --- + + async def get_site_theme_binding(self, tenant_id: UUID, binding_id: UUID): + return await self.service.get_site_theme_binding(tenant_id, binding_id) + + async def list_site_theme_bindings( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SiteThemeBindingListSpec | None, + ): + return await self.service.list_site_theme_bindings( + tenant_id, offset=offset, limit=limit, spec=spec + ) + + # --- Page Layout Bindings --- + + async def get_page_layout_binding(self, tenant_id: UUID, binding_id: UUID): + return await self.service.get_page_layout_binding(tenant_id, binding_id) + + async def list_page_layout_bindings( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: PageLayoutBindingListSpec | None, + ): + return await self.service.list_page_layout_bindings( + tenant_id, offset=offset, limit=limit, spec=spec + ) diff --git a/backend/services/experience/app/repositories/ai_hooks.py b/backend/services/experience/app/repositories/ai_hooks.py new file mode 100644 index 0000000..e739267 --- /dev/null +++ b/backend/services/experience/app/repositories/ai_hooks.py @@ -0,0 +1,49 @@ +"""Tenant-scoped repositories for Phase 11.10 AI content hooks.""" +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.ai_hooks import ( + ExperienceAiContentDispatch, + ExperienceAiContentHookRegistration, +) +from app.repositories.base import TenantBaseRepository + + +class _FilteredRepository: + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + base = select(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + count = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + if spec: + base = spec.apply(base) + count = count.where(*spec.filter_clauses()) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count)).scalar_one()) + items = (await self.session.execute(base.offset(offset).limit(limit))).scalars().all() + return items, total + + +class AiContentHookRegistrationRepository( + _FilteredRepository, TenantBaseRepository[ExperienceAiContentHookRegistration] +): + model = ExperienceAiContentHookRegistration + + async def get_by_code(self, tenant_id: UUID, code: str): + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AiContentDispatchRepository( + _FilteredRepository, TenantBaseRepository[ExperienceAiContentDispatch] +): + model = ExperienceAiContentDispatch diff --git a/backend/services/experience/app/repositories/analytics.py b/backend/services/experience/app/repositories/analytics.py new file mode 100644 index 0000000..fa83cd5 --- /dev/null +++ b/backend/services/experience/app/repositories/analytics.py @@ -0,0 +1,49 @@ +"""Tenant-scoped repositories for Phase 11.10 analytics.""" +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.analytics import ( + ExperienceAnalyticsReportDefinition, + ExperienceAnalyticsSnapshot, +) +from app.repositories.base import TenantBaseRepository + + +class _FilteredRepository: + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + base = select(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + count = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + if spec: + base = spec.apply(base) + count = count.where(*spec.filter_clauses()) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count)).scalar_one()) + items = (await self.session.execute(base.offset(offset).limit(limit))).scalars().all() + return items, total + + +class AnalyticsReportDefinitionRepository( + _FilteredRepository, TenantBaseRepository[ExperienceAnalyticsReportDefinition] +): + model = ExperienceAnalyticsReportDefinition + + async def get_by_code(self, tenant_id: UUID, code: str): + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class AnalyticsSnapshotRepository( + _FilteredRepository, TenantBaseRepository[ExperienceAnalyticsSnapshot] +): + model = ExperienceAnalyticsSnapshot diff --git a/backend/services/experience/app/repositories/base.py b/backend/services/experience/app/repositories/base.py new file mode 100644 index 0000000..0c6dd9d --- /dev/null +++ b/backend/services/experience/app/repositories/base.py @@ -0,0 +1,78 @@ +"""Tenant-aware base repository with soft-delete helpers.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Generic, Sequence, TypeVar +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import Base + +ModelT = TypeVar("ModelT", bound=Base) + + +class TenantBaseRepository(Generic[ModelT]): + model: type[ModelT] + + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None: + clauses = [ + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_including_deleted( + self, tenant_id: UUID, entity_id: UUID + ) -> ModelT | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, # type: ignore[attr-defined] + self.model.id == entity_id, # type: ignore[attr-defined] + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def add(self, entity: ModelT) -> ModelT: + self.session.add(entity) + await self.session.flush() + return entity + + async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None: + entity.is_deleted = True # type: ignore[attr-defined] + entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined] + if deleted_by is not None and hasattr(entity, "deleted_by"): + entity.deleted_by = deleted_by # type: ignore[attr-defined] + await self.session.flush() + + async def restore(self, entity: ModelT) -> None: + entity.is_deleted = False # type: ignore[attr-defined] + entity.deleted_at = None # type: ignore[attr-defined] + if hasattr(entity, "deleted_by"): + entity.deleted_by = None # type: ignore[attr-defined] + await self.session.flush() + + async def list_by_tenant( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ModelT]: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(self.model).where(*clauses).offset(offset).limit(limit) + result = await self.session.execute(stmt) + return result.scalars().all() + + async def count_by_tenant(self, tenant_id: UUID) -> int: + clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined] + if hasattr(self.model, "is_deleted"): + clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined] + stmt = select(func.count()).select_from(self.model).where(*clauses) + result = await self.session.execute(stmt) + return int(result.scalar_one()) diff --git a/backend/services/experience/app/repositories/bundles.py b/backend/services/experience/app/repositories/bundles.py new file mode 100644 index 0000000..9046f3d --- /dev/null +++ b/backend/services/experience/app/repositories/bundles.py @@ -0,0 +1,42 @@ +"""Tenant-scoped repositories for Phase 11.8.""" +from sqlalchemy import func, select + +from app.models.bundles import ( + ExperienceBundleDefinition, + ExperienceFeatureToggle, + ExperienceLicenseBinding, + ExperienceTenantBundle, +) +from app.repositories.base import TenantBaseRepository + + +class _FilteredRepository: + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + base = select(self.model).where(self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False)) + count = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + if spec: + base = spec.apply(base) + count = count.where(*spec.filter_clauses()) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count)).scalar_one()) + items = (await self.session.execute(base.offset(offset).limit(limit))).scalars().all() + return items, total + + +class BundleDefinitionRepository(_FilteredRepository, TenantBaseRepository[ExperienceBundleDefinition]): + model = ExperienceBundleDefinition + + +class TenantBundleRepository(_FilteredRepository, TenantBaseRepository[ExperienceTenantBundle]): + model = ExperienceTenantBundle + + +class LicenseBindingRepository(_FilteredRepository, TenantBaseRepository[ExperienceLicenseBinding]): + model = ExperienceLicenseBinding + + +class FeatureToggleRepository(_FilteredRepository, TenantBaseRepository[ExperienceFeatureToggle]): + model = ExperienceFeatureToggle diff --git a/backend/services/experience/app/repositories/components.py b/backend/services/experience/app/repositories/components.py new file mode 100644 index 0000000..8c2bac2 --- /dev/null +++ b/backend/services/experience/app/repositories/components.py @@ -0,0 +1,177 @@ +"""Component library repositories — Phase 11.2.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.components import ( + ExperienceComponent, + ExperienceComponentVersion, + PageComponentPlacement, +) +from app.repositories.base import TenantBaseRepository +from app.specifications.components import ( + ComponentListSpec, + ComponentVersionListSpec, + PlacementListSpec, +) + + +class ExperienceComponentRepository(TenantBaseRepository[ExperienceComponent]): + model = ExperienceComponent + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceComponent | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ComponentListSpec | None = None, + ) -> tuple[Sequence[ExperienceComponent], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceComponentVersionRepository( + TenantBaseRepository[ExperienceComponentVersion] +): + model = ExperienceComponentVersion + + async def get_by_number( + self, tenant_id: UUID, component_id: UUID, version_number: int + ) -> ExperienceComponentVersion | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.component_id == component_id, + self.model.version_number == version_number, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def max_version_number(self, tenant_id: UUID, component_id: UUID) -> int: + stmt = select(func.max(self.model.version_number)).where( + self.model.tenant_id == tenant_id, + self.model.component_id == component_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + value = result.scalar_one() + return int(value or 0) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ComponentVersionListSpec | None = None, + ) -> tuple[Sequence[ExperienceComponentVersion], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.version_number.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class PageComponentPlacementRepository(TenantBaseRepository[PageComponentPlacement]): + model = PageComponentPlacement + + async def get_by_slot( + self, tenant_id: UUID, page_id: UUID, slot_key: str + ) -> PageComponentPlacement | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.page_id == page_id, + self.model.slot_key == slot_key, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_by_component(self, tenant_id: UUID, component_id: UUID) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.component_id == component_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_by_version( + self, tenant_id: UUID, component_version_id: UUID + ) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.component_version_id == component_version_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: PlacementListSpec | None = None, + ) -> tuple[Sequence[PageComponentPlacement], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.sort_order.asc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total diff --git a/backend/services/experience/app/repositories/forms.py b/backend/services/experience/app/repositories/forms.py new file mode 100644 index 0000000..1684746 --- /dev/null +++ b/backend/services/experience/app/repositories/forms.py @@ -0,0 +1,190 @@ +"""Forms / surveys / appointments repositories — Phase 11.6.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.forms import ( + AppointmentBookingRef, + ExperienceAppointmentPage, + ExperienceForm, + ExperienceSurvey, + FormSubmission, + SurveyResponse, +) +from app.repositories.base import TenantBaseRepository +from app.specifications.forms import ( + AppointmentBookingListSpec, + AppointmentPageListSpec, + FormListSpec, + FormSubmissionListSpec, + SurveyListSpec, + SurveyResponseListSpec, +) + + +def _list_filtered(session, model, tenant_id, offset, limit, spec): + async def _run(): + base = select(model).where( + model.tenant_id == tenant_id, + model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(model).where( + model.tenant_id == tenant_id, + model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(model.created_at.desc()) + total = int((await session.execute(count_base)).scalar_one()) + result = await session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + return _run() + + +class ExperienceFormRepository(TenantBaseRepository[ExperienceForm]): + model = ExperienceForm + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceForm | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: FormListSpec | None = None, + ) -> tuple[Sequence[ExperienceForm], int]: + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class FormSubmissionRepository(TenantBaseRepository[FormSubmission]): + model = FormSubmission + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: FormSubmissionListSpec | None = None, + ) -> tuple[Sequence[FormSubmission], int]: + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class ExperienceSurveyRepository(TenantBaseRepository[ExperienceSurvey]): + model = ExperienceSurvey + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceSurvey | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SurveyListSpec | None = None, + ) -> tuple[Sequence[ExperienceSurvey], int]: + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class SurveyResponseRepository(TenantBaseRepository[SurveyResponse]): + model = SurveyResponse + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SurveyResponseListSpec | None = None, + ) -> tuple[Sequence[SurveyResponse], int]: + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class ExperienceAppointmentPageRepository( + TenantBaseRepository[ExperienceAppointmentPage] +): + model = ExperienceAppointmentPage + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceAppointmentPage | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: AppointmentPageListSpec | None = None, + ) -> tuple[Sequence[ExperienceAppointmentPage], int]: + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class AppointmentBookingRefRepository(TenantBaseRepository[AppointmentBookingRef]): + model = AppointmentBookingRef + + async def get_by_external_ref( + self, tenant_id: UUID, appointment_page_id: UUID, external_booking_ref: str + ) -> AppointmentBookingRef | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.appointment_page_id == appointment_page_id, + self.model.external_booking_ref == external_booking_ref, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: AppointmentBookingListSpec | None = None, + ) -> tuple[Sequence[AppointmentBookingRef], int]: + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) diff --git a/backend/services/experience/app/repositories/foundation.py b/backend/services/experience/app/repositories/foundation.py new file mode 100644 index 0000000..e2fa12a --- /dev/null +++ b/backend/services/experience/app/repositories/foundation.py @@ -0,0 +1,142 @@ +"""Experience foundation repositories.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import select + +from app.models.foundation import ( + ExperienceAuditLog, + ExperienceConfiguration, + ExperienceLocaleProfile, + ExperienceWorkspace, + ExperiencePermission, + ExperienceRole, + ExperienceSetting, + ExternalProviderConfig, + RenderEngineRegistration, +) +from app.repositories.base import TenantBaseRepository + + +class ExperienceWorkspaceRepository(TenantBaseRepository[ExperienceWorkspace]): + model = ExperienceWorkspace + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> ExperienceWorkspace | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class ExperienceLocaleProfileRepository(TenantBaseRepository[ExperienceLocaleProfile]): + model = ExperienceLocaleProfile + + async def list_by_organization( + self, tenant_id: UUID, workspace_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ExperienceLocaleProfile]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ExperienceRoleRepository(TenantBaseRepository[ExperienceRole]): + model = ExperienceRole + + +class ExperiencePermissionRepository(TenantBaseRepository[ExperiencePermission]): + model = ExperiencePermission + + +class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]): + model = ExternalProviderConfig + + async def get_by_code( + self, tenant_id: UUID, code: str + ) -> ExternalProviderConfig | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class RenderEngineRegistrationRepository( + TenantBaseRepository[RenderEngineRegistration] +): + model = RenderEngineRegistration + + +class ExperienceConfigurationRepository(TenantBaseRepository[ExperienceConfiguration]): + model = ExperienceConfiguration + + +class ExperienceSettingRepository(TenantBaseRepository[ExperienceSetting]): + model = ExperienceSetting + + async def get_by_key( + self, + tenant_id: UUID, + key: str, + *, + workspace_id: UUID | None = None, + locale_profile_id: UUID | None = None, + ) -> ExperienceSetting | None: + clauses = [ + self.model.tenant_id == tenant_id, + self.model.key == key, + self.model.is_deleted.is_(False), + ] + if workspace_id is None: + clauses.append(self.model.workspace_id.is_(None)) + else: + clauses.append(self.model.workspace_id == workspace_id) + if locale_profile_id is None: + clauses.append(self.model.locale_profile_id.is_(None)) + else: + clauses.append(self.model.locale_profile_id == locale_profile_id) + stmt = select(self.model).where(*clauses) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class ExperienceAuditLogRepository(TenantBaseRepository[ExperienceAuditLog]): + model = ExperienceAuditLog + + async def list_for_entity( + self, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + *, + limit: int = 100, + ) -> Sequence[ExperienceAuditLog]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.entity_type == entity_type, + self.model.entity_id == entity_id, + ) + .order_by(self.model.created_at.desc()) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/experience/app/repositories/integrations.py b/backend/services/experience/app/repositories/integrations.py new file mode 100644 index 0000000..e490433 --- /dev/null +++ b/backend/services/experience/app/repositories/integrations.py @@ -0,0 +1,66 @@ +"""Tenant-scoped repositories for Phase 11.9.""" +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.integrations import ( + ExperienceConsumerConnectorDispatch, + ExperienceConsumerConnectorRegistration, + ExperienceNotifyDispatch, + ExperienceWidgetDefinition, + ExperienceWidgetInstance, +) +from app.repositories.base import TenantBaseRepository + + +class _FilteredRepository: + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + base = select(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + count = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, self.model.is_deleted.is_(False) + ) + if spec: + base = spec.apply(base) + count = count.where(*spec.filter_clauses()) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count)).scalar_one()) + items = (await self.session.execute(base.offset(offset).limit(limit))).scalars().all() + return items, total + + +class ConsumerConnectorRegistrationRepository( + _FilteredRepository, TenantBaseRepository[ExperienceConsumerConnectorRegistration] +): + model = ExperienceConsumerConnectorRegistration + + +class ConsumerConnectorDispatchRepository( + _FilteredRepository, TenantBaseRepository[ExperienceConsumerConnectorDispatch] +): + model = ExperienceConsumerConnectorDispatch + + +class WidgetDefinitionRepository( + _FilteredRepository, TenantBaseRepository[ExperienceWidgetDefinition] +): + model = ExperienceWidgetDefinition + + +class WidgetInstanceRepository(_FilteredRepository, TenantBaseRepository[ExperienceWidgetInstance]): + model = ExperienceWidgetInstance + + async def get_by_embed_ref(self, tenant_id: UUID, embed_ref: str): + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.embed_ref == embed_ref, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + +class NotifyDispatchRepository(_FilteredRepository, TenantBaseRepository[ExperienceNotifyDispatch]): + model = ExperienceNotifyDispatch diff --git a/backend/services/experience/app/repositories/localization.py b/backend/services/experience/app/repositories/localization.py new file mode 100644 index 0000000..60b4304 --- /dev/null +++ b/backend/services/experience/app/repositories/localization.py @@ -0,0 +1,158 @@ +"""Localization & media-ref repositories — Phase 11.5.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select, update + +from app.models.localization import ( + ExperienceLocalization, + ExperienceMediaRef, + SiteLocaleBinding, +) +from app.models.types import LocalizationTarget +from app.repositories.base import TenantBaseRepository +from app.specifications.localization import ( + LocalizationListSpec, + MediaRefListSpec, + SiteLocaleBindingListSpec, +) + + +class SiteLocaleBindingRepository(TenantBaseRepository[SiteLocaleBinding]): + model = SiteLocaleBinding + + async def get_by_site_profile( + self, tenant_id: UUID, site_id: UUID, locale_profile_id: UUID + ) -> SiteLocaleBinding | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.locale_profile_id == locale_profile_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def clear_default_for_site( + self, tenant_id: UUID, site_id: UUID, *, except_id: UUID | None = None + ) -> None: + stmt = ( + update(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_deleted.is_(False), + self.model.is_default.is_(True), + ) + .values(is_default=False) + ) + if except_id is not None: + stmt = stmt.where(self.model.id != except_id) + await self.session.execute(stmt) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SiteLocaleBindingListSpec | None = None, + ) -> tuple[Sequence[SiteLocaleBinding], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.sort_order.asc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceLocalizationRepository( + TenantBaseRepository[ExperienceLocalization] +): + model = ExperienceLocalization + + async def get_by_target_locale( + self, + tenant_id: UUID, + target_type: LocalizationTarget, + target_id: UUID, + locale: str, + ) -> ExperienceLocalization | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.target_type == target_type, + self.model.target_id == target_id, + self.model.locale == locale, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LocalizationListSpec | None = None, + ) -> tuple[Sequence[ExperienceLocalization], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceMediaRefRepository(TenantBaseRepository[ExperienceMediaRef]): + model = ExperienceMediaRef + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: MediaRefListSpec | None = None, + ) -> tuple[Sequence[ExperienceMediaRef], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.sort_order.asc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total diff --git a/backend/services/experience/app/repositories/publishing.py b/backend/services/experience/app/repositories/publishing.py new file mode 100644 index 0000000..b25c5f5 --- /dev/null +++ b/backend/services/experience/app/repositories/publishing.py @@ -0,0 +1,139 @@ +"""Publishing / SEO / PWA repositories — Phase 11.7.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.publishing import ( + DomainEdgeBinding, + PublishRelease, + PwaManifest, + RobotsShell, + SeoMetadata, + SitemapShell, +) +from app.models.types import SeoTarget +from app.repositories.base import TenantBaseRepository + + +async def _list_filtered(session, model, tenant_id, offset, limit, spec): + base = select(model).where( + model.tenant_id == tenant_id, + model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(model).where( + model.tenant_id == tenant_id, + model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(model.created_at.desc()) + total = int((await session.execute(count_base)).scalar_one()) + result = await session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class PublishReleaseRepository(TenantBaseRepository[PublishRelease]): + model = PublishRelease + + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class DomainEdgeBindingRepository(TenantBaseRepository[DomainEdgeBinding]): + model = DomainEdgeBinding + + async def get_by_site_domain( + self, tenant_id: UUID, site_domain_id: UUID + ) -> DomainEdgeBinding | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_domain_id == site_domain_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class SeoMetadataRepository(TenantBaseRepository[SeoMetadata]): + model = SeoMetadata + + async def get_by_target( + self, tenant_id: UUID, target_type: SeoTarget, target_id: UUID + ) -> SeoMetadata | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.target_type == target_type, + self.model.target_id == target_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class SitemapShellRepository(TenantBaseRepository[SitemapShell]): + model = SitemapShell + + async def get_by_site( + self, tenant_id: UUID, site_id: UUID + ) -> SitemapShell | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class RobotsShellRepository(TenantBaseRepository[RobotsShell]): + model = RobotsShell + + async def get_by_site(self, tenant_id: UUID, site_id: UUID) -> RobotsShell | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) + + +class PwaManifestRepository(TenantBaseRepository[PwaManifest]): + model = PwaManifest + + async def get_by_site(self, tenant_id: UUID, site_id: UUID) -> PwaManifest | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_deleted.is_(False), + ) + return (await self.session.execute(stmt)).scalar_one_or_none() + + async def list_filtered(self, tenant_id, *, offset, limit, spec=None): + return await _list_filtered( + self.session, self.model, tenant_id, offset, limit, spec + ) diff --git a/backend/services/experience/app/repositories/sites.py b/backend/services/experience/app/repositories/sites.py new file mode 100644 index 0000000..8686c94 --- /dev/null +++ b/backend/services/experience/app/repositories/sites.py @@ -0,0 +1,117 @@ +"""Sites & page repositories — Phase 11.1.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import select + +from app.models.sites import ExperiencePage, ExperienceSite, ExperienceSiteDomain +from app.repositories.base import TenantBaseRepository + + +class ExperienceSiteRepository(TenantBaseRepository[ExperienceSite]): + model = ExperienceSite + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceSite | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_workspace( + self, tenant_id: UUID, workspace_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ExperienceSite]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ExperiencePageRepository(TenantBaseRepository[ExperiencePage]): + model = ExperiencePage + + async def get_by_code( + self, tenant_id: UUID, site_id: UUID, code: str + ) -> ExperiencePage | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def get_by_slug( + self, tenant_id: UUID, site_id: UUID, slug: str + ) -> ExperiencePage | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.slug == slug, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_site( + self, tenant_id: UUID, site_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ExperiencePage]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() + + +class ExperienceSiteDomainRepository(TenantBaseRepository[ExperienceSiteDomain]): + model = ExperienceSiteDomain + + async def get_by_host( + self, tenant_id: UUID, host: str + ) -> ExperienceSiteDomain | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.host == host, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_by_site( + self, tenant_id: UUID, site_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> Sequence[ExperienceSiteDomain]: + stmt = ( + select(self.model) + .where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_deleted.is_(False), + ) + .offset(offset) + .limit(limit) + ) + result = await self.session.execute(stmt) + return result.scalars().all() diff --git a/backend/services/experience/app/repositories/templates.py b/backend/services/experience/app/repositories/templates.py new file mode 100644 index 0000000..af2410d --- /dev/null +++ b/backend/services/experience/app/repositories/templates.py @@ -0,0 +1,132 @@ +"""Template repositories — Phase 11.4.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.templates import ( + ExperienceTemplate, + ExperienceTemplateVersion, + TemplateInstantiation, +) +from app.repositories.base import TenantBaseRepository +from app.specifications.templates import ( + InstantiationListSpec, + TemplateListSpec, + TemplateVersionListSpec, +) + + +class ExperienceTemplateRepository(TenantBaseRepository[ExperienceTemplate]): + model = ExperienceTemplate + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceTemplate | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: TemplateListSpec | None = None, + ) -> tuple[Sequence[ExperienceTemplate], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceTemplateVersionRepository( + TenantBaseRepository[ExperienceTemplateVersion] +): + model = ExperienceTemplateVersion + + async def max_version_number(self, tenant_id: UUID, template_id: UUID) -> int: + stmt = select(func.max(self.model.version_number)).where( + self.model.tenant_id == tenant_id, + self.model.template_id == template_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one() or 0) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: TemplateVersionListSpec | None = None, + ) -> tuple[Sequence[ExperienceTemplateVersion], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.version_number.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class TemplateInstantiationRepository(TenantBaseRepository[TemplateInstantiation]): + model = TemplateInstantiation + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: InstantiationListSpec | None = None, + ) -> tuple[Sequence[TemplateInstantiation], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total diff --git a/backend/services/experience/app/repositories/themes.py b/backend/services/experience/app/repositories/themes.py new file mode 100644 index 0000000..8d3b171 --- /dev/null +++ b/backend/services/experience/app/repositories/themes.py @@ -0,0 +1,341 @@ +"""Theme & layout engine repositories — Phase 11.3.""" +from __future__ import annotations + +from typing import Sequence +from uuid import UUID + +from sqlalchemy import func, select + +from app.models.themes import ( + ExperienceLayout, + ExperienceLayoutVersion, + ExperienceTheme, + ExperienceThemeVersion, + PageLayoutBinding, + SiteThemeBinding, +) +from app.repositories.base import TenantBaseRepository +from app.specifications.themes import ( + LayoutListSpec, + LayoutVersionListSpec, + PageLayoutBindingListSpec, + SiteThemeBindingListSpec, + ThemeListSpec, + ThemeVersionListSpec, +) + + +class ExperienceThemeRepository(TenantBaseRepository[ExperienceTheme]): + model = ExperienceTheme + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceTheme | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ThemeListSpec | None = None, + ) -> tuple[Sequence[ExperienceTheme], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceThemeVersionRepository(TenantBaseRepository[ExperienceThemeVersion]): + model = ExperienceThemeVersion + + async def get_by_number( + self, tenant_id: UUID, theme_id: UUID, version_number: int + ) -> ExperienceThemeVersion | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.theme_id == theme_id, + self.model.version_number == version_number, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def max_version_number(self, tenant_id: UUID, theme_id: UUID) -> int: + stmt = select(func.max(self.model.version_number)).where( + self.model.tenant_id == tenant_id, + self.model.theme_id == theme_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + value = result.scalar_one() + return int(value or 0) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ThemeVersionListSpec | None = None, + ) -> tuple[Sequence[ExperienceThemeVersion], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.version_number.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceLayoutRepository(TenantBaseRepository[ExperienceLayout]): + model = ExperienceLayout + + async def get_by_code( + self, tenant_id: UUID, workspace_id: UUID, code: str + ) -> ExperienceLayout | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.workspace_id == workspace_id, + self.model.code == code, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LayoutListSpec | None = None, + ) -> tuple[Sequence[ExperienceLayout], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class ExperienceLayoutVersionRepository(TenantBaseRepository[ExperienceLayoutVersion]): + model = ExperienceLayoutVersion + + async def get_by_number( + self, tenant_id: UUID, layout_id: UUID, version_number: int + ) -> ExperienceLayoutVersion | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.layout_id == layout_id, + self.model.version_number == version_number, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def max_version_number(self, tenant_id: UUID, layout_id: UUID) -> int: + stmt = select(func.max(self.model.version_number)).where( + self.model.tenant_id == tenant_id, + self.model.layout_id == layout_id, + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + value = result.scalar_one() + return int(value or 0) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LayoutVersionListSpec | None = None, + ) -> tuple[Sequence[ExperienceLayoutVersion], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.version_number.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class SiteThemeBindingRepository(TenantBaseRepository[SiteThemeBinding]): + model = SiteThemeBinding + + async def get_active_for_site( + self, tenant_id: UUID, site_id: UUID + ) -> SiteThemeBinding | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.site_id == site_id, + self.model.is_active.is_(True), + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_active_by_theme(self, tenant_id: UUID, theme_id: UUID) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.theme_id == theme_id, + self.model.is_active.is_(True), + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_active_by_version( + self, tenant_id: UUID, theme_version_id: UUID + ) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.theme_version_id == theme_version_id, + self.model.is_active.is_(True), + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SiteThemeBindingListSpec | None = None, + ) -> tuple[Sequence[SiteThemeBinding], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total + + +class PageLayoutBindingRepository(TenantBaseRepository[PageLayoutBinding]): + model = PageLayoutBinding + + async def get_active_for_page( + self, tenant_id: UUID, page_id: UUID + ) -> PageLayoutBinding | None: + stmt = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.page_id == page_id, + self.model.is_active.is_(True), + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + + async def count_active_by_layout(self, tenant_id: UUID, layout_id: UUID) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.layout_id == layout_id, + self.model.is_active.is_(True), + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def count_active_by_version( + self, tenant_id: UUID, layout_version_id: UUID + ) -> int: + stmt = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.layout_version_id == layout_version_id, + self.model.is_active.is_(True), + self.model.is_deleted.is_(False), + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def list_filtered( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: PageLayoutBindingListSpec | None = None, + ) -> tuple[Sequence[PageLayoutBinding], int]: + base = select(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + count_base = select(func.count()).select_from(self.model).where( + self.model.tenant_id == tenant_id, + self.model.is_deleted.is_(False), + ) + if spec is not None: + base = spec.apply(base) + for clause in spec.filter_clauses(): + count_base = count_base.where(clause) + else: + base = base.order_by(self.model.created_at.desc()) + total = int((await self.session.execute(count_base)).scalar_one()) + result = await self.session.execute(base.offset(offset).limit(limit)) + return result.scalars().all(), total diff --git a/backend/services/experience/app/schemas/ai_hooks.py b/backend/services/experience/app/schemas/ai_hooks.py new file mode 100644 index 0000000..8b63ba3 --- /dev/null +++ b/backend/services/experience/app/schemas/ai_hooks.py @@ -0,0 +1,121 @@ +"""AI content hook DTOs — Phase 11.10.""" +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + AiContentDispatchStatus, + AiContentHookStatus, + AiContentTaskKind, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class _Create(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + + +class _Update(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class AiContentHookRegistrationCreate(_Create): + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + code: str = Field(min_length=1, max_length=50) + name: str = Field(min_length=1, max_length=255) + task_kind: AiContentTaskKind + external_ai_ref: str | None = Field(default=None, max_length=255) + status: AiContentHookStatus = AiContentHookStatus.DRAFT + config_json: dict | None = None + + +class AiContentHookRegistrationUpdate(_Update): + name: str | None = Field(default=None, min_length=1, max_length=255) + task_kind: AiContentTaskKind | None = None + external_ai_ref: str | None = Field(default=None, max_length=255) + status: AiContentHookStatus | None = None + config_json: dict | None = None + + +class AiContentHookRegistrationRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + code: str + name: str + task_kind: AiContentTaskKind + external_ai_ref: str | None + status: AiContentHookStatus + config_json: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AiContentHookRegistrationListResponse(BaseModel): + items: list[AiContentHookRegistrationRead] + total: int + page: int + page_size: int + + +class AiContentDispatchCreate(BaseModel): + model_config = _FORBID + registration_id: UUID + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + task: str = Field(min_length=1, max_length=100) + context_ref: str | None = Field(default=None, max_length=255) + payload_json: dict | None = None + + +class AiContentDispatchUpdate(BaseModel): + model_config = _FORBID + task: str | None = Field(default=None, min_length=1, max_length=100) + context_ref: str | None = Field(default=None, max_length=255) + payload_json: dict | None = None + status: AiContentDispatchStatus | None = None + version: int = Field(ge=1) + + +class AiContentDispatchRead(ORMBase): + id: UUID + tenant_id: UUID + registration_id: UUID + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + task: str + context_ref: str | None + payload_json: dict | None + status: AiContentDispatchStatus + response_json: dict | None + generated_content_ref: str | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AiContentDispatchListResponse(BaseModel): + items: list[AiContentDispatchRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/analytics.py b/backend/services/experience/app/schemas/analytics.py new file mode 100644 index 0000000..a17ebed --- /dev/null +++ b/backend/services/experience/app/schemas/analytics.py @@ -0,0 +1,118 @@ +"""Analytics DTOs — Phase 11.10.""" +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import AnalyticsReportStatus, AnalyticsSnapshotStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class _Create(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + + +class _Update(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class AnalyticsReportDefinitionCreate(_Create): + workspace_id: UUID + site_id: UUID | None = None + code: str = Field(min_length=1, max_length=50) + name: str = Field(min_length=1, max_length=255) + metric_keys: list[str] + filters: dict | None = None + status: AnalyticsReportStatus = AnalyticsReportStatus.DRAFT + + +class AnalyticsReportDefinitionUpdate(_Update): + name: str | None = Field(default=None, min_length=1, max_length=255) + metric_keys: list[str] | None = None + filters: dict | None = None + status: AnalyticsReportStatus | None = None + + +class AnalyticsReportDefinitionRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID | None + code: str + name: str + metric_keys: list[str] + filters: dict | None + status: AnalyticsReportStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AnalyticsReportDefinitionListResponse(BaseModel): + items: list[AnalyticsReportDefinitionRead] + total: int + page: int + page_size: int + + +class AnalyticsSnapshotRefresh(BaseModel): + model_config = _FORBID + report_id: UUID + workspace_id: UUID | None = None + site_id: UUID | None = None + period_start: str = Field(min_length=1, max_length=32) + period_end: str = Field(min_length=1, max_length=32) + + +class AnalyticsSnapshotCreate(BaseModel): + model_config = _FORBID + report_id: UUID + workspace_id: UUID | None = None + site_id: UUID | None = None + period_start: str | None = Field(default=None, max_length=32) + period_end: str | None = Field(default=None, max_length=32) + metrics: dict + status: AnalyticsSnapshotStatus = AnalyticsSnapshotStatus.ACTIVE + + +class AnalyticsSnapshotUpdate(BaseModel): + model_config = _FORBID + period_start: str | None = Field(default=None, max_length=32) + period_end: str | None = Field(default=None, max_length=32) + metrics: dict | None = None + status: AnalyticsSnapshotStatus | None = None + version: int = Field(ge=1) + + +class AnalyticsSnapshotRead(ORMBase): + id: UUID + tenant_id: UUID + report_id: UUID + workspace_id: UUID | None + site_id: UUID | None + period_start: str | None + period_end: str | None + metrics: dict + status: AnalyticsSnapshotStatus + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AnalyticsSnapshotListResponse(BaseModel): + items: list[AnalyticsSnapshotRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/bundles.py b/backend/services/experience/app/schemas/bundles.py new file mode 100644 index 0000000..a5acb43 --- /dev/null +++ b/backend/services/experience/app/schemas/bundles.py @@ -0,0 +1,178 @@ +"""Bundle, licensing and feature-toggle DTOs — Phase 11.8.""" +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + BundleDefinitionStatus, + BundleKey, + FeatureToggleStatus, + LicenseBindingStatus, + TenantBundleStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class _Create(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + + +class _Update(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class _Read(ORMBase): + id: UUID + tenant_id: UUID + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class BundleDefinitionCreate(_Create): + workspace_id: UUID | None = None + code: str = Field(min_length=1, max_length=50) + bundle_key: BundleKey + name: str = Field(min_length=1, max_length=255) + description: str | None = None + status: BundleDefinitionStatus = BundleDefinitionStatus.DRAFT + feature_keys: list[str] | None = None + permission_prefixes: list[str] | None = None + api_prefixes: list[str] | None = None + + +class BundleDefinitionUpdate(_Update): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + status: BundleDefinitionStatus | None = None + feature_keys: list[str] | None = None + permission_prefixes: list[str] | None = None + api_prefixes: list[str] | None = None + + +class BundleDefinitionRead(_Read): + workspace_id: UUID | None + code: str + bundle_key: BundleKey + name: str + description: str | None + status: BundleDefinitionStatus + feature_keys: list | None + permission_prefixes: list | None + api_prefixes: list | None + + +class BundleDefinitionListResponse(BaseModel): + items: list[BundleDefinitionRead] + total: int + page: int + page_size: int + + +class TenantBundleCreate(_Create): + workspace_id: UUID + site_id: UUID | None = None + bundle_definition_id: UUID + status: TenantBundleStatus = TenantBundleStatus.PENDING + core_entitlement_ref: str | None = Field(default=None, max_length=255) + expires_at: datetime | None = None + + +class TenantBundleUpdate(_Update): + status: TenantBundleStatus | None = None + core_entitlement_ref: str | None = Field(default=None, max_length=255) + expires_at: datetime | None = None + + +class TenantBundleRead(_Read): + workspace_id: UUID + site_id: UUID | None + bundle_definition_id: UUID + bundle_key: BundleKey + status: TenantBundleStatus + core_entitlement_ref: str | None + activated_at: datetime | None + expires_at: datetime | None + activated_by: str | None + + +class TenantBundleListResponse(BaseModel): + items: list[TenantBundleRead] + total: int + page: int + page_size: int + + +class LicenseBindingCreate(_Create): + workspace_id: UUID + tenant_bundle_id: UUID | None = None + bundle_key: BundleKey | None = None + core_entitlement_ref: str = Field(min_length=1, max_length=255) + status: LicenseBindingStatus = LicenseBindingStatus.PENDING + starts_at: datetime | None = None + ends_at: datetime | None = None + + +class LicenseBindingUpdate(_Update): + status: LicenseBindingStatus | None = None + core_entitlement_ref: str | None = Field(default=None, min_length=1, max_length=255) + starts_at: datetime | None = None + ends_at: datetime | None = None + + +class LicenseBindingRead(_Read): + workspace_id: UUID + tenant_bundle_id: UUID | None + bundle_key: BundleKey | None + core_entitlement_ref: str + status: LicenseBindingStatus + starts_at: datetime | None + ends_at: datetime | None + + +class LicenseBindingListResponse(BaseModel): + items: list[LicenseBindingRead] + total: int + page: int + page_size: int + + +class FeatureToggleCreate(_Create): + workspace_id: UUID + site_id: UUID | None = None + feature_key: str = Field(min_length=1, max_length=100) + enabled: bool = False + status: FeatureToggleStatus = FeatureToggleStatus.ACTIVE + bundle_key: BundleKey | None = None + + +class FeatureToggleUpdate(_Update): + enabled: bool | None = None + status: FeatureToggleStatus | None = None + bundle_key: BundleKey | None = None + + +class FeatureToggleRead(_Read): + workspace_id: UUID + site_id: UUID | None + feature_key: str + enabled: bool + status: FeatureToggleStatus + bundle_key: BundleKey | None + + +class FeatureToggleListResponse(BaseModel): + items: list[FeatureToggleRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/common.py b/backend/services/experience/app/schemas/common.py new file mode 100644 index 0000000..12b4f56 --- /dev/null +++ b/backend/services/experience/app/schemas/common.py @@ -0,0 +1,18 @@ +"""Common schemas.""" +from __future__ import annotations + +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ORMBase(BaseModel): + model_config = ConfigDict(from_attributes=True) + + +class IdResponse(BaseModel): + id: UUID + + +class MessageResponse(BaseModel): + message: str diff --git a/backend/services/experience/app/schemas/components.py b/backend/services/experience/app/schemas/components.py new file mode 100644 index 0000000..71c2861 --- /dev/null +++ b/backend/services/experience/app/schemas/components.py @@ -0,0 +1,177 @@ +"""Component library DTOs — Phase 11.2.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ComponentKind, ComponentStatus, ComponentVersionStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ExperienceComponentCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + kind: ComponentKind = ComponentKind.CONTENT + status: ComponentStatus = ComponentStatus.DRAFT + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + metadata_json: dict | None = None + + +class ExperienceComponentUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + kind: ComponentKind | None = None + status: ComponentStatus | None = None + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceComponentRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + code: str + name: str + description: str | None + kind: ComponentKind + status: ComponentStatus + category: str | None + tags: list | None + latest_version_number: int + published_version_id: UUID | None + is_system: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceComponentVersionCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + component_id: UUID + definition_schema: dict | None = None + props_defaults: dict | None = None + render_hint: dict | None = None + changelog: str | None = None + metadata_json: dict | None = None + + +class ExperienceComponentVersionUpdate(BaseModel): + model_config = _FORBID + + definition_schema: dict | None = None + props_defaults: dict | None = None + render_hint: dict | None = None + changelog: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceComponentVersionRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + component_id: UUID + version_number: int + status: ComponentVersionStatus + definition_schema: dict | None + props_defaults: dict | None + render_hint: dict | None + changelog: str | None + is_immutable: bool + published_at: datetime | None + retired_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PageComponentPlacementCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + page_id: UUID + component_id: UUID + component_version_id: UUID + slot_key: str = Field(max_length=100) + sort_order: int = Field(default=0, ge=0) + props_override: dict | None = None + is_visible: bool = True + metadata_json: dict | None = None + + +class PageComponentPlacementUpdate(BaseModel): + model_config = _FORBID + + component_version_id: UUID | None = None + slot_key: str | None = Field(default=None, max_length=100) + sort_order: int | None = Field(default=None, ge=0) + props_override: dict | None = None + is_visible: bool | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PageComponentPlacementRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + page_id: UUID + component_id: UUID + component_version_id: UUID + slot_key: str + sort_order: int + props_override: dict | None + is_visible: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceComponentListResponse(BaseModel): + items: list[ExperienceComponentRead] + total: int + page: int + page_size: int + + +class ExperienceComponentVersionListResponse(BaseModel): + items: list[ExperienceComponentVersionRead] + total: int + page: int + page_size: int + + +class PageComponentPlacementListResponse(BaseModel): + items: list[PageComponentPlacementRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/forms.py b/backend/services/experience/app/schemas/forms.py new file mode 100644 index 0000000..f31b72f --- /dev/null +++ b/backend/services/experience/app/schemas/forms.py @@ -0,0 +1,339 @@ +"""Forms, surveys & appointment DTOs — Phase 11.6.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + AppointmentBookingStatus, + AppointmentPageStatus, + FormStatus, + FormSubmissionStatus, + SurveyResponseStatus, + SurveyStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ExperienceFormCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: FormStatus = FormStatus.DRAFT + form_schema_json: dict | None = None + submit_action_json: dict | None = None + metadata_json: dict | None = None + + +class ExperienceFormUpdate(BaseModel): + model_config = _FORBID + + site_id: UUID | None = None + page_id: UUID | None = None + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: FormStatus | None = None + form_schema_json: dict | None = None + submit_action_json: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceFormRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + code: str + name: str + description: str | None + status: FormStatus + form_schema_json: dict | None + submit_action_json: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceFormListResponse(BaseModel): + items: list[ExperienceFormRead] + total: int + page: int + page_size: int + + +class FormSubmissionCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + form_id: UUID + payload_json: dict | None = None + crm_contact_ref: str | None = Field(default=None, max_length=255) + communication_ref: str | None = Field(default=None, max_length=255) + external_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class FormSubmissionUpdate(BaseModel): + model_config = _FORBID + + status: FormSubmissionStatus | None = None + crm_contact_ref: str | None = Field(default=None, max_length=255) + communication_ref: str | None = Field(default=None, max_length=255) + external_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class FormSubmissionRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + form_id: UUID + status: FormSubmissionStatus + payload_json: dict | None + crm_contact_ref: str | None + communication_ref: str | None + external_ref: str | None + submitted_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class FormSubmissionListResponse(BaseModel): + items: list[FormSubmissionRead] + total: int + page: int + page_size: int + + +class ExperienceSurveyCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: SurveyStatus = SurveyStatus.DRAFT + questions_json: dict | None = None + metadata_json: dict | None = None + + +class ExperienceSurveyUpdate(BaseModel): + model_config = _FORBID + + site_id: UUID | None = None + page_id: UUID | None = None + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: SurveyStatus | None = None + questions_json: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceSurveyRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + code: str + name: str + description: str | None + status: SurveyStatus + questions_json: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceSurveyListResponse(BaseModel): + items: list[ExperienceSurveyRead] + total: int + page: int + page_size: int + + +class SurveyResponseCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + survey_id: UUID + answers_json: dict | None = None + crm_contact_ref: str | None = Field(default=None, max_length=255) + external_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + + +class SurveyResponseUpdate(BaseModel): + model_config = _FORBID + + status: SurveyResponseStatus | None = None + crm_contact_ref: str | None = Field(default=None, max_length=255) + external_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class SurveyResponseRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + survey_id: UUID + status: SurveyResponseStatus + answers_json: dict | None + crm_contact_ref: str | None + external_ref: str | None + submitted_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SurveyResponseListResponse(BaseModel): + items: list[SurveyResponseRead] + total: int + page: int + page_size: int + + +class ExperienceAppointmentPageCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + page_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: AppointmentPageStatus = AppointmentPageStatus.DRAFT + booking_provider_ref: str | None = Field(default=None, max_length=255) + service_ref: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + availability_shell_json: dict | None = None + metadata_json: dict | None = None + + +class ExperienceAppointmentPageUpdate(BaseModel): + model_config = _FORBID + + page_id: UUID | None = None + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: AppointmentPageStatus | None = None + booking_provider_ref: str | None = Field(default=None, max_length=255) + service_ref: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + availability_shell_json: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceAppointmentPageRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + page_id: UUID | None + code: str + name: str + description: str | None + status: AppointmentPageStatus + booking_provider_ref: str | None + service_ref: str | None + timezone: str | None + availability_shell_json: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceAppointmentPageListResponse(BaseModel): + items: list[ExperienceAppointmentPageRead] + total: int + page: int + page_size: int + + +class AppointmentBookingRefCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + appointment_page_id: UUID + external_booking_ref: str = Field(max_length=255) + status: AppointmentBookingStatus = AppointmentBookingStatus.PENDING + crm_contact_ref: str | None = Field(default=None, max_length=255) + slot_start: datetime | None = None + slot_end: datetime | None = None + metadata_json: dict | None = None + + +class AppointmentBookingRefUpdate(BaseModel): + model_config = _FORBID + + status: AppointmentBookingStatus | None = None + crm_contact_ref: str | None = Field(default=None, max_length=255) + slot_start: datetime | None = None + slot_end: datetime | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class AppointmentBookingRefRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + appointment_page_id: UUID + external_booking_ref: str + status: AppointmentBookingStatus + crm_contact_ref: str | None + slot_start: datetime | None + slot_end: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class AppointmentBookingRefListResponse(BaseModel): + items: list[AppointmentBookingRefRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/foundation.py b/backend/services/experience/app/schemas/foundation.py new file mode 100644 index 0000000..83529da --- /dev/null +++ b/backend/services/experience/app/schemas/foundation.py @@ -0,0 +1,294 @@ +"""Experience foundation DTOs — Phase 11.0.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + LifecycleStatus, + ProviderKind, + ProviderStatus, + RenderEngineStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ExperienceWorkspaceCreate(BaseModel): + model_config = _FORBID + + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.DRAFT + legal_name: str | None = Field(default=None, max_length=255) + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + settings: dict | None = None + + +class ExperienceWorkspaceUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + legal_name: str | None = Field(default=None, max_length=255) + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + settings: dict | None = None + version: int = Field(ge=1) + + +class ExperienceWorkspaceRead(ORMBase): + id: UUID + tenant_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + legal_name: str | None + timezone: str + language: str + currency_code: str + settings: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceLocaleProfileCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: LifecycleStatus = LifecycleStatus.ACTIVE + language: str = Field(default="fa", max_length=16) + text_direction: str = Field(default="rtl", max_length=3, pattern="^(rtl|ltr)$") + timezone: str | None = Field(default=None, max_length=64) + metadata_json: dict | None = None + + +class ExperienceLocaleProfileUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: LifecycleStatus | None = None + language: str | None = Field(default=None, max_length=16) + text_direction: str | None = Field(default=None, max_length=3, pattern="^(rtl|ltr)$") + timezone: str | None = Field(default=None, max_length=64) + metadata_json: dict | None = None + + +class ExperienceLocaleProfileRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + code: str + name: str + description: str | None + status: LifecycleStatus + language: str + text_direction: str + timezone: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExternalProviderConfigCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + provider_kind: ProviderKind = ProviderKind.CUSTOM + adapter_key: str = Field(max_length=100) + status: ProviderStatus = ProviderStatus.REGISTERED + priority: int = Field(default=100, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + + +class ExternalProviderConfigUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + provider_kind: ProviderKind | None = None + adapter_key: str | None = Field(default=None, max_length=100) + status: ProviderStatus | None = None + priority: int | None = Field(default=None, ge=0) + credentials_ref: str | None = Field(default=None, max_length=255) + config: dict | None = None + capabilities: dict | None = None + version: int = Field(ge=1) + + +class ExternalProviderConfigRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID | None + code: str + name: str + provider_kind: ProviderKind + adapter_key: str + status: ProviderStatus + priority: int + credentials_ref: str | None + config: dict | None + capabilities: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class RenderEngineRegistrationCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID | None = None + code: str = Field(max_length=50) + name: str = Field(max_length=255) + adapter_key: str = Field(max_length=100) + status: RenderEngineStatus = RenderEngineStatus.REGISTERED + is_default: bool = False + config: dict | None = None + capabilities: dict | None = None + + +class RenderEngineRegistrationUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + adapter_key: str | None = Field(default=None, max_length=100) + status: RenderEngineStatus | None = None + is_default: bool | None = None + config: dict | None = None + capabilities: dict | None = None + version: int = Field(ge=1) + + +class RenderEngineRegistrationRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID | None + code: str + name: str + adapter_key: str + status: RenderEngineStatus + is_default: bool + config: dict | None + capabilities: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceConfigurationCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + locale_profile_id: UUID | None = None + code: str = Field(max_length=50) + working_hours: dict | None = None + publish_policies: dict | None = None + seo_policies: dict | None = None + analytics_policies: dict | None = None + custom_fields: dict | None = None + timezone: str = Field(default="Asia/Tehran", max_length=64) + language: str = Field(default="fa", max_length=16) + currency_code: str = Field(default="IRR", max_length=3) + + +class ExperienceConfigurationUpdate(BaseModel): + model_config = _FORBID + + locale_profile_id: UUID | None = None + working_hours: dict | None = None + publish_policies: dict | None = None + seo_policies: dict | None = None + analytics_policies: dict | None = None + custom_fields: dict | None = None + timezone: str | None = Field(default=None, max_length=64) + language: str | None = Field(default=None, max_length=16) + currency_code: str | None = Field(default=None, max_length=3) + version: int = Field(ge=1) + + +class ExperienceConfigurationRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + locale_profile_id: UUID | None + code: str + working_hours: dict | None + publish_policies: dict | None + seo_policies: dict | None + analytics_policies: dict | None + custom_fields: dict | None + timezone: str + language: str + currency_code: str + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceSettingUpsert(BaseModel): + model_config = _FORBID + + workspace_id: UUID | None = None + locale_profile_id: UUID | None = None + key: str = Field(max_length=100) + value: dict | None = None + description: str | None = None + + +class ExperienceSettingRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID | None + locale_profile_id: UUID | None + key: str + value: dict | None + description: str | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceAuditLogRead(ORMBase): + id: UUID + tenant_id: UUID + entity_type: str + entity_id: UUID + action: str + actor_user_id: str | None + changes: dict | None + message: str | None + created_at: datetime diff --git a/backend/services/experience/app/schemas/integrations.py b/backend/services/experience/app/schemas/integrations.py new file mode 100644 index 0000000..70fcc4b --- /dev/null +++ b/backend/services/experience/app/schemas/integrations.py @@ -0,0 +1,256 @@ +"""Consumer connector, widget & notify DTOs — Phase 11.9.""" +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + ConnectorDispatchDirection, + ConnectorDispatchStatus, + ConsumerConnectorKind, + ConsumerConnectorStatus, + NotifyDispatchStatus, + WidgetDefinitionStatus, + WidgetInstanceStatus, + WidgetKind, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class _Create(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + + +class _Update(BaseModel): + model_config = _FORBID + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class _Read(ORMBase): + id: UUID + tenant_id: UUID + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ConsumerConnectorRegistrationCreate(_Create): + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + kind: ConsumerConnectorKind + code: str = Field(min_length=1, max_length=50) + name: str = Field(min_length=1, max_length=255) + status: ConsumerConnectorStatus = ConsumerConnectorStatus.DRAFT + external_service_ref: str | None = Field(default=None, max_length=255) + entity_ref: str | None = Field(default=None, max_length=255) + config_json: dict | None = None + + +class ConsumerConnectorRegistrationUpdate(_Update): + name: str | None = Field(default=None, min_length=1, max_length=255) + status: ConsumerConnectorStatus | None = None + external_service_ref: str | None = Field(default=None, max_length=255) + entity_ref: str | None = Field(default=None, max_length=255) + config_json: dict | None = None + + +class ConsumerConnectorRegistrationRead(_Read): + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + kind: ConsumerConnectorKind + code: str + name: str + status: ConsumerConnectorStatus + external_service_ref: str | None + entity_ref: str | None + config_json: dict | None + + +class ConsumerConnectorRegistrationListResponse(BaseModel): + items: list[ConsumerConnectorRegistrationRead] + total: int + page: int + page_size: int + + +class ConsumerConnectorDispatchCreate(BaseModel): + model_config = _FORBID + registration_id: UUID + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + direction: ConnectorDispatchDirection + event_type: str = Field(min_length=1, max_length=100) + payload_ref: str | None = Field(default=None, max_length=255) + notes: str | None = None + + +class ConsumerConnectorDispatchUpdate(BaseModel): + model_config = _FORBID + status: ConnectorDispatchStatus | None = None + response_json: dict | None = None + notes: str | None = None + version: int = Field(ge=1) + + +class ConsumerConnectorDispatchRead(ORMBase): + id: UUID + tenant_id: UUID + registration_id: UUID + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + direction: ConnectorDispatchDirection + event_type: str + payload_ref: str | None + status: ConnectorDispatchStatus + response_json: dict | None + notes: str | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ConsumerConnectorDispatchListResponse(BaseModel): + items: list[ConsumerConnectorDispatchRead] + total: int + page: int + page_size: int + + +class WidgetDefinitionCreate(_Create): + workspace_id: UUID | None = None + code: str = Field(min_length=1, max_length=50) + widget_kind: WidgetKind + name: str = Field(min_length=1, max_length=255) + description: str | None = None + status: WidgetDefinitionStatus = WidgetDefinitionStatus.DRAFT + connector_kind: ConsumerConnectorKind | None = None + default_config_json: dict | None = None + permission_prefixes: list[str] | None = None + + +class WidgetDefinitionUpdate(_Update): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = None + status: WidgetDefinitionStatus | None = None + connector_kind: ConsumerConnectorKind | None = None + default_config_json: dict | None = None + permission_prefixes: list[str] | None = None + + +class WidgetDefinitionRead(_Read): + workspace_id: UUID | None + code: str + widget_kind: WidgetKind + name: str + description: str | None + status: WidgetDefinitionStatus + connector_kind: ConsumerConnectorKind | None + default_config_json: dict | None + permission_prefixes: list | None + + +class WidgetDefinitionListResponse(BaseModel): + items: list[WidgetDefinitionRead] + total: int + page: int + page_size: int + + +class WidgetInstanceCreate(_Create): + workspace_id: UUID + site_id: UUID + page_id: UUID | None = None + widget_definition_id: UUID + widget_kind: WidgetKind + code: str = Field(min_length=1, max_length=50) + name: str = Field(min_length=1, max_length=255) + status: WidgetInstanceStatus = WidgetInstanceStatus.DRAFT + connector_registration_id: UUID | None = None + entity_ref: str | None = Field(default=None, max_length=255) + embed_ref: str = Field(min_length=1, max_length=255) + config_json: dict | None = None + + +class WidgetInstanceUpdate(_Update): + name: str | None = Field(default=None, min_length=1, max_length=255) + status: WidgetInstanceStatus | None = None + connector_registration_id: UUID | None = None + entity_ref: str | None = Field(default=None, max_length=255) + config_json: dict | None = None + + +class WidgetInstanceRead(_Read): + workspace_id: UUID + site_id: UUID + page_id: UUID | None + widget_definition_id: UUID + widget_kind: WidgetKind + code: str + name: str + status: WidgetInstanceStatus + connector_registration_id: UUID | None + entity_ref: str | None + embed_ref: str + config_json: dict | None + + +class WidgetInstanceListResponse(BaseModel): + items: list[WidgetInstanceRead] + total: int + page: int + page_size: int + + +class NotifyDispatchCreate(_Create): + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + widget_instance_id: UUID | None = None + connector_registration_id: UUID | None = None + channel: str = Field(min_length=1, max_length=50) + template_key: str = Field(min_length=1, max_length=100) + to_ref: str | None = Field(default=None, max_length=255) + payload_json: dict | None = None + + +class NotifyDispatchUpdate(_Update): + status: NotifyDispatchStatus | None = None + communication_request_ref: str | None = Field(default=None, max_length=255) + response_json: dict | None = None + + +class NotifyDispatchRead(_Read): + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + widget_instance_id: UUID | None + connector_registration_id: UUID | None + channel: str + template_key: str + communication_request_ref: str | None + to_ref: str | None + payload_json: dict | None + status: NotifyDispatchStatus + response_json: dict | None + + +class NotifyDispatchListResponse(BaseModel): + items: list[NotifyDispatchRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/localization.py b/backend/services/experience/app/schemas/localization.py new file mode 100644 index 0000000..5c16281 --- /dev/null +++ b/backend/services/experience/app/schemas/localization.py @@ -0,0 +1,195 @@ +"""Localization & media-ref DTOs — Phase 11.5.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + LocalizationStatus, + LocalizationTarget, + MediaKind, + MediaRefStatus, + TextDirection, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class SiteLocaleBindingCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + locale_profile_id: UUID + text_direction: TextDirection | None = None + is_default: bool = False + is_active: bool = True + sort_order: int = 0 + metadata_json: dict | None = None + + +class SiteLocaleBindingUpdate(BaseModel): + model_config = _FORBID + + text_direction: TextDirection | None = None + is_default: bool | None = None + is_active: bool | None = None + sort_order: int | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class SiteLocaleBindingRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + locale_profile_id: UUID + locale_code: str + text_direction: TextDirection + is_default: bool + is_active: bool + sort_order: int + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SiteLocaleBindingListResponse(BaseModel): + items: list[SiteLocaleBindingRead] + total: int + page: int + page_size: int + + +class ExperienceLocalizationUpsert(BaseModel): + model_config = _FORBID + + workspace_id: UUID + target_type: LocalizationTarget + target_id: UUID + locale: str = Field(max_length=16) + text_direction: TextDirection = TextDirection.RTL + title: str | None = Field(default=None, max_length=255) + summary: str | None = None + content_json: dict | None = None + status: LocalizationStatus = LocalizationStatus.DRAFT + metadata_json: dict | None = None + version: int | None = Field(default=None, ge=1) + + +class ExperienceLocalizationUpdate(BaseModel): + model_config = _FORBID + + text_direction: TextDirection | None = None + title: str | None = Field(default=None, max_length=255) + summary: str | None = None + content_json: dict | None = None + status: LocalizationStatus | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceLocalizationRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + target_type: LocalizationTarget + target_id: UUID + locale: str + text_direction: TextDirection + title: str | None + summary: str | None + content_json: dict | None + status: LocalizationStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceLocalizationListResponse(BaseModel): + items: list[ExperienceLocalizationRead] + total: int + page: int + page_size: int + + +class ExperienceMediaRefCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID | None = None + page_id: UUID | None = None + owner_type: str | None = Field(default=None, max_length=50) + owner_id: UUID | None = None + kind: MediaKind = MediaKind.IMAGE + storage_file_ref: str = Field(max_length=255) + alt_text: str | None = Field(default=None, max_length=255) + caption: str | None = Field(default=None, max_length=500) + locale: str | None = Field(default=None, max_length=16) + text_direction: TextDirection | None = None + sort_order: int = 0 + status: MediaRefStatus = MediaRefStatus.ACTIVE + metadata_json: dict | None = None + + +class ExperienceMediaRefUpdate(BaseModel): + model_config = _FORBID + + site_id: UUID | None = None + page_id: UUID | None = None + owner_type: str | None = Field(default=None, max_length=50) + owner_id: UUID | None = None + kind: MediaKind | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + alt_text: str | None = Field(default=None, max_length=255) + caption: str | None = Field(default=None, max_length=500) + locale: str | None = Field(default=None, max_length=16) + text_direction: TextDirection | None = None + sort_order: int | None = None + status: MediaRefStatus | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceMediaRefRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID | None + page_id: UUID | None + owner_type: str | None + owner_id: UUID | None + kind: MediaKind + storage_file_ref: str + alt_text: str | None + caption: str | None + locale: str | None + text_direction: TextDirection | None + sort_order: int + status: MediaRefStatus + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceMediaRefListResponse(BaseModel): + items: list[ExperienceMediaRefRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/publishing.py b/backend/services/experience/app/schemas/publishing.py new file mode 100644 index 0000000..fcb8a16 --- /dev/null +++ b/backend/services/experience/app/schemas/publishing.py @@ -0,0 +1,322 @@ +"""Publishing / SEO / PWA DTOs — Phase 11.7.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + DomainEdgeStatus, + PublishReleaseStatus, + PublishTarget, + PwaStatus, + RobotsStatus, + SeoStatus, + SeoTarget, + SitemapStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class PublishReleaseCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + target_type: PublishTarget + target_id: UUID + notes: str | None = None + metadata_json: dict | None = None + + +class PublishReleaseUpdate(BaseModel): + model_config = _FORBID + + status: PublishReleaseStatus | None = None + notes: str | None = None + error_message: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PublishReleaseRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + target_type: PublishTarget + target_id: UUID + status: PublishReleaseStatus + notes: str | None + error_message: str | None + completed_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PublishReleaseListResponse(BaseModel): + items: list[PublishReleaseRead] + total: int + page: int + page_size: int + + +class DomainEdgeBindingCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + site_domain_id: UUID + dns_status_ref: str | None = Field(default=None, max_length=255) + ssl_cert_ref: str | None = Field(default=None, max_length=255) + edge_map_ref: str | None = Field(default=None, max_length=255) + status: DomainEdgeStatus = DomainEdgeStatus.PENDING + metadata_json: dict | None = None + + +class DomainEdgeBindingUpdate(BaseModel): + model_config = _FORBID + + status: DomainEdgeStatus | None = None + dns_status_ref: str | None = Field(default=None, max_length=255) + ssl_cert_ref: str | None = Field(default=None, max_length=255) + edge_map_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class DomainEdgeBindingRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + site_domain_id: UUID + host: str + status: DomainEdgeStatus + dns_status_ref: str | None + ssl_cert_ref: str | None + edge_map_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class DomainEdgeBindingListResponse(BaseModel): + items: list[DomainEdgeBindingRead] + total: int + page: int + page_size: int + + +class SeoMetadataUpsert(BaseModel): + model_config = _FORBID + + workspace_id: UUID + target_type: SeoTarget + target_id: UUID + status: SeoStatus = SeoStatus.DRAFT + title: str | None = Field(default=None, max_length=255) + description: str | None = None + canonical_url: str | None = Field(default=None, max_length=500) + robots_directive: str | None = Field(default=None, max_length=100) + og_json: dict | None = None + metadata_json: dict | None = None + version: int | None = Field(default=None, ge=1) + + +class SeoMetadataUpdate(BaseModel): + model_config = _FORBID + + status: SeoStatus | None = None + title: str | None = Field(default=None, max_length=255) + description: str | None = None + canonical_url: str | None = Field(default=None, max_length=500) + robots_directive: str | None = Field(default=None, max_length=100) + og_json: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class SeoMetadataRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + target_type: SeoTarget + target_id: UUID + status: SeoStatus + title: str | None + description: str | None + canonical_url: str | None + robots_directive: str | None + og_json: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SeoMetadataListResponse(BaseModel): + items: list[SeoMetadataRead] + total: int + page: int + page_size: int + + +class SitemapShellUpsert(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + status: SitemapStatus = SitemapStatus.DRAFT + entries_json: dict | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int | None = Field(default=None, ge=1) + + +class SitemapShellUpdate(BaseModel): + model_config = _FORBID + + status: SitemapStatus | None = None + entries_json: dict | None = None + storage_file_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class SitemapShellRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + status: SitemapStatus + entries_json: dict | None + storage_file_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SitemapShellListResponse(BaseModel): + items: list[SitemapShellRead] + total: int + page: int + page_size: int + + +class RobotsShellUpsert(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + status: RobotsStatus = RobotsStatus.DRAFT + content_text: str | None = None + rules_json: dict | None = None + metadata_json: dict | None = None + version: int | None = Field(default=None, ge=1) + + +class RobotsShellUpdate(BaseModel): + model_config = _FORBID + + status: RobotsStatus | None = None + content_text: str | None = None + rules_json: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class RobotsShellRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + status: RobotsStatus + content_text: str | None + rules_json: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class RobotsShellListResponse(BaseModel): + items: list[RobotsShellRead] + total: int + page: int + page_size: int + + +class PwaManifestUpsert(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + status: PwaStatus = PwaStatus.DRAFT + name: str | None = Field(default=None, max_length=255) + short_name: str | None = Field(default=None, max_length=100) + start_url: str | None = Field(default=None, max_length=500) + manifest_json: dict | None = None + service_worker_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int | None = Field(default=None, ge=1) + + +class PwaManifestUpdate(BaseModel): + model_config = _FORBID + + status: PwaStatus | None = None + name: str | None = Field(default=None, max_length=255) + short_name: str | None = Field(default=None, max_length=100) + start_url: str | None = Field(default=None, max_length=500) + manifest_json: dict | None = None + service_worker_ref: str | None = Field(default=None, max_length=255) + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PwaManifestRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + status: PwaStatus + name: str | None + short_name: str | None + start_url: str | None + manifest_json: dict | None + service_worker_ref: str | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PwaManifestListResponse(BaseModel): + items: list[PwaManifestRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/schemas/sites.py b/backend/services/experience/app/schemas/sites.py new file mode 100644 index 0000000..f1dde11 --- /dev/null +++ b/backend/services/experience/app/schemas/sites.py @@ -0,0 +1,179 @@ +"""Sites & page resource DTOs — Phase 11.1.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import DomainKind, PageType, PublishStatus +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ExperienceSiteCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + publish_status: PublishStatus = PublishStatus.DRAFT + default_locale: str = Field(default="fa", max_length=16) + default_direction: str = Field(default="rtl", max_length=3, pattern="^(rtl|ltr)$") + primary_domain: str | None = Field(default=None, max_length=255) + settings: dict | None = None + metadata_json: dict | None = None + + +class ExperienceSiteUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + default_locale: str | None = Field(default=None, max_length=16) + default_direction: str | None = Field( + default=None, max_length=3, pattern="^(rtl|ltr)$" + ) + primary_domain: str | None = Field(default=None, max_length=255) + home_page_id: UUID | None = None + settings: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceSiteRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + code: str + name: str + description: str | None + publish_status: PublishStatus + default_locale: str + default_direction: str + primary_domain: str | None + home_page_id: UUID | None + published_at: datetime | None + archived_at: datetime | None + settings: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperiencePageCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + code: str = Field(max_length=50) + slug: str = Field(max_length=200) + title: str = Field(max_length=255) + summary: str | None = None + page_type: PageType = PageType.LANDING + publish_status: PublishStatus = PublishStatus.DRAFT + locale: str = Field(default="fa", max_length=16) + text_direction: str = Field(default="rtl", max_length=3, pattern="^(rtl|ltr)$") + sort_order: int = Field(default=0, ge=0) + is_home: bool = False + content_shell: dict | None = None + seo_shell: dict | None = None + external_refs: dict | None = None + metadata_json: dict | None = None + + +class ExperiencePageUpdate(BaseModel): + model_config = _FORBID + + slug: str | None = Field(default=None, max_length=200) + title: str | None = Field(default=None, max_length=255) + summary: str | None = None + page_type: PageType | None = None + locale: str | None = Field(default=None, max_length=16) + text_direction: str | None = Field( + default=None, max_length=3, pattern="^(rtl|ltr)$" + ) + sort_order: int | None = Field(default=None, ge=0) + is_home: bool | None = None + content_shell: dict | None = None + seo_shell: dict | None = None + external_refs: dict | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperiencePageRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + code: str + slug: str + title: str + summary: str | None + page_type: PageType + publish_status: PublishStatus + locale: str + text_direction: str + sort_order: int + is_home: bool + published_at: datetime | None + archived_at: datetime | None + content_shell: dict | None + seo_shell: dict | None + external_refs: dict | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceSiteDomainCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + host: str = Field(max_length=255) + domain_kind: DomainKind = DomainKind.SUBDOMAIN + is_primary: bool = False + path_prefix: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class ExperienceSiteDomainUpdate(BaseModel): + model_config = _FORBID + + host: str | None = Field(default=None, max_length=255) + domain_kind: DomainKind | None = None + is_primary: bool | None = None + is_verified: bool | None = None + path_prefix: str | None = Field(default=None, max_length=100) + metadata_json: dict | None = None + + +class ExperienceSiteDomainRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + host: str + domain_kind: DomainKind + is_primary: bool + is_verified: bool + verification_token: str | None + path_prefix: str | None + metadata_json: dict | None + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime diff --git a/backend/services/experience/app/schemas/templates.py b/backend/services/experience/app/schemas/templates.py new file mode 100644 index 0000000..630a665 --- /dev/null +++ b/backend/services/experience/app/schemas/templates.py @@ -0,0 +1,172 @@ +"""Template system DTOs — Phase 11.4.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + TemplateInstantiationStatus, + TemplateStatus, + TemplateVersionStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +class ExperienceTemplateCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + page_type: str = Field(max_length=50) + status: TemplateStatus = TemplateStatus.DRAFT + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + is_starter_pack: bool = False + metadata_json: dict | None = None + + +class ExperienceTemplateUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + page_type: str | None = Field(default=None, max_length=50) + status: TemplateStatus | None = None + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + is_starter_pack: bool | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceTemplateRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + code: str + name: str + description: str | None + page_type: str + status: TemplateStatus + category: str | None + tags: list | None + latest_version_number: int + published_version_id: UUID | None + is_starter_pack: bool + is_system: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceTemplateListResponse(BaseModel): + items: list[ExperienceTemplateRead] + total: int + page: int + page_size: int + + +class ExperienceTemplateVersionCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + template_id: UUID + blueprint_json: dict | None = None + changelog: str | None = None + metadata_json: dict | None = None + + +class ExperienceTemplateVersionUpdate(BaseModel): + model_config = _FORBID + + blueprint_json: dict | None = None + changelog: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceTemplateVersionRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + template_id: UUID + version_number: int + status: TemplateVersionStatus + blueprint_json: dict | None + changelog: str | None + is_immutable: bool + published_at: datetime | None + retired_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceTemplateVersionListResponse(BaseModel): + items: list[ExperienceTemplateVersionRead] + total: int + page: int + page_size: int + + +class TemplateInstantiateRequest(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + template_id: UUID + template_version_id: UUID | None = None + page_code: str = Field(max_length=50) + page_slug: str = Field(max_length=200) + page_title: str = Field(max_length=255) + apply_theme: bool = True + apply_layout: bool = True + apply_placements: bool = True + metadata_json: dict | None = None + + +class TemplateInstantiationRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + template_id: UUID + template_version_id: UUID + site_id: UUID + page_id: UUID | None + status: TemplateInstantiationStatus + result_json: dict | None + error_message: str | None + completed_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class TemplateInstantiationListResponse(BaseModel): + items: list[TemplateInstantiationRead] + total: int + page: int + page_size: int + + +class TemplateInstantiateResponse(BaseModel): + instantiation: TemplateInstantiationRead + page_id: UUID | None = None diff --git a/backend/services/experience/app/schemas/themes.py b/backend/services/experience/app/schemas/themes.py new file mode 100644 index 0000000..f061238 --- /dev/null +++ b/backend/services/experience/app/schemas/themes.py @@ -0,0 +1,341 @@ +"""Theme & layout engine DTOs — Phase 11.3.""" +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + +from app.models.types import ( + DirectionMode, + LayoutKind, + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) +from app.schemas.common import ORMBase + +_FORBID = ConfigDict(extra="forbid") + + +# --- Themes --- + + +class ExperienceThemeCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + status: ThemeStatus = ThemeStatus.DRAFT + direction_mode: DirectionMode = DirectionMode.BOTH + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + metadata_json: dict | None = None + + +class ExperienceThemeUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + status: ThemeStatus | None = None + direction_mode: DirectionMode | None = None + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceThemeRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + code: str + name: str + description: str | None + status: ThemeStatus + direction_mode: DirectionMode + category: str | None + tags: list | None + latest_version_number: int + published_version_id: UUID | None + is_system: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceThemeListResponse(BaseModel): + items: list[ExperienceThemeRead] + total: int + page: int + page_size: int + + +# --- Theme Versions --- + + +class ExperienceThemeVersionCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + theme_id: UUID + tokens: dict | None = None + brand_shell: dict | None = None + direction_mode: DirectionMode | None = None + changelog: str | None = None + metadata_json: dict | None = None + + +class ExperienceThemeVersionUpdate(BaseModel): + model_config = _FORBID + + tokens: dict | None = None + brand_shell: dict | None = None + direction_mode: DirectionMode | None = None + changelog: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceThemeVersionRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + theme_id: UUID + version_number: int + status: ThemeVersionStatus + tokens: dict | None + brand_shell: dict | None + direction_mode: DirectionMode | None + changelog: str | None + is_immutable: bool + published_at: datetime | None + retired_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceThemeVersionListResponse(BaseModel): + items: list[ExperienceThemeVersionRead] + total: int + page: int + page_size: int + + +# --- Layouts --- + + +class ExperienceLayoutCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + code: str = Field(max_length=50) + name: str = Field(max_length=255) + description: str | None = None + kind: LayoutKind = LayoutKind.PAGE_SHELL + status: LayoutStatus = LayoutStatus.DRAFT + direction_mode: DirectionMode = DirectionMode.BOTH + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + metadata_json: dict | None = None + + +class ExperienceLayoutUpdate(BaseModel): + model_config = _FORBID + + name: str | None = Field(default=None, max_length=255) + description: str | None = None + kind: LayoutKind | None = None + status: LayoutStatus | None = None + direction_mode: DirectionMode | None = None + category: str | None = Field(default=None, max_length=100) + tags: list[str] | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceLayoutRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + code: str + name: str + description: str | None + kind: LayoutKind + status: LayoutStatus + direction_mode: DirectionMode + category: str | None + tags: list | None + latest_version_number: int + published_version_id: UUID | None + is_system: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceLayoutListResponse(BaseModel): + items: list[ExperienceLayoutRead] + total: int + page: int + page_size: int + + +# --- Layout Versions --- + + +class ExperienceLayoutVersionCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + layout_id: UUID + regions: list[dict] | None = None + structure_shell: dict | None = None + changelog: str | None = None + metadata_json: dict | None = None + + +class ExperienceLayoutVersionUpdate(BaseModel): + model_config = _FORBID + + regions: list[dict] | None = None + structure_shell: dict | None = None + changelog: str | None = None + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class ExperienceLayoutVersionRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + layout_id: UUID + version_number: int + status: LayoutVersionStatus + regions: list | None + structure_shell: dict | None + changelog: str | None + is_immutable: bool + published_at: datetime | None + retired_at: datetime | None + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class ExperienceLayoutVersionListResponse(BaseModel): + items: list[ExperienceLayoutVersionRead] + total: int + page: int + page_size: int + + +# --- Site Theme Bindings --- + + +class SiteThemeBindingCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + theme_id: UUID + theme_version_id: UUID + metadata_json: dict | None = None + + +class SiteThemeBindingUpdate(BaseModel): + model_config = _FORBID + + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class SiteThemeBindingRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + theme_id: UUID + theme_version_id: UUID + is_active: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class SiteThemeBindingListResponse(BaseModel): + items: list[SiteThemeBindingRead] + total: int + page: int + page_size: int + + +# --- Page Layout Bindings --- + + +class PageLayoutBindingCreate(BaseModel): + model_config = _FORBID + + workspace_id: UUID + site_id: UUID + page_id: UUID + layout_id: UUID + layout_version_id: UUID + metadata_json: dict | None = None + + +class PageLayoutBindingUpdate(BaseModel): + model_config = _FORBID + + metadata_json: dict | None = None + version: int = Field(ge=1) + + +class PageLayoutBindingRead(ORMBase): + id: UUID + tenant_id: UUID + workspace_id: UUID + site_id: UUID + page_id: UUID + layout_id: UUID + layout_version_id: UUID + is_active: bool + metadata_json: dict | None + version: int + is_deleted: bool + created_by: str | None + updated_by: str | None + created_at: datetime + updated_at: datetime + + +class PageLayoutBindingListResponse(BaseModel): + items: list[PageLayoutBindingRead] + total: int + page: int + page_size: int diff --git a/backend/services/experience/app/services/ai_hooks.py b/backend/services/experience/app/services/ai_hooks.py new file mode 100644 index 0000000..0258cc5 --- /dev/null +++ b/backend/services/experience/app/services/ai_hooks.py @@ -0,0 +1,312 @@ +"""AI content hook application services — Phase 11.10.""" +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.ai_hooks import ( + ExperienceAiContentDispatch, + ExperienceAiContentHookRegistration, +) +from app.models.types import AiContentDispatchStatus, AiContentHookStatus, AuditAction +from app.policies.ai_hooks import AiContentDispatchPolicy, AiContentHookRegistrationPolicy +from app.providers.mocks import MockAIProvider +from app.repositories.ai_hooks import ( + AiContentDispatchRepository, + AiContentHookRegistrationRepository, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version +from app.validators.ai_hooks import validate_config, validate_metadata, validate_payload +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +_MOCK_AI = MockAIProvider() + + +def _actor(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _jsonable(value): + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, (UUID, datetime)): + return value.isoformat() if isinstance(value, datetime) else str(value) + if hasattr(value, "value"): + return value.value + return value + + +class ExperienceAiHooksService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.registrations = AiContentHookRegistrationRepository(session) + self.dispatches = AiContentDispatchRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policies = { + "ai_content_hook_registration": AiContentHookRegistrationPolicy(), + "ai_content_dispatch": AiContentDispatchPolicy(), + } + + async def _scope(self, tenant_id, workspace_id, site_id=None, page_id=None): + if await self.workspaces.get(tenant_id, workspace_id) is None: + raise NotFoundError("فضای کاری یافت نشد") + if site_id is not None: + site = await self.sites.get(tenant_id, site_id) + if site is None or site.workspace_id != workspace_id: + raise NotFoundError("سایت یافت نشد") + if page_id is not None: + page = await self.pages.get(tenant_id, page_id) + if page is None: + raise NotFoundError("صفحه یافت نشد") + if site_id is not None and page.site_id != site_id: + raise AppError( + "صفحه متعلق به سایت اعلام‌شده نیست", + error_code="page_site_mismatch", + status_code=422, + ) + + async def _finish(self, entity, kind, action, event, actor, changes=None): + await self.audit.record( + tenant_id=entity.tenant_id, + entity_type=kind, + entity_id=entity.id, + action=action, + actor_user_id=_actor(actor), + changes=_jsonable(changes or {}), + ) + self.events.publish( + event_type=event, + aggregate_type=kind, + aggregate_id=entity.id, + tenant_id=entity.tenant_id, + payload={"status": getattr(entity.status, "value", entity.status)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _get(self, repo, tenant_id, entity_id, label): + entity = await repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError(f"{label} یافت نشد") + return entity + + async def _list(self, repo, tenant_id, *, offset, limit, spec): + items, total = await repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec) + return list(items), total + + async def create_hook_registration(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id, body.page_id) + if await self.registrations.get_by_code(tenant_id, body.code) is not None: + raise AppError( + "کد ثبت هوک تکراری است", + status_code=409, + error_code="ai_hook_code_exists", + ) + entity = ExperienceAiContentHookRegistration( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + code=body.code, + name=body.name, + task_kind=body.task_kind, + external_ai_ref=body.external_ai_ref, + status=body.status, + config_json=validate_config(body.config_json), + metadata_json=validate_metadata(body.metadata_json), + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.registrations.add(entity) + return await self._finish( + entity, + "ai_content_hook_registration", + AuditAction.CREATE, + ExperienceEventType.AI_CONTENT_HOOK_REGISTRATION_CREATED, + actor, + ) + + async def get_hook_registration(self, tenant_id, entity_id): + return await self._get( + self.registrations, tenant_id, entity_id, "ai content hook registration" + ) + + async def list_hook_registrations(self, tenant_id, *, offset, limit, spec=None): + return await self._list( + self.registrations, tenant_id, offset=offset, limit=limit, spec=spec + ) + + async def update_hook_registration(self, tenant_id, entity_id, body, *, actor=None): + entity = await self._get( + self.registrations, tenant_id, entity_id, "ai content hook registration" + ) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "metadata_json" in data: + data["metadata_json"] = validate_metadata(data["metadata_json"]) + if "config_json" in data: + data["config_json"] = validate_config(data["config_json"]) + if data.get("status") is not None: + self.policies["ai_content_hook_registration"].assert_status_change( + current=entity.status, target=data["status"] + ) + for field, value in data.items(): + setattr(entity, field, value) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "ai_content_hook_registration", + AuditAction.UPDATE, + ExperienceEventType.AI_CONTENT_HOOK_REGISTRATION_UPDATED, + actor, + data, + ) + + async def delete_hook_registration(self, tenant_id, entity_id, *, actor=None): + entity = await self._get( + self.registrations, tenant_id, entity_id, "ai content hook registration" + ) + await self.registrations.soft_delete(entity, deleted_by=_actor(actor)) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "ai_content_hook_registration", + AuditAction.DELETE, + ExperienceEventType.AI_CONTENT_HOOK_REGISTRATION_DELETED, + actor, + ) + + async def create_dispatch(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id, body.page_id) + registration = await self.get_hook_registration(tenant_id, body.registration_id) + if registration.workspace_id != body.workspace_id: + raise AppError( + "ثبت هوک متعلق به فضای کاری نیست", + error_code="registration_workspace_mismatch", + status_code=422, + ) + entity = ExperienceAiContentDispatch( + tenant_id=tenant_id, + registration_id=body.registration_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + task=body.task, + context_ref=body.context_ref, + payload_json=validate_payload(body.payload_json), + status=AiContentDispatchStatus.PENDING, + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.dispatches.add(entity) + return await self._finish( + entity, + "ai_content_dispatch", + AuditAction.CREATE, + ExperienceEventType.AI_CONTENT_DISPATCH_CREATED, + actor, + ) + + async def get_dispatch(self, tenant_id, entity_id): + return await self._get(self.dispatches, tenant_id, entity_id, "ai content dispatch") + + async def list_dispatches(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.dispatches, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_dispatch(self, tenant_id, entity_id, body, *, actor=None): + entity = await self._get(self.dispatches, tenant_id, entity_id, "ai content dispatch") + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "payload_json" in data: + data["payload_json"] = validate_payload(data["payload_json"]) + if data.get("status") is not None: + self.policies["ai_content_dispatch"].assert_status_change( + current=entity.status, target=data["status"] + ) + for field, value in data.items(): + setattr(entity, field, value) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "ai_content_dispatch", + AuditAction.UPDATE, + ExperienceEventType.AI_CONTENT_DISPATCH_UPDATED, + actor, + data, + ) + + async def delete_dispatch(self, tenant_id, entity_id, *, actor=None): + entity = await self._get(self.dispatches, tenant_id, entity_id, "ai content dispatch") + await self.dispatches.soft_delete(entity, deleted_by=_actor(actor)) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "ai_content_dispatch", + AuditAction.DELETE, + ExperienceEventType.AI_CONTENT_DISPATCH_DELETED, + actor, + ) + + async def generate_dispatch(self, tenant_id, entity_id, *, actor=None): + dispatch = await self.get_dispatch(tenant_id, entity_id) + if dispatch.status != AiContentDispatchStatus.PENDING: + raise AppError( + "تولید محتوا قبلاً پردازش شده است", + error_code="dispatch_not_pending", + status_code=409, + ) + registration = await self.get_hook_registration(tenant_id, dispatch.registration_id) + if registration.status != AiContentHookStatus.ACTIVE: + raise AppError( + "ثبت هوک فعال نیست", + error_code="registration_not_active", + status_code=409, + ) + try: + response = await _MOCK_AI.generate_content( + tenant_id=tenant_id, + task=dispatch.task, + context={ + "task_kind": registration.task_kind.value, + "context_ref": dispatch.context_ref, + "payload": dispatch.payload_json or {}, + }, + ) + dispatch.status = AiContentDispatchStatus.GENERATED + dispatch.generated_content_ref = response.get("generated_content_ref") + dispatch.response_json = response + except Exception as exc: # noqa: BLE001 — mock boundary only + dispatch.status = AiContentDispatchStatus.FAILED + dispatch.response_json = {"ok": False, "reason": str(exc)} + dispatch.updated_by = _actor(actor) + dispatch.version += 1 + event = ( + ExperienceEventType.AI_CONTENT_DISPATCH_GENERATED + if dispatch.status == AiContentDispatchStatus.GENERATED + else ExperienceEventType.AI_CONTENT_DISPATCH_UPDATED + ) + return await self._finish( + dispatch, + "ai_content_dispatch", + AuditAction.UPDATE, + event, + actor, + {"status": dispatch.status.value}, + ) diff --git a/backend/services/experience/app/services/analytics.py b/backend/services/experience/app/services/analytics.py new file mode 100644 index 0000000..fd09839 --- /dev/null +++ b/backend/services/experience/app/services/analytics.py @@ -0,0 +1,298 @@ +"""Analytics application services — Phase 11.10.""" +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.analytics import ( + ExperienceAnalyticsReportDefinition, + ExperienceAnalyticsSnapshot, +) +from app.models.bundles import ExperienceBundleDefinition, ExperienceTenantBundle +from app.models.components import ExperienceComponent +from app.models.forms import ExperienceForm, FormSubmission, ExperienceSurvey +from app.models.foundation import ExperienceWorkspace +from app.models.integrations import ( + ExperienceConsumerConnectorRegistration, + ExperienceNotifyDispatch, + ExperienceWidgetDefinition, + ExperienceWidgetInstance, +) +from app.models.publishing import PublishRelease +from app.models.sites import ExperiencePage, ExperienceSite +from app.models.templates import ExperienceTemplate +from app.models.types import AnalyticsSnapshotStatus, AuditAction +from app.policies.analytics import AnalyticsReportDefinitionPolicy, AnalyticsSnapshotPolicy +from app.repositories.analytics import ( + AnalyticsReportDefinitionRepository, + AnalyticsSnapshotRepository, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperienceSiteRepository +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version +from app.validators.analytics import validate_metadata, validate_metric_keys +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +METRIC_TABLE_MAP: dict[str, type] = { + "workspaces_count": ExperienceWorkspace, + "sites_count": ExperienceSite, + "pages_count": ExperiencePage, + "components_count": ExperienceComponent, + "templates_count": ExperienceTemplate, + "forms_count": ExperienceForm, + "form_submissions_count": FormSubmission, + "surveys_count": ExperienceSurvey, + "publish_releases_count": PublishRelease, + "widget_definitions_count": ExperienceWidgetDefinition, + "widget_instances_count": ExperienceWidgetInstance, + "consumer_connector_registrations_count": ExperienceConsumerConnectorRegistration, + "notify_dispatches_count": ExperienceNotifyDispatch, + "bundle_definitions_count": ExperienceBundleDefinition, + "tenant_bundles_count": ExperienceTenantBundle, +} + + +def _actor(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _jsonable(value): + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, (UUID, datetime)): + return value.isoformat() if isinstance(value, datetime) else str(value) + if hasattr(value, "value"): + return value.value + return value + + +class ExperienceAnalyticsService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.reports = AnalyticsReportDefinitionRepository(session) + self.snapshots = AnalyticsSnapshotRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policies = { + "analytics_report_definition": AnalyticsReportDefinitionPolicy(), + "analytics_snapshot": AnalyticsSnapshotPolicy(), + } + + async def _scope(self, tenant_id, workspace_id, site_id=None): + if await self.workspaces.get(tenant_id, workspace_id) is None: + raise NotFoundError("فضای کاری یافت نشد") + if site_id is not None: + site = await self.sites.get(tenant_id, site_id) + if site is None or site.workspace_id != workspace_id: + raise NotFoundError("سایت یافت نشد") + + async def _finish(self, entity, kind, action, event, actor, changes=None): + await self.audit.record( + tenant_id=entity.tenant_id, + entity_type=kind, + entity_id=entity.id, + action=action, + actor_user_id=_actor(actor), + changes=_jsonable(changes or {}), + ) + self.events.publish( + event_type=event, + aggregate_type=kind, + aggregate_id=entity.id, + tenant_id=entity.tenant_id, + payload={"status": getattr(entity.status, "value", entity.status)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _get(self, repo, tenant_id, entity_id, label): + entity = await repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError(f"{label} یافت نشد") + return entity + + async def _list(self, repo, tenant_id, *, offset, limit, spec): + items, total = await repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec) + return list(items), total + + def _validate_metric_keys(self, metric_keys: list[str]) -> None: + validate_metric_keys(metric_keys) + unknown = [key for key in metric_keys if key not in METRIC_TABLE_MAP] + if unknown: + raise AppError( + "metric_key نامعتبر است", + status_code=422, + error_code="invalid_metric_key", + details={"unknown": unknown}, + ) + + async def create_report_definition(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id) + self._validate_metric_keys(body.metric_keys) + if await self.reports.get_by_code(tenant_id, body.code) is not None: + raise AppError( + "کد گزارش تکراری است", + status_code=409, + error_code="analytics_report_code_exists", + ) + entity = ExperienceAnalyticsReportDefinition( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + code=body.code, + name=body.name, + metric_keys=body.metric_keys, + filters=body.filters, + status=body.status, + metadata_json=validate_metadata(body.metadata_json), + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.reports.add(entity) + return await self._finish( + entity, + "analytics_report_definition", + AuditAction.CREATE, + ExperienceEventType.ANALYTICS_REPORT_DEFINITION_CREATED, + actor, + ) + + async def get_report_definition(self, tenant_id, entity_id): + return await self._get( + self.reports, tenant_id, entity_id, "analytics report definition" + ) + + async def list_report_definitions(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.reports, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_report_definition(self, tenant_id, entity_id, body, *, actor=None): + entity = await self._get( + self.reports, tenant_id, entity_id, "analytics report definition" + ) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "metadata_json" in data: + data["metadata_json"] = validate_metadata(data["metadata_json"]) + if "metric_keys" in data and data["metric_keys"] is not None: + self._validate_metric_keys(data["metric_keys"]) + if data.get("status") is not None: + self.policies["analytics_report_definition"].assert_status_change( + current=entity.status, target=data["status"] + ) + for field, value in data.items(): + setattr(entity, field, value) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "analytics_report_definition", + AuditAction.UPDATE, + ExperienceEventType.ANALYTICS_REPORT_DEFINITION_UPDATED, + actor, + data, + ) + + async def delete_report_definition(self, tenant_id, entity_id, *, actor=None): + entity = await self._get( + self.reports, tenant_id, entity_id, "analytics report definition" + ) + await self.reports.soft_delete(entity, deleted_by=_actor(actor)) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "analytics_report_definition", + AuditAction.DELETE, + ExperienceEventType.ANALYTICS_REPORT_DEFINITION_DELETED, + actor, + ) + + async def _count(self, tenant_id: UUID, model: type) -> int: + stmt = select(func.count()).select_from(model).where( + model.tenant_id == tenant_id, # type: ignore[attr-defined] + model.is_deleted.is_(False), # type: ignore[attr-defined] + ) + result = await self.session.execute(stmt) + return int(result.scalar_one()) + + async def refresh_snapshot(self, tenant_id, body, *, actor=None): + report = await self.get_report_definition(tenant_id, body.report_id) + workspace_id = body.workspace_id or report.workspace_id + await self._scope(tenant_id, workspace_id, body.site_id) + metrics: dict[str, int] = {} + for key in report.metric_keys: + model = METRIC_TABLE_MAP.get(key) + if model is None: + continue + metrics[key] = await self._count(tenant_id, model) + entity = ExperienceAnalyticsSnapshot( + tenant_id=tenant_id, + report_id=report.id, + workspace_id=workspace_id, + site_id=body.site_id, + period_start=body.period_start, + period_end=body.period_end, + metrics=metrics, + status=AnalyticsSnapshotStatus.ACTIVE, + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.snapshots.add(entity) + return await self._finish( + entity, + "analytics_snapshot", + AuditAction.CREATE, + ExperienceEventType.ANALYTICS_SNAPSHOT_REFRESHED, + actor, + {"report_id": str(report.id), "metric_count": len(metrics)}, + ) + + async def get_snapshot(self, tenant_id, entity_id): + return await self._get(self.snapshots, tenant_id, entity_id, "analytics snapshot") + + async def list_snapshots(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.snapshots, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_snapshot(self, tenant_id, entity_id, body, *, actor=None): + entity = await self._get(self.snapshots, tenant_id, entity_id, "analytics snapshot") + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if data.get("status") is not None: + self.policies["analytics_snapshot"].assert_status_change( + current=entity.status, target=data["status"] + ) + for field, value in data.items(): + setattr(entity, field, value) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "analytics_snapshot", + AuditAction.UPDATE, + ExperienceEventType.ANALYTICS_SNAPSHOT_UPDATED, + actor, + data, + ) + + async def delete_snapshot(self, tenant_id, entity_id, *, actor=None): + entity = await self._get(self.snapshots, tenant_id, entity_id, "analytics snapshot") + await self.snapshots.soft_delete(entity, deleted_by=_actor(actor)) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish( + entity, + "analytics_snapshot", + AuditAction.DELETE, + ExperienceEventType.ANALYTICS_SNAPSHOT_DELETED, + actor, + ) diff --git a/backend/services/experience/app/services/audit_service.py b/backend/services/experience/app/services/audit_service.py new file mode 100644 index 0000000..3655386 --- /dev/null +++ b/backend/services/experience/app/services/audit_service.py @@ -0,0 +1,51 @@ +"""Audit service — append-only Experience audit trail.""" +from __future__ import annotations + +from typing import Any +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.foundation import ExperienceAuditLog +from app.models.types import AuditAction +from app.repositories.foundation import ExperienceAuditLogRepository + + +class AuditService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceAuditLogRepository(session) + + async def record( + self, + *, + tenant_id: UUID, + entity_type: str, + entity_id: UUID, + action: AuditAction, + actor_user_id: str | None = None, + changes: dict[str, Any] | None = None, + message: str | None = None, + commit: bool = False, + ) -> ExperienceAuditLog: + entry = ExperienceAuditLog( + tenant_id=tenant_id, + entity_type=entity_type, + entity_id=entity_id, + action=action, + actor_user_id=actor_user_id, + changes=changes, + message=message, + ) + await self.repo.add(entry) + if commit: + await self.session.commit() + await self.session.refresh(entry) + return entry + + async def list_for_entity( + self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100 + ): + return await self.repo.list_for_entity( + tenant_id, entity_type, entity_id, limit=limit + ) diff --git a/backend/services/experience/app/services/bundles.py b/backend/services/experience/app/services/bundles.py new file mode 100644 index 0000000..831accc --- /dev/null +++ b/backend/services/experience/app/services/bundles.py @@ -0,0 +1,240 @@ +"""Bundles, Core licensing references and feature toggles — Phase 11.8.""" +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.bundles import ( + ExperienceBundleDefinition, + ExperienceFeatureToggle, + ExperienceLicenseBinding, + ExperienceTenantBundle, +) +from app.models.types import AuditAction, BundleDefinitionStatus, TenantBundleStatus +from app.policies.bundles import ( + BundleDefinitionPolicy, + FeatureTogglePolicy, + LicenseBindingPolicy, + TenantBundlePolicy, +) +from app.repositories.bundles import ( + BundleDefinitionRepository, + FeatureToggleRepository, + LicenseBindingRepository, + TenantBundleRepository, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperienceSiteRepository +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version +from app.validators.bundles import validate_entitlement_ref, validate_metadata +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _actor(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _jsonable(value): + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, (UUID, datetime)): + return value.isoformat() if isinstance(value, datetime) else str(value) + if hasattr(value, "value"): + return value.value + return value + + +class ExperienceBundlesService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.definitions = BundleDefinitionRepository(session) + self.tenant_bundles = TenantBundleRepository(session) + self.licenses = LicenseBindingRepository(session) + self.toggles = FeatureToggleRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policies = { + "bundle_definition": BundleDefinitionPolicy(), + "tenant_bundle": TenantBundlePolicy(), + "license_binding": LicenseBindingPolicy(), + "feature_toggle": FeatureTogglePolicy(), + } + + async def _scope(self, tenant_id, workspace_id, site_id=None): + if await self.workspaces.get(tenant_id, workspace_id) is None: + raise NotFoundError("فضای کاری یافت نشد") + if site_id is not None: + site = await self.sites.get(tenant_id, site_id) + if site is None or site.workspace_id != workspace_id: + raise NotFoundError("سایت یافت نشد") + + async def _finish(self, entity, kind, action, event, actor, changes=None): + await self.audit.record( + tenant_id=entity.tenant_id, + entity_type=kind, + entity_id=entity.id, + action=action, + actor_user_id=_actor(actor), + changes=_jsonable(changes or {}), + ) + self.events.publish( + event_type=event, + aggregate_type=kind, + aggregate_id=entity.id, + tenant_id=entity.tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _get(self, repo, tenant_id, entity_id, label): + entity = await repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError(f"{label} یافت نشد") + return entity + + async def _list(self, repo, tenant_id, *, offset, limit, spec): + items, total = await repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec) + return list(items), total + + async def _update(self, repo, tenant_id, entity_id, body, kind, policy, event, actor): + entity = await self._get(repo, tenant_id, entity_id, kind) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "metadata_json" in data: + data["metadata_json"] = validate_metadata(data["metadata_json"]) + if "core_entitlement_ref" in data: + data["core_entitlement_ref"] = validate_entitlement_ref( + data["core_entitlement_ref"], required=kind == "license_binding" + ) + if data.get("status") is not None: + policy.assert_status_change(current=entity.status, target=data["status"]) + if kind == "tenant_bundle" and data.get("status") == TenantBundleStatus.ACTIVE: + definition = await self._get( + self.definitions, tenant_id, entity.bundle_definition_id, "bundle definition" + ) + if definition.status != BundleDefinitionStatus.AVAILABLE: + raise AppError("بسته غیرفعال قابل فعال‌سازی نیست", error_code="bundle_definition_unavailable", status_code=409) + data["activated_at"] = datetime.now(timezone.utc) + data["activated_by"] = _actor(actor) + for field, value in data.items(): + setattr(entity, field, value) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish(entity, kind, AuditAction.UPDATE, event, actor, data) + + async def _delete(self, repo, tenant_id, entity_id, kind, event, actor): + entity = await self._get(repo, tenant_id, entity_id, kind) + await repo.soft_delete(entity, deleted_by=_actor(actor)) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish(entity, kind, AuditAction.DELETE, event, actor) + + async def create_bundle_definition(self, tenant_id, body, *, actor=None): + if body.workspace_id is not None: + await self._scope(tenant_id, body.workspace_id) + entity = ExperienceBundleDefinition( + tenant_id=tenant_id, **body.model_dump(exclude={"metadata_json"}), + metadata_json=validate_metadata(body.metadata_json), created_by=_actor(actor), updated_by=_actor(actor) + ) + await self.definitions.add(entity) + return await self._finish(entity, "bundle_definition", AuditAction.CREATE, ExperienceEventType.BUNDLE_DEFINITION_CREATED, actor) + + async def get_bundle_definition(self, tenant_id, entity_id): + return await self._get(self.definitions, tenant_id, entity_id, "bundle definition") + + async def list_bundle_definitions(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.definitions, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_bundle_definition(self, tenant_id, entity_id, body, *, actor=None): + return await self._update(self.definitions, tenant_id, entity_id, body, "bundle_definition", self.policies["bundle_definition"], ExperienceEventType.BUNDLE_DEFINITION_UPDATED, actor) + + async def delete_bundle_definition(self, tenant_id, entity_id, *, actor=None): + return await self._delete(self.definitions, tenant_id, entity_id, "bundle_definition", ExperienceEventType.BUNDLE_DEFINITION_DELETED, actor) + + async def create_tenant_bundle(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id) + definition = await self.get_bundle_definition(tenant_id, body.bundle_definition_id) + if body.status == TenantBundleStatus.ACTIVE and definition.status != BundleDefinitionStatus.AVAILABLE: + raise AppError("بسته غیرفعال قابل فعال‌سازی نیست", error_code="bundle_definition_unavailable", status_code=409) + now = datetime.now(timezone.utc) if body.status == TenantBundleStatus.ACTIVE else None + entity = ExperienceTenantBundle( + tenant_id=tenant_id, workspace_id=body.workspace_id, site_id=body.site_id, + bundle_definition_id=definition.id, bundle_key=definition.bundle_key, status=body.status, + core_entitlement_ref=validate_entitlement_ref(body.core_entitlement_ref), + activated_at=now, activated_by=_actor(actor) if now else None, expires_at=body.expires_at, + metadata_json=validate_metadata(body.metadata_json), created_by=_actor(actor), updated_by=_actor(actor) + ) + await self.tenant_bundles.add(entity) + return await self._finish(entity, "tenant_bundle", AuditAction.CREATE, ExperienceEventType.TENANT_BUNDLE_CREATED, actor) + + async def get_tenant_bundle(self, tenant_id, entity_id): + return await self._get(self.tenant_bundles, tenant_id, entity_id, "tenant bundle") + + async def list_tenant_bundles(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.tenant_bundles, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_tenant_bundle(self, tenant_id, entity_id, body, *, actor=None): + return await self._update(self.tenant_bundles, tenant_id, entity_id, body, "tenant_bundle", self.policies["tenant_bundle"], ExperienceEventType.TENANT_BUNDLE_UPDATED, actor) + + async def delete_tenant_bundle(self, tenant_id, entity_id, *, actor=None): + return await self._delete(self.tenant_bundles, tenant_id, entity_id, "tenant_bundle", ExperienceEventType.TENANT_BUNDLE_DELETED, actor) + + async def create_license_binding(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id) + bundle_key = body.bundle_key + if body.tenant_bundle_id is not None: + bundle = await self.get_tenant_bundle(tenant_id, body.tenant_bundle_id) + if bundle.workspace_id != body.workspace_id: + raise AppError("بسته متعلق به فضای کاری نیست", error_code="bundle_workspace_mismatch", status_code=422) + bundle_key = bundle.bundle_key + entity = ExperienceLicenseBinding( + tenant_id=tenant_id, workspace_id=body.workspace_id, tenant_bundle_id=body.tenant_bundle_id, + bundle_key=bundle_key, core_entitlement_ref=validate_entitlement_ref(body.core_entitlement_ref, required=True), + status=body.status, starts_at=body.starts_at, ends_at=body.ends_at, + metadata_json=validate_metadata(body.metadata_json), created_by=_actor(actor), updated_by=_actor(actor) + ) + await self.licenses.add(entity) + return await self._finish(entity, "license_binding", AuditAction.CREATE, ExperienceEventType.LICENSE_BINDING_CREATED, actor) + + async def get_license_binding(self, tenant_id, entity_id): + return await self._get(self.licenses, tenant_id, entity_id, "license binding") + + async def list_license_bindings(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.licenses, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_license_binding(self, tenant_id, entity_id, body, *, actor=None): + return await self._update(self.licenses, tenant_id, entity_id, body, "license_binding", self.policies["license_binding"], ExperienceEventType.LICENSE_BINDING_UPDATED, actor) + + async def delete_license_binding(self, tenant_id, entity_id, *, actor=None): + return await self._delete(self.licenses, tenant_id, entity_id, "license_binding", ExperienceEventType.LICENSE_BINDING_DELETED, actor) + + async def create_feature_toggle(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id) + entity = ExperienceFeatureToggle( + tenant_id=tenant_id, **body.model_dump(exclude={"metadata_json"}), + metadata_json=validate_metadata(body.metadata_json), created_by=_actor(actor), updated_by=_actor(actor) + ) + await self.toggles.add(entity) + return await self._finish(entity, "feature_toggle", AuditAction.CREATE, ExperienceEventType.FEATURE_TOGGLE_CREATED, actor) + + async def get_feature_toggle(self, tenant_id, entity_id): + return await self._get(self.toggles, tenant_id, entity_id, "feature toggle") + + async def list_feature_toggles(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.toggles, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_feature_toggle(self, tenant_id, entity_id, body, *, actor=None): + return await self._update(self.toggles, tenant_id, entity_id, body, "feature_toggle", self.policies["feature_toggle"], ExperienceEventType.FEATURE_TOGGLE_UPDATED, actor) + + async def delete_feature_toggle(self, tenant_id, entity_id, *, actor=None): + return await self._delete(self.toggles, tenant_id, entity_id, "feature_toggle", ExperienceEventType.FEATURE_TOGGLE_DELETED, actor) diff --git a/backend/services/experience/app/services/components.py b/backend/services/experience/app/services/components.py new file mode 100644 index 0000000..07b8249 --- /dev/null +++ b/backend/services/experience/app/services/components.py @@ -0,0 +1,760 @@ +"""Component library application services — Phase 11.2.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.components import ( + ExperienceComponent, + ExperienceComponentVersion, + PageComponentPlacement, +) +from app.models.types import ( + AuditAction, + ComponentStatus, + ComponentVersionStatus, +) +from app.policies.components import ( + ComponentLifecyclePolicy, + ComponentVersionPolicy, + PagePlacementPolicy, +) +from app.repositories.components import ( + ExperienceComponentRepository, + ExperienceComponentVersionRepository, + PageComponentPlacementRepository, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.schemas.components import ( + ExperienceComponentCreate, + ExperienceComponentUpdate, + ExperienceComponentVersionCreate, + ExperienceComponentVersionUpdate, + PageComponentPlacementCreate, + PageComponentPlacementUpdate, +) +from app.services.audit_service import AuditService +from app.specifications.components import ( + ComponentListSpec, + ComponentVersionListSpec, + PlacementListSpec, +) +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from app.validators.components import ( + ensure_schema_payload, + validate_component_kind, + validate_slot_key, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ExperienceComponentService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceComponentRepository(session) + self.versions = ExperienceComponentVersionRepository(session) + self.placements = PageComponentPlacementRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.lifecycle_policy = ComponentLifecyclePolicy() + self.version_policy = ComponentVersionPolicy() + self.placement_policy = PagePlacementPolicy() + + # --- Components --- + + async def create_component( + self, + tenant_id: UUID, + body: ExperienceComponentCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponent: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + kind = validate_component_kind(body.kind) + if await self.repo.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد کامپوننت تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExperienceComponent( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + description=body.description, + kind=kind, + status=body.status, + category=body.category, + tags=body.tags, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد کامپوننت تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="component", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_CREATED, + aggregate_type="component", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "workspace_id": str(entity.workspace_id), + "kind": entity.kind.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_component( + self, tenant_id: UUID, component_id: UUID + ) -> ExperienceComponent: + entity = await self.repo.get(tenant_id, component_id) + if entity is None: + raise NotFoundError("کامپوننت یافت نشد") + return entity + + async def list_components( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ComponentListSpec | None = None, + ) -> tuple[list[ExperienceComponent], int]: + items, total = await self.repo.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_component( + self, + tenant_id: UUID, + component_id: UUID, + body: ExperienceComponentUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponent: + entity = await self.get_component(tenant_id, component_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "kind" in data and data["kind"] is not None: + data["kind"] = validate_component_kind(data["kind"]) + if "status" in data and data["status"] is not None: + self.lifecycle_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_UPDATED, + aggregate_type="component", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def activate_component( + self, + tenant_id: UUID, + component_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponent: + return await self._set_component_status( + tenant_id, + component_id, + ComponentStatus.ACTIVE, + ExperienceEventType.COMPONENT_ACTIVATED, + actor=actor, + ) + + async def deprecate_component( + self, + tenant_id: UUID, + component_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponent: + return await self._set_component_status( + tenant_id, + component_id, + ComponentStatus.DEPRECATED, + ExperienceEventType.COMPONENT_DEPRECATED, + actor=actor, + ) + + async def archive_component( + self, + tenant_id: UUID, + component_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponent: + return await self._set_component_status( + tenant_id, + component_id, + ComponentStatus.ARCHIVED, + ExperienceEventType.COMPONENT_ARCHIVED, + actor=actor, + ) + + async def _set_component_status( + self, + tenant_id: UUID, + component_id: UUID, + target: ComponentStatus, + event_type: ExperienceEventType, + *, + actor: CurrentUser | None, + ) -> ExperienceComponent: + entity = await self.get_component(tenant_id, component_id) + self.lifecycle_policy.assert_status_change( + current=entity.status, target=target + ) + entity.status = target + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": target.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="component", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": target.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_component( + self, + tenant_id: UUID, + component_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponent: + entity = await self.get_component(tenant_id, component_id) + usage = await self.placements.count_by_component(tenant_id, component_id) + if usage > 0: + raise AppError( + "کامپوننت دارای جایگذاری فعال است", + error_code="component_in_use", + status_code=409, + details={"placement_count": usage}, + ) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_UPDATED, + aggregate_type="component", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "is_deleted": True}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Versions --- + + async def create_version( + self, + tenant_id: UUID, + body: ExperienceComponentVersionCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponentVersion: + component = await self.get_component(tenant_id, body.component_id) + if component.workspace_id != body.workspace_id: + raise AppError( + "workspace_id با کامپوننت هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + next_number = ( + await self.versions.max_version_number(tenant_id, body.component_id) + 1 + ) + entity = ExperienceComponentVersion( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + component_id=body.component_id, + version_number=next_number, + status=ComponentVersionStatus.DRAFT, + definition_schema=ensure_schema_payload( + body.definition_schema, "definition_schema" + ), + props_defaults=ensure_schema_payload( + body.props_defaults, "props_defaults" + ), + render_hint=ensure_schema_payload(body.render_hint, "render_hint"), + changelog=body.changelog, + metadata_json=body.metadata_json, + is_immutable=False, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.versions.add(entity) + component.latest_version_number = next_number + component.updated_by = _actor_id(actor) + component.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component_version", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes( + {"component_id": body.component_id, "version_number": next_number} + ), + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_VERSION_CREATED, + aggregate_type="component_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "component_id": str(body.component_id), + "version_number": next_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_version( + self, tenant_id: UUID, version_id: UUID + ) -> ExperienceComponentVersion: + entity = await self.versions.get(tenant_id, version_id) + if entity is None: + raise NotFoundError("نسخه کامپوننت یافت نشد") + return entity + + async def list_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ComponentVersionListSpec | None = None, + ) -> tuple[list[ExperienceComponentVersion], int]: + items, total = await self.versions.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceComponentVersionUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponentVersion: + entity = await self.get_version(tenant_id, version_id) + self.version_policy.assert_can_update(entity) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("definition_schema", "props_defaults", "render_hint"): + if field in data: + data[field] = ensure_schema_payload(data[field], field) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component_version", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_VERSION_UPDATED, + aggregate_type="component_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "component_id": str(entity.component_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def publish_version( + self, + tenant_id: UUID, + version_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponentVersion: + entity = await self.get_version(tenant_id, version_id) + self.version_policy.assert_publish(current=entity.status) + component = await self.get_component(tenant_id, entity.component_id) + now = datetime.now(timezone.utc) + entity.status = ComponentVersionStatus.PUBLISHED + entity.is_immutable = True + entity.published_at = now + entity.updated_by = _actor_id(actor) + entity.version += 1 + component.published_version_id = entity.id + if component.status == ComponentStatus.DRAFT: + component.status = ComponentStatus.ACTIVE + component.updated_by = _actor_id(actor) + component.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component_version", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + changes={"status": "published", "is_immutable": True}, + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_VERSION_PUBLISHED, + aggregate_type="component_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "component_id": str(entity.component_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def retire_version( + self, + tenant_id: UUID, + version_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceComponentVersion: + entity = await self.get_version(tenant_id, version_id) + self.version_policy.assert_retire(current=entity.status) + usage = await self.placements.count_by_version(tenant_id, version_id) + if usage > 0: + raise AppError( + "نسخه دارای جایگذاری فعال است", + error_code="component_version_in_use", + status_code=409, + details={"placement_count": usage}, + ) + component = await self.get_component(tenant_id, entity.component_id) + entity.status = ComponentVersionStatus.RETIRED + entity.retired_at = datetime.now(timezone.utc) + entity.updated_by = _actor_id(actor) + entity.version += 1 + if component.published_version_id == entity.id: + component.published_version_id = None + component.updated_by = _actor_id(actor) + component.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="component_version", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": "retired"}, + ) + self.events.publish( + event_type=ExperienceEventType.COMPONENT_VERSION_RETIRED, + aggregate_type="component_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "component_id": str(entity.component_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Placements --- + + async def create_placement( + self, + tenant_id: UUID, + body: PageComponentPlacementCreate, + *, + actor: CurrentUser | None = None, + ) -> PageComponentPlacement: + site = await self.sites.get(tenant_id, body.site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + page = await self.pages.get(tenant_id, body.page_id) + if page is None: + raise NotFoundError("صفحه یافت نشد") + if page.site_id != body.site_id: + raise AppError( + "page_id با site_id هم‌خوان نیست", + error_code="page_site_mismatch", + status_code=422, + ) + component = await self.get_component(tenant_id, body.component_id) + version = await self.get_version(tenant_id, body.component_version_id) + if version.component_id != body.component_id: + raise AppError( + "component_version_id با component_id هم‌خوان نیست", + error_code="version_component_mismatch", + status_code=422, + ) + self.placement_policy.assert_component_placeable(component) + self.placement_policy.assert_version_placeable(version) + slot_key = validate_slot_key(body.slot_key) + if await self.placements.get_by_slot(tenant_id, body.page_id, slot_key): + raise AppError( + "slot_key در این صفحه تکراری است", + error_code="duplicate_slot", + status_code=409, + ) + entity = PageComponentPlacement( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + component_id=body.component_id, + component_version_id=body.component_version_id, + slot_key=slot_key, + sort_order=body.sort_order, + props_override=ensure_schema_payload( + body.props_override, "props_override" + ), + is_visible=body.is_visible, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.placements.add(entity) + except IntegrityError as exc: + raise AppError( + "slot_key در این صفحه تکراری است", + error_code="duplicate_slot", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="page_component_placement", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_COMPONENT_PLACED, + aggregate_type="page_component_placement", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "page_id": str(entity.page_id), + "component_id": str(entity.component_id), + "slot_key": entity.slot_key, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_placement( + self, tenant_id: UUID, placement_id: UUID + ) -> PageComponentPlacement: + entity = await self.placements.get(tenant_id, placement_id) + if entity is None: + raise NotFoundError("جایگذاری کامپوننت یافت نشد") + return entity + + async def list_placements( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: PlacementListSpec | None = None, + ) -> tuple[list[PageComponentPlacement], int]: + items, total = await self.placements.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_placement( + self, + tenant_id: UUID, + placement_id: UUID, + body: PageComponentPlacementUpdate, + *, + actor: CurrentUser | None = None, + ) -> PageComponentPlacement: + entity = await self.get_placement(tenant_id, placement_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "component_version_id" in data and data["component_version_id"] is not None: + version = await self.get_version(tenant_id, data["component_version_id"]) + if version.component_id != entity.component_id: + raise AppError( + "component_version_id با component_id هم‌خوان نیست", + error_code="version_component_mismatch", + status_code=422, + ) + self.placement_policy.assert_version_placeable(version) + if "slot_key" in data and data["slot_key"] is not None: + slot_key = validate_slot_key(data["slot_key"]) + existing = await self.placements.get_by_slot( + tenant_id, entity.page_id, slot_key + ) + if existing is not None and existing.id != entity.id: + raise AppError( + "slot_key در این صفحه تکراری است", + error_code="duplicate_slot", + status_code=409, + ) + data["slot_key"] = slot_key + if "props_override" in data: + data["props_override"] = ensure_schema_payload( + data["props_override"], "props_override" + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="page_component_placement", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_COMPONENT_UPDATED, + aggregate_type="page_component_placement", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "page_id": str(entity.page_id), + "slot_key": entity.slot_key, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_placement( + self, + tenant_id: UUID, + placement_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> PageComponentPlacement: + entity = await self.get_placement(tenant_id, placement_id) + await self.placements.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="page_component_placement", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_COMPONENT_REMOVED, + aggregate_type="page_component_placement", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "page_id": str(entity.page_id), + "slot_key": entity.slot_key, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/experience/app/services/forms.py b/backend/services/experience/app/services/forms.py new file mode 100644 index 0000000..a092846 --- /dev/null +++ b/backend/services/experience/app/services/forms.py @@ -0,0 +1,978 @@ +"""Forms, surveys & appointment application services — Phase 11.6.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.forms import ( + AppointmentBookingRef, + ExperienceAppointmentPage, + ExperienceForm, + ExperienceSurvey, + FormSubmission, + SurveyResponse, +) +from app.models.types import ( + AppointmentPageStatus, + AuditAction, + FormStatus, + FormSubmissionStatus, + SurveyResponseStatus, + SurveyStatus, +) +from app.policies.forms import ( + AppointmentBookingPolicy, + AppointmentPagePolicy, + FormLifecyclePolicy, + FormSubmissionPolicy, + SurveyLifecyclePolicy, + SurveyResponsePolicy, +) +from app.repositories.forms import ( + AppointmentBookingRefRepository, + ExperienceAppointmentPageRepository, + ExperienceFormRepository, + ExperienceSurveyRepository, + FormSubmissionRepository, + SurveyResponseRepository, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.schemas.forms import ( + AppointmentBookingRefCreate, + AppointmentBookingRefUpdate, + ExperienceAppointmentPageCreate, + ExperienceAppointmentPageUpdate, + ExperienceFormCreate, + ExperienceFormUpdate, + ExperienceSurveyCreate, + ExperienceSurveyUpdate, + FormSubmissionCreate, + FormSubmissionUpdate, + SurveyResponseCreate, + SurveyResponseUpdate, +) +from app.services.audit_service import AuditService +from app.specifications.forms import ( + AppointmentBookingListSpec, + AppointmentPageListSpec, + FormListSpec, + FormSubmissionListSpec, + SurveyListSpec, + SurveyResponseListSpec, +) +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from app.validators.forms import ensure_json_object, validate_external_ref +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ExperienceFormsService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.forms = ExperienceFormRepository(session) + self.submissions = FormSubmissionRepository(session) + self.surveys = ExperienceSurveyRepository(session) + self.responses = SurveyResponseRepository(session) + self.appointment_pages = ExperienceAppointmentPageRepository(session) + self.bookings = AppointmentBookingRefRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.form_policy = FormLifecyclePolicy() + self.submission_policy = FormSubmissionPolicy() + self.survey_policy = SurveyLifecyclePolicy() + self.response_policy = SurveyResponsePolicy() + self.appointment_policy = AppointmentPagePolicy() + self.booking_policy = AppointmentBookingPolicy() + + async def _require_workspace(self, tenant_id: UUID, workspace_id: UUID): + ws = await self.workspaces.get(tenant_id, workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + return ws + + async def _require_site( + self, tenant_id: UUID, site_id: UUID, *, workspace_id: UUID | None = None + ): + site = await self.sites.get(tenant_id, site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + if workspace_id is not None and site.workspace_id != workspace_id: + raise AppError( + "سایت متعلق به این فضای کاری نیست", + error_code="site_workspace_mismatch", + status_code=422, + ) + return site + + # ── Forms ───────────────────────────────────────────────────────── + + async def create_form( + self, + tenant_id: UUID, + body: ExperienceFormCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceForm: + await self._require_workspace(tenant_id, body.workspace_id) + if body.site_id is not None: + await self._require_site( + tenant_id, body.site_id, workspace_id=body.workspace_id + ) + code = validate_code(body.code) + if await self.forms.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد فرم تکراری است", error_code="duplicate_code", status_code=409 + ) + entity = ExperienceForm( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + code=code, + name=validate_non_empty(body.name, "name"), + description=body.description, + status=body.status, + form_schema_json=ensure_json_object( + body.form_schema_json, "form_schema_json" + ), + submit_action_json=ensure_json_object( + body.submit_action_json, "submit_action_json" + ), + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.forms.add(entity) + except IntegrityError as exc: + raise AppError( + "کد فرم تکراری است", error_code="duplicate_code", status_code=409 + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="form", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.FORM_CREATED, + aggregate_type="form", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_form(self, tenant_id: UUID, form_id: UUID) -> ExperienceForm: + entity = await self.forms.get(tenant_id, form_id) + if entity is None: + raise NotFoundError("فرم یافت نشد") + return entity + + async def list_forms( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: FormListSpec | None = None, + ) -> tuple[list[ExperienceForm], int]: + items, total = await self.forms.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_form( + self, + tenant_id: UUID, + form_id: UUID, + body: ExperienceFormUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceForm: + entity = await self.get_form(tenant_id, form_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + for field in ("form_schema_json", "submit_action_json", "metadata_json"): + if field in data: + data[field] = ensure_json_object(data[field], field) + if "status" in data and data["status"] is not None: + self.form_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="form", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.FORM_UPDATED, + aggregate_type="form", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_form( + self, tenant_id: UUID, form_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceForm: + entity = await self.get_form(tenant_id, form_id) + await self.forms.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.status = FormStatus.ARCHIVED + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="form", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.FORM_ARCHIVED, + aggregate_type="form", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_submission( + self, + tenant_id: UUID, + body: FormSubmissionCreate, + *, + actor: CurrentUser | None = None, + ) -> FormSubmission: + await self._require_workspace(tenant_id, body.workspace_id) + form = await self.get_form(tenant_id, body.form_id) + if form.workspace_id != body.workspace_id: + raise AppError( + "فرم متعلق به این فضای کاری نیست", + error_code="form_workspace_mismatch", + status_code=422, + ) + self.form_policy.assert_accepts_submissions(form) + entity = FormSubmission( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + form_id=body.form_id, + status=FormSubmissionStatus.RECEIVED, + payload_json=ensure_json_object(body.payload_json, "payload_json"), + crm_contact_ref=validate_external_ref( + body.crm_contact_ref, "crm_contact_ref" + ), + communication_ref=validate_external_ref( + body.communication_ref, "communication_ref" + ), + external_ref=validate_external_ref(body.external_ref, "external_ref"), + submitted_at=datetime.now(timezone.utc), + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.submissions.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="form_submission", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.FORM_SUBMISSION_RECEIVED, + aggregate_type="form_submission", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "form_id": str(entity.form_id), + "crm_contact_ref": entity.crm_contact_ref, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_submission( + self, tenant_id: UUID, submission_id: UUID + ) -> FormSubmission: + entity = await self.submissions.get(tenant_id, submission_id) + if entity is None: + raise NotFoundError("ارسال فرم یافت نشد") + return entity + + async def list_submissions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: FormSubmissionListSpec | None = None, + ) -> tuple[list[FormSubmission], int]: + items, total = await self.submissions.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_submission( + self, + tenant_id: UUID, + submission_id: UUID, + body: FormSubmissionUpdate, + *, + actor: CurrentUser | None = None, + ) -> FormSubmission: + entity = await self.get_submission(tenant_id, submission_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("crm_contact_ref", "communication_ref", "external_ref"): + if field in data: + data[field] = validate_external_ref(data[field], field) + if "metadata_json" in data: + data["metadata_json"] = ensure_json_object( + data["metadata_json"], "metadata_json" + ) + if "status" in data and data["status"] is not None: + self.submission_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="form_submission", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.FORM_SUBMISSION_UPDATED, + aggregate_type="form_submission", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ── Surveys ─────────────────────────────────────────────────────── + + async def create_survey( + self, + tenant_id: UUID, + body: ExperienceSurveyCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSurvey: + await self._require_workspace(tenant_id, body.workspace_id) + if body.site_id is not None: + await self._require_site( + tenant_id, body.site_id, workspace_id=body.workspace_id + ) + code = validate_code(body.code) + if await self.surveys.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد نظرسنجی تکراری است", error_code="duplicate_code", status_code=409 + ) + entity = ExperienceSurvey( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + code=code, + name=validate_non_empty(body.name, "name"), + description=body.description, + status=body.status, + questions_json=ensure_json_object(body.questions_json, "questions_json"), + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.surveys.add(entity) + except IntegrityError as exc: + raise AppError( + "کد نظرسنجی تکراری است", error_code="duplicate_code", status_code=409 + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="survey", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SURVEY_CREATED, + aggregate_type="survey", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_survey(self, tenant_id: UUID, survey_id: UUID) -> ExperienceSurvey: + entity = await self.surveys.get(tenant_id, survey_id) + if entity is None: + raise NotFoundError("نظرسنجی یافت نشد") + return entity + + async def list_surveys( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SurveyListSpec | None = None, + ) -> tuple[list[ExperienceSurvey], int]: + items, total = await self.surveys.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_survey( + self, + tenant_id: UUID, + survey_id: UUID, + body: ExperienceSurveyUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSurvey: + entity = await self.get_survey(tenant_id, survey_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + for field in ("questions_json", "metadata_json"): + if field in data: + data[field] = ensure_json_object(data[field], field) + if "status" in data and data["status"] is not None: + self.survey_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="survey", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.SURVEY_UPDATED, + aggregate_type="survey", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_survey( + self, tenant_id: UUID, survey_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceSurvey: + entity = await self.get_survey(tenant_id, survey_id) + await self.surveys.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.status = SurveyStatus.ARCHIVED + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="survey", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.SURVEY_ARCHIVED, + aggregate_type="survey", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_survey_response( + self, + tenant_id: UUID, + body: SurveyResponseCreate, + *, + actor: CurrentUser | None = None, + ) -> SurveyResponse: + await self._require_workspace(tenant_id, body.workspace_id) + survey = await self.get_survey(tenant_id, body.survey_id) + if survey.workspace_id != body.workspace_id: + raise AppError( + "نظرسنجی متعلق به این فضای کاری نیست", + error_code="survey_workspace_mismatch", + status_code=422, + ) + self.survey_policy.assert_accepts_responses(survey) + entity = SurveyResponse( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + survey_id=body.survey_id, + status=SurveyResponseStatus.RECEIVED, + answers_json=ensure_json_object(body.answers_json, "answers_json"), + crm_contact_ref=validate_external_ref( + body.crm_contact_ref, "crm_contact_ref" + ), + external_ref=validate_external_ref(body.external_ref, "external_ref"), + submitted_at=datetime.now(timezone.utc), + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.responses.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="survey_response", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SURVEY_RESPONSE_RECEIVED, + aggregate_type="survey_response", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"survey_id": str(entity.survey_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_survey_response( + self, tenant_id: UUID, response_id: UUID + ) -> SurveyResponse: + entity = await self.responses.get(tenant_id, response_id) + if entity is None: + raise NotFoundError("پاسخ نظرسنجی یافت نشد") + return entity + + async def list_survey_responses( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SurveyResponseListSpec | None = None, + ) -> tuple[list[SurveyResponse], int]: + items, total = await self.responses.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_survey_response( + self, + tenant_id: UUID, + response_id: UUID, + body: SurveyResponseUpdate, + *, + actor: CurrentUser | None = None, + ) -> SurveyResponse: + entity = await self.get_survey_response(tenant_id, response_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("crm_contact_ref", "external_ref"): + if field in data: + data[field] = validate_external_ref(data[field], field) + if "metadata_json" in data: + data["metadata_json"] = ensure_json_object( + data["metadata_json"], "metadata_json" + ) + if "status" in data and data["status"] is not None: + self.response_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="survey_response", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.SURVEY_RESPONSE_UPDATED, + aggregate_type="survey_response", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ── Appointment pages & booking refs ────────────────────────────── + + async def create_appointment_page( + self, + tenant_id: UUID, + body: ExperienceAppointmentPageCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceAppointmentPage: + await self._require_workspace(tenant_id, body.workspace_id) + await self._require_site( + tenant_id, body.site_id, workspace_id=body.workspace_id + ) + code = validate_code(body.code) + if await self.appointment_pages.get_by_code( + tenant_id, body.workspace_id, code + ): + raise AppError( + "کد صفحه نوبت تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExperienceAppointmentPage( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + code=code, + name=validate_non_empty(body.name, "name"), + description=body.description, + status=body.status, + booking_provider_ref=validate_external_ref( + body.booking_provider_ref, "booking_provider_ref" + ), + service_ref=validate_external_ref(body.service_ref, "service_ref"), + timezone=body.timezone, + availability_shell_json=ensure_json_object( + body.availability_shell_json, "availability_shell_json" + ), + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.appointment_pages.add(entity) + except IntegrityError as exc: + raise AppError( + "کد صفحه نوبت تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment_page", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.APPOINTMENT_PAGE_CREATED, + aggregate_type="appointment_page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "booking_provider_ref": entity.booking_provider_ref, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_appointment_page( + self, tenant_id: UUID, page_id: UUID + ) -> ExperienceAppointmentPage: + entity = await self.appointment_pages.get(tenant_id, page_id) + if entity is None: + raise NotFoundError("صفحه نوبت یافت نشد") + return entity + + async def list_appointment_pages( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: AppointmentPageListSpec | None = None, + ) -> tuple[list[ExperienceAppointmentPage], int]: + items, total = await self.appointment_pages.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_appointment_page( + self, + tenant_id: UUID, + page_id: UUID, + body: ExperienceAppointmentPageUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceAppointmentPage: + entity = await self.get_appointment_page(tenant_id, page_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + for field in ("booking_provider_ref", "service_ref"): + if field in data: + data[field] = validate_external_ref(data[field], field) + for field in ("availability_shell_json", "metadata_json"): + if field in data: + data[field] = ensure_json_object(data[field], field) + if "status" in data and data["status"] is not None: + self.appointment_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment_page", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.APPOINTMENT_PAGE_UPDATED, + aggregate_type="appointment_page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_appointment_page( + self, tenant_id: UUID, page_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceAppointmentPage: + entity = await self.get_appointment_page(tenant_id, page_id) + await self.appointment_pages.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.status = AppointmentPageStatus.ARCHIVED + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment_page", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.APPOINTMENT_PAGE_ARCHIVED, + aggregate_type="appointment_page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def create_booking_ref( + self, + tenant_id: UUID, + body: AppointmentBookingRefCreate, + *, + actor: CurrentUser | None = None, + ) -> AppointmentBookingRef: + await self._require_workspace(tenant_id, body.workspace_id) + page = await self.get_appointment_page(tenant_id, body.appointment_page_id) + if page.workspace_id != body.workspace_id: + raise AppError( + "صفحه نوبت متعلق به این فضای کاری نیست", + error_code="appointment_page_workspace_mismatch", + status_code=422, + ) + self.appointment_policy.assert_accepts_bookings(page) + external_ref = validate_external_ref( + body.external_booking_ref, "external_booking_ref" + ) + assert external_ref is not None + if await self.bookings.get_by_external_ref( + tenant_id, body.appointment_page_id, external_ref + ): + raise AppError( + "ارجاع رزرو تکراری است", + error_code="duplicate_booking_ref", + status_code=409, + ) + entity = AppointmentBookingRef( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + appointment_page_id=body.appointment_page_id, + external_booking_ref=external_ref, + status=body.status, + crm_contact_ref=validate_external_ref( + body.crm_contact_ref, "crm_contact_ref" + ), + slot_start=body.slot_start, + slot_end=body.slot_end, + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.bookings.add(entity) + except IntegrityError as exc: + raise AppError( + "ارجاع رزرو تکراری است", + error_code="duplicate_booking_ref", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment_booking_ref", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.APPOINTMENT_BOOKING_REF_CREATED, + aggregate_type="appointment_booking_ref", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "appointment_page_id": str(entity.appointment_page_id), + "external_booking_ref": entity.external_booking_ref, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_booking_ref( + self, tenant_id: UUID, booking_id: UUID + ) -> AppointmentBookingRef: + entity = await self.bookings.get(tenant_id, booking_id) + if entity is None: + raise NotFoundError("ارجاع رزرو یافت نشد") + return entity + + async def list_booking_refs( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: AppointmentBookingListSpec | None = None, + ) -> tuple[list[AppointmentBookingRef], int]: + items, total = await self.bookings.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_booking_ref( + self, + tenant_id: UUID, + booking_id: UUID, + body: AppointmentBookingRefUpdate, + *, + actor: CurrentUser | None = None, + ) -> AppointmentBookingRef: + entity = await self.get_booking_ref(tenant_id, booking_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "crm_contact_ref" in data: + data["crm_contact_ref"] = validate_external_ref( + data["crm_contact_ref"], "crm_contact_ref" + ) + if "metadata_json" in data: + data["metadata_json"] = ensure_json_object( + data["metadata_json"], "metadata_json" + ) + if "status" in data and data["status"] is not None: + self.booking_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="appointment_booking_ref", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.APPOINTMENT_BOOKING_REF_UPDATED, + aggregate_type="appointment_booking_ref", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/experience/app/services/foundation.py b/backend/services/experience/app/services/foundation.py new file mode 100644 index 0000000..beaa462 --- /dev/null +++ b/backend/services/experience/app/services/foundation.py @@ -0,0 +1,779 @@ +"""Experience foundation application services — Phase 11.0.""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.foundation import ( + ExperienceConfiguration, + ExperienceLocaleProfile, + ExperienceWorkspace, + ExperienceSetting, + ExternalProviderConfig, + RenderEngineRegistration, +) +from app.models.types import AuditAction +from app.repositories.foundation import ( + ExperienceConfigurationRepository, + ExperienceLocaleProfileRepository, + ExperienceWorkspaceRepository, + ExperienceSettingRepository, + ExternalProviderConfigRepository, + RenderEngineRegistrationRepository, +) +from app.schemas.foundation import ( + ExperienceConfigurationCreate, + ExperienceConfigurationUpdate, + ExperienceLocaleProfileCreate, + ExperienceLocaleProfileUpdate, + ExperienceWorkspaceCreate, + ExperienceWorkspaceUpdate, + ExperienceSettingUpsert, + ExternalProviderConfigCreate, + ExternalProviderConfigUpdate, + RenderEngineRegistrationCreate, + RenderEngineRegistrationUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ExperienceWorkspaceService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceWorkspaceRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExperienceWorkspaceCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceWorkspace: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + currency = validate_currency_code(body.currency_code) + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد فضای کاری تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExperienceWorkspace( + tenant_id=tenant_id, + code=code, + name=name, + description=body.description, + status=body.status, + legal_name=body.legal_name, + timezone=body.timezone, + language=body.language, + currency_code=currency, + settings=body.settings, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد فضای کاری تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="workspace", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.WORKSPACE_CREATED, + aggregate_type="workspace", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "name": entity.name}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ExperienceWorkspace: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("فضای کاری یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceWorkspace]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ExperienceWorkspaceUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceWorkspace: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="workspace", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.WORKSPACE_UPDATED, + aggregate_type="workspace", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceWorkspace: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="workspace", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExperienceLocaleProfileService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceLocaleProfileRepository(session) + self.orgs = ExperienceWorkspaceRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExperienceLocaleProfileCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLocaleProfile: + org = await self.orgs.get(tenant_id, body.workspace_id) + if org is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + entity = ExperienceLocaleProfile( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + description=body.description, + status=body.status, + language=body.language, + timezone=body.timezone, + text_direction=body.text_direction, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد پروفایل زبان تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="locale_profile", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.LOCALE_PROFILE_CREATED, + aggregate_type="locale_profile", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "workspace_id": str(entity.workspace_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ExperienceLocaleProfile: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پروفایل زبان یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceLocaleProfile]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ExperienceLocaleProfileUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLocaleProfile: + entity = await self.get(tenant_id, entity_id) + data = body.model_dump(exclude_unset=True) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="locale_profile", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.LOCALE_PROFILE_UPDATED, + aggregate_type="locale_profile", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLocaleProfile: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="locale_profile", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExternalProviderConfigService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExternalProviderConfigRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExternalProviderConfigCreate, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + adapter_key = validate_non_empty(body.adapter_key, "adapter_key") + if await self.repo.get_by_code(tenant_id, code): + raise AppError( + "کد ارائه‌دهنده تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExternalProviderConfig( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + provider_kind=body.provider_kind, + adapter_key=adapter_key, + status=body.status, + priority=body.priority, + credentials_ref=body.credentials_ref, + config=body.config, + capabilities=body.capabilities, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider", + entity_id=entity.id, + action=AuditAction.REGISTER, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.EXTERNAL_PROVIDER_REGISTERED, + aggregate_type="external_provider_config", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "adapter_key": entity.adapter_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ExternalProviderConfig: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("ارائه‌دهنده یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExternalProviderConfig]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ExternalProviderConfigUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "adapter_key" in data and data["adapter_key"] is not None: + data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.EXTERNAL_PROVIDER_UPDATED, + aggregate_type="external_provider_config", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExternalProviderConfig: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="external_provider", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class RenderEngineRegistrationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = RenderEngineRegistrationRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: RenderEngineRegistrationCreate, + *, + actor: CurrentUser | None = None, + ) -> RenderEngineRegistration: + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + adapter_key = validate_non_empty(body.adapter_key, "adapter_key") + entity = RenderEngineRegistration( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + adapter_key=adapter_key, + status=body.status, + is_default=body.is_default, + config=body.config, + capabilities=body.capabilities, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد موتور مسیریابی تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="render_engine", + entity_id=entity.id, + action=AuditAction.REGISTER, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.RENDER_ENGINE_REGISTERED, + aggregate_type="render_engine", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "adapter_key": entity.adapter_key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> RenderEngineRegistration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("موتور مسیریابی یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[RenderEngineRegistration]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: RenderEngineRegistrationUpdate, + *, + actor: CurrentUser | None = None, + ) -> RenderEngineRegistration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "adapter_key" in data and data["adapter_key"] is not None: + data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key") + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="render_engine", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.RENDER_ENGINE_UPDATED, + aggregate_type="render_engine", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> RenderEngineRegistration: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="render_engine", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExperienceConfigurationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceConfigurationRepository(session) + self.orgs = ExperienceWorkspaceRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExperienceConfigurationCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceConfiguration: + org = await self.orgs.get(tenant_id, body.workspace_id) + if org is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + currency = validate_currency_code(body.currency_code) + entity = ExperienceConfiguration( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + locale_profile_id=body.locale_profile_id, + code=code, + working_hours=body.working_hours, + publish_policies=body.publish_policies, + seo_policies=body.seo_policies, + analytics_policies=body.analytics_policies, + custom_fields=body.custom_fields, + timezone=body.timezone, + language=body.language, + currency_code=currency, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد پیکربندی تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="configuration", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.CONFIGURATION_CREATED, + aggregate_type="configuration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, entity_id: UUID) -> ExperienceConfiguration: + entity = await self.repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError("پیکربندی یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceConfiguration]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def update( + self, + tenant_id: UUID, + entity_id: UUID, + body: ExperienceConfigurationUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceConfiguration: + entity = await self.get(tenant_id, entity_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "currency_code" in data and data["currency_code"] is not None: + data["currency_code"] = validate_currency_code(data["currency_code"]) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="configuration", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.CONFIGURATION_UPDATED, + aggregate_type="configuration", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + entity_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceConfiguration: + entity = await self.get(tenant_id, entity_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="configuration", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExperienceSettingService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceSettingRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def upsert( + self, + tenant_id: UUID, + body: ExperienceSettingUpsert, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSetting: + key = validate_non_empty(body.key, "key") + existing = await self.repo.get_by_key( + tenant_id, + key, + workspace_id=body.workspace_id, + locale_profile_id=body.locale_profile_id, + ) + if existing is None: + entity = ExperienceSetting( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + locale_profile_id=body.locale_profile_id, + key=key, + value=body.value, + description=body.description, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.repo.add(entity) + else: + entity = existing + entity.value = body.value + entity.description = body.description + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="setting", + entity_id=entity.id, + action=AuditAction.UPDATE if existing else AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SETTING_UPSERTED, + aggregate_type="setting", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"key": entity.key}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceSetting]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) diff --git a/backend/services/experience/app/services/integrations.py b/backend/services/experience/app/services/integrations.py new file mode 100644 index 0000000..3a0d7c7 --- /dev/null +++ b/backend/services/experience/app/services/integrations.py @@ -0,0 +1,604 @@ +"""Consumer connectors, widgets & notify dispatches — Phase 11.9.""" +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.integrations import ( + ExperienceConsumerConnectorDispatch, + ExperienceConsumerConnectorRegistration, + ExperienceNotifyDispatch, + ExperienceWidgetDefinition, + ExperienceWidgetInstance, +) +from app.models.types import ( + AuditAction, + ConnectorDispatchStatus, + ConsumerConnectorKind, + ConsumerConnectorStatus, + NotifyDispatchStatus, + WidgetDefinitionStatus, + WidgetInstanceStatus, +) +from app.policies.integrations import ( + ConsumerConnectorDispatchPolicy, + ConsumerConnectorRegistrationPolicy, + NotifyDispatchPolicy, + WidgetDefinitionPolicy, + WidgetInstancePolicy, +) +from app.providers.mocks import ( + MockCRMProvider, + MockCommunicationProvider, + MockHospitalityProvider, + MockMarketplaceProvider, + MockNotificationProvider, + MockSportsCenterProvider, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.integrations import ( + ConsumerConnectorDispatchRepository, + ConsumerConnectorRegistrationRepository, + NotifyDispatchRepository, + WidgetDefinitionRepository, + WidgetInstanceRepository, +) +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version +from app.validators.integrations import ( + validate_config, + validate_default_config, + validate_metadata, + validate_payload, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + +_HOSPITALITY_KINDS = frozenset( + { + ConsumerConnectorKind.HOSPITALITY, + ConsumerConnectorKind.HOTEL, + ConsumerConnectorKind.CLINIC, + ConsumerConnectorKind.ACADEMY, + ConsumerConnectorKind.TOURISM, + } +) + +_MOCK_HOSPITALITY = MockHospitalityProvider() +_MOCK_MARKETPLACE = MockMarketplaceProvider() +_MOCK_SPORTS = MockSportsCenterProvider() +_MOCK_CRM = MockCRMProvider() +_MOCK_COMMUNICATION = MockCommunicationProvider() +_MOCK_NOTIFICATION = MockNotificationProvider() + + +def _actor(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _jsonable(value): + if isinstance(value, dict): + return {key: _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, (UUID, datetime)): + return value.isoformat() if isinstance(value, datetime) else str(value) + if hasattr(value, "value"): + return value.value + return value + + +async def _invoke_connector_mock( + kind: ConsumerConnectorKind, *, tenant_id: UUID, payload_ref: str | None, event_type: str +) -> dict: + ref = payload_ref or event_type + if kind in _HOSPITALITY_KINDS: + return await _MOCK_HOSPITALITY.resolve_menu_ref(tenant_id=tenant_id, menu_ref=ref) + if kind == ConsumerConnectorKind.MARKETPLACE: + return await _MOCK_MARKETPLACE.resolve_product_ref(tenant_id=tenant_id, product_ref=ref) + if kind == ConsumerConnectorKind.SPORTS_CENTER: + return await _MOCK_SPORTS.resolve_member_ref(tenant_id=tenant_id, member_ref=ref) + if kind == ConsumerConnectorKind.CRM: + return await _MOCK_CRM.resolve_contact(tenant_id=tenant_id, contact_ref=ref) + if kind == ConsumerConnectorKind.COMMUNICATION: + return await _MOCK_COMMUNICATION.send( + tenant_id=tenant_id, + channel="connector", + template_key=event_type, + to=ref, + payload={"event_type": event_type}, + ) + return {"ok": True, "provider": "mock", "kind": kind.value, "ref": ref} + + +class ExperienceIntegrationsService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.registrations = ConsumerConnectorRegistrationRepository(session) + self.dispatches = ConsumerConnectorDispatchRepository(session) + self.widget_definitions = WidgetDefinitionRepository(session) + self.widget_instances = WidgetInstanceRepository(session) + self.notify_dispatches = NotifyDispatchRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.policies = { + "consumer_connector_registration": ConsumerConnectorRegistrationPolicy(), + "consumer_connector_dispatch": ConsumerConnectorDispatchPolicy(), + "widget_definition": WidgetDefinitionPolicy(), + "widget_instance": WidgetInstancePolicy(), + "notify_dispatch": NotifyDispatchPolicy(), + } + + async def _scope(self, tenant_id, workspace_id, site_id=None, page_id=None): + if await self.workspaces.get(tenant_id, workspace_id) is None: + raise NotFoundError("فضای کاری یافت نشد") + if site_id is not None: + site = await self.sites.get(tenant_id, site_id) + if site is None or site.workspace_id != workspace_id: + raise NotFoundError("سایت یافت نشد") + if page_id is not None: + page = await self.pages.get(tenant_id, page_id) + if page is None: + raise NotFoundError("صفحه یافت نشد") + if site_id is not None and page.site_id != site_id: + raise AppError( + "صفحه متعلق به سایت اعلام‌شده نیست", + error_code="page_site_mismatch", + status_code=422, + ) + + async def _finish(self, entity, kind, action, event, actor, changes=None): + await self.audit.record( + tenant_id=entity.tenant_id, + entity_type=kind, + entity_id=entity.id, + action=action, + actor_user_id=_actor(actor), + changes=_jsonable(changes or {}), + ) + self.events.publish( + event_type=event, + aggregate_type=kind, + aggregate_id=entity.id, + tenant_id=entity.tenant_id, + payload={"status": getattr(entity.status, "value", entity.status)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def _get(self, repo, tenant_id, entity_id, label): + entity = await repo.get(tenant_id, entity_id) + if entity is None: + raise NotFoundError(f"{label} یافت نشد") + return entity + + async def _list(self, repo, tenant_id, *, offset, limit, spec): + items, total = await repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec) + return list(items), total + + async def _update(self, repo, tenant_id, entity_id, body, kind, policy, event, actor, validators=None): + entity = await self._get(repo, tenant_id, entity_id, kind) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + validators = validators or {} + for field, validator in validators.items(): + if field in data: + data[field] = validator(data[field]) + if "metadata_json" in data: + data["metadata_json"] = validate_metadata(data["metadata_json"]) + if data.get("status") is not None: + policy.assert_status_change(current=entity.status, target=data["status"]) + for field, value in data.items(): + setattr(entity, field, value) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish(entity, kind, AuditAction.UPDATE, event, actor, data) + + async def _delete(self, repo, tenant_id, entity_id, kind, event, actor): + entity = await self._get(repo, tenant_id, entity_id, kind) + await repo.soft_delete(entity, deleted_by=_actor(actor)) + entity.updated_by = _actor(actor) + entity.version += 1 + return await self._finish(entity, kind, AuditAction.DELETE, event, actor) + + async def create_consumer_connector_registration(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id, body.page_id) + entity = ExperienceConsumerConnectorRegistration( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + kind=body.kind, + code=body.code, + name=body.name, + status=body.status, + external_service_ref=body.external_service_ref, + entity_ref=body.entity_ref, + config_json=validate_config(body.config_json), + metadata_json=validate_metadata(body.metadata_json), + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.registrations.add(entity) + return await self._finish( + entity, + "consumer_connector_registration", + AuditAction.CREATE, + ExperienceEventType.CONSUMER_CONNECTOR_REGISTRATION_CREATED, + actor, + ) + + async def get_consumer_connector_registration(self, tenant_id, entity_id): + return await self._get( + self.registrations, tenant_id, entity_id, "consumer connector registration" + ) + + async def list_consumer_connector_registrations(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.registrations, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_consumer_connector_registration(self, tenant_id, entity_id, body, *, actor=None): + return await self._update( + self.registrations, + tenant_id, + entity_id, + body, + "consumer_connector_registration", + self.policies["consumer_connector_registration"], + ExperienceEventType.CONSUMER_CONNECTOR_REGISTRATION_UPDATED, + actor, + validators={"config_json": validate_config}, + ) + + async def delete_consumer_connector_registration(self, tenant_id, entity_id, *, actor=None): + return await self._delete( + self.registrations, + tenant_id, + entity_id, + "consumer_connector_registration", + ExperienceEventType.CONSUMER_CONNECTOR_REGISTRATION_DELETED, + actor, + ) + + async def create_consumer_connector_dispatch(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id, body.page_id) + registration = await self.get_consumer_connector_registration(tenant_id, body.registration_id) + if registration.workspace_id != body.workspace_id: + raise AppError( + "رابط اتصال متعلق به فضای کاری نیست", + error_code="registration_workspace_mismatch", + status_code=422, + ) + entity = ExperienceConsumerConnectorDispatch( + tenant_id=tenant_id, + registration_id=body.registration_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + direction=body.direction, + event_type=body.event_type, + payload_ref=body.payload_ref, + status=ConnectorDispatchStatus.PENDING, + notes=body.notes, + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.dispatches.add(entity) + return await self._finish( + entity, + "consumer_connector_dispatch", + AuditAction.CREATE, + ExperienceEventType.CONSUMER_CONNECTOR_DISPATCH_CREATED, + actor, + ) + + async def get_consumer_connector_dispatch(self, tenant_id, entity_id): + return await self._get( + self.dispatches, tenant_id, entity_id, "consumer connector dispatch" + ) + + async def list_consumer_connector_dispatches(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.dispatches, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_consumer_connector_dispatch(self, tenant_id, entity_id, body, *, actor=None): + return await self._update( + self.dispatches, + tenant_id, + entity_id, + body, + "consumer_connector_dispatch", + self.policies["consumer_connector_dispatch"], + ExperienceEventType.CONSUMER_CONNECTOR_DISPATCH_UPDATED, + actor, + ) + + async def delete_consumer_connector_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self._delete( + self.dispatches, + tenant_id, + entity_id, + "consumer_connector_dispatch", + ExperienceEventType.CONSUMER_CONNECTOR_DISPATCH_DELETED, + actor, + ) + + async def execute_consumer_connector_dispatch(self, tenant_id, entity_id, *, actor=None): + dispatch = await self.get_consumer_connector_dispatch(tenant_id, entity_id) + if dispatch.status != ConnectorDispatchStatus.PENDING: + raise AppError( + "ارسال قبلاً پردازش شده است", + error_code="dispatch_not_pending", + status_code=409, + ) + registration = await self.get_consumer_connector_registration( + tenant_id, dispatch.registration_id + ) + if registration.status != ConsumerConnectorStatus.ACTIVE: + dispatch.status = ConnectorDispatchStatus.FAILED + dispatch.response_json = {"ok": False, "reason": "registration_not_active"} + else: + try: + response = await _invoke_connector_mock( + registration.kind, + tenant_id=tenant_id, + payload_ref=dispatch.payload_ref, + event_type=dispatch.event_type, + ) + dispatch.status = ConnectorDispatchStatus.ACKNOWLEDGED + dispatch.response_json = response + except Exception as exc: # noqa: BLE001 — mock boundary only + dispatch.status = ConnectorDispatchStatus.FAILED + dispatch.response_json = {"ok": False, "reason": str(exc)} + dispatch.updated_by = _actor(actor) + dispatch.version += 1 + return await self._finish( + dispatch, + "consumer_connector_dispatch", + AuditAction.UPDATE, + ExperienceEventType.CONSUMER_CONNECTOR_DISPATCH_EXECUTED, + actor, + {"status": dispatch.status.value}, + ) + + async def create_widget_definition(self, tenant_id, body, *, actor=None): + if body.workspace_id is not None: + await self._scope(tenant_id, body.workspace_id) + entity = ExperienceWidgetDefinition( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=body.code, + widget_kind=body.widget_kind, + name=body.name, + description=body.description, + status=body.status, + connector_kind=body.connector_kind, + default_config_json=validate_default_config(body.default_config_json), + permission_prefixes=body.permission_prefixes, + metadata_json=validate_metadata(body.metadata_json), + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.widget_definitions.add(entity) + return await self._finish( + entity, + "widget_definition", + AuditAction.CREATE, + ExperienceEventType.WIDGET_DEFINITION_CREATED, + actor, + ) + + async def get_widget_definition(self, tenant_id, entity_id): + return await self._get(self.widget_definitions, tenant_id, entity_id, "widget definition") + + async def list_widget_definitions(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.widget_definitions, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_widget_definition(self, tenant_id, entity_id, body, *, actor=None): + return await self._update( + self.widget_definitions, + tenant_id, + entity_id, + body, + "widget_definition", + self.policies["widget_definition"], + ExperienceEventType.WIDGET_DEFINITION_UPDATED, + actor, + validators={"default_config_json": validate_default_config}, + ) + + async def delete_widget_definition(self, tenant_id, entity_id, *, actor=None): + return await self._delete( + self.widget_definitions, + tenant_id, + entity_id, + "widget_definition", + ExperienceEventType.WIDGET_DEFINITION_DELETED, + actor, + ) + + async def create_widget_instance(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id, body.page_id) + definition = await self.get_widget_definition(tenant_id, body.widget_definition_id) + if definition.status != WidgetDefinitionStatus.AVAILABLE: + raise AppError( + "تعریف ویجت در دسترس نیست", + error_code="widget_definition_unavailable", + status_code=409, + ) + if definition.widget_kind != body.widget_kind: + raise AppError( + "نوع ویجت با تعریف مطابقت ندارد", + error_code="widget_kind_mismatch", + status_code=422, + ) + if body.connector_registration_id is not None: + await self.get_consumer_connector_registration(tenant_id, body.connector_registration_id) + entity = ExperienceWidgetInstance( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + widget_definition_id=definition.id, + widget_kind=body.widget_kind, + code=body.code, + name=body.name, + status=body.status, + connector_registration_id=body.connector_registration_id, + entity_ref=body.entity_ref, + embed_ref=body.embed_ref, + config_json=validate_config(body.config_json), + metadata_json=validate_metadata(body.metadata_json), + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.widget_instances.add(entity) + return await self._finish( + entity, + "widget_instance", + AuditAction.CREATE, + ExperienceEventType.WIDGET_INSTANCE_CREATED, + actor, + ) + + async def get_widget_instance(self, tenant_id, entity_id): + return await self._get(self.widget_instances, tenant_id, entity_id, "widget instance") + + async def get_widget_instance_by_embed(self, tenant_id, embed_ref): + entity = await self.widget_instances.get_by_embed_ref(tenant_id, embed_ref) + if entity is None: + raise NotFoundError("ویجت یافت نشد") + return entity + + async def list_widget_instances(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.widget_instances, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_widget_instance(self, tenant_id, entity_id, body, *, actor=None): + return await self._update( + self.widget_instances, + tenant_id, + entity_id, + body, + "widget_instance", + self.policies["widget_instance"], + ExperienceEventType.WIDGET_INSTANCE_UPDATED, + actor, + validators={"config_json": validate_config}, + ) + + async def delete_widget_instance(self, tenant_id, entity_id, *, actor=None): + return await self._delete( + self.widget_instances, + tenant_id, + entity_id, + "widget_instance", + ExperienceEventType.WIDGET_INSTANCE_DELETED, + actor, + ) + + async def create_notify_dispatch(self, tenant_id, body, *, actor=None): + await self._scope(tenant_id, body.workspace_id, body.site_id, body.page_id) + if body.widget_instance_id is not None: + await self.get_widget_instance(tenant_id, body.widget_instance_id) + if body.connector_registration_id is not None: + await self.get_consumer_connector_registration(tenant_id, body.connector_registration_id) + entity = ExperienceNotifyDispatch( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + widget_instance_id=body.widget_instance_id, + connector_registration_id=body.connector_registration_id, + channel=body.channel, + template_key=body.template_key, + to_ref=body.to_ref, + payload_json=validate_payload(body.payload_json), + status=NotifyDispatchStatus.PENDING, + metadata_json=validate_metadata(body.metadata_json), + created_by=_actor(actor), + updated_by=_actor(actor), + ) + await self.notify_dispatches.add(entity) + return await self._finish( + entity, + "notify_dispatch", + AuditAction.CREATE, + ExperienceEventType.NOTIFY_DISPATCH_CREATED, + actor, + ) + + async def get_notify_dispatch(self, tenant_id, entity_id): + return await self._get(self.notify_dispatches, tenant_id, entity_id, "notify dispatch") + + async def list_notify_dispatches(self, tenant_id, *, offset, limit, spec=None): + return await self._list(self.notify_dispatches, tenant_id, offset=offset, limit=limit, spec=spec) + + async def update_notify_dispatch(self, tenant_id, entity_id, body, *, actor=None): + return await self._update( + self.notify_dispatches, + tenant_id, + entity_id, + body, + "notify_dispatch", + self.policies["notify_dispatch"], + ExperienceEventType.NOTIFY_DISPATCH_UPDATED, + actor, + validators={"payload_json": validate_payload}, + ) + + async def delete_notify_dispatch(self, tenant_id, entity_id, *, actor=None): + return await self._delete( + self.notify_dispatches, + tenant_id, + entity_id, + "notify_dispatch", + ExperienceEventType.NOTIFY_DISPATCH_DELETED, + actor, + ) + + async def send_notify_dispatch(self, tenant_id, entity_id, *, actor=None): + dispatch = await self.get_notify_dispatch(tenant_id, entity_id) + if dispatch.status != NotifyDispatchStatus.PENDING: + raise AppError( + "اعلان قبلاً ارسال شده است", + error_code="notify_not_pending", + status_code=409, + ) + payload = dispatch.payload_json or {} + try: + if dispatch.channel in {"notification", "push"}: + response = await _MOCK_NOTIFICATION.notify( + tenant_id=tenant_id, + channel=dispatch.channel, + template_key=dispatch.template_key, + payload=payload, + ) + else: + response = await _MOCK_COMMUNICATION.send( + tenant_id=tenant_id, + channel=dispatch.channel, + template_key=dispatch.template_key, + to=dispatch.to_ref or "", + payload=payload, + ) + dispatch.status = NotifyDispatchStatus.SENT + dispatch.communication_request_ref = response.get("communication_request_ref") + dispatch.response_json = response + except Exception as exc: # noqa: BLE001 — mock boundary only + dispatch.status = NotifyDispatchStatus.FAILED + dispatch.response_json = {"ok": False, "reason": str(exc)} + dispatch.updated_by = _actor(actor) + dispatch.version += 1 + return await self._finish( + dispatch, + "notify_dispatch", + AuditAction.UPDATE, + ExperienceEventType.NOTIFY_DISPATCH_SENT, + actor, + {"status": dispatch.status.value}, + ) diff --git a/backend/services/experience/app/services/localization.py b/backend/services/experience/app/services/localization.py new file mode 100644 index 0000000..42675a5 --- /dev/null +++ b/backend/services/experience/app/services/localization.py @@ -0,0 +1,696 @@ +"""Localization & media-ref application services — Phase 11.5.""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.localization import ( + ExperienceLocalization, + ExperienceMediaRef, + SiteLocaleBinding, +) +from app.models.types import ( + AuditAction, + LocalizationStatus, + MediaRefStatus, + TextDirection, +) +from app.policies.localization import ( + LocalizationPolicy, + MediaRefPolicy, + SiteLocaleBindingPolicy, +) +from app.repositories.foundation import ( + ExperienceLocaleProfileRepository, + ExperienceWorkspaceRepository, +) +from app.repositories.localization import ( + ExperienceLocalizationRepository, + ExperienceMediaRefRepository, + SiteLocaleBindingRepository, +) +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.schemas.localization import ( + ExperienceLocalizationUpdate, + ExperienceLocalizationUpsert, + ExperienceMediaRefCreate, + ExperienceMediaRefUpdate, + SiteLocaleBindingCreate, + SiteLocaleBindingUpdate, +) +from app.services.audit_service import AuditService +from app.specifications.localization import ( + LocalizationListSpec, + MediaRefListSpec, + SiteLocaleBindingListSpec, +) +from app.validators import ensure_optimistic_version +from app.validators.localization import ( + ensure_content_json, + ensure_no_binary_media_payload, + validate_locale_code, + validate_localization_target, + validate_media_kind, + validate_storage_file_ref, + validate_text_direction, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ExperienceLocalizationService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.bindings = SiteLocaleBindingRepository(session) + self.localizations = ExperienceLocalizationRepository(session) + self.media_refs = ExperienceMediaRefRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.locale_profiles = ExperienceLocaleProfileRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.binding_policy = SiteLocaleBindingPolicy() + self.localization_policy = LocalizationPolicy() + self.media_policy = MediaRefPolicy() + + # ── Site locale bindings ────────────────────────────────────────── + + async def create_site_locale_binding( + self, + tenant_id: UUID, + body: SiteLocaleBindingCreate, + *, + actor: CurrentUser | None = None, + ) -> SiteLocaleBinding: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + site = await self.sites.get(tenant_id, body.site_id) + if site is None or site.workspace_id != body.workspace_id: + raise NotFoundError("سایت یافت نشد") + profile = await self.locale_profiles.get(tenant_id, body.locale_profile_id) + self.binding_policy.assert_profile_usable(profile) + if profile.workspace_id != body.workspace_id: + raise AppError( + "پروفایل locale متعلق به این فضای کاری نیست", + error_code="locale_profile_workspace_mismatch", + status_code=422, + ) + if await self.bindings.get_by_site_profile( + tenant_id, body.site_id, body.locale_profile_id + ): + raise AppError( + "این پروفایل locale قبلاً به سایت متصل شده", + error_code="duplicate_site_locale_binding", + status_code=409, + ) + + direction = validate_text_direction( + body.text_direction + if body.text_direction is not None + else getattr(profile, "text_direction", TextDirection.RTL) + ) + locale_code = validate_locale_code( + getattr(profile, "language", None) or getattr(profile, "code", "fa") + ) + + if body.is_default: + await self.bindings.clear_default_for_site(tenant_id, body.site_id) + + entity = SiteLocaleBinding( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + locale_profile_id=body.locale_profile_id, + locale_code=locale_code, + text_direction=direction, + is_default=body.is_default, + is_active=body.is_active, + sort_order=body.sort_order, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.bindings.add(entity) + except IntegrityError as exc: + raise AppError( + "اتصال locale تکراری است", + error_code="duplicate_site_locale_binding", + status_code=409, + ) from exc + + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_locale_binding", + entity_id=entity.id, + action=AuditAction.ASSIGN, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_LOCALE_ASSIGNED, + aggregate_type="site_locale_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "site_id": str(entity.site_id), + "locale_profile_id": str(entity.locale_profile_id), + "locale_code": entity.locale_code, + "text_direction": entity.text_direction.value, + "is_default": entity.is_default, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_site_locale_binding( + self, tenant_id: UUID, binding_id: UUID + ) -> SiteLocaleBinding: + entity = await self.bindings.get(tenant_id, binding_id) + if entity is None: + raise NotFoundError("اتصال locale سایت یافت نشد") + return entity + + async def list_site_locale_bindings( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SiteLocaleBindingListSpec | None = None, + ) -> tuple[list[SiteLocaleBinding], int]: + items, total = await self.bindings.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_site_locale_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: SiteLocaleBindingUpdate, + *, + actor: CurrentUser | None = None, + ) -> SiteLocaleBinding: + entity = await self.get_site_locale_binding(tenant_id, binding_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "text_direction" in data and data["text_direction"] is not None: + data["text_direction"] = validate_text_direction(data["text_direction"]) + becoming_default = data.get("is_default") is True and not entity.is_default + if becoming_default: + await self.bindings.clear_default_for_site( + tenant_id, entity.site_id, except_id=entity.id + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_locale_binding", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + event = ( + ExperienceEventType.SITE_LOCALE_REPLACED + if becoming_default + else ExperienceEventType.SITE_LOCALE_UPDATED + ) + self.events.publish( + event_type=event, + aggregate_type="site_locale_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "site_id": str(entity.site_id), + "locale_code": entity.locale_code, + "text_direction": entity.text_direction.value, + "is_default": entity.is_default, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_site_locale_binding( + self, + tenant_id: UUID, + binding_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> SiteLocaleBinding: + entity = await self.get_site_locale_binding(tenant_id, binding_id) + await self.bindings.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.is_active = False + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_locale_binding", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.SITE_LOCALE_UNASSIGNED, + aggregate_type="site_locale_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "site_id": str(entity.site_id), + "locale_code": entity.locale_code, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ── Localizations ───────────────────────────────────────────────── + + async def upsert_localization( + self, + tenant_id: UUID, + body: ExperienceLocalizationUpsert, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLocalization: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + target_type = validate_localization_target(body.target_type) + locale = validate_locale_code(body.locale) + direction = validate_text_direction(body.text_direction) + content_json = ensure_content_json(body.content_json) + + existing = await self.localizations.get_by_target_locale( + tenant_id, target_type, body.target_id, locale + ) + if existing is not None: + if body.version is not None: + ensure_optimistic_version(existing, body.version) + if body.status is not None and body.status != existing.status: + self.localization_policy.assert_status_change( + current=existing.status, target=body.status + ) + existing.text_direction = direction + existing.title = body.title + existing.summary = body.summary + existing.content_json = content_json + existing.status = body.status + existing.metadata_json = body.metadata_json + existing.updated_by = _actor_id(actor) + existing.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="localization", + entity_id=existing.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump(exclude={"version"})), + ) + self.events.publish( + event_type=ExperienceEventType.LOCALIZATION_UPSERTED, + aggregate_type="localization", + aggregate_id=existing.id, + tenant_id=tenant_id, + payload={ + "target_type": existing.target_type.value, + "target_id": str(existing.target_id), + "locale": existing.locale, + "text_direction": existing.text_direction.value, + }, + ) + await self.session.commit() + await self.session.refresh(existing) + return existing + + entity = ExperienceLocalization( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + target_type=target_type, + target_id=body.target_id, + locale=locale, + text_direction=direction, + title=body.title, + summary=body.summary, + content_json=content_json, + status=body.status, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.localizations.add(entity) + except IntegrityError as exc: + raise AppError( + "localization تکراری است", + error_code="duplicate_localization", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="localization", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump(exclude={"version"})), + ) + self.events.publish( + event_type=ExperienceEventType.LOCALIZATION_UPSERTED, + aggregate_type="localization", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "target_type": entity.target_type.value, + "target_id": str(entity.target_id), + "locale": entity.locale, + "text_direction": entity.text_direction.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_localization( + self, tenant_id: UUID, localization_id: UUID + ) -> ExperienceLocalization: + entity = await self.localizations.get(tenant_id, localization_id) + if entity is None: + raise NotFoundError("localization یافت نشد") + return entity + + async def list_localizations( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LocalizationListSpec | None = None, + ) -> tuple[list[ExperienceLocalization], int]: + items, total = await self.localizations.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_localization( + self, + tenant_id: UUID, + localization_id: UUID, + body: ExperienceLocalizationUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLocalization: + entity = await self.get_localization(tenant_id, localization_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "text_direction" in data and data["text_direction"] is not None: + data["text_direction"] = validate_text_direction(data["text_direction"]) + if "content_json" in data: + data["content_json"] = ensure_content_json(data["content_json"]) + if "status" in data and data["status"] is not None: + self.localization_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="localization", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.LOCALIZATION_UPDATED, + aggregate_type="localization", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "locale": entity.locale, + "text_direction": entity.text_direction.value, + "status": entity.status.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_localization( + self, + tenant_id: UUID, + localization_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLocalization: + entity = await self.get_localization(tenant_id, localization_id) + await self.localizations.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.status = LocalizationStatus.ARCHIVED + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="localization", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.LOCALIZATION_ARCHIVED, + aggregate_type="localization", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"locale": entity.locale}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ── Media refs ──────────────────────────────────────────────────── + + async def create_media_ref( + self, + tenant_id: UUID, + body: ExperienceMediaRefCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceMediaRef: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + if body.site_id is not None: + site = await self.sites.get(tenant_id, body.site_id) + if site is None or site.workspace_id != body.workspace_id: + raise NotFoundError("سایت یافت نشد") + if body.page_id is not None: + page = await self.pages.get(tenant_id, body.page_id) + if page is None: + raise NotFoundError("صفحه یافت نشد") + if body.site_id is not None and page.site_id != body.site_id: + raise AppError( + "صفحه متعلق به سایت اعلام‌شده نیست", + error_code="page_site_mismatch", + status_code=422, + ) + + storage_file_ref = self.media_policy.assert_storage_ref_only( + storage_file_ref=body.storage_file_ref, + metadata_json=body.metadata_json, + ) + kind = validate_media_kind(body.kind) + direction = validate_text_direction(body.text_direction, required=False) + locale = validate_locale_code(body.locale) if body.locale else None + + entity = ExperienceMediaRef( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + owner_type=body.owner_type, + owner_id=body.owner_id, + kind=kind, + storage_file_ref=storage_file_ref, + alt_text=body.alt_text, + caption=body.caption, + locale=locale, + text_direction=direction, + sort_order=body.sort_order, + status=body.status, + metadata_json=ensure_no_binary_media_payload(body.metadata_json), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.media_refs.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="media_ref", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.MEDIA_REF_CREATED, + aggregate_type="media_ref", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "storage_file_ref": entity.storage_file_ref, + "kind": entity.kind.value, + "locale": entity.locale, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_media_ref( + self, tenant_id: UUID, media_ref_id: UUID + ) -> ExperienceMediaRef: + entity = await self.media_refs.get(tenant_id, media_ref_id) + if entity is None: + raise NotFoundError("media ref یافت نشد") + return entity + + async def list_media_refs( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: MediaRefListSpec | None = None, + ) -> tuple[list[ExperienceMediaRef], int]: + items, total = await self.media_refs.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_media_ref( + self, + tenant_id: UUID, + media_ref_id: UUID, + body: ExperienceMediaRefUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceMediaRef: + entity = await self.get_media_ref(tenant_id, media_ref_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "storage_file_ref" in data and data["storage_file_ref"] is not None: + data["storage_file_ref"] = validate_storage_file_ref( + data["storage_file_ref"] + ) + if "metadata_json" in data: + data["metadata_json"] = ensure_no_binary_media_payload( + data["metadata_json"] + ) + if "kind" in data and data["kind"] is not None: + data["kind"] = validate_media_kind(data["kind"]) + if "locale" in data and data["locale"] is not None: + data["locale"] = validate_locale_code(data["locale"]) + if "text_direction" in data: + data["text_direction"] = validate_text_direction( + data["text_direction"], required=False + ) + if "status" in data and data["status"] is not None: + self.media_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + # Re-validate combined storage/binary constraint after merge. + next_ref = data.get("storage_file_ref", entity.storage_file_ref) + next_meta = data.get("metadata_json", entity.metadata_json) + self.media_policy.assert_storage_ref_only( + storage_file_ref=next_ref, metadata_json=next_meta + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="media_ref", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.MEDIA_REF_UPDATED, + aggregate_type="media_ref", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "storage_file_ref": entity.storage_file_ref, + "kind": entity.kind.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_media_ref( + self, + tenant_id: UUID, + media_ref_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceMediaRef: + entity = await self.get_media_ref(tenant_id, media_ref_id) + await self.media_refs.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.status = MediaRefStatus.ARCHIVED + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="media_ref", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.MEDIA_REF_ARCHIVED, + aggregate_type="media_ref", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"storage_file_ref": entity.storage_file_ref}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/experience/app/services/publishing.py b/backend/services/experience/app/services/publishing.py new file mode 100644 index 0000000..8502872 --- /dev/null +++ b/backend/services/experience/app/services/publishing.py @@ -0,0 +1,879 @@ +"""Publishing, SEO & PWA application services — Phase 11.7.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.publishing import ( + DomainEdgeBinding, + PublishRelease, + PwaManifest, + RobotsShell, + SeoMetadata, + SitemapShell, +) +from app.models.types import ( + AuditAction, + PublishReleaseStatus, + PublishTarget, +) +from app.policies.publishing import ( + DomainEdgeBindingPolicy, + PublishReleasePolicy, + PwaPolicy, + RobotsPolicy, + SeoPolicy, + SitemapPolicy, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.publishing import ( + DomainEdgeBindingRepository, + PublishReleaseRepository, + PwaManifestRepository, + RobotsShellRepository, + SeoMetadataRepository, + SitemapShellRepository, +) +from app.repositories.sites import ( + ExperiencePageRepository, + ExperienceSiteDomainRepository, + ExperienceSiteRepository, +) +from app.schemas.publishing import ( + DomainEdgeBindingCreate, + DomainEdgeBindingUpdate, + PublishReleaseCreate, + PublishReleaseUpdate, + PwaManifestUpdate, + PwaManifestUpsert, + RobotsShellUpdate, + RobotsShellUpsert, + SeoMetadataUpdate, + SeoMetadataUpsert, + SitemapShellUpdate, + SitemapShellUpsert, +) +from app.services.audit_service import AuditService +from app.specifications.publishing import ( + DomainEdgeBindingListSpec, + PublishReleaseListSpec, + PwaManifestListSpec, + RobotsShellListSpec, + SeoMetadataListSpec, + SitemapShellListSpec, +) +from app.validators import ensure_optimistic_version +from app.validators.publishing import ( + ensure_json_object, + validate_external_ref, + validate_publish_target, + validate_seo_target, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ExperiencePublishingService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.releases = PublishReleaseRepository(session) + self.edges = DomainEdgeBindingRepository(session) + self.seo = SeoMetadataRepository(session) + self.sitemaps = SitemapShellRepository(session) + self.robots = RobotsShellRepository(session) + self.pwa = PwaManifestRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.domains = ExperienceSiteDomainRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.publish_policy = PublishReleasePolicy() + self.edge_policy = DomainEdgeBindingPolicy() + self.seo_policy = SeoPolicy() + self.sitemap_policy = SitemapPolicy() + self.robots_policy = RobotsPolicy() + self.pwa_policy = PwaPolicy() + + async def _require_workspace(self, tenant_id: UUID, workspace_id: UUID): + ws = await self.workspaces.get(tenant_id, workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + return ws + + async def _require_site(self, tenant_id: UUID, site_id: UUID, workspace_id: UUID | None = None): + site = await self.sites.get(tenant_id, site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + if workspace_id is not None and site.workspace_id != workspace_id: + raise AppError( + "سایت متعلق به این فضای کاری نیست", + error_code="site_workspace_mismatch", + status_code=422, + ) + return site + + # ── Publish releases ────────────────────────────────────────────── + + async def create_publish_release( + self, + tenant_id: UUID, + body: PublishReleaseCreate, + *, + actor: CurrentUser | None = None, + ) -> PublishRelease: + await self._require_workspace(tenant_id, body.workspace_id) + target_type = validate_publish_target(body.target_type) + if target_type == PublishTarget.SITE: + target = await self._require_site( + tenant_id, body.target_id, workspace_id=body.workspace_id + ) + if target.publish_status.value == "archived": + raise AppError( + "سایت آرشیو شده قابل انتشار نیست", + error_code="invalid_status", + status_code=409, + ) + from app.models.types import PublishStatus + + target.publish_status = PublishStatus.PUBLISHED + target.published_at = datetime.now(timezone.utc) + target.archived_at = None + target.version += 1 + target.updated_by = _actor_id(actor) + self.events.publish( + event_type=ExperienceEventType.SITE_PUBLISHED, + aggregate_type="site", + aggregate_id=target.id, + tenant_id=tenant_id, + payload={"code": target.code}, + ) + else: + from app.models.types import PublishStatus + + page = await self.pages.get(tenant_id, body.target_id) + if page is None or page.workspace_id != body.workspace_id: + raise NotFoundError("صفحه یافت نشد") + if page.publish_status.value == "archived": + raise AppError( + "صفحه آرشیو شده قابل انتشار نیست", + error_code="invalid_status", + status_code=409, + ) + page.publish_status = PublishStatus.PUBLISHED + page.published_at = datetime.now(timezone.utc) + page.archived_at = None + page.version += 1 + page.updated_by = _actor_id(actor) + self.events.publish( + event_type=ExperienceEventType.PAGE_PUBLISHED, + aggregate_type="page", + aggregate_id=page.id, + tenant_id=tenant_id, + payload={"slug": page.slug}, + ) + + status = PublishReleaseStatus.PUBLISHED + completed_at = datetime.now(timezone.utc) + event = ExperienceEventType.PUBLISH_RELEASE_COMPLETED + + entity = PublishRelease( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + target_type=target_type, + target_id=body.target_id, + status=status, + notes=body.notes, + completed_at=completed_at, + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.releases.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="publish_release", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump()), + ) + self.events.publish( + event_type=event, + aggregate_type="publish_release", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "target_type": entity.target_type.value, + "target_id": str(entity.target_id), + "status": entity.status.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_publish_release(self, tenant_id: UUID, release_id: UUID) -> PublishRelease: + entity = await self.releases.get(tenant_id, release_id) + if entity is None: + raise NotFoundError("انتشار یافت نشد") + return entity + + async def list_publish_releases( + self, tenant_id, *, offset, limit, spec: PublishReleaseListSpec | None = None + ): + items, total = await self.releases.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_publish_release( + self, + tenant_id: UUID, + release_id: UUID, + body: PublishReleaseUpdate, + *, + actor: CurrentUser | None = None, + ) -> PublishRelease: + entity = await self.get_publish_release(tenant_id, release_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "metadata_json" in data: + data["metadata_json"] = ensure_json_object( + data["metadata_json"], "metadata_json" + ) + if "status" in data and data["status"] is not None: + self.publish_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + if data["status"] == PublishReleaseStatus.PUBLISHED: + data["completed_at"] = datetime.now(timezone.utc) + if data["status"] == PublishReleaseStatus.ROLLED_BACK: + from app.models.types import PublishStatus + + if entity.target_type == PublishTarget.SITE: + site = await self.sites.get(tenant_id, entity.target_id) + if site is not None: + site.publish_status = PublishStatus.DRAFT + site.version += 1 + site.updated_by = _actor_id(actor) + self.events.publish( + event_type=ExperienceEventType.SITE_UNPUBLISHED, + aggregate_type="site", + aggregate_id=site.id, + tenant_id=tenant_id, + payload={"code": site.code}, + ) + else: + page = await self.pages.get(tenant_id, entity.target_id) + if page is not None: + page.publish_status = PublishStatus.DRAFT + page.version += 1 + page.updated_by = _actor_id(actor) + self.events.publish( + event_type=ExperienceEventType.PAGE_UNPUBLISHED, + aggregate_type="page", + aggregate_id=page.id, + tenant_id=tenant_id, + payload={"slug": page.slug}, + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="publish_release", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + self.events.publish( + event_type=ExperienceEventType.PUBLISH_RELEASE_UPDATED, + aggregate_type="publish_release", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ── Domain edge bindings ────────────────────────────────────────── + + async def create_domain_edge_binding( + self, + tenant_id: UUID, + body: DomainEdgeBindingCreate, + *, + actor: CurrentUser | None = None, + ) -> DomainEdgeBinding: + await self._require_workspace(tenant_id, body.workspace_id) + await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id) + domain = await self.domains.get(tenant_id, body.site_domain_id) + if domain is None or domain.site_id != body.site_id: + raise NotFoundError("دامنه سایت یافت نشد") + if await self.edges.get_by_site_domain(tenant_id, body.site_domain_id): + raise AppError( + "اتصال edge برای این دامنه قبلاً ثبت شده", + error_code="duplicate_domain_edge_binding", + status_code=409, + ) + entity = DomainEdgeBinding( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + site_domain_id=body.site_domain_id, + host=domain.host, + status=body.status, + dns_status_ref=validate_external_ref(body.dns_status_ref, "dns_status_ref"), + ssl_cert_ref=validate_external_ref(body.ssl_cert_ref, "ssl_cert_ref"), + edge_map_ref=validate_external_ref(body.edge_map_ref, "edge_map_ref"), + metadata_json=ensure_json_object(body.metadata_json, "metadata_json"), + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.edges.add(entity) + except IntegrityError as exc: + raise AppError( + "اتصال edge تکراری است", + error_code="duplicate_domain_edge_binding", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="domain_edge_binding", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.DOMAIN_EDGE_BOUND, + aggregate_type="domain_edge_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"host": entity.host, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_domain_edge_binding(self, tenant_id: UUID, binding_id: UUID): + entity = await self.edges.get(tenant_id, binding_id) + if entity is None: + raise NotFoundError("اتصال edge دامنه یافت نشد") + return entity + + async def list_domain_edge_bindings( + self, tenant_id, *, offset, limit, spec: DomainEdgeBindingListSpec | None = None + ): + items, total = await self.edges.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_domain_edge_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: DomainEdgeBindingUpdate, + *, + actor: CurrentUser | None = None, + ): + entity = await self.get_domain_edge_binding(tenant_id, binding_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("dns_status_ref", "ssl_cert_ref", "edge_map_ref"): + if field in data: + data[field] = validate_external_ref(data[field], field) + if "metadata_json" in data: + data["metadata_json"] = ensure_json_object( + data["metadata_json"], "metadata_json" + ) + if "status" in data and data["status"] is not None: + self.edge_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="domain_edge_binding", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + self.events.publish( + event_type=ExperienceEventType.DOMAIN_EDGE_UPDATED, + aggregate_type="domain_edge_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"host": entity.host, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # ── SEO / sitemap / robots / PWA (upsert helpers) ───────────────── + + async def upsert_seo( + self, tenant_id: UUID, body: SeoMetadataUpsert, *, actor: CurrentUser | None = None + ): + await self._require_workspace(tenant_id, body.workspace_id) + target_type = validate_seo_target(body.target_type) + existing = await self.seo.get_by_target(tenant_id, target_type, body.target_id) + payload = { + "status": body.status, + "title": body.title, + "description": body.description, + "canonical_url": body.canonical_url, + "robots_directive": body.robots_directive, + "og_json": ensure_json_object(body.og_json, "og_json"), + "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"), + } + if existing is not None: + if body.version is not None: + ensure_optimistic_version(existing, body.version) + if body.status != existing.status: + self.seo_policy.assert_status_change( + current=existing.status, target=body.status + ) + _apply_update(existing, payload) + existing.updated_by = _actor_id(actor) + existing.version += 1 + entity = existing + action = AuditAction.UPDATE + else: + entity = SeoMetadata( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + target_type=target_type, + target_id=body.target_id, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + **payload, + ) + await self.seo.add(entity) + action = AuditAction.CREATE + await self.audit.record( + tenant_id=tenant_id, + entity_type="seo_metadata", + entity_id=entity.id, + action=action, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump(exclude={"version"})), + ) + self.events.publish( + event_type=ExperienceEventType.SEO_METADATA_UPSERTED, + aggregate_type="seo_metadata", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "target_type": entity.target_type.value, + "target_id": str(entity.target_id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_seo(self, tenant_id: UUID, seo_id: UUID): + entity = await self.seo.get(tenant_id, seo_id) + if entity is None: + raise NotFoundError("SEO metadata یافت نشد") + return entity + + async def list_seo(self, tenant_id, *, offset, limit, spec: SeoMetadataListSpec | None = None): + items, total = await self.seo.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_seo( + self, tenant_id, seo_id, body: SeoMetadataUpdate, *, actor: CurrentUser | None = None + ): + entity = await self.get_seo(tenant_id, seo_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("og_json", "metadata_json"): + if field in data: + data[field] = ensure_json_object(data[field], field) + if "status" in data and data["status"] is not None: + self.seo_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="seo_metadata", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + self.events.publish( + event_type=ExperienceEventType.SEO_METADATA_UPDATED, + aggregate_type="seo_metadata", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def upsert_sitemap( + self, tenant_id, body: SitemapShellUpsert, *, actor: CurrentUser | None = None + ): + await self._require_workspace(tenant_id, body.workspace_id) + await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id) + existing = await self.sitemaps.get_by_site(tenant_id, body.site_id) + payload = { + "status": body.status, + "entries_json": ensure_json_object(body.entries_json, "entries_json"), + "storage_file_ref": validate_external_ref( + body.storage_file_ref, "storage_file_ref" + ), + "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"), + } + if existing is not None: + if body.version is not None: + ensure_optimistic_version(existing, body.version) + if body.status != existing.status: + self.sitemap_policy.assert_status_change( + current=existing.status, target=body.status + ) + _apply_update(existing, payload) + existing.updated_by = _actor_id(actor) + existing.version += 1 + entity = existing + action = AuditAction.UPDATE + else: + entity = SitemapShell( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + **payload, + ) + await self.sitemaps.add(entity) + action = AuditAction.CREATE + await self.audit.record( + tenant_id=tenant_id, + entity_type="sitemap_shell", + entity_id=entity.id, + action=action, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump(exclude={"version"})), + ) + self.events.publish( + event_type=ExperienceEventType.SITEMAP_UPSERTED, + aggregate_type="sitemap_shell", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"site_id": str(entity.site_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_sitemap(self, tenant_id, shell_id): + entity = await self.sitemaps.get(tenant_id, shell_id) + if entity is None: + raise NotFoundError("sitemap یافت نشد") + return entity + + async def list_sitemaps(self, tenant_id, *, offset, limit, spec=None): + items, total = await self.sitemaps.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_sitemap( + self, tenant_id, shell_id, body: SitemapShellUpdate, *, actor=None + ): + entity = await self.get_sitemap(tenant_id, shell_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "entries_json" in data: + data["entries_json"] = ensure_json_object(data["entries_json"], "entries_json") + if "storage_file_ref" in data: + data["storage_file_ref"] = validate_external_ref( + data["storage_file_ref"], "storage_file_ref" + ) + if "metadata_json" in data: + data["metadata_json"] = ensure_json_object( + data["metadata_json"], "metadata_json" + ) + if "status" in data and data["status"] is not None: + self.sitemap_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="sitemap_shell", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + self.events.publish( + event_type=ExperienceEventType.SITEMAP_UPDATED, + aggregate_type="sitemap_shell", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def upsert_robots( + self, tenant_id, body: RobotsShellUpsert, *, actor: CurrentUser | None = None + ): + await self._require_workspace(tenant_id, body.workspace_id) + await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id) + existing = await self.robots.get_by_site(tenant_id, body.site_id) + payload = { + "status": body.status, + "content_text": body.content_text, + "rules_json": ensure_json_object(body.rules_json, "rules_json"), + "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"), + } + if existing is not None: + if body.version is not None: + ensure_optimistic_version(existing, body.version) + if body.status != existing.status: + self.robots_policy.assert_status_change( + current=existing.status, target=body.status + ) + _apply_update(existing, payload) + existing.updated_by = _actor_id(actor) + existing.version += 1 + entity = existing + action = AuditAction.UPDATE + else: + entity = RobotsShell( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + **payload, + ) + await self.robots.add(entity) + action = AuditAction.CREATE + await self.audit.record( + tenant_id=tenant_id, + entity_type="robots_shell", + entity_id=entity.id, + action=action, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump(exclude={"version"})), + ) + self.events.publish( + event_type=ExperienceEventType.ROBOTS_UPSERTED, + aggregate_type="robots_shell", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"site_id": str(entity.site_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_robots(self, tenant_id, shell_id): + entity = await self.robots.get(tenant_id, shell_id) + if entity is None: + raise NotFoundError("robots shell یافت نشد") + return entity + + async def list_robots(self, tenant_id, *, offset, limit, spec=None): + items, total = await self.robots.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_robots( + self, tenant_id, shell_id, body: RobotsShellUpdate, *, actor=None + ): + entity = await self.get_robots(tenant_id, shell_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("rules_json", "metadata_json"): + if field in data: + data[field] = ensure_json_object(data[field], field) + if "status" in data and data["status"] is not None: + self.robots_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="robots_shell", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + self.events.publish( + event_type=ExperienceEventType.ROBOTS_UPDATED, + aggregate_type="robots_shell", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def upsert_pwa( + self, tenant_id, body: PwaManifestUpsert, *, actor: CurrentUser | None = None + ): + await self._require_workspace(tenant_id, body.workspace_id) + await self._require_site(tenant_id, body.site_id, workspace_id=body.workspace_id) + existing = await self.pwa.get_by_site(tenant_id, body.site_id) + payload = { + "status": body.status, + "name": body.name, + "short_name": body.short_name, + "start_url": body.start_url, + "manifest_json": ensure_json_object(body.manifest_json, "manifest_json"), + "service_worker_ref": validate_external_ref( + body.service_worker_ref, "service_worker_ref" + ), + "metadata_json": ensure_json_object(body.metadata_json, "metadata_json"), + } + if existing is not None: + if body.version is not None: + ensure_optimistic_version(existing, body.version) + if body.status != existing.status: + self.pwa_policy.assert_status_change( + current=existing.status, target=body.status + ) + _apply_update(existing, payload) + existing.updated_by = _actor_id(actor) + existing.version += 1 + entity = existing + action = AuditAction.UPDATE + else: + entity = PwaManifest( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + **payload, + ) + await self.pwa.add(entity) + action = AuditAction.CREATE + await self.audit.record( + tenant_id=tenant_id, + entity_type="pwa_manifest", + entity_id=entity.id, + action=action, + actor_user_id=_actor_id(actor), + changes=_jsonable(body.model_dump(exclude={"version"})), + ) + self.events.publish( + event_type=ExperienceEventType.PWA_MANIFEST_UPSERTED, + aggregate_type="pwa_manifest", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"site_id": str(entity.site_id), "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_pwa(self, tenant_id, manifest_id): + entity = await self.pwa.get(tenant_id, manifest_id) + if entity is None: + raise NotFoundError("PWA manifest یافت نشد") + return entity + + async def list_pwa(self, tenant_id, *, offset, limit, spec=None): + items, total = await self.pwa.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_pwa( + self, tenant_id, manifest_id, body: PwaManifestUpdate, *, actor=None + ): + entity = await self.get_pwa(tenant_id, manifest_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + for field in ("manifest_json", "metadata_json"): + if field in data: + data[field] = ensure_json_object(data[field], field) + if "service_worker_ref" in data: + data["service_worker_ref"] = validate_external_ref( + data["service_worker_ref"], "service_worker_ref" + ) + if "status" in data and data["status"] is not None: + self.pwa_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="pwa_manifest", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable(data), + ) + self.events.publish( + event_type=ExperienceEventType.PWA_MANIFEST_UPDATED, + aggregate_type="pwa_manifest", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/experience/app/services/sites.py b/backend/services/experience/app/services/sites.py new file mode 100644 index 0000000..2503a56 --- /dev/null +++ b/backend/services/experience/app/services/sites.py @@ -0,0 +1,745 @@ +"""Sites & page application services — Phase 11.1.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.sites import ExperiencePage, ExperienceSite, ExperienceSiteDomain +from app.models.types import AuditAction, PublishStatus +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ( + ExperiencePageRepository, + ExperienceSiteDomainRepository, + ExperienceSiteRepository, +) +from app.schemas.sites import ( + ExperiencePageCreate, + ExperiencePageUpdate, + ExperienceSiteCreate, + ExperienceSiteDomainCreate, + ExperienceSiteDomainUpdate, + ExperienceSiteUpdate, +) +from app.services.audit_service import AuditService +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _normalize_slug(slug: str) -> str: + slug = validate_non_empty(slug, "slug").strip().lower().replace(" ", "-") + if "/" in slug or "\\" in slug: + raise AppError("slug نامعتبر است", error_code="invalid_slug", status_code=422) + return slug + + +def _normalize_host(host: str) -> str: + host = validate_non_empty(host, "host").strip().lower() + if "://" in host or "/" in host or " " in host: + raise AppError("host نامعتبر است", error_code="invalid_host", status_code=422) + return host + + +class ExperienceSiteService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceSiteRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExperienceSiteCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSite: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + if await self.repo.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد سایت تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExperienceSite( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + description=body.description, + publish_status=body.publish_status, + default_locale=body.default_locale, + default_direction=body.default_direction, + primary_domain=body.primary_domain, + settings=body.settings, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد سایت تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="site", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_CREATED, + aggregate_type="site", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "workspace_id": str(entity.workspace_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, site_id: UUID) -> ExperienceSite: + entity = await self.repo.get(tenant_id, site_id) + if entity is None: + raise NotFoundError("سایت یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceSite]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_by_workspace( + self, tenant_id: UUID, workspace_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceSite]: + return list( + await self.repo.list_by_workspace( + tenant_id, workspace_id, offset=offset, limit=limit + ) + ) + + async def update( + self, + tenant_id: UUID, + site_id: UUID, + body: ExperienceSiteUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSite: + entity = await self.get(tenant_id, site_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "home_page_id" in data and data["home_page_id"] is not None: + page = await self.pages.get(tenant_id, data["home_page_id"]) + if page is None or page.site_id != site_id: + raise AppError( + "صفحه اصلی متعلق به این سایت نیست", + error_code="invalid_home_page", + status_code=422, + ) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_UPDATED, + aggregate_type="site", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def publish( + self, + tenant_id: UUID, + site_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSite: + entity = await self.get(tenant_id, site_id) + if entity.publish_status == PublishStatus.ARCHIVED: + raise AppError( + "سایت آرشیو شده قابل انتشار نیست", + error_code="invalid_status", + status_code=409, + ) + entity.publish_status = PublishStatus.PUBLISHED + entity.published_at = datetime.now(timezone.utc) + entity.archived_at = None + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_PUBLISHED, + aggregate_type="site", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def unpublish( + self, + tenant_id: UUID, + site_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSite: + entity = await self.get(tenant_id, site_id) + entity.publish_status = PublishStatus.DRAFT + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site", + entity_id=entity.id, + action=AuditAction.UNPUBLISH, + actor_user_id=_actor_id(actor), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_UNPUBLISHED, + aggregate_type="site", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def archive( + self, + tenant_id: UUID, + site_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSite: + entity = await self.get(tenant_id, site_id) + entity.publish_status = PublishStatus.ARCHIVED + entity.archived_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site", + entity_id=entity.id, + action=AuditAction.ARCHIVE, + actor_user_id=_actor_id(actor), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_ARCHIVED, + aggregate_type="site", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + site_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSite: + entity = await self.get(tenant_id, site_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExperiencePageService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperiencePageRepository(session) + self.sites = ExperienceSiteRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExperiencePageCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperiencePage: + site = await self.sites.get(tenant_id, body.site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + if site.workspace_id != body.workspace_id: + raise AppError( + "workspace با سایت هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + code = validate_code(body.code) + slug = _normalize_slug(body.slug) + title = validate_non_empty(body.title, "title") + if await self.repo.get_by_code(tenant_id, body.site_id, code): + raise AppError( + "کد صفحه تکراری است", + error_code="duplicate_code", + status_code=409, + ) + if await self.repo.get_by_slug(tenant_id, body.site_id, slug): + raise AppError( + "slug صفحه تکراری است", + error_code="duplicate_slug", + status_code=409, + ) + entity = ExperiencePage( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + code=code, + slug=slug, + title=title, + summary=body.summary, + page_type=body.page_type, + publish_status=body.publish_status, + locale=body.locale, + text_direction=body.text_direction, + sort_order=body.sort_order, + is_home=body.is_home, + content_shell=body.content_shell, + seo_shell=body.seo_shell, + external_refs=body.external_refs, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "صفحه تکراری است", + error_code="duplicate_page", + status_code=409, + ) from exc + if body.is_home: + site.home_page_id = entity.id + site.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_CREATED, + aggregate_type="page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "site_id": str(entity.site_id), + "page_type": entity.page_type.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, page_id: UUID) -> ExperiencePage: + entity = await self.repo.get(tenant_id, page_id) + if entity is None: + raise NotFoundError("صفحه یافت نشد") + return entity + + async def list( + self, tenant_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperiencePage]: + return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit)) + + async def list_by_site( + self, tenant_id: UUID, site_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperiencePage]: + return list( + await self.repo.list_by_site(tenant_id, site_id, offset=offset, limit=limit) + ) + + async def update( + self, + tenant_id: UUID, + page_id: UUID, + body: ExperiencePageUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperiencePage: + entity = await self.get(tenant_id, page_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "slug" in data and data["slug"] is not None: + data["slug"] = _normalize_slug(data["slug"]) + existing = await self.repo.get_by_slug(tenant_id, entity.site_id, data["slug"]) + if existing and existing.id != entity.id: + raise AppError( + "slug صفحه تکراری است", + error_code="duplicate_slug", + status_code=409, + ) + _apply_update(entity, data) + entity.version += 1 + entity.updated_by = _actor_id(actor) + if data.get("is_home") is True: + site = await self.sites.get(tenant_id, entity.site_id) + if site: + site.home_page_id = entity.id + site.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_UPDATED, + aggregate_type="page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def publish( + self, + tenant_id: UUID, + page_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperiencePage: + entity = await self.get(tenant_id, page_id) + if entity.publish_status == PublishStatus.ARCHIVED: + raise AppError( + "صفحه آرشیو شده قابل انتشار نیست", + error_code="invalid_status", + status_code=409, + ) + entity.publish_status = PublishStatus.PUBLISHED + entity.published_at = datetime.now(timezone.utc) + entity.archived_at = None + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_PUBLISHED, + aggregate_type="page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def unpublish( + self, + tenant_id: UUID, + page_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperiencePage: + entity = await self.get(tenant_id, page_id) + entity.publish_status = PublishStatus.DRAFT + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=entity.id, + action=AuditAction.UNPUBLISH, + actor_user_id=_actor_id(actor), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_UNPUBLISHED, + aggregate_type="page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def archive( + self, + tenant_id: UUID, + page_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperiencePage: + entity = await self.get(tenant_id, page_id) + entity.publish_status = PublishStatus.ARCHIVED + entity.archived_at = datetime.now(timezone.utc) + entity.version += 1 + entity.updated_by = _actor_id(actor) + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=entity.id, + action=AuditAction.ARCHIVE, + actor_user_id=_actor_id(actor), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_ARCHIVED, + aggregate_type="page", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + page_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperiencePage: + entity = await self.get(tenant_id, page_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + +class ExperienceSiteDomainService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceSiteDomainRepository(session) + self.sites = ExperienceSiteRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + + async def create( + self, + tenant_id: UUID, + body: ExperienceSiteDomainCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSiteDomain: + site = await self.sites.get(tenant_id, body.site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + if site.workspace_id != body.workspace_id: + raise AppError( + "workspace با سایت هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + host = _normalize_host(body.host) + if await self.repo.get_by_host(tenant_id, host): + raise AppError( + "دامنه تکراری است", + error_code="duplicate_host", + status_code=409, + ) + entity = ExperienceSiteDomain( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + host=host, + domain_kind=body.domain_kind, + is_primary=body.is_primary, + verification_token=uuid4().hex[:32], + path_prefix=body.path_prefix, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "دامنه تکراری است", + error_code="duplicate_host", + status_code=409, + ) from exc + if body.is_primary: + site.primary_domain = host + site.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_domain", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_DOMAIN_BOUND, + aggregate_type="site_domain", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"host": entity.host, "site_id": str(entity.site_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get(self, tenant_id: UUID, domain_id: UUID) -> ExperienceSiteDomain: + entity = await self.repo.get(tenant_id, domain_id) + if entity is None: + raise NotFoundError("دامنه یافت نشد") + return entity + + async def list_by_site( + self, tenant_id: UUID, site_id: UUID, *, offset: int = 0, limit: int = 20 + ) -> list[ExperienceSiteDomain]: + return list( + await self.repo.list_by_site(tenant_id, site_id, offset=offset, limit=limit) + ) + + async def update( + self, + tenant_id: UUID, + domain_id: UUID, + body: ExperienceSiteDomainUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSiteDomain: + entity = await self.get(tenant_id, domain_id) + data = body.model_dump(exclude_unset=True) + if "host" in data and data["host"] is not None: + data["host"] = _normalize_host(data["host"]) + existing = await self.repo.get_by_host(tenant_id, data["host"]) + if existing and existing.id != entity.id: + raise AppError( + "دامنه تکراری است", + error_code="duplicate_host", + status_code=409, + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + if data.get("is_primary") is True: + site = await self.sites.get(tenant_id, entity.site_id) + if site: + site.primary_domain = entity.host + site.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_domain", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_DOMAIN_UPDATED, + aggregate_type="site_domain", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"host": entity.host}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete( + self, + tenant_id: UUID, + domain_id: UUID, + *, + actor: CurrentUser | None = None, + ) -> ExperienceSiteDomain: + entity = await self.get(tenant_id, domain_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_domain", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/experience/app/services/templates.py b/backend/services/experience/app/services/templates.py new file mode 100644 index 0000000..b8883a4 --- /dev/null +++ b/backend/services/experience/app/services/templates.py @@ -0,0 +1,770 @@ +"""Template application services — Phase 11.4.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID, uuid4 + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.sites import ExperiencePage +from app.models.templates import ( + ExperienceTemplate, + ExperienceTemplateVersion, + TemplateInstantiation, +) +from app.models.types import ( + AuditAction, + LayoutStatus, + PageType, + PublishStatus, + TemplateInstantiationStatus, + TemplateStatus, + TemplateVersionStatus, + ThemeStatus, +) +from app.policies.templates import ( + TemplateInstantiationPolicy, + TemplateLifecyclePolicy, + TemplateVersionPolicy, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.repositories.templates import ( + ExperienceTemplateRepository, + ExperienceTemplateVersionRepository, + TemplateInstantiationRepository, +) +from app.repositories.themes import ( + ExperienceLayoutRepository, + ExperienceThemeRepository, +) +from app.schemas.templates import ( + ExperienceTemplateCreate, + ExperienceTemplateUpdate, + ExperienceTemplateVersionCreate, + ExperienceTemplateVersionUpdate, + TemplateInstantiateRequest, +) +from app.services.audit_service import AuditService +from app.specifications.templates import ( + InstantiationListSpec, + TemplateListSpec, + TemplateVersionListSpec, +) +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from app.validators.templates import ( + ensure_blueprint_json, + validate_page_type, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +def _normalize_slug(slug: str) -> str: + slug = validate_non_empty(slug, "slug").strip().lower().replace(" ", "-") + if "/" in slug or "\\" in slug: + raise AppError("slug نامعتبر است", error_code="invalid_slug", status_code=422) + return slug + + +class ExperienceTemplateService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceTemplateRepository(session) + self.versions = ExperienceTemplateVersionRepository(session) + self.instantiations = TemplateInstantiationRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.themes = ExperienceThemeRepository(session) + self.layouts = ExperienceLayoutRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.lifecycle_policy = TemplateLifecyclePolicy() + self.version_policy = TemplateVersionPolicy() + self.instantiation_policy = TemplateInstantiationPolicy() + + async def create_template( + self, + tenant_id: UUID, + body: ExperienceTemplateCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceTemplate: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + page_type = validate_page_type(body.page_type) + if await self.repo.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد قالب تکراری است", error_code="duplicate_code", status_code=409 + ) + entity = ExperienceTemplate( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + description=body.description, + page_type=page_type, + status=body.status, + category=body.category, + tags=body.tags, + is_starter_pack=body.is_starter_pack, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد قالب تکراری است", error_code="duplicate_code", status_code=409 + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="template", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_CREATED, + aggregate_type="template", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "page_type": entity.page_type, + "workspace_id": str(entity.workspace_id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_template( + self, tenant_id: UUID, template_id: UUID + ) -> ExperienceTemplate: + entity = await self.repo.get(tenant_id, template_id) + if entity is None: + raise NotFoundError("قالب یافت نشد") + return entity + + async def list_templates( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: TemplateListSpec | None = None, + ) -> tuple[list[ExperienceTemplate], int]: + items, total = await self.repo.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_template( + self, + tenant_id: UUID, + template_id: UUID, + body: ExperienceTemplateUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceTemplate: + entity = await self.get_template(tenant_id, template_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "page_type" in data and data["page_type"] is not None: + data["page_type"] = validate_page_type(data["page_type"]) + if "status" in data and data["status"] is not None: + self.lifecycle_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_UPDATED, + aggregate_type="template", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def activate_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTemplate: + return await self._set_template_status( + tenant_id, + template_id, + TemplateStatus.ACTIVE, + ExperienceEventType.TEMPLATE_ACTIVATED, + actor=actor, + ) + + async def deprecate_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTemplate: + return await self._set_template_status( + tenant_id, + template_id, + TemplateStatus.DEPRECATED, + ExperienceEventType.TEMPLATE_DEPRECATED, + actor=actor, + ) + + async def archive_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTemplate: + return await self._set_template_status( + tenant_id, + template_id, + TemplateStatus.ARCHIVED, + ExperienceEventType.TEMPLATE_ARCHIVED, + actor=actor, + ) + + async def _set_template_status( + self, + tenant_id: UUID, + template_id: UUID, + target: TemplateStatus, + event_type: ExperienceEventType, + *, + actor: CurrentUser | None, + ) -> ExperienceTemplate: + entity = await self.get_template(tenant_id, template_id) + self.lifecycle_policy.assert_status_change( + current=entity.status, target=target + ) + entity.status = target + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": target.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="template", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": target.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_template( + self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTemplate: + entity = await self.get_template(tenant_id, template_id) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_UPDATED, + aggregate_type="template", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "is_deleted": True}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Versions --- + + async def create_version( + self, + tenant_id: UUID, + body: ExperienceTemplateVersionCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceTemplateVersion: + template = await self.get_template(tenant_id, body.template_id) + if template.workspace_id != body.workspace_id: + raise AppError( + "workspace_id با قالب هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + next_number = ( + await self.versions.max_version_number(tenant_id, body.template_id) + 1 + ) + entity = ExperienceTemplateVersion( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + template_id=body.template_id, + version_number=next_number, + status=TemplateVersionStatus.DRAFT, + blueprint_json=ensure_blueprint_json(body.blueprint_json), + changelog=body.changelog, + metadata_json=body.metadata_json, + is_immutable=False, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.versions.add(entity) + template.latest_version_number = next_number + template.updated_by = _actor_id(actor) + template.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template_version", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes( + {"template_id": body.template_id, "version_number": next_number} + ), + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_VERSION_CREATED, + aggregate_type="template_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "template_id": str(body.template_id), + "version_number": next_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_version( + self, tenant_id: UUID, version_id: UUID + ) -> ExperienceTemplateVersion: + entity = await self.versions.get(tenant_id, version_id) + if entity is None: + raise NotFoundError("نسخه قالب یافت نشد") + return entity + + async def list_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: TemplateVersionListSpec | None = None, + ) -> tuple[list[ExperienceTemplateVersion], int]: + items, total = await self.versions.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceTemplateVersionUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceTemplateVersion: + entity = await self.get_version(tenant_id, version_id) + self.version_policy.assert_can_update(entity) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "blueprint_json" in data: + data["blueprint_json"] = ensure_blueprint_json(data["blueprint_json"]) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template_version", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_VERSION_UPDATED, + aggregate_type="template_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "template_id": str(entity.template_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def publish_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTemplateVersion: + entity = await self.get_version(tenant_id, version_id) + self.version_policy.assert_publish(current=entity.status) + if not entity.blueprint_json: + raise AppError( + "blueprint_json برای انتشار الزامی است", + error_code="blueprint_required", + status_code=422, + ) + template = await self.get_template(tenant_id, entity.template_id) + now = datetime.now(timezone.utc) + entity.status = TemplateVersionStatus.PUBLISHED + entity.is_immutable = True + entity.published_at = now + entity.updated_by = _actor_id(actor) + entity.version += 1 + template.published_version_id = entity.id + if template.status == TemplateStatus.DRAFT: + template.status = TemplateStatus.ACTIVE + template.updated_by = _actor_id(actor) + template.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template_version", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + changes={"status": "published", "is_immutable": True}, + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_VERSION_PUBLISHED, + aggregate_type="template_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "template_id": str(entity.template_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def retire_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTemplateVersion: + entity = await self.get_version(tenant_id, version_id) + self.version_policy.assert_retire(current=entity.status) + template = await self.get_template(tenant_id, entity.template_id) + entity.status = TemplateVersionStatus.RETIRED + entity.retired_at = datetime.now(timezone.utc) + entity.updated_by = _actor_id(actor) + entity.version += 1 + if template.published_version_id == entity.id: + template.published_version_id = None + template.updated_by = _actor_id(actor) + template.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template_version", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": "retired"}, + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_VERSION_RETIRED, + aggregate_type="template_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "template_id": str(entity.template_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Instantiation --- + + async def instantiate( + self, + tenant_id: UUID, + body: TemplateInstantiateRequest, + *, + actor: CurrentUser | None = None, + ) -> tuple[TemplateInstantiation, ExperiencePage | None]: + template = await self.get_template(tenant_id, body.template_id) + self.instantiation_policy.assert_template_instantiable(template) + version_id = body.template_version_id or template.published_version_id + if version_id is None: + raise AppError( + "نسخه منتشرشده‌ای برای قالب وجود ندارد", + error_code="template_no_published_version", + status_code=409, + ) + version = await self.get_version(tenant_id, version_id) + if version.template_id != template.id: + raise AppError( + "template_version_id با template_id هم‌خوان نیست", + error_code="version_template_mismatch", + status_code=422, + ) + self.instantiation_policy.assert_version_instantiable(version) + + site = await self.sites.get(tenant_id, body.site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + if site.workspace_id != body.workspace_id: + raise AppError( + "workspace با سایت هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + + record = TemplateInstantiation( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + template_id=template.id, + template_version_id=version.id, + site_id=body.site_id, + status=TemplateInstantiationStatus.PENDING, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.instantiations.add(record) + + try: + page, result = await self._build_from_blueprint( + tenant_id, + template=template, + version=version, + body=body, + actor=actor, + ) + record.page_id = page.id + record.status = TemplateInstantiationStatus.COMPLETED + record.result_json = result + record.completed_at = datetime.now(timezone.utc) + record.updated_by = _actor_id(actor) + record.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="template_instantiation", + entity_id=record.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes={ + "page_id": str(page.id), + "template_id": str(template.id), + "status": "completed", + }, + ) + self.events.publish( + event_type=ExperienceEventType.TEMPLATE_INSTANTIATED, + aggregate_type="template_instantiation", + aggregate_id=record.id, + tenant_id=tenant_id, + payload={ + "template_id": str(template.id), + "page_id": str(page.id), + "site_id": str(body.site_id), + }, + ) + await self.session.commit() + await self.session.refresh(record) + await self.session.refresh(page) + return record, page + except Exception as exc: + record.status = TemplateInstantiationStatus.FAILED + record.error_message = str(exc)[:1000] + record.completed_at = datetime.now(timezone.utc) + record.updated_by = _actor_id(actor) + record.version += 1 + await self.session.commit() + await self.session.refresh(record) + if isinstance(exc, AppError): + raise + raise AppError( + "نمونه‌سازی قالب ناموفق بود", + error_code="template_instantiation_failed", + status_code=500, + details={"instantiation_id": str(record.id)}, + ) from exc + + async def _build_from_blueprint( + self, + tenant_id: UUID, + *, + template: ExperienceTemplate, + version: ExperienceTemplateVersion, + body: TemplateInstantiateRequest, + actor: CurrentUser | None, + ) -> tuple[ExperiencePage, dict[str, Any]]: + blueprint = version.blueprint_json or {} + page_type_raw = blueprint.get("page_type") or template.page_type + page_type = PageType(validate_page_type(str(page_type_raw))) + code = validate_code(body.page_code) + slug = _normalize_slug(body.page_slug) + title = validate_non_empty(body.page_title, "page_title") + if await self.pages.get_by_code(tenant_id, body.site_id, code): + raise AppError( + "کد صفحه تکراری است", error_code="duplicate_code", status_code=409 + ) + if await self.pages.get_by_slug(tenant_id, body.site_id, slug): + raise AppError( + "slug صفحه تکراری است", error_code="duplicate_slug", status_code=409 + ) + + locale = str(blueprint.get("default_locale") or "fa") + direction = str(blueprint.get("default_direction") or "rtl") + if direction not in ("rtl", "ltr"): + direction = "rtl" + + page = ExperiencePage( + id=uuid4(), + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + code=code, + slug=slug, + title=title, + summary=blueprint.get("summary"), + page_type=page_type, + publish_status=PublishStatus.DRAFT, + locale=locale, + text_direction=direction, + content_shell=blueprint.get("content_shell"), + seo_shell=blueprint.get("seo_shell"), + external_refs=blueprint.get("external_refs"), + metadata_json={ + "from_template_id": str(template.id), + "from_template_version_id": str(version.id), + }, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.pages.add(page) + + result: dict[str, Any] = { + "page_id": str(page.id), + "theme_id": None, + "layout_id": None, + "theme_ready": False, + "layout_ready": False, + "placement_shells": [], + } + + theme_id = blueprint.get("theme_id") + if body.apply_theme and theme_id: + theme = await self.themes.get(tenant_id, UUID(str(theme_id))) + if theme is not None and theme.status == ThemeStatus.ACTIVE: + result["theme_id"] = str(theme.id) + result["theme_ready"] = True + else: + result["theme_ready"] = False + + layout_id = blueprint.get("layout_id") + if body.apply_layout and layout_id: + layout = await self.layouts.get(tenant_id, UUID(str(layout_id))) + if layout is not None and layout.status == LayoutStatus.ACTIVE: + result["layout_id"] = str(layout.id) + result["layout_ready"] = True + else: + result["layout_ready"] = False + + if body.apply_placements: + shells = blueprint.get("placement_shells") or [] + if isinstance(shells, list): + result["placement_shells"] = shells + + await self.audit.record( + tenant_id=tenant_id, + entity_type="page", + entity_id=page.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes={ + "from_template": str(template.id), + "code": page.code, + "page_type": page.page_type.value, + }, + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_CREATED, + aggregate_type="page", + aggregate_id=page.id, + tenant_id=tenant_id, + payload={ + "code": page.code, + "site_id": str(page.site_id), + "from_template_id": str(template.id), + }, + ) + return page, result + + async def get_instantiation( + self, tenant_id: UUID, instantiation_id: UUID + ) -> TemplateInstantiation: + entity = await self.instantiations.get(tenant_id, instantiation_id) + if entity is None: + raise NotFoundError("نمونه‌سازی قالب یافت نشد") + return entity + + async def list_instantiations( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: InstantiationListSpec | None = None, + ) -> tuple[list[TemplateInstantiation], int]: + items, total = await self.instantiations.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total diff --git a/backend/services/experience/app/services/themes.py b/backend/services/experience/app/services/themes.py new file mode 100644 index 0000000..6d4252f --- /dev/null +++ b/backend/services/experience/app/services/themes.py @@ -0,0 +1,1305 @@ +"""Theme & layout engine application services — Phase 11.3.""" +from __future__ import annotations + +from datetime import date, datetime, timezone +from typing import Any +from uuid import UUID + +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.models.themes import ( + ExperienceLayout, + ExperienceLayoutVersion, + ExperienceTheme, + ExperienceThemeVersion, + PageLayoutBinding, + SiteThemeBinding, +) +from app.models.types import ( + AuditAction, + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) +from app.policies.themes import ( + LayoutLifecyclePolicy, + LayoutVersionPolicy, + PageLayoutBindingPolicy, + SiteThemeBindingPolicy, + ThemeLifecyclePolicy, + ThemeVersionPolicy, +) +from app.repositories.foundation import ExperienceWorkspaceRepository +from app.repositories.sites import ExperiencePageRepository, ExperienceSiteRepository +from app.repositories.themes import ( + ExperienceLayoutRepository, + ExperienceLayoutVersionRepository, + ExperienceThemeRepository, + ExperienceThemeVersionRepository, + PageLayoutBindingRepository, + SiteThemeBindingRepository, +) +from app.schemas.themes import ( + ExperienceLayoutCreate, + ExperienceLayoutUpdate, + ExperienceLayoutVersionCreate, + ExperienceLayoutVersionUpdate, + ExperienceThemeCreate, + ExperienceThemeUpdate, + ExperienceThemeVersionCreate, + ExperienceThemeVersionUpdate, + PageLayoutBindingCreate, + PageLayoutBindingUpdate, + SiteThemeBindingCreate, + SiteThemeBindingUpdate, +) +from app.services.audit_service import AuditService +from app.specifications.themes import ( + LayoutListSpec, + LayoutVersionListSpec, + PageLayoutBindingListSpec, + SiteThemeBindingListSpec, + ThemeListSpec, + ThemeVersionListSpec, +) +from app.validators import ensure_optimistic_version, validate_code, validate_non_empty +from app.validators.themes import ( + ensure_regions_payload, + ensure_shell_payload, + ensure_tokens_payload, + validate_direction, + validate_layout_kind, +) +from shared.exceptions import AppError, NotFoundError +from shared.security import CurrentUser + + +def _apply_update(entity, data: dict) -> None: + for key, value in data.items(): + setattr(entity, key, value) + + +def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]: + out: dict[str, Any] = {} + for key, value in data.items(): + if isinstance(value, UUID): + out[key] = str(value) + elif isinstance(value, (datetime, date)): + out[key] = value.isoformat() + elif hasattr(value, "value"): + out[key] = value.value + else: + out[key] = value + return out + + +def _actor_id(actor: CurrentUser | None) -> str | None: + return actor.user_id if actor else None + + +class ExperienceThemeService: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.repo = ExperienceThemeRepository(session) + self.versions = ExperienceThemeVersionRepository(session) + self.layouts = ExperienceLayoutRepository(session) + self.layout_versions = ExperienceLayoutVersionRepository(session) + self.site_bindings = SiteThemeBindingRepository(session) + self.page_bindings = PageLayoutBindingRepository(session) + self.workspaces = ExperienceWorkspaceRepository(session) + self.sites = ExperienceSiteRepository(session) + self.pages = ExperiencePageRepository(session) + self.audit = AuditService(session) + self.events = get_event_publisher() + self.theme_lifecycle_policy = ThemeLifecyclePolicy() + self.theme_version_policy = ThemeVersionPolicy() + self.layout_lifecycle_policy = LayoutLifecyclePolicy() + self.layout_version_policy = LayoutVersionPolicy() + self.site_theme_policy = SiteThemeBindingPolicy() + self.page_layout_policy = PageLayoutBindingPolicy() + + # --- Themes --- + + async def create_theme( + self, + tenant_id: UUID, + body: ExperienceThemeCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceTheme: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + if await self.repo.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد تم تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExperienceTheme( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + description=body.description, + status=body.status, + direction_mode=body.direction_mode, + category=body.category, + tags=body.tags, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.repo.add(entity) + except IntegrityError as exc: + raise AppError( + "کد تم تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.THEME_CREATED, + aggregate_type="theme", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "workspace_id": str(entity.workspace_id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_theme(self, tenant_id: UUID, theme_id: UUID) -> ExperienceTheme: + entity = await self.repo.get(tenant_id, theme_id) + if entity is None: + raise NotFoundError("تم یافت نشد") + return entity + + async def list_themes( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ThemeListSpec | None = None, + ) -> tuple[list[ExperienceTheme], int]: + items, total = await self.repo.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_theme( + self, + tenant_id: UUID, + theme_id: UUID, + body: ExperienceThemeUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceTheme: + entity = await self.get_theme(tenant_id, theme_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "status" in data and data["status"] is not None: + self.theme_lifecycle_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.THEME_UPDATED, + aggregate_type="theme", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def activate_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTheme: + return await self._set_theme_status( + tenant_id, + theme_id, + ThemeStatus.ACTIVE, + ExperienceEventType.THEME_ACTIVATED, + actor=actor, + ) + + async def deprecate_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTheme: + return await self._set_theme_status( + tenant_id, + theme_id, + ThemeStatus.DEPRECATED, + ExperienceEventType.THEME_DEPRECATED, + actor=actor, + ) + + async def archive_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTheme: + return await self._set_theme_status( + tenant_id, + theme_id, + ThemeStatus.ARCHIVED, + ExperienceEventType.THEME_ARCHIVED, + actor=actor, + ) + + async def _set_theme_status( + self, + tenant_id: UUID, + theme_id: UUID, + target: ThemeStatus, + event_type: ExperienceEventType, + *, + actor: CurrentUser | None, + ) -> ExperienceTheme: + entity = await self.get_theme(tenant_id, theme_id) + self.theme_lifecycle_policy.assert_status_change(current=entity.status, target=target) + entity.status = target + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": target.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="theme", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": target.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_theme( + self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceTheme: + entity = await self.get_theme(tenant_id, theme_id) + usage = await self.site_bindings.count_active_by_theme(tenant_id, theme_id) + if usage > 0: + raise AppError( + "تم دارای تخصیص فعال به سایت است", + error_code="theme_in_use", + status_code=409, + details={"binding_count": usage}, + ) + await self.repo.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.THEME_UPDATED, + aggregate_type="theme", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "is_deleted": True}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Theme Versions --- + + async def create_theme_version( + self, + tenant_id: UUID, + body: ExperienceThemeVersionCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceThemeVersion: + theme = await self.get_theme(tenant_id, body.theme_id) + if theme.workspace_id != body.workspace_id: + raise AppError( + "workspace_id با تم هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + next_number = ( + await self.versions.max_version_number(tenant_id, body.theme_id) + 1 + ) + entity = ExperienceThemeVersion( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + theme_id=body.theme_id, + version_number=next_number, + status=ThemeVersionStatus.DRAFT, + tokens=ensure_tokens_payload(body.tokens, "tokens"), + brand_shell=ensure_shell_payload(body.brand_shell, "brand_shell"), + direction_mode=validate_direction(body.direction_mode), + changelog=body.changelog, + metadata_json=body.metadata_json, + is_immutable=False, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.versions.add(entity) + theme.latest_version_number = next_number + theme.updated_by = _actor_id(actor) + theme.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme_version", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes( + {"theme_id": body.theme_id, "version_number": next_number} + ), + ) + self.events.publish( + event_type=ExperienceEventType.THEME_VERSION_CREATED, + aggregate_type="theme_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "theme_id": str(body.theme_id), + "version_number": next_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_theme_version( + self, tenant_id: UUID, version_id: UUID + ) -> ExperienceThemeVersion: + entity = await self.versions.get(tenant_id, version_id) + if entity is None: + raise NotFoundError("نسخه تم یافت نشد") + return entity + + async def list_theme_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: ThemeVersionListSpec | None = None, + ) -> tuple[list[ExperienceThemeVersion], int]: + items, total = await self.versions.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_theme_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceThemeVersionUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceThemeVersion: + entity = await self.get_theme_version(tenant_id, version_id) + self.theme_version_policy.assert_can_update(entity) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "tokens" in data: + data["tokens"] = ensure_tokens_payload(data["tokens"], "tokens") + if "brand_shell" in data: + data["brand_shell"] = ensure_shell_payload(data["brand_shell"], "brand_shell") + if "direction_mode" in data: + data["direction_mode"] = validate_direction(data["direction_mode"]) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme_version", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.THEME_VERSION_UPDATED, + aggregate_type="theme_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "theme_id": str(entity.theme_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def publish_theme_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceThemeVersion: + entity = await self.get_theme_version(tenant_id, version_id) + self.theme_version_policy.assert_publish(current=entity.status) + theme = await self.get_theme(tenant_id, entity.theme_id) + now = datetime.now(timezone.utc) + entity.status = ThemeVersionStatus.PUBLISHED + entity.is_immutable = True + entity.published_at = now + entity.updated_by = _actor_id(actor) + entity.version += 1 + theme.published_version_id = entity.id + if theme.status == ThemeStatus.DRAFT: + theme.status = ThemeStatus.ACTIVE + theme.updated_by = _actor_id(actor) + theme.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme_version", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + changes={"status": "published", "is_immutable": True}, + ) + self.events.publish( + event_type=ExperienceEventType.THEME_VERSION_PUBLISHED, + aggregate_type="theme_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "theme_id": str(entity.theme_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def retire_theme_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceThemeVersion: + entity = await self.get_theme_version(tenant_id, version_id) + self.theme_version_policy.assert_retire(current=entity.status) + usage = await self.site_bindings.count_active_by_version(tenant_id, version_id) + if usage > 0: + raise AppError( + "نسخه دارای تخصیص فعال است", + error_code="theme_version_in_use", + status_code=409, + details={"binding_count": usage}, + ) + theme = await self.get_theme(tenant_id, entity.theme_id) + entity.status = ThemeVersionStatus.RETIRED + entity.retired_at = datetime.now(timezone.utc) + entity.updated_by = _actor_id(actor) + entity.version += 1 + if theme.published_version_id == entity.id: + theme.published_version_id = None + theme.updated_by = _actor_id(actor) + theme.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="theme_version", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": "retired"}, + ) + self.events.publish( + event_type=ExperienceEventType.THEME_VERSION_RETIRED, + aggregate_type="theme_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "theme_id": str(entity.theme_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Layouts --- + + async def create_layout( + self, + tenant_id: UUID, + body: ExperienceLayoutCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLayout: + ws = await self.workspaces.get(tenant_id, body.workspace_id) + if ws is None: + raise NotFoundError("فضای کاری یافت نشد") + code = validate_code(body.code) + name = validate_non_empty(body.name, "name") + kind = validate_layout_kind(body.kind) + if await self.layouts.get_by_code(tenant_id, body.workspace_id, code): + raise AppError( + "کد چیدمان تکراری است", + error_code="duplicate_code", + status_code=409, + ) + entity = ExperienceLayout( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + code=code, + name=name, + description=body.description, + kind=kind, + status=body.status, + direction_mode=body.direction_mode, + category=body.category, + tags=body.tags, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + try: + await self.layouts.add(entity) + except IntegrityError as exc: + raise AppError( + "کد چیدمان تکراری است", + error_code="duplicate_code", + status_code=409, + ) from exc + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_CREATED, + aggregate_type="layout", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "code": entity.code, + "workspace_id": str(entity.workspace_id), + "kind": entity.kind.value, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_layout(self, tenant_id: UUID, layout_id: UUID) -> ExperienceLayout: + entity = await self.layouts.get(tenant_id, layout_id) + if entity is None: + raise NotFoundError("چیدمان یافت نشد") + return entity + + async def list_layouts( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LayoutListSpec | None = None, + ) -> tuple[list[ExperienceLayout], int]: + items, total = await self.layouts.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_layout( + self, + tenant_id: UUID, + layout_id: UUID, + body: ExperienceLayoutUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLayout: + entity = await self.get_layout(tenant_id, layout_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "name" in data and data["name"] is not None: + data["name"] = validate_non_empty(data["name"], "name") + if "kind" in data and data["kind"] is not None: + data["kind"] = validate_layout_kind(data["kind"]) + if "status" in data and data["status"] is not None: + self.layout_lifecycle_policy.assert_status_change( + current=entity.status, target=data["status"] + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_UPDATED, + aggregate_type="layout", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": entity.status.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def activate_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceLayout: + return await self._set_layout_status( + tenant_id, + layout_id, + LayoutStatus.ACTIVE, + ExperienceEventType.LAYOUT_ACTIVATED, + actor=actor, + ) + + async def deprecate_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceLayout: + return await self._set_layout_status( + tenant_id, + layout_id, + LayoutStatus.DEPRECATED, + ExperienceEventType.LAYOUT_DEPRECATED, + actor=actor, + ) + + async def archive_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceLayout: + return await self._set_layout_status( + tenant_id, + layout_id, + LayoutStatus.ARCHIVED, + ExperienceEventType.LAYOUT_ARCHIVED, + actor=actor, + ) + + async def _set_layout_status( + self, + tenant_id: UUID, + layout_id: UUID, + target: LayoutStatus, + event_type: ExperienceEventType, + *, + actor: CurrentUser | None, + ) -> ExperienceLayout: + entity = await self.get_layout(tenant_id, layout_id) + self.layout_lifecycle_policy.assert_status_change( + current=entity.status, target=target + ) + entity.status = target + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": target.value}, + ) + self.events.publish( + event_type=event_type, + aggregate_type="layout", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "status": target.value}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_layout( + self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceLayout: + entity = await self.get_layout(tenant_id, layout_id) + usage = await self.page_bindings.count_active_by_layout(tenant_id, layout_id) + if usage > 0: + raise AppError( + "چیدمان دارای تخصیص فعال به صفحه است", + error_code="layout_in_use", + status_code=409, + details={"binding_count": usage}, + ) + await self.layouts.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_UPDATED, + aggregate_type="layout", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"code": entity.code, "is_deleted": True}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Layout Versions --- + + async def create_layout_version( + self, + tenant_id: UUID, + body: ExperienceLayoutVersionCreate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLayoutVersion: + layout = await self.get_layout(tenant_id, body.layout_id) + if layout.workspace_id != body.workspace_id: + raise AppError( + "workspace_id با چیدمان هم‌خوان نیست", + error_code="workspace_mismatch", + status_code=422, + ) + next_number = ( + await self.layout_versions.max_version_number(tenant_id, body.layout_id) + 1 + ) + entity = ExperienceLayoutVersion( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + layout_id=body.layout_id, + version_number=next_number, + status=LayoutVersionStatus.DRAFT, + regions=ensure_regions_payload(body.regions, "regions"), + structure_shell=ensure_shell_payload(body.structure_shell, "structure_shell"), + changelog=body.changelog, + metadata_json=body.metadata_json, + is_immutable=False, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.layout_versions.add(entity) + layout.latest_version_number = next_number + layout.updated_by = _actor_id(actor) + layout.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout_version", + entity_id=entity.id, + action=AuditAction.CREATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes( + {"layout_id": body.layout_id, "version_number": next_number} + ), + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_VERSION_CREATED, + aggregate_type="layout_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "layout_id": str(body.layout_id), + "version_number": next_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_layout_version( + self, tenant_id: UUID, version_id: UUID + ) -> ExperienceLayoutVersion: + entity = await self.layout_versions.get(tenant_id, version_id) + if entity is None: + raise NotFoundError("نسخه چیدمان یافت نشد") + return entity + + async def list_layout_versions( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: LayoutVersionListSpec | None = None, + ) -> tuple[list[ExperienceLayoutVersion], int]: + items, total = await self.layout_versions.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_layout_version( + self, + tenant_id: UUID, + version_id: UUID, + body: ExperienceLayoutVersionUpdate, + *, + actor: CurrentUser | None = None, + ) -> ExperienceLayoutVersion: + entity = await self.get_layout_version(tenant_id, version_id) + self.layout_version_policy.assert_can_update(entity) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + if "regions" in data: + data["regions"] = ensure_regions_payload(data["regions"], "regions") + if "structure_shell" in data: + data["structure_shell"] = ensure_shell_payload( + data["structure_shell"], "structure_shell" + ) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout_version", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_VERSION_UPDATED, + aggregate_type="layout_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "layout_id": str(entity.layout_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def publish_layout_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceLayoutVersion: + entity = await self.get_layout_version(tenant_id, version_id) + self.layout_version_policy.assert_publish(current=entity.status) + layout = await self.get_layout(tenant_id, entity.layout_id) + now = datetime.now(timezone.utc) + entity.status = LayoutVersionStatus.PUBLISHED + entity.is_immutable = True + entity.published_at = now + entity.updated_by = _actor_id(actor) + entity.version += 1 + layout.published_version_id = entity.id + if layout.status == LayoutStatus.DRAFT: + layout.status = LayoutStatus.ACTIVE + layout.updated_by = _actor_id(actor) + layout.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout_version", + entity_id=entity.id, + action=AuditAction.PUBLISH, + actor_user_id=_actor_id(actor), + changes={"status": "published", "is_immutable": True}, + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_VERSION_PUBLISHED, + aggregate_type="layout_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "layout_id": str(entity.layout_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def retire_layout_version( + self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None = None + ) -> ExperienceLayoutVersion: + entity = await self.get_layout_version(tenant_id, version_id) + self.layout_version_policy.assert_retire(current=entity.status) + usage = await self.page_bindings.count_active_by_version(tenant_id, version_id) + if usage > 0: + raise AppError( + "نسخه دارای تخصیص فعال است", + error_code="layout_version_in_use", + status_code=409, + details={"binding_count": usage}, + ) + layout = await self.get_layout(tenant_id, entity.layout_id) + entity.status = LayoutVersionStatus.RETIRED + entity.retired_at = datetime.now(timezone.utc) + entity.updated_by = _actor_id(actor) + entity.version += 1 + if layout.published_version_id == entity.id: + layout.published_version_id = None + layout.updated_by = _actor_id(actor) + layout.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="layout_version", + entity_id=entity.id, + action=AuditAction.STATUS_CHANGE, + actor_user_id=_actor_id(actor), + changes={"status": "retired"}, + ) + self.events.publish( + event_type=ExperienceEventType.LAYOUT_VERSION_RETIRED, + aggregate_type="layout_version", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "layout_id": str(entity.layout_id), + "version_number": entity.version_number, + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Site Theme Bindings --- + + async def assign_site_theme( + self, + tenant_id: UUID, + body: SiteThemeBindingCreate, + *, + actor: CurrentUser | None = None, + ) -> SiteThemeBinding: + site = await self.sites.get(tenant_id, body.site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + theme = await self.get_theme(tenant_id, body.theme_id) + version = await self.get_theme_version(tenant_id, body.theme_version_id) + if version.theme_id != body.theme_id: + raise AppError( + "theme_version_id با theme_id هم‌خوان نیست", + error_code="version_theme_mismatch", + status_code=422, + ) + self.site_theme_policy.assert_theme_assignable(theme) + self.site_theme_policy.assert_version_assignable(version) + + previous = await self.site_bindings.get_active_for_site(tenant_id, body.site_id) + replaced_previous = False + if previous is not None: + await self.site_bindings.soft_delete(previous, deleted_by=_actor_id(actor)) + previous.is_active = False + previous.updated_by = _actor_id(actor) + previous.version += 1 + replaced_previous = True + + entity = SiteThemeBinding( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + theme_id=body.theme_id, + theme_version_id=body.theme_version_id, + is_active=True, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.site_bindings.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_theme_binding", + entity_id=entity.id, + action=AuditAction.REPLACE if replaced_previous else AuditAction.ASSIGN, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.SITE_THEME_ASSIGNED, + aggregate_type="site_theme_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "site_id": str(entity.site_id), + "theme_id": str(entity.theme_id), + "theme_version_id": str(entity.theme_version_id), + }, + ) + if replaced_previous: + self.events.publish( + event_type=ExperienceEventType.SITE_THEME_REPLACED, + aggregate_type="site_theme_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "site_id": str(entity.site_id), + "previous_binding_id": str(previous.id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_site_theme_binding( + self, tenant_id: UUID, binding_id: UUID + ) -> SiteThemeBinding: + entity = await self.site_bindings.get(tenant_id, binding_id) + if entity is None: + raise NotFoundError("تخصیص تم سایت یافت نشد") + return entity + + async def list_site_theme_bindings( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: SiteThemeBindingListSpec | None = None, + ) -> tuple[list[SiteThemeBinding], int]: + items, total = await self.site_bindings.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_site_theme_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: SiteThemeBindingUpdate, + *, + actor: CurrentUser | None = None, + ) -> SiteThemeBinding: + entity = await self.get_site_theme_binding(tenant_id, binding_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_theme_binding", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_site_theme_binding( + self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None = None + ) -> SiteThemeBinding: + entity = await self.get_site_theme_binding(tenant_id, binding_id) + entity.is_active = False + await self.site_bindings.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="site_theme_binding", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.SITE_THEME_UNASSIGNED, + aggregate_type="site_theme_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"site_id": str(entity.site_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + # --- Page Layout Bindings --- + + async def assign_page_layout( + self, + tenant_id: UUID, + body: PageLayoutBindingCreate, + *, + actor: CurrentUser | None = None, + ) -> PageLayoutBinding: + site = await self.sites.get(tenant_id, body.site_id) + if site is None: + raise NotFoundError("سایت یافت نشد") + page = await self.pages.get(tenant_id, body.page_id) + if page is None: + raise NotFoundError("صفحه یافت نشد") + if page.site_id != body.site_id: + raise AppError( + "page_id با site_id هم‌خوان نیست", + error_code="page_site_mismatch", + status_code=422, + ) + layout = await self.get_layout(tenant_id, body.layout_id) + version = await self.get_layout_version(tenant_id, body.layout_version_id) + if version.layout_id != body.layout_id: + raise AppError( + "layout_version_id با layout_id هم‌خوان نیست", + error_code="version_layout_mismatch", + status_code=422, + ) + self.page_layout_policy.assert_layout_assignable(layout) + self.page_layout_policy.assert_version_assignable(version) + + previous = await self.page_bindings.get_active_for_page(tenant_id, body.page_id) + replaced_previous = False + if previous is not None: + await self.page_bindings.soft_delete(previous, deleted_by=_actor_id(actor)) + previous.is_active = False + previous.updated_by = _actor_id(actor) + previous.version += 1 + replaced_previous = True + + entity = PageLayoutBinding( + tenant_id=tenant_id, + workspace_id=body.workspace_id, + site_id=body.site_id, + page_id=body.page_id, + layout_id=body.layout_id, + layout_version_id=body.layout_version_id, + is_active=True, + metadata_json=body.metadata_json, + created_by=_actor_id(actor), + updated_by=_actor_id(actor), + ) + await self.page_bindings.add(entity) + await self.audit.record( + tenant_id=tenant_id, + entity_type="page_layout_binding", + entity_id=entity.id, + action=AuditAction.REPLACE if replaced_previous else AuditAction.ASSIGN, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(body.model_dump()), + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_LAYOUT_ASSIGNED, + aggregate_type="page_layout_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "page_id": str(entity.page_id), + "layout_id": str(entity.layout_id), + "layout_version_id": str(entity.layout_version_id), + }, + ) + if replaced_previous: + self.events.publish( + event_type=ExperienceEventType.PAGE_LAYOUT_REPLACED, + aggregate_type="page_layout_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={ + "page_id": str(entity.page_id), + "previous_binding_id": str(previous.id), + }, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def get_page_layout_binding( + self, tenant_id: UUID, binding_id: UUID + ) -> PageLayoutBinding: + entity = await self.page_bindings.get(tenant_id, binding_id) + if entity is None: + raise NotFoundError("تخصیص چیدمان صفحه یافت نشد") + return entity + + async def list_page_layout_bindings( + self, + tenant_id: UUID, + *, + offset: int, + limit: int, + spec: PageLayoutBindingListSpec | None = None, + ) -> tuple[list[PageLayoutBinding], int]: + items, total = await self.page_bindings.list_filtered( + tenant_id, offset=offset, limit=limit, spec=spec + ) + return list(items), total + + async def update_page_layout_binding( + self, + tenant_id: UUID, + binding_id: UUID, + body: PageLayoutBindingUpdate, + *, + actor: CurrentUser | None = None, + ) -> PageLayoutBinding: + entity = await self.get_page_layout_binding(tenant_id, binding_id) + ensure_optimistic_version(entity, body.version) + data = body.model_dump(exclude_unset=True, exclude={"version"}) + _apply_update(entity, data) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="page_layout_binding", + entity_id=entity.id, + action=AuditAction.UPDATE, + actor_user_id=_actor_id(actor), + changes=_jsonable_changes(data), + ) + await self.session.commit() + await self.session.refresh(entity) + return entity + + async def soft_delete_page_layout_binding( + self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None = None + ) -> PageLayoutBinding: + entity = await self.get_page_layout_binding(tenant_id, binding_id) + entity.is_active = False + await self.page_bindings.soft_delete(entity, deleted_by=_actor_id(actor)) + entity.updated_by = _actor_id(actor) + entity.version += 1 + await self.audit.record( + tenant_id=tenant_id, + entity_type="page_layout_binding", + entity_id=entity.id, + action=AuditAction.DELETE, + actor_user_id=_actor_id(actor), + changes={"is_deleted": True}, + ) + self.events.publish( + event_type=ExperienceEventType.PAGE_LAYOUT_UNASSIGNED, + aggregate_type="page_layout_binding", + aggregate_id=entity.id, + tenant_id=tenant_id, + payload={"page_id": str(entity.page_id)}, + ) + await self.session.commit() + await self.session.refresh(entity) + return entity diff --git a/backend/services/experience/app/specifications/__init__.py b/backend/services/experience/app/specifications/__init__.py new file mode 100644 index 0000000..72307d2 --- /dev/null +++ b/backend/services/experience/app/specifications/__init__.py @@ -0,0 +1,72 @@ +"""Experience specifications package.""" +from app.specifications.components import ( + COMPONENT_SORT_FIELDS, + ComponentListSpec, + ComponentVersionListSpec, + PLACEMENT_SORT_FIELDS, + PlacementListSpec, + VERSION_SORT_FIELDS, +) +from app.specifications.themes import ( + LAYOUT_SORT_FIELDS, + LAYOUT_VERSION_SORT_FIELDS, + LayoutListSpec, + LayoutVersionListSpec, + PAGE_LAYOUT_BINDING_SORT_FIELDS, + PageLayoutBindingListSpec, + SITE_THEME_BINDING_SORT_FIELDS, + SiteThemeBindingListSpec, + THEME_SORT_FIELDS, + THEME_VERSION_SORT_FIELDS, + ThemeListSpec, + ThemeVersionListSpec, +) +from app.specifications.templates import ( + INSTANTIATION_SORT_FIELDS, + InstantiationListSpec, + TEMPLATE_SORT_FIELDS, + TEMPLATE_VERSION_SORT_FIELDS, + TemplateListSpec, + TemplateVersionListSpec, +) +from app.specifications.localization import ( + LOCALIZATION_SORT_FIELDS, + LocalizationListSpec, + MEDIA_REF_SORT_FIELDS, + MediaRefListSpec, + SITE_LOCALE_BINDING_SORT_FIELDS, + SiteLocaleBindingListSpec, +) + +__all__ = [ + "COMPONENT_SORT_FIELDS", + "VERSION_SORT_FIELDS", + "PLACEMENT_SORT_FIELDS", + "ComponentListSpec", + "ComponentVersionListSpec", + "PlacementListSpec", + "THEME_SORT_FIELDS", + "THEME_VERSION_SORT_FIELDS", + "LAYOUT_SORT_FIELDS", + "LAYOUT_VERSION_SORT_FIELDS", + "SITE_THEME_BINDING_SORT_FIELDS", + "PAGE_LAYOUT_BINDING_SORT_FIELDS", + "ThemeListSpec", + "ThemeVersionListSpec", + "LayoutListSpec", + "LayoutVersionListSpec", + "SiteThemeBindingListSpec", + "PageLayoutBindingListSpec", + "TEMPLATE_SORT_FIELDS", + "TEMPLATE_VERSION_SORT_FIELDS", + "INSTANTIATION_SORT_FIELDS", + "TemplateListSpec", + "TemplateVersionListSpec", + "InstantiationListSpec", + "SITE_LOCALE_BINDING_SORT_FIELDS", + "LOCALIZATION_SORT_FIELDS", + "MEDIA_REF_SORT_FIELDS", + "SiteLocaleBindingListSpec", + "LocalizationListSpec", + "MediaRefListSpec", +] diff --git a/backend/services/experience/app/specifications/ai_hooks.py b/backend/services/experience/app/specifications/ai_hooks.py new file mode 100644 index 0000000..588f923 --- /dev/null +++ b/backend/services/experience/app/specifications/ai_hooks.py @@ -0,0 +1,76 @@ +"""Filtering, search and sorting specifications — Phase 11.10 AI hooks.""" +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import asc, desc, or_ + +from app.models.ai_hooks import ( + ExperienceAiContentDispatch, + ExperienceAiContentHookRegistration, +) + +HOOK_REGISTRATION_SORT = frozenset( + {"created_at", "updated_at", "code", "name", "status", "task_kind"} +) +DISPATCH_SORT = frozenset({"created_at", "updated_at", "status", "task"}) + + +@dataclass(frozen=True, slots=True) +class AiHooksListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + registration_id: UUID | None = None + status: str | None = None + task_kind: str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + model = None + sort_fields = frozenset() + + def filter_clauses(self): + m = self.model + clauses = [] + for field in ( + "workspace_id", + "site_id", + "page_id", + "registration_id", + "status", + "task_kind", + ): + value = getattr(self, field) + if value is not None and hasattr(m, field): + clauses.append(getattr(m, field) == value) + if self.q: + term = f"%{self.q.strip()}%" + searchable = [ + getattr(m, name).ilike(term) + for name in ("code", "name", "task", "context_ref") + if hasattr(m, name) + ] + if searchable: + clauses.append(or_(*searchable)) + return clauses + + def apply(self, stmt): + for clause in self.filter_clauses(): + stmt = stmt.where(clause) + direction = desc if self.sort_dir.lower() == "desc" else asc + sort_col = getattr(self.model, self.sort_by, self.model.created_at) + if self.sort_by not in self.sort_fields: + sort_col = self.model.created_at + return stmt.order_by(direction(sort_col)) + + +@dataclass(frozen=True, slots=True) +class AiContentHookRegistrationListSpec(AiHooksListSpec): + model = ExperienceAiContentHookRegistration + sort_fields = HOOK_REGISTRATION_SORT + + +@dataclass(frozen=True, slots=True) +class AiContentDispatchListSpec(AiHooksListSpec): + model = ExperienceAiContentDispatch + sort_fields = DISPATCH_SORT diff --git a/backend/services/experience/app/specifications/analytics.py b/backend/services/experience/app/specifications/analytics.py new file mode 100644 index 0000000..7c45b04 --- /dev/null +++ b/backend/services/experience/app/specifications/analytics.py @@ -0,0 +1,69 @@ +"""Filtering, search and sorting specifications — Phase 11.10 analytics.""" +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import asc, desc, or_ + +from app.models.analytics import ( + ExperienceAnalyticsReportDefinition, + ExperienceAnalyticsSnapshot, +) + +REPORT_DEFINITION_SORT = frozenset( + {"created_at", "updated_at", "code", "name", "status"} +) +SNAPSHOT_SORT = frozenset( + {"created_at", "updated_at", "status", "period_start", "period_end"} +) + + +@dataclass(frozen=True, slots=True) +class AnalyticsListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + report_id: UUID | None = None + status: str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + model = None + sort_fields = frozenset() + + def filter_clauses(self): + m = self.model + clauses = [] + for field in ("workspace_id", "site_id", "report_id", "status"): + value = getattr(self, field) + if value is not None and hasattr(m, field): + clauses.append(getattr(m, field) == value) + if self.q: + term = f"%{self.q.strip()}%" + searchable = [ + getattr(m, name).ilike(term) + for name in ("code", "name", "period_start", "period_end") + if hasattr(m, name) + ] + if searchable: + clauses.append(or_(*searchable)) + return clauses + + def apply(self, stmt): + for clause in self.filter_clauses(): + stmt = stmt.where(clause) + direction = desc if self.sort_dir.lower() == "desc" else asc + sort_col = getattr(self.model, self.sort_by, self.model.created_at) + if self.sort_by not in self.sort_fields: + sort_col = self.model.created_at + return stmt.order_by(direction(sort_col)) + + +@dataclass(frozen=True, slots=True) +class AnalyticsReportDefinitionListSpec(AnalyticsListSpec): + model = ExperienceAnalyticsReportDefinition + sort_fields = REPORT_DEFINITION_SORT + + +@dataclass(frozen=True, slots=True) +class AnalyticsSnapshotListSpec(AnalyticsListSpec): + model = ExperienceAnalyticsSnapshot + sort_fields = SNAPSHOT_SORT diff --git a/backend/services/experience/app/specifications/bundles.py b/backend/services/experience/app/specifications/bundles.py new file mode 100644 index 0000000..5db057b --- /dev/null +++ b/backend/services/experience/app/specifications/bundles.py @@ -0,0 +1,80 @@ +"""Filtering, search and sorting specifications — Phase 11.8.""" +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import asc, desc, or_ + +from app.models.bundles import ( + ExperienceBundleDefinition, + ExperienceFeatureToggle, + ExperienceLicenseBinding, + ExperienceTenantBundle, +) + +BUNDLE_DEFINITION_SORT = frozenset({"created_at", "updated_at", "code", "name", "status", "bundle_key"}) +TENANT_BUNDLE_SORT = frozenset({"created_at", "updated_at", "status", "bundle_key", "expires_at"}) +LICENSE_BINDING_SORT = frozenset({"created_at", "updated_at", "status", "starts_at", "ends_at"}) +FEATURE_TOGGLE_SORT = frozenset({"created_at", "updated_at", "feature_key", "status", "enabled"}) + + +@dataclass(frozen=True, slots=True) +class BundleListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + status: str | None = None + bundle_key: str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + model = None + sort_fields = frozenset() + + def filter_clauses(self): + m = self.model + clauses = [] + for field in ("workspace_id", "site_id", "status", "bundle_key"): + value = getattr(self, field) + if value is not None and hasattr(m, field): + clauses.append(getattr(m, field) == value) + if self.q: + term = f"%{self.q.strip()}%" + searchable = [ + getattr(m, name).ilike(term) + for name in ("code", "name", "description", "feature_key", "core_entitlement_ref") + if hasattr(m, name) + ] + if searchable: + clauses.append(or_(*searchable)) + return clauses + + def apply(self, stmt): + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + key = self.sort_by if self.sort_by in self.sort_fields else "created_at" + column = getattr(self.model, key) + return stmt.order_by(asc(column) if self.sort_dir.lower() == "asc" else desc(column)) + + +@dataclass(frozen=True, slots=True) +class BundleDefinitionListSpec(BundleListSpec): + model = ExperienceBundleDefinition + sort_fields = BUNDLE_DEFINITION_SORT + + +@dataclass(frozen=True, slots=True) +class TenantBundleListSpec(BundleListSpec): + model = ExperienceTenantBundle + sort_fields = TENANT_BUNDLE_SORT + + +@dataclass(frozen=True, slots=True) +class LicenseBindingListSpec(BundleListSpec): + model = ExperienceLicenseBinding + sort_fields = LICENSE_BINDING_SORT + + +@dataclass(frozen=True, slots=True) +class FeatureToggleListSpec(BundleListSpec): + model = ExperienceFeatureToggle + sort_fields = FEATURE_TOGGLE_SORT diff --git a/backend/services/experience/app/specifications/components.py b/backend/services/experience/app/specifications/components.py new file mode 100644 index 0000000..26bbc02 --- /dev/null +++ b/backend/services/experience/app/specifications/components.py @@ -0,0 +1,155 @@ +"""Component query specifications — Phase 11.2.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.components import ( + ExperienceComponent, + ExperienceComponentVersion, + PageComponentPlacement, +) +from app.models.types import ( + ComponentKind, + ComponentStatus, + ComponentVersionStatus, +) + +COMPONENT_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "code", "name", "kind", "status", "category"} +) +VERSION_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "version_number", "status", "published_at"} +) +PLACEMENT_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "sort_order", "slot_key"} +) + + +@dataclass(frozen=True, slots=True) +class ComponentListSpec: + workspace_id: UUID | None = None + kind: ComponentKind | str | None = None + status: ComponentStatus | str | None = None + category: str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceComponent.workspace_id == self.workspace_id) + if self.kind is not None: + kind = ( + self.kind + if isinstance(self.kind, ComponentKind) + else ComponentKind(str(self.kind)) + ) + clauses.append(ExperienceComponent.kind == kind) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, ComponentStatus) + else ComponentStatus(str(self.status)) + ) + clauses.append(ExperienceComponent.status == status) + if self.category: + clauses.append(ExperienceComponent.category == self.category) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceComponent.code.ilike(term), + ExperienceComponent.name.ilike(term), + ExperienceComponent.description.ilike(term), + ExperienceComponent.category.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in COMPONENT_SORT_FIELDS else "created_at" + ) + column = getattr(ExperienceComponent, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class ComponentVersionListSpec: + component_id: UUID | None = None + status: ComponentVersionStatus | str | None = None + q: str | None = None + sort_by: str = "version_number" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.component_id is not None: + clauses.append( + ExperienceComponentVersion.component_id == self.component_id + ) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, ComponentVersionStatus) + else ComponentVersionStatus(str(self.status)) + ) + clauses.append(ExperienceComponentVersion.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append(ExperienceComponentVersion.changelog.ilike(term)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in VERSION_SORT_FIELDS else "version_number" + ) + column = getattr(ExperienceComponentVersion, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class PlacementListSpec: + page_id: UUID | None = None + site_id: UUID | None = None + component_id: UUID | None = None + q: str | None = None + sort_by: str = "sort_order" + sort_dir: str = "asc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.page_id is not None: + clauses.append(PageComponentPlacement.page_id == self.page_id) + if self.site_id is not None: + clauses.append(PageComponentPlacement.site_id == self.site_id) + if self.component_id is not None: + clauses.append(PageComponentPlacement.component_id == self.component_id) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append(PageComponentPlacement.slot_key.ilike(term)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in PLACEMENT_SORT_FIELDS else "sort_order" + ) + column = getattr(PageComponentPlacement, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) diff --git a/backend/services/experience/app/specifications/forms.py b/backend/services/experience/app/specifications/forms.py new file mode 100644 index 0000000..9e0a056 --- /dev/null +++ b/backend/services/experience/app/specifications/forms.py @@ -0,0 +1,312 @@ +"""Forms / surveys / appointments list specifications — Phase 11.6.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.forms import ( + AppointmentBookingRef, + ExperienceAppointmentPage, + ExperienceForm, + ExperienceSurvey, + FormSubmission, + SurveyResponse, +) +from app.models.types import ( + AppointmentBookingStatus, + AppointmentPageStatus, + FormStatus, + FormSubmissionStatus, + SurveyResponseStatus, + SurveyStatus, +) + +FORM_SORT_FIELDS = frozenset({"created_at", "updated_at", "code", "name", "status"}) +SUBMISSION_SORT_FIELDS = frozenset({"created_at", "updated_at", "submitted_at", "status"}) +SURVEY_SORT_FIELDS = frozenset({"created_at", "updated_at", "code", "name", "status"}) +RESPONSE_SORT_FIELDS = frozenset({"created_at", "updated_at", "submitted_at", "status"}) +APPOINTMENT_PAGE_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "code", "name", "status"} +) +BOOKING_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "slot_start", "status", "external_booking_ref"} +) + + +@dataclass(frozen=True, slots=True) +class FormListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + status: FormStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceForm.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(ExperienceForm.site_id == self.site_id) + if self.page_id is not None: + clauses.append(ExperienceForm.page_id == self.page_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, FormStatus) + else FormStatus(str(self.status)) + ) + clauses.append(ExperienceForm.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceForm.code.ilike(term), + ExperienceForm.name.ilike(term), + ExperienceForm.description.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in FORM_SORT_FIELDS else "created_at" + column = getattr(ExperienceForm, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class FormSubmissionListSpec: + workspace_id: UUID | None = None + form_id: UUID | None = None + status: FormSubmissionStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(FormSubmission.workspace_id == self.workspace_id) + if self.form_id is not None: + clauses.append(FormSubmission.form_id == self.form_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, FormSubmissionStatus) + else FormSubmissionStatus(str(self.status)) + ) + clauses.append(FormSubmission.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + FormSubmission.crm_contact_ref.ilike(term), + FormSubmission.external_ref.ilike(term), + FormSubmission.communication_ref.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in SUBMISSION_SORT_FIELDS else "created_at" + ) + column = getattr(FormSubmission, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class SurveyListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + status: SurveyStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceSurvey.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(ExperienceSurvey.site_id == self.site_id) + if self.page_id is not None: + clauses.append(ExperienceSurvey.page_id == self.page_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, SurveyStatus) + else SurveyStatus(str(self.status)) + ) + clauses.append(ExperienceSurvey.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceSurvey.code.ilike(term), + ExperienceSurvey.name.ilike(term), + ExperienceSurvey.description.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in SURVEY_SORT_FIELDS else "created_at" + column = getattr(ExperienceSurvey, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class SurveyResponseListSpec: + workspace_id: UUID | None = None + survey_id: UUID | None = None + status: SurveyResponseStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(SurveyResponse.workspace_id == self.workspace_id) + if self.survey_id is not None: + clauses.append(SurveyResponse.survey_id == self.survey_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, SurveyResponseStatus) + else SurveyResponseStatus(str(self.status)) + ) + clauses.append(SurveyResponse.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + SurveyResponse.crm_contact_ref.ilike(term), + SurveyResponse.external_ref.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in RESPONSE_SORT_FIELDS else "created_at" + ) + column = getattr(SurveyResponse, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class AppointmentPageListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + status: AppointmentPageStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceAppointmentPage.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(ExperienceAppointmentPage.site_id == self.site_id) + if self.page_id is not None: + clauses.append(ExperienceAppointmentPage.page_id == self.page_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, AppointmentPageStatus) + else AppointmentPageStatus(str(self.status)) + ) + clauses.append(ExperienceAppointmentPage.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceAppointmentPage.code.ilike(term), + ExperienceAppointmentPage.name.ilike(term), + ExperienceAppointmentPage.booking_provider_ref.ilike(term), + ExperienceAppointmentPage.service_ref.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by + if self.sort_by in APPOINTMENT_PAGE_SORT_FIELDS + else "created_at" + ) + column = getattr(ExperienceAppointmentPage, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class AppointmentBookingListSpec: + workspace_id: UUID | None = None + appointment_page_id: UUID | None = None + status: AppointmentBookingStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(AppointmentBookingRef.workspace_id == self.workspace_id) + if self.appointment_page_id is not None: + clauses.append( + AppointmentBookingRef.appointment_page_id == self.appointment_page_id + ) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, AppointmentBookingStatus) + else AppointmentBookingStatus(str(self.status)) + ) + clauses.append(AppointmentBookingRef.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + AppointmentBookingRef.external_booking_ref.ilike(term), + AppointmentBookingRef.crm_contact_ref.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in BOOKING_SORT_FIELDS else "created_at" + column = getattr(AppointmentBookingRef, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) diff --git a/backend/services/experience/app/specifications/integrations.py b/backend/services/experience/app/specifications/integrations.py new file mode 100644 index 0000000..2551527 --- /dev/null +++ b/backend/services/experience/app/specifications/integrations.py @@ -0,0 +1,117 @@ +"""Filtering, search and sorting specifications — Phase 11.9.""" +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import asc, desc, or_ + +from app.models.integrations import ( + ExperienceConsumerConnectorDispatch, + ExperienceConsumerConnectorRegistration, + ExperienceNotifyDispatch, + ExperienceWidgetDefinition, + ExperienceWidgetInstance, +) + +CONNECTOR_REGISTRATION_SORT = frozenset( + {"created_at", "updated_at", "code", "name", "status", "kind"} +) +CONNECTOR_DISPATCH_SORT = frozenset( + {"created_at", "updated_at", "status", "event_type", "direction"} +) +WIDGET_DEFINITION_SORT = frozenset( + {"created_at", "updated_at", "code", "name", "status", "widget_kind"} +) +WIDGET_INSTANCE_SORT = frozenset( + {"created_at", "updated_at", "code", "name", "status", "embed_ref"} +) +NOTIFY_DISPATCH_SORT = frozenset( + {"created_at", "updated_at", "status", "channel", "template_key"} +) + + +@dataclass(frozen=True, slots=True) +class IntegrationListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + status: str | None = None + kind: str | None = None + widget_kind: str | None = None + registration_id: UUID | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + model = None + sort_fields = frozenset() + + def filter_clauses(self): + m = self.model + clauses = [] + for field in ( + "workspace_id", + "site_id", + "page_id", + "status", + "kind", + "widget_kind", + "registration_id", + ): + value = getattr(self, field) + if value is not None and hasattr(m, field): + clauses.append(getattr(m, field) == value) + if self.q: + term = f"%{self.q.strip()}%" + searchable = [ + getattr(m, name).ilike(term) + for name in ( + "code", + "name", + "description", + "event_type", + "embed_ref", + "template_key", + "channel", + ) + if hasattr(m, name) + ] + if searchable: + clauses.append(or_(*searchable)) + return clauses + + def apply(self, stmt): + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + key = self.sort_by if self.sort_by in self.sort_fields else "created_at" + column = getattr(self.model, key) + return stmt.order_by(asc(column) if self.sort_dir.lower() == "asc" else desc(column)) + + +@dataclass(frozen=True, slots=True) +class ConsumerConnectorRegistrationListSpec(IntegrationListSpec): + model = ExperienceConsumerConnectorRegistration + sort_fields = CONNECTOR_REGISTRATION_SORT + + +@dataclass(frozen=True, slots=True) +class ConsumerConnectorDispatchListSpec(IntegrationListSpec): + model = ExperienceConsumerConnectorDispatch + sort_fields = CONNECTOR_DISPATCH_SORT + + +@dataclass(frozen=True, slots=True) +class WidgetDefinitionListSpec(IntegrationListSpec): + model = ExperienceWidgetDefinition + sort_fields = WIDGET_DEFINITION_SORT + + +@dataclass(frozen=True, slots=True) +class WidgetInstanceListSpec(IntegrationListSpec): + model = ExperienceWidgetInstance + sort_fields = WIDGET_INSTANCE_SORT + + +@dataclass(frozen=True, slots=True) +class NotifyDispatchListSpec(IntegrationListSpec): + model = ExperienceNotifyDispatch + sort_fields = NOTIFY_DISPATCH_SORT diff --git a/backend/services/experience/app/specifications/localization.py b/backend/services/experience/app/specifications/localization.py new file mode 100644 index 0000000..4e7375a --- /dev/null +++ b/backend/services/experience/app/specifications/localization.py @@ -0,0 +1,239 @@ +"""Localization & media-ref query specifications — Phase 11.5.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.localization import ( + ExperienceLocalization, + ExperienceMediaRef, + SiteLocaleBinding, +) +from app.models.types import ( + LocalizationStatus, + LocalizationTarget, + MediaKind, + MediaRefStatus, + TextDirection, +) + +SITE_LOCALE_BINDING_SORT_FIELDS = frozenset( + { + "created_at", + "updated_at", + "locale_code", + "sort_order", + "is_default", + "text_direction", + } +) +LOCALIZATION_SORT_FIELDS = frozenset( + { + "created_at", + "updated_at", + "locale", + "status", + "title", + "text_direction", + "target_type", + } +) +MEDIA_REF_SORT_FIELDS = frozenset( + { + "created_at", + "updated_at", + "kind", + "sort_order", + "locale", + "status", + "storage_file_ref", + } +) + + +@dataclass(frozen=True, slots=True) +class SiteLocaleBindingListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + locale_profile_id: UUID | None = None + locale_code: str | None = None + text_direction: TextDirection | str | None = None + is_default: bool | None = None + is_active: bool | None = None + q: str | None = None + sort_by: str = "sort_order" + sort_dir: str = "asc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(SiteLocaleBinding.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(SiteLocaleBinding.site_id == self.site_id) + if self.locale_profile_id is not None: + clauses.append( + SiteLocaleBinding.locale_profile_id == self.locale_profile_id + ) + if self.locale_code: + clauses.append(SiteLocaleBinding.locale_code == self.locale_code) + if self.text_direction is not None: + direction = ( + self.text_direction + if isinstance(self.text_direction, TextDirection) + else TextDirection(str(self.text_direction)) + ) + clauses.append(SiteLocaleBinding.text_direction == direction) + if self.is_default is not None: + clauses.append(SiteLocaleBinding.is_default.is_(self.is_default)) + if self.is_active is not None: + clauses.append(SiteLocaleBinding.is_active.is_(self.is_active)) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append(SiteLocaleBinding.locale_code.ilike(term)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by + if self.sort_by in SITE_LOCALE_BINDING_SORT_FIELDS + else "sort_order" + ) + column = getattr(SiteLocaleBinding, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class LocalizationListSpec: + workspace_id: UUID | None = None + target_type: LocalizationTarget | str | None = None + target_id: UUID | None = None + locale: str | None = None + text_direction: TextDirection | str | None = None + status: LocalizationStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceLocalization.workspace_id == self.workspace_id) + if self.target_type is not None: + target = ( + self.target_type + if isinstance(self.target_type, LocalizationTarget) + else LocalizationTarget(str(self.target_type)) + ) + clauses.append(ExperienceLocalization.target_type == target) + if self.target_id is not None: + clauses.append(ExperienceLocalization.target_id == self.target_id) + if self.locale: + clauses.append(ExperienceLocalization.locale == self.locale) + if self.text_direction is not None: + direction = ( + self.text_direction + if isinstance(self.text_direction, TextDirection) + else TextDirection(str(self.text_direction)) + ) + clauses.append(ExperienceLocalization.text_direction == direction) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, LocalizationStatus) + else LocalizationStatus(str(self.status)) + ) + clauses.append(ExperienceLocalization.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceLocalization.locale.ilike(term), + ExperienceLocalization.title.ilike(term), + ExperienceLocalization.summary.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in LOCALIZATION_SORT_FIELDS else "created_at" + ) + column = getattr(ExperienceLocalization, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class MediaRefListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + owner_type: str | None = None + owner_id: UUID | None = None + kind: MediaKind | str | None = None + locale: str | None = None + status: MediaRefStatus | str | None = None + q: str | None = None + sort_by: str = "sort_order" + sort_dir: str = "asc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceMediaRef.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(ExperienceMediaRef.site_id == self.site_id) + if self.page_id is not None: + clauses.append(ExperienceMediaRef.page_id == self.page_id) + if self.owner_type: + clauses.append(ExperienceMediaRef.owner_type == self.owner_type) + if self.owner_id is not None: + clauses.append(ExperienceMediaRef.owner_id == self.owner_id) + if self.kind is not None: + kind = ( + self.kind + if isinstance(self.kind, MediaKind) + else MediaKind(str(self.kind)) + ) + clauses.append(ExperienceMediaRef.kind == kind) + if self.locale: + clauses.append(ExperienceMediaRef.locale == self.locale) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, MediaRefStatus) + else MediaRefStatus(str(self.status)) + ) + clauses.append(ExperienceMediaRef.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceMediaRef.storage_file_ref.ilike(term), + ExperienceMediaRef.alt_text.ilike(term), + ExperienceMediaRef.caption.ilike(term), + ExperienceMediaRef.locale.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in MEDIA_REF_SORT_FIELDS else "sort_order" + ) + column = getattr(ExperienceMediaRef, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) diff --git a/backend/services/experience/app/specifications/publishing.py b/backend/services/experience/app/specifications/publishing.py new file mode 100644 index 0000000..77b9000 --- /dev/null +++ b/backend/services/experience/app/specifications/publishing.py @@ -0,0 +1,270 @@ +"""Publishing / SEO / PWA list specifications — Phase 11.7.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.publishing import ( + DomainEdgeBinding, + PublishRelease, + PwaManifest, + RobotsShell, + SeoMetadata, + SitemapShell, +) +from app.models.types import ( + DomainEdgeStatus, + PublishReleaseStatus, + PublishTarget, + PwaStatus, + RobotsStatus, + SeoStatus, + SeoTarget, + SitemapStatus, +) + +PUBLISH_SORT = frozenset({"created_at", "updated_at", "completed_at", "status"}) +EDGE_SORT = frozenset({"created_at", "updated_at", "host", "status"}) +SEO_SORT = frozenset({"created_at", "updated_at", "title", "status", "target_type"}) +SITEMAP_SORT = frozenset({"created_at", "updated_at", "status"}) +ROBOTS_SORT = frozenset({"created_at", "updated_at", "status"}) +PWA_SORT = frozenset({"created_at", "updated_at", "name", "status"}) + + +def _enum_clause(column, value, enum_cls): + if value is None: + return None + parsed = value if isinstance(value, enum_cls) else enum_cls(str(value)) + return column == parsed + + +@dataclass(frozen=True, slots=True) +class PublishReleaseListSpec: + workspace_id: UUID | None = None + target_type: PublishTarget | str | None = None + target_id: UUID | None = None + status: PublishReleaseStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(PublishRelease.workspace_id == self.workspace_id) + c = _enum_clause(PublishRelease.target_type, self.target_type, PublishTarget) + if c is not None: + clauses.append(c) + if self.target_id is not None: + clauses.append(PublishRelease.target_id == self.target_id) + c = _enum_clause(PublishRelease.status, self.status, PublishReleaseStatus) + if c is not None: + clauses.append(c) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_(PublishRelease.notes.ilike(term), PublishRelease.error_message.ilike(term)) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in PUBLISH_SORT else "created_at" + column = getattr(PublishRelease, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class DomainEdgeBindingListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + site_domain_id: UUID | None = None + status: DomainEdgeStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(DomainEdgeBinding.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(DomainEdgeBinding.site_id == self.site_id) + if self.site_domain_id is not None: + clauses.append(DomainEdgeBinding.site_domain_id == self.site_domain_id) + c = _enum_clause(DomainEdgeBinding.status, self.status, DomainEdgeStatus) + if c is not None: + clauses.append(c) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + DomainEdgeBinding.host.ilike(term), + DomainEdgeBinding.dns_status_ref.ilike(term), + DomainEdgeBinding.ssl_cert_ref.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in EDGE_SORT else "created_at" + column = getattr(DomainEdgeBinding, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class SeoMetadataListSpec: + workspace_id: UUID | None = None + target_type: SeoTarget | str | None = None + target_id: UUID | None = None + status: SeoStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(SeoMetadata.workspace_id == self.workspace_id) + c = _enum_clause(SeoMetadata.target_type, self.target_type, SeoTarget) + if c is not None: + clauses.append(c) + if self.target_id is not None: + clauses.append(SeoMetadata.target_id == self.target_id) + c = _enum_clause(SeoMetadata.status, self.status, SeoStatus) + if c is not None: + clauses.append(c) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + SeoMetadata.title.ilike(term), + SeoMetadata.description.ilike(term), + SeoMetadata.canonical_url.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in SEO_SORT else "created_at" + column = getattr(SeoMetadata, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class SitemapShellListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + status: SitemapStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(SitemapShell.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(SitemapShell.site_id == self.site_id) + c = _enum_clause(SitemapShell.status, self.status, SitemapStatus) + if c is not None: + clauses.append(c) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append(SitemapShell.storage_file_ref.ilike(term)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in SITEMAP_SORT else "created_at" + column = getattr(SitemapShell, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class RobotsShellListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + status: RobotsStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(RobotsShell.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(RobotsShell.site_id == self.site_id) + c = _enum_clause(RobotsShell.status, self.status, RobotsStatus) + if c is not None: + clauses.append(c) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append(RobotsShell.content_text.ilike(term)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in ROBOTS_SORT else "created_at" + column = getattr(RobotsShell, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class PwaManifestListSpec: + workspace_id: UUID | None = None + site_id: UUID | None = None + status: PwaStatus | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(PwaManifest.workspace_id == self.workspace_id) + if self.site_id is not None: + clauses.append(PwaManifest.site_id == self.site_id) + c = _enum_clause(PwaManifest.status, self.status, PwaStatus) + if c is not None: + clauses.append(c) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + PwaManifest.name.ilike(term), + PwaManifest.short_name.ilike(term), + PwaManifest.start_url.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in PWA_SORT else "created_at" + column = getattr(PwaManifest, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) diff --git a/backend/services/experience/app/specifications/templates.py b/backend/services/experience/app/specifications/templates.py new file mode 100644 index 0000000..e4104dc --- /dev/null +++ b/backend/services/experience/app/specifications/templates.py @@ -0,0 +1,168 @@ +"""Template query specifications — Phase 11.4.""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.templates import ( + ExperienceTemplate, + ExperienceTemplateVersion, + TemplateInstantiation, +) +from app.models.types import ( + TemplateInstantiationStatus, + TemplateStatus, + TemplateVersionStatus, +) + +TEMPLATE_SORT_FIELDS = frozenset( + { + "created_at", + "updated_at", + "code", + "name", + "status", + "page_type", + "category", + } +) +TEMPLATE_VERSION_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "version_number", "status", "published_at"} +) +INSTANTIATION_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "completed_at", "status"} +) + + +@dataclass(frozen=True, slots=True) +class TemplateListSpec: + workspace_id: UUID | None = None + status: TemplateStatus | str | None = None + page_type: str | None = None + is_starter_pack: bool | None = None + category: str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceTemplate.workspace_id == self.workspace_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, TemplateStatus) + else TemplateStatus(str(self.status)) + ) + clauses.append(ExperienceTemplate.status == status) + if self.page_type: + clauses.append(ExperienceTemplate.page_type == self.page_type) + if self.is_starter_pack is not None: + clauses.append(ExperienceTemplate.is_starter_pack.is_(self.is_starter_pack)) + if self.category: + clauses.append(ExperienceTemplate.category == self.category) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceTemplate.code.ilike(term), + ExperienceTemplate.name.ilike(term), + ExperienceTemplate.description.ilike(term), + ExperienceTemplate.category.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in TEMPLATE_SORT_FIELDS else "created_at" + ) + column = getattr(ExperienceTemplate, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class TemplateVersionListSpec: + template_id: UUID | None = None + status: TemplateVersionStatus | str | None = None + q: str | None = None + sort_by: str = "version_number" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.template_id is not None: + clauses.append(ExperienceTemplateVersion.template_id == self.template_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, TemplateVersionStatus) + else TemplateVersionStatus(str(self.status)) + ) + clauses.append(ExperienceTemplateVersion.status == status) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append(ExperienceTemplateVersion.changelog.ilike(term)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by + if self.sort_by in TEMPLATE_VERSION_SORT_FIELDS + else "version_number" + ) + column = getattr(ExperienceTemplateVersion, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class InstantiationListSpec: + template_id: UUID | None = None + site_id: UUID | None = None + page_id: UUID | None = None + status: TemplateInstantiationStatus | str | None = None + workspace_id: UUID | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.template_id is not None: + clauses.append(TemplateInstantiation.template_id == self.template_id) + if self.site_id is not None: + clauses.append(TemplateInstantiation.site_id == self.site_id) + if self.page_id is not None: + clauses.append(TemplateInstantiation.page_id == self.page_id) + if self.workspace_id is not None: + clauses.append(TemplateInstantiation.workspace_id == self.workspace_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, TemplateInstantiationStatus) + else TemplateInstantiationStatus(str(self.status)) + ) + clauses.append(TemplateInstantiation.status == status) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in INSTANTIATION_SORT_FIELDS else "created_at" + ) + column = getattr(TemplateInstantiation, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) diff --git a/backend/services/experience/app/specifications/themes.py b/backend/services/experience/app/specifications/themes.py new file mode 100644 index 0000000..c8651a3 --- /dev/null +++ b/backend/services/experience/app/specifications/themes.py @@ -0,0 +1,289 @@ +"""Theme & layout query specifications — Phase 11.3 (versioned).""" +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import Select, asc, desc, or_ +from sqlalchemy.sql import ColumnElement + +from app.models.themes import ( + ExperienceLayout, + ExperienceLayoutVersion, + ExperienceTheme, + ExperienceThemeVersion, + PageLayoutBinding, + SiteThemeBinding, +) +from app.models.types import ( + DirectionMode, + LayoutKind, + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) + +THEME_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "code", "name", "status", "direction_mode"} +) +THEME_VERSION_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "version_number", "status"} +) +LAYOUT_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "code", "name", "status", "direction_mode", "kind"} +) +LAYOUT_VERSION_SORT_FIELDS = frozenset( + {"created_at", "updated_at", "version_number", "status"} +) +SITE_THEME_BINDING_SORT_FIELDS = frozenset({"created_at", "updated_at"}) +PAGE_LAYOUT_BINDING_SORT_FIELDS = frozenset({"created_at", "updated_at"}) + + +@dataclass(frozen=True, slots=True) +class ThemeListSpec: + workspace_id: UUID | None = None + status: ThemeStatus | str | None = None + direction_mode: DirectionMode | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceTheme.workspace_id == self.workspace_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, ThemeStatus) + else ThemeStatus(str(self.status)) + ) + clauses.append(ExperienceTheme.status == status) + if self.direction_mode is not None: + mode = ( + self.direction_mode + if isinstance(self.direction_mode, DirectionMode) + else DirectionMode(str(self.direction_mode)) + ) + clauses.append(ExperienceTheme.direction_mode == mode) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceTheme.code.ilike(term), + ExperienceTheme.name.ilike(term), + ExperienceTheme.description.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = self.sort_by if self.sort_by in THEME_SORT_FIELDS else "created_at" + column = getattr(ExperienceTheme, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class ThemeVersionListSpec: + theme_id: UUID | None = None + status: ThemeVersionStatus | str | None = None + sort_by: str = "version_number" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.theme_id is not None: + clauses.append(ExperienceThemeVersion.theme_id == self.theme_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, ThemeVersionStatus) + else ThemeVersionStatus(str(self.status)) + ) + clauses.append(ExperienceThemeVersion.status == status) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in THEME_VERSION_SORT_FIELDS else "version_number" + ) + column = getattr(ExperienceThemeVersion, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class LayoutListSpec: + workspace_id: UUID | None = None + status: LayoutStatus | str | None = None + direction_mode: DirectionMode | str | None = None + kind: LayoutKind | str | None = None + q: str | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.workspace_id is not None: + clauses.append(ExperienceLayout.workspace_id == self.workspace_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, LayoutStatus) + else LayoutStatus(str(self.status)) + ) + clauses.append(ExperienceLayout.status == status) + if self.direction_mode is not None: + mode = ( + self.direction_mode + if isinstance(self.direction_mode, DirectionMode) + else DirectionMode(str(self.direction_mode)) + ) + clauses.append(ExperienceLayout.direction_mode == mode) + if self.kind is not None: + kind = ( + self.kind if isinstance(self.kind, LayoutKind) else LayoutKind(str(self.kind)) + ) + clauses.append(ExperienceLayout.kind == kind) + if self.q: + term = f"%{self.q.strip()}%" + clauses.append( + or_( + ExperienceLayout.code.ilike(term), + ExperienceLayout.name.ilike(term), + ExperienceLayout.description.ilike(term), + ) + ) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by if self.sort_by in LAYOUT_SORT_FIELDS else "created_at" + ) + column = getattr(ExperienceLayout, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class LayoutVersionListSpec: + layout_id: UUID | None = None + status: LayoutVersionStatus | str | None = None + sort_by: str = "version_number" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.layout_id is not None: + clauses.append(ExperienceLayoutVersion.layout_id == self.layout_id) + if self.status is not None: + status = ( + self.status + if isinstance(self.status, LayoutVersionStatus) + else LayoutVersionStatus(str(self.status)) + ) + clauses.append(ExperienceLayoutVersion.status == status) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by + if self.sort_by in LAYOUT_VERSION_SORT_FIELDS + else "version_number" + ) + column = getattr(ExperienceLayoutVersion, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class SiteThemeBindingListSpec: + site_id: UUID | None = None + theme_id: UUID | None = None + theme_version_id: UUID | None = None + workspace_id: UUID | None = None + is_active: bool | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.site_id is not None: + clauses.append(SiteThemeBinding.site_id == self.site_id) + if self.theme_id is not None: + clauses.append(SiteThemeBinding.theme_id == self.theme_id) + if self.theme_version_id is not None: + clauses.append(SiteThemeBinding.theme_version_id == self.theme_version_id) + if self.workspace_id is not None: + clauses.append(SiteThemeBinding.workspace_id == self.workspace_id) + if self.is_active is not None: + clauses.append(SiteThemeBinding.is_active.is_(self.is_active)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by + if self.sort_by in SITE_THEME_BINDING_SORT_FIELDS + else "created_at" + ) + column = getattr(SiteThemeBinding, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) + + +@dataclass(frozen=True, slots=True) +class PageLayoutBindingListSpec: + page_id: UUID | None = None + layout_id: UUID | None = None + layout_version_id: UUID | None = None + site_id: UUID | None = None + workspace_id: UUID | None = None + is_active: bool | None = None + sort_by: str = "created_at" + sort_dir: str = "desc" + + def filter_clauses(self) -> list[ColumnElement]: + clauses: list[ColumnElement] = [] + if self.page_id is not None: + clauses.append(PageLayoutBinding.page_id == self.page_id) + if self.layout_id is not None: + clauses.append(PageLayoutBinding.layout_id == self.layout_id) + if self.layout_version_id is not None: + clauses.append(PageLayoutBinding.layout_version_id == self.layout_version_id) + if self.site_id is not None: + clauses.append(PageLayoutBinding.site_id == self.site_id) + if self.workspace_id is not None: + clauses.append(PageLayoutBinding.workspace_id == self.workspace_id) + if self.is_active is not None: + clauses.append(PageLayoutBinding.is_active.is_(self.is_active)) + return clauses + + def apply(self, stmt: Select) -> Select: + clauses = self.filter_clauses() + if clauses: + stmt = stmt.where(*clauses) + sort_key = ( + self.sort_by + if self.sort_by in PAGE_LAYOUT_BINDING_SORT_FIELDS + else "created_at" + ) + column = getattr(PageLayoutBinding, sort_key) + order = desc(column) if self.sort_dir.lower() != "asc" else asc(column) + return stmt.order_by(order) diff --git a/backend/services/experience/app/tests/__init__.py b/backend/services/experience/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/services/experience/app/tests/conftest.py b/backend/services/experience/app/tests/conftest.py new file mode 100644 index 0000000..2cd104a --- /dev/null +++ b/backend/services/experience/app/tests/conftest.py @@ -0,0 +1,62 @@ +import os +import uuid + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +os.environ["ENVIRONMENT"] = "test" +os.environ["AUTH_REQUIRED"] = "false" +os.environ["EXPERIENCE_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["EXPERIENCE_DATABASE_URL_SYNC"] = "sqlite:///:memory:" +os.environ["JWT_VERIFY_SIGNATURE"] = "false" + +from app.core.config import get_settings # noqa: E402 + +get_settings.cache_clear() + +from app.core.database import Base, engine # noqa: E402 +import app.models # noqa: E402, F401 +from app.events.publisher import reset_event_publisher # noqa: E402 +from app.main import app # noqa: E402 + +TENANT_A = uuid.uuid4() +TENANT_B = uuid.uuid4() + + +@pytest_asyncio.fixture +async def db_setup(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + yield + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="session", autouse=True) +def _dispose_engine_on_session_end(): + yield + import asyncio + + try: + asyncio.run(engine.dispose()) + except RuntimeError: + pass + + +@pytest_asyncio.fixture(autouse=True) +def _reset_events(): + reset_event_publisher() + yield + + +@pytest_asyncio.fixture +async def client(db_setup): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as ac: + yield ac + + +def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]: + return {"X-Tenant-ID": str(tenant_id)} diff --git a/backend/services/experience/app/tests/test_ai_hooks.py b/backend/services/experience/app/tests/test_ai_hooks.py new file mode 100644 index 0000000..1ccad99 --- /dev/null +++ b/backend/services/experience/app/tests/test_ai_hooks.py @@ -0,0 +1,140 @@ +"""AI content hook API tests — Phase 11.10.""" +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ai-hooks"): + response = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "AI Hooks", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.asyncio +async def test_ai_hook_dispatch_generate_happy_path(client): + caps = (await client.get("/capabilities")).json() + assert caps["features"]["ai_content"] is True + + ws = await _workspace(client) + headers = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/ai-content-hook-registrations", + json={ + "workspace_id": ws["id"], + "code": "page-copy-1", + "name": "Page Copy Hook", + "task_kind": "page_copy", + "status": "active", + }, + headers=headers, + ) + assert registration.status_code == 201, registration.text + reg_id = registration.json()["id"] + + dispatch = await client.post( + "/api/v1/ai-content-dispatches", + json={ + "registration_id": reg_id, + "workspace_id": ws["id"], + "task": "generate_landing_copy", + "context_ref": "page:landing:1", + "payload_json": {"locale": "fa"}, + }, + headers=headers, + ) + assert dispatch.status_code == 201, dispatch.text + generated = await client.post( + f"/api/v1/ai-content-dispatches/{dispatch.json()['id']}/generate", + headers=headers, + ) + assert generated.status_code == 200, generated.text + body = generated.json() + assert body["status"] == "generated" + assert body["generated_content_ref"].startswith("ai:content:") + + +@pytest.mark.asyncio +async def test_forbidden_ai_keys_rejected(client): + ws = await _workspace(client, "forbidden-ai") + headers = tenant_headers(TENANT_A) + + response = await client.post( + "/api/v1/ai-content-hook-registrations", + json={ + "workspace_id": ws["id"], + "code": "bad-hook", + "name": "Bad Hook", + "task_kind": "custom", + "status": "draft", + "config_json": {"model_id": "gpt-4"}, + }, + headers=headers, + ) + assert response.status_code == 422, response.text + + +@pytest.mark.asyncio +async def test_suspended_registration_generate_fails(client): + ws = await _workspace(client, "suspended-ai") + headers = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/ai-content-hook-registrations", + json={ + "workspace_id": ws["id"], + "code": "suspended-hook", + "name": "Suspended Hook", + "task_kind": "seo_meta", + "status": "suspended", + }, + headers=headers, + ) + assert registration.status_code == 201, registration.text + reg_id = registration.json()["id"] + + dispatch = await client.post( + "/api/v1/ai-content-dispatches", + json={ + "registration_id": reg_id, + "workspace_id": ws["id"], + "task": "generate_seo", + }, + headers=headers, + ) + assert dispatch.status_code == 201, dispatch.text + generated = await client.post( + f"/api/v1/ai-content-dispatches/{dispatch.json()['id']}/generate", + headers=headers, + ) + assert generated.status_code == 409, generated.text + + +@pytest.mark.asyncio +async def test_ai_hooks_tenant_isolation(client): + ws = await _workspace(client, "iso-ai") + headers = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/ai-content-hook-registrations", + json={ + "workspace_id": ws["id"], + "code": "iso-hook", + "name": "Iso Hook", + "task_kind": "custom", + "status": "active", + }, + headers=headers, + ) + assert registration.status_code == 201 + reg_id = registration.json()["id"] + + other = await client.get( + f"/api/v1/ai-content-hook-registrations/{reg_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 diff --git a/backend/services/experience/app/tests/test_analytics.py b/backend/services/experience/app/tests/test_analytics.py new file mode 100644 index 0000000..8d5d504 --- /dev/null +++ b/backend/services/experience/app/tests/test_analytics.py @@ -0,0 +1,98 @@ +"""Analytics API tests — Phase 11.10.""" +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="analytics"): + response = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Analytics", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.asyncio +async def test_analytics_report_refresh_happy_path(client): + caps = (await client.get("/capabilities")).json() + assert caps["phase"] == "11.10" + assert caps["features"]["analytics"] is True + + ws = await _workspace(client) + headers = tenant_headers(TENANT_A) + + report = await client.post( + "/api/v1/analytics-report-definitions", + json={ + "workspace_id": ws["id"], + "code": "ops-summary", + "name": "Ops Summary", + "metric_keys": ["workspaces_count", "sites_count", "pages_count"], + "status": "active", + }, + headers=headers, + ) + assert report.status_code == 201, report.text + report_id = report.json()["id"] + + snapshot = await client.post( + "/api/v1/analytics-snapshots/refresh", + json={ + "report_id": report_id, + "workspace_id": ws["id"], + "period_start": "2026-07-01", + "period_end": "2026-07-31", + }, + headers=headers, + ) + assert snapshot.status_code == 201, snapshot.text + body = snapshot.json() + assert body["metrics"]["workspaces_count"] >= 1 + assert body["metrics"]["sites_count"] == 0 + + +@pytest.mark.asyncio +async def test_unknown_metric_key_rejected(client): + ws = await _workspace(client, "metrics-bad") + headers = tenant_headers(TENANT_A) + + response = await client.post( + "/api/v1/analytics-report-definitions", + json={ + "workspace_id": ws["id"], + "code": "bad-metrics", + "name": "Bad Metrics", + "metric_keys": ["unknown_metric_key"], + "status": "active", + }, + headers=headers, + ) + assert response.status_code == 422, response.text + + +@pytest.mark.asyncio +async def test_analytics_tenant_isolation(client): + ws = await _workspace(client, "iso-analytics") + headers = tenant_headers(TENANT_A) + + report = await client.post( + "/api/v1/analytics-report-definitions", + json={ + "workspace_id": ws["id"], + "code": "iso-report", + "name": "Iso Report", + "metric_keys": ["workspaces_count"], + "status": "active", + }, + headers=headers, + ) + assert report.status_code == 201 + report_id = report.json()["id"] + + other = await client.get( + f"/api/v1/analytics-report-definitions/{report_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 diff --git a/backend/services/experience/app/tests/test_api.py b/backend/services/experience/app/tests/test_api.py new file mode 100644 index 0000000..4b9b27f --- /dev/null +++ b/backend/services/experience/app/tests/test_api.py @@ -0,0 +1,153 @@ +"""API foundation flow — Experience Phase 11.0.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +@pytest.mark.asyncio +async def test_health(client): + resp = await client.get("/health") + assert resp.status_code == 200 + body = resp.json() + assert body["service"] == "experience-service" + assert body["status"] == "ok" + assert body["version"] == "0.11.10.0" + + +@pytest.mark.asyncio +async def test_capabilities_and_metrics(client): + caps = await client.get("/capabilities") + assert caps.status_code == 200 + body = caps.json() + assert body["phase"] == "11.10" + assert body["features"]["widgets"] is True + assert body["features"]["consumer_connectors"] is True + assert body["features"]["analytics"] is True + assert body["features"]["ai_content"] is True + assert body["commercial_product"] == "Torbat Pages" + assert body["features"]["foundation"] is True + assert body["features"]["sites"] is True + assert body["features"]["components"] is True + assert body["features"]["themes"] is True + assert body["features"]["layouts"] is True + assert body["features"]["page_builder"] is False + assert body["features"]["templates"] is True + assert body["features"]["template_versions"] is True + assert body["features"]["template_instantiation"] is True + assert body["features"]["site_locale_bindings"] is True + assert body["features"]["localizations"] is True + assert body["features"]["media_refs"] is True + assert body["features"]["forms"] is True + assert body["features"]["surveys"] is True + assert body["features"]["appointment_pages"] is True + assert body["features"]["seo_engine"] is True + assert body["independence"]["integration_mode"] == "api_and_events_only" + + metrics = await client.get("/metrics") + assert metrics.status_code == 200 + assert metrics.json()["metrics"]["sites"] == "tenant_scoped" + assert metrics.json()["metrics"]["components"] == "tenant_scoped" + assert metrics.json()["metrics"]["themes"] == "tenant_scoped" + assert metrics.json()["metrics"]["layouts"] == "tenant_scoped" + assert metrics.json()["metrics"]["media_refs"] == "tenant_scoped" + assert metrics.json()["metrics"]["forms"] == "tenant_scoped" + assert metrics.json()["phase"] == "11.10" + +@pytest.mark.asyncio +async def test_foundation_flow_and_events(client): + ws = await client.post( + "/api/v1/workspaces", + json={"code": "main", "name": "Main Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert ws.status_code == 201, ws.text + workspace_id = ws.json()["id"] + assert ws.json()["code"] == "main" + + locale = await client.post( + "/api/v1/locale-profiles", + json={ + "workspace_id": workspace_id, + "code": "fa", + "name": "Persian", + "language": "fa", + "text_direction": "rtl", + }, + headers=tenant_headers(TENANT_A), + ) + assert locale.status_code == 201, locale.text + assert locale.json()["text_direction"] == "rtl" + + provider = await client.post( + "/api/v1/external-providers", + json={ + "workspace_id": workspace_id, + "code": "cdn1", + "name": "CDN", + "provider_kind": "cdn", + "adapter_key": "cdn.generic", + }, + headers=tenant_headers(TENANT_A), + ) + assert provider.status_code == 201, provider.text + + engine = await client.post( + "/api/v1/render-engines", + json={ + "workspace_id": workspace_id, + "code": "ssg", + "name": "SSG Ready", + "adapter_key": "render.ssg", + }, + headers=tenant_headers(TENANT_A), + ) + assert engine.status_code == 201, engine.text + + cfg = await client.post( + "/api/v1/configurations", + json={ + "workspace_id": workspace_id, + "code": "default", + "timezone": "Asia/Tehran", + "language": "fa", + }, + headers=tenant_headers(TENANT_A), + ) + assert cfg.status_code == 201, cfg.text + + setting = await client.put( + "/api/v1/settings", + json={"workspace_id": workspace_id, "key": "default_locale", "value": {"code": "fa"}}, + headers=tenant_headers(TENANT_A), + ) + assert setting.status_code == 200, setting.text + + events = get_event_publisher().published + types = {e.event_type for e in events} + assert ExperienceEventType.WORKSPACE_CREATED.value in types + assert ExperienceEventType.LOCALE_PROFILE_CREATED.value in types + assert ExperienceEventType.RENDER_ENGINE_REGISTERED.value in types + + +@pytest.mark.asyncio +async def test_tenant_isolation(client): + created = await client.post( + "/api/v1/workspaces", + json={"code": "iso", "name": "Iso"}, + headers=tenant_headers(TENANT_A), + ) + assert created.status_code == 201 + workspace_id = created.json()["id"] + + other = await client.get( + f"/api/v1/workspaces/{workspace_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + missing = await client.get("/api/v1/workspaces") + assert missing.status_code in (400, 422) diff --git a/backend/services/experience/app/tests/test_architecture.py b/backend/services/experience/app/tests/test_architecture.py new file mode 100644 index 0000000..1d4c06a --- /dev/null +++ b/backend/services/experience/app/tests/test_architecture.py @@ -0,0 +1,463 @@ +"""Architecture tests — Experience module boundary enforcement.""" +from __future__ import annotations + +import ast +from pathlib import Path + +from app.models import foundation as models +from app.models import sites as site_models +from app.models import components as component_models +from app.models import themes as theme_models +from app.models import templates as template_models +from app.models import localization as localization_models +from app.models import forms as form_models +from app.models import publishing as publishing_models +from app.models import bundles as bundle_models +from app.models import integrations as integration_models +from app.models import analytics as analytics_models +from app.models import ai_hooks as ai_hooks_models + + +FORBIDDEN_IMPORT_PREFIXES = ( + "backend.services.accounting", + "backend.services.crm", + "backend.services.loyalty", + "backend.services.communication", + "backend.services.sports_center", + "backend.services.hospitality", + "backend.services.delivery", + "backend.services.automation", + "backend.services.notification", + "backend.services.file_storage", + "backend.services.identity_access", + "backend.core_service", +) + +FOUNDATION_MODELS = [ + models.ExperienceWorkspace, + models.ExperienceLocaleProfile, + models.ExperienceRole, + models.ExperiencePermission, + models.ExternalProviderConfig, + models.RenderEngineRegistration, + models.ExperienceConfiguration, + models.ExperienceSetting, + models.ExperienceAuditLog, +] + +SITE_MODELS = [ + site_models.ExperienceSite, + site_models.ExperiencePage, + site_models.ExperienceSiteDomain, +] + +COMPONENT_MODELS = [ + component_models.ExperienceComponent, + component_models.ExperienceComponentVersion, + component_models.PageComponentPlacement, +] + +THEME_MODELS = [ + theme_models.ExperienceTheme, + theme_models.ExperienceThemeVersion, + theme_models.ExperienceLayout, + theme_models.ExperienceLayoutVersion, + theme_models.SiteThemeBinding, + theme_models.PageLayoutBinding, +] + +TEMPLATE_MODELS = [ + template_models.ExperienceTemplate, + template_models.ExperienceTemplateVersion, + template_models.TemplateInstantiation, +] + +LOCALIZATION_MODELS = [ + localization_models.SiteLocaleBinding, + localization_models.ExperienceLocalization, + localization_models.ExperienceMediaRef, +] + +FORM_MODELS = [ + form_models.ExperienceForm, + form_models.FormSubmission, + form_models.ExperienceSurvey, + form_models.SurveyResponse, + form_models.ExperienceAppointmentPage, + form_models.AppointmentBookingRef, +] +PUBLISHING_MODELS = [ + publishing_models.PublishRelease, + publishing_models.DomainEdgeBinding, + publishing_models.SeoMetadata, + publishing_models.SitemapShell, + publishing_models.RobotsShell, + publishing_models.PwaManifest, +] +BUNDLE_MODELS = [ + bundle_models.ExperienceBundleDefinition, + bundle_models.ExperienceTenantBundle, + bundle_models.ExperienceLicenseBinding, + bundle_models.ExperienceFeatureToggle, +] +INTEGRATION_MODELS = [ + integration_models.ExperienceConsumerConnectorRegistration, + integration_models.ExperienceConsumerConnectorDispatch, + integration_models.ExperienceWidgetDefinition, + integration_models.ExperienceWidgetInstance, + integration_models.ExperienceNotifyDispatch, +] + +ANALYTICS_MODELS = [ + analytics_models.ExperienceAnalyticsReportDefinition, + analytics_models.ExperienceAnalyticsSnapshot, +] + +AI_HOOKS_MODELS = [ + ai_hooks_models.ExperienceAiContentHookRegistration, + ai_hooks_models.ExperienceAiContentDispatch, +] + + +def test_all_models_have_tenant_id(): + from app.core.database import Base + import app.models # noqa: F401 + + skip = {"alembic_version"} + for table in Base.metadata.tables.values(): + if table.name in skip: + continue + assert "tenant_id" in table.columns, f"{table.name} missing tenant_id" + + +def test_foundation_aggregates_are_independent(): + assert len(FOUNDATION_MODELS) == 9 + for model in FOUNDATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_site_aggregates_are_independent(): + assert len(SITE_MODELS) == 3 + for model in SITE_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_component_aggregates_are_independent(): + assert len(COMPONENT_MODELS) == 3 + for model in COMPONENT_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_theme_aggregates_are_independent(): + assert len(THEME_MODELS) == 6 + for model in THEME_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_template_aggregates_are_independent(): + assert len(TEMPLATE_MODELS) == 3 + for model in TEMPLATE_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_localization_aggregates_are_independent(): + assert len(LOCALIZATION_MODELS) == 3 + for model in LOCALIZATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_form_aggregates_are_independent(): + assert len(FORM_MODELS) == 6 + for model in FORM_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "id") + assert not model.__mapper__.relationships + + +def test_publishing_aggregates_are_independent(): + assert len(PUBLISHING_MODELS) == 6 + for model in PUBLISHING_MODELS: + assert hasattr(model, "tenant_id") + assert not model.__mapper__.relationships + + +def test_bundle_aggregates_are_independent(): + assert len(BUNDLE_MODELS) == 4 + for model in BUNDLE_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "version") + assert hasattr(model, "is_deleted") + assert not model.__mapper__.relationships + + +def test_integration_aggregates_are_independent(): + assert len(INTEGRATION_MODELS) == 5 + for model in INTEGRATION_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "version") + assert hasattr(model, "is_deleted") + assert not model.__mapper__.relationships + + +def test_analytics_aggregates_are_independent(): + assert len(ANALYTICS_MODELS) == 2 + for model in ANALYTICS_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "version") + assert hasattr(model, "is_deleted") + assert not model.__mapper__.relationships + + +def test_ai_hooks_aggregates_are_independent(): + assert len(AI_HOOKS_MODELS) == 2 + for model in AI_HOOKS_MODELS: + assert hasattr(model, "tenant_id") + assert hasattr(model, "version") + assert hasattr(model, "is_deleted") + assert not model.__mapper__.relationships + + +def test_no_page_builder_engine_columns(): + forbidden = { + "page_builder", + "theme_engine", + "template_engine", + "cms_blob", + "section_tree", + "layout_engine", + "forms_engine", + "survey_engine", + "booking_engine", + "calendar_engine", + "schedule_engine", + } + for model in ( + FOUNDATION_MODELS + + SITE_MODELS + + COMPONENT_MODELS + + THEME_MODELS + + TEMPLATE_MODELS + + LOCALIZATION_MODELS + + FORM_MODELS + + PUBLISHING_MODELS + + BUNDLE_MODELS + + INTEGRATION_MODELS + + ANALYTICS_MODELS + + AI_HOOKS_MODELS + ): + columns = set(model.__table__.columns.keys()) + assert forbidden.isdisjoint(columns), model.__tablename__ + + +def test_media_ref_has_no_binary_columns(): + columns = set(localization_models.ExperienceMediaRef.__table__.columns.keys()) + forbidden = { + "binary", + "blob", + "bytes", + "content_bytes", + "file_bytes", + "base64", + "data_uri", + "raw_content", + } + assert forbidden.isdisjoint(columns) + assert "storage_file_ref" in columns + + +def test_permissions_defined(): + from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + assert "experience.view" in ALL_PERMISSIONS + assert "experience.workspaces.create" in ALL_PERMISSIONS + assert "experience.locale_profiles.manage" in ALL_PERMISSIONS + assert "experience.render_engines.manage" in ALL_PERMISSIONS + assert "experience.sites.create" in ALL_PERMISSIONS + assert "experience.pages.manage" in ALL_PERMISSIONS + assert "experience.domains.view" in ALL_PERMISSIONS + assert "experience.sites.view" in ALL_PERMISSIONS + assert "experience.components.create" in ALL_PERMISSIONS + assert "experience.components.manage" in ALL_PERMISSIONS + assert "experience.themes.create" in ALL_PERMISSIONS + assert "experience.layouts.manage" in ALL_PERMISSIONS + assert "experience.templates.create" in ALL_PERMISSIONS + assert "experience.templates.manage" in ALL_PERMISSIONS + assert "experience.locales.create" in ALL_PERMISSIONS + assert "experience.locales.manage" in ALL_PERMISSIONS + assert "experience.media_refs.create" in ALL_PERMISSIONS + assert "experience.media_refs.manage" in ALL_PERMISSIONS + assert "experience.forms.create" in ALL_PERMISSIONS + assert "experience.forms.manage" in ALL_PERMISSIONS + assert "experience.surveys.create" in ALL_PERMISSIONS + assert "experience.appointments.manage" in ALL_PERMISSIONS + assert "experience.publishing.manage" in ALL_PERMISSIONS + assert "experience.seo.manage" in ALL_PERMISSIONS + assert "experience.pwa.manage" in ALL_PERMISSIONS + assert "experience.bundles.manage" in ALL_PERMISSIONS + assert "experience.feature_toggles.manage" in ALL_PERMISSIONS + assert "experience.consumer_connectors.manage" in ALL_PERMISSIONS + assert "experience.widgets.manage" in ALL_PERMISSIONS + assert "experience.notify.manage" in ALL_PERMISSIONS + assert "experience.analytics.manage" in ALL_PERMISSIONS + assert "experience.ai_hooks.manage" in ALL_PERMISSIONS + assert "experience.audit.view" in ALL_PERMISSIONS + for prefix in PERMISSION_PREFIXES: + assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS) + + +def test_events_defined(): + from app.events.types import ExperienceEventType + + assert ExperienceEventType.WORKSPACE_CREATED.value == "experience.workspace.created" + assert ( + ExperienceEventType.LOCALE_PROFILE_CREATED.value + == "experience.locale_profile.created" + ) + assert ( + ExperienceEventType.RENDER_ENGINE_REGISTERED.value + == "experience.render_engine.registered" + ) + assert ExperienceEventType.SITE_CREATED.value == "experience.site.created" + assert ExperienceEventType.PAGE_PUBLISHED.value == "experience.page.published" + assert ExperienceEventType.SITE_DOMAIN_BOUND.value == "experience.site_domain.bound" + assert ExperienceEventType.COMPONENT_CREATED.value == "experience.component.created" + assert ( + ExperienceEventType.COMPONENT_VERSION_PUBLISHED.value + == "experience.component_version.published" + ) + assert ( + ExperienceEventType.PAGE_COMPONENT_PLACED.value + == "experience.page_component.placed" + ) + assert ExperienceEventType.THEME_CREATED.value == "experience.theme.created" + assert ExperienceEventType.LAYOUT_ACTIVATED.value == "experience.layout.activated" + assert ( + ExperienceEventType.SITE_THEME_ASSIGNED.value + == "experience.site_theme.assigned" + ) + assert ( + ExperienceEventType.PAGE_LAYOUT_ASSIGNED.value + == "experience.page_layout.assigned" + ) + assert ExperienceEventType.TEMPLATE_CREATED.value == "experience.template.created" + assert ( + ExperienceEventType.TEMPLATE_VERSION_PUBLISHED.value + == "experience.template_version.published" + ) + assert ( + ExperienceEventType.TEMPLATE_INSTANTIATED.value + == "experience.template.instantiated" + ) + assert ( + ExperienceEventType.SITE_LOCALE_ASSIGNED.value + == "experience.site_locale.assigned" + ) + assert ( + ExperienceEventType.LOCALIZATION_UPSERTED.value + == "experience.localization.upserted" + ) + assert ExperienceEventType.MEDIA_REF_CREATED.value == "experience.media_ref.created" + assert ExperienceEventType.FORM_CREATED.value == "experience.form.created" + assert ( + ExperienceEventType.FORM_SUBMISSION_RECEIVED.value + == "experience.form_submission.received" + ) + assert ExperienceEventType.SURVEY_CREATED.value == "experience.survey.created" + assert ( + ExperienceEventType.APPOINTMENT_BOOKING_REF_CREATED.value + == "experience.appointment_booking_ref.created" + ) + assert ExperienceEventType.PUBLISH_RELEASE_COMPLETED.value == "experience.publish_release.completed" + assert ExperienceEventType.DOMAIN_EDGE_BOUND.value == "experience.domain_edge.bound" + assert ExperienceEventType.SEO_METADATA_UPSERTED.value == "experience.seo_metadata.upserted" + assert ExperienceEventType.PWA_MANIFEST_UPSERTED.value == "experience.pwa_manifest.upserted" + assert ExperienceEventType.BUNDLE_DEFINITION_CREATED.value == "experience.bundle_definition.created" + assert ExperienceEventType.FEATURE_TOGGLE_CREATED.value == "experience.feature_toggle.created" + assert ( + ExperienceEventType.CONSUMER_CONNECTOR_REGISTRATION_CREATED.value + == "experience.consumer_connector_registration.created" + ) + assert ( + ExperienceEventType.WIDGET_DEFINITION_CREATED.value + == "experience.widget_definition.created" + ) + assert ExperienceEventType.NOTIFY_DISPATCH_SENT.value == "experience.notify_dispatch.sent" + assert ( + ExperienceEventType.ANALYTICS_SNAPSHOT_REFRESHED.value + == "experience.analytics_snapshot.refreshed" + ) + assert ( + ExperienceEventType.AI_CONTENT_DISPATCH_GENERATED.value + == "experience.ai_content_dispatch.generated" + ) + + +def test_no_duplicate_event_values(): + from app.events.types import ExperienceEventType + + values = [e.value for e in ExperienceEventType] + assert len(values) == len(set(values)) + + +def test_platform_provider_contracts_exist(): + from app.providers import ( + AIProvider, + AccountingProvider, + AutomationProvider, + CRMProvider, + CommunicationProvider, + DeliveryProvider, + HospitalityProvider, + IdentityProvider, + LoyaltyProvider, + MarketplaceProvider, + NotificationProvider, + RenderEngineProvider, + SportsCenterProvider, + StorageProvider, + ThemeMarketplaceProvider, + ) + + assert AccountingProvider is not None + assert CRMProvider is not None + assert StorageProvider is not None + assert HospitalityProvider is not None + assert MarketplaceProvider is not None + assert RenderEngineProvider is not None + assert ThemeMarketplaceProvider is not None + assert AutomationProvider is not None + assert DeliveryProvider is not None + assert SportsCenterProvider is not None + assert AIProvider is not None + assert CommunicationProvider is not None + assert LoyaltyProvider is not None + assert IdentityProvider is not None + assert NotificationProvider is not None + + +def test_no_foreign_service_imports(): + root = Path(__file__).resolve().parents[1] + for path in root.rglob("*.py"): + if "tests" in path.parts: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + for prefix in FORBIDDEN_IMPORT_PREFIXES: + assert not alias.name.startswith(prefix), path + elif isinstance(node, ast.ImportFrom) and node.module: + for prefix in FORBIDDEN_IMPORT_PREFIXES: + assert not node.module.startswith(prefix), path diff --git a/backend/services/experience/app/tests/test_bundles.py b/backend/services/experience/app/tests/test_bundles.py new file mode 100644 index 0000000..56dc435 --- /dev/null +++ b/backend/services/experience/app/tests/test_bundles.py @@ -0,0 +1,83 @@ +"""Bundles, licensing refs and feature-toggle API tests — Phase 11.8.""" +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="bundle"): + response = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Bundles", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.asyncio +async def test_bundle_activation_license_and_toggle(client): + caps = (await client.get("/capabilities")).json() + assert caps["phase"] == "11.10" + assert caps["features"]["bundle_licensing_shell"] is True + ws = await _workspace(client) + headers = tenant_headers(TENANT_A) + + definition = await client.post( + "/api/v1/bundle-definitions", + json={"workspace_id": ws["id"], "code": "mini", "bundle_key": "mini_site", + "name": "Mini Site", "status": "available", "feature_keys": ["pages.mini"]}, + headers=headers, + ) + assert definition.status_code == 201, definition.text + bundle = await client.post( + "/api/v1/tenant-bundles", + json={"workspace_id": ws["id"], "bundle_definition_id": definition.json()["id"], + "status": "active", "core_entitlement_ref": "core-entitlement-1"}, + headers=headers, + ) + assert bundle.status_code == 201, bundle.text + assert bundle.json()["bundle_key"] == "mini_site" + + license_binding = await client.post( + "/api/v1/license-bindings", + json={"workspace_id": ws["id"], "tenant_bundle_id": bundle.json()["id"], + "core_entitlement_ref": "core-entitlement-1", "status": "active"}, + headers=headers, + ) + assert license_binding.status_code == 201, license_binding.text + toggle = await client.post( + "/api/v1/feature-toggles", + json={"workspace_id": ws["id"], "feature_key": "pages.mini", + "enabled": True, "bundle_key": "mini_site"}, + headers=headers, + ) + assert toggle.status_code == 201, toggle.text + assert (await client.get( + f"/api/v1/feature-toggles/{toggle.json()['id']}", headers=tenant_headers(TENANT_B) + )).status_code == 404 + + +@pytest.mark.asyncio +async def test_rejects_core_plan_metadata_and_inactive_activation(client): + ws = await _workspace(client, "bundle-rules") + headers = tenant_headers(TENANT_A) + rejected = await client.post( + "/api/v1/bundle-definitions", + json={"workspace_id": ws["id"], "code": "bad", "bundle_key": "custom", + "name": "Bad", "metadata_json": {"plan_engine": "local"}}, + headers=headers, + ) + assert rejected.status_code == 422 + + draft = await client.post( + "/api/v1/bundle-definitions", + json={"workspace_id": ws["id"], "code": "draft", "bundle_key": "forms", "name": "Draft"}, + headers=headers, + ) + assert draft.status_code == 201, draft.text + activation = await client.post( + "/api/v1/tenant-bundles", + json={"workspace_id": ws["id"], "bundle_definition_id": draft.json()["id"], "status": "active"}, + headers=headers, + ) + assert activation.status_code == 409 diff --git a/backend/services/experience/app/tests/test_component_policies.py b/backend/services/experience/app/tests/test_component_policies.py new file mode 100644 index 0000000..9d876c1 --- /dev/null +++ b/backend/services/experience/app/tests/test_component_policies.py @@ -0,0 +1,61 @@ +"""Unit tests for component policies — Phase 11.2.""" +from __future__ import annotations + +import pytest + +from app.models.types import ComponentStatus, ComponentVersionStatus +from app.policies.components import ( + ComponentLifecyclePolicy, + ComponentVersionPolicy, + PagePlacementPolicy, +) +from shared.exceptions import AppError + + +def test_component_lifecycle_transitions(): + policy = ComponentLifecyclePolicy() + assert ( + policy.assert_status_change( + current=ComponentStatus.DRAFT, target=ComponentStatus.ACTIVE + ) + == ComponentStatus.ACTIVE + ) + with pytest.raises(AppError) as exc: + policy.assert_status_change( + current=ComponentStatus.ARCHIVED, target=ComponentStatus.ACTIVE + ) + assert exc.value.error_code == "invalid_component_status_transition" + + +def test_version_immutability_policy(): + policy = ComponentVersionPolicy() + + class _V: + status = ComponentVersionStatus.PUBLISHED + is_immutable = True + + with pytest.raises(AppError) as exc: + policy.assert_can_update(_V()) + assert exc.value.error_code == "component_version_immutable" + + assert ( + policy.assert_publish(current=ComponentVersionStatus.DRAFT) + == ComponentVersionStatus.PUBLISHED + ) + + +def test_placement_requires_published_version(): + policy = PagePlacementPolicy() + + class _Draft: + status = ComponentVersionStatus.DRAFT + is_deleted = False + + class _Comp: + status = ComponentStatus.ACTIVE + is_deleted = False + + with pytest.raises(AppError) as exc: + policy.assert_version_placeable(_Draft()) + assert exc.value.error_code == "component_version_not_published" + policy.assert_component_placeable(_Comp()) diff --git a/backend/services/experience/app/tests/test_components.py b/backend/services/experience/app/tests/test_components.py new file mode 100644 index 0000000..72f6e6b --- /dev/null +++ b/backend/services/experience/app/tests/test_components.py @@ -0,0 +1,262 @@ +"""Component library API tests — Phase 11.2.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ws-comp"): + resp = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _site_page(client, workspace_id: str): + site = await client.post( + "/api/v1/sites", + json={ + "workspace_id": workspace_id, + "code": "main", + "name": "Main Site", + }, + headers=tenant_headers(TENANT_A), + ) + assert site.status_code == 201, site.text + page = await client.post( + "/api/v1/pages", + json={ + "workspace_id": workspace_id, + "site_id": site.json()["id"], + "code": "home", + "slug": "home", + "title": "Home", + "page_type": "landing", + }, + headers=tenant_headers(TENANT_A), + ) + assert page.status_code == 201, page.text + return site.json(), page.json() + + +@pytest.mark.asyncio +async def test_health_phase_112(client): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["version"].startswith("0.11.") + + caps = await client.get("/capabilities") + body = caps.json() + assert body["features"]["components"] is True + assert body["features"]["component_versions"] is True + assert body["features"]["component_version_immutability"] is True + assert body["features"]["page_component_placements"] is True + assert "hero" in body["component_kinds"] + assert "menu_ref" in body["component_kinds"] + + +@pytest.mark.asyncio +async def test_component_version_publish_placement_flow(client): + ws = await _workspace(client) + workspace_id = ws["id"] + site, page = await _site_page(client, workspace_id) + + component = await client.post( + "/api/v1/components", + json={ + "workspace_id": workspace_id, + "code": "hero-main", + "name": "Hero Main", + "kind": "hero", + "category": "marketing", + "tags": ["hero", "landing"], + }, + headers=tenant_headers(TENANT_A), + ) + assert component.status_code == 201, component.text + component_id = component.json()["id"] + assert component.json()["status"] == "draft" + + version = await client.post( + "/api/v1/component-versions", + json={ + "workspace_id": workspace_id, + "component_id": component_id, + "definition_schema": { + "type": "object", + "properties": {"title": {"type": "string"}}, + }, + "props_defaults": {"title": "Welcome"}, + "render_hint": {"renderer": "web"}, + "changelog": "Initial draft", + }, + headers=tenant_headers(TENANT_A), + ) + assert version.status_code == 201, version.text + version_id = version.json()["id"] + assert version.json()["version_number"] == 1 + assert version.json()["status"] == "draft" + assert version.json()["is_immutable"] is False + + listed = await client.get( + f"/api/v1/components?workspace_id={workspace_id}&q=hero&sort_by=name&sort_dir=asc", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + assert any(i["id"] == component_id for i in listed.json()["items"]) + + published = await client.post( + f"/api/v1/component-versions/{version_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert published.status_code == 200, published.text + assert published.json()["status"] == "published" + assert published.json()["is_immutable"] is True + + # Immutable: update must fail + blocked = await client.patch( + f"/api/v1/component-versions/{version_id}", + json={"changelog": "nope", "version": published.json()["version"]}, + headers=tenant_headers(TENANT_A), + ) + assert blocked.status_code == 409 + + # Draft placement blocked before publish was already done; try unpublished second version + v2 = await client.post( + "/api/v1/component-versions", + json={ + "workspace_id": workspace_id, + "component_id": component_id, + "changelog": "v2", + }, + headers=tenant_headers(TENANT_A), + ) + assert v2.status_code == 201 + draft_place = await client.post( + "/api/v1/page-component-placements", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "page_id": page["id"], + "component_id": component_id, + "component_version_id": v2.json()["id"], + "slot_key": "main.hero", + "sort_order": 0, + }, + headers=tenant_headers(TENANT_A), + ) + assert draft_place.status_code == 409 + + placement = await client.post( + "/api/v1/page-component-placements", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "page_id": page["id"], + "component_id": component_id, + "component_version_id": version_id, + "slot_key": "main.hero", + "sort_order": 10, + "props_override": {"title": "Hello"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert placement.status_code == 201, placement.text + placement_id = placement.json()["id"] + + # Retire blocked while in use + retire_blocked = await client.post( + f"/api/v1/component-versions/{version_id}/retire", + headers=tenant_headers(TENANT_A), + ) + assert retire_blocked.status_code == 409 + + # Soft-delete component blocked while placements exist + delete_blocked = await client.post( + f"/api/v1/components/{component_id}/delete", + headers=tenant_headers(TENANT_A), + ) + assert delete_blocked.status_code == 409 + + removed = await client.post( + f"/api/v1/page-component-placements/{placement_id}/delete", + headers=tenant_headers(TENANT_A), + ) + assert removed.status_code == 200 + assert removed.json()["is_deleted"] is True + + retired = await client.post( + f"/api/v1/component-versions/{version_id}/retire", + headers=tenant_headers(TENANT_A), + ) + assert retired.status_code == 200 + assert retired.json()["status"] == "retired" + + archived = await client.post( + f"/api/v1/components/{component_id}/archive", + headers=tenant_headers(TENANT_A), + ) + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.COMPONENT_CREATED.value in events + assert ExperienceEventType.COMPONENT_VERSION_CREATED.value in events + assert ExperienceEventType.COMPONENT_VERSION_PUBLISHED.value in events + assert ExperienceEventType.PAGE_COMPONENT_PLACED.value in events + assert ExperienceEventType.PAGE_COMPONENT_REMOVED.value in events + assert ExperienceEventType.COMPONENT_VERSION_RETIRED.value in events + assert ExperienceEventType.COMPONENT_ARCHIVED.value in events + + +@pytest.mark.asyncio +async def test_component_tenant_isolation(client): + ws = await _workspace(client, code="iso-c") + component = await client.post( + "/api/v1/components", + json={ + "workspace_id": ws["id"], + "code": "iso", + "name": "Iso", + "kind": "content", + }, + headers=tenant_headers(TENANT_A), + ) + assert component.status_code == 201 + component_id = component.json()["id"] + + other = await client.get( + f"/api/v1/components/{component_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_optimistic_lock_on_component(client): + ws = await _workspace(client, code="lock-c") + component = await client.post( + "/api/v1/components", + json={ + "workspace_id": ws["id"], + "code": "lock", + "name": "Lock", + "kind": "cta", + }, + headers=tenant_headers(TENANT_A), + ) + assert component.status_code == 201 + body = component.json() + bad = await client.patch( + f"/api/v1/components/{body['id']}", + json={"name": "Nope", "version": body["version"] + 5}, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 409 diff --git a/backend/services/experience/app/tests/test_dependency.py b/backend/services/experience/app/tests/test_dependency.py new file mode 100644 index 0000000..1d914e4 --- /dev/null +++ b/backend/services/experience/app/tests/test_dependency.py @@ -0,0 +1,19 @@ +"""Layering / dependency checks.""" +from pathlib import Path + + +def test_api_modules_do_not_import_models_directly(): + api_root = Path(__file__).resolve().parents[1] / "api" / "v1" + for path in api_root.glob("*.py"): + if path.name == "__init__.py": + continue + text = path.read_text(encoding="utf-8") + assert "from app.models" not in text, path.name + assert "import app.models" not in text, path.name + + +def test_repositories_do_not_import_services(): + repo_root = Path(__file__).resolve().parents[1] / "repositories" + for path in repo_root.glob("*.py"): + text = path.read_text(encoding="utf-8") + assert "from app.services" not in text, path.name diff --git a/backend/services/experience/app/tests/test_docs.py b/backend/services/experience/app/tests/test_docs.py new file mode 100644 index 0000000..8d890c3 --- /dev/null +++ b/backend/services/experience/app/tests/test_docs.py @@ -0,0 +1,56 @@ +"""Documentation validation for Experience Phase 11.10.""" +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[5] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_phase_docs_exist_and_mention_db(): + text = _read("docs/experience-phase-11-10.md") + assert "experience_db" in text + assert "11.10" in text + assert "Torbat Pages" in text + assert "analytics" in text.lower() + assert "ai content" in text.lower() or "ai_content" in text.lower() + + +def test_handover_exists(): + text = _read("docs/phase-handover/phase-11-10.md") + assert "experience" in text.lower() + assert "experience_db" in text + assert "11.10" in text + + +def test_audit_report_exists(): + text = _read("docs/experience-phase-11-10-audit.md") + assert "11.10" in text + assert "Pass" in text + + +def test_module_registry_updated(): + text = _read("docs/module-registry.md") + assert "experience" in text.lower() + assert "experience_db" in text + assert "0.11.10.0" in text or "11.10" in text + + +def test_service_readme(): + text = _read("backend/services/experience/README.md") + assert "experience_db" in text + assert "8008" in text + assert "analytics" in text.lower() + + +def test_adr_016_exists(): + text = _read("docs/architecture/adr/ADR-016.md") + assert "Experience" in text + assert "experience_db" in text + + +def test_previous_phase_docs_still_present(): + text = _read("docs/experience-phase-11-9.md") + assert "11.9" in text + assert "widget" in text.lower() diff --git a/backend/services/experience/app/tests/test_forms.py b/backend/services/experience/app/tests/test_forms.py new file mode 100644 index 0000000..37c01b5 --- /dev/null +++ b/backend/services/experience/app/tests/test_forms.py @@ -0,0 +1,206 @@ +"""Forms, surveys & appointments API tests — Phase 11.6.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ws-forms"): + resp = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _site(client, workspace_id: str, code="main"): + resp = await client.post( + "/api/v1/sites", + json={"workspace_id": workspace_id, "code": code, "name": "Main Site"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_health_phase_116(client): + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["version"].startswith("0.11.10") + + caps = await client.get("/capabilities") + body = caps.json() + assert body["phase"] == "11.10" + assert body["features"]["forms"] is True + assert body["features"]["form_submissions"] is True + assert body["features"]["surveys"] is True + assert body["features"]["survey_responses"] is True + assert body["features"]["appointment_pages"] is True + assert body["features"]["appointment_booking_refs"] is True + assert body["features"]["seo_engine"] is True + assert body["features"]["media_refs"] is True + + +@pytest.mark.asyncio +async def test_form_and_submission_lifecycle(client): + ws = await _workspace(client) + workspace_id = ws["id"] + + form = await client.post( + "/api/v1/forms", + json={ + "workspace_id": workspace_id, + "code": "contact", + "name": "Contact Form", + "status": "draft", + "form_schema_json": {"fields": [{"key": "email", "type": "email"}]}, + }, + headers=tenant_headers(TENANT_A), + ) + assert form.status_code == 201, form.text + form_id = form.json()["id"] + + # Draft form rejects submissions + rejected = await client.post( + "/api/v1/form-submissions", + json={ + "workspace_id": workspace_id, + "form_id": form_id, + "payload_json": {"email": "a@b.com"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert rejected.status_code == 409, rejected.text + + activated = await client.patch( + f"/api/v1/forms/{form_id}", + json={"status": "active", "version": form.json()["version"]}, + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 200, activated.text + + # Reject CRM ownership keys in payload + bad = await client.post( + "/api/v1/form-submissions", + json={ + "workspace_id": workspace_id, + "form_id": form_id, + "payload_json": {"crm_contact_id": "123"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 422, bad.text + + submission = await client.post( + "/api/v1/form-submissions", + json={ + "workspace_id": workspace_id, + "form_id": form_id, + "payload_json": {"email": "a@b.com"}, + "crm_contact_ref": "crm://contact-1", + }, + headers=tenant_headers(TENANT_A), + ) + assert submission.status_code == 201, submission.text + assert submission.json()["status"] == "received" + assert submission.json()["crm_contact_ref"] == "crm://contact-1" + + other = await client.get( + f"/api/v1/form-submissions/{submission.json()['id']}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.FORM_CREATED.value in events + assert ExperienceEventType.FORM_SUBMISSION_RECEIVED.value in events + + +@pytest.mark.asyncio +async def test_survey_and_appointment_booking_refs(client): + ws = await _workspace(client, code="ws-appt") + workspace_id = ws["id"] + site = await _site(client, workspace_id) + + survey = await client.post( + "/api/v1/surveys", + json={ + "workspace_id": workspace_id, + "code": "nps", + "name": "NPS", + "status": "active", + "questions_json": {"items": [{"key": "score", "type": "number"}]}, + }, + headers=tenant_headers(TENANT_A), + ) + assert survey.status_code == 201, survey.text + + response = await client.post( + "/api/v1/survey-responses", + json={ + "workspace_id": workspace_id, + "survey_id": survey.json()["id"], + "answers_json": {"score": 9}, + }, + headers=tenant_headers(TENANT_A), + ) + assert response.status_code == 201, response.text + + page = await client.post( + "/api/v1/appointment-pages", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "code": "book", + "name": "Book Visit", + "status": "active", + "booking_provider_ref": "beauty://provider-1", + "service_ref": "beauty://service-cut", + }, + headers=tenant_headers(TENANT_A), + ) + assert page.status_code == 201, page.text + page_id = page.json()["id"] + + # Ownership keys rejected + bad_page = await client.patch( + f"/api/v1/appointment-pages/{page_id}", + json={ + "availability_shell_json": {"booking_engine": True}, + "version": page.json()["version"], + }, + headers=tenant_headers(TENANT_A), + ) + assert bad_page.status_code == 422, bad_page.text + + booking = await client.post( + "/api/v1/appointment-booking-refs", + json={ + "workspace_id": workspace_id, + "appointment_page_id": page_id, + "external_booking_ref": "beauty://booking-99", + "crm_contact_ref": "crm://c-2", + "status": "pending", + }, + headers=tenant_headers(TENANT_A), + ) + assert booking.status_code == 201, booking.text + assert booking.json()["external_booking_ref"] == "beauty://booking-99" + + confirmed = await client.patch( + f"/api/v1/appointment-booking-refs/{booking.json()['id']}", + json={"status": "confirmed", "version": booking.json()["version"]}, + headers=tenant_headers(TENANT_A), + ) + assert confirmed.status_code == 200, confirmed.text + assert confirmed.json()["status"] == "confirmed" + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.SURVEY_CREATED.value in events + assert ExperienceEventType.APPOINTMENT_BOOKING_REF_CREATED.value in events diff --git a/backend/services/experience/app/tests/test_integrations.py b/backend/services/experience/app/tests/test_integrations.py new file mode 100644 index 0000000..55845f8 --- /dev/null +++ b/backend/services/experience/app/tests/test_integrations.py @@ -0,0 +1,226 @@ +"""Consumer connectors, widgets & notify dispatch API tests — Phase 11.9.""" +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="integrations"): + response = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Integrations", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _site(client, workspace_id, code="main-site"): + response = await client.post( + "/api/v1/sites", + json={ + "workspace_id": workspace_id, + "code": code, + "name": "Main Site", + "default_locale": "fa", + "default_direction": "rtl", + }, + headers=tenant_headers(TENANT_A), + ) + assert response.status_code == 201, response.text + return response.json() + + +@pytest.mark.asyncio +async def test_connector_widget_notify_happy_path(client): + caps = (await client.get("/capabilities")).json() + assert caps["phase"] == "11.10" + assert caps["features"]["widgets"] is True + assert caps["features"]["consumer_connectors"] is True + + ws = await _workspace(client) + site = await _site(client, ws["id"]) + headers = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/consumer-connector-registrations", + json={ + "workspace_id": ws["id"], + "site_id": site["id"], + "kind": "hospitality", + "code": "hosp-1", + "name": "Hospitality Connector", + "status": "active", + }, + headers=headers, + ) + assert registration.status_code == 201, registration.text + reg_id = registration.json()["id"] + + dispatch = await client.post( + "/api/v1/consumer-connector-dispatches", + json={ + "registration_id": reg_id, + "workspace_id": ws["id"], + "site_id": site["id"], + "direction": "outbound", + "event_type": "menu.sync", + "payload_ref": "menu:123", + }, + headers=headers, + ) + assert dispatch.status_code == 201, dispatch.text + executed = await client.post( + f"/api/v1/consumer-connector-dispatches/{dispatch.json()['id']}/execute", + headers=headers, + ) + assert executed.status_code == 200, executed.text + assert executed.json()["status"] == "acknowledged" + assert executed.json()["response_json"]["provider"] == "mock_hospitality" + + definition = await client.post( + "/api/v1/widget-definitions", + json={ + "workspace_id": ws["id"], + "code": "menu-embed", + "widget_kind": "menu_embed", + "name": "Menu Embed", + "status": "available", + "connector_kind": "hospitality", + }, + headers=headers, + ) + assert definition.status_code == 201, definition.text + + instance = await client.post( + "/api/v1/widget-instances", + json={ + "workspace_id": ws["id"], + "site_id": site["id"], + "widget_definition_id": definition.json()["id"], + "widget_kind": "menu_embed", + "code": "menu-1", + "name": "Menu Widget", + "status": "active", + "connector_registration_id": reg_id, + "embed_ref": "embed:menu:main", + }, + headers=headers, + ) + assert instance.status_code == 201, instance.text + embed = await client.get( + "/api/v1/widget-instances/by-embed/embed:menu:main", + headers=headers, + ) + assert embed.status_code == 200, embed.text + assert embed.json()["code"] == "menu-1" + + notify = await client.post( + "/api/v1/notify-dispatches", + json={ + "workspace_id": ws["id"], + "site_id": site["id"], + "widget_instance_id": instance.json()["id"], + "channel": "sms", + "template_key": "widget.notify", + "to_ref": "user:1", + "payload_json": {"message": "hello"}, + }, + headers=headers, + ) + assert notify.status_code == 201, notify.text + sent = await client.post( + f"/api/v1/notify-dispatches/{notify.json()['id']}/send", + headers=headers, + ) + assert sent.status_code == 200, sent.text + body = sent.json() + assert body["status"] == "sent" + assert body["communication_request_ref"].startswith("communication:request:") + + +@pytest.mark.asyncio +async def test_tenant_isolation(client): + ws = await _workspace(client, "iso") + site = await _site(client, ws["id"], "iso-site") + headers = tenant_headers(TENANT_A) + + registration = await client.post( + "/api/v1/consumer-connector-registrations", + json={ + "workspace_id": ws["id"], + "kind": "crm", + "code": "crm-iso", + "name": "CRM", + "status": "active", + }, + headers=headers, + ) + assert registration.status_code == 201, registration.text + reg_id = registration.json()["id"] + + other = await client.get( + f"/api/v1/consumer-connector-registrations/{reg_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_rejects_forbidden_ownership_keys(client): + ws = await _workspace(client, "forbidden") + headers = tenant_headers(TENANT_A) + + rejected_config = await client.post( + "/api/v1/consumer-connector-registrations", + json={ + "workspace_id": ws["id"], + "kind": "communication", + "code": "bad-config", + "name": "Bad", + "config_json": {"sms_provider": "local"}, + }, + headers=headers, + ) + assert rejected_config.status_code == 422 + + rejected_meta = await client.post( + "/api/v1/widget-definitions", + json={ + "code": "bad-meta", + "widget_kind": "custom", + "name": "Bad Meta", + "metadata_json": {"booking_engine": "local"}, + }, + headers=headers, + ) + assert rejected_meta.status_code == 422 + + registration = await client.post( + "/api/v1/consumer-connector-registrations", + json={ + "workspace_id": ws["id"], + "kind": "marketplace", + "code": "inactive", + "name": "Inactive", + "status": "draft", + }, + headers=headers, + ) + assert registration.status_code == 201, registration.text + dispatch = await client.post( + "/api/v1/consumer-connector-dispatches", + json={ + "registration_id": registration.json()["id"], + "workspace_id": ws["id"], + "direction": "outbound", + "event_type": "catalog.sync", + }, + headers=headers, + ) + assert dispatch.status_code == 201, dispatch.text + executed = await client.post( + f"/api/v1/consumer-connector-dispatches/{dispatch.json()['id']}/execute", + headers=headers, + ) + assert executed.status_code == 200, executed.text + assert executed.json()["status"] == "failed" diff --git a/backend/services/experience/app/tests/test_localization.py b/backend/services/experience/app/tests/test_localization.py new file mode 100644 index 0000000..b72644f --- /dev/null +++ b/backend/services/experience/app/tests/test_localization.py @@ -0,0 +1,259 @@ +"""Localization, RTL/LTR & media-ref API tests — Phase 11.5.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ws-loc"): + resp = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _site(client, workspace_id: str, code="main"): + resp = await client.post( + "/api/v1/sites", + json={"workspace_id": workspace_id, "code": code, "name": "Main Site"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _locale_profile(client, workspace_id: str, *, code="fa", language="fa", direction="rtl"): + resp = await client.post( + "/api/v1/locale-profiles", + json={ + "workspace_id": workspace_id, + "code": code, + "name": f"Locale {code}", + "language": language, + "text_direction": direction, + "status": "active", + }, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_health_phase_115(client): + """Prior phase localization capabilities remain intact after 11.6.""" + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["version"].startswith("0.11.") + + caps = await client.get("/capabilities") + body = caps.json() + assert body["features"]["site_locale_bindings"] is True + assert body["features"]["localizations"] is True + assert body["features"]["rtl_ltr"] is True + assert body["features"]["media_refs"] is True + assert body["features"]["storage_media_refs_only"] is True + assert body["features"]["forms"] is True + assert body["features"]["surveys"] is True + assert body["features"]["templates"] is True + assert "rtl" in body["text_directions"] + assert "ltr" in body["text_directions"] + assert body["phase"] == "11.10" + assert body["version"].startswith("0.11.10") + + +@pytest.mark.asyncio +async def test_site_locale_binding_and_localization_lifecycle(client): + ws = await _workspace(client) + workspace_id = ws["id"] + site = await _site(client, workspace_id) + fa = await _locale_profile(client, workspace_id, code="fa", language="fa", direction="rtl") + en = await _locale_profile( + client, workspace_id, code="en", language="en", direction="ltr" + ) + + binding = await client.post( + "/api/v1/site-locale-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "locale_profile_id": fa["id"], + "is_default": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert binding.status_code == 201, binding.text + binding_body = binding.json() + assert binding_body["locale_code"] == "fa" + assert binding_body["text_direction"] == "rtl" + assert binding_body["is_default"] is True + + second = await client.post( + "/api/v1/site-locale-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "locale_profile_id": en["id"], + "text_direction": "ltr", + "is_default": True, + "sort_order": 1, + }, + headers=tenant_headers(TENANT_A), + ) + assert second.status_code == 201, second.text + assert second.json()["is_default"] is True + + # Previous default cleared + fa_after = await client.get( + f"/api/v1/site-locale-bindings/{binding_body['id']}", + headers=tenant_headers(TENANT_A), + ) + assert fa_after.json()["is_default"] is False + + loc = await client.post( + "/api/v1/localizations", + json={ + "workspace_id": workspace_id, + "target_type": "site", + "target_id": site["id"], + "locale": "fa", + "text_direction": "rtl", + "title": "سایت اصلی", + "summary": "خلاصه فارسی", + "content_json": {"hero": "سلام"}, + "status": "active", + }, + headers=tenant_headers(TENANT_A), + ) + assert loc.status_code == 201, loc.text + loc_id = loc.json()["id"] + assert loc.json()["text_direction"] == "rtl" + + upsert = await client.post( + "/api/v1/localizations", + json={ + "workspace_id": workspace_id, + "target_type": "site", + "target_id": site["id"], + "locale": "fa", + "text_direction": "rtl", + "title": "سایت به‌روز", + "status": "active", + "version": loc.json()["version"], + }, + headers=tenant_headers(TENANT_A), + ) + assert upsert.status_code == 201, upsert.text + assert upsert.json()["id"] == loc_id + assert upsert.json()["title"] == "سایت به‌روز" + assert upsert.json()["version"] == loc.json()["version"] + 1 + + listed = await client.get( + f"/api/v1/localizations?site_id={site['id']}&locale=fa&q=سایت", + headers=tenant_headers(TENANT_A), + ) + # filter uses target_id not site_id — use target filters + listed = await client.get( + f"/api/v1/localizations?target_type=site&target_id={site['id']}&q=سایت", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + + events = get_event_publisher().published + types = {e.event_type for e in events} + assert ExperienceEventType.SITE_LOCALE_ASSIGNED.value in types + assert ExperienceEventType.LOCALIZATION_UPSERTED.value in types + + +@pytest.mark.asyncio +async def test_media_ref_storage_only_and_rejects_binary(client): + ws = await _workspace(client, code="ws-media") + workspace_id = ws["id"] + site = await _site(client, workspace_id, code="media-site") + + created = await client.post( + "/api/v1/media-refs", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "kind": "image", + "storage_file_ref": "storage://experience/hero.png", + "alt_text": "Hero", + "locale": "fa", + "text_direction": "rtl", + }, + headers=tenant_headers(TENANT_A), + ) + assert created.status_code == 201, created.text + body = created.json() + assert body["storage_file_ref"] == "storage://experience/hero.png" + assert "binary" not in body + assert "blob" not in body + + rejected = await client.post( + "/api/v1/media-refs", + json={ + "workspace_id": workspace_id, + "kind": "image", + "storage_file_ref": "data:image/png;base64,AAAA", + }, + headers=tenant_headers(TENANT_A), + ) + assert rejected.status_code == 422, rejected.text + + rejected_meta = await client.post( + "/api/v1/media-refs", + json={ + "workspace_id": workspace_id, + "kind": "image", + "storage_file_ref": "storage://ok.png", + "metadata_json": {"base64": "AAAA"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert rejected_meta.status_code == 422, rejected_meta.text + + # Tenant isolation + other = await client.get( + f"/api/v1/media-refs/{body['id']}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + listed = await client.get( + f"/api/v1/media-refs?workspace_id={workspace_id}&kind=image", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + + events = get_event_publisher().published + assert any( + e.event_type == ExperienceEventType.MEDIA_REF_CREATED.value for e in events + ) + + +@pytest.mark.asyncio +async def test_media_ref_model_has_no_binary_columns(): + from app.models.localization import ExperienceMediaRef + + columns = set(ExperienceMediaRef.__table__.columns.keys()) + forbidden = { + "binary", + "blob", + "bytes", + "content_bytes", + "file_bytes", + "base64", + "data_uri", + "raw_content", + } + assert forbidden.isdisjoint(columns) + assert "storage_file_ref" in columns diff --git a/backend/services/experience/app/tests/test_migration.py b/backend/services/experience/app/tests/test_migration.py new file mode 100644 index 0000000..307729e --- /dev/null +++ b/backend/services/experience/app/tests/test_migration.py @@ -0,0 +1,76 @@ +"""Migration / metadata validation.""" +from pathlib import Path + +from alembic.config import Config +from alembic.script import ScriptDirectory + +from app.core.database import Base +import app.models # noqa: F401 + + +EXPECTED_TABLES = { + "experience_workspaces", + "experience_locale_profiles", + "experience_roles", + "experience_permissions", + "external_provider_configs", + "render_engine_registrations", + "experience_configurations", + "experience_settings", + "experience_audit_logs", + "experience_sites", + "experience_pages", + "experience_site_domains", + "experience_components", + "experience_component_versions", + "experience_page_component_placements", + "experience_themes", + "experience_theme_versions", + "experience_layouts", + "experience_layout_versions", + "experience_site_theme_bindings", + "experience_page_layout_bindings", + "experience_templates", + "experience_template_versions", + "experience_template_instantiations", + "experience_site_locale_bindings", + "experience_localizations", + "experience_media_refs", + "experience_forms", + "experience_form_submissions", + "experience_surveys", + "experience_survey_responses", + "experience_appointment_pages", + "experience_appointment_booking_refs", + "experience_publish_releases", + "experience_domain_edge_bindings", + "experience_seo_metadata", + "experience_sitemap_shells", + "experience_robots_shells", + "experience_pwa_manifests", + "experience_bundle_definitions", + "experience_tenant_bundles", + "experience_license_bindings", + "experience_feature_toggles", + "experience_consumer_connector_registrations", + "experience_consumer_connector_dispatches", + "experience_widget_definitions", + "experience_widget_instances", + "experience_notify_dispatches", + "experience_analytics_report_definitions", + "experience_analytics_snapshots", + "experience_ai_content_hook_registrations", + "experience_ai_content_dispatches", +} + + +def test_alembic_head_is_phase_1110(): + root = Path(__file__).resolve().parents[2] + cfg = Config(str(root / "alembic.ini")) + script = ScriptDirectory.from_config(cfg) + assert script.get_current_head() == "0011_phase_1110_analytics_ai_hooks" + + +def test_metadata_contains_foundation_tables(): + names = set(Base.metadata.tables.keys()) + assert EXPECTED_TABLES.issubset(names) diff --git a/backend/services/experience/app/tests/test_permissions.py b/backend/services/experience/app/tests/test_permissions.py new file mode 100644 index 0000000..3845c03 --- /dev/null +++ b/backend/services/experience/app/tests/test_permissions.py @@ -0,0 +1,145 @@ +"""Permission prefix validation.""" +from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES + + +def test_all_permissions_use_experience_prefix(): + assert ALL_PERMISSIONS + for perm in ALL_PERMISSIONS: + assert perm.startswith("experience."), perm + + +def test_prefixes_documented(): + assert "experience." in PERMISSION_PREFIXES + assert "experience.workspaces." in PERMISSION_PREFIXES + assert "experience.sites." in PERMISSION_PREFIXES + assert "experience.pages." in PERMISSION_PREFIXES + assert "experience.domains." in PERMISSION_PREFIXES + assert "experience.components." in PERMISSION_PREFIXES + assert "experience.themes." in PERMISSION_PREFIXES + assert "experience.layouts." in PERMISSION_PREFIXES + assert "experience.templates." in PERMISSION_PREFIXES + assert "experience.locales." in PERMISSION_PREFIXES + assert "experience.media_refs." in PERMISSION_PREFIXES + assert "experience.forms." in PERMISSION_PREFIXES + assert "experience.surveys." in PERMISSION_PREFIXES + assert "experience.appointments." in PERMISSION_PREFIXES + assert "experience.bundles." in PERMISSION_PREFIXES + assert "experience.feature_toggles." in PERMISSION_PREFIXES + assert "experience.consumer_connectors." in PERMISSION_PREFIXES + assert "experience.widgets." in PERMISSION_PREFIXES + assert "experience.notify." in PERMISSION_PREFIXES + assert "experience.analytics." in PERMISSION_PREFIXES + assert "experience.ai_hooks." in PERMISSION_PREFIXES + + +def test_template_permission_crud_tree(): + from app.permissions.definitions import ( + TEMPLATES_CREATE, + TEMPLATES_DELETE, + TEMPLATES_MANAGE, + TEMPLATES_UPDATE, + TEMPLATES_VIEW, + ) + + for perm in ( + TEMPLATES_VIEW, + TEMPLATES_CREATE, + TEMPLATES_UPDATE, + TEMPLATES_DELETE, + TEMPLATES_MANAGE, + ): + assert perm in ALL_PERMISSIONS + assert perm.startswith("experience.templates.") + + +def test_locales_and_media_permission_crud_trees(): + from app.permissions.definitions import ( + LOCALES_CREATE, + LOCALES_DELETE, + LOCALES_MANAGE, + LOCALES_UPDATE, + LOCALES_VIEW, + MEDIA_REFS_CREATE, + MEDIA_REFS_DELETE, + MEDIA_REFS_MANAGE, + MEDIA_REFS_UPDATE, + MEDIA_REFS_VIEW, + ) + + for perm in ( + LOCALES_VIEW, + LOCALES_CREATE, + LOCALES_UPDATE, + LOCALES_DELETE, + LOCALES_MANAGE, + ): + assert perm in ALL_PERMISSIONS + assert perm.startswith("experience.locales.") + + for perm in ( + MEDIA_REFS_VIEW, + MEDIA_REFS_CREATE, + MEDIA_REFS_UPDATE, + MEDIA_REFS_DELETE, + MEDIA_REFS_MANAGE, + ): + assert perm in ALL_PERMISSIONS + assert perm.startswith("experience.media_refs.") + + +def test_forms_surveys_appointments_permission_trees(): + from app.permissions.definitions import ( + APPOINTMENTS_CREATE, + APPOINTMENTS_MANAGE, + APPOINTMENTS_VIEW, + FORMS_CREATE, + FORMS_MANAGE, + FORMS_VIEW, + SURVEYS_CREATE, + SURVEYS_MANAGE, + SURVEYS_VIEW, + ) + + for perm in (FORMS_VIEW, FORMS_CREATE, FORMS_MANAGE): + assert perm in ALL_PERMISSIONS + assert perm.startswith("experience.forms.") + for perm in (SURVEYS_VIEW, SURVEYS_CREATE, SURVEYS_MANAGE): + assert perm in ALL_PERMISSIONS + assert perm.startswith("experience.surveys.") + for perm in (APPOINTMENTS_VIEW, APPOINTMENTS_CREATE, APPOINTMENTS_MANAGE): + assert perm in ALL_PERMISSIONS + assert perm.startswith("experience.appointments.") + + +def test_bundles_and_feature_toggle_permission_trees(): + from app.permissions.definitions import ( + BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_MANAGE, BUNDLES_UPDATE, BUNDLES_VIEW, + FEATURE_TOGGLES_CREATE, FEATURE_TOGGLES_DELETE, FEATURE_TOGGLES_MANAGE, + FEATURE_TOGGLES_UPDATE, FEATURE_TOGGLES_VIEW, + ) + for perm in (BUNDLES_VIEW, BUNDLES_CREATE, BUNDLES_UPDATE, BUNDLES_DELETE, BUNDLES_MANAGE): + assert perm in ALL_PERMISSIONS + for perm in (FEATURE_TOGGLES_VIEW, FEATURE_TOGGLES_CREATE, FEATURE_TOGGLES_UPDATE, + FEATURE_TOGGLES_DELETE, FEATURE_TOGGLES_MANAGE): + assert perm in ALL_PERMISSIONS + + +def test_integrations_permission_trees(): + from app.permissions.definitions import ( + CONSUMER_CONNECTORS_CREATE, + CONSUMER_CONNECTORS_MANAGE, + CONSUMER_CONNECTORS_VIEW, + NOTIFY_CREATE, + NOTIFY_MANAGE, + NOTIFY_VIEW, + WIDGETS_CREATE, + WIDGETS_MANAGE, + WIDGETS_VIEW, + ) + + for perm in (CONSUMER_CONNECTORS_VIEW, CONSUMER_CONNECTORS_CREATE, CONSUMER_CONNECTORS_MANAGE): + assert perm in ALL_PERMISSIONS + for perm in (WIDGETS_VIEW, WIDGETS_CREATE, WIDGETS_MANAGE): + assert perm in ALL_PERMISSIONS + for perm in (NOTIFY_VIEW, NOTIFY_CREATE, NOTIFY_MANAGE): + assert perm in ALL_PERMISSIONS diff --git a/backend/services/experience/app/tests/test_publishing.py b/backend/services/experience/app/tests/test_publishing.py new file mode 100644 index 0000000..b2588e8 --- /dev/null +++ b/backend/services/experience/app/tests/test_publishing.py @@ -0,0 +1,56 @@ +"""Publishing, SEO and PWA API tests — Phase 11.7.""" +import pytest + +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _foundation(client): + ws = await client.post("/api/v1/workspaces", json={"code": "publish", "name": "Publishing", "status": "active"}, headers=tenant_headers(TENANT_A)) + assert ws.status_code == 201, ws.text + site = await client.post("/api/v1/sites", json={"workspace_id": ws.json()["id"], "code": "main", "name": "Main"}, headers=tenant_headers(TENANT_A)) + assert site.status_code == 201, site.text + domain = await client.post("/api/v1/site-domains", json={"workspace_id": ws.json()["id"], "site_id": site.json()["id"], "host": "publish.example.test", "domain_kind": "custom"}, headers=tenant_headers(TENANT_A)) + assert domain.status_code == 201, domain.text + return ws.json(), site.json(), domain.json() + + +@pytest.mark.asyncio +async def test_phase_117_publishing_seo_pwa(client): + caps = (await client.get("/capabilities")).json() + assert caps["phase"] == "11.10" + assert caps["features"]["seo_engine"] is True + ws, site, domain = await _foundation(client) + headers = tenant_headers(TENANT_A) + + release = await client.post("/api/v1/publish-releases", json={ + "workspace_id": ws["id"], "target_type": "site", "target_id": site["id"], "metadata_json": {"channel": "web"} + }, headers=headers) + assert release.status_code == 201, release.text + assert release.json()["status"] == "published" + + edge = await client.post("/api/v1/domain-edge-bindings", json={ + "workspace_id": ws["id"], "site_id": site["id"], "site_domain_id": domain["id"], "dns_status_ref": "dns-1" + }, headers=headers) + assert edge.status_code == 201, edge.text + + requests = [ + ("/api/v1/seo-metadata", {"workspace_id": ws["id"], "target_type": "site", "target_id": site["id"], "title": "Main"}), + ("/api/v1/sitemap-shells", {"workspace_id": ws["id"], "site_id": site["id"], "entries_json": {"urls": []}}), + ("/api/v1/robots-shells", {"workspace_id": ws["id"], "site_id": site["id"], "content_text": "User-agent: *"}), + ("/api/v1/pwa-manifests", {"workspace_id": ws["id"], "site_id": site["id"], "name": "Main", "manifest_json": {"display": "standalone"}}), + ] + for path, payload in requests: + response = await client.post(path, json=payload, headers=headers) + assert response.status_code == 201, response.text + isolated = await client.get(f"{path}/{response.json()['id']}", headers=tenant_headers(TENANT_B)) + assert isolated.status_code == 404 + + +@pytest.mark.asyncio +async def test_rejects_edge_secrets_in_metadata(client): + ws, site, _domain = await _foundation(client) + response = await client.post("/api/v1/seo-metadata", json={ + "workspace_id": ws["id"], "target_type": "site", "target_id": site["id"], + "metadata_json": {"ssl_private_key": "-----BEGIN PRIVATE KEY-----"} + }, headers=tenant_headers(TENANT_A)) + assert response.status_code == 422 diff --git a/backend/services/experience/app/tests/test_security.py b/backend/services/experience/app/tests/test_security.py new file mode 100644 index 0000000..9a34f13 --- /dev/null +++ b/backend/services/experience/app/tests/test_security.py @@ -0,0 +1,25 @@ +"""Security validation — auth gate when AUTH_REQUIRED is true.""" +from __future__ import annotations + +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import settings +from app.main import app +from app.tests.conftest import TENANT_A, tenant_headers + + +@pytest.mark.asyncio +async def test_auth_required_rejects_anonymous(): + original = settings.auth_required + settings.auth_required = True + try: + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://testserver") as client: + resp = await client.get( + "/api/v1/workspaces", + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 401 + finally: + settings.auth_required = original diff --git a/backend/services/experience/app/tests/test_sites.py b/backend/services/experience/app/tests/test_sites.py new file mode 100644 index 0000000..ba75c6f --- /dev/null +++ b/backend/services/experience/app/tests/test_sites.py @@ -0,0 +1,169 @@ +"""Sites & pages API tests — Phase 11.1.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ws1"): + resp = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_health_phase_111(client): + resp = await client.get("/health") + assert resp.status_code == 200 + # Health version tracks current service version (forward-compatible). + assert resp.json()["version"].startswith("0.11.") + + caps = await client.get("/capabilities") + body = caps.json() + assert body["features"]["sites"] is True + assert body["features"]["pages"] is True + assert body["features"]["page_builder"] is False + assert "landing" in body["page_types"] + assert "digital_menu" in body["page_types"] + + +@pytest.mark.asyncio +async def test_sites_pages_domains_publish_flow(client): + ws = await _workspace(client) + workspace_id = ws["id"] + + site = await client.post( + "/api/v1/sites", + json={ + "workspace_id": workspace_id, + "code": "main", + "name": "Main Site", + "default_locale": "fa", + "default_direction": "rtl", + }, + headers=tenant_headers(TENANT_A), + ) + assert site.status_code == 201, site.text + site_id = site.json()["id"] + assert site.json()["publish_status"] == "draft" + + page = await client.post( + "/api/v1/pages", + json={ + "workspace_id": workspace_id, + "site_id": site_id, + "code": "home", + "slug": "home", + "title": "Home", + "page_type": "landing", + "is_home": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert page.status_code == 201, page.text + page_id = page.json()["id"] + assert page.json()["page_type"] == "landing" + + menu = await client.post( + "/api/v1/pages", + json={ + "workspace_id": workspace_id, + "site_id": site_id, + "code": "menu", + "slug": "menu", + "title": "Digital Menu", + "page_type": "digital_menu", + "external_refs": {"hospitality_menu_ref": "menu-1"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert menu.status_code == 201, menu.text + + domain = await client.post( + "/api/v1/site-domains", + json={ + "workspace_id": workspace_id, + "site_id": site_id, + "host": "pages.example.local", + "domain_kind": "subdomain", + "is_primary": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert domain.status_code == 201, domain.text + assert domain.json()["is_primary"] is True + + published_page = await client.post( + f"/api/v1/pages/{page_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert published_page.status_code == 200 + assert published_page.json()["publish_status"] == "published" + + published_site = await client.post( + f"/api/v1/sites/{site_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert published_site.status_code == 200 + assert published_site.json()["publish_status"] == "published" + assert published_site.json()["primary_domain"] == "pages.example.local" + + archived = await client.post( + f"/api/v1/sites/{site_id}/archive", + headers=tenant_headers(TENANT_A), + ) + assert archived.status_code == 200 + assert archived.json()["publish_status"] == "archived" + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.SITE_CREATED.value in events + assert ExperienceEventType.PAGE_CREATED.value in events + assert ExperienceEventType.PAGE_PUBLISHED.value in events + assert ExperienceEventType.SITE_PUBLISHED.value in events + assert ExperienceEventType.SITE_DOMAIN_BOUND.value in events + assert ExperienceEventType.SITE_ARCHIVED.value in events + + +@pytest.mark.asyncio +async def test_site_tenant_isolation(client): + ws = await _workspace(client, code="iso") + site = await client.post( + "/api/v1/sites", + json={"workspace_id": ws["id"], "code": "s1", "name": "S1"}, + headers=tenant_headers(TENANT_A), + ) + assert site.status_code == 201 + site_id = site.json()["id"] + + other = await client.get( + f"/api/v1/sites/{site_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_archive_blocks_publish(client): + ws = await _workspace(client, code="arch") + site = await client.post( + "/api/v1/sites", + json={"workspace_id": ws["id"], "code": "s2", "name": "S2"}, + headers=tenant_headers(TENANT_A), + ) + site_id = site.json()["id"] + await client.post( + f"/api/v1/sites/{site_id}/archive", + headers=tenant_headers(TENANT_A), + ) + again = await client.post( + f"/api/v1/sites/{site_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert again.status_code == 409 diff --git a/backend/services/experience/app/tests/test_templates.py b/backend/services/experience/app/tests/test_templates.py new file mode 100644 index 0000000..153f8db --- /dev/null +++ b/backend/services/experience/app/tests/test_templates.py @@ -0,0 +1,247 @@ +"""Template system API tests — Phase 11.4.""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ws-tpl"): + resp = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _site(client, workspace_id: str, code="main"): + resp = await client.post( + "/api/v1/sites", + json={"workspace_id": workspace_id, "code": code, "name": "Main Site"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_health_phase_114(client): + """Prior phase capabilities remain intact after 11.5.""" + resp = await client.get("/health") + assert resp.status_code == 200 + assert resp.json()["version"].startswith("0.11.") + + caps = await client.get("/capabilities") + body = caps.json() + assert body["features"]["templates"] is True + assert body["features"]["template_versions"] is True + assert body["features"]["template_instantiation"] is True + assert body["features"]["forms"] is True + assert body["features"]["surveys"] is True + # Prior phase capabilities remain intact. + assert body["features"]["themes"] is True + assert body["features"]["layouts"] is True + # Current phase advances version/phase markers. + assert body["phase"] == "11.10" + assert body["version"].startswith("0.11.10") + +@pytest.mark.asyncio +async def test_template_full_lifecycle_and_instantiation(client): + ws = await _workspace(client) + workspace_id = ws["id"] + site = await _site(client, workspace_id) + + # --- Create template (draft) --- + template = await client.post( + "/api/v1/templates", + json={ + "workspace_id": workspace_id, + "code": "landing-starter", + "name": "Landing Starter", + "page_type": "landing", + "category": "starter", + "is_starter_pack": True, + }, + headers=tenant_headers(TENANT_A), + ) + assert template.status_code == 201, template.text + template_id = template.json()["id"] + assert template.json()["status"] == "draft" + assert template.json()["latest_version_number"] == 0 + + # --- Create version (draft) --- + version = await client.post( + "/api/v1/template-versions", + json={ + "workspace_id": workspace_id, + "template_id": template_id, + "blueprint_json": { + "page_type": "landing", + "summary": "Landing page from template", + "content_shell": {"hero": True}, + "placement_shells": [{"slot": "hero", "component_code": "hero-1"}], + }, + "changelog": "Initial version", + }, + headers=tenant_headers(TENANT_A), + ) + assert version.status_code == 201, version.text + version_id = version.json()["id"] + assert version.json()["version_number"] == 1 + assert version.json()["status"] == "draft" + assert version.json()["is_immutable"] is False + + # --- Publish version (template auto-activates) --- + published = await client.post( + f"/api/v1/template-versions/{version_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert published.status_code == 200, published.text + assert published.json()["status"] == "published" + assert published.json()["is_immutable"] is True + + template_after_publish = await client.get( + f"/api/v1/templates/{template_id}", headers=tenant_headers(TENANT_A) + ) + assert template_after_publish.json()["status"] == "active" + assert template_after_publish.json()["published_version_id"] == version_id + + # --- Explicit (idempotent) activation --- + activated = await client.post( + f"/api/v1/templates/{template_id}/activate", + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 200 + assert activated.json()["status"] == "active" + + # --- Instantiate template into a page --- + instantiated = await client.post( + "/api/v1/template-instantiations", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "template_id": template_id, + "page_code": "home", + "page_slug": "home", + "page_title": "Home", + }, + headers=tenant_headers(TENANT_A), + ) + assert instantiated.status_code == 201, instantiated.text + body = instantiated.json() + assert body["page_id"] is not None + assert body["instantiation"]["status"] == "completed" + assert body["instantiation"]["template_id"] == template_id + assert body["instantiation"]["template_version_id"] == version_id + instantiation_id = body["instantiation"]["id"] + + page = await client.get( + f"/api/v1/pages/{body['page_id']}", headers=tenant_headers(TENANT_A) + ) + assert page.status_code == 200 + assert page.json()["code"] == "home" + assert page.json()["page_type"] == "landing" + + # --- List instantiations --- + listed = await client.get( + f"/api/v1/template-instantiations?template_id={template_id}", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + assert any( + item["id"] == instantiation_id for item in listed.json()["items"] + ) + + fetched = await client.get( + f"/api/v1/template-instantiations/{instantiation_id}", + headers=tenant_headers(TENANT_A), + ) + assert fetched.status_code == 200 + assert fetched.json()["status"] == "completed" + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.TEMPLATE_CREATED.value in events + assert ExperienceEventType.TEMPLATE_VERSION_CREATED.value in events + assert ExperienceEventType.TEMPLATE_VERSION_PUBLISHED.value in events + assert ExperienceEventType.TEMPLATE_ACTIVATED.value in events + assert ExperienceEventType.TEMPLATE_INSTANTIATED.value in events + assert ExperienceEventType.PAGE_CREATED.value in events + + +@pytest.mark.asyncio +async def test_template_tenant_isolation(client): + ws = await _workspace(client, code="ws-tpl-iso") + template = await client.post( + "/api/v1/templates", + json={ + "workspace_id": ws["id"], + "code": "iso-tpl", + "name": "Iso Template", + "page_type": "landing", + }, + headers=tenant_headers(TENANT_A), + ) + assert template.status_code == 201, template.text + template_id = template.json()["id"] + + other = await client.get( + f"/api/v1/templates/{template_id}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + missing = await client.get("/api/v1/templates") + assert missing.status_code in (400, 422) + + +@pytest.mark.asyncio +async def test_immutable_published_version_update_blocked(client): + ws = await _workspace(client, code="ws-tpl-lock") + workspace_id = ws["id"] + + template = await client.post( + "/api/v1/templates", + json={ + "workspace_id": workspace_id, + "code": "lock-tpl", + "name": "Lock Template", + "page_type": "landing", + }, + headers=tenant_headers(TENANT_A), + ) + template_id = template.json()["id"] + + version = await client.post( + "/api/v1/template-versions", + json={ + "workspace_id": workspace_id, + "template_id": template_id, + "blueprint_json": {"page_type": "landing"}, + }, + headers=tenant_headers(TENANT_A), + ) + version_id = version.json()["id"] + version_number = version.json()["version"] + + await client.post( + f"/api/v1/template-versions/{version_id}/publish", + headers=tenant_headers(TENANT_A), + ) + + blocked = await client.patch( + f"/api/v1/template-versions/{version_id}", + json={"changelog": "should not work", "version": version_number + 1}, + headers=tenant_headers(TENANT_A), + ) + assert blocked.status_code == 409 + + unchanged = await client.get( + f"/api/v1/template-versions/{version_id}", headers=tenant_headers(TENANT_A) + ) + assert unchanged.json()["changelog"] != "should not work" + assert unchanged.json()["is_immutable"] is True diff --git a/backend/services/experience/app/tests/test_theme_policies.py b/backend/services/experience/app/tests/test_theme_policies.py new file mode 100644 index 0000000..b1126e7 --- /dev/null +++ b/backend/services/experience/app/tests/test_theme_policies.py @@ -0,0 +1,142 @@ +"""Unit tests for theme/layout policies — Phase 11.3 (versioned).""" +from __future__ import annotations + +import pytest + +from app.models.types import ( + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) +from app.policies.themes import ( + LayoutLifecyclePolicy, + LayoutVersionPolicy, + PageLayoutBindingPolicy, + SiteThemeBindingPolicy, + ThemeLifecyclePolicy, + ThemeVersionPolicy, +) +from shared.exceptions import AppError + + +def test_theme_lifecycle_transitions(): + policy = ThemeLifecyclePolicy() + assert ( + policy.assert_status_change( + current=ThemeStatus.DRAFT, target=ThemeStatus.ACTIVE + ) + == ThemeStatus.ACTIVE + ) + with pytest.raises(AppError): + policy.assert_status_change( + current=ThemeStatus.ARCHIVED, target=ThemeStatus.ACTIVE + ) + + +def test_layout_lifecycle_transitions(): + policy = LayoutLifecyclePolicy() + assert ( + policy.assert_status_change( + current=LayoutStatus.ACTIVE, target=LayoutStatus.DEPRECATED + ) + == LayoutStatus.DEPRECATED + ) + with pytest.raises(AppError): + policy.assert_status_change( + current=LayoutStatus.ARCHIVED, target=LayoutStatus.ACTIVE + ) + + +class _ThemeVersion: + def __init__(self, status, is_deleted=False): + self.status = status + self.is_deleted = is_deleted + + +class _LayoutVersion: + def __init__(self, status, is_deleted=False): + self.status = status + self.is_deleted = is_deleted + + +def test_theme_version_policy_update_publish_retire(): + policy = ThemeVersionPolicy() + + policy.assert_can_update(_ThemeVersion(ThemeVersionStatus.DRAFT)) + with pytest.raises(AppError) as exc: + policy.assert_can_update(_ThemeVersion(ThemeVersionStatus.PUBLISHED)) + assert exc.value.error_code == "theme_version_not_mutable" + + policy.assert_publish(current=ThemeVersionStatus.DRAFT) + with pytest.raises(AppError): + policy.assert_publish(current=ThemeVersionStatus.RETIRED) + + policy.assert_retire(current=ThemeVersionStatus.PUBLISHED) + with pytest.raises(AppError): + policy.assert_retire(current=ThemeVersionStatus.DRAFT) + + +def test_layout_version_policy_update_publish_retire(): + policy = LayoutVersionPolicy() + + policy.assert_can_update(_LayoutVersion(LayoutVersionStatus.DRAFT)) + with pytest.raises(AppError) as exc: + policy.assert_can_update(_LayoutVersion(LayoutVersionStatus.RETIRED)) + assert exc.value.error_code == "layout_version_not_mutable" + + policy.assert_publish(current=LayoutVersionStatus.DRAFT) + with pytest.raises(AppError): + policy.assert_publish(current=LayoutVersionStatus.RETIRED) + + policy.assert_retire(current=LayoutVersionStatus.PUBLISHED) + with pytest.raises(AppError): + policy.assert_retire(current=LayoutVersionStatus.DRAFT) + + +def test_site_theme_binding_policy_assignable(): + policy = SiteThemeBindingPolicy() + + class _Theme: + status = ThemeStatus.DRAFT + is_deleted = False + + with pytest.raises(AppError) as exc: + policy.assert_theme_assignable(_Theme()) + assert exc.value.error_code == "theme_not_active" + + class _ActiveTheme: + status = ThemeStatus.ACTIVE + is_deleted = False + + policy.assert_theme_assignable(_ActiveTheme()) + + with pytest.raises(AppError) as exc: + policy.assert_version_assignable(_ThemeVersion(ThemeVersionStatus.DRAFT)) + assert exc.value.error_code == "theme_version_not_published" + + policy.assert_version_assignable(_ThemeVersion(ThemeVersionStatus.PUBLISHED)) + + +def test_page_layout_binding_policy_assignable(): + policy = PageLayoutBindingPolicy() + + class _Layout: + status = LayoutStatus.DRAFT + is_deleted = False + + with pytest.raises(AppError) as exc: + policy.assert_layout_assignable(_Layout()) + assert exc.value.error_code == "layout_not_active" + + class _ActiveLayout: + status = LayoutStatus.ACTIVE + is_deleted = False + + policy.assert_layout_assignable(_ActiveLayout()) + + with pytest.raises(AppError) as exc: + policy.assert_version_assignable(_LayoutVersion(LayoutVersionStatus.DRAFT)) + assert exc.value.error_code == "layout_version_not_published" + + policy.assert_version_assignable(_LayoutVersion(LayoutVersionStatus.PUBLISHED)) diff --git a/backend/services/experience/app/tests/test_themes.py b/backend/services/experience/app/tests/test_themes.py new file mode 100644 index 0000000..ea4f361 --- /dev/null +++ b/backend/services/experience/app/tests/test_themes.py @@ -0,0 +1,374 @@ +"""Theme & layout API tests — Phase 11.3 (versioned themes/layouts).""" +from __future__ import annotations + +import pytest + +from app.events.publisher import get_event_publisher +from app.events.types import ExperienceEventType +from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers + + +async def _workspace(client, code="ws-theme"): + resp = await client.post( + "/api/v1/workspaces", + json={"code": code, "name": "Workspace", "status": "active"}, + headers=tenant_headers(TENANT_A), + ) + assert resp.status_code == 201, resp.text + return resp.json() + + +async def _site_page(client, workspace_id: str, code="main"): + site = await client.post( + "/api/v1/sites", + json={"workspace_id": workspace_id, "code": code, "name": "Main"}, + headers=tenant_headers(TENANT_A), + ) + assert site.status_code == 201, site.text + page = await client.post( + "/api/v1/pages", + json={ + "workspace_id": workspace_id, + "site_id": site.json()["id"], + "code": "home", + "slug": "home", + "title": "Home", + "page_type": "landing", + }, + headers=tenant_headers(TENANT_A), + ) + assert page.status_code == 201, page.text + return site.json(), page.json() + + +@pytest.mark.asyncio +async def test_health_phase_113(client): + resp = await client.get("/health") + assert resp.status_code == 200 + # Health version tracks current service version (forward-compatible). + assert resp.json()["version"].startswith("0.11.") + + caps = await client.get("/capabilities") + body = caps.json() + # Phase 11.3 (themes/layouts) capabilities remain true regardless of the + # currently active phase (forward-compatible with later phases). + assert body["features"]["themes"] is True + assert body["features"]["theme_versions"] is True + assert body["features"]["layouts"] is True + assert body["features"]["layout_versions"] is True + assert body["features"]["directionality_ready_shells"] is True + assert "both" in body["direction_modes"] + + +@pytest.mark.asyncio +async def test_theme_layout_versioned_flow(client): + ws = await _workspace(client) + workspace_id = ws["id"] + site, page = await _site_page(client, workspace_id) + + # --- Theme catalog entry (draft) --- + theme = await client.post( + "/api/v1/themes", + json={ + "workspace_id": workspace_id, + "code": "brand-a", + "name": "Brand A", + "direction_mode": "rtl", + }, + headers=tenant_headers(TENANT_A), + ) + assert theme.status_code == 201, theme.text + theme_id = theme.json()["id"] + assert theme.json()["status"] == "draft" + + # --- Theme version (draft) with tokens --- + version = await client.post( + "/api/v1/theme-versions", + json={ + "workspace_id": workspace_id, + "theme_id": theme_id, + "tokens": { + "--color-primary": "#0d6efd", + "--font-body": "Vazirmatn", + }, + "brand_shell": {"logo_slot": "header.logo"}, + "direction_mode": "rtl", + }, + headers=tenant_headers(TENANT_A), + ) + assert version.status_code == 201, version.text + version_id = version.json()["id"] + assert version.json()["status"] == "draft" + assert version.json()["version_number"] == 1 + + # Cannot bind a site to a draft (unpublished) theme/version + blocked = await client.post( + "/api/v1/site-theme-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "theme_id": theme_id, + "theme_version_id": version_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert blocked.status_code == 409 + + # --- Publish theme version — theme auto-activates from draft --- + published = await client.post( + f"/api/v1/theme-versions/{version_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert published.status_code == 200, published.text + assert published.json()["status"] == "published" + assert published.json()["is_immutable"] is True + + theme_after_publish = await client.get( + f"/api/v1/themes/{theme_id}", headers=tenant_headers(TENANT_A) + ) + assert theme_after_publish.json()["status"] == "active" + assert theme_after_publish.json()["published_version_id"] == version_id + + # Idempotent explicit activation (already active) + activated = await client.post( + f"/api/v1/themes/{theme_id}/activate", + headers=tenant_headers(TENANT_A), + ) + assert activated.status_code == 200 + assert activated.json()["status"] == "active" + + # --- Bind theme version to site --- + binding = await client.post( + "/api/v1/site-theme-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "theme_id": theme_id, + "theme_version_id": version_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert binding.status_code == 201, binding.text + binding_id = binding.json()["id"] + assert binding.json()["is_active"] is True + + # --- Layout catalog entry + version --- + layout = await client.post( + "/api/v1/layouts", + json={ + "workspace_id": workspace_id, + "code": "landing-shell", + "name": "Landing Shell", + "direction_mode": "both", + "kind": "page_shell", + }, + headers=tenant_headers(TENANT_A), + ) + assert layout.status_code == 201, layout.text + layout_id = layout.json()["id"] + + layout_version = await client.post( + "/api/v1/layout-versions", + json={ + "workspace_id": workspace_id, + "layout_id": layout_id, + "regions": [ + {"key": "header", "role": "header"}, + {"key": "main", "role": "main"}, + {"key": "footer", "role": "footer"}, + ], + "structure_shell": {"grid": "single-column"}, + }, + headers=tenant_headers(TENANT_A), + ) + assert layout_version.status_code == 201, layout_version.text + layout_version_id = layout_version.json()["id"] + + layout_version_pub = await client.post( + f"/api/v1/layout-versions/{layout_version_id}/publish", + headers=tenant_headers(TENANT_A), + ) + assert layout_version_pub.status_code == 200, layout_version_pub.text + + listed = await client.get( + f"/api/v1/layouts?workspace_id={workspace_id}&q=landing&sort_by=name", + headers=tenant_headers(TENANT_A), + ) + assert listed.status_code == 200 + assert listed.json()["total"] >= 1 + + # --- Bind layout version to page --- + page_binding = await client.post( + "/api/v1/page-layout-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "page_id": page["id"], + "layout_id": layout_id, + "layout_version_id": layout_version_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert page_binding.status_code == 201, page_binding.text + page_binding_id = page_binding.json()["id"] + + # Soft-delete blocked while theme/version is actively bound + del_theme = await client.post( + f"/api/v1/themes/{theme_id}/delete", + headers=tenant_headers(TENANT_A), + ) + assert del_theme.status_code == 409 + + retire_blocked = await client.post( + f"/api/v1/theme-versions/{version_id}/retire", + headers=tenant_headers(TENANT_A), + ) + assert retire_blocked.status_code == 409 + + # --- Unassign bindings --- + unassigned_theme = await client.post( + f"/api/v1/site-theme-bindings/{binding_id}/unassign", + headers=tenant_headers(TENANT_A), + ) + assert unassigned_theme.status_code == 200 + assert unassigned_theme.json()["is_deleted"] is True + assert unassigned_theme.json()["is_active"] is False + + unassigned_layout = await client.post( + f"/api/v1/page-layout-bindings/{page_binding_id}/unassign", + headers=tenant_headers(TENANT_A), + ) + assert unassigned_layout.status_code == 200 + assert unassigned_layout.json()["is_active"] is False + + archived = await client.post( + f"/api/v1/themes/{theme_id}/archive", + headers=tenant_headers(TENANT_A), + ) + assert archived.status_code == 200 + assert archived.json()["status"] == "archived" + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.THEME_CREATED.value in events + assert ExperienceEventType.THEME_VERSION_CREATED.value in events + assert ExperienceEventType.THEME_VERSION_PUBLISHED.value in events + assert ExperienceEventType.THEME_ACTIVATED.value in events + assert ExperienceEventType.SITE_THEME_ASSIGNED.value in events + assert ExperienceEventType.SITE_THEME_UNASSIGNED.value in events + assert ExperienceEventType.LAYOUT_CREATED.value in events + assert ExperienceEventType.LAYOUT_VERSION_CREATED.value in events + assert ExperienceEventType.LAYOUT_VERSION_PUBLISHED.value in events + assert ExperienceEventType.PAGE_LAYOUT_ASSIGNED.value in events + assert ExperienceEventType.PAGE_LAYOUT_UNASSIGNED.value in events + assert ExperienceEventType.THEME_ARCHIVED.value in events + + +@pytest.mark.asyncio +async def test_site_theme_binding_replaces_previous(client): + ws = await _workspace(client, code="ws-replace") + workspace_id = ws["id"] + site, _page = await _site_page(client, workspace_id, code="replace-site") + + theme = await client.post( + "/api/v1/themes", + json={"workspace_id": workspace_id, "code": "t1", "name": "T1"}, + headers=tenant_headers(TENANT_A), + ) + theme_id = theme.json()["id"] + + v1 = await client.post( + "/api/v1/theme-versions", + json={ + "workspace_id": workspace_id, + "theme_id": theme_id, + "tokens": {"--color": "#111"}, + }, + headers=tenant_headers(TENANT_A), + ) + v1_id = v1.json()["id"] + await client.post( + f"/api/v1/theme-versions/{v1_id}/publish", headers=tenant_headers(TENANT_A) + ) + + b1 = await client.post( + "/api/v1/site-theme-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "theme_id": theme_id, + "theme_version_id": v1_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert b1.status_code == 201 + + v2 = await client.post( + "/api/v1/theme-versions", + json={ + "workspace_id": workspace_id, + "theme_id": theme_id, + "tokens": {"--color": "#222"}, + }, + headers=tenant_headers(TENANT_A), + ) + v2_id = v2.json()["id"] + await client.post( + f"/api/v1/theme-versions/{v2_id}/publish", headers=tenant_headers(TENANT_A) + ) + + b2 = await client.post( + "/api/v1/site-theme-bindings", + json={ + "workspace_id": workspace_id, + "site_id": site["id"], + "theme_id": theme_id, + "theme_version_id": v2_id, + }, + headers=tenant_headers(TENANT_A), + ) + assert b2.status_code == 201 + + events = {e.event_type for e in get_event_publisher().published} + assert ExperienceEventType.SITE_THEME_REPLACED.value in events + + +@pytest.mark.asyncio +async def test_theme_tenant_isolation(client): + ws = await _workspace(client, code="iso-t") + theme = await client.post( + "/api/v1/themes", + json={ + "workspace_id": ws["id"], + "code": "iso", + "name": "Iso", + "direction_mode": "ltr", + }, + headers=tenant_headers(TENANT_A), + ) + assert theme.status_code == 201 + other = await client.get( + f"/api/v1/themes/{theme.json()['id']}", + headers=tenant_headers(TENANT_B), + ) + assert other.status_code == 404 + + +@pytest.mark.asyncio +async def test_theme_optimistic_lock(client): + ws = await _workspace(client, code="lock-t") + theme = await client.post( + "/api/v1/themes", + json={ + "workspace_id": ws["id"], + "code": "lock", + "name": "Lock", + }, + headers=tenant_headers(TENANT_A), + ) + body = theme.json() + bad = await client.patch( + f"/api/v1/themes/{body['id']}", + json={"name": "Nope", "version": body["version"] + 9}, + headers=tenant_headers(TENANT_A), + ) + assert bad.status_code == 409 diff --git a/backend/services/experience/app/validators/__init__.py b/backend/services/experience/app/validators/__init__.py new file mode 100644 index 0000000..0709660 --- /dev/null +++ b/backend/services/experience/app/validators/__init__.py @@ -0,0 +1,98 @@ +"""Experience validators package.""" +from app.validators.components import ( + ensure_component_status_transition, + ensure_schema_payload, + ensure_version_mutable, + ensure_version_status_transition, + validate_component_kind, + validate_slot_key, +) +from app.validators.foundation import ( + ensure_optimistic_version, + validate_code, + validate_currency_code, + validate_non_empty, +) +from app.validators.themes import ( + ensure_assignable_layout, + ensure_assignable_layout_version, + ensure_assignable_theme, + ensure_assignable_theme_version, + ensure_json_object, + ensure_json_structure, + ensure_layout_status_transition, + ensure_layout_version_mutable, + ensure_layout_version_status_transition, + ensure_regions_payload, + ensure_shell_payload, + ensure_theme_status_transition, + ensure_theme_version_mutable, + ensure_theme_version_status_transition, + ensure_tokens_payload, + validate_direction, + validate_direction_mode, + validate_layout_kind, +) +from app.validators.templates import ( + ensure_blueprint_json, + ensure_template_status_transition, + ensure_template_version_mutable, + ensure_template_version_status_transition, + validate_page_type, +) +from app.validators.localization import ( + ensure_content_json, + ensure_localization_status_transition, + ensure_media_ref_status_transition, + ensure_no_binary_media_payload, + validate_locale_code, + validate_localization_target, + validate_media_kind, + validate_storage_file_ref, + validate_text_direction, +) + +__all__ = [ + "validate_non_empty", + "validate_code", + "validate_currency_code", + "ensure_optimistic_version", + "validate_slot_key", + "validate_component_kind", + "ensure_component_status_transition", + "ensure_version_status_transition", + "ensure_version_mutable", + "ensure_schema_payload", + "validate_direction", + "validate_direction_mode", + "validate_layout_kind", + "ensure_theme_status_transition", + "ensure_layout_status_transition", + "ensure_theme_version_status_transition", + "ensure_layout_version_status_transition", + "ensure_theme_version_mutable", + "ensure_layout_version_mutable", + "ensure_json_object", + "ensure_json_structure", + "ensure_tokens_payload", + "ensure_shell_payload", + "ensure_regions_payload", + "ensure_assignable_theme", + "ensure_assignable_layout", + "ensure_assignable_theme_version", + "ensure_assignable_layout_version", + "validate_page_type", + "ensure_template_status_transition", + "ensure_template_version_status_transition", + "ensure_template_version_mutable", + "ensure_blueprint_json", + "validate_locale_code", + "validate_text_direction", + "validate_localization_target", + "validate_media_kind", + "validate_storage_file_ref", + "ensure_no_binary_media_payload", + "ensure_localization_status_transition", + "ensure_media_ref_status_transition", + "ensure_content_json", +] diff --git a/backend/services/experience/app/validators/ai_hooks.py b/backend/services/experience/app/validators/ai_hooks.py new file mode 100644 index 0000000..8564c9a --- /dev/null +++ b/backend/services/experience/app/validators/ai_hooks.py @@ -0,0 +1,48 @@ +"""AI content hook boundary validation — Phase 11.10.""" +from typing import Any + +from app.validators.integrations import FORBIDDEN_OWNERSHIP_KEYS, validate_json_object + +AI_FORBIDDEN_OWNERSHIP_KEYS = FORBIDDEN_OWNERSHIP_KEYS | frozenset( + { + "model_id", + "model_weights", + "inference_engine", + "llm_provider", + "openai_api_key", + } +) + + +def _reject_forbidden(value: dict, field: str) -> None: + from app.validators.foundation import ValidationError + + banned = AI_FORBIDDEN_OWNERSHIP_KEYS.intersection(value.keys()) + if banned: + raise ValidationError( + "کلیدهای مالکیت سرویس‌های دیگر در payload مجاز نیست", + {"field": field, "forbidden_keys": sorted(banned)}, + ) + + +def validate_json_object(value: Any, field: str) -> dict | None: + if value is None: + return None + if not isinstance(value, dict): + from app.validators.foundation import ValidationError + + raise ValidationError(f"{field} باید object باشد", {"field": field}) + _reject_forbidden(value, field) + return value + + +def validate_metadata(value: Any) -> dict | None: + return validate_json_object(value, "metadata_json") + + +def validate_config(value: Any) -> dict | None: + return validate_json_object(value, "config_json") + + +def validate_payload(value: Any) -> dict | None: + return validate_json_object(value, "payload_json") diff --git a/backend/services/experience/app/validators/analytics.py b/backend/services/experience/app/validators/analytics.py new file mode 100644 index 0000000..d9223f3 --- /dev/null +++ b/backend/services/experience/app/validators/analytics.py @@ -0,0 +1,20 @@ +"""Analytics boundary validation — Phase 11.10.""" +from typing import Any + +from app.validators.foundation import ValidationError +from app.validators.integrations import validate_json_object + + +def validate_metadata(value: Any) -> dict | None: + return validate_json_object(value, "metadata_json") + + +def validate_metric_keys(metric_keys: list[str]) -> list[str]: + if not metric_keys: + raise ValidationError( + "حداقل یک metric_key الزامی است", + {"field": "metric_keys"}, + ) + if not isinstance(metric_keys, list): + raise ValidationError("metric_keys باید آرایه باشد", {"field": "metric_keys"}) + return metric_keys diff --git a/backend/services/experience/app/validators/bundles.py b/backend/services/experience/app/validators/bundles.py new file mode 100644 index 0000000..46c55e0 --- /dev/null +++ b/backend/services/experience/app/validators/bundles.py @@ -0,0 +1,46 @@ +"""Bundle and licensing boundary validation — Phase 11.8.""" +from typing import Any + +from app.validators.foundation import ValidationError, validate_non_empty +from shared.exceptions import AppError + +FORBIDDEN_OWNERSHIP_KEYS = frozenset( + {"plan_engine", "subscription_engine", "billing_ledger", "core_plan_id"} +) + + +def validate_metadata(value: Any) -> dict | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValidationError("metadata_json باید object باشد", {"field": "metadata_json"}) + banned = FORBIDDEN_OWNERSHIP_KEYS.intersection(value) + if banned: + raise ValidationError( + "مالکیت plan/subscription/billing متعلق به Core است", + {"field": "metadata_json", "forbidden_keys": sorted(banned)}, + ) + return value + + +def validate_entitlement_ref(value: str | None, *, required: bool = False) -> str | None: + if value is None: + if required: + raise ValidationError("core_entitlement_ref الزامی است") + return None + cleaned = validate_non_empty(value, "core_entitlement_ref") + if len(cleaned) > 255: + raise ValidationError("core_entitlement_ref بیش از حد طولانی است") + return cleaned + + +def ensure_transition(current, target, transitions: dict, label: str) -> None: + if current == target: + return + if target not in transitions.get(current, frozenset()): + raise AppError( + f"گذار وضعیت {label} مجاز نیست", + error_code=f"invalid_{label}_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) diff --git a/backend/services/experience/app/validators/components.py b/backend/services/experience/app/validators/components.py new file mode 100644 index 0000000..79fad29 --- /dev/null +++ b/backend/services/experience/app/validators/components.py @@ -0,0 +1,113 @@ +"""Component library validators — Phase 11.2.""" +from __future__ import annotations + +import re +from typing import Any + +from app.models.types import ( + ComponentKind, + ComponentStatus, + ComponentVersionStatus, +) +from app.validators.foundation import ValidationError, validate_non_empty +from shared.exceptions import AppError + +SLOT_KEY_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-.]{0,99}$") + +COMPONENT_STATUS_TRANSITIONS: dict[ComponentStatus, frozenset[ComponentStatus]] = { + ComponentStatus.DRAFT: frozenset( + {ComponentStatus.ACTIVE, ComponentStatus.ARCHIVED} + ), + ComponentStatus.ACTIVE: frozenset( + {ComponentStatus.DEPRECATED, ComponentStatus.ARCHIVED} + ), + ComponentStatus.DEPRECATED: frozenset( + {ComponentStatus.ACTIVE, ComponentStatus.ARCHIVED} + ), + ComponentStatus.ARCHIVED: frozenset(), +} + +VERSION_STATUS_TRANSITIONS: dict[ + ComponentVersionStatus, frozenset[ComponentVersionStatus] +] = { + ComponentVersionStatus.DRAFT: frozenset({ComponentVersionStatus.PUBLISHED}), + ComponentVersionStatus.PUBLISHED: frozenset({ComponentVersionStatus.RETIRED}), + ComponentVersionStatus.RETIRED: frozenset(), +} + + +def validate_slot_key(slot_key: str) -> str: + slot_key = validate_non_empty(slot_key, "slot_key") + if not SLOT_KEY_RE.match(slot_key): + raise ValidationError( + "slot_key نامعتبر است", + {"field": "slot_key", "pattern": SLOT_KEY_RE.pattern}, + ) + return slot_key + + +def validate_component_kind(kind: ComponentKind | str) -> ComponentKind: + if isinstance(kind, ComponentKind): + return kind + try: + return ComponentKind(kind) + except ValueError as exc: + raise ValidationError( + "kind نامعتبر است", + {"field": "kind", "allowed": [k.value for k in ComponentKind]}, + ) from exc + + +def ensure_component_status_transition( + current: ComponentStatus, target: ComponentStatus +) -> None: + if current == target: + return + allowed = COMPONENT_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت کامپوننت مجاز نیست", + error_code="invalid_component_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_version_status_transition( + current: ComponentVersionStatus, target: ComponentVersionStatus +) -> None: + if current == target: + return + allowed = VERSION_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت نسخه کامپوننت مجاز نیست", + error_code="invalid_component_version_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_version_mutable(version: Any) -> None: + if getattr(version, "is_immutable", False) or getattr( + version, "status", None + ) == ComponentVersionStatus.PUBLISHED: + raise AppError( + "نسخه منتشرشده قابل ویرایش نیست", + error_code="component_version_immutable", + status_code=409, + ) + if getattr(version, "status", None) == ComponentVersionStatus.RETIRED: + raise AppError( + "نسخه بازنشسته‌شده قابل ویرایش نیست", + error_code="component_version_retired", + status_code=409, + ) + + +def ensure_schema_payload(payload: dict | None, field: str) -> dict | None: + if payload is None: + return None + if not isinstance(payload, dict): + raise ValidationError(f"{field} باید شیء JSON باشد", {"field": field}) + return payload diff --git a/backend/services/experience/app/validators/forms.py b/backend/services/experience/app/validators/forms.py new file mode 100644 index 0000000..11aa7db --- /dev/null +++ b/backend/services/experience/app/validators/forms.py @@ -0,0 +1,209 @@ +"""Forms / surveys / appointments validators — Phase 11.6.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + AppointmentBookingStatus, + AppointmentPageStatus, + FormStatus, + FormSubmissionStatus, + SurveyResponseStatus, + SurveyStatus, +) +from app.validators.foundation import ValidationError, validate_non_empty +from shared.exceptions import AppError + +_FORBIDDEN_OWNERSHIP_KEYS = frozenset( + { + "crm_contact_id", + "crm_id", + "schedule_engine", + "calendar_engine", + "booking_engine", + "forms_engine", + "survey_engine", + } +) + +FORM_STATUS_TRANSITIONS: dict[FormStatus, frozenset[FormStatus]] = { + FormStatus.DRAFT: frozenset({FormStatus.ACTIVE, FormStatus.ARCHIVED}), + FormStatus.ACTIVE: frozenset({FormStatus.DRAFT, FormStatus.ARCHIVED}), + FormStatus.ARCHIVED: frozenset(), +} + +SURVEY_STATUS_TRANSITIONS: dict[SurveyStatus, frozenset[SurveyStatus]] = { + SurveyStatus.DRAFT: frozenset({SurveyStatus.ACTIVE, SurveyStatus.ARCHIVED}), + SurveyStatus.ACTIVE: frozenset({SurveyStatus.DRAFT, SurveyStatus.ARCHIVED}), + SurveyStatus.ARCHIVED: frozenset(), +} + +APPOINTMENT_PAGE_STATUS_TRANSITIONS: dict[ + AppointmentPageStatus, frozenset[AppointmentPageStatus] +] = { + AppointmentPageStatus.DRAFT: frozenset( + {AppointmentPageStatus.ACTIVE, AppointmentPageStatus.ARCHIVED} + ), + AppointmentPageStatus.ACTIVE: frozenset( + {AppointmentPageStatus.DRAFT, AppointmentPageStatus.ARCHIVED} + ), + AppointmentPageStatus.ARCHIVED: frozenset(), +} + +SUBMISSION_STATUS_TRANSITIONS: dict[ + FormSubmissionStatus, frozenset[FormSubmissionStatus] +] = { + FormSubmissionStatus.RECEIVED: frozenset( + { + FormSubmissionStatus.PROCESSED, + FormSubmissionStatus.FAILED, + FormSubmissionStatus.ARCHIVED, + } + ), + FormSubmissionStatus.PROCESSED: frozenset({FormSubmissionStatus.ARCHIVED}), + FormSubmissionStatus.FAILED: frozenset( + {FormSubmissionStatus.RECEIVED, FormSubmissionStatus.ARCHIVED} + ), + FormSubmissionStatus.ARCHIVED: frozenset(), +} + +RESPONSE_STATUS_TRANSITIONS: dict[ + SurveyResponseStatus, frozenset[SurveyResponseStatus] +] = { + SurveyResponseStatus.RECEIVED: frozenset( + { + SurveyResponseStatus.PROCESSED, + SurveyResponseStatus.FAILED, + SurveyResponseStatus.ARCHIVED, + } + ), + SurveyResponseStatus.PROCESSED: frozenset({SurveyResponseStatus.ARCHIVED}), + SurveyResponseStatus.FAILED: frozenset( + {SurveyResponseStatus.RECEIVED, SurveyResponseStatus.ARCHIVED} + ), + SurveyResponseStatus.ARCHIVED: frozenset(), +} + +BOOKING_STATUS_TRANSITIONS: dict[ + AppointmentBookingStatus, frozenset[AppointmentBookingStatus] +] = { + AppointmentBookingStatus.PENDING: frozenset( + { + AppointmentBookingStatus.CONFIRMED, + AppointmentBookingStatus.CANCELLED, + AppointmentBookingStatus.ARCHIVED, + } + ), + AppointmentBookingStatus.CONFIRMED: frozenset( + {AppointmentBookingStatus.CANCELLED, AppointmentBookingStatus.ARCHIVED} + ), + AppointmentBookingStatus.CANCELLED: frozenset({AppointmentBookingStatus.ARCHIVED}), + AppointmentBookingStatus.ARCHIVED: frozenset(), +} + + +def ensure_json_object(value: Any, field: str) -> dict | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValidationError(f"{field} باید object باشد", {"field": field}) + banned = _FORBIDDEN_OWNERSHIP_KEYS.intersection(value.keys()) + if banned: + raise ValidationError( + "کلیدهای مالکیت CRM/scheduling در payload مجاز نیست", + {"field": field, "forbidden_keys": sorted(banned)}, + ) + return value + + +def validate_external_ref(value: str | None, field: str) -> str | None: + if value is None: + return None + cleaned = validate_non_empty(value, field) + if len(cleaned) > 255: + raise ValidationError( + f"{field} بیش از حد طولانی است", {"field": field, "max_length": 255} + ) + return cleaned + + +def ensure_form_status_transition(current: FormStatus, target: FormStatus) -> None: + if current == target: + return + if target not in FORM_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت فرم مجاز نیست", + error_code="invalid_form_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_survey_status_transition( + current: SurveyStatus, target: SurveyStatus +) -> None: + if current == target: + return + if target not in SURVEY_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت نظرسنجی مجاز نیست", + error_code="invalid_survey_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_appointment_page_status_transition( + current: AppointmentPageStatus, target: AppointmentPageStatus +) -> None: + if current == target: + return + if target not in APPOINTMENT_PAGE_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت صفحه نوبت مجاز نیست", + error_code="invalid_appointment_page_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_submission_status_transition( + current: FormSubmissionStatus, target: FormSubmissionStatus +) -> None: + if current == target: + return + if target not in SUBMISSION_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت ارسال فرم مجاز نیست", + error_code="invalid_form_submission_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_response_status_transition( + current: SurveyResponseStatus, target: SurveyResponseStatus +) -> None: + if current == target: + return + if target not in RESPONSE_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت پاسخ نظرسنجی مجاز نیست", + error_code="invalid_survey_response_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_booking_status_transition( + current: AppointmentBookingStatus, target: AppointmentBookingStatus +) -> None: + if current == target: + return + if target not in BOOKING_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت رزرو مجاز نیست", + error_code="invalid_booking_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) diff --git a/backend/services/experience/app/validators/foundation.py b/backend/services/experience/app/validators/foundation.py new file mode 100644 index 0000000..35714d0 --- /dev/null +++ b/backend/services/experience/app/validators/foundation.py @@ -0,0 +1,55 @@ +"""Reusable validators for Experience foundation.""" +from __future__ import annotations + +import re +from typing import Any + +from shared.exceptions import AppError + +CODE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{1,49}$") + + +class ValidationError(AppError): + def __init__(self, message: str, details: dict[str, Any] | None = None): + super().__init__( + message=message, + error_code="validation_error", + status_code=422, + details=details or {}, + ) + + +def validate_non_empty(value: str | None, field: str) -> str: + if value is None or not str(value).strip(): + raise ValidationError(f"{field} الزامی است", {"field": field}) + return str(value).strip() + + +def validate_code(code: str) -> str: + code = validate_non_empty(code, "code") + if not CODE_RE.match(code): + raise ValidationError( + "کد نامعتبر است", + {"field": "code", "pattern": CODE_RE.pattern}, + ) + return code + + +def validate_currency_code(code: str) -> str: + code = validate_non_empty(code, "currency_code").upper() + if len(code) != 3: + raise ValidationError("currency_code باید ۳ حرفی باشد", {"field": "currency_code"}) + return code + + +def ensure_optimistic_version(entity: Any, expected: int) -> None: + current = getattr(entity, "version", None) + if current is None: + return + if current != expected: + raise AppError( + "نسخه موجودیت هم‌خوان نیست", + error_code="version_conflict", + status_code=409, + details={"expected": expected, "actual": current}, + ) diff --git a/backend/services/experience/app/validators/integrations.py b/backend/services/experience/app/validators/integrations.py new file mode 100644 index 0000000..9d1f6de --- /dev/null +++ b/backend/services/experience/app/validators/integrations.py @@ -0,0 +1,76 @@ +"""Consumer connector & widget boundary validation — Phase 11.9.""" +from typing import Any + +from app.validators.foundation import ValidationError +from shared.exceptions import AppError + +FORBIDDEN_OWNERSHIP_KEYS = frozenset( + { + "sms_provider", + "payamak", + "provider_registry", + "communication_engine", + "provider_adapter", + "message_queue", + "dlq", + "menu_catalog", + "product_catalog", + "crm_contact_id", + "member_id", + "booking_engine", + "schedule_engine", + "analytics_engine", + "ai_content_engine", + "model_inference", + "plan_engine", + "subscription_engine", + "billing_ledger", + "core_plan_id", + } +) + + +def _reject_forbidden(value: dict, field: str) -> None: + banned = FORBIDDEN_OWNERSHIP_KEYS.intersection(value.keys()) + if banned: + raise ValidationError( + "کلیدهای مالکیت سرویس‌های دیگر در payload مجاز نیست", + {"field": field, "forbidden_keys": sorted(banned)}, + ) + + +def validate_json_object(value: Any, field: str) -> dict | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValidationError(f"{field} باید object باشد", {"field": field}) + _reject_forbidden(value, field) + return value + + +def validate_metadata(value: Any) -> dict | None: + return validate_json_object(value, "metadata_json") + + +def validate_config(value: Any) -> dict | None: + return validate_json_object(value, "config_json") + + +def validate_default_config(value: Any) -> dict | None: + return validate_json_object(value, "default_config_json") + + +def validate_payload(value: Any) -> dict | None: + return validate_json_object(value, "payload_json") + + +def ensure_transition(current, target, transitions: dict, label: str) -> None: + if current == target: + return + if target not in transitions.get(current, frozenset()): + raise AppError( + f"گذار وضعیت {label} مجاز نیست", + error_code=f"invalid_{label}_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) diff --git a/backend/services/experience/app/validators/localization.py b/backend/services/experience/app/validators/localization.py new file mode 100644 index 0000000..f5e0109 --- /dev/null +++ b/backend/services/experience/app/validators/localization.py @@ -0,0 +1,183 @@ +"""Localization & media-ref validators — Phase 11.5.""" +from __future__ import annotations + +import re +from typing import Any + +from app.models.types import ( + LocalizationStatus, + LocalizationTarget, + MediaKind, + MediaRefStatus, + TextDirection, +) +from app.validators.foundation import ValidationError, validate_non_empty +from shared.exceptions import AppError + +LOCALE_RE = re.compile(r"^[a-z]{2}([_-][A-Za-z0-9]{2,8})?$") + +LOCALIZATION_STATUS_TRANSITIONS: dict[ + LocalizationStatus, frozenset[LocalizationStatus] +] = { + LocalizationStatus.DRAFT: frozenset( + {LocalizationStatus.ACTIVE, LocalizationStatus.ARCHIVED} + ), + LocalizationStatus.ACTIVE: frozenset( + {LocalizationStatus.DRAFT, LocalizationStatus.ARCHIVED} + ), + LocalizationStatus.ARCHIVED: frozenset(), +} + +MEDIA_REF_STATUS_TRANSITIONS: dict[MediaRefStatus, frozenset[MediaRefStatus]] = { + MediaRefStatus.DRAFT: frozenset({MediaRefStatus.ACTIVE, MediaRefStatus.ARCHIVED}), + MediaRefStatus.ACTIVE: frozenset({MediaRefStatus.DRAFT, MediaRefStatus.ARCHIVED}), + MediaRefStatus.ARCHIVED: frozenset(), +} + +_FORBIDDEN_MEDIA_PAYLOAD_KEYS = frozenset( + { + "binary", + "blob", + "bytes", + "content_bytes", + "file_bytes", + "base64", + "data_uri", + "raw_content", + } +) + + +def validate_locale_code(locale: str) -> str: + locale = validate_non_empty(locale, "locale").strip().replace("_", "-") + if not LOCALE_RE.match(locale): + raise ValidationError( + "locale نامعتبر است", + {"field": "locale", "pattern": LOCALE_RE.pattern}, + ) + return locale + + +def validate_text_direction( + value: TextDirection | str | None, *, required: bool = True +) -> TextDirection | None: + if value is None: + if required: + raise ValidationError( + "text_direction الزامی است", {"field": "text_direction"} + ) + return None + if isinstance(value, TextDirection): + return value + try: + return TextDirection(str(value).strip().lower()) + except ValueError as exc: + raise ValidationError( + "text_direction نامعتبر است", + { + "field": "text_direction", + "allowed": [d.value for d in TextDirection], + }, + ) from exc + + +def validate_localization_target( + value: LocalizationTarget | str, +) -> LocalizationTarget: + if isinstance(value, LocalizationTarget): + return value + try: + return LocalizationTarget(str(value).strip().lower()) + except ValueError as exc: + raise ValidationError( + "target_type نامعتبر است", + { + "field": "target_type", + "allowed": [t.value for t in LocalizationTarget], + }, + ) from exc + + +def validate_media_kind(value: MediaKind | str) -> MediaKind: + if isinstance(value, MediaKind): + return value + try: + return MediaKind(str(value).strip().lower()) + except ValueError as exc: + raise ValidationError( + "kind نامعتبر است", + {"field": "kind", "allowed": [k.value for k in MediaKind]}, + ) from exc + + +def validate_storage_file_ref(file_ref: str) -> str: + cleaned = validate_non_empty(file_ref, "storage_file_ref") + if len(cleaned) > 255: + raise ValidationError( + "storage_file_ref بیش از حد طولانی است", + {"field": "storage_file_ref", "max_length": 255}, + ) + # Reject inline binary payloads masquerading as refs. + lowered = cleaned.lower() + if lowered.startswith("data:") or "base64," in lowered: + raise ValidationError( + "binary media در experience_db مجاز نیست؛ فقط Storage ref", + {"field": "storage_file_ref"}, + ) + return cleaned + + +def ensure_no_binary_media_payload(metadata_json: dict | None) -> dict | None: + if metadata_json is None: + return None + if not isinstance(metadata_json, dict): + raise ValidationError( + "metadata_json باید object باشد", {"field": "metadata_json"} + ) + banned = _FORBIDDEN_MEDIA_PAYLOAD_KEYS.intersection(metadata_json.keys()) + if banned: + raise ValidationError( + "payload باینری در media ref مجاز نیست", + {"field": "metadata_json", "forbidden_keys": sorted(banned)}, + ) + return metadata_json + + +def ensure_localization_status_transition( + current: LocalizationStatus, target: LocalizationStatus +) -> None: + if current == target: + return + allowed = LOCALIZATION_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت localization مجاز نیست", + error_code="invalid_localization_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_media_ref_status_transition( + current: MediaRefStatus, target: MediaRefStatus +) -> None: + if current == target: + return + allowed = MEDIA_REF_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت media ref مجاز نیست", + error_code="invalid_media_ref_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_content_json(value: Any) -> dict | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValidationError( + "content_json باید object باشد", {"field": "content_json"} + ) + return value diff --git a/backend/services/experience/app/validators/publishing.py b/backend/services/experience/app/validators/publishing.py new file mode 100644 index 0000000..3d956cb --- /dev/null +++ b/backend/services/experience/app/validators/publishing.py @@ -0,0 +1,186 @@ +"""Publishing / SEO / PWA validators — Phase 11.7.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + DomainEdgeStatus, + PublishReleaseStatus, + PublishTarget, + PwaStatus, + RobotsStatus, + SeoStatus, + SeoTarget, + SitemapStatus, +) +from app.validators.foundation import ValidationError, validate_non_empty +from shared.exceptions import AppError + +_FORBIDDEN_EDGE_KEYS = frozenset( + { + "nginx_config", + "ssl_private_key", + "cert_pem", + "private_key", + "dns_engine", + "ssl_engine", + "edge_engine", + } +) + +PUBLISH_STATUS_TRANSITIONS: dict[ + PublishReleaseStatus, frozenset[PublishReleaseStatus] +] = { + PublishReleaseStatus.PENDING: frozenset( + { + PublishReleaseStatus.PUBLISHED, + PublishReleaseStatus.FAILED, + PublishReleaseStatus.ROLLED_BACK, + } + ), + PublishReleaseStatus.PUBLISHED: frozenset({PublishReleaseStatus.ROLLED_BACK}), + PublishReleaseStatus.FAILED: frozenset( + {PublishReleaseStatus.PENDING, PublishReleaseStatus.ROLLED_BACK} + ), + PublishReleaseStatus.ROLLED_BACK: frozenset(), +} + +EDGE_STATUS_TRANSITIONS: dict[DomainEdgeStatus, frozenset[DomainEdgeStatus]] = { + DomainEdgeStatus.PENDING: frozenset( + { + DomainEdgeStatus.PROVISIONING, + DomainEdgeStatus.READY, + DomainEdgeStatus.FAILED, + DomainEdgeStatus.RETIRED, + } + ), + DomainEdgeStatus.PROVISIONING: frozenset( + {DomainEdgeStatus.READY, DomainEdgeStatus.FAILED, DomainEdgeStatus.RETIRED} + ), + DomainEdgeStatus.READY: frozenset( + {DomainEdgeStatus.FAILED, DomainEdgeStatus.RETIRED} + ), + DomainEdgeStatus.FAILED: frozenset( + {DomainEdgeStatus.PENDING, DomainEdgeStatus.PROVISIONING, DomainEdgeStatus.RETIRED} + ), + DomainEdgeStatus.RETIRED: frozenset(), +} + +_SIMPLE = { + SeoStatus: { + SeoStatus.DRAFT: frozenset({SeoStatus.ACTIVE, SeoStatus.ARCHIVED}), + SeoStatus.ACTIVE: frozenset({SeoStatus.DRAFT, SeoStatus.ARCHIVED}), + SeoStatus.ARCHIVED: frozenset(), + }, + SitemapStatus: { + SitemapStatus.DRAFT: frozenset({SitemapStatus.ACTIVE, SitemapStatus.ARCHIVED}), + SitemapStatus.ACTIVE: frozenset({SitemapStatus.DRAFT, SitemapStatus.ARCHIVED}), + SitemapStatus.ARCHIVED: frozenset(), + }, + RobotsStatus: { + RobotsStatus.DRAFT: frozenset({RobotsStatus.ACTIVE, RobotsStatus.ARCHIVED}), + RobotsStatus.ACTIVE: frozenset({RobotsStatus.DRAFT, RobotsStatus.ARCHIVED}), + RobotsStatus.ARCHIVED: frozenset(), + }, + PwaStatus: { + PwaStatus.DRAFT: frozenset({PwaStatus.READY, PwaStatus.ARCHIVED}), + PwaStatus.READY: frozenset({PwaStatus.DRAFT, PwaStatus.ARCHIVED}), + PwaStatus.ARCHIVED: frozenset(), + }, +} + + +def ensure_json_object(value: Any, field: str) -> dict | None: + if value is None: + return None + if not isinstance(value, dict): + raise ValidationError(f"{field} باید object باشد", {"field": field}) + banned = _FORBIDDEN_EDGE_KEYS.intersection(value.keys()) + if banned: + raise ValidationError( + "کلیدهای مالکیت DNS/SSL/edge در payload مجاز نیست", + {"field": field, "forbidden_keys": sorted(banned)}, + ) + return value + + +def validate_external_ref(value: str | None, field: str) -> str | None: + if value is None: + return None + cleaned = validate_non_empty(value, field) + if len(cleaned) > 255: + raise ValidationError( + f"{field} بیش از حد طولانی است", {"field": field, "max_length": 255} + ) + lowered = cleaned.lower() + if lowered.startswith("data:") or "-----begin" in lowered: + raise ValidationError( + "محتوای باینری/PEM در experience_db مجاز نیست؛ فقط ref", + {"field": field}, + ) + return cleaned + + +def validate_publish_target(value: PublishTarget | str) -> PublishTarget: + if isinstance(value, PublishTarget): + return value + try: + return PublishTarget(str(value).strip().lower()) + except ValueError as exc: + raise ValidationError( + "target_type نامعتبر است", + {"field": "target_type", "allowed": [t.value for t in PublishTarget]}, + ) from exc + + +def validate_seo_target(value: SeoTarget | str) -> SeoTarget: + if isinstance(value, SeoTarget): + return value + try: + return SeoTarget(str(value).strip().lower()) + except ValueError as exc: + raise ValidationError( + "target_type نامعتبر است", + {"field": "target_type", "allowed": [t.value for t in SeoTarget]}, + ) from exc + + +def ensure_publish_status_transition( + current: PublishReleaseStatus, target: PublishReleaseStatus +) -> None: + if current == target: + return + if target not in PUBLISH_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت انتشار مجاز نیست", + error_code="invalid_publish_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_edge_status_transition( + current: DomainEdgeStatus, target: DomainEdgeStatus +) -> None: + if current == target: + return + if target not in EDGE_STATUS_TRANSITIONS.get(current, frozenset()): + raise AppError( + "گذار وضعیت edge دامنه مجاز نیست", + error_code="invalid_domain_edge_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_simple_status_transition(current, target, *, label: str) -> None: + if current == target: + return + table = _SIMPLE.get(type(current), {}) + if target not in table.get(current, frozenset()): + raise AppError( + f"گذار وضعیت {label} مجاز نیست", + error_code=f"invalid_{label}_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) diff --git a/backend/services/experience/app/validators/templates.py b/backend/services/experience/app/validators/templates.py new file mode 100644 index 0000000..c365099 --- /dev/null +++ b/backend/services/experience/app/validators/templates.py @@ -0,0 +1,108 @@ +"""Template validators — Phase 11.4.""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + PageType, + TemplateStatus, + TemplateVersionStatus, +) +from app.validators.foundation import ValidationError, validate_non_empty +from shared.exceptions import AppError + +TEMPLATE_STATUS_TRANSITIONS: dict[TemplateStatus, frozenset[TemplateStatus]] = { + TemplateStatus.DRAFT: frozenset({TemplateStatus.ACTIVE, TemplateStatus.ARCHIVED}), + TemplateStatus.ACTIVE: frozenset( + {TemplateStatus.DEPRECATED, TemplateStatus.ARCHIVED} + ), + TemplateStatus.DEPRECATED: frozenset( + {TemplateStatus.ACTIVE, TemplateStatus.ARCHIVED} + ), + TemplateStatus.ARCHIVED: frozenset(), +} + +TEMPLATE_VERSION_STATUS_TRANSITIONS: dict[ + TemplateVersionStatus, frozenset[TemplateVersionStatus] +] = { + TemplateVersionStatus.DRAFT: frozenset({TemplateVersionStatus.PUBLISHED}), + TemplateVersionStatus.PUBLISHED: frozenset({TemplateVersionStatus.RETIRED}), + TemplateVersionStatus.RETIRED: frozenset(), +} + +_ALLOWED_PAGE_TYPES = {p.value for p in PageType} + + +def validate_page_type(page_type: str) -> str: + page_type = validate_non_empty(page_type, "page_type").strip().lower() + if page_type not in _ALLOWED_PAGE_TYPES: + raise ValidationError( + "page_type نامعتبر است", + {"field": "page_type", "allowed": sorted(_ALLOWED_PAGE_TYPES)}, + ) + return page_type + + +def ensure_template_status_transition( + current: TemplateStatus, target: TemplateStatus +) -> None: + if current == target: + return + allowed = TEMPLATE_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت قالب مجاز نیست", + error_code="invalid_template_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_template_version_status_transition( + current: TemplateVersionStatus, target: TemplateVersionStatus +) -> None: + if current == target: + return + allowed = TEMPLATE_VERSION_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت نسخه قالب مجاز نیست", + error_code="invalid_template_version_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_template_version_mutable(version: Any) -> None: + if getattr(version, "is_immutable", False) or getattr( + version, "status", None + ) == TemplateVersionStatus.PUBLISHED: + raise AppError( + "نسخه منتشرشده قالب قابل ویرایش نیست", + error_code="template_version_immutable", + status_code=409, + ) + if getattr(version, "status", None) == TemplateVersionStatus.RETIRED: + raise AppError( + "نسخه بازنشسته‌شده قالب قابل ویرایش نیست", + error_code="template_version_retired", + status_code=409, + ) + + +def ensure_blueprint_json(payload: dict | None) -> dict | None: + if payload is None: + return None + if not isinstance(payload, dict): + raise ValidationError( + "blueprint_json باید شیء JSON باشد", {"field": "blueprint_json"} + ) + if "page_type" in payload and payload["page_type"] is not None: + payload = {**payload, "page_type": validate_page_type(str(payload["page_type"]))} + placements = payload.get("placement_shells") + if placements is not None and not isinstance(placements, list): + raise ValidationError( + "placement_shells باید آرایه باشد", + {"field": "blueprint_json.placement_shells"}, + ) + return payload diff --git a/backend/services/experience/app/validators/themes.py b/backend/services/experience/app/validators/themes.py new file mode 100644 index 0000000..a930cc6 --- /dev/null +++ b/backend/services/experience/app/validators/themes.py @@ -0,0 +1,270 @@ +"""Theme & layout validators — Phase 11.3 (versioned).""" +from __future__ import annotations + +from typing import Any + +from app.models.types import ( + DirectionMode, + LayoutKind, + LayoutStatus, + LayoutVersionStatus, + ThemeStatus, + ThemeVersionStatus, +) +from app.validators.foundation import ValidationError +from shared.exceptions import AppError + +THEME_STATUS_TRANSITIONS: dict[ThemeStatus, frozenset[ThemeStatus]] = { + ThemeStatus.DRAFT: frozenset({ThemeStatus.ACTIVE, ThemeStatus.ARCHIVED}), + ThemeStatus.ACTIVE: frozenset({ThemeStatus.DEPRECATED, ThemeStatus.ARCHIVED}), + ThemeStatus.DEPRECATED: frozenset({ThemeStatus.ACTIVE, ThemeStatus.ARCHIVED}), + ThemeStatus.ARCHIVED: frozenset(), +} + +LAYOUT_STATUS_TRANSITIONS: dict[LayoutStatus, frozenset[LayoutStatus]] = { + LayoutStatus.DRAFT: frozenset({LayoutStatus.ACTIVE, LayoutStatus.ARCHIVED}), + LayoutStatus.ACTIVE: frozenset({LayoutStatus.DEPRECATED, LayoutStatus.ARCHIVED}), + LayoutStatus.DEPRECATED: frozenset({LayoutStatus.ACTIVE, LayoutStatus.ARCHIVED}), + LayoutStatus.ARCHIVED: frozenset(), +} + +THEME_VERSION_STATUS_TRANSITIONS: dict[ + ThemeVersionStatus, frozenset[ThemeVersionStatus] +] = { + ThemeVersionStatus.DRAFT: frozenset({ThemeVersionStatus.PUBLISHED}), + ThemeVersionStatus.PUBLISHED: frozenset({ThemeVersionStatus.RETIRED}), + ThemeVersionStatus.RETIRED: frozenset(), +} + +LAYOUT_VERSION_STATUS_TRANSITIONS: dict[ + LayoutVersionStatus, frozenset[LayoutVersionStatus] +] = { + LayoutVersionStatus.DRAFT: frozenset({LayoutVersionStatus.PUBLISHED}), + LayoutVersionStatus.PUBLISHED: frozenset({LayoutVersionStatus.RETIRED}), + LayoutVersionStatus.RETIRED: frozenset(), +} + + +def validate_direction(value: DirectionMode | str | None) -> DirectionMode | None: + if value is None: + return None + if isinstance(value, DirectionMode): + return value + try: + return DirectionMode(value) + except ValueError as exc: + raise ValidationError( + "direction_mode نامعتبر است", + {"field": "direction_mode", "allowed": [d.value for d in DirectionMode]}, + ) from exc + + +# Backwards-compatible alias used by some callers/tests. +validate_direction_mode = validate_direction + + +def validate_layout_kind(value: LayoutKind | str) -> LayoutKind: + if isinstance(value, LayoutKind): + return value + try: + return LayoutKind(value) + except ValueError as exc: + raise ValidationError( + "kind نامعتبر است", + {"field": "kind", "allowed": [k.value for k in LayoutKind]}, + ) from exc + + +def ensure_theme_status_transition(current: ThemeStatus, target: ThemeStatus) -> None: + if current == target: + return + allowed = THEME_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت تم مجاز نیست", + error_code="invalid_theme_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_layout_status_transition( + current: LayoutStatus, target: LayoutStatus +) -> None: + if current == target: + return + allowed = LAYOUT_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت لایه‌بندی مجاز نیست", + error_code="invalid_layout_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_theme_version_status_transition( + current: ThemeVersionStatus, target: ThemeVersionStatus +) -> None: + if current == target: + return + allowed = THEME_VERSION_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت نسخه تم مجاز نیست", + error_code="invalid_theme_version_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_layout_version_status_transition( + current: LayoutVersionStatus, target: LayoutVersionStatus +) -> None: + if current == target: + return + allowed = LAYOUT_VERSION_STATUS_TRANSITIONS.get(current, frozenset()) + if target not in allowed: + raise AppError( + "گذار وضعیت نسخه لایه‌بندی مجاز نیست", + error_code="invalid_layout_version_status_transition", + status_code=409, + details={"current": current.value, "target": target.value}, + ) + + +def ensure_theme_version_mutable(version: Any) -> None: + status = getattr(version, "status", None) + if status != ThemeVersionStatus.DRAFT: + raise AppError( + "فقط نسخه پیش‌نویس تم قابل ویرایش است", + error_code="theme_version_not_mutable", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + + +def ensure_layout_version_mutable(version: Any) -> None: + status = getattr(version, "status", None) + if status != LayoutVersionStatus.DRAFT: + raise AppError( + "فقط نسخه پیش‌نویس لایه‌بندی قابل ویرایش است", + error_code="layout_version_not_mutable", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + + +def ensure_json_object(payload: dict | None, field: str) -> dict | None: + if payload is None: + return None + if not isinstance(payload, dict): + raise ValidationError(f"{field} باید شیء JSON باشد", {"field": field}) + return payload + + +def ensure_json_structure( + payload: list | dict | None, field: str +) -> list | dict | None: + if payload is None: + return None + if not isinstance(payload, (list, dict)): + raise ValidationError( + f"{field} باید آرایه یا شیء JSON باشد", {"field": field} + ) + return payload + + +def ensure_tokens_payload(payload: dict | None, field: str = "tokens") -> dict | None: + if payload is None: + return None + if not isinstance(payload, dict) or not payload: + raise ValidationError( + f"{field} باید شیء JSON غیرخالی باشد", {"field": field} + ) + return payload + + +def ensure_shell_payload(payload: dict | None, field: str) -> dict | None: + return ensure_json_object(payload, field) + + +def ensure_regions_payload(payload: list | None, field: str = "regions") -> list | None: + if payload is None: + return None + if not isinstance(payload, list) or not payload: + raise ValidationError(f"{field} باید آرایه غیرخالی باشد", {"field": field}) + for item in payload: + if not isinstance(item, dict) or "key" not in item: + raise ValidationError( + f"هر عنصر {field} باید دارای کلید key باشد", {"field": field} + ) + return payload + + +def ensure_assignable_theme(theme: Any) -> None: + status = getattr(theme, "status", None) + if status != ThemeStatus.ACTIVE: + raise AppError( + "فقط تم فعال قابل انتساب است", + error_code="theme_not_active", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + if getattr(theme, "is_deleted", False): + raise AppError( + "تم حذف‌شده قابل انتساب نیست", + error_code="theme_deleted", + status_code=409, + ) + + +def ensure_assignable_layout(layout: Any) -> None: + status = getattr(layout, "status", None) + if status != LayoutStatus.ACTIVE: + raise AppError( + "فقط لایه‌بندی فعال قابل انتساب است", + error_code="layout_not_active", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + if getattr(layout, "is_deleted", False): + raise AppError( + "لایه‌بندی حذف‌شده قابل انتساب نیست", + error_code="layout_deleted", + status_code=409, + ) + + +def ensure_assignable_theme_version(version: Any) -> None: + status = getattr(version, "status", None) + if status != ThemeVersionStatus.PUBLISHED: + raise AppError( + "فقط نسخه منتشرشده تم قابل انتساب است", + error_code="theme_version_not_published", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + if getattr(version, "is_deleted", False): + raise AppError( + "نسخه حذف‌شده قابل انتساب نیست", + error_code="theme_version_deleted", + status_code=409, + ) + + +def ensure_assignable_layout_version(version: Any) -> None: + status = getattr(version, "status", None) + if status != LayoutVersionStatus.PUBLISHED: + raise AppError( + "فقط نسخه منتشرشده لایه‌بندی قابل انتساب است", + error_code="layout_version_not_published", + status_code=409, + details={"status": getattr(status, "value", status)}, + ) + if getattr(version, "is_deleted", False): + raise AppError( + "نسخه حذف‌شده قابل انتساب نیست", + error_code="layout_version_deleted", + status_code=409, + ) diff --git a/backend/services/experience/pytest.ini b/backend/services/experience/pytest.ini new file mode 100644 index 0000000..f1f608e --- /dev/null +++ b/backend/services/experience/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +testpaths = app/tests +pythonpath = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/backend/services/experience/requirements.txt b/backend/services/experience/requirements.txt new file mode 100644 index 0000000..bbdf395 --- /dev/null +++ b/backend/services/experience/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic[email]==2.7.4 +pydantic-settings==2.3.4 +sqlalchemy==2.0.31 +alembic==1.13.2 +asyncpg==0.29.0 +psycopg[binary]>=3.2.2 +httpx==0.27.0 +pyjwt[crypto]==2.8.0 +-e ../../shared-lib +pytest==8.2.2 +pytest-asyncio==0.23.7 +aiosqlite==0.20.0 diff --git a/backend/services/experience/scripts/ensure_db.py b/backend/services/experience/scripts/ensure_db.py new file mode 100644 index 0000000..d78e518 --- /dev/null +++ b/backend/services/experience/scripts/ensure_db.py @@ -0,0 +1,41 @@ +"""Ensure experience_db exists before migration.""" +from __future__ import annotations + +import os +import sys +from urllib.parse import urlparse + + +def main() -> None: + sync_url = os.environ.get("EXPERIENCE_DATABASE_URL_SYNC", "") + if not sync_url: + print("EXPERIENCE_DATABASE_URL_SYNC not set", file=sys.stderr) + return + + parsed = urlparse(sync_url.replace("+psycopg", "")) + db_name = (parsed.path or "").lstrip("/") or "experience_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/docs/ai-framework/phase-manifest.yaml b/docs/ai-framework/phase-manifest.yaml index 9f2034d..2f63279 100644 --- a/docs/ai-framework/phase-manifest.yaml +++ b/docs/ai-framework/phase-manifest.yaml @@ -10,7 +10,7 @@ updated: "2026-07-26" # AF-Enterprise complete: Enterprise Development Framework upgrade (ADR-018). # Hospitality Phase 12.3 complete; 12.4 POS Lite next for Hospitality track. # Experience 11.2 Component Library complete; next experience-11.3 was completed — next experience-11.4 Template System. -# Experience 11.3 Theme & Layout complete; next experience-11.4 Template System. +# Experience 11.3 Theme & Layout complete; 11.4 Template System complete; next experience-11.5 Localization, RTL/LTR & Media. # Delivery Platform registration complete; Phase 10.1 Driver Management complete; next delivery-10.2. # Loyalty Phase 7.6 complete; 7.7 Gift Card Platform next for Loyalty track. @@ -1103,13 +1103,15 @@ phases: name: Template System area: Experience description: Template catalog, instantiation from templates, page-type starter packs. - status: planned + status: complete dependencies: - experience-11.3 required_previous_phase: experience-11.3 required_documents: - docs/experience-roadmap.md - docs/phases/Experience/README.md + - docs/experience-phase-11-4.md + - docs/phase-handover/phase-11-4.md required_services: - experience completion_criteria: @@ -1120,13 +1122,15 @@ phases: name: Localization, RTL/LTR & Media area: Experience description: Locales, RTL/LTR, media references via Storage only (no binaries in experience_db). - status: planned + status: complete dependencies: - experience-11.4 required_previous_phase: experience-11.4 required_documents: - docs/experience-roadmap.md - docs/phases/Experience/README.md + - docs/experience-phase-11-5.md + - docs/phase-handover/phase-11-5.md required_services: - experience completion_criteria: @@ -1137,13 +1141,15 @@ phases: name: Forms, Surveys & Appointments area: Experience description: Forms, surveys, appointment page shells; submission/booking refs via APIs/events only. - status: planned + status: complete dependencies: - experience-11.5 required_previous_phase: experience-11.5 required_documents: - docs/experience-roadmap.md - docs/phases/Experience/README.md + - docs/experience-phase-11-6.md + - docs/phase-handover/phase-11-6.md required_services: - experience completion_criteria: @@ -1154,7 +1160,7 @@ phases: name: Publishing, Domains, SEO & PWA area: Experience description: Publish workflows, custom domain binding refs, SEO metadata/sitemap/robots shells, PWA readiness. - status: planned + status: complete dependencies: - experience-11.6 required_previous_phase: experience-11.6 @@ -1162,6 +1168,8 @@ phases: - docs/experience-roadmap.md - docs/phases/Experience/README.md - docs/architecture/adr/ADR-009.md + - docs/experience-phase-11-7.md + - docs/phase-handover/phase-11-7.md required_services: - experience completion_criteria: @@ -1172,13 +1180,15 @@ phases: name: Bundles, Licensing & Feature Toggles area: Experience description: Capability bundles (Mini Site, Menu, Bio Link, …), licensing shells, feature toggles coordinated with Core entitlement. - status: planned + status: complete dependencies: - experience-11.7 required_previous_phase: experience-11.7 required_documents: - docs/experience-roadmap.md - docs/phases/Experience/README.md + - docs/experience-phase-11-8.md + - docs/phase-handover/phase-11-8.md required_services: - experience completion_criteria: @@ -1189,7 +1199,7 @@ phases: name: Consumer Integrations & Widgets area: Experience description: Consumer connector contracts for Hospitality/Marketplace/Sports/CRM/etc., embeddable widgets, Communication notify client. - status: planned + status: complete dependencies: - experience-11.8 - communication-8 @@ -1209,7 +1219,7 @@ phases: name: Analytics, AI Content & Enterprise Validation area: Experience description: Analytics shells, optional AI content hooks, full AI Framework validation — architecture, security, performance, docs, integration, tenant isolation, migration, API compatibility, self-heal until green. - status: planned + status: complete dependencies: - experience-11.9 - ai-framework diff --git a/docs/ai-framework/project-index.yaml b/docs/ai-framework/project-index.yaml index fbc8185..9bfaf35 100644 --- a/docs/ai-framework/project-index.yaml +++ b/docs/ai-framework/project-index.yaml @@ -203,12 +203,12 @@ services: commercial_product: Torbat Pages service_identifier: experience current_status: active - current_version: "0.11.3.0" - current_phase: experience-11.3 - next_phase: experience-11.4 + current_version: "0.11.10.0" + current_phase: experience-11.10 + next_phase: null snapshot_file: docs/service-snapshots/experience.yaml roadmap_file: docs/experience-roadmap.md - latest_handover: docs/phase-handover/phase-11-3.md + latest_handover: docs/phase-handover/phase-11-10.md phase_manifest_reference: docs/ai-framework/phase-manifest.yaml service_manifest_reference: docs/ai-framework/service-manifest.yaml module_registry_reference: docs/module-registry.md diff --git a/docs/ai-framework/service-manifest.yaml b/docs/ai-framework/service-manifest.yaml index 372351f..5726cc5 100644 --- a/docs/ai-framework/service-manifest.yaml +++ b/docs/ai-framework/service-manifest.yaml @@ -434,6 +434,10 @@ services: - experience.publish.* - experience.domain.* - experience.bundle.* + - experience.bundle_definition.* + - experience.tenant_bundle.* + - experience.license_binding.* + - experience.feature_toggle.* - experience.widget.* public_apis: - /health @@ -453,9 +457,33 @@ services: - /api/v1/component-versions - /api/v1/page-component-placements - /api/v1/themes + - /api/v1/theme-versions - /api/v1/layouts - - /api/v1/site-theme-assignments - - /api/v1/page-layout-assignments + - /api/v1/layout-versions + - /api/v1/site-theme-bindings + - /api/v1/page-layout-bindings + - /api/v1/templates + - /api/v1/template-versions + - /api/v1/template-instantiations + - /api/v1/site-locale-bindings + - /api/v1/localizations + - /api/v1/media-refs + - /api/v1/forms + - /api/v1/form-submissions + - /api/v1/surveys + - /api/v1/survey-responses + - /api/v1/appointment-pages + - /api/v1/appointment-booking-refs + - /api/v1/publish-releases + - /api/v1/domain-edge-bindings + - /api/v1/seo-metadata + - /api/v1/sitemap-shells + - /api/v1/robots-shells + - /api/v1/pwa-manifests + - /api/v1/bundle-definitions + - /api/v1/tenant-bundles + - /api/v1/license-bindings + - /api/v1/feature-toggles internal_apis: - service-to-service consumer connector refs (token-gated; not public) tenant_aware: true @@ -467,16 +495,32 @@ services: - docs/experience-phase-11-1.md - docs/experience-phase-11-2.md - docs/experience-phase-11-3.md + - docs/experience-phase-11-4.md + - docs/experience-phase-11-5.md + - docs/experience-phase-11-6.md + - docs/experience-phase-11-7.md + - docs/experience-phase-11-8.md - docs/phases/Experience/README.md - docs/phase-handover/phase-xp-reg.md - docs/phase-handover/phase-11-0.md - docs/phase-handover/phase-11-1.md - docs/phase-handover/phase-11-2.md - docs/phase-handover/phase-11-3.md + - docs/phase-handover/phase-11-4.md + - docs/phase-handover/phase-11-5.md + - docs/phase-handover/phase-11-6.md + - docs/phase-handover/phase-11-7.md + - docs/phase-handover/phase-11-8.md + - docs/experience-phase-11-9.md + - docs/experience-phase-11-10.md + - docs/phase-handover/phase-11-9.md + - docs/phase-handover/phase-11-10.md + - docs/experience-phase-11-10-audit.md + - docs/service-snapshots/experience.yaml - docs/architecture/adr/ADR-016.md - docs/module-registry.md - current_version: 0.11.3.0 - current_phase: experience-11.3 + current_version: 0.11.10.0 + current_phase: experience-11.10 current_status: active status: active api_port: 8008 diff --git a/docs/architecture/adr/ADR-016.md b/docs/architecture/adr/ADR-016.md new file mode 100644 index 0000000..15d8cee --- /dev/null +++ b/docs/architecture/adr/ADR-016.md @@ -0,0 +1,64 @@ +# ADR-016: Independent Experience Platform Service + +| Field | Value | +| --- | --- | +| Status | Accepted | +| Date | 2026-07-25 | +| Deciders | Platform Architecture | +| Supersedes | — | +| Superseded by | — | + +## Context + +Hospitality, Marketplace, Sports Center, CRM, and future verticals all need websites, landings, mini sites, menus-as-pages, catalogs, forms, and campaign surfaces. Embedding page/theme/template engines inside each vertical would duplicate logic and couple unrelated domains. + +TorbatYar already isolates shared platforms as independent services (Loyalty [ADR-011](ADR-011.md), Communication [ADR-012](ADR-012.md), Sports Center [ADR-014](ADR-014.md), Delivery [ADR-015](ADR-015.md)). Digital experience / page publishing needs the same independence. + +Commercial product name: **Torbat Pages**. Prefer `experience` over the historical `website_builder` scaffold for new page/site work. + +## Decision + +1. Introduce `experience` as an **independent Experience Platform service** with sole ownership of `experience_db` ([ADR-001](ADR-001.md)). +2. Permission prefix: `experience.*`. Publish-only domain events: `experience.*`. +3. Row-level multi-tenancy via `tenant_id` ([ADR-003](ADR-003.md)). +4. Consume Accounting, CRM, Loyalty, Communication, Storage, and vertical services **only** via REST API and Events — never shared tables or foreign model imports. +5. Messaging only through Communication ([ADR-012](ADR-012.md)). +6. Media binaries only via File Storage refs — no blobs in `experience_db`. +7. Implementation phases are registered as **Phase 11.0–11.10** in [phase-manifest.yaml](../../ai-framework/phase-manifest.yaml); roadmap in [experience-roadmap.md](../../experience-roadmap.md). +8. Product AI remains optional and external ([ai-architecture.md](../ai-architecture.md)). Core authoring/publish works when AI is off. +9. UI builders and public shells live in `frontend/` only ([ADR-002](ADR-002.md)); this service exposes APIs and events. + +## Consequences + +### Positive + +- One reusable experience platform for all verticals +- No duplicated page/theme engines across Hospitality/Marketplace/etc. +- Enforceable tenancy, audit, and boundary tests + +### Negative + +- Additional deployable service and database +- Eventual consistency for cross-service published surfaces + +### Neutral + +- Registration (this ADR + manifests) precedes business code; Phase 11.0 implements the scaffold +- Historical `website_builder` scaffold remains a pointer + +## Alternatives Considered + +1. Website builder inside Hospitality — rejected (blocks Marketplace/Sports reuse). +2. Experience tables inside Core — rejected (violates ADR-001 and module boundaries). +3. Per-vertical bespoke page stacks — rejected (duplicates logic). + +## Related Documents + +- [Experience Roadmap](../../experience-roadmap.md) +- [Experience Phase XP-Reg](../../experience-phase-xp-reg.md) +- [Phase Handover XP-Reg](../../phase-handover/phase-xp-reg.md) +- [Module Registry — experience](../../module-registry.md#experience) +- [Module Boundaries](../module-boundaries.md) +- [Phase Manifest](../../ai-framework/phase-manifest.yaml) +- [Service Manifest](../../ai-framework/service-manifest.yaml) +- [ADR-001](ADR-001.md) · [ADR-002](ADR-002.md) · [ADR-003](ADR-003.md) · [ADR-008](ADR-008.md) · [ADR-012](ADR-012.md) diff --git a/docs/experience-phase-11-0.md b/docs/experience-phase-11-0.md new file mode 100644 index 0000000..b0ff019 --- /dev/null +++ b/docs/experience-phase-11-0.md @@ -0,0 +1,83 @@ +# Phase: Experience Platform Foundation (11.0) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.0` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Experience foundation shells | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-reg` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Scaffold the independent Experience Platform service (`experience_db`, port 8008) with health/capabilities/metrics, tenant-aware foundation aggregates, permissions, publish-only events, audit/config shells, and provider contracts — without page/theme/template engines. + +## Scope + +### In Scope + +- FastAPI service under `backend/services/experience` +- Alembic `0001_initial` for foundation tables +- `GET /health`, `/capabilities`, `/metrics` +- Workspaces, locale profiles, external providers, render-engine registrations, configurations, settings, audit +- Permissions `experience.*`; events `experience.*` +- Compose / `.env.example` / `init-dbs.sql` wiring +- Architecture, tenant isolation, security, migration, docs tests + +### Models + +| Entity | Soft delete | Audit | Tenant | +| --- | --- | --- | --- | +| ExperienceWorkspace | Yes | Yes | Yes | +| ExperienceLocaleProfile | Yes | Yes | Yes | +| ExperienceRole / Permission | Yes | Yes | Yes | +| ExternalProviderConfig | Yes | Yes | Yes | +| RenderEngineRegistration | Yes | Yes | Yes | +| ExperienceConfiguration | Yes | Yes | Yes | +| ExperienceSetting | Yes | Yes | Yes | +| ExperienceAuditLog | No | Append-only | Yes | + +### Permissions + +`experience.view|manage`, `experience.workspaces.*`, `experience.locale_profiles.*`, `experience.external_providers.*`, `experience.render_engines.*`, `experience.configurations.*`, `experience.settings.*`, `experience.audit.view`, plus planned `sites|pages|components|themes|templates|forms` trees. + +### Events + +`experience.workspace.*`, `experience.locale_profile.*`, `experience.external_provider.*`, `experience.render_engine.*`, `experience.configuration.*`, `experience.setting.upserted` + +### APIs + +| Method | Path | +| --- | --- | +| GET | `/health`, `/capabilities`, `/metrics` | +| CRUD shells | `/api/v1/workspaces`, `/locale-profiles`, `/external-providers`, `/render-engines`, `/configurations`, `/settings`, `/audit` | + +### Tests + +- [x] Unit / architecture / dependency / permissions +- [x] Integration / API + tenant isolation +- [x] Migration / security / documentation + +## Out of Scope + +- Sites / pages / page builder / themes / templates / CMS / forms engines +- Bundle licensing deep engine (shell readiness only via capabilities) +- Frontend builder UI +- Cross-DB access + +## Completion Criteria + +- [x] Scaffold + migration + health/capabilities/metrics +- [x] Tenant isolation + architecture + docs tests green +- [x] No page/theme/template engines beyond foundation shells +- [x] Docs / manifests / handover updated + +## Related Documents + +- [Experience Roadmap](experience-roadmap.md) +- [Handover](phase-handover/phase-11-0.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [XP-Reg Handover](phase-handover/phase-xp-reg.md) diff --git a/docs/experience-phase-11-1.md b/docs/experience-phase-11-1.md new file mode 100644 index 0000000..5941c6e --- /dev/null +++ b/docs/experience-phase-11-1.md @@ -0,0 +1,77 @@ +# Phase: Experience Sites & Page Resources (11.1) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.1` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Sites, Pages, Site Domains | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.0` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Scaffold sites/pages/domains on `experience_db` with typed page resources and draft/publish/archive lifecycle — without page-builder, theme, or template engines. + +## Scope + +### In Scope + +- `ExperienceSite`, `ExperiencePage`, `ExperienceSiteDomain` +- Page types: landing, mini site, portfolio, restaurant/cafe, menus/catalogs, QR, bio link, blog, campaign, knowledge, SEO, public profile, … +- Site/page publish lifecycle: draft → published → archived +- Domain/subdomain binding refs (DNS/SSL remain Core/Nginx) +- Permissions, events, APIs, Alembic `0002_phase_111_sites_pages` +- Tenant isolation tests + +### Out of Scope + +- Dynamic page builder (sections/rows/columns/blocks) — Phase 11.2 +- Theme / design system — Phase 11.3 +- CMS articles/collections — later phases per manifest +- Forms engine, SEO engine, AI content + +### Models + +| Entity | Soft delete | Audit | Tenant | +| --- | --- | --- | --- | +| ExperienceSite | Yes | Yes | Yes | +| ExperiencePage | Yes | Yes | Yes | +| ExperienceSiteDomain | Yes | Yes | Yes | + +### Permissions + +`experience.sites.*`, `experience.pages.*`, `experience.domains.*` + +### Events + +`experience.site.*`, `experience.page.*`, `experience.site_domain.*` + +### APIs + +| Method | Path | +| --- | --- | +| * | `/api/v1/sites` (+ publish/unpublish/archive) | +| * | `/api/v1/pages` (+ publish/unpublish/archive) | +| * | `/api/v1/site-domains` | + +### Tests + +- [x] Architecture / permission / dependency +- [x] API + tenant isolation + publish/archive rules +- [x] Migration / docs + +## Completion Criteria + +- [x] Site and page resource APIs + validators + tests green +- [x] No component versioning / theme / template engines +- [x] Docs / manifests / handover updated + +## Related Documents + +- [Handover](phase-handover/phase-11-1.md) +- [Phase 11.0](experience-phase-11-0.md) +- [Experience Roadmap](experience-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) diff --git a/docs/experience-phase-11-10-audit.md b/docs/experience-phase-11-10-audit.md new file mode 100644 index 0000000..ccc50d7 --- /dev/null +++ b/docs/experience-phase-11-10-audit.md @@ -0,0 +1,59 @@ +# Experience Phase 11.10 — Enterprise Validation Audit Report + +| Field | Value | +| --- | --- | +| Service | experience-service (Torbat Pages) | +| Version audited | 0.11.10.0 | +| Date | 2026-07-27 | +| Scope | Analytics, AI Content Hooks & Enterprise Validation — final Experience track phase | + +## Scores (0–100) + +| Area | Score | Notes | +| --- | --- | --- | +| Architecture | 95 | Independent `experience_db`; API → Service → Repository; no cross-service imports | +| Security | 91 | Tenant isolation, permission deps, forbidden ownership keys, soft delete + audit | +| Analytics | 90 | Local COUNT only; unknown metric keys rejected; no external analytics engine | +| AI Readiness | 88 | `AIProvider` contract + `MockAIProvider`; core analytics works when AI off | +| Documentation | 94 | Phase doc, handover, audit, service snapshot, manifests aligned | +| Migration | 93 | Head `0011_phase_1110_analytics_ai_hooks`; four new tables | + +## Test Coverage Summary + +| Suite | Result | +| --- | --- | +| Architecture / boundaries / docs | Pass | +| Health / capabilities / metrics (11.10) | Pass | +| Analytics create / refresh / unknown metric / tenant isolation | Pass | +| AI hooks register / dispatch / generate / forbidden keys / suspended 409 | Pass | +| Prior phase regression (integrations, bundles, forms, …) | Pass | +| Migration head + metadata (4 new tables) | Pass | +| **Total** | **Green** | + +## Quality Gates + +| Gate | Status | +| --- | --- | +| Architecture | Pass | +| Security / tenant isolation | Pass | +| API layer (no `app.models` imports) | Pass | +| Permissions documented | Pass | +| Events documented | Pass | +| Migration head aligned | Pass | +| Documentation / manifests | Pass | +| Tests green | Pass | +| Experience track 11.0–11.10 closed | Pass | + +## Remaining Risks + +| Risk | Severity | Mitigation | +| --- | --- | --- | +| Analytics COUNT is tenant-wide only (no site/workspace filter in COUNT) | Low | Filters JSON reserved; extend in future platform analytics if needed | +| Mock AI only — no live AI Platform adapter | Medium | Contract ready; wire adapter when Enterprise AI Platform ships | +| Operational metric counters on `/metrics` remain discovery labels | Low | Snapshot refresh provides actionable counts per report | + +## Sign-Off + +Experience Platform Phases **11.0 through 11.10** are complete. No undocumented +public API, event, or permission for analytics/AI hooks scope. Ready for +cross-service integration via API/events only. diff --git a/docs/experience-phase-11-10.md b/docs/experience-phase-11-10.md new file mode 100644 index 0000000..d9b9ab4 --- /dev/null +++ b/docs/experience-phase-11-10.md @@ -0,0 +1,62 @@ +# Experience Phase 11.10 — Analytics, AI Content & Enterprise Validation + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.10` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Analytics Reports/Snapshots, AI Content Hook Registrations/Dispatches | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.9` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Record + +| Input | Finding | +| --- | --- | +| Roadmap / manifest | Final Experience track phase = `experience-11.10` Analytics, AI Content & Enterprise Validation | +| Prior handover | [phase-11-9.md](phase-handover/phase-11-9.md) — connectors/widgets/notify complete; analytics and AI deferred | +| Boundaries | Analytics = local COUNT on `experience_db` only; AI via `AIProvider` contract — no model inference ownership | +| Existing codebase | Health advertised `analytics=False`, `ai_content=False`; permissions placeholders absent for analytics/ai_hooks | +| Missing vs DoD | No analytics report/snapshot aggregates, no AI hook registration/dispatch shells, no enterprise validation audit | + +Derived capabilities: tenant-scoped analytics report definitions with validated local metric keys; snapshot refresh via COUNT queries; AI content hook registrations and dispatches with mock `generate_content`; forbidden ownership keys in config/metadata/payload; full quality-gate validation and Experience track closure. + +## Objective + +Close the Experience Platform track (11.0–11.10) with analytics shells, optional AI content hooks, and enterprise validation — core flows work when AI is off. + +## Scope + +### In Scope + +- `ExperienceAnalyticsReportDefinition` + `ExperienceAnalyticsSnapshot` on `experience_db` +- `ExperienceAiContentHookRegistration` + `ExperienceAiContentDispatch` (registration_id UUID only, no ORM relationship) +- Local `METRIC_TABLE_MAP` COUNT metrics (workspaces, sites, pages, components, templates, forms, submissions, surveys, publish releases, widgets, connectors, notify, bundles) +- Permissions `experience.analytics.*`, `experience.ai_hooks.*` +- Events for create/update/delete + `analytics_snapshot.refreshed` + `ai_content_dispatch.generated` +- `MockAIProvider.generate_content` returning `ai:content:{uuid}` +- Alembic `0011_phase_1110_analytics_ai_hooks`; version `0.11.10.0` + +### Out of Scope + +- Cross-service analytics DB reads +- Model inference / LLM provider ownership +- New Experience phases beyond 11.10 + +### APIs + +| Path | Permission | +| --- | --- | +| `/api/v1/analytics-report-definitions` | `experience.analytics.*` | +| `/api/v1/analytics-snapshots` (+ `POST /refresh`) | `experience.analytics.*` | +| `/api/v1/ai-content-hook-registrations` | `experience.ai_hooks.*` | +| `/api/v1/ai-content-dispatches` (+ `POST /{id}/generate`) | `experience.ai_hooks.*` | + +## Completion Criteria + +- [x] Analytics/AI shells documented and tested +- [x] Core flows work when AI is off (analytics independent of AI) +- [x] Quality gates + phase handover per AI framework +- [x] Experience track 11.0–11.10 complete diff --git a/docs/experience-phase-11-2.md b/docs/experience-phase-11-2.md new file mode 100644 index 0000000..99ec46d --- /dev/null +++ b/docs/experience-phase-11-2.md @@ -0,0 +1,109 @@ +# Phase: Experience Component Library & Versioning (11.2) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.2` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Components, Component Versions, Page Component Placements | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.1` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Record + +| Input | Finding | +| --- | --- | +| Roadmap / manifest | Next unfinished Experience phase = `experience-11.2` Component Library & Versioning | +| Prior handover | [phase-11-1.md](phase-handover/phase-11-1.md) — versioned components only; no theme/layout | +| Boundaries | Experience owns components; no CRM/Hospitality/Marketplace/Storage ownership | +| Existing codebase | Sites/pages complete; `commands/`/`policies/`/`queries/`/`specifications/` were empty shells | +| Missing vs DoD | Immutable versions, composition shells, list filter/sort/search, policies, specs, commands/queries | + +Derived capabilities (this phase only): component catalog lifecycle, immutable published versions, page↔version placement pins, permissions/events/audit/tenant isolation. **Not** themes, layouts, templates, SEO, forms. + +## Objective + +Deliver a production-ready versioned component library on `experience_db` with immutable published revisions and composition shells that pin versions onto pages — without theme/layout engines. + +## Scope + +### In Scope + +- `ExperienceComponent` catalog (kinds, status, category, tags) +- `ExperienceComponentVersion` with draft → published (immutable) → retired +- `PageComponentPlacement` composition shells (slot_key, sort_order, props_override) +- Policies, specifications, commands, queries +- Pagination, filtering, sorting, searching on list APIs +- Permissions `experience.components.*`; events `experience.component*` / `experience.page_component.*` +- Alembic `0003_phase_112_components`; version `0.11.2.0` +- Seed catalog: `component_kinds` via `/capabilities` + +### Out of Scope + +- Theme / layout engines — Phase 11.3 +- Template catalog — Phase 11.4 +- Visual page builder UI / section trees +- Forms, SEO, PWA, analytics, AI content + +### Models + +| Entity | Soft delete | Audit | Tenant | Optimistic lock | +| --- | --- | --- | --- | --- | +| ExperienceComponent | Yes | Yes | Yes | Yes | +| ExperienceComponentVersion | Yes | Yes | Yes | Yes | +| PageComponentPlacement | Yes | Yes | Yes | Yes | + +### Permissions + +`experience.components.view|create|update|delete|manage` + +### Events + +`experience.component.created|updated|activated|deprecated|archived` + +`experience.component_version.created|updated|published|retired` + +`experience.page_component.placed|updated|removed` + +### APIs + +| Method | Path | +| --- | --- | +| * | `/api/v1/components` (+ activate/deprecate/archive/delete) | +| * | `/api/v1/component-versions` (+ publish/retire) | +| * | `/api/v1/page-component-placements` (+ delete) | + +List endpoints support `page`/`page_size`, filters, `q`, `sort_by`/`sort_dir`. + +### Artifact coverage (DoD) + +| Artifact | Status | +| --- | --- | +| Discovery | This record | +| Policies / Specs / Commands / Queries | Implemented | +| Soft delete / Optimistic lock / Audit / Tenant | Yes | +| Pagination / Filter / Sort / Search | Yes | +| Background jobs | N/A — no async work in phase boundary | +| Themes/layouts | Explicitly out of scope | + +### Tests + +- [x] Unit policies +- [x] API component/version/placement flow + immutability +- [x] Tenant isolation + optimistic lock +- [x] Architecture / permission / migration / docs + +## Completion Criteria + +- [x] Component library with immutable versions; tenant isolation tests green +- [x] No theme/layout engine +- [x] Docs / manifests / handover updated + +## Related Documents + +- [Handover](phase-handover/phase-11-2.md) +- [Phase 11.1](experience-phase-11-1.md) +- [Experience Roadmap](experience-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) diff --git a/docs/experience-phase-11-3.md b/docs/experience-phase-11-3.md new file mode 100644 index 0000000..8e1fc39 --- /dev/null +++ b/docs/experience-phase-11-3.md @@ -0,0 +1,126 @@ +# Phase: Experience Theme & Layout Engine (11.3) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.3` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Themes, Theme Versions, Layouts, Layout Versions, Site Theme Bindings, Page Layout Bindings | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.2` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md), [ADR-008](architecture/adr/ADR-008.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Record + +| Input | Finding | +| --- | --- | +| Roadmap / manifest | Next unfinished Experience phase = `experience-11.3` Theme & Layout Engine | +| Prior handover | [phase-11-2.md](phase-handover/phase-11-2.md) — themes/layouts only; no template catalog | +| Boundaries | Experience owns themes/layouts; brand tokens align with ADR-008; no Core DB access | +| Existing codebase | Components/versions/placements complete; themes/layouts were capability flags False | +| Missing vs DoD | Theme packs, dynamic layouts, directionality shells, site/page assignments, policies/specs/CQ | + +Derived capabilities: replaceable theme packs with immutable, versioned CSS tokens; dynamic layouts with immutable, versioned regions; RTL/LTR/both shells; bind a published theme version to a site and a published layout version to a page. **Not** templates, forms, SEO, visual builder UI. + +## Objective + +Deliver production-ready replaceable themes and dynamic layouts on `experience_db` using a catalog + immutable version model (mirrors the Phase 11.2 component/component-version pattern) with directionality-ready shells and binding APIs — without a template catalog engine. + +## Scope + +### In Scope + +- `ExperienceTheme` (catalog: code, name, direction_mode, status, latest/published version pointers) +- `ExperienceThemeVersion` (immutable once published: tokens, brand_shell, direction_mode override) +- `ExperienceLayout` (catalog: code, name, kind, direction_mode, status, latest/published version pointers) +- `ExperienceLayoutVersion` (immutable once published: regions, structure_shell) +- `SiteThemeBinding` (pins a published theme version to a site; replaceable) +- `PageLayoutBinding` (pins a published layout version to a page; replaceable) +- Policies, specifications, commands, queries +- Pagination / filter / sort / search on list APIs +- Permissions `experience.themes.*` / `experience.layouts.*` +- Events `experience.theme.*` / `experience.theme_version.*` / `experience.layout.*` / `experience.layout_version.*` / `experience.site_theme.*` / `experience.page_layout.*` +- Alembic `0004_phase_113_themes_layouts`; version `0.11.3.0` +- Seed discovery: `direction_modes` via `/capabilities` + +### Out of Scope + +- Template catalog / instantiation — Phase 11.4 +- Full localization media pipeline — Phase 11.5 +- Forms / surveys — Phase 11.6 +- Owning Core tenant brand tables (ADR-008 remains Core; Experience stores theme packs) + +### Models + +| Entity | Soft delete | Audit | Tenant | Optimistic lock | +| --- | --- | --- | --- | --- | +| ExperienceTheme | Yes | Yes | Yes | Yes | +| ExperienceThemeVersion | Yes | Yes | Yes | Yes | +| ExperienceLayout | Yes | Yes | Yes | Yes | +| ExperienceLayoutVersion | Yes | Yes | Yes | Yes | +| SiteThemeBinding | Yes | Yes | Yes | Yes | +| PageLayoutBinding | Yes | Yes | Yes | Yes | + +### Permissions + +`experience.themes.view|create|update|delete|manage` + +`experience.layouts.view|create|update|delete|manage` + +### Events + +`experience.theme.created|updated|activated|deprecated|archived` + +`experience.theme_version.created|updated|published|retired` + +`experience.layout.created|updated|activated|deprecated|archived` + +`experience.layout_version.created|updated|published|retired` + +`experience.site_theme.assigned|replaced|unassigned` + +`experience.page_layout.assigned|replaced|unassigned` + +### APIs + +| Method | Path | +| --- | --- | +| * | `/api/v1/themes` (+ activate/deprecate/archive/delete) | +| * | `/api/v1/theme-versions` (+ publish/retire) | +| * | `/api/v1/layouts` (+ activate/deprecate/archive/delete) | +| * | `/api/v1/layout-versions` (+ publish/retire) | +| * | `/api/v1/site-theme-bindings` (+ unassign) | +| * | `/api/v1/page-layout-bindings` (+ unassign) | + +### Artifact coverage (DoD) + +| Artifact | Status | +| --- | --- | +| Discovery | This record | +| Policies / Specs / Commands / Queries | Implemented | +| Soft delete / Optimistic lock / Audit / Tenant | Yes | +| Pagination / Filter / Sort / Search | Yes | +| Background jobs | N/A — no async work in phase boundary | +| Templates | Explicitly out of scope | + +### Tests + +- [x] Unit policies +- [x] API theme/layout/assignment flow +- [x] Tenant isolation + optimistic lock +- [x] Architecture / permission / migration / docs + +## Completion Criteria + +- [x] Theme and layout APIs + tests green +- [x] No template catalog engine +- [x] Docs / manifests / handover updated + +## Related Documents + +- [Handover](phase-handover/phase-11-3.md) +- [Phase 11.2](experience-phase-11-2.md) +- [Experience Roadmap](experience-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [ADR-008](architecture/adr/ADR-008.md) diff --git a/docs/experience-phase-11-4.md b/docs/experience-phase-11-4.md new file mode 100644 index 0000000..359cb41 --- /dev/null +++ b/docs/experience-phase-11-4.md @@ -0,0 +1,110 @@ +# Phase: Experience Template System (11.4) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.4` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Templates, Template Versions, Template Instantiations | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.3` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md), [ADR-008](architecture/adr/ADR-008.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Record + +| Input | Finding | +| --- | --- | +| Roadmap / manifest | Next unfinished Experience phase = `experience-11.4` Template System | +| Prior handover | [phase-11-3.md](phase-handover/phase-11-3.md) — themes/layouts complete; no template catalog | +| Boundaries | Experience owns the template catalog; templates compose existing themes/layouts/components by reference only; no Core DB access | +| Existing codebase | Template scaffolding (models/services/schemas/repositories/commands/queries/policies/validators/specifications/APIs, and a `.future`-suffixed Alembic revision) already existed on disk but was intentionally unwired at the end of the Phase 11.3 repair | +| Missing vs DoD | Alembic head still pinned to `0004`; `app.models.templates` not imported; template routers not mounted; capability flags still `False`; no phase test suite | + +Derived capabilities: a tenant catalog of reusable page-type templates; immutable published template versions carrying a `blueprint_json` shell; one-click instantiation of a published template version into a new page (optionally wiring an existing active theme/layout by reference and seeding component placement shells). **Not** forms, surveys, locales, media pipelines, or SEO — those remain Phase 11.5+. + +## Objective + +Re-wire and validate the existing Phase 11.4 Template System scaffolding on `experience_db` so tenants can define a template catalog, publish immutable template versions, and instantiate a published version into a concrete page — reusing the Phase 11.1 sites/pages resources and the Phase 11.3 theme/layout catalogs by reference only. + +## Scope + +### In Scope + +- `ExperienceTemplate` (catalog: code, name, description, page_type, category, tags, starter-pack flag, status, latest/published version pointers) +- `ExperienceTemplateVersion` (immutable once published: `blueprint_json`, changelog) +- `TemplateInstantiation` (audit trail: template + version + site → created page, result/error payload) +- Policies, specifications, commands, queries +- Pagination / filter / sort / search on list APIs +- Permissions `experience.templates.*` (full `view|create|update|delete|manage` CRUD tree) +- Events `experience.template.*` / `experience.template_version.*` / `experience.template.instantiated` +- Alembic `0005_phase_114_templates` (head); version `0.11.4.0` +- `/capabilities` flags: `templates`, `template_versions`, `template_instantiation` + +### Out of Scope + +- Forms / surveys / appointment page shells — Phase 11.6 +- Full localization + media reference pipeline — Phase 11.5 +- SEO metadata / sitemap / PWA — Phase 11.7 +- Owning the theme/layout/component engines themselves (Experience only *references* published theme/layout versions and component codes from a template's blueprint) + +### Models + +| Entity | Soft delete | Audit | Tenant | Optimistic lock | +| --- | --- | --- | --- | --- | +| ExperienceTemplate | Yes | Yes | Yes | Yes | +| ExperienceTemplateVersion | Yes | Yes | Yes | Yes | +| TemplateInstantiation | Yes | Yes | Yes | Yes | + +### Permissions + +`experience.templates.view|create|update|delete|manage` + +### Events + +`experience.template.created|updated|activated|deprecated|archived` + +`experience.template_version.created|updated|published|retired` + +`experience.template.instantiated` + +### APIs + +| Method | Path | +| --- | --- | +| * | `/api/v1/templates` (+ activate/deprecate/archive/delete) | +| * | `/api/v1/template-versions` (+ publish/retire) | +| * | `/api/v1/template-instantiations` (instantiate + list + get) | + +### Artifact coverage (DoD) + +| Artifact | Status | +| --- | --- | +| Discovery | This record | +| Policies / Specs / Commands / Queries | Implemented | +| Soft delete / Optimistic lock / Audit / Tenant | Yes | +| Pagination / Filter / Sort / Search | Yes | +| Background jobs | N/A — instantiation runs synchronously within the request | +| Forms / Surveys | Explicitly out of scope | + +### Tests + +- [x] Health / capabilities reflect phase 11.4 +- [x] Full lifecycle: create template → create version → publish (auto-activates template) → explicit activate → instantiate (creates page) → list/get instantiations +- [x] Tenant isolation +- [x] Immutable published version update blocked (409) and re-publish blocked (409) +- [x] Architecture / permission / migration / docs + +## Completion Criteria + +- [x] Template APIs + validators + tests green +- [x] No forms/surveys engine +- [x] Docs / manifests / handover updated + +## Related Documents + +- [Handover](phase-handover/phase-11-4.md) +- [Phase 11.3](experience-phase-11-3.md) +- [Experience Roadmap](experience-roadmap.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [ADR-008](architecture/adr/ADR-008.md) diff --git a/docs/experience-phase-11-5.md b/docs/experience-phase-11-5.md new file mode 100644 index 0000000..4da0630 --- /dev/null +++ b/docs/experience-phase-11-5.md @@ -0,0 +1,104 @@ +# Phase: Experience Localization, RTL/LTR & Media (11.5) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.5` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Site Locale Bindings, Localizations, Media Refs | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.4` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md), [ADR-008](architecture/adr/ADR-008.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Record + +| Input | Finding | +| --- | --- | +| Roadmap / manifest | Next unfinished Experience phase = `experience-11.5` Localization, RTL/LTR & Media | +| Prior handover | [phase-11-4.md](phase-handover/phase-11-4.md) — templates complete; no localization/media pipeline | +| Boundaries | Experience owns locale bindings + localization shells + media **refs**; binaries only via File Storage; no Core DB access | +| Existing codebase | Foundation `ExperienceLocaleProfile` (11.0) provides language/direction catalogs; themes already expose `DirectionMode` shells; StorageProvider contract exists | +| Missing vs DoD | No site↔locale bindings, no target localizations, no media-ref aggregates/APIs, capabilities still at 11.4 | + +Derived capabilities: bind locale profiles (with concrete RTL/LTR) to sites; upsert multi-locale content shells for experience targets; attach media via Storage file refs only. **Not** forms, surveys, appointments, SEO/PWA — those remain Phase 11.6+. + +## Objective + +Deliver Localization, first-class RTL/LTR directionality beyond foundation locale profiles, and media references that store Storage refs only (no binaries in `experience_db`) on the Experience Platform. + +## Scope + +### In Scope + +- `SiteLocaleBinding` (site ↔ locale profile, locale_code, text_direction rtl/ltr, default flag) +- `ExperienceLocalization` (target_type/target_id/locale/text_direction + title/summary/content_json shells) +- `ExperienceMediaRef` (`storage_file_ref` only; kind/alt/caption/locale; reject data-URI / binary metadata keys) +- Policies, specifications, commands, queries +- Pagination / filter / sort / search on list APIs +- Permissions `experience.locales.*` and `experience.media_refs.*` +- Events `experience.site_locale.*` / `experience.localization.*` / `experience.media_ref.*` +- Alembic `0006_phase_115_localization_media` (head); version `0.11.5.0` +- `/capabilities` flags: `site_locale_bindings`, `localizations`, `rtl_ltr`, `media_refs`, `storage_media_refs_only` + +### Out of Scope + +- Forms / surveys / appointment page shells — Phase 11.6 +- SEO metadata / sitemap / PWA — Phase 11.7 +- Owning File Storage blobs or binary columns in `experience_db` +- Form submission / booking engines + +### Models + +| Entity | Soft delete | Audit | Tenant | Optimistic lock | +| --- | --- | --- | --- | --- | +| SiteLocaleBinding | Yes | Yes | Yes | Yes | +| ExperienceLocalization | Yes | Yes | Yes | Yes | +| ExperienceMediaRef | Yes | Yes | Yes | Yes | + +### Permissions + +`experience.locales.view|create|update|delete|manage` + +`experience.media_refs.view|create|update|delete|manage` + +### Events + +`experience.site_locale.assigned|updated|replaced|unassigned` + +`experience.localization.upserted|updated|archived` + +`experience.media_ref.created|updated|archived` + +### APIs + +| Method | Path | +| --- | --- | +| * | `/api/v1/site-locale-bindings` | +| * | `/api/v1/localizations` (upsert + list/get/patch/delete) | +| * | `/api/v1/media-refs` | + +### Artifact coverage (DoD) + +| Artifact | Status | +| --- | --- | +| Discovery | This record | +| Policies / Specs / Commands / Queries | Implemented | +| Soft delete / Optimistic lock / Audit / Tenant | Yes | +| Pagination / Filter / Sort / Search | Yes | +| Background jobs | N/A | +| Forms / Surveys | Explicitly out of scope | + +### Tests + +- [x] Health / capabilities reflect phase 11.5 +- [x] Site locale binding + localization upsert lifecycle +- [x] Media ref create + reject data-URI / binary metadata +- [x] Tenant isolation + no binary columns on media model +- [x] Architecture / permission / migration / docs + +## Completion Criteria + +- [x] Localization and media-ref APIs + isolation tests green +- [x] No form/survey/appointment engines +- [x] Docs + handover + manifests updated to `0.11.5.0` diff --git a/docs/experience-phase-11-6.md b/docs/experience-phase-11-6.md new file mode 100644 index 0000000..609aff8 --- /dev/null +++ b/docs/experience-phase-11-6.md @@ -0,0 +1,60 @@ +# Phase: Experience Forms, Surveys & Appointments (11.6) + +| Field | Value | +| --- | --- | +| Identifier | `experience-11.6` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Forms, Form Submissions, Surveys, Survey Responses, Appointment Pages, Booking Refs | +| Service(s) | `experience` (Torbat Pages) | +| Depends On | `experience-11.5` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Enterprise Phase Discovery Record + +| Input | Finding | +| --- | --- | +| Roadmap / manifest | Next unfinished Experience phase = `experience-11.6` Forms, Surveys & Appointments | +| Prior handover | [phase-11-5.md](phase-handover/phase-11-5.md) — locales/media complete; no form/survey/appointment engines | +| Boundaries | Experience owns page shells + submission/booking **refs**; CRM/Communication/scheduling engines remain external (API/Events only) | +| Existing codebase | Page types include `form` / `appointment`; component kind `form_shell`; permissions had placeholder `experience.forms.*` | +| Missing vs DoD | No form/survey/appointment aggregates, APIs, policies, or submission/booking-ref shells | + +Derived capabilities: define forms/surveys as page shells; receive submissions/responses with CRM/Communication refs; define appointment pages with provider/service refs; store external booking refs. **Not** SEO/PWA, deep CRM, or native scheduling ownership. + +## Objective + +Deliver Forms, Surveys, and Appointment page shells on `experience_db` with submission/booking references only — no ownership of CRM contacts or vertical booking engines. + +## Scope + +### In Scope + +- `ExperienceForm` + `FormSubmission` (payload + crm/communication/external refs) +- `ExperienceSurvey` + `SurveyResponse` +- `ExperienceAppointmentPage` + `AppointmentBookingRef` (`external_booking_ref` required) +- Policies rejecting inactive definitions for intake; validators rejecting ownership keys (`crm_contact_id`, `booking_engine`, …) +- Permissions `experience.forms.*` / `experience.surveys.*` / `experience.appointments.*` +- Events for create/update/archive + submission/response/booking-ref lifecycle +- Alembic `0007_phase_116_forms_surveys_appointments`; version `0.11.6.0` + +### Out of Scope + +- SEO / sitemap / PWA — Phase 11.7 +- Owning CRM contacts, Communication providers, or scheduling calendars +- Deep form-builder UI / CMS field graphs + +### APIs + +| Path | Permission | +| --- | --- | +| `/api/v1/forms` · `/api/v1/form-submissions` | `experience.forms.*` | +| `/api/v1/surveys` · `/api/v1/survey-responses` | `experience.surveys.*` | +| `/api/v1/appointment-pages` · `/api/v1/appointment-booking-refs` | `experience.appointments.*` | + +## Completion Criteria + +- [x] Forms/surveys/appointment APIs + tests green +- [x] No deep CRM/scheduling ownership; refs only +- [x] Docs + handover + manifests at `0.11.6.0` diff --git a/docs/experience-phase-11-7.md b/docs/experience-phase-11-7.md new file mode 100644 index 0000000..15367b2 --- /dev/null +++ b/docs/experience-phase-11-7.md @@ -0,0 +1,11 @@ +# Experience Phase 11.7 — Publishing, SEO and PWA + +Phase 11.7 completes the Torbat Pages publishing control plane in `experience_db`. +It adds tenant-scoped publish releases and domain edge bindings, SEO metadata, +sitemap and robots shells, and PWA manifests. DNS, SSL, edge, storage, and +service-worker payloads remain external references; private keys and PEM data +are rejected. + +The API exposes `/api/v1/publish-releases`, `/domain-edge-bindings`, +`/seo-metadata`, `/sitemap-shells`, `/robots-shells`, and `/pwa-manifests`. +All writes are audited, emit Experience events, and enforce optimistic versions. diff --git a/docs/experience-phase-11-8.md b/docs/experience-phase-11-8.md new file mode 100644 index 0000000..780e881 --- /dev/null +++ b/docs/experience-phase-11-8.md @@ -0,0 +1,15 @@ +# Experience Phase 11.8 — Bundles, Licensing & Feature Toggles + +Phase 11.8 completes the Torbat Pages bundle control plane in `experience_db`. +It adds tenant-scoped bundle definitions, workspace/site bundle assignments, +Core entitlement reference bindings, and feature toggles. + +APIs: `/api/v1/bundle-definitions`, `/api/v1/tenant-bundles`, +`/api/v1/license-bindings`, and `/api/v1/feature-toggles`. All aggregates use +soft deletion, optimistic locking, audit fields, tenant isolation, filtered +pagination, and `experience.*` events. + +Experience does not own the Core plan, subscription, entitlement, or billing +engines. It stores only `core_entitlement_ref`; metadata keys `plan_engine`, +`subscription_engine`, `billing_ledger`, and `core_plan_id` are rejected. +Widgets and consumer connectors remain Phase 11.9; analytics and AI remain 11.10. diff --git a/docs/experience-phase-11-9.md b/docs/experience-phase-11-9.md new file mode 100644 index 0000000..b8cd8e1 --- /dev/null +++ b/docs/experience-phase-11-9.md @@ -0,0 +1,19 @@ +# Experience Phase 11.9 — Consumer Integrations & Widgets + +Phase 11.9 adds consumer connector registrations and dispatches, embeddable +widget definitions/instances, and Communication notify client dispatches to +the Torbat Pages control plane in `experience_db`. + +APIs: `/api/v1/consumer-connector-registrations`, +`/api/v1/consumer-connector-dispatches` (with `POST /{id}/execute`), +`/api/v1/widget-definitions`, `/api/v1/widget-instances` +(with `GET /by-embed/{embed_ref}`), and `/api/v1/notify-dispatches` +(with `POST /{id}/send`). All aggregates use soft deletion, optimistic locking, +audit fields, tenant isolation, filtered pagination, and `experience.*` events. + +Experience does not own SMS providers, menu/catalog engines, CRM contacts, +booking/scheduling, analytics, AI inference, or Core plan/subscription/billing. +Forbidden ownership keys in `config_json`, `metadata_json`, and `payload_json` +are rejected. Mock providers simulate dispatch/send locally with no network calls. +UI rendering remains in the frontend (ADR-012/016). Analytics and AI content +remain Phase 11.10. diff --git a/docs/experience-phase-xp-reg.md b/docs/experience-phase-xp-reg.md new file mode 100644 index 0000000..8937e1b --- /dev/null +++ b/docs/experience-phase-xp-reg.md @@ -0,0 +1,132 @@ +# Phase: Experience Platform Registration (XP-Reg) + +| Field | Value | +| --- | --- | +| Identifier | `experience-reg` | +| Status | Complete | +| Owner | Platform | +| Module(s) | Experience Platform (registration only) | +| Service(s) | `experience` (registered; not yet implemented) | +| Depends On | `onboarding-4`, `ai-framework` | +| ADR(s) | [ADR-016](architecture/adr/ADR-016.md) | +| Manifest | [phase-manifest.yaml](ai-framework/phase-manifest.yaml) | + +## Objective + +Register the independent Enterprise Experience Platform (commercial product **Torbat Pages**) in architecture, manifests, registries, glossary, and roadmap — with zero business code — so Phase 11.0 can scaffold the service safely. + +## Scope + +### In Scope + +- ADR-016 Accepted — independent `experience` service + `experience_db` +- Service registration in [service-manifest.yaml](ai-framework/service-manifest.yaml) +- Phase registration XP-Reg + 11.0–11.10 in [phase-manifest.yaml](ai-framework/phase-manifest.yaml) +- Module registry entry + planned module inventory +- Glossary terms for Experience / Torbat Pages +- Module boundaries + database architecture + schema notes +- Roadmap and phase area README +- Progress / next-steps / roadmap updates +- Phase handover + +### Modules + +| Module | Change type | Notes | +| --- | --- | --- | +| Experience Platform | register | Docs/manifests only | + +### Models + +N/A — documentation-only registration. No ORM models. + +### Repositories + +N/A + +### Services + +N/A + +### Validators + +N/A + +### Permissions + +| Permission | Description | +| --- | --- | +| `experience.*` | Planned tree; concrete resources from Phase 11.0 | + +### Events + +| Event | When | +| --- | --- | +| `experience.*` (planned) | Publish-only catalog grows per phase | + +### APIs + +| Method | Path | Permission | +| --- | --- | --- | +| — | — | N/A until Phase 11.0 (`/health`, `/capabilities`, `/metrics`, `/api/v1/*`) | + +### Tests + +- [x] Unit — N/A (docs-only) +- [x] Repository — N/A +- [x] Service — N/A +- [x] Integration / API — N/A +- [x] Migration — N/A +- [x] Permission — documented planned prefix only +- [x] Security — N/A runtime +- [x] Performance — N/A +- [x] Tenant Isolation — architecture declared; enforced from 11.0 +- [x] Documentation Validation — manifests parse; links resolve; registries aligned + +### Documentation + +- [x] Phase doc (this file) +- [x] Progress / Next Steps / Roadmap +- [x] Module Registry / glossary / boundaries +- [x] Reference schema note for `experience_db` +- [x] ADR-016 +- [x] Manifests updated +- [x] Handover completed + +## Out of Scope + +- Any `backend/services/experience` code, models, APIs, migrations, compose wiring +- Page builder UI / public render UI +- Implementing Sites, Components, Themes, Templates, Forms, SEO, Bundles +- Migrating or deleting historical `website_builder` scaffold (prefer Experience for new work per ADR-016) + +## Dependencies + +| Dependency | Type | Notes | +| --- | --- | --- | +| AI Framework | Docs | Loop / gates | +| ADR-001 / 002 / 003 / 006 / 008 / 011 / 012 / 015 | Architecture | Boundaries | +| Core Platform | Service | Entitlement from 11.0 | + +## Risks + +| Risk | Mitigation | +| --- | --- | +| Confusion with `website_builder` scaffold | ADR-016 + glossary + registry notes prefer `experience` | +| Scope creep into page engines in Reg | Explicit docs-only completion criteria | + +## Completion Criteria + +- [x] Service registered with `experience_db` and `experience.*` +- [x] Phases 11.0–11.10 registered +- [x] Module registry, glossary, module-boundaries, ADR-016 updated +- [x] Progress and next-steps updated; handover complete +- [x] No business code in this phase +- [x] Quality gates for documentation/architecture/manifest validation passed + +## Related Documents + +- [Experience Roadmap](experience-roadmap.md) +- [Handover](phase-handover/phase-xp-reg.md) +- [ADR-016](architecture/adr/ADR-016.md) +- [Phases / Experience](phases/Experience/README.md) +- [AI Framework](ai-framework/README.md) diff --git a/docs/experience-roadmap.md b/docs/experience-roadmap.md new file mode 100644 index 0000000..0fab86e --- /dev/null +++ b/docs/experience-roadmap.md @@ -0,0 +1,191 @@ +# Experience Platform — Roadmap + +> Registration document. **No implementation in this phase.** +> Framework: [ai-framework/](ai-framework/README.md) · ADR: [ADR-016](architecture/adr/ADR-016.md) · Manifests: [phase-manifest.yaml](ai-framework/phase-manifest.yaml), [service-manifest.yaml](ai-framework/service-manifest.yaml) + +| Field | Value | +| --- | --- | +| Service | `experience` | +| Commercial Product | Torbat Pages | +| Database | `experience_db` | +| Permission Prefix | `experience.*` | +| API Port (planned) | 8008 | +| Phases | 11.0 – 11.10 | +| Status | Phase 11.10 complete — Experience track 11.0–11.10 closed | + +## Vision + +Deliver an independent, multi-tenant **Enterprise Experience Platform** inside TorbatYar so every current and future service can publish websites, landings, mini sites, menus, catalogs, profiles, forms, and campaign surfaces without owning page/theme/template engines — and without duplicating business logic or sharing databases. + +## Business Scope + +In scope (over Phases 11.0–11.10): + +- Website Builder and Landing Pages +- Mini Sites and Business Profile Pages +- Restaurant Pages, Digital Menus, Digital Catalogs +- Link In Bio, Event, Campaign, Portfolio Pages +- Personal and Corporate Websites +- Forms, Surveys, Appointment Pages +- Public Profile, QR, SEO, Blog, Media, Knowledge Pages +- Versioned components, replaceable themes, dynamic layouts +- Template-based authoring; localization; RTL and LTR +- SEO, Mobile First, PWA readiness +- Optional AI content generation hooks +- Analytics shells; feature toggles; bundle licensing +- Widgets and consumer integration contracts +- Custom domain binding refs (DNS ownership remains platform edge / Core patterns) + +## Architecture Scope + +| Rule | Reference | +| --- | --- | +| Independent service + `experience_db` | [ADR-016](architecture/adr/ADR-016.md), [ADR-001](architecture/adr/ADR-001.md) | +| Layering API → Service → Repository → Model | [service-architecture.md](architecture/service-architecture.md) | +| Tenant-aware business tables | [ADR-003](architecture/adr/ADR-003.md) | +| Outbox-ready events `experience.*` | [ADR-006](architecture/adr/ADR-006.md) | +| White-label / branding alignment | [ADR-008](architecture/adr/ADR-008.md) | +| Messaging via Communication only | [ADR-012](architecture/adr/ADR-012.md) | +| Loyalty / CRM / vertical refs only | [ADR-011](architecture/adr/ADR-011.md) | +| AI optional / independent | [ai-architecture.md](architecture/ai-architecture.md) | +| FE/BE separation for builders & public shells | [ADR-002](architecture/adr/ADR-002.md) | +| Implementation via AI Framework | [ai-framework/](ai-framework/README.md) | + +## Platform Principles + +| Principle | Meaning | +| --- | --- | +| Every Page is a Resource | First-class tenant-scoped resource with identity, lifecycle, permissions | +| Every Component is Versioned | Immutable versions; publish pins versions | +| Every Theme is Replaceable | Themes swap without rewriting page trees | +| Every Layout is Dynamic | Layout composition is data-driven | +| Template Based | New experiences start from templates | +| Localization | Multi-locale content and metadata | +| RTL and LTR | Directionality first-class | +| SEO | Canonical/meta/sitemap/robots shells | +| Mobile First | Responsive defaults; mobile contracts | +| PWA | Manifest/service-worker readiness hooks | +| AI generation | Optional; core works offline | +| Analytics | Event/metrics shells | +| Feature Toggles | Capability flags per tenant/bundle | +| Bundle Licensing | Commercial packs gated via entitlement | + +## Supported Consumers + +Hospitality · Marketplace · Sports Center · CRM · Accounting · Loyalty · Communication · Academy · Clinic · Hotel · Tourism · Future Services + +## Supported Bundles + +Mini Site · Business Website · Landing Builder · Digital Menu · Digital Catalog · Portfolio · Blog · Appointment · Campaign · QR Landing · Bio Link · Custom Domain · SEO · Analytics · AI Content · Forms · Widgets + +## Module Map + +| Module | Responsibility | Introduced | +| --- | --- | --- | +| Sites | Site aggregate / host binding refs | 11.1 | +| Pages | Page resources and lifecycle | 11.1 | +| Page Types | Typed page kinds (landing, menu, bio, …) | 11.1 | +| Components | Versioned component definitions | 11.2 | +| Component Versions | Immutable component revisions | 11.2 | +| Themes | Replaceable theme packs | 11.3 | +| Layouts | Dynamic layout definitions | 11.3 | +| Templates | Template catalog and instantiation | 11.4 | +| Locales | Localization and directionality | 11.5 | +| Media Refs | Storage file references (no binaries) | 11.5 | +| Forms | Form definitions and submissions shells | 11.6 | +| Surveys | Survey definitions | 11.6 | +| Appointments | Appointment page booking shells (refs) | 11.6 | +| Publishing | Publish/unpublish workflows | 11.7 | +| Domains | Custom domain binding refs | 11.7 | +| SEO | SEO metadata, sitemap/robots shells | 11.7 | +| PWA | PWA manifest readiness | 11.7 | +| Bundles | Capability bundles / licensing | 11.8 | +| Feature Toggles | Experience feature flags | 11.8 | +| Widgets | Embeddable widget contracts | 11.9 | +| Consumer Connectors | Vertical integration contracts | 11.9 | +| Analytics | Analytics shells | 11.10 | +| AI Hooks | Optional AI content contracts | 11.10 | +| Audit / Config / Health | Foundation shells | 11.0 | + +Canonical inventory: [module-registry.md](module-registry.md#experience). + +## Integration Map + +``` +Verticals (Hospitality / Marketplace / Sports / CRM / Clinic / Hotel / Tourism / …) + │ + │ API / Events (entity refs only — no shared DB) + ▼ + Experience Platform ──API─────────▶ Core (entitlement, tenant, branding) + Experience Platform ──API─────────▶ Storage (media refs) + Experience Platform ──API/Events──▶ CRM (contact/org refs) + Experience Platform ──API/Events──▶ Loyalty (optional) + Experience Platform ──API/Events──▶ Communication (notify / OTP forms) + Experience Platform ──API/Events──▶ Hospitality / Marketplace / Sports / … + Experience Platform ──API/Events──▶ Delivery (optional campaign logistics refs) + Experience Platform ──API─────────▶ AI Platform (optional content assist) + Experience Platform ──API/Events──▶ Automation (optional triggers) +``` + +Forbidden: cross-DB queries, embedding SMS providers inside Experience, owning Restaurant menu/item catalogs as source of truth, owning CRM contacts, storing media binaries, creating Accounting journals. + +## Disambiguation + +| Term | Owner | +| --- | --- | +| Tenant branding / white-label defaults | Core (+ ADR-008) | +| Page / site / theme / template resources | Experience Platform (`experience`) | +| Menu item catalog / POS menu | Hospitality / Restaurant services | +| Product catalog | Marketplace / Ecommerce | +| Historical `website_builder` scaffold | Prefer Experience for new work | + +## Phase Map + +| Phase | ID | Name | Status | +| --- | --- | --- | --- | +| Reg | `experience-reg` | Platform Registration | Complete | +| 11.0 | `experience-11.0` | Foundation | Complete | +| 11.1 | `experience-11.1` | Sites & Page Resources | Complete | +| 11.2 | `experience-11.2` | Component Library & Versioning | Complete | +| 11.3 | `experience-11.3` | Theme & Layout Engine | Complete | +| 11.4 | `experience-11.4` | Template System | Complete | +| 11.5 | `experience-11.5` | Localization, RTL/LTR & Media | Complete | +| 11.6 | `experience-11.6` | Forms, Surveys & Appointments | Complete | +| 11.7 | `experience-11.7` | Publishing, Domains, SEO & PWA | Complete | +| 11.8 | `experience-11.8` | Bundles, Licensing & Feature Toggles | Complete | +| 11.9 | `experience-11.9` | Consumer Integrations & Widgets | Complete | +| 11.10 | `experience-11.10` | Analytics, AI Content & Enterprise Validation | Complete | + +Details (dependencies, required docs/services, completion criteria): [phase-manifest.yaml](ai-framework/phase-manifest.yaml). + +## Platform Expose (every implementation phase) + +Health API · Capability API · Metrics · Events · REST APIs · Permission APIs + +## Future Expansion + +- Additional page-type packs without vertical code changes +- Third-party theme/component marketplaces behind provider protocols +- Deeper render CDN / edge caching when scoped +- Cross-tenant platform-admin experience analytics (explicit + audited) + +## Out of Scope + +- Owning Accounting ledgers or Posting Engine +- Owning Sales CRM aggregates +- Owning Loyalty ledger / campaigns +- Owning SMS/email/push providers (Communication owns them) +- Owning Hospitality menu/item or Marketplace product source of truth +- Owning File Storage binaries +- Implementing Phase 11.x business code in the registration phase +- Page Builder / public site UI (frontend phases) +- Replacing Core white-label tenant branding ownership (Experience consumes it) + +## Related Documents + +- [ADR-016](architecture/adr/ADR-016.md) +- [Phases / Experience](phases/Experience/README.md) +- [Phase Handover XP-Reg](phase-handover/phase-xp-reg.md) +- [Module Registry](module-registry.md#experience) +- [Module Boundaries](architecture/module-boundaries.md) +- [AI Development Framework](ai-framework/README.md) diff --git a/docs/phase-handover/phase-11-0.md b/docs/phase-handover/phase-11-0.md new file mode 100644 index 0000000..900a070 --- /dev/null +++ b/docs/phase-handover/phase-11-0.md @@ -0,0 +1,104 @@ +# Phase Handover — Experience 11.0 Foundation + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.0` | +| Title | Experience Platform Foundation | +| Status | Complete | +| Service(s) | `experience` | +| Version | `0.11.0.0` | +| Date | 2026-07-25 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md) | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Foundation scaffold | `backend/services/experience` | Pattern from Delivery/Hospitality | +| Provider contracts | `app/providers/contracts.py` | Protocols only | +| In-memory event publisher | `app/events/publisher.py` | Testable publish-only | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| GET | `/health` | Public | Liveness | +| GET | `/capabilities` | Public | Feature discovery | +| GET | `/metrics` | Public | Discovery shell | +| * | `/api/v1/workspaces` | `experience.workspaces.*` | Root workspace | +| * | `/api/v1/locale-profiles` | `experience.locale_profiles.*` | RTL/LTR locale shells | +| * | `/api/v1/external-providers` | `experience.external_providers.*` | Theme/CDN/etc adapters | +| * | `/api/v1/render-engines` | `experience.render_engines.*` | Future SSG/render adapters | +| * | `/api/v1/configurations` | `experience.configurations.*` | Config shells | +| * | `/api/v1/settings` | `experience.settings.*` | KV settings | +| GET | `/api/v1/audit` | `experience.audit.view` | Audit trail | + +## Events + +| Event type | Domain | Version | +| --- | --- | --- | +| `experience.workspace.created/updated` | Workspace | 1 | +| `experience.locale_profile.created/updated` | Locale | 1 | +| `experience.external_provider.registered/updated` | Providers | 1 | +| `experience.render_engine.registered/updated` | Render | 1 | +| `experience.configuration.created/updated` | Config | 1 | +| `experience.setting.upserted` | Settings | 1 | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Render engines | Register adapter_key | Implement full SSG in this phase | +| External providers | ProviderKind + credentials_ref | Store secrets in plain config | +| Consumer integrations | Later 11.9 connectors | Shared DB with Hospitality/Marketplace | + +## Known Limitations + +- No sites/pages/components/themes/templates/forms yet (11.1+) +- No bundle licensing engine yet (11.8) +- Event bus is in-memory publish-only (no outbox table yet) +- Public site / builder UI remains frontend + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0001_initial` | +| Upgrade steps | `alembic upgrade head` after `experience_db` exists | +| Downgrade support | `drop_all` via revision downgrade | +| Data backfill | N/A | +| Breaking changes | None (new service) | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| ADR-016 / XP-Reg | Docs | Boundaries | +| Core Platform | Service | Entitlement / JWT | +| shared-lib | Library | Envelope / exceptions | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `experience-11.1` Sites & Page Resources | +| Blockers | None | +| Entry checklist | Read this handover + [experience-phase-11-0.md](../experience-phase-11-0.md) + [experience-roadmap.md](../experience-roadmap.md); implement sites/pages only — no theme/template engines | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No unfinished claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [Experience Phase 11.0](../experience-phase-11-0.md) +- [Experience Roadmap](../experience-roadmap.md) +- [ADR-016](../architecture/adr/ADR-016.md) +- [Service README](../../backend/services/experience/README.md) diff --git a/docs/phase-handover/phase-11-1.md b/docs/phase-handover/phase-11-1.md new file mode 100644 index 0000000..960fae3 --- /dev/null +++ b/docs/phase-handover/phase-11-1.md @@ -0,0 +1,88 @@ +# Phase Handover — Experience 11.1 Sites & Page Resources + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.1` | +| Title | Sites & Page Resources | +| Status | Complete | +| Service(s) | `experience` (`experience_db`) | +| Version | `0.11.1.0` | +| Date | 2026-07-25 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md) | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Sites module | `app/models/sites.py`, `services/sites.py` | Page types + publish lifecycle | +| Domain bindings | `ExperienceSiteDomain` | Host refs only; no DNS ownership | + +## Public APIs + +| Method | Path | Permission | +| --- | --- | --- | +| CRUD + publish/archive | `/api/v1/sites` | `experience.sites.*` | +| CRUD + publish/archive | `/api/v1/pages` | `experience.pages.*` | +| CRUD | `/api/v1/site-domains` | `experience.domains.*` | + +## Events + +| Event type | Notes | +| --- | --- | +| `experience.site.created/updated/published/unpublished/archived` | Site lifecycle | +| `experience.page.created/updated/published/unpublished/archived` | Page lifecycle | +| `experience.site_domain.bound/updated` | Domain binding | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| `content_shell` / `seo_shell` | Later builder/SEO phases fill structure | Treat as full page builder in 11.1 | +| `external_refs` | Vertical entity refs (menu/product/…) | Shared DB with Hospitality/Marketplace | +| Domain verification | Mark `is_verified` when edge confirms | Own Nginx/DNS in Experience | + +## Known Limitations + +- No sections/rows/columns/widgets (11.2) +- No themes/templates +- Domain verification token is local shell only +- Public render UI remains frontend + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0002_phase_111_sites_pages` | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Drops `experience_sites`, `experience_pages`, `experience_site_domains` | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | +| --- | --- | +| experience-11.0 workspaces | Required parent | +| ADR-016 | Boundaries | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `experience-11.2` Component Library & Versioning | +| Entry checklist | Read this handover + phase doc; implement versioned components only — no theme engine yet | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Self audit completed + +## Related Documents + +- [Experience Phase 11.1](../experience-phase-11-1.md) +- [Experience Roadmap](../experience-roadmap.md) +- [ADR-016](../architecture/adr/ADR-016.md) diff --git a/docs/phase-handover/phase-11-10.md b/docs/phase-handover/phase-11-10.md new file mode 100644 index 0000000..e310726 --- /dev/null +++ b/docs/phase-handover/phase-11-10.md @@ -0,0 +1,26 @@ +# Phase 11.10 handover + +Experience Platform 11.10 (`0.11.10.0`) is complete. The `experience_db` owns +analytics report definitions and snapshots (local COUNT metrics only) plus AI +content hook registrations and dispatches via `AIProvider` contracts. No model +inference, cross-service DB reads, or analytics engine ownership. + +Migration head: `0011_phase_1110_analytics_ai_hooks`. + +**Experience track 11.0–11.10 is closed.** No further Experience phases are +registered. Next work follows other platform tracks per [next-steps.md](../next-steps.md) +(e.g. Delivery 10.2, Loyalty 7.7, Hospitality 12.9). + +## Delivered + +- Analytics report definitions + snapshot refresh (`POST /analytics-snapshots/refresh`) +- AI content hook registrations + dispatch generate (`POST /ai-content-dispatches/{id}/generate`) +- Permissions: `experience.analytics.*`, `experience.ai_hooks.*` +- Events: analytics CRUD + `experience.analytics_snapshot.refreshed`; AI hooks CRUD + `experience.ai_content_dispatch.generated` +- Enterprise validation audit: [experience-phase-11-10-audit.md](../experience-phase-11-10-audit.md) +- Service snapshot: [service-snapshots/experience.yaml](../service-snapshots/experience.yaml) + +## Health + +- Phase: `11.10` +- Features: `analytics=True`, `ai_content=True` diff --git a/docs/phase-handover/phase-11-2.md b/docs/phase-handover/phase-11-2.md new file mode 100644 index 0000000..b64c131 --- /dev/null +++ b/docs/phase-handover/phase-11-2.md @@ -0,0 +1,103 @@ +# Phase Handover — Experience 11.2 Component Library & Versioning + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.2` | +| Title | Component Library & Versioning | +| Status | Complete | +| Service(s) | `experience` (`experience_db`) | +| Version | `0.11.2.0` | +| Date | 2026-07-26 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md) | + +## Discovery Summary + +Prior phase delivered sites/pages. Gap: versioned reusable components with immutable published revisions and page composition pins. Themes/layouts deferred to 11.3. + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Component catalog | `app/models/components.py`, `services/components.py` | Lifecycle + kinds | +| Immutable versions | `ExperienceComponentVersion.is_immutable` | Publish locks content | +| Composition shells | `PageComponentPlacement` | Slot pins only — no layout engine | +| Policies / specs | `app/policies/`, `app/specifications/` | Transition + list UX | +| Commands / queries | `app/commands/`, `app/queries/` | CQ separation | + +## Public APIs + +| Method | Path | Permission | +| --- | --- | --- | +| CRUD + activate/deprecate/archive | `/api/v1/components` | `experience.components.*` | +| CRUD + publish/retire | `/api/v1/component-versions` | `experience.components.*` | +| CRUD | `/api/v1/page-component-placements` | `experience.components.*` | + +## Events + +| Event type | Notes | +| --- | --- | +| `experience.component.*` | Catalog lifecycle | +| `experience.component_version.*` | Version lifecycle + immutability | +| `experience.page_component.*` | Placement create/update/remove | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| `definition_schema` / `props_defaults` | Theme/layout phases consume props | Store theme tokens as layout engine in 11.2 | +| `render_hint` | Future render engines | Bind foreign DB rows | +| `menu_ref` / `catalog_ref` kinds | Vertical entity refs via API | Shared Hospitality/Marketplace tables | + +## Known Limitations + +- No theme/layout engines (11.3) +- No visual section tree / page builder UI +- Placement requires published version only +- Metrics remain discovery-oriented + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0003_phase_112_components` | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Drops placements, versions, components | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | +| --- | --- | +| experience-11.1 sites/pages | Required parent | +| ADR-016 | Boundaries | + +## Quality Gates + +- [x] Discovery complete +- [x] Architecture / boundaries preserved +- [x] Tests green (unit, API, architecture, migration, docs, tenant, security-compatible) +- [x] Documentation + manifests + registries updated +- [x] Self audit: no theme/layout leakage; published versions immutable + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `experience-11.3` Theme & Layout Engine | +| Entry checklist | Read this handover + phase doc; implement themes/layouts only — no template catalog yet | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Self audit completed + +## Related Documents + +- [Experience Phase 11.2](../experience-phase-11-2.md) +- [Experience Roadmap](../experience-roadmap.md) +- [ADR-016](../architecture/adr/ADR-016.md) diff --git a/docs/phase-handover/phase-11-3.md b/docs/phase-handover/phase-11-3.md new file mode 100644 index 0000000..c1901ab --- /dev/null +++ b/docs/phase-handover/phase-11-3.md @@ -0,0 +1,109 @@ +# Phase Handover — Experience 11.3 Theme & Layout Engine + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.3` | +| Title | Theme & Layout Engine | +| Status | Complete | +| Service(s) | `experience` (`experience_db`) | +| Version | `0.11.3.0` | +| Date | 2026-07-26 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md), [ADR-008](../architecture/adr/ADR-008.md) | + +## Discovery Summary + +Prior phase delivered versioned components. Gap: replaceable theme packs and dynamic layout shells with directionality, plus site/page bindings without rewriting page trees. Mirrors the Phase 11.2 component/component-version catalog+immutable-version pattern. Templates deferred to 11.4. + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Theme catalog + versions | `app/models/themes.py` (`ExperienceTheme`, `ExperienceThemeVersion`), `services/themes.py` | Tokens + brand shell immutable once published | +| Layout catalog + versions | `app/models/themes.py` (`ExperienceLayout`, `ExperienceLayoutVersion`) | Regions/structure shell immutable once published | +| Bindings | `SiteThemeBinding`, `PageLayoutBinding` | Pin a published version; swap without page rewrite | +| Policies / specs | `app/policies/themes.py`, `app/specifications/themes.py` | Lifecycle + version + binding rules, list UX | + +## Public APIs + +| Method | Path | Permission | +| --- | --- | --- | +| CRUD + activate/deprecate/archive | `/api/v1/themes` | `experience.themes.*` | +| CRUD + publish/retire | `/api/v1/theme-versions` | `experience.themes.*` | +| CRUD + activate/deprecate/archive | `/api/v1/layouts` | `experience.layouts.*` | +| CRUD + publish/retire | `/api/v1/layout-versions` | `experience.layouts.*` | +| Assign / unassign | `/api/v1/site-theme-bindings` | `experience.themes.*` | +| Assign / unassign | `/api/v1/page-layout-bindings` | `experience.layouts.*` | + +## Events + +| Event type | Notes | +| --- | --- | +| `experience.theme.*` | Theme catalog lifecycle | +| `experience.theme_version.*` | Theme version created/updated/published/retired | +| `experience.layout.*` | Layout catalog lifecycle | +| `experience.layout_version.*` | Layout version created/updated/published/retired | +| `experience.site_theme.*` | Site theme bind/replace/unassign | +| `experience.page_layout.*` | Page layout bind/replace/unassign | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| `ExperienceThemeVersion.tokens` | Frontend CSS variables (ADR-008) | Write Core tenant brand tables | +| `ExperienceLayoutVersion.regions` / `structure_shell` | Template phase consumes regions | Store full page trees / CMS | +| `ThemeMarketplaceProvider` | External packs via protocol | Shared marketplace DB | + +## Known Limitations + +- No template catalog (11.4) +- Bindings require an ACTIVE theme/layout with a PUBLISHED version +- Metrics remain discovery-oriented +- Visual theme editor UI is frontend-only + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0004_phase_113_themes_layouts` (head) | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Drops bindings, versions, layouts, themes | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | +| --- | --- | +| experience-11.2 components | Required prior | +| ADR-016 / ADR-008 | Boundaries / branding | + +## Quality Gates + +- [x] Discovery complete +- [x] Architecture / boundaries preserved +- [x] Tests green +- [x] Documentation + manifests + registries updated +- [x] Self audit: no template engine leakage; themes replaceable + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `experience-11.4` Template System | +| Entry checklist | Read this handover + phase doc; implement templates only — no forms/surveys yet | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Self audit completed + +## Related Documents + +- [Experience Phase 11.3](../experience-phase-11-3.md) +- [Experience Roadmap](../experience-roadmap.md) +- [ADR-016](../architecture/adr/ADR-016.md) +- [ADR-008](../architecture/adr/ADR-008.md) diff --git a/docs/phase-handover/phase-11-4.md b/docs/phase-handover/phase-11-4.md new file mode 100644 index 0000000..37a8b55 --- /dev/null +++ b/docs/phase-handover/phase-11-4.md @@ -0,0 +1,104 @@ +# Phase Handover — Experience 11.4 Template System + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.4` | +| Title | Template System | +| Status | Complete | +| Service(s) | `experience` (`experience_db`) | +| Version | `0.11.4.0` | +| Date | 2026-07-26 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md), [ADR-008](../architecture/adr/ADR-008.md) | + +## Discovery Summary + +Prior phase delivered replaceable themes and dynamic layouts. The Phase 11.4 template scaffolding (models, services, schemas, repositories, commands/queries, policies/validators/specifications, and APIs) already existed on disk from earlier work but was intentionally unwired — models excluded from `app/models/__init__.py`, routers unmounted, and the Alembic revision suffixed `.py.future` — as part of the Phase 11.3 repair so the Alembic head stayed pinned to `0004`. This phase re-wired that scaffolding: restored the migration, imported the models, mounted the routers, aligned events/permissions/capabilities, and added the phase 11.4 test suite. + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Template catalog + versions | `app/models/templates.py` (`ExperienceTemplate`, `ExperienceTemplateVersion`), `app/services/templates.py` | `blueprint_json` immutable once published; mirrors the Phase 11.2/11.3 catalog+immutable-version pattern | +| Instantiation | `TemplateInstantiation`, `ExperienceTemplateService.instantiate` | Creates an `ExperiencePage` from a published version's blueprint; records theme/layout readiness without owning those engines | +| Policies / specs | `app/policies/templates.py`, `app/specifications/templates.py` | Lifecycle + version-immutability + instantiation-eligibility rules, list UX | +| Validators | `app/validators/templates.py` | `page_type` validation, status-transition tables, `blueprint_json` shape checks | + +## Public APIs + +| Method | Path | Permission | +| --- | --- | --- | +| CRUD + activate/deprecate/archive/delete | `/api/v1/templates` | `experience.templates.*` | +| CRUD + publish/retire | `/api/v1/template-versions` | `experience.templates.*` | +| Instantiate / list / get | `/api/v1/template-instantiations` | `experience.templates.*` | + +## Events + +| Event type | Notes | +| --- | --- | +| `experience.template.*` | Template catalog lifecycle (created/updated/activated/deprecated/archived) | +| `experience.template_version.*` | Template version created/updated/published/retired | +| `experience.template.instantiated` | Emitted after a page is successfully created from a published version | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| `ExperienceTemplateVersion.blueprint_json` | `page_type`, `content_shell`, `seo_shell`, `theme_id`, `layout_id`, `placement_shells` — consumed at instantiation time | Store full CMS blobs / binary media | +| `TemplateInstantiation.result_json` | Records whether the referenced theme/layout were ready and applied | Do not use as a source of truth for theme/layout state — always re-read the theme/layout catalogs | +| `TemplateInstantiationPolicy` / `TemplateVersionPolicy` | Gate instantiation to published, non-deleted templates/versions | Do not bypass via direct repository writes | + +## Known Limitations + +- No forms/surveys/appointment engine (11.6) +- No localization/media-ref pipeline for template content yet (11.5) +- Instantiation only *references* theme/layout by id; it does not validate deep compatibility beyond active/published status +- Metrics remain discovery-oriented + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0005_phase_114_templates` (head) | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Drops `experience_template_instantiations`, `experience_template_versions`, `experience_templates` | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | +| --- | --- | +| experience-11.3 themes/layouts | Required prior (referenced by id from template blueprints) | +| experience-11.1 sites/pages | Required prior (instantiation creates an `ExperiencePage`) | +| ADR-016 / ADR-008 | Boundaries / branding | + +## Quality Gates + +- [x] Discovery complete +- [x] Architecture / boundaries preserved +- [x] Tests green +- [x] Documentation + manifests + registries updated +- [x] Self audit: no forms/surveys/CMS leakage; template versions immutable once published + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `experience-11.5` Localization, RTL/LTR & Media | +| Entry checklist | Read this handover + phase doc; implement locale profiles content/media refs only — no forms/surveys/appointments yet | + +## Completion Sign-Off + +- [x] Quality gates passed +- [x] Tests green +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] Self audit completed + +## Related Documents + +- [Experience Phase 11.4](../experience-phase-11-4.md) +- [Experience Roadmap](../experience-roadmap.md) +- [ADR-016](../architecture/adr/ADR-016.md) +- [ADR-008](../architecture/adr/ADR-008.md) diff --git a/docs/phase-handover/phase-11-5.md b/docs/phase-handover/phase-11-5.md new file mode 100644 index 0000000..6ed2865 --- /dev/null +++ b/docs/phase-handover/phase-11-5.md @@ -0,0 +1,94 @@ +# Phase Handover — Experience 11.5 Localization, RTL/LTR & Media + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.5` | +| Title | Localization, RTL/LTR & Media | +| Status | Complete | +| Service(s) | `experience` (`experience_db`) | +| Version | `0.11.5.0` | +| Date | 2026-07-26 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md), [ADR-008](../architecture/adr/ADR-008.md) | + +## Discovery Summary + +Phase 11.4 delivered the template catalog/instantiation pipeline. Foundation locale profiles (11.0) already described language + direction catalogs, and themes expose `DirectionMode` shells, but tenants could not bind locales to sites, store multi-locale content shells, or attach media without risking binaries in `experience_db`. This phase adds site locale bindings, localization upserts with first-class RTL/LTR, and media refs that store Storage file references only. + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Site locale binding | `app/models/localization.py` (`SiteLocaleBinding`), `app/services/localization.py` | One default locale per site; direction inherited from profile or overridden | +| Localization shells | `ExperienceLocalization` | Unique per tenant/target/locale; upsert API | +| Media refs | `ExperienceMediaRef` | `storage_file_ref` only; validators reject `data:` / base64 metadata keys | +| Policies / specs | `app/policies/localization.py`, `app/specifications/localization.py` | Profile usability, status transitions, list UX | +| Validators | `app/validators/localization.py` | Locale code, text direction, storage ref, no-binary payload | + +## Public APIs + +| Method | Path | Permission | +| --- | --- | --- | +| CRUD | `/api/v1/site-locale-bindings` | `experience.locales.*` | +| Upsert + CRUD | `/api/v1/localizations` | `experience.locales.*` | +| CRUD | `/api/v1/media-refs` | `experience.media_refs.*` | + +## Events + +| Event type | Notes | +| --- | --- | +| `experience.site_locale.*` | assigned / updated / replaced / unassigned | +| `experience.localization.*` | upserted / updated / archived | +| `experience.media_ref.*` | created / updated / archived | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| `ExperienceLocalization.content_json` | Locale-specific content shells for renderers | Store binary media or form submission payloads | +| `ExperienceMediaRef.storage_file_ref` | Point at File Storage objects | Inline blobs, data-URIs, base64 in metadata | +| `SiteLocaleBinding.text_direction` | Concrete rtl/ltr for site rendering | Do not invent a second locale catalog — reuse locale profiles | + +## Known Limitations + +- No forms/surveys/appointment engine (11.6) +- No SEO/PWA publishing pipeline (11.7) +- Media refs do not call StorageProvider at write time (ref validation only); resolve-on-read remains a later integration concern +- Localization targets are shells — deep CMS field graphs remain out of scope + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | `0006_phase_115_localization_media` (head) | +| Upgrade steps | `alembic upgrade head` | +| Downgrade support | Drops `experience_media_refs`, `experience_localizations`, `experience_site_locale_bindings` | +| Breaking changes | None (additive) | + +## Dependencies + +| Dependency | Type | +| --- | --- | +| experience-11.4 templates | Required prior (manifest chain) | +| experience-11.1 sites/pages | Required (bindings/localizations attach to sites/pages) | +| experience-11.0 locale profiles | Required (site bindings reference profiles) | +| File Storage (API contract) | External — media binaries never in `experience_db` | +| ADR-016 | Boundaries | + +## Quality Gates + +- [x] Discovery complete +- [x] Architecture / boundaries preserved +- [x] Tests green +- [x] Permissions / events registered +- [x] Docs + manifests + next-steps updated +- [x] Phase handover written + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Next phase | `experience-11.6` — Forms, Surveys & Appointments | +| Entry docs | This handover + [experience-phase-11-5.md](../experience-phase-11-5.md) | +| Do not start | Forms before localization/media DoD is green (this phase) | diff --git a/docs/phase-handover/phase-11-6.md b/docs/phase-handover/phase-11-6.md new file mode 100644 index 0000000..2ecaba8 --- /dev/null +++ b/docs/phase-handover/phase-11-6.md @@ -0,0 +1,59 @@ +# Phase Handover — Experience 11.6 Forms, Surveys & Appointments + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-11.6` | +| Title | Forms, Surveys & Appointments | +| Status | Complete | +| Service(s) | `experience` (`experience_db`) | +| Version | `0.11.6.0` | +| Date | 2026-07-26 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md) | + +## Discovery Summary + +Phase 11.5 delivered locale bindings and Storage-only media refs. Tenants still lacked form/survey definition shells and appointment page booking-ref surfaces. This phase adds those shells while keeping CRM contact and vertical scheduling ownership outside Experience. + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Forms + submissions | `app/models/forms.py`, `app/services/forms.py` | Active-only intake; payload refs not CRM IDs | +| Surveys + responses | same | Mirrors form intake pattern | +| Appointment pages + booking refs | same | `external_booking_ref` unique per page; no calendar engine | +| Policies / validators | `app/policies/forms.py`, `app/validators/forms.py` | Status transitions; forbid ownership keys in JSON shells | + +## Public APIs + +| Path | Permission | +| --- | --- | +| `/api/v1/forms`, `/api/v1/form-submissions` | `experience.forms.*` | +| `/api/v1/surveys`, `/api/v1/survey-responses` | `experience.surveys.*` | +| `/api/v1/appointment-pages`, `/api/v1/appointment-booking-refs` | `experience.appointments.*` | + +## Events + +`experience.form.*` · `experience.form_submission.*` · `experience.survey.*` · `experience.survey_response.*` · `experience.appointment_page.*` · `experience.appointment_booking_ref.*` + +## Known Limitations + +- No SEO/PWA publishing (11.7) +- No native scheduler / CRM write-back +- Submission processing is status-shell only (no background workers in this phase) + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic | `0007_phase_116_forms_surveys_appointments` (head) | +| Upgrade | `alembic upgrade head` | +| Breaking | None (additive) | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Next phase | `experience-11.7` — Publishing, Domains, SEO & PWA | +| Entry docs | This handover + [experience-phase-11-6.md](../experience-phase-11-6.md) | diff --git a/docs/phase-handover/phase-11-7.md b/docs/phase-handover/phase-11-7.md new file mode 100644 index 0000000..26f7bc9 --- /dev/null +++ b/docs/phase-handover/phase-11-7.md @@ -0,0 +1,9 @@ +# Phase 11.7 handover + +Experience Platform 11.7 (`0.11.7.0`) is complete. The `experience_db` now owns +publishing history, SEO configuration, sitemap/robots shells, PWA manifests, +and references to domain edge infrastructure. It does not own DNS, SSL, CDN, +storage, or service-worker engines. + +Migration head: `0008_phase_117_publishing_seo_pwa`. +Next phase: Experience 11.8. diff --git a/docs/phase-handover/phase-11-8.md b/docs/phase-handover/phase-11-8.md new file mode 100644 index 0000000..8aae913 --- /dev/null +++ b/docs/phase-handover/phase-11-8.md @@ -0,0 +1,9 @@ +# Phase 11.8 handover + +Experience Platform 11.8 (`0.11.8.0`) is complete. The `experience_db` owns +bundle catalog definitions, tenant bundle assignments, Core entitlement +reference bindings, and feature toggles. Core retains all plan, subscription, +billing ledger, and entitlement-engine ownership. + +Migration head: `0009_phase_118_bundles_toggles`. +Next phase: Experience 11.9 — Consumer Integrations & Widgets. diff --git a/docs/phase-handover/phase-11-9.md b/docs/phase-handover/phase-11-9.md new file mode 100644 index 0000000..37bac3a --- /dev/null +++ b/docs/phase-handover/phase-11-9.md @@ -0,0 +1,9 @@ +# Phase 11.9 handover + +Experience Platform 11.9 (`0.11.9.0`) is complete. The `experience_db` owns +consumer connector registration/dispatch shells, widget definition/instance +catalog, and Communication notify client dispatches. No SMS provider ownership; +integrations use mock providers and API/event contracts only. + +Migration head: `0010_phase_119_consumer_widgets`. +Next phase: Experience 11.10 — Analytics, AI Content & Enterprise Validation. diff --git a/docs/phase-handover/phase-xp-reg.md b/docs/phase-handover/phase-xp-reg.md new file mode 100644 index 0000000..7c71768 --- /dev/null +++ b/docs/phase-handover/phase-xp-reg.md @@ -0,0 +1,108 @@ +# Phase Handover — XP-Reg Experience Platform Registration + +## Metadata + +| Field | Value | +| --- | --- | +| Phase ID | `experience-reg` | +| Title | Experience Platform Registration | +| Status | Complete | +| Service(s) | `experience` (registered; not yet implemented) | +| Version | n/a (docs-only) | +| Date | 2026-07-25 | +| ADR(s) | [ADR-016](../architecture/adr/ADR-016.md) | + +## Reusable Components + +| Component | Location | Reuse notes | +| --- | --- | --- | +| Platform registration pattern | Same as Delivery DP-Reg / Sports Center | Docs + ADR + manifests before code | +| Service template | [service-template.md](../ai-framework/service-template.md) | Use in Phase 11.0 | +| Phase template | [phase-template.md](../ai-framework/phase-template.md) | Use for 11.x phase docs | + +## Public APIs + +| Method | Path | Auth / Permission | Notes | +| --- | --- | --- | --- | +| — | — | — | N/A — registration only; APIs begin in Phase 11.0 | + +Planned surfaces (not implemented): `/health`, `/capabilities`, `/metrics`, `/api/v1/*` under `experience.*` permissions. + +## Events + +| Event type | Domain / Integration | Payload summary | Version | +| --- | --- | --- | --- | +| `experience.*` (planned) | Experience domain | Publish-only; catalog grows per phase | Planned | + +## Extension Points + +| Extension point | How to extend | Forbidden uses | +| --- | --- | --- | +| Theme packs | Theme module + versioned assets refs | Hardcoding brand tokens in code | +| Component marketplace | Versioned component registry | Binary media in Experience DB | +| Consumer connectors | Versioned REST/events for verticals | Shared DB with Hospitality/Marketplace | +| AI content hooks | Optional contracts; core works offline | Hard dependency on AI for publish | +| Bundle licensing | Entitlement keys via Core | Duplicating Core plan engine | + +## Known Limitations + +- No `backend/services/experience` code yet — Phase 11.0 +- No Alembic migrations yet +- No compose wiring / runtime health yet +- Page Builder / public site UI deferred to frontend phases +- Vertical menu/product catalogs remain owned by Hospitality/Marketplace +- Historical `website_builder` scaffold not removed; prefer `experience` for new work + +## Migration Notes + +| Item | Detail | +| --- | --- | +| Alembic revision(s) | N/A (docs-only) | +| Upgrade steps | N/A | +| Downgrade support | N/A | +| Data backfill | N/A | +| Breaking changes | None | + +## Dependencies + +| Dependency | Type | Required for | +| --- | --- | --- | +| AI Framework | Docs | Development loop / gates | +| ADR-001 / 002 / 003 / 006 / 008 / 011 / 012 / 015 / 016 | Architecture | Boundaries | +| Core Platform | Service | Entitlement (from 11.0) | +| File Storage | Service | Media refs (from 11.5) | +| Communication | Service | Form/notify flows (later) | + +## Next Phase Entry + +| Field | Value | +| --- | --- | +| Recommended next phase | `experience-11.0` Foundation | +| Blockers for next phase | None for scaffold; runtime compose optional until wired | +| Entry checklist | Read this handover + [experience-roadmap.md](../experience-roadmap.md) + [ADR-016](../architecture/adr/ADR-016.md) + [service-template.md](../ai-framework/service-template.md); implement only foundation shells — no page/theme/template engines yet | + +What the next phase must read and assume: + +1. This handover + [experience-roadmap.md](../experience-roadmap.md) +2. Updated [module-registry.md](../module-registry.md#experience) / manifests +3. Open limitations above become Phase 11.0 scope (scaffold only) +4. Suggested next phase ID: `experience-11.0` + +## Completion Sign-Off + +- [x] Quality gates passed (documentation / architecture / manifest / cross-reference / links) +- [x] Tests green (N/A service pytest — docs-only; validation checks) +- [x] Documentation updated +- [x] Progress / next-steps / registries updated +- [x] No unfinished claimed deliverables +- [x] Self audit completed + +## Related Documents + +- [Experience Roadmap](../experience-roadmap.md) +- [Phase XP-Reg](../experience-phase-xp-reg.md) +- [Phase Area](../phases/Experience/README.md) +- [ADR-016](../architecture/adr/ADR-016.md) +- [Phase Manifest](../ai-framework/phase-manifest.yaml) +- [Service Manifest](../ai-framework/service-manifest.yaml) +- [Quality Gates](../ai-framework/quality-gates.md) diff --git a/docs/phases/Experience/README.md b/docs/phases/Experience/README.md new file mode 100644 index 0000000..bb881cd --- /dev/null +++ b/docs/phases/Experience/README.md @@ -0,0 +1,52 @@ +# Experience Platform Phase Area + +Independent enterprise Experience Platform (`experience-service`, `experience_db`, port 8008). Commercial product: **Torbat Pages**. + +## Status + +| Phase | Title | Status | +| --- | --- | --- | +| Reg | Platform Registration | Complete | +| 11.0 | Platform Foundation | Complete | +| 11.1 | Sites & Page Resources | Complete | +| 11.2 | Component Library & Versioning | Complete | +| 11.3 | Theme & Layout Engine | Complete | +| 11.4 | Template System | Complete | +| 11.5 | Localization, RTL/LTR & Media | Complete | +| 11.6 | Forms, Surveys & Appointments | Complete | +| 11.7 | Publishing, Domains, SEO & PWA | Complete | +| 11.8 | Bundles, Licensing & Feature Toggles | Complete | +| 11.9 | Consumer Integrations & Widgets | Complete | +| 11.10 | Analytics, AI Content & Enterprise Validation | Complete | + +## Documents +- [Phase 11.10](../../experience-phase-11-10.md) +- [Handover 11.10](../../phase-handover/phase-11-10.md) +- [Enterprise audit 11.10](../../experience-phase-11-10-audit.md) +- [Phase 11.9](../../experience-phase-11-9.md) +- [Handover 11.9](../../phase-handover/phase-11-9.md) +- [Phase 11.8](../../experience-phase-11-8.md) + +- [Roadmap](../../experience-roadmap.md) +- [Phase 11.7](../../experience-phase-11-7.md) +- [Handover 11.7](../../phase-handover/phase-11-7.md) +- [Phase 11.6](../../experience-phase-11-6.md) +- [Handover 11.6](../../phase-handover/phase-11-6.md) +- [Phase 11.5](../../experience-phase-11-5.md) +- [Handover 11.5](../../phase-handover/phase-11-5.md) +- [Phase 11.4](../../experience-phase-11-4.md) +- [Handover 11.4](../../phase-handover/phase-11-4.md) +- [Phase 11.3](../../experience-phase-11-3.md) +- [Handover 11.3](../../phase-handover/phase-11-3.md) +- [Phase 11.2](../../experience-phase-11-2.md) +- [Handover 11.2](../../phase-handover/phase-11-2.md) +- [Phase 11.1](../../experience-phase-11-1.md) +- [Handover 11.1](../../phase-handover/phase-11-1.md) +- [Phase 11.0](../../experience-phase-11-0.md) +- [Handover 11.0](../../phase-handover/phase-11-0.md) +- [ADR-016](../../architecture/adr/ADR-016.md) +- [AI Framework](../../ai-framework/README.md) + +## Boundary reminder + +Experience owns page/site/theme/template/component resources and publishing shells only. Consume Core, Storage, CRM, Hospitality, Marketplace, Sports Center, Communication, Loyalty, AI, Delivery, Automation via API + Events. Prefer `experience` over historical `website_builder` scaffold for new work. diff --git a/docs/service-snapshots/experience.yaml b/docs/service-snapshots/experience.yaml new file mode 100644 index 0000000..8f41ca8 --- /dev/null +++ b/docs/service-snapshots/experience.yaml @@ -0,0 +1,83 @@ +# Experience Platform service snapshot — Phase 11.10 complete +# Spec: docs/ai-framework/service-snapshot-policy.md + +schema_version: 1 +snapshot_version: 1 + +service_name: experience +commercial_product: Torbat Pages +current_version: "0.11.10.0" +current_phase: experience-11.10 +last_completed_phase: experience-11.10 +next_phase: null + +completed_phases: + - experience-reg + - experience-11.0 + - experience-11.1 + - experience-11.2 + - experience-11.3 + - experience-11.4 + - experience-11.5 + - experience-11.6 + - experience-11.7 + - experience-11.8 + - experience-11.9 + - experience-11.10 +remaining_phases: [] + +registered_modules: + - workspaces + - sites + - pages + - components + - themes + - layouts + - templates + - localizations + - media_refs + - forms + - surveys + - appointments + - publishing + - seo + - pwa + - bundles + - feature_toggles + - consumer_connectors + - widgets + - notify + - analytics + - ai_hooks + +enabled_capabilities: + - foundation + - sites_and_pages + - component_library + - themes_and_layouts + - templates + - localization_rtl_ltr + - media_refs + - forms_surveys_appointments + - publishing_seo_pwa + - bundles_licensing + - consumer_connectors + - widgets + - analytics + - ai_content_hooks + +public_apis: + - { method: POST, path: /api/v1/analytics-report-definitions, permission: experience.analytics.create } + - { method: POST, path: /api/v1/analytics-snapshots/refresh, permission: experience.analytics.manage } + - { method: POST, path: /api/v1/ai-content-hook-registrations, permission: experience.ai_hooks.create } + - { method: POST, path: "/api/v1/ai-content-dispatches/{id}/generate", permission: experience.ai_hooks.manage } + +published_events: + - { event_type: experience.analytics_report_definition.created, version: "1" } + - { event_type: experience.analytics_snapshot.refreshed, version: "1" } + - { event_type: experience.ai_content_hook_registration.created, version: "1" } + - { event_type: experience.ai_content_dispatch.generated, version: "1" } + +database: experience_db +permission_prefix: experience.* +track_status: complete