diff --git a/src/govoplan_scheduling/backend/db/__init__.py b/src/govoplan_scheduling/backend/db/__init__.py index df70210..bce973e 100644 --- a/src/govoplan_scheduling/backend/db/__init__.py +++ b/src/govoplan_scheduling/backend/db/__init__.py @@ -1,5 +1,17 @@ from __future__ import annotations -from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest +from govoplan_scheduling.backend.db.models import ( + SchedulingCandidateSlot, + SchedulingNotification, + SchedulingParticipant, + SchedulingPublicEnrollmentLink, + SchedulingRequest, +) -__all__ = ["SchedulingCandidateSlot", "SchedulingNotification", "SchedulingParticipant", "SchedulingRequest"] +__all__ = [ + "SchedulingCandidateSlot", + "SchedulingNotification", + "SchedulingParticipant", + "SchedulingPublicEnrollmentLink", + "SchedulingRequest", +] diff --git a/src/govoplan_scheduling/backend/db/models.py b/src/govoplan_scheduling/backend/db/models.py index 4d5f14e..ad48b7a 100644 --- a/src/govoplan_scheduling/backend/db/models.py +++ b/src/govoplan_scheduling/backend/db/models.py @@ -4,7 +4,7 @@ import uuid from datetime import datetime from typing import Any -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, JSON, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from govoplan_core.db.base import Base, TimestampMixin @@ -67,6 +67,40 @@ class SchedulingRequest(Base, TimestampMixin): cascade="all, delete-orphan", order_by="SchedulingParticipant.created_at", ) + enrollment_links: Mapped[list["SchedulingPublicEnrollmentLink"]] = relationship( + back_populates="request", + cascade="all, delete-orphan", + order_by="SchedulingPublicEnrollmentLink.created_at", + ) + + +class SchedulingPublicEnrollmentLink(Base, TimestampMixin): + """Reusable public credential that may create bounded participants.""" + + __tablename__ = "scheduling_public_enrollment_links" + __table_args__ = ( + UniqueConstraint("token_hash", name="uq_scheduling_enrollment_link_token_hash"), + Index("ix_scheduling_enrollment_links_request", "tenant_id", "request_id"), + Index("ix_scheduling_enrollment_links_expiry", "tenant_id", "expires_at"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) + request_id: Mapped[str] = mapped_column( + ForeignKey("scheduling_requests.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False) + max_enrollments: Mapped[int] = mapped_column(Integer, nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, index=True) + allow_anonymous: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + allow_authenticated: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + created_by: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + request: Mapped[SchedulingRequest] = relationship(back_populates="enrollment_links") class SchedulingCandidateSlot(Base, TimestampMixin): @@ -116,6 +150,14 @@ class SchedulingParticipant(Base, TimestampMixin): status: Mapped[str] = mapped_column(String(40), default="invited", nullable=False, index=True) poll_invitation_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) participation_gateway: Mapped[str | None] = mapped_column(String(40), nullable=True) + self_enrollment_link_id: Mapped[str | None] = mapped_column( + ForeignKey("scheduling_public_enrollment_links.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + self_enrollment_proof_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + bound_account_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + account_bound_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) last_invited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) response_comment: Mapped[str | None] = mapped_column(Text, nullable=True) @@ -150,6 +192,7 @@ __all__ = [ "SchedulingCandidateSlot", "SchedulingNotification", "SchedulingParticipant", + "SchedulingPublicEnrollmentLink", "SchedulingRequest", "new_uuid", ] diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 0bdd3f8..5724073 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -182,11 +182,48 @@ DOCUMENTATION = ( ], }, ), + DocumentationTopic( + id="scheduling.public-self-enrollment", + title="Use governed public self-enrollment links", + summary="Issue reusable scheduling links with explicit capacity, expiry, identity, account-binding, and abuse controls.", + body=( + "A public self-enrollment link is distinct from a participant-specific invitation. " + "Organizers must choose a capacity and future expiry, and can independently allow anonymous and signed-in enrollment. " + "Every participant supplies a display name; email is required only when the request policy says so. Anonymous participants create and retain a separate recovery proof, which is submitted in the request body and is never embedded in the link, logs, analytics, or durable clear text. " + "Signed-in participants must explicitly confirm account binding. A later signed-in submission may bind an anonymous enrollment only when its recovery proof is supplied; the binding is audited. " + "Deployment policy can disable self-enrollment or cap its maximum capacity. Redis provides shared fixed-window throttling when configured, while development uses the bounded single-node fallback. Existing personalized invitation links are unchanged. " + "Revoking or expiring the reusable link prevents new access immediately. Capacity is serialized with participant creation, retries are idempotent through the caller's idempotency key, and existing proof holders can update only while request policy permits updates. Other participants receive only the request's existing aggregate or governed roster projection." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("organizer", "participant", "module_admin", "tenant_admin"), + conditions=( + DocumentationCondition( + any_scopes=(WRITE_SCOPE, ADMIN_SCOPE, RESPOND_SCOPE), + ), + ), + related_modules=("poll", "access", "policy"), + metadata={ + "kind": "workflow", + "route": "/scheduling", + "help_contexts": [ + "scheduling.public-self-enrollment", + "scheduling.public-self-enrollment-governance", + ], + "steps": [ + "Choose a bounded capacity, expiry, and permitted identity modes.", + "Copy the newly issued link; the raw credential is shown only once.", + "Monitor enrollment count and revoke the link when it is no longer needed.", + "Require recovery proof before updating or binding an anonymous response.", + ], + "verification": "The link list shows status, expiry, capacity use, access modes, and revocation without redisplaying its credential.", + }, + ), ) def _tenant_summary(session, tenant_id: str) -> dict[str, int]: - from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest + from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingPublicEnrollmentLink, SchedulingRequest return { "scheduling_requests": ( @@ -209,6 +246,14 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: .filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending") .count() ), + "scheduling_public_enrollment_links": ( + session.query(SchedulingPublicEnrollmentLink) + .filter( + SchedulingPublicEnrollmentLink.tenant_id == tenant_id, + SchedulingPublicEnrollmentLink.revoked_at.is_(None), + ) + .count() + ), } @@ -225,10 +270,27 @@ def _public_tenant_resolver(request: object, session: object) -> str | None: request_id = str(path_params.get("request_id") or "").strip() token = str(path_params.get("token") or "").strip() path = str(getattr(getattr(request, "url", None), "path", "")) - if not request_id or not token or "/scheduling/public/" not in path: + if not request_id or not token: return None - from govoplan_scheduling.backend.db.models import SchedulingRequest + from govoplan_scheduling.backend.db.models import SchedulingPublicEnrollmentLink, SchedulingRequest + from govoplan_scheduling.backend.security import public_credential_hash + from govoplan_core.db.base import utcnow + + if "/scheduling/public-enrollment/" in path: + link = ( + session.query(SchedulingPublicEnrollmentLink) + .filter( + SchedulingPublicEnrollmentLink.request_id == request_id, + SchedulingPublicEnrollmentLink.token_hash == public_credential_hash(token), + SchedulingPublicEnrollmentLink.revoked_at.is_(None), + SchedulingPublicEnrollmentLink.expires_at > utcnow(), + ) + .one_or_none() + ) + return link.tenant_id if link is not None else None + if "/scheduling/public/" not in path: + return None app = getattr(request, "app", None) registry = getattr(getattr(app, "state", None), "govoplan_registry", None) @@ -315,6 +377,11 @@ manifest = ModuleManifest( component="SchedulingPublicPage", order=10, ), + PublicFrontendRoute( + path="/scheduling/enrol/:requestId/:token", + component="SchedulingEnrollmentPage", + order=11, + ), ), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), product_areas=( @@ -350,6 +417,7 @@ manifest = ModuleManifest( scheduling_models.SchedulingRequest, scheduling_models.SchedulingCandidateSlot, scheduling_models.SchedulingParticipant, + scheduling_models.SchedulingPublicEnrollmentLink, scheduling_models.SchedulingNotification, label="Scheduling", ), @@ -360,6 +428,7 @@ manifest = ModuleManifest( scheduling_models.SchedulingRequest, scheduling_models.SchedulingCandidateSlot, scheduling_models.SchedulingParticipant, + scheduling_models.SchedulingPublicEnrollmentLink, scheduling_models.SchedulingNotification, label="Scheduling", ), @@ -372,7 +441,7 @@ manifest = ModuleManifest( documentation_ref="README.md", test_ref="tests/test_service.py", known_limits=("Reference deployment notification delivery and every calendar-provider constraint remain incomplete.",), - owned_concepts=("scheduling request", "candidate slot", "scheduling participant", "scheduling decision"), + owned_concepts=("scheduling request", "candidate slot", "scheduling participant", "public self-enrollment link", "scheduling decision"), non_owned_concepts=("poll response primitive", "calendar event", "mail delivery"), recovery_docs=("README.md",), security_docs=("README.md",), diff --git a/src/govoplan_scheduling/backend/migrations/versions/d7a4c1e8f205_v0119_public_self_enrollment.py b/src/govoplan_scheduling/backend/migrations/versions/d7a4c1e8f205_v0119_public_self_enrollment.py new file mode 100644 index 0000000..d3a0277 --- /dev/null +++ b/src/govoplan_scheduling/backend/migrations/versions/d7a4c1e8f205_v0119_public_self_enrollment.py @@ -0,0 +1,134 @@ +"""Governed public self-enrollment links. + +Revision ID: d7a4c1e8f205 +Revises: c9d4e7f1a2b3 +Create Date: 2026-08-20 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "d7a4c1e8f205" +down_revision = "c9d4e7f1a2b3" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + table_names = set(inspector.get_table_names()) + if "scheduling_public_enrollment_links" not in table_names: + op.create_table( + "scheduling_public_enrollment_links", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=False), + sa.Column("request_id", sa.String(length=36), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("max_enrollments", sa.Integer(), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("allow_anonymous", sa.Boolean(), nullable=False), + sa.Column("allow_authenticated", sa.Boolean(), nullable=False), + sa.Column("created_by", sa.String(length=255), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint( + ["request_id"], + ["scheduling_requests.id"], + ondelete="CASCADE", + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "token_hash", + name="uq_scheduling_enrollment_link_token_hash", + ), + ) + op.create_index( + "ix_scheduling_enrollment_links_request", + "scheduling_public_enrollment_links", + ["tenant_id", "request_id"], + ) + op.create_index( + "ix_scheduling_enrollment_links_expiry", + "scheduling_public_enrollment_links", + ["tenant_id", "expires_at"], + ) + for column in ("tenant_id", "request_id", "expires_at", "created_by", "revoked_at"): + op.create_index( + f"ix_scheduling_public_enrollment_links_{column}", + "scheduling_public_enrollment_links", + [column], + ) + else: + columns = {item["name"] for item in inspector.get_columns("scheduling_public_enrollment_links")} + expected = { + "id", "tenant_id", "request_id", "token_hash", "max_enrollments", + "expires_at", "allow_anonymous", "allow_authenticated", "created_by", + "revoked_at", "metadata", "created_at", "updated_at", + } + if columns != expected: + raise RuntimeError( + "Cannot adopt scheduling_public_enrollment_links because its schema is unexpected" + ) + + participant_columns = { + item["name"] for item in sa.inspect(op.get_bind()).get_columns("scheduling_participants") + } + additions = ( + ("self_enrollment_link_id", sa.String(length=36)), + ("self_enrollment_proof_hash", sa.String(length=64)), + ("bound_account_id", sa.String(length=255)), + ("account_bound_at", sa.DateTime(timezone=True)), + ) + present = {name for name, _type in additions if name in participant_columns} + if present and len(present) != len(additions): + raise RuntimeError( + "Cannot adopt partial scheduling participant self-enrollment columns" + ) + if not present: + with op.batch_alter_table("scheduling_participants") as batch: + batch.add_column( + sa.Column("self_enrollment_link_id", sa.String(length=36), nullable=True) + ) + batch.add_column( + sa.Column("self_enrollment_proof_hash", sa.String(length=64), nullable=True) + ) + batch.add_column( + sa.Column("account_bound_at", sa.DateTime(timezone=True), nullable=True) + ) + batch.add_column( + sa.Column("bound_account_id", sa.String(length=255), nullable=True) + ) + batch.create_foreign_key( + "fk_scheduling_participants_enrollment_link", + "scheduling_public_enrollment_links", + ["self_enrollment_link_id"], + ["id"], + ondelete="SET NULL", + ) + batch.create_index( + "ix_scheduling_participants_self_enrollment_link_id", + ["self_enrollment_link_id"], + ) + batch.create_index( + "ix_scheduling_participants_bound_account_id", + ["bound_account_id"], + ) + + +def downgrade() -> None: + with op.batch_alter_table("scheduling_participants") as batch: + batch.drop_index("ix_scheduling_participants_bound_account_id") + batch.drop_index("ix_scheduling_participants_self_enrollment_link_id") + batch.drop_constraint( + "fk_scheduling_participants_enrollment_link", + type_="foreignkey", + ) + batch.drop_column("account_bound_at") + batch.drop_column("self_enrollment_proof_hash") + batch.drop_column("self_enrollment_link_id") + batch.drop_column("bound_account_id") + op.drop_table("scheduling_public_enrollment_links") diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index a8c506d..f964678 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -14,12 +14,17 @@ from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPON from govoplan_scheduling.backend.schemas import ( SchedulingAvailabilityResponse, SchedulingAvailabilityResponseRequest, + SchedulingAuthenticatedEnrollmentSubmitRequest, SchedulingCalendarActionResponse, SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, SchedulingInvitationActionRequest, SchedulingInvitationActionResponse, SchedulingInvitationRevokeRequest, + SchedulingEnrollmentLinkActionResponse, + SchedulingEnrollmentLinkCreateRequest, + SchedulingEnrollmentLinkListResponse, + SchedulingEnrollmentLinkResponse, SchedulingNotificationCreateRequest, SchedulingNotificationListResponse, SchedulingNotificationResponse, @@ -34,6 +39,9 @@ from govoplan_scheduling.backend.schemas import ( SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationResponse, SchedulingPublicParticipationSubmitRequest, + SchedulingPublicEnrollmentAccessRequest, + SchedulingPublicEnrollmentResponse, + SchedulingPublicEnrollmentSubmitRequest, SchedulingStatusResponse, SchedulingSummaryResponse, ) @@ -47,6 +55,7 @@ from govoplan_scheduling.backend.service import ( close_scheduling_request, create_final_calendar_event, create_scheduling_notification_jobs, + create_scheduling_enrollment_link, create_scheduling_request, create_tentative_calendar_holds, decide_scheduling_request, @@ -54,18 +63,25 @@ from govoplan_scheduling.backend.service import ( get_scheduling_request, get_scheduling_availability_response, get_public_scheduling_participation, + get_public_scheduling_enrollment, get_visible_scheduling_request, list_visible_scheduling_notifications, list_visible_scheduling_requests, + list_scheduling_enrollment_links, issue_scheduling_participant_invitation, open_scheduling_request, require_visible_scheduling_results, revoke_scheduling_participant_invitation, + revoke_scheduling_enrollment_link, + response_datetime, + scheduling_enrollment_link_response, scheduling_notification_response, scheduling_request_response, scheduling_request_summary, submit_scheduling_availability, submit_public_scheduling_participation, + submit_authenticated_scheduling_enrollment, + submit_public_scheduling_enrollment, update_scheduling_candidate_slot, update_scheduling_request_with_change_log, ) @@ -253,6 +269,107 @@ def api_submit_public_scheduling_participation( return validated +@router.post( + "/public-enrollment/{request_id}/{token}", + response_model=SchedulingPublicEnrollmentResponse, +) +def api_get_public_scheduling_enrollment( + request_id: str, + token: str, + payload: SchedulingPublicEnrollmentAccessRequest, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> SchedulingPublicEnrollmentResponse: + try: + result = get_public_scheduling_enrollment( + session, + request_id=request_id, + token=token, + payload=payload, + client_address=_client_address(request), + ) + except SchedulingPublicParticipationError as exc: + raise _public_participation_http_error(exc) from exc + _set_sensitive_response_headers(response) + return SchedulingPublicEnrollmentResponse.model_validate(result) + + +@router.post( + "/public-enrollment/{request_id}/{token}/responses", + response_model=SchedulingPublicEnrollmentResponse, +) +def api_submit_public_scheduling_enrollment( + request_id: str, + token: str, + payload: SchedulingPublicEnrollmentSubmitRequest, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> SchedulingPublicEnrollmentResponse: + try: + result = submit_public_scheduling_enrollment( + session, + request_id=request_id, + token=token, + payload=payload, + client_address=_client_address(request), + ) + except SchedulingPublicParticipationError as exc: + raise _public_participation_http_error(exc) from exc + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + validated = SchedulingPublicEnrollmentResponse.model_validate(result) + _set_sensitive_response_headers(response) + session.commit() + return validated + + +@router.post( + "/public-enrollment/{request_id}/{token}/authenticated-responses", + response_model=SchedulingPublicEnrollmentResponse, +) +def api_submit_authenticated_scheduling_enrollment( + request_id: str, + token: str, + payload: SchedulingAuthenticatedEnrollmentSubmitRequest, + request: Request, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingPublicEnrollmentResponse: + _require_scope(principal, RESPOND_SCOPE) + try: + result = submit_authenticated_scheduling_enrollment( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + token=token, + account_id=principal.account_id, + account_email=principal.email, + payload=payload, + client_address=_client_address(request), + ) + except SchedulingPublicParticipationError as exc: + raise _public_participation_http_error(exc) from exc + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=(getattr(principal.user, "id", None) or principal.account_id), + api_key_id=principal.api_key_id, + action="scheduling.self_enrollment_account_bound", + object_type="scheduling_request", + object_id=request_id, + details={"link_id": result["link_id"]}, + ) + validated = SchedulingPublicEnrollmentResponse.model_validate(result) + _set_sensitive_response_headers(response) + session.commit() + return validated + + @router.get("/people", response_model=SchedulingPeopleSearchResponse) def api_search_scheduling_people( query: str = Query(min_length=1), @@ -443,6 +560,126 @@ def api_update_scheduling_request( return response +@router.get( + "/requests/{request_id}/enrollment-links", + response_model=SchedulingEnrollmentLinkListResponse, +) +def api_list_scheduling_enrollment_links( + request_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingEnrollmentLinkListResponse: + _require_request_editor(session, principal=principal, request_id=request_id) + try: + links = list_scheduling_enrollment_links( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + return SchedulingEnrollmentLinkListResponse( + links=[ + SchedulingEnrollmentLinkResponse.model_validate( + scheduling_enrollment_link_response(session, link) + ) + for link in links + ] + ) + + +@router.post( + "/requests/{request_id}/enrollment-links", + response_model=SchedulingEnrollmentLinkActionResponse, + status_code=status.HTTP_201_CREATED, +) +def api_create_scheduling_enrollment_link( + request_id: str, + payload: SchedulingEnrollmentLinkCreateRequest, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingEnrollmentLinkActionResponse: + _require_request_editor(session, principal=principal, request_id=request_id) + try: + link, token = create_scheduling_enrollment_link( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + created_by=principal.account_id, + payload=payload, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=(getattr(principal.user, "id", None) or principal.account_id), + api_key_id=principal.api_key_id, + action="scheduling.self_enrollment_link_issued", + object_type="scheduling_request", + object_id=request_id, + details={ + "link_id": link.id, + "expires_at": response_datetime(link.expires_at).isoformat(), + "max_enrollments": link.max_enrollments, + "allow_anonymous": link.allow_anonymous, + "allow_authenticated": link.allow_authenticated, + }, + ) + validated = SchedulingEnrollmentLinkActionResponse( + link=SchedulingEnrollmentLinkResponse.model_validate( + scheduling_enrollment_link_response(session, link) + ), + action_url=f"/scheduling/enrol/{request_id}/{token}", + ) + _set_sensitive_response_headers(response) + session.commit() + return validated + + +@router.delete( + "/requests/{request_id}/enrollment-links/{link_id}", + response_model=SchedulingEnrollmentLinkActionResponse, +) +def api_revoke_scheduling_enrollment_link( + request_id: str, + link_id: str, + response: Response, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> SchedulingEnrollmentLinkActionResponse: + _require_request_editor(session, principal=principal, request_id=request_id) + try: + link, replayed = revoke_scheduling_enrollment_link( + session, + tenant_id=principal.tenant_id, + request_id=request_id, + link_id=link_id, + ) + except SchedulingError as exc: + raise _scheduling_http_error(exc) from exc + audit_event( + session, + tenant_id=principal.tenant_id, + user_id=(getattr(principal.user, "id", None) or principal.account_id), + api_key_id=principal.api_key_id, + action="scheduling.self_enrollment_link_revoked", + object_type="scheduling_request", + object_id=request_id, + details={"link_id": link.id, "replayed": replayed}, + ) + validated = SchedulingEnrollmentLinkActionResponse( + link=SchedulingEnrollmentLinkResponse.model_validate( + scheduling_enrollment_link_response(session, link) + ), + replayed=replayed, + ) + _set_sensitive_response_headers(response) + session.commit() + return validated + + @router.post( "/requests/{request_id}/participants/{participant_id}/invitation", response_model=SchedulingInvitationActionResponse, diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index e6de55a..b1f05e1 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -458,6 +458,108 @@ class SchedulingPublicParticipationResponse(BaseModel): slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list) +class SchedulingEnrollmentLinkCreateRequest(BaseModel): + """Organizer policy for one reusable, bounded self-enrollment link.""" + + model_config = ConfigDict(extra="forbid") + + expires_at: AwareDatetime + max_enrollments: int = Field(ge=1, le=10_000) + allow_anonymous: bool = True + allow_authenticated: bool = True + + @model_validator(mode="after") + def validate_access_modes(self) -> "SchedulingEnrollmentLinkCreateRequest": + if not self.allow_anonymous and not self.allow_authenticated: + raise ValueError("At least one enrollment access mode must be enabled") + return self + + +class SchedulingEnrollmentLinkResponse(BaseModel): + id: str + request_id: str + status: Literal["active", "expired", "revoked", "exhausted"] + expires_at: datetime + max_enrollments: int + enrollment_count: int + allow_anonymous: bool + allow_authenticated: bool + created_at: datetime + revoked_at: datetime | None = None + + +class SchedulingEnrollmentLinkListResponse(BaseModel): + links: list[SchedulingEnrollmentLinkResponse] = Field(default_factory=list) + + +class SchedulingEnrollmentLinkActionResponse(BaseModel): + link: SchedulingEnrollmentLinkResponse + action_url: str | None = None + replayed: bool = False + + +class SchedulingPublicEnrollmentAccessRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + password: SecretStr | None = Field(default=None, max_length=1024) + + +class SchedulingPublicEnrollmentSubmitRequest(SchedulingAvailabilityResponseRequest): + model_config = ConfigDict(extra="forbid") + + display_name: str = Field(min_length=1, max_length=500) + email: str | None = Field(default=None, max_length=320) + password: SecretStr | None = Field(default=None, max_length=1024) + participant_proof: SecretStr = Field(min_length=32, max_length=1024) + idempotency_key: str = Field(min_length=1, max_length=255) + + _validate_email = field_validator("email")(_participant_email) + + +class SchedulingAuthenticatedEnrollmentSubmitRequest(SchedulingAvailabilityResponseRequest): + model_config = ConfigDict(extra="forbid") + + display_name: str = Field(min_length=1, max_length=500) + email: str | None = Field(default=None, max_length=320) + password: SecretStr | None = Field(default=None, max_length=1024) + bind_account_confirmed: bool + participant_proof: SecretStr | None = Field(default=None, min_length=32, max_length=1024) + idempotency_key: str = Field(min_length=1, max_length=255) + + _validate_email = field_validator("email")(_participant_email) + + +class SchedulingPublicEnrollmentResponse(BaseModel): + request_id: str + link_id: str + title: str + description: str | None = None + location: str | None = None + timezone: str + status: str + deadline_at: datetime | None = None + enrollment_expires_at: datetime + enrollment_remaining: int + display_name_required: bool = True + participant_email_required: bool + anonymous_allowed: bool + authenticated_allowed: bool + anonymous_password_required: bool + single_choice: bool + max_participants_per_option: int | None = None + allow_maybe: bool + allow_comments: bool + allow_participant_updates: bool + enrolled: bool = False + account_bound: bool = False + has_response: bool = False + submitted_at: datetime | None = None + answers: list[SchedulingAvailabilityAnswerResponse] = Field(default_factory=list) + comment: str | None = None + replayed: bool = False + slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list) + + class SchedulingPollOptionResultResponse(BaseModel): option_id: str option_key: str diff --git a/src/govoplan_scheduling/backend/security.py b/src/govoplan_scheduling/backend/security.py index 5b3d081..5205644 100644 --- a/src/govoplan_scheduling/backend/security.py +++ b/src/govoplan_scheduling/backend/security.py @@ -4,6 +4,7 @@ import base64 import hashlib import hmac import os +import secrets _ALGORITHM = "pbkdf2_sha256" @@ -11,6 +12,22 @@ _DEFAULT_ITERATIONS = 260_000 _SALT_BYTES = 16 +def new_public_credential() -> str: + """Create a URL-safe credential with at least 256 bits of entropy.""" + + return secrets.token_urlsafe(32) + + +def public_credential_hash(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def verify_public_credential(value: str, expected_hash: str | None) -> bool: + if not expected_hash: + return False + return hmac.compare_digest(public_credential_hash(value), expected_hash) + + def hash_participant_password( password: str, *, @@ -58,4 +75,10 @@ def verify_participant_password(password: str, encoded: str | None) -> bool: return hmac.compare_digest(actual, expected) -__all__ = ["hash_participant_password", "verify_participant_password"] +__all__ = [ + "hash_participant_password", + "new_public_credential", + "public_credential_hash", + "verify_participant_password", + "verify_public_credential", +] diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index 7d4cd46..cea0513 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -55,13 +55,23 @@ from govoplan_core.core.throttling import ( build_fixed_window_throttle, ) from govoplan_core.db.base import utcnow -from govoplan_scheduling.backend.db.models import SchedulingCandidateSlot, SchedulingNotification, SchedulingParticipant, SchedulingRequest +from govoplan_scheduling.backend.db.models import ( + SchedulingCandidateSlot, + SchedulingNotification, + SchedulingParticipant, + SchedulingPublicEnrollmentLink, + SchedulingRequest, +) from govoplan_scheduling.backend.schemas import ( SchedulingAvailabilityResponseRequest, SchedulingCandidateSlotReconcileInput, SchedulingCandidateSlotUpdateRequest, SchedulingDecisionRequest, SchedulingParticipantReconcileInput, + SchedulingAuthenticatedEnrollmentSubmitRequest, + SchedulingEnrollmentLinkCreateRequest, + SchedulingPublicEnrollmentAccessRequest, + SchedulingPublicEnrollmentSubmitRequest, SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationSubmitRequest, SchedulingRequestCreateRequest, @@ -70,7 +80,10 @@ from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.runtime import get_registry, get_settings from govoplan_scheduling.backend.security import ( hash_participant_password, + new_public_credential, + public_credential_hash, verify_participant_password, + verify_public_credential, ) @@ -137,6 +150,9 @@ SCHEDULING_PARTICIPATION_GATEWAY = "scheduling" PARTICIPATION_PASSWORD_ATTEMPT_LIMIT = 10 PARTICIPATION_PASSWORD_REQUEST_LIMIT = 100 PARTICIPATION_PASSWORD_WINDOW_SECONDS = 15 * 60 +SELF_ENROLLMENT_ATTEMPT_LIMIT = 50 +SELF_ENROLLMENT_IDENTITY_LIMIT = 10 +SELF_ENROLLMENT_WINDOW_SECONDS = 15 * 60 def response_datetime(value: datetime | None) -> datetime | None: @@ -191,6 +207,8 @@ def scheduling_participant_revision(participant: SchedulingParticipant) -> str: "status": participant.status, "poll_invitation_id": participant.poll_invitation_id, "participation_gateway": participant.participation_gateway, + "self_enrollment_link_id": participant.self_enrollment_link_id, + "bound_account_id": participant.bound_account_id, "metadata": participant.metadata_ or {}, } encoded = json.dumps( @@ -1528,7 +1546,10 @@ def _participant_matches_actor( return _identity_matches_actor( participant.respondent_id, actor_ids, - ) or _identity_matches_actor(participant.email, actor_ids) + ) or _identity_matches_actor( + participant.email, + actor_ids, + ) or _identity_matches_actor(participant.bound_account_id, actor_ids) def scheduling_request_is_visible( @@ -1583,6 +1604,7 @@ def list_visible_scheduling_requests( SchedulingParticipant.deleted_at.is_(None), or_( SchedulingParticipant.respondent_id.in_(ids or ("",)), + SchedulingParticipant.bound_account_id.in_(ids or ("",)), func.lower(SchedulingParticipant.email).in_( email_ids or ("",) ), @@ -1988,6 +2010,686 @@ def _availability_response_answers( return answers +@lru_cache(maxsize=8) +def _configured_self_enrollment_throttle( + redis_url: str | None, +) -> FixedWindowThrottle: + return build_fixed_window_throttle( + redis_url=redis_url, + window_seconds=SELF_ENROLLMENT_WINDOW_SECONDS, + key_prefix="govoplan:scheduling:self-enrollment:v1", + ) + + +def _self_enrollment_throttle() -> FixedWindowThrottle: + configured_url = getattr(get_settings(), "redis_url", None) + redis_url = configured_url if isinstance(configured_url, str) else None + return _configured_self_enrollment_throttle(redis_url) + + +def _self_enrollment_dimensions( + link: SchedulingPublicEnrollmentLink, + *, + token: str, + client_address: str | None, + identity: str | None = None, +) -> tuple[ThrottleDimension, ...]: + dimensions = [ + ThrottleDimension( + namespace="public-self-enrollment-request", + subject=":".join( + ( + link.tenant_id, + link.request_id, + participation_token_fingerprint(token), + client_address or "unknown-client", + ) + ), + limit=SELF_ENROLLMENT_ATTEMPT_LIMIT, + ) + ] + if identity: + dimensions.append( + ThrottleDimension( + namespace="public-self-enrollment-identity", + subject=":".join( + ( + link.tenant_id, + link.request_id, + public_credential_hash(identity.strip().casefold()), + ) + ), + limit=SELF_ENROLLMENT_IDENTITY_LIMIT, + ) + ) + return tuple(dimensions) + + +def _require_self_enrollment_enabled() -> None: + if getattr(get_settings(), "scheduling_public_self_enrollment_enabled", True) is False: + raise SchedulingError("Public self-enrollment is disabled by deployment policy") + + +def _self_enrollment_count( + session: Session, + link: SchedulingPublicEnrollmentLink, +) -> int: + return ( + session.query(SchedulingParticipant) + .filter( + SchedulingParticipant.tenant_id == link.tenant_id, + SchedulingParticipant.request_id == link.request_id, + SchedulingParticipant.self_enrollment_link_id == link.id, + SchedulingParticipant.deleted_at.is_(None), + ) + .count() + ) + + +def _self_enrollment_link_status( + link: SchedulingPublicEnrollmentLink, + *, + enrollment_count: int, +) -> str: + if link.revoked_at is not None: + return "revoked" + if response_datetime(link.expires_at) <= _now(): + return "expired" + if enrollment_count >= link.max_enrollments: + return "exhausted" + return "active" + + +def scheduling_enrollment_link_response( + session: Session, + link: SchedulingPublicEnrollmentLink, +) -> dict[str, Any]: + enrollment_count = _self_enrollment_count(session, link) + return { + "id": link.id, + "request_id": link.request_id, + "status": _self_enrollment_link_status( + link, + enrollment_count=enrollment_count, + ), + "expires_at": response_datetime(link.expires_at), + "max_enrollments": link.max_enrollments, + "enrollment_count": enrollment_count, + "allow_anonymous": link.allow_anonymous, + "allow_authenticated": link.allow_authenticated, + "created_at": response_datetime(link.created_at), + "revoked_at": response_datetime(link.revoked_at), + } + + +def list_scheduling_enrollment_links( + session: Session, + *, + tenant_id: str, + request_id: str, +) -> list[SchedulingPublicEnrollmentLink]: + get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + return ( + session.query(SchedulingPublicEnrollmentLink) + .filter( + SchedulingPublicEnrollmentLink.tenant_id == tenant_id, + SchedulingPublicEnrollmentLink.request_id == request_id, + ) + .order_by(SchedulingPublicEnrollmentLink.created_at.desc()) + .all() + ) + + +def create_scheduling_enrollment_link( + session: Session, + *, + tenant_id: str, + request_id: str, + created_by: str | None, + payload: SchedulingEnrollmentLinkCreateRequest, +) -> tuple[SchedulingPublicEnrollmentLink, str]: + _require_self_enrollment_enabled() + request = _lock_scheduling_request( + session, + tenant_id=tenant_id, + request_id=request_id, + lock_participants=True, + ) + _require_scheduling_response_collection_open(request) + expires_at = response_datetime(payload.expires_at) + if expires_at is None or expires_at <= _now(): + raise SchedulingError("Self-enrollment link expiry must be in the future") + deadline = response_datetime(request.deadline_at) + if deadline is not None and expires_at > deadline: + raise SchedulingError("Self-enrollment link expiry cannot exceed the response deadline") + configured_max = getattr( + get_settings(), + "scheduling_public_self_enrollment_max_capacity", + 10_000, + ) + try: + max_capacity = max(1, min(int(configured_max), 10_000)) + except (TypeError, ValueError): + max_capacity = 10_000 + if payload.max_enrollments > max_capacity: + raise SchedulingError( + f"Self-enrollment capacity exceeds the deployment maximum of {max_capacity}" + ) + if payload.allow_anonymous and not request.allow_external_participants: + raise SchedulingError("Anonymous self-enrollment requires external participants") + token = new_public_credential() + link = SchedulingPublicEnrollmentLink( + tenant_id=tenant_id, + request_id=request.id, + token_hash=public_credential_hash(token), + max_enrollments=payload.max_enrollments, + expires_at=expires_at, + allow_anonymous=payload.allow_anonymous, + allow_authenticated=payload.allow_authenticated, + created_by=created_by, + metadata_={"policy_version": 1}, + ) + session.add(link) + session.flush() + return link, token + + +def revoke_scheduling_enrollment_link( + session: Session, + *, + tenant_id: str, + request_id: str, + link_id: str, +) -> tuple[SchedulingPublicEnrollmentLink, bool]: + get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) + link = ( + session.query(SchedulingPublicEnrollmentLink) + .filter( + SchedulingPublicEnrollmentLink.id == link_id, + SchedulingPublicEnrollmentLink.tenant_id == tenant_id, + SchedulingPublicEnrollmentLink.request_id == request_id, + ) + .with_for_update() + .one_or_none() + ) + if link is None: + raise SchedulingError("Self-enrollment link not found") + replayed = link.revoked_at is not None + if not replayed: + link.revoked_at = _now() + session.flush() + return link, replayed + + +def _resolve_self_enrollment_link( + session: Session, + *, + request_id: str, + token: str, + client_address: str | None, + password: Any, + lock_for_submission: bool = False, + identity: str | None = None, +) -> tuple[SchedulingRequest, SchedulingPublicEnrollmentLink]: + request = _public_scheduling_request(session, request_id=request_id) + if lock_for_submission: + request = _lock_public_scheduling_submission(session, request) + query = session.query(SchedulingPublicEnrollmentLink).filter( + SchedulingPublicEnrollmentLink.request_id == request.id, + SchedulingPublicEnrollmentLink.tenant_id == request.tenant_id, + SchedulingPublicEnrollmentLink.token_hash == public_credential_hash(token), + ) + if lock_for_submission: + query = query.with_for_update() + link = query.one_or_none() + if link is None: + raise SchedulingPublicParticipationError() + enrollment_count = _self_enrollment_count(session, link) + if _self_enrollment_link_status( + link, + enrollment_count=enrollment_count, + ) in {"expired", "revoked"}: + raise SchedulingPublicParticipationError() + dimensions = _self_enrollment_dimensions( + link, + token=token, + client_address=client_address, + identity=identity, + ) + throttle = _self_enrollment_throttle() + decision = throttle.check(dimensions) + if not decision.allowed: + raise SchedulingPublicParticipationError( + retry_after_seconds=decision.retry_after_seconds + ) + access = SchedulingPublicParticipationAccessRequest(password=password) + try: + password_dimensions = _verify_public_participation_password( + request, + token=token, + payload=access, + client_address=client_address, + ) + except SchedulingPublicParticipationError: + decision = throttle.record(dimensions) + retry_after = decision.retry_after_seconds if not decision.allowed else 0 + raise SchedulingPublicParticipationError(retry_after_seconds=retry_after) from None + if password_dimensions: + _participation_password_throttle().reset(password_dimensions[:1]) + if lock_for_submission: + decision = throttle.record(dimensions) + if not decision.allowed: + raise SchedulingPublicParticipationError( + retry_after_seconds=decision.retry_after_seconds + ) + return request, link + + +def _self_enrollment_participant_by_proof( + request: SchedulingRequest, + link: SchedulingPublicEnrollmentLink, + proof: str, +) -> SchedulingParticipant | None: + matches = [ + participant + for participant in _active_participants(request) + if participant.self_enrollment_link_id == link.id + and verify_public_credential(proof, participant.self_enrollment_proof_hash) + ] + return matches[0] if len(matches) == 1 else None + + +def _self_enrollment_participant_by_account( + request: SchedulingRequest, + link: SchedulingPublicEnrollmentLink, + account_id: str, +) -> SchedulingParticipant | None: + matches = [ + participant + for participant in _active_participants(request) + if participant.self_enrollment_link_id == link.id + and participant.bound_account_id == account_id + ] + return matches[0] if len(matches) == 1 else None + + +def _create_self_enrolled_participant( + session: Session, + *, + request: SchedulingRequest, + link: SchedulingPublicEnrollmentLink, + display_name: str, + email: str | None, + proof: str | None, + account_id: str | None, +) -> SchedulingParticipant: + if _self_enrollment_count(session, link) >= link.max_enrollments: + raise SchedulingConflictError("Self-enrollment capacity has been reached") + normalized_email = email.strip().casefold() if email else None + if request.participant_email_required and normalized_email is None: + raise SchedulingError("Participant email is required") + if normalized_email is not None and any( + (participant.email or "").strip().casefold() == normalized_email + for participant in _active_participants(request) + ): + raise SchedulingPublicParticipationError() + if account_id is not None and any( + participant.bound_account_id == account_id + for participant in _active_participants(request) + ): + raise SchedulingPublicParticipationError() + participant = SchedulingParticipant( + tenant_id=request.tenant_id, + request_id=request.id, + respondent_id=account_id, + bound_account_id=account_id, + account_bound_at=_now() if account_id else None, + display_name=display_name.strip(), + email=normalized_email, + participant_type="internal" if account_id else "external", + required=False, + status="invited", + participation_gateway=SCHEDULING_PARTICIPATION_GATEWAY, + self_enrollment_link_id=link.id, + self_enrollment_proof_hash=( + public_credential_hash(proof) if proof is not None else None + ), + metadata_={"self_enrollment_policy_version": 1}, + ) + session.add(participant) + session.flush() + respondent_id = _stable_participant_respondent_id(participant) + provider = _poll_participation_provider() + if provider is None or request.poll_id is None: + raise SchedulingError("Poll governed participation capability is unavailable") + try: + invitation = provider.create_governed_invitation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + command=PollGovernedInvitationCommand( + gateway=_public_participation_gateway(request.id), + policy=_public_participation_policy(request), + respondent_id=respondent_id, + respondent_label=participant.display_name, + email=participant.email, + expires_at=min( + value + for value in ( + response_datetime(link.expires_at), + response_datetime(request.deadline_at), + ) + if value is not None + ), + metadata={ + "scheduling_request_id": request.id, + "scheduling_participant_id": participant.id, + "self_enrollment_link_id": link.id, + "public_link_issued": False, + }, + ), + ) + except PollCapabilityError as exc: + raise SchedulingError(str(exc)) from exc + participant.poll_invitation_id = invitation.id + return participant + + +def _self_enrollment_poll_response( + session: Session, + *, + request: SchedulingRequest, + participant: SchedulingParticipant, +) -> PollGovernedResponseRef | None: + if request.poll_id is None or participant.poll_invitation_id is None: + return None + provider = _poll_participation_provider() + if provider is None: + raise SchedulingError("Poll governed participation capability is unavailable") + try: + context = provider.resolve_authenticated_participation( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=participant.poll_invitation_id, + gateway=_public_participation_gateway(request.id), + respondent_id=_stable_participant_respondent_id(participant), + ) + except PollCapabilityError as exc: + raise SchedulingPublicParticipationError() from exc + return context.response + + +def _self_enrollment_response( + session: Session, + *, + request: SchedulingRequest, + link: SchedulingPublicEnrollmentLink, + participant: SchedulingParticipant | None = None, + response: PollGovernedResponseRef | None = None, +) -> dict[str, Any]: + current_response = response + if participant is not None and current_response is None: + current_response = _self_enrollment_poll_response( + session, + request=request, + participant=participant, + ) + enrollment_count = _self_enrollment_count(session, link) + poll_response = current_response.response if current_response is not None else None + return { + "request_id": request.id, + "link_id": link.id, + "title": request.title, + "description": request.description, + "location": request.location, + "timezone": request.timezone, + "status": request.status, + "deadline_at": response_datetime(request.deadline_at), + "enrollment_expires_at": response_datetime(link.expires_at), + "enrollment_remaining": max(0, link.max_enrollments - enrollment_count), + "display_name_required": True, + "participant_email_required": request.participant_email_required, + "anonymous_allowed": link.allow_anonymous, + "authenticated_allowed": link.allow_authenticated, + "anonymous_password_required": request.anonymous_password_protection_enabled, + "single_choice": request.single_choice, + "max_participants_per_option": request.max_participants_per_option, + "allow_maybe": request.allow_maybe, + "allow_comments": request.allow_comments, + "allow_participant_updates": request.allow_participant_updates, + "enrolled": participant is not None, + "account_bound": participant is not None and participant.bound_account_id is not None, + "has_response": current_response is not None, + "submitted_at": ( + response_datetime(poll_response.submitted_at) + if poll_response is not None + else None + ), + "answers": ( + _availability_response_answers(request, poll_response) + if poll_response is not None + else [] + ), + "comment": current_response.comment if current_response is not None else None, + "replayed": current_response.replayed if current_response is not None else False, + "slots": [ + { + "id": slot.id, + "label": slot.label, + "description": slot.description, + "start_at": response_datetime(slot.start_at), + "end_at": response_datetime(slot.end_at), + "timezone": slot.timezone, + "location": slot.location, + "position": slot.position, + "revision": scheduling_slot_revision(slot), + } + for slot in _active_slots(request) + ], + } + + +def get_public_scheduling_enrollment( + session: Session, + *, + request_id: str, + token: str, + payload: SchedulingPublicEnrollmentAccessRequest, + client_address: str | None, +) -> dict[str, Any]: + request, link = _resolve_self_enrollment_link( + session, + request_id=request_id, + token=token, + client_address=client_address, + password=payload.password, + ) + return _self_enrollment_response( + session, + request=request, + link=link, + ) + + +def _submit_self_enrollment_response( + session: Session, + *, + request: SchedulingRequest, + link: SchedulingPublicEnrollmentLink, + participant: SchedulingParticipant, + payload: SchedulingPublicEnrollmentSubmitRequest | SchedulingAuthenticatedEnrollmentSubmitRequest, +) -> dict[str, Any]: + if participant.status == "responded" and not request.allow_participant_updates: + raise SchedulingConflictError("Participant responses cannot be updated") + answers: list[PollAnswerRequest] = [] + for answer in payload.answers: + slot = _selected_slot(request, slot_id=answer.slot_id) + if slot.poll_option_id is None: + raise SchedulingError("Scheduling slot has no backing poll option") + if answer.option_revision != scheduling_slot_revision(slot): + raise SchedulingConflictError( + "Scheduling options changed after this response form was loaded; reload before responding" + ) + answers.append(PollAnswerRequest(option_id=slot.poll_option_id, value=answer.value)) + provider = _poll_participation_provider() + if provider is None or request.poll_id is None or participant.poll_invitation_id is None: + raise SchedulingError("Poll governed participation capability is unavailable") + try: + governed_response = provider.submit_authenticated_response( + session, + tenant_id=request.tenant_id, + poll_id=request.poll_id, + invitation_id=participant.poll_invitation_id, + gateway=_public_participation_gateway(request.id), + respondent_id=_stable_participant_respondent_id(participant), + command=PollGovernedResponseCommand( + respondent_id=_stable_participant_respondent_id(participant), + respondent_label=participant.display_name, + participant_email=participant.email, + # Scheduling has authenticated this pseudonymous participant + # with either the recovery proof or a bound account before + # invoking Poll's non-token in-process contract. + participant_is_authenticated=True, + answers=tuple(answers), + comment=payload.comment, + idempotency_key=payload.idempotency_key, + metadata={ + "scheduling_participant_id": participant.id, + "self_enrollment_link_id": link.id, + }, + ), + ) + except PollCapabilityError as exc: + message = str(exc) + if message == "Poll invitation not found": + raise SchedulingPublicParticipationError() from exc + if "Participant limit reached" in message or "Idempotency key" in message: + raise SchedulingConflictError(message) from exc + raise SchedulingError(message) from exc + participant.status = "responded" + participant.responded_at = response_datetime(governed_response.response.submitted_at) + participant.response_comment = governed_response.comment + if request.notify_on_answers and not governed_response.replayed: + _emit_scheduling_center_notification( + session, + request=request, + participant=participant, + event_kind="scheduling.participant_self_enrolled", + subject=f"Scheduling self-enrollment: {request.title}", + body_text=f"{participant.display_name or 'A participant'} self-enrolled and responded.", + ) + session.flush() + return _self_enrollment_response( + session, + request=request, + link=link, + participant=participant, + response=governed_response, + ) + + +def submit_public_scheduling_enrollment( + session: Session, + *, + request_id: str, + token: str, + payload: SchedulingPublicEnrollmentSubmitRequest, + client_address: str | None, +) -> dict[str, Any]: + proof = payload.participant_proof.get_secret_value() + request, link = _resolve_self_enrollment_link( + session, + request_id=request_id, + token=token, + client_address=client_address, + password=payload.password, + lock_for_submission=True, + identity=payload.email or proof, + ) + if not link.allow_anonymous: + raise SchedulingPublicParticipationError() + participant = _self_enrollment_participant_by_proof(request, link, proof) + if participant is None: + participant = _create_self_enrolled_participant( + session, + request=request, + link=link, + display_name=payload.display_name, + email=payload.email, + proof=proof, + account_id=None, + ) + elif ( + participant.display_name != payload.display_name.strip() + or (participant.email or None) != (payload.email.strip().casefold() if payload.email else None) + ): + raise SchedulingPublicParticipationError() + return _submit_self_enrollment_response( + session, + request=request, + link=link, + participant=participant, + payload=payload, + ) + + +def submit_authenticated_scheduling_enrollment( + session: Session, + *, + tenant_id: str, + request_id: str, + token: str, + account_id: str, + account_email: str | None, + payload: SchedulingAuthenticatedEnrollmentSubmitRequest, + client_address: str | None, +) -> dict[str, Any]: + if not payload.bind_account_confirmed: + raise SchedulingError("Account binding must be confirmed") + request, link = _resolve_self_enrollment_link( + session, + request_id=request_id, + token=token, + client_address=client_address, + password=payload.password, + lock_for_submission=True, + identity=account_id, + ) + if request.tenant_id != tenant_id or not link.allow_authenticated: + raise SchedulingPublicParticipationError() + participant = _self_enrollment_participant_by_account(request, link, account_id) + if participant is None and payload.participant_proof is not None: + participant = _self_enrollment_participant_by_proof( + request, + link, + payload.participant_proof.get_secret_value(), + ) + if participant is None: + raise SchedulingPublicParticipationError() + participant.bound_account_id = account_id + participant.account_bound_at = _now() + if participant is None: + participant = _create_self_enrolled_participant( + session, + request=request, + link=link, + display_name=payload.display_name, + email=payload.email or account_email, + proof=None, + account_id=account_id, + ) + elif participant.display_name != payload.display_name.strip(): + raise SchedulingPublicParticipationError() + return _submit_self_enrollment_response( + session, + request=request, + link=link, + participant=participant, + payload=payload, + ) + + def _resolve_public_scheduling_participation( session: Session, *, diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 0d4fe6b..66ef1a0 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -33,7 +33,10 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.frontend) self.assertEqual( - ["/scheduling/public/:requestId/:token"], + [ + "/scheduling/public/:requestId/:token", + "/scheduling/enrol/:requestId/:token", + ], [route.path for route in manifest.frontend.public_routes], ) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) @@ -63,6 +66,10 @@ class SchedulingManifestTests(unittest.TestCase): "scheduling.calendar-coordination", documentation["scheduling.calendar-coordination"].metadata["help_contexts"], ) + self.assertIn( + "scheduling.public-self-enrollment", + documentation["scheduling.public-self-enrollment"].metadata["help_contexts"], + ) if __name__ == "__main__": diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 443eb4c..d405ab8 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import ( from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest -_SCHEDULING_HEAD = "c9d4e7f1a2b3" +_SCHEDULING_HEAD = "d7a4c1e8f205" _SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10" _ENABLED_MODULES = ("poll", "scheduling") _MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest) @@ -150,6 +150,14 @@ class SchedulingMigrationTests(unittest.TestCase): self.assertIn("max_participants_per_option", columns) self.assertIn("response_comment", participant_columns) self.assertIn("participation_gateway", participant_columns) + self.assertIn("self_enrollment_link_id", participant_columns) + self.assertIn("self_enrollment_proof_hash", participant_columns) + self.assertIn("bound_account_id", participant_columns) + self.assertIn("account_bound_at", participant_columns) + self.assertIn( + "scheduling_public_enrollment_links", + inspect(engine).get_table_names(), + ) self.assertEqual(visibility, "aggregates_only") self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None)) self.assertEqual(set(counts.values()), {1}) diff --git a/tests/test_self_enrollment.py b/tests/test_self_enrollment.py new file mode 100644 index 0000000..1ff3b60 --- /dev/null +++ b/tests/test_self_enrollment.py @@ -0,0 +1,328 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import patch + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker +from pydantic import SecretStr + +from govoplan_core.core.modules import ModuleContext +from govoplan_core.core.registry import PlatformRegistry +from govoplan_core.db.base import Base +from govoplan_poll.backend.db.models import ( + Poll, + PollInvitation, + PollLifecycleTransition, + PollOption, + PollParticipationSubmission, + PollResponse, +) +from govoplan_poll.backend.manifest import get_manifest as get_poll_manifest +from govoplan_scheduling.backend.db.models import ( + SchedulingCandidateSlot, + SchedulingNotification, + SchedulingParticipant, + SchedulingPublicEnrollmentLink, + SchedulingRequest, +) +from govoplan_scheduling.backend.schemas import ( + SchedulingAuthenticatedEnrollmentSubmitRequest, + SchedulingAvailabilityAnswerInput, + SchedulingCandidateSlotInput, + SchedulingEnrollmentLinkCreateRequest, + SchedulingPublicEnrollmentAccessRequest, + SchedulingPublicEnrollmentSubmitRequest, + SchedulingRequestCreateRequest, +) +from govoplan_scheduling.backend.runtime import configure_runtime +from govoplan_scheduling.backend.service import ( + SchedulingConflictError, + SchedulingPublicParticipationError, + create_scheduling_enrollment_link, + create_scheduling_request, + get_public_scheduling_enrollment, + revoke_scheduling_enrollment_link, + scheduling_request_is_visible, + scheduling_slot_revision, + submit_authenticated_scheduling_enrollment, + submit_public_scheduling_enrollment, +) + + +class SchedulingSelfEnrollmentTests(unittest.TestCase): + def setUp(self) -> None: + self.now = datetime(2026, 8, 20, 10, tzinfo=timezone.utc) + self.patches = ( + patch("govoplan_scheduling.backend.service._now", return_value=self.now), + patch("govoplan_poll.backend.service._now", return_value=self.now), + patch("govoplan_poll.backend.participation_service._now", return_value=self.now), + ) + for item in self.patches: + item.start() + registry = PlatformRegistry() + registry.register(get_poll_manifest()) + settings = SimpleNamespace( + redis_url=None, + scheduling_public_self_enrollment_enabled=True, + scheduling_public_self_enrollment_max_capacity=100, + ) + registry.configure_capability_context( + ModuleContext(registry=registry, settings=settings) + ) + configure_runtime(registry=registry, settings=settings) + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all( + self.engine, + tables=[ + Poll.__table__, + PollOption.__table__, + PollResponse.__table__, + PollInvitation.__table__, + PollParticipationSubmission.__table__, + PollLifecycleTransition.__table__, + SchedulingRequest.__table__, + SchedulingPublicEnrollmentLink.__table__, + SchedulingCandidateSlot.__table__, + SchedulingParticipant.__table__, + SchedulingNotification.__table__, + ], + ) + self.Session = sessionmaker(bind=self.engine) + self.session: Session = self.Session() + + def tearDown(self) -> None: + self.session.close() + Base.metadata.drop_all(self.engine) + self.engine.dispose() + for item in reversed(self.patches): + item.stop() + + def _request(self, *, email_required: bool = False) -> SchedulingRequest: + start = self.now + timedelta(days=1) + request, _tokens = create_scheduling_request( + self.session, + tenant_id="tenant-1", + user_id="organizer-1", + payload=SchedulingRequestCreateRequest( + title="Public planning", + status="collecting", + deadline_at=self.now + timedelta(days=4), + participant_email_required=email_required, + slots=[ + SchedulingCandidateSlotInput( + label="First option", + start_at=start, + end_at=start + timedelta(hours=1), + ) + ], + ), + ) + return request + + def _link( + self, + request: SchedulingRequest, + *, + capacity: int = 2, + ) -> tuple[SchedulingPublicEnrollmentLink, str]: + return create_scheduling_enrollment_link( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + created_by="organizer-1", + payload=SchedulingEnrollmentLinkCreateRequest( + expires_at=self.now + timedelta(days=2), + max_enrollments=capacity, + ), + ) + + @staticmethod + def _answer(request: SchedulingRequest) -> SchedulingAvailabilityAnswerInput: + slot = request.slots[0] + return SchedulingAvailabilityAnswerInput( + slot_id=slot.id, + value="available", + option_revision=scheduling_slot_revision(slot), + ) + + def test_anonymous_enrollment_requires_proof_for_updates_and_is_idempotent(self) -> None: + request = self._request(email_required=True) + link, token = self._link(request) + + opened = get_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicEnrollmentAccessRequest(), + client_address="192.0.2.1", + ) + self.assertTrue(opened["participant_email_required"]) + self.assertEqual(opened["enrollment_remaining"], 2) + + payload = SchedulingPublicEnrollmentSubmitRequest( + display_name="Ada Example", + email="ADA@example.test", + participant_proof="proof-" + "a" * 40, + idempotency_key="enrollment-submit-1", + answers=[self._answer(request)], + ) + created = submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=payload, + client_address="192.0.2.1", + ) + replayed = submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=payload, + client_address="192.0.2.1", + ) + + self.assertTrue(created["enrolled"]) + self.assertTrue(created["has_response"]) + self.assertTrue(replayed["replayed"]) + self.assertEqual(self.session.query(SchedulingParticipant).count(), 1) + participant = self.session.query(SchedulingParticipant).one() + self.assertEqual(participant.email, "ada@example.test") + self.assertNotEqual(participant.self_enrollment_proof_hash, payload.participant_proof.get_secret_value()) + self.assertNotIn(token, repr(participant.metadata_)) + + with self.assertRaises(SchedulingPublicParticipationError): + submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=payload.model_copy( + update={ + "participant_proof": SecretStr("proof-" + "b" * 40), + "idempotency_key": "enrollment-submit-2", + } + ), + client_address="192.0.2.2", + ) + + def test_capacity_is_serialized_but_existing_proof_can_update(self) -> None: + request = self._request() + _link, token = self._link(request, capacity=1) + first = SchedulingPublicEnrollmentSubmitRequest( + display_name="Ada", + participant_proof="proof-" + "a" * 40, + idempotency_key="first", + answers=[self._answer(request)], + ) + submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=first, + client_address="192.0.2.3", + ) + updated = submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=first.model_copy(update={"idempotency_key": "update"}), + client_address="192.0.2.3", + ) + self.assertTrue(updated["has_response"]) + with self.assertRaises(SchedulingConflictError): + submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=first.model_copy( + update={ + "display_name": "Grace", + "participant_proof": SecretStr("proof-" + "g" * 40), + "idempotency_key": "second", + } + ), + client_address="192.0.2.4", + ) + + def test_signed_in_binding_requires_confirmation_and_can_claim_proof(self) -> None: + request = self._request() + link, token = self._link(request) + anonymous = SchedulingPublicEnrollmentSubmitRequest( + display_name="Ada", + participant_proof="proof-" + "a" * 40, + idempotency_key="anonymous", + answers=[self._answer(request)], + ) + submit_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=anonymous, + client_address="192.0.2.5", + ) + authenticated = SchedulingAuthenticatedEnrollmentSubmitRequest( + display_name="Ada", + bind_account_confirmed=True, + participant_proof=anonymous.participant_proof, + idempotency_key="bound", + answers=[self._answer(request)], + ) + bound = submit_authenticated_scheduling_enrollment( + self.session, + tenant_id="tenant-1", + request_id=request.id, + token=token, + account_id="account-ada", + account_email="ada@example.test", + payload=authenticated, + client_address="192.0.2.5", + ) + participant = self.session.query(SchedulingParticipant).one() + self.assertTrue(bound["account_bound"]) + self.assertEqual(participant.bound_account_id, "account-ada") + self.assertEqual(participant.self_enrollment_link_id, link.id) + self.assertTrue( + scheduling_request_is_visible( + request, + actor_ids=("account-ada",), + ) + ) + + with self.assertRaisesRegex(ValueError, "Account binding must be confirmed"): + submit_authenticated_scheduling_enrollment( + self.session, + tenant_id="tenant-1", + request_id=request.id, + token=token, + account_id="other-account", + account_email=None, + payload=authenticated.model_copy(update={"bind_account_confirmed": False}), + client_address="192.0.2.6", + ) + + def test_revocation_invalidates_link_without_storing_raw_token(self) -> None: + request = self._request() + link, token = self._link(request) + self.assertNotEqual(link.token_hash, token) + revoked, replayed = revoke_scheduling_enrollment_link( + self.session, + tenant_id=request.tenant_id, + request_id=request.id, + link_id=link.id, + ) + self.assertFalse(replayed) + self.assertIsNotNone(revoked.revoked_at) + with self.assertRaises(SchedulingPublicParticipationError): + get_public_scheduling_enrollment( + self.session, + request_id=request.id, + token=token, + payload=SchedulingPublicEnrollmentAccessRequest(), + client_address="192.0.2.7", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index 1986c89..bc03483 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -4,11 +4,13 @@ import { fileURLToPath } from "node:url"; const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url)); const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url)); +const enrollmentPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingEnrollmentPage.tsx", import.meta.url)); const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url)); const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url)); const widgetPath = fileURLToPath(new URL("../src/features/scheduling/SchedulingRequestsWidget.tsx", import.meta.url)); const page = readFileSync(pagePath, "utf8"); const publicPage = readFileSync(publicPagePath, "utf8"); +const enrollmentPage = readFileSync(enrollmentPagePath, "utf8"); const api = readFileSync(apiPath, "utf8"); const moduleSource = readFileSync(modulePath, "utf8"); const widget = readFileSync(widgetPath, "utf8"); @@ -48,6 +50,11 @@ assert.match(page, //); assert.match(page, //); @@ -161,11 +168,14 @@ assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action, assert.match(api, /revokeSchedulingParticipantInvitation\([\s\S]*method: "DELETE"[\s\S]*participant_revision: participantRevision/); assert.match(api, /participants\/\$\{encodeURIComponent\(participantId\)\}\/invitation/); assert.match(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/); +assert.match(api, /\/api\/v1\/scheduling\/public-enrollment\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/); assert.match(page, /useSearchParams\(\)/); assert.match(page, /Promise\.allSettled/); assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/); assert.match(moduleSource, /SchedulingPublicPage/); +assert.match(moduleSource, /path: "\/scheduling\/enrol\/:requestId\/:token"/); +assert.match(moduleSource, /SchedulingEnrollmentPage/); assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*DocumentationHelpLink,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*PasswordField,[\s\S]*from "@govoplan\/core-webui"/); assert.match(publicPage, /; + comment?: string | null; + replayed: boolean; + slots: SchedulingPublicCandidateSlot[]; +}; + +export type SchedulingPublicEnrollmentSubmitPayload = SchedulingAvailabilityPayload & { + display_name: string; + email?: string | null; + password?: string | null; + participant_proof: string; + idempotency_key: string; +}; + +export type SchedulingAuthenticatedEnrollmentSubmitPayload = SchedulingAvailabilityPayload & { + display_name: string; + email?: string | null; + password?: string | null; + bind_account_confirmed: boolean; + participant_proof?: string | null; + idempotency_key: string; +}; + export type SchedulingCalendarActionResponse = { request: SchedulingRequest; created_event_ids: string[]; @@ -423,6 +499,79 @@ export function submitPublicSchedulingParticipation( ); } +export function listSchedulingEnrollmentLinks( + settings: ApiSettings, + requestId: string +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/requests/${requestId}/enrollment-links` + ); +} + +export function createSchedulingEnrollmentLink( + settings: ApiSettings, + requestId: string, + payload: SchedulingEnrollmentLinkCreatePayload +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/requests/${requestId}/enrollment-links`, + json(payload) + ); +} + +export function revokeSchedulingEnrollmentLink( + settings: ApiSettings, + requestId: string, + linkId: string +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/requests/${requestId}/enrollment-links/${linkId}`, + { method: "DELETE" } + ); +} + +export function getPublicSchedulingEnrollment( + settings: ApiSettings, + requestId: string, + token: string, + password?: string +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}`, + json({ password: password || null }) + ); +} + +export function submitPublicSchedulingEnrollment( + settings: ApiSettings, + requestId: string, + token: string, + payload: SchedulingPublicEnrollmentSubmitPayload +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/responses`, + json(payload) + ); +} + +export function submitAuthenticatedSchedulingEnrollment( + settings: ApiSettings, + requestId: string, + token: string, + payload: SchedulingAuthenticatedEnrollmentSubmitPayload +): Promise { + return apiFetch( + settings, + `/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/authenticated-responses`, + json(payload) + ); +} + export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise { return apiFetch(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({})); } diff --git a/webui/src/features/scheduling/SchedulingEnrollmentPage.tsx b/webui/src/features/scheduling/SchedulingEnrollmentPage.tsx new file mode 100644 index 0000000..ce54843 --- /dev/null +++ b/webui/src/features/scheduling/SchedulingEnrollmentPage.tsx @@ -0,0 +1,301 @@ +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { Link, useParams } from "react-router"; +import { + Button, + Card, + DismissibleAlert, + DocumentationHelpLink, + FormField, + FormGrid, + LoadingFrame, + PasswordField, + formatDateTime, + type ApiSettings, + type AuthInfo +} from "@govoplan/core-webui"; +import { + getPublicSchedulingEnrollment, + submitAuthenticatedSchedulingEnrollment, + submitPublicSchedulingEnrollment, + type SchedulingAvailabilityValue, + type SchedulingPublicEnrollmentResponse +} from "../../api/scheduling"; +import { applySchedulingAvailabilityChoice } from "./schedulingViewModel"; + +type SchedulingEnrollmentPageProps = { + settings: ApiSettings; + auth: AuthInfo | null; +}; + +const I18N = { + access: "i18n:govoplan-scheduling.self_enrollment.access", + answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f", + available: "i18n:govoplan-scheduling.available.7c62a142", + back: "i18n:govoplan-scheduling.open_in_scheduling.48df1541", + bindAccount: "i18n:govoplan-scheduling.self_enrollment.bind_account", + bindHelp: "i18n:govoplan-scheduling.self_enrollment.bind_help", + claimAnonymous: "i18n:govoplan-scheduling.self_enrollment.claim_anonymous", + comment: "i18n:govoplan-scheduling.comment.d03495b1", + deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa", + displayName: "i18n:govoplan-scheduling.name.709a2322", + email: "i18n:govoplan-scheduling.participant_email.2cadfd9e", + expires: "i18n:govoplan-scheduling.self_enrollment.expires", + invalid: "i18n:govoplan-scheduling.self_enrollment.invalid", + loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b", + maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b", + participantDetails: "i18n:govoplan-scheduling.self_enrollment.participant_details", + password: "i18n:govoplan-scheduling.guest_password.94545e82", + proof: "i18n:govoplan-scheduling.self_enrollment.proof", + proofHelp: "i18n:govoplan-scheduling.self_enrollment.proof_help", + remaining: "i18n:govoplan-scheduling.self_enrollment.remaining", + response: "i18n:govoplan-scheduling.your_availability.f86c8215", + saved: "i18n:govoplan-scheduling.self_enrollment.saved", + saving: "i18n:govoplan-scheduling.saving.56a2285c", + submit: "i18n:govoplan-scheduling.self_enrollment.submit", + unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79" +} as const; + +function newSecret(prefix: string): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return `${prefix}-${crypto.randomUUID()}`; + } + return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}-${Math.random().toString(16).slice(2)}`; +} + +function initialAvailability(response: SchedulingPublicEnrollmentResponse) { + const previous = new Map(response.answers.map((answer) => [answer.slot_id, answer.value])); + return Object.fromEntries(response.slots.map((slot) => [slot.id, previous.get(slot.id) ?? ""])) as Record< + string, + SchedulingAvailabilityValue | "" + >; +} + +export default function SchedulingEnrollmentPage({ settings, auth }: SchedulingEnrollmentPageProps) { + const { requestId = "", token = "" } = useParams(); + const [enrollment, setEnrollment] = useState(null); + const [displayName, setDisplayName] = useState(auth?.user.display_name ?? ""); + const [email, setEmail] = useState(auth?.user.email ?? ""); + const [password, setPassword] = useState(""); + const [proof, setProof] = useState(() => newSecret("scheduling-proof")); + const [bindAccount, setBindAccount] = useState(Boolean(auth)); + const [claimAnonymous, setClaimAnonymous] = useState(false); + const [availability, setAvailability] = useState>({}); + const [comment, setComment] = useState(""); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [needsAccess, setNeedsAccess] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const slotIds = useMemo(() => enrollment?.slots.map((slot) => slot.id) ?? [], [enrollment]); + + function applyResponse(next: SchedulingPublicEnrollmentResponse) { + setEnrollment(next); + setAvailability(initialAvailability(next)); + setComment(next.comment ?? ""); + setNeedsAccess(false); + setError(""); + } + + useEffect(() => { + let cancelled = false; + setLoading(true); + void getPublicSchedulingEnrollment(settings, requestId, token) + .then((next) => { + if (!cancelled) applyResponse(next); + }) + .catch(() => { + if (!cancelled) setNeedsAccess(true); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [requestId, settings.apiBaseUrl, settings.apiKey, token]); + + async function openEnrollment(event: FormEvent) { + event.preventDefault(); + setLoading(true); + setError(""); + try { + applyResponse(await getPublicSchedulingEnrollment(settings, requestId, token, password)); + } catch { + setError(I18N.invalid); + } finally { + setLoading(false); + } + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!enrollment || !enrollment.slots.some((slot) => availability[slot.id])) { + setError(I18N.answerRequired); + return; + } + setSaving(true); + setError(""); + setSuccess(""); + const common = { + display_name: displayName.trim(), + email: email.trim() || null, + password: password || null, + answers: enrollment.slots + .filter((slot) => availability[slot.id]) + .map((slot) => ({ + slot_id: slot.id, + value: availability[slot.id] as SchedulingAvailabilityValue, + option_revision: slot.revision + })), + comment: enrollment.allow_comments ? comment.trim() || null : null, + idempotency_key: newSecret("scheduling-enrollment") + }; + try { + const next = auth && bindAccount + ? await submitAuthenticatedSchedulingEnrollment(settings, requestId, token, { + ...common, + bind_account_confirmed: true, + participant_proof: claimAnonymous || (enrollment.enrolled && !enrollment.account_bound) ? proof : null + }) + : await submitPublicSchedulingEnrollment(settings, requestId, token, { + ...common, + participant_proof: proof + }); + applyResponse(next); + setSuccess(I18N.saved); + } catch { + setError(I18N.invalid); + } finally { + setSaving(false); + } + } + + return ( +
+ + {auth ? ( +
+ + {I18N.back} + +
+ ) : null} + + {needsAccess && !enrollment ? ( + +
+ {error ? {error} : null} + + + +
+ +
+
+
+ ) : null} + + {enrollment ? ( +
+ }> + {enrollment.description ?

{enrollment.description}

: null} +
+ {enrollment.deadline_at ? <>
{I18N.deadline}
{formatDateTime(enrollment.deadline_at)}
: null} +
{I18N.expires}
{formatDateTime(enrollment.enrollment_expires_at)}
+
{I18N.remaining}
{enrollment.enrollment_remaining}
+
+
+ + {error ? {error} : null} + {success ? {success} : null} + + + + + setDisplayName(event.target.value)} /> + + + setEmail(event.target.value)} /> + + + {auth && enrollment.authenticated_allowed ? ( + <> + + {bindAccount ? ( + + ) : null} + + ) : null} + {((!auth || !bindAccount) && enrollment.anonymous_allowed) || claimAnonymous ? ( + + + + ) : null} + + + +
+ {enrollment.slots.map((slot) => ( +
+ {slot.label} +

{formatDateTime(slot.start_at)} – {formatDateTime(slot.end_at)}

+
+ {([ + ["available", I18N.available], + ...(enrollment.allow_maybe ? [["maybe", I18N.maybe] as const] : []), + ["unavailable", I18N.unavailable] + ] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => ( + + ))} +
+
+ ))} +
+ {enrollment.allow_comments ? ( + +