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

@@ -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: