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>
979 lines
37 KiB
Python
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
|