feat: add governed public self-enrollment links

This commit is contained in:
2026-08-20 13:03:57 +02:00
parent 79cfaa951a
commit 539e3cbb5e
18 changed files with 2481 additions and 13 deletions
+14 -2
View File
@@ -1,5 +1,17 @@
from __future__ import annotations 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",
]
+44 -1
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any 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 sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin from govoplan_core.db.base import Base, TimestampMixin
@@ -67,6 +67,40 @@ class SchedulingRequest(Base, TimestampMixin):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="SchedulingParticipant.created_at", 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): 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) 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) 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) 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) 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) responded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
response_comment: Mapped[str | None] = mapped_column(Text, nullable=True) response_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -150,6 +192,7 @@ __all__ = [
"SchedulingCandidateSlot", "SchedulingCandidateSlot",
"SchedulingNotification", "SchedulingNotification",
"SchedulingParticipant", "SchedulingParticipant",
"SchedulingPublicEnrollmentLink",
"SchedulingRequest", "SchedulingRequest",
"new_uuid", "new_uuid",
] ]
+73 -4
View File
@@ -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]: 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 { return {
"scheduling_requests": ( "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") .filter(SchedulingNotification.tenant_id == tenant_id, SchedulingNotification.status == "pending")
.count() .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() request_id = str(path_params.get("request_id") or "").strip()
token = str(path_params.get("token") or "").strip() token = str(path_params.get("token") or "").strip()
path = str(getattr(getattr(request, "url", None), "path", "")) 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 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) app = getattr(request, "app", None)
registry = getattr(getattr(app, "state", None), "govoplan_registry", None) registry = getattr(getattr(app, "state", None), "govoplan_registry", None)
@@ -315,6 +377,11 @@ manifest = ModuleManifest(
component="SchedulingPublicPage", component="SchedulingPublicPage",
order=10, 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),), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
product_areas=( product_areas=(
@@ -350,6 +417,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest, scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot, scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant, scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingPublicEnrollmentLink,
scheduling_models.SchedulingNotification, scheduling_models.SchedulingNotification,
label="Scheduling", label="Scheduling",
), ),
@@ -360,6 +428,7 @@ manifest = ModuleManifest(
scheduling_models.SchedulingRequest, scheduling_models.SchedulingRequest,
scheduling_models.SchedulingCandidateSlot, scheduling_models.SchedulingCandidateSlot,
scheduling_models.SchedulingParticipant, scheduling_models.SchedulingParticipant,
scheduling_models.SchedulingPublicEnrollmentLink,
scheduling_models.SchedulingNotification, scheduling_models.SchedulingNotification,
label="Scheduling", label="Scheduling",
), ),
@@ -372,7 +441,7 @@ manifest = ModuleManifest(
documentation_ref="README.md", documentation_ref="README.md",
test_ref="tests/test_service.py", test_ref="tests/test_service.py",
known_limits=("Reference deployment notification delivery and every calendar-provider constraint remain incomplete.",), 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"), non_owned_concepts=("poll response primitive", "calendar event", "mail delivery"),
recovery_docs=("README.md",), recovery_docs=("README.md",),
security_docs=("README.md",), security_docs=("README.md",),
@@ -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")
+237
View File
@@ -14,12 +14,17 @@ from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPON
from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponse, SchedulingAvailabilityResponse,
SchedulingAvailabilityResponseRequest, SchedulingAvailabilityResponseRequest,
SchedulingAuthenticatedEnrollmentSubmitRequest,
SchedulingCalendarActionResponse, SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest, SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingInvitationActionRequest, SchedulingInvitationActionRequest,
SchedulingInvitationActionResponse, SchedulingInvitationActionResponse,
SchedulingInvitationRevokeRequest, SchedulingInvitationRevokeRequest,
SchedulingEnrollmentLinkActionResponse,
SchedulingEnrollmentLinkCreateRequest,
SchedulingEnrollmentLinkListResponse,
SchedulingEnrollmentLinkResponse,
SchedulingNotificationCreateRequest, SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse, SchedulingNotificationListResponse,
SchedulingNotificationResponse, SchedulingNotificationResponse,
@@ -34,6 +39,9 @@ from govoplan_scheduling.backend.schemas import (
SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationResponse, SchedulingPublicParticipationResponse,
SchedulingPublicParticipationSubmitRequest, SchedulingPublicParticipationSubmitRequest,
SchedulingPublicEnrollmentAccessRequest,
SchedulingPublicEnrollmentResponse,
SchedulingPublicEnrollmentSubmitRequest,
SchedulingStatusResponse, SchedulingStatusResponse,
SchedulingSummaryResponse, SchedulingSummaryResponse,
) )
@@ -47,6 +55,7 @@ from govoplan_scheduling.backend.service import (
close_scheduling_request, close_scheduling_request,
create_final_calendar_event, create_final_calendar_event,
create_scheduling_notification_jobs, create_scheduling_notification_jobs,
create_scheduling_enrollment_link,
create_scheduling_request, create_scheduling_request,
create_tentative_calendar_holds, create_tentative_calendar_holds,
decide_scheduling_request, decide_scheduling_request,
@@ -54,18 +63,25 @@ from govoplan_scheduling.backend.service import (
get_scheduling_request, get_scheduling_request,
get_scheduling_availability_response, get_scheduling_availability_response,
get_public_scheduling_participation, get_public_scheduling_participation,
get_public_scheduling_enrollment,
get_visible_scheduling_request, get_visible_scheduling_request,
list_visible_scheduling_notifications, list_visible_scheduling_notifications,
list_visible_scheduling_requests, list_visible_scheduling_requests,
list_scheduling_enrollment_links,
issue_scheduling_participant_invitation, issue_scheduling_participant_invitation,
open_scheduling_request, open_scheduling_request,
require_visible_scheduling_results, require_visible_scheduling_results,
revoke_scheduling_participant_invitation, revoke_scheduling_participant_invitation,
revoke_scheduling_enrollment_link,
response_datetime,
scheduling_enrollment_link_response,
scheduling_notification_response, scheduling_notification_response,
scheduling_request_response, scheduling_request_response,
scheduling_request_summary, scheduling_request_summary,
submit_scheduling_availability, submit_scheduling_availability,
submit_public_scheduling_participation, submit_public_scheduling_participation,
submit_authenticated_scheduling_enrollment,
submit_public_scheduling_enrollment,
update_scheduling_candidate_slot, update_scheduling_candidate_slot,
update_scheduling_request_with_change_log, update_scheduling_request_with_change_log,
) )
@@ -253,6 +269,107 @@ def api_submit_public_scheduling_participation(
return validated 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) @router.get("/people", response_model=SchedulingPeopleSearchResponse)
def api_search_scheduling_people( def api_search_scheduling_people(
query: str = Query(min_length=1), query: str = Query(min_length=1),
@@ -443,6 +560,126 @@ def api_update_scheduling_request(
return response 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( @router.post(
"/requests/{request_id}/participants/{participant_id}/invitation", "/requests/{request_id}/participants/{participant_id}/invitation",
response_model=SchedulingInvitationActionResponse, response_model=SchedulingInvitationActionResponse,
+102
View File
@@ -458,6 +458,108 @@ class SchedulingPublicParticipationResponse(BaseModel):
slots: list[SchedulingPublicCandidateSlotResponse] = Field(default_factory=list) 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): class SchedulingPollOptionResultResponse(BaseModel):
option_id: str option_id: str
option_key: str option_key: str
+24 -1
View File
@@ -4,6 +4,7 @@ import base64
import hashlib import hashlib
import hmac import hmac
import os import os
import secrets
_ALGORITHM = "pbkdf2_sha256" _ALGORITHM = "pbkdf2_sha256"
@@ -11,6 +12,22 @@ _DEFAULT_ITERATIONS = 260_000
_SALT_BYTES = 16 _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( def hash_participant_password(
password: str, password: str,
*, *,
@@ -58,4 +75,10 @@ def verify_participant_password(password: str, encoded: str | None) -> bool:
return hmac.compare_digest(actual, expected) 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",
]
+704 -2
View File
@@ -55,13 +55,23 @@ from govoplan_core.core.throttling import (
build_fixed_window_throttle, build_fixed_window_throttle,
) )
from govoplan_core.db.base import utcnow 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 ( from govoplan_scheduling.backend.schemas import (
SchedulingAvailabilityResponseRequest, SchedulingAvailabilityResponseRequest,
SchedulingCandidateSlotReconcileInput, SchedulingCandidateSlotReconcileInput,
SchedulingCandidateSlotUpdateRequest, SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingParticipantReconcileInput, SchedulingParticipantReconcileInput,
SchedulingAuthenticatedEnrollmentSubmitRequest,
SchedulingEnrollmentLinkCreateRequest,
SchedulingPublicEnrollmentAccessRequest,
SchedulingPublicEnrollmentSubmitRequest,
SchedulingPublicParticipationAccessRequest, SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationSubmitRequest, SchedulingPublicParticipationSubmitRequest,
SchedulingRequestCreateRequest, 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.runtime import get_registry, get_settings
from govoplan_scheduling.backend.security import ( from govoplan_scheduling.backend.security import (
hash_participant_password, hash_participant_password,
new_public_credential,
public_credential_hash,
verify_participant_password, verify_participant_password,
verify_public_credential,
) )
@@ -137,6 +150,9 @@ SCHEDULING_PARTICIPATION_GATEWAY = "scheduling"
PARTICIPATION_PASSWORD_ATTEMPT_LIMIT = 10 PARTICIPATION_PASSWORD_ATTEMPT_LIMIT = 10
PARTICIPATION_PASSWORD_REQUEST_LIMIT = 100 PARTICIPATION_PASSWORD_REQUEST_LIMIT = 100
PARTICIPATION_PASSWORD_WINDOW_SECONDS = 15 * 60 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: def response_datetime(value: datetime | None) -> datetime | None:
@@ -191,6 +207,8 @@ def scheduling_participant_revision(participant: SchedulingParticipant) -> str:
"status": participant.status, "status": participant.status,
"poll_invitation_id": participant.poll_invitation_id, "poll_invitation_id": participant.poll_invitation_id,
"participation_gateway": participant.participation_gateway, "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 {}, "metadata": participant.metadata_ or {},
} }
encoded = json.dumps( encoded = json.dumps(
@@ -1528,7 +1546,10 @@ def _participant_matches_actor(
return _identity_matches_actor( return _identity_matches_actor(
participant.respondent_id, participant.respondent_id,
actor_ids, 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( def scheduling_request_is_visible(
@@ -1583,6 +1604,7 @@ def list_visible_scheduling_requests(
SchedulingParticipant.deleted_at.is_(None), SchedulingParticipant.deleted_at.is_(None),
or_( or_(
SchedulingParticipant.respondent_id.in_(ids or ("",)), SchedulingParticipant.respondent_id.in_(ids or ("",)),
SchedulingParticipant.bound_account_id.in_(ids or ("",)),
func.lower(SchedulingParticipant.email).in_( func.lower(SchedulingParticipant.email).in_(
email_ids or ("",) email_ids or ("",)
), ),
@@ -1988,6 +2010,686 @@ def _availability_response_answers(
return 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( def _resolve_public_scheduling_participation(
session: Session, session: Session,
*, *,
+8 -1
View File
@@ -33,7 +33,10 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend) self.assertIsNotNone(manifest.frontend)
self.assertEqual( self.assertEqual(
["/scheduling/public/:requestId/:token"], [
"/scheduling/public/:requestId/:token",
"/scheduling/enrol/:requestId/:token",
],
[route.path for route in manifest.frontend.public_routes], [route.path for route in manifest.frontend.public_routes],
) )
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
@@ -63,6 +66,10 @@ class SchedulingManifestTests(unittest.TestCase):
"scheduling.calendar-coordination", "scheduling.calendar-coordination",
documentation["scheduling.calendar-coordination"].metadata["help_contexts"], documentation["scheduling.calendar-coordination"].metadata["help_contexts"],
) )
self.assertIn(
"scheduling.public-self-enrollment",
documentation["scheduling.public-self-enrollment"].metadata["help_contexts"],
)
if __name__ == "__main__": if __name__ == "__main__":
+9 -1
View File
@@ -20,7 +20,7 @@ from govoplan_scheduling.backend.db.models import (
from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
_SCHEDULING_HEAD = "c9d4e7f1a2b3" _SCHEDULING_HEAD = "d7a4c1e8f205"
_SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10" _SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10"
_ENABLED_MODULES = ("poll", "scheduling") _ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest) _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("max_participants_per_option", columns)
self.assertIn("response_comment", participant_columns) self.assertIn("response_comment", participant_columns)
self.assertIn("participation_gateway", 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(visibility, "aggregates_only")
self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None)) self.assertEqual(tuple(response_defaults), (1, 0, None, 1, 0, 0, 0, None))
self.assertEqual(set(counts.values()), {1}) self.assertEqual(set(counts.values()), {1})
+328
View File
@@ -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 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 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 apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
const modulePath = fileURLToPath(new URL("../src/module.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 widgetPath = fileURLToPath(new URL("../src/features/scheduling/SchedulingRequestsWidget.tsx", import.meta.url));
const page = readFileSync(pagePath, "utf8"); const page = readFileSync(pagePath, "utf8");
const publicPage = readFileSync(publicPagePath, "utf8"); const publicPage = readFileSync(publicPagePath, "utf8");
const enrollmentPage = readFileSync(enrollmentPagePath, "utf8");
const api = readFileSync(apiPath, "utf8"); const api = readFileSync(apiPath, "utf8");
const moduleSource = readFileSync(modulePath, "utf8"); const moduleSource = readFileSync(modulePath, "utf8");
const widget = readFileSync(widgetPath, "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\.find-and-decide-meeting-time"/);
assert.match(page, /topicId: "scheduling\.calendar-coordination"/); assert.match(page, /topicId: "scheduling\.calendar-coordination"/);
assert.match(page, /topicId: "scheduling\.participation-governance"/); 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, /public_participation_policy_enforcement_available === false[\s\S]*<ActionBlockerHint/);
assert.match(page, /<Card title=\{I18N\.generalSettings\}>/); assert.match(page, /<Card title=\{I18N\.generalSettings\}>/);
assert.match(page, /<Card title=\{I18N\.participantPrivacy\}>/); 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, /revokeSchedulingParticipantInvitation\([\s\S]*method: "DELETE"[\s\S]*participant_revision: participantRevision/);
assert.match(api, /participants\/\$\{encodeURIComponent\(participantId\)\}\/invitation/); 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\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
assert.match(api, /\/api\/v1\/scheduling\/public-enrollment\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
assert.match(page, /useSearchParams\(\)/); assert.match(page, /useSearchParams\(\)/);
assert.match(page, /Promise\.allSettled/); assert.match(page, /Promise\.allSettled/);
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/); assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
assert.match(moduleSource, /SchedulingPublicPage/); 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, /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.match(publicPage, /<PasswordField[\s\S]*autoComplete="current-password"/);
assert.doesNotMatch(publicPage, /<input[\s\S]{0,120}type="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, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/); 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, /DocumentationHelpLink/);
assert.match(widget, /to: `\/scheduling\?request_id=\$\{encodeURIComponent\(request\.id\)\}`/); assert.match(widget, /to: `\/scheduling\?request_id=\$\{encodeURIComponent\(request\.id\)\}`/);
assert.match(widget, /label=\{request\.status === "collecting" \? I18N\.open : I18N\.draft\}/); assert.match(widget, /label=\{request\.status === "collecting" \? I18N\.open : I18N\.draft\}/);
+149
View File
@@ -264,6 +264,82 @@ export type SchedulingPublicParticipationSubmitPayload = SchedulingPublicPartici
idempotency_key?: string; 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 = { export type SchedulingCalendarActionResponse = {
request: SchedulingRequest; request: SchedulingRequest;
created_event_ids: string[]; 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> { export function openSchedulingRequest(settings: ApiSettings, requestId: string): Promise<SchedulingStatusResponse> {
return apiFetch<SchedulingStatusResponse>(settings, `/api/v1/scheduling/requests/${requestId}/open`, json({})); 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, closeSchedulingRequest,
createSchedulingCalendarEvent, createSchedulingCalendarEvent,
createSchedulingHolds, createSchedulingHolds,
createSchedulingEnrollmentLink,
createSchedulingNotifications, createSchedulingNotifications,
createSchedulingRequest, createSchedulingRequest,
decideSchedulingRequest, decideSchedulingRequest,
@@ -65,9 +66,11 @@ import {
getSchedulingAvailabilityResponse, getSchedulingAvailabilityResponse,
issueSchedulingParticipantInvitation, issueSchedulingParticipantInvitation,
listSchedulingNotifications, listSchedulingNotifications,
listSchedulingEnrollmentLinks,
listSchedulingRequests, listSchedulingRequests,
openSchedulingRequest, openSchedulingRequest,
revokeSchedulingParticipantInvitation, revokeSchedulingParticipantInvitation,
revokeSchedulingEnrollmentLink,
searchSchedulingPeople, searchSchedulingPeople,
schedulingSummary, schedulingSummary,
submitSchedulingAvailability, submitSchedulingAvailability,
@@ -75,6 +78,7 @@ import {
type SchedulingCandidateSlot, type SchedulingCandidateSlot,
type SchedulingAvailabilityValue, type SchedulingAvailabilityValue,
type SchedulingInvitationActionResponse, type SchedulingInvitationActionResponse,
type SchedulingEnrollmentLink,
type SchedulingNotification, type SchedulingNotification,
type SchedulingParticipant, type SchedulingParticipant,
type SchedulingPollOptionResult, type SchedulingPollOptionResult,
@@ -251,6 +255,30 @@ const I18N = {
requiresEventWrite: "i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763", requiresEventWrite: "i18n:govoplan-scheduling.requires_calendar_event_write_access.887b0763",
responseRecorded: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d", responseRecorded: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
responseReplace: "i18n:govoplan-scheduling.the_response_replaces_your_previous_availability_choices.74c16d53", 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", 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", invitationHelp: "i18n:govoplan-scheduling.you_can_respond_here_or_use_the_invitation_link_you_rece.1a25fd53",
save: "i18n:govoplan-scheduling.save.efc007a3", 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 })} /> onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card> </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") ? ( {selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? (
<Card <Card
title={I18N.calendarCoordination} 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())}`; 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 { function addLocalMinutes(value: string, minutes: number): string {
if (!value) return ""; if (!value) return "";
const date = new Date(value); const date = new Date(value);
+72
View File
@@ -1,5 +1,41 @@
export const generatedTranslations = { export const generatedTranslations = {
en: { 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_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.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", "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." "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
}, },
de: { 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_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.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", "i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
+7 -1
View File
@@ -9,6 +9,7 @@ import "./styles/scheduling.css";
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage")); const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage")); const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const SchedulingEnrollmentPage = lazy(() => import("./features/scheduling/SchedulingEnrollmentPage"));
const scheduleRead = ["scheduling:schedule:read"]; const scheduleRead = ["scheduling:schedule:read"];
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = { const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
@@ -59,7 +60,7 @@ const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
export const schedulingModule: PlatformWebModule = { export const schedulingModule: PlatformWebModule = {
id: "scheduling", id: "scheduling",
label: "Scheduling", label: "Scheduling",
version: "0.1.11", version: "0.1.18",
dependencies: ["poll"], dependencies: ["poll"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"], optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations, translations: generatedTranslations,
@@ -81,6 +82,11 @@ export const schedulingModule: PlatformWebModule = {
path: "/scheduling/public/:requestId/:token", path: "/scheduling/public/:requestId/:token",
order: 10, order: 10,
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth }) render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
},
{
path: "/scheduling/enrol/:requestId/:token",
order: 11,
render: ({ settings, auth }) => createElement(SchedulingEnrollmentPage, { settings, auth })
} }
], ],
uiCapabilities: { uiCapabilities: {
+30
View File
@@ -403,6 +403,36 @@
max-width: 560px; 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) { @media (max-width: 900px) {
.scheduling-workspace-layout { .scheduling-workspace-layout {
grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); grid-template-columns: minmax(270px, 320px) minmax(0, 1fr);