TorbatYar/backend/services/experience/app/services/forms.py
Mortezakoohjani 203671a7bf 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 <cursoragent@cursor.com>
2026-07-27 11:43:10 +03:30

979 lines
37 KiB
Python

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