feat(scheduling): add explicit invitation link lifecycle

This commit is contained in:
2026-07-22 03:08:56 +02:00
parent ed828685f6
commit 2cb86c90dc
6 changed files with 785 additions and 122 deletions

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
import dataclasses
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.db.session import get_session
@@ -18,6 +19,8 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationActionResponse,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
@@ -52,9 +55,11 @@ from govoplan_scheduling.backend.service import (
get_visible_scheduling_request,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
issue_scheduling_participant_invitation,
open_scheduling_request,
refresh_participant_response_state,
require_visible_scheduling_results,
revoke_scheduling_participant_invitation,
scheduling_notification_response,
scheduling_request_response,
scheduling_request_summary,
@@ -186,16 +191,41 @@ def _client_address(request: Request) -> str | None:
return request.client.host if request.client is not None else None
def _set_sensitive_response_headers(response: Response) -> None:
response.headers["Cache-Control"] = "no-store, private"
response.headers["Pragma"] = "no-cache"
response.headers["Referrer-Policy"] = "no-referrer"
def _audit_invitation_action(
session: Session,
*,
principal: ApiPrincipal,
request_id: str,
participant_id: str,
action: str,
details: dict[str, Any],
) -> None:
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=action,
object_type="scheduling_request",
object_id=request_id,
details={"participant_id": participant_id, **details},
)
def _request_response(
request,
*,
principal: ApiPrincipal,
invitation_tokens: dict[str, str] | None = None,
) -> SchedulingRequestResponse:
return SchedulingRequestResponse.model_validate(
scheduling_request_response(
request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal),
@@ -311,7 +341,7 @@ def api_create_scheduling_request(
) -> SchedulingRequestResponse:
_require_scheduling_writer(principal)
try:
request, invitation_tokens = create_scheduling_request(
request, _invitation_tokens = create_scheduling_request(
session,
tenant_id=principal.tenant_id,
user_id=principal.account_id,
@@ -319,7 +349,7 @@ def api_create_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal, invitation_tokens=invitation_tokens)
response = _request_response(request, principal=principal)
session.commit()
return response
@@ -411,7 +441,7 @@ def api_update_scheduling_request(
request_id=request_id,
)
try:
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
request, _invitation_tokens = update_scheduling_request_with_invitation_tokens(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
@@ -419,15 +449,135 @@ def api_update_scheduling_request(
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = _request_response(
request,
principal=principal,
invitation_tokens=invitation_tokens,
)
response = _request_response(request, principal=principal)
session.commit()
return response
@router.post(
"/requests/{request_id}/participants/{participant_id}/invitation",
response_model=SchedulingInvitationActionResponse,
)
def api_issue_scheduling_participant_invitation(
request_id: str,
participant_id: str,
payload: SchedulingInvitationActionRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingInvitationActionResponse:
_require_request_editor(
session,
principal=principal,
request_id=request_id,
)
try:
result = issue_scheduling_participant_invitation(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
participant_id=participant_id,
action=payload.action,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
audit_details = {
"request_status": result.request.status,
"delivery_action": payload.action,
"replaced_existing": result.replaced_existing,
}
_audit_invitation_action(
session,
principal=principal,
request_id=request_id,
participant_id=participant_id,
action="scheduling.invitation_issued",
details=audit_details,
)
_audit_invitation_action(
session,
principal=principal,
request_id=request_id,
participant_id=participant_id,
action=(
"scheduling.invitation_copied"
if payload.action == "copy"
else "scheduling.invitation_send_requested"
),
details={
**audit_details,
"notification_id": (
result.notification.id if result.notification is not None else None
),
"notification_status": result.status,
},
)
validated = SchedulingInvitationActionResponse(
participant_id=result.participant.id,
action=payload.action,
status=result.status,
action_url=result.action_url,
issued_at=result.participant.last_invited_at,
notification=(
SchedulingNotificationResponse.model_validate(
scheduling_notification_response(result.notification)
)
if result.notification is not None
else None
),
)
_set_sensitive_response_headers(response)
session.commit()
return validated
@router.delete(
"/requests/{request_id}/participants/{participant_id}/invitation",
response_model=SchedulingInvitationActionResponse,
)
def api_revoke_scheduling_participant_invitation(
request_id: str,
participant_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingInvitationActionResponse:
_require_request_editor(
session,
principal=principal,
request_id=request_id,
)
try:
result = revoke_scheduling_participant_invitation(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
participant_id=participant_id,
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
_audit_invitation_action(
session,
principal=principal,
request_id=request_id,
participant_id=participant_id,
action="scheduling.invitation_revoked",
details={
"request_status": result.request.status,
"replayed": result.replayed,
},
)
validated = SchedulingInvitationActionResponse(
participant_id=result.participant.id,
action="revoke",
status=result.status,
replayed=result.replayed,
)
_set_sensitive_response_headers(response)
session.commit()
return validated
@router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse)
def api_update_scheduling_candidate_slot(
request_id: str,

View File

@@ -158,7 +158,14 @@ class SchedulingRequestCreateRequest(BaseModel):
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list)
create_participant_invitations: bool = True
create_participant_invitations: bool = Field(
default=False,
deprecated=True,
description=(
"Compatibility field; participant links are issued only through "
"the explicit participant invitation action."
),
)
metadata: dict[str, Any] = Field(default_factory=dict)
_validate_timezone = field_validator("timezone")(_known_timezone)
@@ -197,7 +204,14 @@ class SchedulingRequestUpdateRequest(BaseModel):
min_length=1,
)
participants: list[SchedulingParticipantReconcileInput] | None = None
create_participant_invitations: bool = True
create_participant_invitations: bool = Field(
default=False,
deprecated=True,
description=(
"Compatibility field; participant links are issued only through "
"the explicit participant invitation action."
),
)
metadata: dict[str, Any] | None = None
@model_validator(mode="after")
@@ -483,6 +497,24 @@ class SchedulingNotificationCreateRequest(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingInvitationActionRequest(BaseModel):
"""Explicitly issue one fresh participant-specific participation link."""
model_config = ConfigDict(extra="forbid")
action: Literal["copy", "send"]
class SchedulingInvitationActionResponse(BaseModel):
participant_id: str
action: Literal["copy", "send", "revoke"]
status: str
action_url: str | None = None
issued_at: datetime | None = None
replayed: bool = False
notification: SchedulingNotificationResponse | None = None
class SchedulingAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str

View File

@@ -87,6 +87,18 @@ class SchedulingPublicParticipationError(SchedulingError):
self.retry_after_seconds = max(0, retry_after_seconds)
@dataclass(frozen=True, slots=True)
class SchedulingInvitationActionResult:
request: SchedulingRequest
participant: SchedulingParticipant
action: str
status: str
action_url: str | None = None
notification: SchedulingNotification | None = None
replaced_existing: bool = False
replayed: bool = False
WORKFLOW_DRAFT = "draft"
WORKFLOW_COLLECTING = "collecting_availability"
WORKFLOW_CLOSED = "availability_closed"
@@ -626,13 +638,30 @@ def create_scheduling_notification_jobs(
channel: str = "mail",
metadata: dict[str, Any] | None = None,
invitation_tokens: dict[str, str] | None = None,
participant_ids: frozenset[str] | None = None,
) -> list[SchedulingNotification]:
if event_kind not in NOTIFICATION_KINDS:
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
participants = [
participant
for participant in _active_participants(request)
if participant_ids is None or participant.id in participant_ids
]
if participant_ids is not None and {
participant.id for participant in participants
} != set(participant_ids):
raise SchedulingError("Scheduling participant not found")
if event_kind == "invitation" and (
not invitation_tokens
or any(participant.id not in invitation_tokens for participant in participants)
):
raise SchedulingError(
"Invitation notifications require a freshly issued participation link"
)
notifications: list[SchedulingNotification] = []
base_metadata = metadata or {}
for participant in _active_participants(request):
for participant in participants:
recipient = participant.email or participant.respondent_id
status = "pending" if recipient else "skipped"
notification = SchedulingNotification(
@@ -694,9 +723,12 @@ def _mirror_scheduling_notifications_to_center(
),
enqueue_delivery=True,
)
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
except Exception: # noqa: BLE001 - mirroring must not block scheduling workflow.
notification.status = "failed"
notification.error = f"Notification center enqueue failed: {exc}"
# A provider exception can include a representation of the dispatch
# request. Invitation requests contain a bearer link, so durable
# error text must never copy exception details.
notification.error = "Notification center enqueue failed"
continue
notification.metadata_ = {
**(notification.metadata_ or {}),
@@ -707,7 +739,7 @@ def _mirror_scheduling_notifications_to_center(
notification.status = str(response["status"])
elif response.get("status") == "skipped":
notification.status = "skipped"
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
notification.error = "Notification center skipped delivery"
session.flush()
@@ -1070,12 +1102,6 @@ def create_scheduling_request(
user_id: str | None,
payload: SchedulingRequestCreateRequest,
) -> tuple[SchedulingRequest, dict[str, str]]:
participation_provider = _poll_participation_provider()
issue_public_invitations = (
payload.create_participant_invitations and payload.status != "draft"
)
if issue_public_invitations and participation_provider is None:
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
if not payload.allow_external_participants:
for participant_input in payload.participants:
if participant_input.participant_type == "external":
@@ -1135,7 +1161,7 @@ def create_scheduling_request(
email=participant_input.email,
participant_type=participant_input.participant_type,
required=participant_input.required,
status="invited" if issue_public_invitations else "draft",
status="draft",
metadata_=participant_input.metadata,
)
session.add(participant)
@@ -1186,47 +1212,11 @@ def create_scheduling_request(
for slot in _active_slots(request):
slot.poll_option_id = options_by_position.get(slot.position)
invitation_tokens: dict[str, str] = {}
if issue_public_invitations:
if participation_provider is None: # Guarded above; narrows the type.
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
for participant in _active_participants(request):
try:
respondent_id = _stable_participant_respondent_id(participant)
invitation = participation_provider.create_governed_invitation(
session,
tenant_id=tenant_id,
poll_id=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=payload.deadline_at,
metadata={
"scheduling_request_id": request.id,
"scheduling_participant_id": participant.id,
},
),
)
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
participant.poll_invitation_id = invitation.id
participant.last_invited_at = _now()
participant.status = "invited"
invitation_tokens[participant.id] = invitation.token
create_scheduling_notification_jobs(
session,
tenant_id=tenant_id,
request_id=request.id,
event_kind="invitation",
metadata={"created_with_request": True},
invitation_tokens=invitation_tokens,
)
session.flush()
return request, invitation_tokens
# The deprecated create_participant_invitations field is deliberately a
# no-op. Bearer credentials are minted only by the explicit participant
# invitation action, where copy and delivery have distinct handling.
return request, {}
def list_scheduling_requests(session: Session, *, tenant_id: str, status: str | None = None) -> list[SchedulingRequest]:
@@ -2134,10 +2124,6 @@ def _update_scheduling_request_with_invitation_tokens(
session,
request=request,
supplied_participants=payload.participants,
create_invitations=(
payload.create_participant_invitations
and request.status != "draft"
),
)
if deadline_changed:
for participant in _active_participants(request):
@@ -2218,7 +2204,7 @@ def update_scheduling_request_with_invitation_tokens(
request_id: str,
payload: SchedulingRequestUpdateRequest,
) -> tuple[SchedulingRequest, dict[str, str]]:
"""Update a request and return any one-time tokens issued for new rows."""
"""Compatibility wrapper; explicit invitation actions own raw tokens."""
return _update_scheduling_request_with_invitation_tokens(
session,
@@ -2612,19 +2598,102 @@ def _validate_participant_reconciliation(
)
def _create_reconciled_participant_invitation(
def _participant_for_invitation_action(
request: SchedulingRequest,
*,
participant_id: str,
) -> SchedulingParticipant:
participant = next(
(
item
for item in _active_participants(request)
if item.id == participant_id
),
None,
)
if participant is None:
raise SchedulingError("Scheduling participant not found")
return participant
def _new_public_invitation_expiry(
request: SchedulingRequest,
) -> datetime | None:
"""Keep collecting links deadline-bound without minting expired tokens.
Read-only links can still be issued after a response deadline or in a later
lifecycle state; Scheduling independently rejects submissions whenever the
request is not collecting. Cancellation can impose its own bounded notice
expiry on top of this lifecycle helper.
"""
deadline = response_datetime(request.deadline_at)
return deadline if deadline is not None and deadline > _now() else None
def _participant_has_delivery_target(
participant: SchedulingParticipant,
) -> bool:
if participant.email:
return True
respondent_id = (participant.respondent_id or "").strip()
return bool(
respondent_id
and not respondent_id.startswith("scheduling-participant:")
)
def issue_scheduling_participant_invitation(
session: Session,
*,
request: SchedulingRequest,
participant: SchedulingParticipant,
) -> str:
tenant_id: str,
request_id: str,
participant_id: str,
action: str,
) -> SchedulingInvitationActionResult:
"""Rotate one bearer link for explicit copy or immediate delivery.
The returned result contains the bearer URL only for ``copy``. ``send``
passes it directly into the notification dispatch boundary and returns only
the durable job projection.
"""
if action not in {"copy", "send"}:
raise SchedulingError("Unsupported scheduling invitation action")
request = _lock_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_participants=True,
)
participant = _participant_for_invitation_action(
request,
participant_id=participant_id,
)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
raise SchedulingError("Scheduling invitation action is unavailable")
participation_provider = _poll_participation_provider()
if participation_provider is None:
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
if action == "send":
if not _participant_has_delivery_target(participant):
raise SchedulingError(
"Participant has no deliverable email address or account"
)
if notification_dispatch_provider(get_registry()) is None:
raise SchedulingError(
"Notification delivery is unavailable; copy the link instead"
)
previous_invitation_id = participant.poll_invitation_id
try:
respondent_id = _stable_participant_respondent_id(participant)
if previous_invitation_id is not None:
participation_provider.revoke_invitation(
session,
tenant_id=request.tenant_id,
poll_id=request.poll_id,
invitation_id=previous_invitation_id,
)
invitation = participation_provider.create_governed_invitation(
session,
tenant_id=request.tenant_id,
@@ -2632,23 +2701,110 @@ def _create_reconciled_participant_invitation(
command=PollGovernedInvitationCommand(
gateway=_public_participation_gateway(request.id),
policy=_public_participation_policy(request),
respondent_id=respondent_id,
respondent_id=_stable_participant_respondent_id(participant),
respondent_label=participant.display_name,
email=participant.email,
expires_at=request.deadline_at,
expires_at=_new_public_invitation_expiry(request),
metadata={
"scheduling_request_id": request.id,
"scheduling_participant_id": participant.id,
"public_link_issued": True,
"issue_action": action,
},
),
)
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
except PollCapabilityError as exc:
raise SchedulingError(str(exc)) from exc
# Poll deliberately keeps its external participation errors generic;
# retain that property at Scheduling's management boundary too.
raise SchedulingError(
"Scheduling invitation action could not be completed"
) from exc
participant.poll_invitation_id = invitation.id
participant.participation_gateway = SCHEDULING_PARTICIPATION_GATEWAY
participant.last_invited_at = _now()
participant.status = "invited"
return invitation.token
if participant.status in {"draft", "invited"}:
participant.status = "invited"
action_url = f"/scheduling/public/{request.id}/{invitation.token}"
notification: SchedulingNotification | None = None
result_status = "issued"
if action == "send":
notifications = create_scheduling_notification_jobs(
session,
tenant_id=request.tenant_id,
request_id=request.id,
event_kind="invitation",
metadata={"explicit_participant_delivery": True},
invitation_tokens={participant.id: invitation.token},
participant_ids=frozenset({participant.id}),
)
notification = notifications[0]
result_status = notification.status
action_url = None
session.flush()
return SchedulingInvitationActionResult(
request=request,
participant=participant,
action=action,
status=result_status,
action_url=action_url,
notification=notification,
replaced_existing=previous_invitation_id is not None,
)
def revoke_scheduling_participant_invitation(
session: Session,
*,
tenant_id: str,
request_id: str,
participant_id: str,
) -> SchedulingInvitationActionResult:
"""Immediately revoke the current link; exact repeats are safe no-ops."""
request = _lock_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_participants=True,
)
participant = _participant_for_invitation_action(
request,
participant_id=participant_id,
)
invitation_id = participant.poll_invitation_id
replayed = invitation_id is None
if invitation_id is not None:
if request.poll_id is None:
raise SchedulingError("Scheduling invitation action is unavailable")
participation_provider = _poll_participation_provider()
if participation_provider is None:
raise SchedulingError(PUBLIC_PARTICIPATION_POLICY_UNAVAILABLE_REASON)
try:
revocation = participation_provider.revoke_invitation(
session,
tenant_id=request.tenant_id,
poll_id=request.poll_id,
invitation_id=invitation_id,
)
except PollCapabilityError as exc:
raise SchedulingError(
"Scheduling invitation action could not be completed"
) from exc
replayed = revocation.replayed
participant.poll_invitation_id = None
participant.participation_gateway = None
if participant.status == "invited":
participant.status = "draft"
session.flush()
return SchedulingInvitationActionResult(
request=request,
participant=participant,
action="revoke",
status="revoked",
replayed=replayed,
)
def _update_participant_invitation_expiry(
@@ -2695,7 +2851,6 @@ def _reconcile_scheduling_participants(
*,
request: SchedulingRequest,
supplied_participants: list[SchedulingParticipantReconcileInput],
create_invitations: bool,
) -> dict[str, str]:
"""Apply a complete participant collection and revoke removed links."""
@@ -2753,12 +2908,6 @@ def _reconcile_scheduling_participants(
)
session.add(participant)
session.flush()
if create_invitations:
invitation_tokens[participant.id] = _create_reconciled_participant_invitation(
session,
request=request,
participant=participant,
)
participation_provider = _poll_participation_provider()
for participant in removed_participants: