feat: add governed public self-enrollment links
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",),
|
||||
|
||||
+134
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
@@ -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, /<StageRail[\s\S]*schedulingLifecycleStages\(selected\.status
|
||||
assert.match(page, /topicId: "scheduling\.find-and-decide-meeting-time"/);
|
||||
assert.match(page, /topicId: "scheduling\.calendar-coordination"/);
|
||||
assert.match(page, /topicId: "scheduling\.participation-governance"/);
|
||||
assert.match(page, /topicId: "scheduling\.public-self-enrollment"/);
|
||||
assert.match(page, /function SelfEnrollmentLinksCard/);
|
||||
assert.match(page, /createSchedulingEnrollmentLink/);
|
||||
assert.match(page, /revokeSchedulingEnrollmentLink/);
|
||||
assert.match(page, /title=\{I18N\.selfEnrollmentRevokeTitle\}[\s\S]*tone="danger"/);
|
||||
assert.match(page, /public_participation_policy_enforcement_available === false[\s\S]*<ActionBlockerHint/);
|
||||
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
|
||||
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/);
|
||||
@@ -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, /<PasswordField[\s\S]*autoComplete="current-password"/);
|
||||
assert.doesNotMatch(publicPage, /<input[\s\S]{0,120}type="password"/);
|
||||
@@ -178,6 +188,14 @@ assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
|
||||
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
|
||||
|
||||
assert.match(enrollmentPage, /submitAuthenticatedSchedulingEnrollment/);
|
||||
assert.match(enrollmentPage, /submitPublicSchedulingEnrollment/);
|
||||
assert.match(enrollmentPage, /bind_account_confirmed: true/);
|
||||
assert.match(enrollmentPage, /participant_proof:/);
|
||||
assert.match(enrollmentPage, /topicId: "scheduling\.public-self-enrollment"/);
|
||||
assert.doesNotMatch(enrollmentPage, /window\.(?:alert|confirm)\(/);
|
||||
assert.doesNotMatch(enrollmentPage, /(?:localStorage|sessionStorage).*(?:token|proof)|(?:token|proof).*(?:localStorage|sessionStorage)/);
|
||||
|
||||
assert.match(widget, /DocumentationHelpLink/);
|
||||
assert.match(widget, /to: `\/scheduling\?request_id=\$\{encodeURIComponent\(request\.id\)\}`/);
|
||||
assert.match(widget, /label=\{request\.status === "collecting" \? I18N\.open : I18N\.draft\}/);
|
||||
|
||||
@@ -264,6 +264,82 @@ export type SchedulingPublicParticipationSubmitPayload = SchedulingPublicPartici
|
||||
idempotency_key?: string;
|
||||
};
|
||||
|
||||
export type SchedulingEnrollmentLink = {
|
||||
id: string;
|
||||
request_id: string;
|
||||
status: "active" | "expired" | "revoked" | "exhausted";
|
||||
expires_at: string;
|
||||
max_enrollments: number;
|
||||
enrollment_count: number;
|
||||
allow_anonymous: boolean;
|
||||
allow_authenticated: boolean;
|
||||
created_at: string;
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulingEnrollmentLinkListResponse = { links: SchedulingEnrollmentLink[] };
|
||||
|
||||
export type SchedulingEnrollmentLinkActionResponse = {
|
||||
link: SchedulingEnrollmentLink;
|
||||
action_url?: string | null;
|
||||
replayed: boolean;
|
||||
};
|
||||
|
||||
export type SchedulingEnrollmentLinkCreatePayload = {
|
||||
expires_at: string;
|
||||
max_enrollments: number;
|
||||
allow_anonymous: boolean;
|
||||
allow_authenticated: boolean;
|
||||
};
|
||||
|
||||
export type SchedulingPublicEnrollmentResponse = {
|
||||
request_id: string;
|
||||
link_id: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
timezone: string;
|
||||
status: SchedulingStatus;
|
||||
deadline_at?: string | null;
|
||||
enrollment_expires_at: string;
|
||||
enrollment_remaining: number;
|
||||
display_name_required: boolean;
|
||||
participant_email_required: boolean;
|
||||
anonymous_allowed: boolean;
|
||||
authenticated_allowed: boolean;
|
||||
anonymous_password_required: boolean;
|
||||
single_choice: boolean;
|
||||
max_participants_per_option: number | null;
|
||||
allow_maybe: boolean;
|
||||
allow_comments: boolean;
|
||||
allow_participant_updates: boolean;
|
||||
enrolled: boolean;
|
||||
account_bound: boolean;
|
||||
has_response: boolean;
|
||||
submitted_at?: string | null;
|
||||
answers: Array<{ slot_id: string; value: SchedulingAvailabilityValue }>;
|
||||
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<SchedulingEnrollmentLinkListResponse> {
|
||||
return apiFetch<SchedulingEnrollmentLinkListResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${requestId}/enrollment-links`
|
||||
);
|
||||
}
|
||||
|
||||
export function createSchedulingEnrollmentLink(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
payload: SchedulingEnrollmentLinkCreatePayload
|
||||
): Promise<SchedulingEnrollmentLinkActionResponse> {
|
||||
return apiFetch<SchedulingEnrollmentLinkActionResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${requestId}/enrollment-links`,
|
||||
json(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export function revokeSchedulingEnrollmentLink(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
linkId: string
|
||||
): Promise<SchedulingEnrollmentLinkActionResponse> {
|
||||
return apiFetch<SchedulingEnrollmentLinkActionResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/requests/${requestId}/enrollment-links/${linkId}`,
|
||||
{ method: "DELETE" }
|
||||
);
|
||||
}
|
||||
|
||||
export function getPublicSchedulingEnrollment(
|
||||
settings: ApiSettings,
|
||||
requestId: string,
|
||||
token: string,
|
||||
password?: string
|
||||
): Promise<SchedulingPublicEnrollmentResponse> {
|
||||
return apiFetch<SchedulingPublicEnrollmentResponse>(
|
||||
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<SchedulingPublicEnrollmentResponse> {
|
||||
return apiFetch<SchedulingPublicEnrollmentResponse>(
|
||||
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<SchedulingPublicEnrollmentResponse> {
|
||||
return apiFetch<SchedulingPublicEnrollmentResponse>(
|
||||
settings,
|
||||
`/api/v1/scheduling/public-enrollment/${encodeURIComponent(requestId)}/${encodeURIComponent(token)}/authenticated-responses`,
|
||||
json(payload)
|
||||
);
|
||||
}
|
||||
|
||||
export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
|
||||
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({}));
|
||||
}
|
||||
|
||||
@@ -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<SchedulingPublicEnrollmentResponse | null>(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<Record<string, SchedulingAvailabilityValue | "">>({});
|
||||
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 (
|
||||
<main className="scheduling-public-page">
|
||||
<LoadingFrame loading={loading} label={I18N.loading}>
|
||||
{auth ? (
|
||||
<div className="scheduling-public-deep-link">
|
||||
<Link className="btn btn-secondary" to={`/scheduling?request_id=${encodeURIComponent(requestId)}`}>
|
||||
{I18N.back}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{needsAccess && !enrollment ? (
|
||||
<Card title={I18N.access}>
|
||||
<form className="scheduling-public-access-form" onSubmit={openEnrollment}>
|
||||
{error ? <DismissibleAlert tone="danger">{error}</DismissibleAlert> : null}
|
||||
<FormField label={I18N.password}>
|
||||
<PasswordField
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onValueChange={setPassword} />
|
||||
</FormField>
|
||||
<div className="scheduling-public-actions">
|
||||
<Button type="submit" variant="primary" disabled={loading}>{I18N.access}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{enrollment ? (
|
||||
<form className="scheduling-public-content" onSubmit={submit}>
|
||||
<Card
|
||||
title={enrollment.title}
|
||||
actions={<DocumentationHelpLink reference={{
|
||||
topicId: "scheduling.public-self-enrollment",
|
||||
documentationType: "user"
|
||||
}} />}>
|
||||
{enrollment.description ? <p>{enrollment.description}</p> : null}
|
||||
<dl className="scheduling-public-summary">
|
||||
{enrollment.deadline_at ? <><dt>{I18N.deadline}</dt><dd>{formatDateTime(enrollment.deadline_at)}</dd></> : null}
|
||||
<dt>{I18N.expires}</dt><dd>{formatDateTime(enrollment.enrollment_expires_at)}</dd>
|
||||
<dt>{I18N.remaining}</dt><dd>{enrollment.enrollment_remaining}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{error ? <DismissibleAlert tone="danger">{error}</DismissibleAlert> : null}
|
||||
{success ? <DismissibleAlert tone="success">{success}</DismissibleAlert> : null}
|
||||
|
||||
<Card title={I18N.participantDetails}>
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label={I18N.displayName}>
|
||||
<input required maxLength={500} value={displayName} onChange={(event) => setDisplayName(event.target.value)} />
|
||||
</FormField>
|
||||
<FormField label={I18N.email}>
|
||||
<input type="email" required={enrollment.participant_email_required} maxLength={320} value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
{auth && enrollment.authenticated_allowed ? (
|
||||
<>
|
||||
<label className="scheduling-enrollment-confirmation">
|
||||
<input type="checkbox" checked={bindAccount} onChange={(event) => setBindAccount(event.target.checked)} />
|
||||
<span><strong>{I18N.bindAccount}</strong><small>{I18N.bindHelp}</small></span>
|
||||
</label>
|
||||
{bindAccount ? (
|
||||
<label className="scheduling-enrollment-confirmation">
|
||||
<input type="checkbox" checked={claimAnonymous} onChange={(event) => setClaimAnonymous(event.target.checked)} />
|
||||
<span>{I18N.claimAnonymous}</span>
|
||||
</label>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{((!auth || !bindAccount) && enrollment.anonymous_allowed) || claimAnonymous ? (
|
||||
<FormField label={I18N.proof} help={I18N.proofHelp}>
|
||||
<PasswordField
|
||||
autoComplete="off"
|
||||
value={proof}
|
||||
onValueChange={setProof} />
|
||||
</FormField>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title={I18N.response}>
|
||||
<div className="scheduling-public-slots">
|
||||
{enrollment.slots.map((slot) => (
|
||||
<fieldset className="scheduling-public-slot" key={slot.id} disabled={saving}>
|
||||
<legend>{slot.label}</legend>
|
||||
<p>{formatDateTime(slot.start_at)} – {formatDateTime(slot.end_at)}</p>
|
||||
<div className="scheduling-public-choice-group">
|
||||
{([
|
||||
["available", I18N.available],
|
||||
...(enrollment.allow_maybe ? [["maybe", I18N.maybe] as const] : []),
|
||||
["unavailable", I18N.unavailable]
|
||||
] as Array<[SchedulingAvailabilityValue, string]>).map(([value, label]) => (
|
||||
<label key={value}>
|
||||
<input
|
||||
type="radio"
|
||||
name={`slot-${slot.id}`}
|
||||
checked={availability[slot.id] === value}
|
||||
onChange={() => setAvailability((current) => applySchedulingAvailabilityChoice(
|
||||
slotIds,
|
||||
current,
|
||||
slot.id,
|
||||
value,
|
||||
enrollment.single_choice
|
||||
))} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
{enrollment.allow_comments ? (
|
||||
<FormField label={I18N.comment}>
|
||||
<textarea rows={4} maxLength={4000} value={comment} onChange={(event) => setComment(event.target.value)} />
|
||||
</FormField>
|
||||
) : null}
|
||||
<div className="scheduling-public-actions">
|
||||
<Button type="submit" variant="primary" disabled={saving || !displayName.trim()}>
|
||||
{saving ? I18N.saving : I18N.submit}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
) : null}
|
||||
</LoadingFrame>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
closeSchedulingRequest,
|
||||
createSchedulingCalendarEvent,
|
||||
createSchedulingHolds,
|
||||
createSchedulingEnrollmentLink,
|
||||
createSchedulingNotifications,
|
||||
createSchedulingRequest,
|
||||
decideSchedulingRequest,
|
||||
@@ -65,9 +66,11 @@ import {
|
||||
getSchedulingAvailabilityResponse,
|
||||
issueSchedulingParticipantInvitation,
|
||||
listSchedulingNotifications,
|
||||
listSchedulingEnrollmentLinks,
|
||||
listSchedulingRequests,
|
||||
openSchedulingRequest,
|
||||
revokeSchedulingParticipantInvitation,
|
||||
revokeSchedulingEnrollmentLink,
|
||||
searchSchedulingPeople,
|
||||
schedulingSummary,
|
||||
submitSchedulingAvailability,
|
||||
@@ -75,6 +78,7 @@ import {
|
||||
type SchedulingCandidateSlot,
|
||||
type SchedulingAvailabilityValue,
|
||||
type SchedulingInvitationActionResponse,
|
||||
type SchedulingEnrollmentLink,
|
||||
type SchedulingNotification,
|
||||
type SchedulingParticipant,
|
||||
type SchedulingPollOptionResult,
|
||||
@@ -251,6 +255,30 @@ const I18N = {
|
||||
requiresEventWrite: "i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763",
|
||||
responseRecorded: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
|
||||
responseReplace: "i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53",
|
||||
selfEnrollmentAllowAnonymous: "i18n:govoplan-scheduling.self_enrollment.allow_anonymous",
|
||||
selfEnrollmentAllowAuthenticated: "i18n:govoplan-scheduling.self_enrollment.allow_authenticated",
|
||||
selfEnrollmentClipboardFailed: "i18n:govoplan-scheduling.self_enrollment.clipboard_failed",
|
||||
selfEnrollmentCopied: "i18n:govoplan-scheduling.self_enrollment.copied",
|
||||
selfEnrollmentExpiresAt: "i18n:govoplan-scheduling.self_enrollment.expires_at",
|
||||
selfEnrollmentIssueCopy: "i18n:govoplan-scheduling.self_enrollment.issue_copy",
|
||||
selfEnrollmentIssueFailed: "i18n:govoplan-scheduling.self_enrollment.issue_failed",
|
||||
selfEnrollmentLinks: "i18n:govoplan-scheduling.self_enrollment.links",
|
||||
selfEnrollmentLinksHelp: "i18n:govoplan-scheduling.self_enrollment.links_help",
|
||||
selfEnrollmentLoadingLinks: "i18n:govoplan-scheduling.self_enrollment.loading_links",
|
||||
selfEnrollmentLoadFailed: "i18n:govoplan-scheduling.self_enrollment.load_failed",
|
||||
selfEnrollmentMaximum: "i18n:govoplan-scheduling.self_enrollment.maximum",
|
||||
selfEnrollmentModeAnonymous: "i18n:govoplan-scheduling.self_enrollment.mode_anonymous",
|
||||
selfEnrollmentModeAuthenticated: "i18n:govoplan-scheduling.self_enrollment.mode_authenticated",
|
||||
selfEnrollmentNoLinks: "i18n:govoplan-scheduling.self_enrollment.no_links",
|
||||
selfEnrollmentOpenFirst: "i18n:govoplan-scheduling.self_enrollment.open_first",
|
||||
selfEnrollmentRevokeFailed: "i18n:govoplan-scheduling.self_enrollment.revoke_failed",
|
||||
selfEnrollmentRevoked: "i18n:govoplan-scheduling.self_enrollment.revoked",
|
||||
selfEnrollmentRevokeMessage: "i18n:govoplan-scheduling.self_enrollment.revoke_message",
|
||||
selfEnrollmentRevokeTitle: "i18n:govoplan-scheduling.self_enrollment.revoke_title",
|
||||
selfEnrollmentStatusActive: "i18n:govoplan-scheduling.self_enrollment.status_active",
|
||||
selfEnrollmentStatusExpired: "i18n:govoplan-scheduling.self_enrollment.status_expired",
|
||||
selfEnrollmentStatusExhausted: "i18n:govoplan-scheduling.self_enrollment.status_exhausted",
|
||||
selfEnrollmentStatusRevoked: "i18n:govoplan-scheduling.self_enrollment.status_revoked",
|
||||
resultsUnavailable: "i18n:govoplan-scheduling.response_results_are_not_available_for_this_view.1e82db18",
|
||||
invitationHelp: "i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53",
|
||||
save: "i18n:govoplan-scheduling.save.efc007a3",
|
||||
@@ -1308,6 +1336,13 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
|
||||
</Card>
|
||||
|
||||
{canManageSelected ? (
|
||||
<SelfEnrollmentLinksCard
|
||||
settings={settings}
|
||||
request={selected}
|
||||
disabled={saving} />
|
||||
) : null}
|
||||
|
||||
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? (
|
||||
<Card
|
||||
title={I18N.calendarCoordination}
|
||||
@@ -2283,6 +2318,198 @@ function localValue(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
function SelfEnrollmentLinksCard({
|
||||
settings,
|
||||
request,
|
||||
disabled
|
||||
}: {
|
||||
settings: ApiSettings;
|
||||
request: SchedulingRequest;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const defaultExpiry = useMemo(() => {
|
||||
const candidate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const deadline = request.deadline_at ? new Date(request.deadline_at) : null;
|
||||
return localValue(deadline && deadline < candidate ? deadline : candidate);
|
||||
}, [request.deadline_at]);
|
||||
const [links, setLinks] = useState<SchedulingEnrollmentLink[]>([]);
|
||||
const [expiresAt, setExpiresAt] = useState(defaultExpiry);
|
||||
const [capacity, setCapacity] = useState(25);
|
||||
const [allowAnonymous, setAllowAnonymous] = useState(true);
|
||||
const [allowAuthenticated, setAllowAuthenticated] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [working, setWorking] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState("");
|
||||
const [revokeTarget, setRevokeTarget] = useState<SchedulingEnrollmentLink | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setLinks((await listSchedulingEnrollmentLinks(settings, request.id)).links);
|
||||
setError("");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, translateText(I18N.selfEnrollmentLoadFailed)));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [request.id, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function createLink() {
|
||||
let issuedLinkId: string | null = null;
|
||||
setWorking(true);
|
||||
setError("");
|
||||
setSuccess("");
|
||||
try {
|
||||
const result = await createSchedulingEnrollmentLink(settings, request.id, {
|
||||
expires_at: isoFromLocal(expiresAt),
|
||||
max_enrollments: capacity,
|
||||
allow_anonymous: allowAnonymous,
|
||||
allow_authenticated: allowAuthenticated
|
||||
});
|
||||
issuedLinkId = result.link.id;
|
||||
const absoluteUrl = result.action_url
|
||||
? new URL(result.action_url, window.location.origin).toString()
|
||||
: null;
|
||||
if (!absoluteUrl || !navigator.clipboard) {
|
||||
throw new Error(translateText(I18N.selfEnrollmentClipboardFailed));
|
||||
}
|
||||
await navigator.clipboard.writeText(absoluteUrl);
|
||||
setSuccess(I18N.selfEnrollmentCopied);
|
||||
await load();
|
||||
} catch (err) {
|
||||
if (issuedLinkId) {
|
||||
try {
|
||||
await revokeSchedulingEnrollmentLink(settings, request.id, issuedLinkId);
|
||||
} catch {
|
||||
// The list reload below surfaces the still-active link for explicit revocation.
|
||||
}
|
||||
}
|
||||
setError(errorMessage(err, translateText(I18N.selfEnrollmentIssueFailed)));
|
||||
await load();
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeLink() {
|
||||
if (!revokeTarget) return;
|
||||
const target = revokeTarget;
|
||||
setRevokeTarget(null);
|
||||
setWorking(true);
|
||||
setError("");
|
||||
try {
|
||||
await revokeSchedulingEnrollmentLink(settings, request.id, target.id);
|
||||
setSuccess(I18N.selfEnrollmentRevoked);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, translateText(I18N.selfEnrollmentRevokeFailed)));
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={I18N.selfEnrollmentLinks}
|
||||
actions={<DocumentationHelpLink reference={{
|
||||
topicId: "scheduling.public-self-enrollment",
|
||||
documentationType: "user"
|
||||
}} />}>
|
||||
<p className="scheduling-capability-note">
|
||||
{I18N.selfEnrollmentLinksHelp}
|
||||
</p>
|
||||
{error ? <DismissibleAlert tone="danger">{error}</DismissibleAlert> : null}
|
||||
{success ? <DismissibleAlert tone="success">{success}</DismissibleAlert> : null}
|
||||
<FormGrid columns={2} collapseAt="standard" className="">
|
||||
<FormField label={I18N.selfEnrollmentExpiresAt}>
|
||||
<DateTimeField
|
||||
required
|
||||
min={localValue(new Date())}
|
||||
value={expiresAt}
|
||||
disabled={disabled || working || request.status !== "collecting"}
|
||||
onChange={setExpiresAt} />
|
||||
</FormField>
|
||||
<FormField label={I18N.selfEnrollmentMaximum}>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
min={1}
|
||||
max={10_000}
|
||||
value={capacity}
|
||||
disabled={disabled || working || request.status !== "collecting"}
|
||||
onChange={(event) => setCapacity(Number(event.target.value))} />
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<div className="scheduling-enrollment-modes">
|
||||
<ToggleSwitch
|
||||
label={I18N.selfEnrollmentAllowAnonymous}
|
||||
checked={allowAnonymous}
|
||||
disabled={disabled || working || request.status !== "collecting" || !request.allow_external_participants}
|
||||
onChange={setAllowAnonymous} />
|
||||
<ToggleSwitch
|
||||
label={I18N.selfEnrollmentAllowAuthenticated}
|
||||
checked={allowAuthenticated}
|
||||
disabled={disabled || working || request.status !== "collecting"}
|
||||
onChange={setAllowAuthenticated} />
|
||||
</div>
|
||||
<div className="scheduling-public-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={disabled || working || loading || request.status !== "collecting" || !expiresAt || capacity < 1 || (!allowAnonymous && !allowAuthenticated)}
|
||||
disabledReason={request.status !== "collecting" ? I18N.selfEnrollmentOpenFirst : undefined}
|
||||
onClick={() => void createLink()}>
|
||||
<Copy aria-hidden="true" size={16} /> {I18N.selfEnrollmentIssueCopy}
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? <p className="scheduling-note">{I18N.selfEnrollmentLoadingLinks}</p> : links.length ? (
|
||||
<div className="scheduling-enrollment-links">
|
||||
{links.map((link) => (
|
||||
<div className="scheduling-compact-row" key={link.id}>
|
||||
<span>
|
||||
<strong>{link.enrollment_count} / {link.max_enrollments}</strong>
|
||||
<small>{formatDateTime(link.expires_at)} · {link.allow_anonymous ? translateText(I18N.selfEnrollmentModeAnonymous) : ""}{link.allow_anonymous && link.allow_authenticated ? " + " : ""}{link.allow_authenticated ? translateText(I18N.selfEnrollmentModeAuthenticated) : ""}</small>
|
||||
</span>
|
||||
<StatusBadge status={link.status} label={selfEnrollmentLinkStatusLabel(link.status)} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={disabled || working || link.status === "revoked"}
|
||||
onClick={() => setRevokeTarget(link)}>
|
||||
<Link2Off aria-hidden="true" size={16} /> {I18N.revokeLink}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : <p className="scheduling-note">{I18N.selfEnrollmentNoLinks}</p>}
|
||||
<ConfirmDialog
|
||||
open={Boolean(revokeTarget)}
|
||||
title={I18N.selfEnrollmentRevokeTitle}
|
||||
message={I18N.selfEnrollmentRevokeMessage}
|
||||
confirmLabel={I18N.revokeLink}
|
||||
tone="danger"
|
||||
busy={working}
|
||||
onCancel={() => setRevokeTarget(null)}
|
||||
onConfirm={() => void revokeLink()} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function selfEnrollmentLinkStatusLabel(status: SchedulingEnrollmentLink["status"]): string {
|
||||
return {
|
||||
active: I18N.selfEnrollmentStatusActive,
|
||||
expired: I18N.selfEnrollmentStatusExpired,
|
||||
revoked: I18N.selfEnrollmentStatusRevoked,
|
||||
exhausted: I18N.selfEnrollmentStatusExhausted
|
||||
}[status];
|
||||
}
|
||||
|
||||
function addLocalMinutes(value: string, minutes: number): string {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
export const generatedTranslations = {
|
||||
en: {
|
||||
"i18n:govoplan-scheduling.self_enrollment.access": "Open self-enrollment",
|
||||
"i18n:govoplan-scheduling.self_enrollment.bind_account": "Bind this enrollment to my signed-in account",
|
||||
"i18n:govoplan-scheduling.self_enrollment.bind_help": "Account binding requires confirmation and lets you manage this response from Scheduling.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.claim_anonymous": "Bind an earlier anonymous enrollment using its recovery proof",
|
||||
"i18n:govoplan-scheduling.self_enrollment.expires": "Self-enrollment link expires",
|
||||
"i18n:govoplan-scheduling.self_enrollment.invalid": "This self-enrollment link is invalid, expired, full, or the supplied details are incorrect.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.proof": "Anonymous recovery proof",
|
||||
"i18n:govoplan-scheduling.self_enrollment.proof_help": "Keep this value privately. It is required to update or later bind an anonymous response and is never stored in clear text.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.remaining": "Enrollment places remaining",
|
||||
"i18n:govoplan-scheduling.self_enrollment.submit": "Enroll and submit response",
|
||||
"i18n:govoplan-scheduling.self_enrollment.saved": "Your enrollment and response have been recorded.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.participant_details": "Participant details",
|
||||
"i18n:govoplan-scheduling.self_enrollment.links": "Public self-enrollment links",
|
||||
"i18n:govoplan-scheduling.self_enrollment.links_help": "Reusable links are separate from participant invitations. Every link requires a capacity and expiry and is shown only once when copied.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.expires_at": "Expires at",
|
||||
"i18n:govoplan-scheduling.self_enrollment.maximum": "Maximum enrollments",
|
||||
"i18n:govoplan-scheduling.self_enrollment.allow_anonymous": "Allow anonymous enrollment with a recovery proof",
|
||||
"i18n:govoplan-scheduling.self_enrollment.allow_authenticated": "Allow signed-in enrollment after account-binding confirmation",
|
||||
"i18n:govoplan-scheduling.self_enrollment.issue_copy": "Issue and copy link",
|
||||
"i18n:govoplan-scheduling.self_enrollment.open_first": "Open the request before issuing a self-enrollment link.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.loading_links": "Loading self-enrollment links…",
|
||||
"i18n:govoplan-scheduling.self_enrollment.no_links": "No self-enrollment links have been issued.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoke_title": "Revoke self-enrollment link",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoke_message": "This stops the reusable link immediately. Existing participant responses remain governed by the request policy.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.copied": "A new self-enrollment link was issued and copied. Its credential will not be shown again.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoked": "The self-enrollment link was revoked.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.load_failed": "Self-enrollment links could not be loaded.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.issue_failed": "The self-enrollment link could not be issued.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoke_failed": "The self-enrollment link could not be revoked.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.clipboard_failed": "The link was issued, but clipboard access is unavailable. The new link will be revoked automatically.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.mode_anonymous": "anonymous",
|
||||
"i18n:govoplan-scheduling.self_enrollment.mode_authenticated": "signed in",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_active": "Active",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_expired": "Expired",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_revoked": "Revoked",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_exhausted": "Full",
|
||||
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Calendar cleanup requires attention.",
|
||||
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} tentative hold operations remain. Reconcile failed Calendar outbound changes if necessary, then repeat the original decision or cancellation action.",
|
||||
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
|
||||
@@ -182,6 +218,42 @@ export const generatedTranslations = {
|
||||
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
|
||||
},
|
||||
de: {
|
||||
"i18n:govoplan-scheduling.self_enrollment.access": "Selbstanmeldung öffnen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.bind_account": "Diese Anmeldung mit meinem angemeldeten Konto verknüpfen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.bind_help": "Die Kontoverknüpfung muss bestätigt werden und ermöglicht die Verwaltung dieser Antwort in der Terminplanung.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.claim_anonymous": "Eine frühere anonyme Anmeldung mit ihrem Wiederherstellungsnachweis verknüpfen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.expires": "Link zur Selbstanmeldung läuft ab",
|
||||
"i18n:govoplan-scheduling.self_enrollment.invalid": "Dieser Link zur Selbstanmeldung ist ungültig, abgelaufen oder vollständig belegt, oder die angegebenen Daten sind falsch.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.proof": "Wiederherstellungsnachweis für anonyme Anmeldung",
|
||||
"i18n:govoplan-scheduling.self_enrollment.proof_help": "Bewahren Sie diesen Wert vertraulich auf. Er wird zum Aktualisieren oder späteren Verknüpfen einer anonymen Antwort benötigt und nie im Klartext gespeichert.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.remaining": "Verbleibende Anmeldeplätze",
|
||||
"i18n:govoplan-scheduling.self_enrollment.submit": "Anmelden und Antwort senden",
|
||||
"i18n:govoplan-scheduling.self_enrollment.saved": "Ihre Anmeldung und Antwort wurden gespeichert.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.participant_details": "Angaben zur teilnehmenden Person",
|
||||
"i18n:govoplan-scheduling.self_enrollment.links": "Öffentliche Links zur Selbstanmeldung",
|
||||
"i18n:govoplan-scheduling.self_enrollment.links_help": "Wiederverwendbare Links sind von persönlichen Einladungen getrennt. Jeder Link benötigt eine Kapazität und ein Ablaufdatum und wird beim Kopieren nur einmal angezeigt.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.expires_at": "Läuft ab am",
|
||||
"i18n:govoplan-scheduling.self_enrollment.maximum": "Maximale Anmeldungen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.allow_anonymous": "Anonyme Anmeldung mit Wiederherstellungsnachweis zulassen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.allow_authenticated": "Anmeldung mit Konto nach Bestätigung der Verknüpfung zulassen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.issue_copy": "Link ausstellen und kopieren",
|
||||
"i18n:govoplan-scheduling.self_enrollment.open_first": "Öffnen Sie die Anfrage, bevor Sie einen Link zur Selbstanmeldung ausstellen.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.loading_links": "Links zur Selbstanmeldung werden geladen …",
|
||||
"i18n:govoplan-scheduling.self_enrollment.no_links": "Es wurden noch keine Links zur Selbstanmeldung ausgestellt.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoke_title": "Link zur Selbstanmeldung widerrufen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoke_message": "Der wiederverwendbare Link wird sofort deaktiviert. Vorhandene Antworten bleiben weiterhin durch die Richtlinie der Anfrage geregelt.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.copied": "Ein neuer Link zur Selbstanmeldung wurde ausgestellt und kopiert. Seine Zugangsdaten werden nicht erneut angezeigt.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoked": "Der Link zur Selbstanmeldung wurde widerrufen.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.load_failed": "Die Links zur Selbstanmeldung konnten nicht geladen werden.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.issue_failed": "Der Link zur Selbstanmeldung konnte nicht ausgestellt werden.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.revoke_failed": "Der Link zur Selbstanmeldung konnte nicht widerrufen werden.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.clipboard_failed": "Der Link wurde ausgestellt, aber die Zwischenablage ist nicht verfügbar. Der neue Link wird automatisch widerrufen.",
|
||||
"i18n:govoplan-scheduling.self_enrollment.mode_anonymous": "anonym",
|
||||
"i18n:govoplan-scheduling.self_enrollment.mode_authenticated": "angemeldet",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_active": "Aktiv",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_expired": "Abgelaufen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_revoked": "Widerrufen",
|
||||
"i18n:govoplan-scheduling.self_enrollment.status_exhausted": "Vollständig belegt",
|
||||
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Die Kalenderbereinigung erfordert Aufmerksamkeit.",
|
||||
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} Vorgänge für vorläufige Reservierungen stehen noch aus. Gleichen Sie fehlgeschlagene ausgehende Kalenderänderungen bei Bedarf ab und wiederholen Sie anschließend die ursprüngliche Entscheidungs- oder Abbruchaktion.",
|
||||
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
|
||||
|
||||
+7
-1
@@ -9,6 +9,7 @@ import "./styles/scheduling.css";
|
||||
|
||||
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
|
||||
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
|
||||
const SchedulingEnrollmentPage = lazy(() => import("./features/scheduling/SchedulingEnrollmentPage"));
|
||||
|
||||
const scheduleRead = ["scheduling:schedule:read"];
|
||||
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
@@ -59,7 +60,7 @@ const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
export const schedulingModule: PlatformWebModule = {
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
version: "0.1.11",
|
||||
version: "0.1.18",
|
||||
dependencies: ["poll"],
|
||||
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
|
||||
translations: generatedTranslations,
|
||||
@@ -81,6 +82,11 @@ export const schedulingModule: PlatformWebModule = {
|
||||
path: "/scheduling/public/:requestId/:token",
|
||||
order: 10,
|
||||
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
|
||||
},
|
||||
{
|
||||
path: "/scheduling/enrol/:requestId/:token",
|
||||
order: 11,
|
||||
render: ({ settings, auth }) => createElement(SchedulingEnrollmentPage, { settings, auth })
|
||||
}
|
||||
],
|
||||
uiCapabilities: {
|
||||
|
||||
@@ -403,6 +403,36 @@
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.scheduling-enrollment-confirmation {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.scheduling-enrollment-confirmation span {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.scheduling-enrollment-confirmation small,
|
||||
.scheduling-enrollment-links small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.scheduling-enrollment-modes,
|
||||
.scheduling-enrollment-links {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.scheduling-enrollment-links .scheduling-compact-row > span:first-child {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.scheduling-workspace-layout {
|
||||
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user