Compare commits

...

15 Commits

Author SHA1 Message Date
b328df67a3 feat(scheduling-webui): manage participant invitations 2026-07-22 04:25:54 +02:00
fc356e22c6 feat(scheduling-webui): model invitation actions 2026-07-22 04:24:01 +02:00
f512784dd3 fix(scheduling): reject stale invitation actions 2026-07-22 04:18:03 +02:00
ed39f83688 feat(scheduling): expose invitation delivery capability 2026-07-22 04:01:56 +02:00
95c9f654e1 chore(scheduling): document password flag false positive 2026-07-22 03:53:27 +02:00
55447bd05c chore(scheduling): bump version to 0.1.11 2026-07-22 03:42:46 +02:00
27fa24cf4d fix(scheduling-webui): submit participant revisions 2026-07-22 03:40:10 +02:00
448546e487 feat(scheduling): retire replaced participant responses 2026-07-22 03:40:04 +02:00
835ad82916 feat(scheduling): persist candidate slot ordering 2026-07-22 03:26:21 +02:00
a316341226 refactor(scheduling): consume core participation contract 2026-07-22 03:21:10 +02:00
58619484b6 feat(scheduling): bound public cancellation notices 2026-07-22 03:17:51 +02:00
ea2f721377 feat(scheduling): use central people picker 2026-07-22 03:17:13 +02:00
2cb86c90dc feat(scheduling): add explicit invitation link lifecycle 2026-07-22 03:08:56 +02:00
ed828685f6 feat(scheduling): render signed public response links 2026-07-22 03:02:54 +02:00
4279ea2827 feat(scheduling): persist bounded cancellation notices 2026-07-22 03:01:26 +02:00
26 changed files with 3876 additions and 495 deletions

132
README.md
View File

@@ -82,10 +82,12 @@ uses Poll context fields to point back to its request or proposal resource.
Typical workflow steps are collect availability, rank candidates, decide, notify
participants, and hand off to Calendar or Appointments.
The manifest declares `access` and `evaluation` as optional dependencies.
Scheduling may use Access for identity, groups, and permissions, and may trigger
post-event or post-appointment feedback through Evaluation. It must not require
either module just to find a meeting time.
The manifest declares `access`, `addresses`, and `evaluation` as optional
dependencies. Scheduling uses Core's principal-aware people-search boundary to
combine only the account and contact records visible to the current organizer;
it never calls the instance-wide Identity search. It may trigger post-event or
post-appointment feedback through Evaluation, and must not require any of these
optional modules just to find a meeting time.
## Expected Integrations
@@ -98,6 +100,22 @@ either module just to find a meeting time.
- `govoplan-portal`: external participant scheduling flows
- `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected
## Participant selection boundary
The editor uses Core's shared `PeoplePicker`. Its server-side search aggregates
the optional `access.people_search` and `addresses.people_search` capabilities,
and each provider applies the active principal and tenant visibility rules
before returning a candidate. Scheduling exposes only the fields needed to
select a person; provider provenance, address-book topology, group membership,
and other internals are not returned by its picker endpoint.
An account selection becomes an internal participant bound to that account.
A visible address-book contact becomes an external participant with a bounded
directory-selection reference and revision for later organizer editing. Manual
name-and-email entry is available only while the request allows external
participants. The picker stores neither provider-internal provenance nor a
global Identity record reference in participant metadata.
## First Package Scaffold Decision
The first backend implementation slice adds runtime APIs and storage around
@@ -109,13 +127,14 @@ poll-backed scheduling requests:
- request lifecycle APIs: draft, collecting, closed, decided, handed off, cancelled
- result summaries sourced from Poll response aggregation
- optional Calendar free/busy checks, tentative holds, and final event creation
- notification outbox jobs for invitations, reminders, decisions, and cancellations
- notification outbox jobs for invitations, reminders, decisions,
cancellations, and participant access changes
- a first Scheduling WebUI package with request creation, slot matrix, Calendar
actions, decisions, and notification-job creation
The next slices should add real notification delivery workers, richer public
participant pages, Calendar hold cleanup after decision, and advanced scoring
constraints such as required participants and quorum rules.
The next slices should add generic self-enrolment links after their abuse and
identity policy is agreed, Calendar hold cleanup after decision, and advanced
scoring constraints such as required participants and quorum rules.
The active backlog lives in Gitea issues.
@@ -150,11 +169,14 @@ same link usable again, and clearing the deadline removes its expiry. No raw
replacement token crosses the PATCH response, and existing responses plus
participant status remain attached to the same durable respondent identity.
Open lifecycle decision: cancellation closes the backing Poll, so submission
fails, but an otherwise valid invitation can still resolve the reduced public
view and show that the request was cancelled. Decide whether cancellation
should revoke links immediately or retain that acknowledgement view for a
bounded period; links without a deadline would otherwise remain readable.
Cancellation closes the backing Poll, so submission fails, while active links
remain usable as a reduced cancellation notice for a bounded period. The
deployment setting `SCHEDULING_CANCELLATION_NOTICE_DAYS` defaults to 30 and is
bounded to 190 days. Cancellation transactionally aligns governed invitation
expiry with that timestamp. The public projection contains only the request
title and cancellation timestamps; descriptions, locations, candidate slots,
comments, and previous answers are omitted. After the bound, access fails with
the same generic response as an invalid or expired link.
If the governed capability is absent, API responses advertise that policy
enforcement is unavailable and restricted links fail closed. Plaintext
@@ -169,26 +191,72 @@ projection; participants retain candidate-slot revisions, their own marker and
email, response settings, aggregates, and any roster names/statuses permitted
by the configured privacy policy.
The WebUI package exposes typed clients for the public access and submission
endpoints. A signed-out browser page cannot yet be registered by a module:
Core's `App` renders `PublicLandingPage` directly whenever `auth` is absent and
only mounts module route contributions inside the authenticated branch. Until
Core gains an explicit, allowlisted `publicRoutes` contract, notification action
URLs under `/scheduling/public/{request}/{token}` must be treated as a blocked
frontend handoff rather than a working guest page. The token is never moved into
query parameters, browser storage, or an authenticated API contract while that
shell boundary is unresolved.
The WebUI package registers `/scheduling/public/{request}/{token}` through
Core's explicit, backend-allowlisted public-route contract. The guest page uses
the shared UI components, prompts for email/password only when needed, prefills
an existing response, and enforces the snapshotted response rules. The token
stays in the path and is never copied into query parameters or browser storage.
Signed-in users also receive an in-app deep link without weakening the signed
guest-link boundary.
Draft saves never issue public tokens or enqueue invitation delivery, even when
`create_participant_invitations` is left at its compatibility default. A
collecting request may explicitly issue invitations; authenticated in-module
responses lazily create a gateway-bound invitation and discard its token. The
draft-to-open transition therefore supports authenticated lazy responses, but
does not make a guest link available from the current UI. The product decision
still open is the explicit organizer workflow for issuing or reissuing public
links after a draft is opened and, when Mail is installed, whether that action
should also enqueue delivery or return links for separate distribution. Until
that workflow is agreed, opening a draft does not silently send anything.
Creating, editing, and opening a request never issue public tokens or enqueue
invitation delivery. The deprecated `create_participant_invitations` request
field remains accepted for compatibility, defaults to `false`, and has no side
effect. Authenticated in-module responses can still lazily create a
gateway-bound invitation whose token is discarded.
Organizers and Scheduling administrators use the participant-specific
invitation action instead:
- `POST /scheduling/requests/{request_id}/participants/{participant_id}/invitation`
with `{"action":"copy","participant_revision":"..."}` rotates the previous
invitation and returns the new relative action URL once. The response is
marked `no-store`.
- The same endpoint with `{"action":"send"}` rotates the invitation and passes
its URL directly to the notification dispatch job; it also requires the
current `participant_revision`. Neither the API response nor Scheduling's
durable notification projection contains the token.
- `DELETE` on the same resource uses a JSON body containing the current
`participant_revision` and revokes the active link immediately. A revoke
against the refreshed no-link projection is an idempotent replay.
The semantic participant revision includes the current invitation identity.
It is checked after the participant row is locked, so stale copy, send, and
revoke commands return `409` before rotating a newer link or delivering to a
changed recipient.
The participant DataGrid presents copy, send, and revoke as a fixed icon-only
action group. Authorized but unavailable actions remain visible and explain
why they are disabled: for example, delivery is disabled without a dispatch
provider or recipient target, and revoke is disabled when no active link
exists. The delivery capability is exposed only in management projections.
Copy accepts only the same-origin Scheduling public path and does not persist
the bearer URL in component state, logs, or browser storage.
Links can be issued in any request state. Collection state and deadline checks
remain independent submission requirements, so a link to a draft, closed, or
decided request is read-only. Issue, copy, send-request, and revoke actions are
audited with request and participant identifiers but never a bearer token.
Only an organizer (under the ordinary Scheduling write policy) or a tenant-wide
Scheduling administrator can use these actions. If notification delivery is
not installed, `send` fails before rotating the current link and the organizer
can use `copy` for separate distribution.
Participant edits carry a semantic revision so a stale organizer form cannot
overwrite an invitation or response change. Corrections that retain a stable
account or directory identity update the existing participant. A display-name
correction keeps its invitation; changing a delivery email revokes the stale
link but keeps a response tied to the unchanged account identity.
Changing the canonical identity creates a new participant instead of assigning
the former participant's response to another person. In the same transaction,
Scheduling revokes the old invitation and Poll soft-deletes every matching
live response. Poll retains the answers and a bounded retirement record for
audit, while result summaries and capacity checks immediately exclude them.
Removing an invited or responding participant follows the same retirement
path. Scheduling records privacy-safe audit facts and queues a removal or
replacement notice to the former recipient without placing email addresses or
response contents in the audit event.
## FieldLabel omission register

View File

@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-scheduling"
version = "0.1.10"
version = "0.1.11"
description = "GovOPlaN meeting scheduling and Terminfindung module seed."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.10",
"govoplan-poll>=0.1.10",
"govoplan-core>=0.1.11",
"govoplan-poll>=0.1.11",
]
[tool.setuptools.packages.find]

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"]
__version__ = "0.1.10"
__version__ = "0.1.11"

View File

@@ -53,6 +53,7 @@ class SchedulingRequest(Base, TimestampMixin):
calendar_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
handed_off_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancelled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
cancellation_notice_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_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)

View File

@@ -15,17 +15,22 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
PublicFrontendRoute,
RoleTemplate,
)
from govoplan_core.db.base import Base
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.poll_participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
from govoplan_core.db.base import Base
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling"
MODULE_NAME = "Scheduling"
MODULE_VERSION = "0.1.10"
MODULE_VERSION = "0.1.11"
READ_SCOPE = "scheduling:schedule:read"
WRITE_SCOPE = "scheduling:schedule:write"
ADMIN_SCOPE = "scheduling:schedule:admin"
@@ -137,6 +142,8 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
),
required_capabilities=(
CAPABILITY_POLL_SCHEDULING,
@@ -147,13 +154,15 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
),
requires_interfaces=(
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.option_ordering", version_min="0.1.11", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.availability_matrix", version_min="0.1.11", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.response_collection", version_min="0.1.11", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.11", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.11", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
),
permissions=PERMISSIONS,
@@ -162,6 +171,13 @@ manifest = ModuleManifest(
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui",
public_routes=(
PublicFrontendRoute(
path="/scheduling/public/:requestId/:token",
component="SchedulingPublicPage",
order=10,
),
),
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
),
route_factory=_scheduling_router,

View File

@@ -0,0 +1,43 @@
"""v0.1.11 bounded scheduling cancellation notice
Revision ID: c9d4e7f1a2b3
Revises: be8f4d2c1a70
Create Date: 2026-07-22 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c9d4e7f1a2b3"
down_revision = "be8f4d2c1a70"
branch_labels = None
depends_on = None
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
columns = {
item["name"]: item
for item in inspector.get_columns("scheduling_requests")
}
existing = columns.get("cancellation_notice_until")
if existing is not None:
if (
not existing.get("nullable")
or not isinstance(existing["type"], sa.DateTime)
):
raise RuntimeError(
"Cannot adopt scheduling_requests.cancellation_notice_until "
"because its schema is unexpected"
)
return
op.add_column(
"scheduling_requests",
sa.Column("cancellation_notice_until", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("scheduling_requests", "cancellation_notice_until")

View File

@@ -1,26 +1,31 @@
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.core.people import search_visible_people
from govoplan_core.db.session import get_session
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import (
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingAvailabilityResponse,
SchedulingAvailabilityResponseRequest,
SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationActionResponse,
SchedulingInvitationRevokeRequest,
SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse,
SchedulingNotificationResponse,
SchedulingPeopleSearchCandidate,
SchedulingPeopleSearchGroup,
SchedulingPeopleSearchResponse,
SchedulingRequestCreateRequest,
SchedulingRequestListResponse,
SchedulingRequestResponse,
@@ -52,53 +57,22 @@ 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,
submit_scheduling_availability,
submit_public_scheduling_participation,
update_scheduling_candidate_slot,
update_scheduling_request_with_invitation_tokens,
update_scheduling_request_with_change_log,
)
router = APIRouter(prefix="/scheduling", tags=["scheduling"])
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
def _capability_payload(value: object) -> dict[str, Any]:
if dataclasses.is_dataclass(value):
return dataclasses.asdict(value)
if isinstance(value, dict):
return dict(value)
payload: dict[str, Any] = {}
for key in (
"contact_id",
"address_book_id",
"display_name",
"email",
"email_label",
"organization",
"role_title",
"tags",
"source_kind",
"source_ref",
"source_revision",
"provenance",
):
if hasattr(value, key):
payload[key] = getattr(value, key)
return payload
def _registry_capability(name: str) -> object | None:
registry = get_registry()
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
return None
return registry.capability(name)
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
@@ -186,16 +160,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),
@@ -255,21 +254,43 @@ def api_submit_public_scheduling_participation(
return validated
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
def api_lookup_scheduling_addresses(
@router.get("/people", response_model=SchedulingPeopleSearchResponse)
def api_search_scheduling_people(
query: str = Query(min_length=1),
limit: int = Query(default=25, ge=1, le=100),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingAddressLookupResponse:
) -> SchedulingPeopleSearchResponse:
_require_scheduling_writer(principal)
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
if capability is None or not hasattr(capability, "lookup"):
return SchedulingAddressLookupResponse(available=False, candidates=[])
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
return SchedulingAddressLookupResponse(
available=True,
candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
groups = search_visible_people(
get_registry(),
session,
principal,
query=query,
limit=limit,
)
return SchedulingPeopleSearchResponse(
groups=[
SchedulingPeopleSearchGroup(
key=group.key,
label=group.label,
candidates=[
SchedulingPeopleSearchCandidate(
selection_key=candidate.selection_key,
kind=candidate.kind,
reference_id=candidate.reference_id,
display_name=candidate.display_name,
email=candidate.email,
source_module=candidate.source_module,
source_label=candidate.source_label,
source_revision=candidate.source_revision,
description=candidate.description,
)
for candidate in group.candidates
],
)
for group in groups
]
)
@@ -311,7 +332,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 +340,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 +432,7 @@ def api_update_scheduling_request(
request_id=request_id,
)
try:
request, invitation_tokens = update_scheduling_request_with_invitation_tokens(
request, participant_mutations = update_scheduling_request_with_change_log(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
@@ -419,15 +440,153 @@ 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,
)
for mutation in participant_mutations:
_audit_invitation_action(
session,
principal=principal,
request_id=request_id,
participant_id=mutation.participant_id,
action=mutation.action,
details={
"replacement_participant_id": mutation.replacement_participant_id,
"changed_fields": list(mutation.changed_fields),
"invitation_revoked": mutation.invitation_revoked,
"retired_response_count": mutation.retired_response_count,
"notification_id": mutation.notification_id,
},
)
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,
participant_revision=payload.participant_revision,
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,
payload: SchedulingInvitationRevokeRequest,
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,
participant_revision=payload.participant_revision,
)
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

@@ -132,6 +132,20 @@ class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
id: str | None = Field(default=None, max_length=36)
revision: str | None = Field(
default=None,
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
)
@model_validator(mode="after")
def validate_existing_revision(self) -> "SchedulingParticipantReconcileInput":
if self.id is not None and self.revision is None:
raise ValueError("revision is required for an existing scheduling participant")
if self.id is None and self.revision is not None:
raise ValueError("revision can only be supplied for an existing scheduling participant")
return self
class SchedulingRequestCreateRequest(BaseModel):
@@ -158,7 +172,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 +218,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")
@@ -238,6 +266,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
class SchedulingParticipantResponse(BaseModel):
id: str
revision: str | None = None
is_current_participant: bool = False
respondent_id: str | None = None
display_name: str | None = None
@@ -291,6 +320,7 @@ class SchedulingRequestResponse(BaseModel):
anonymous_password_protection_enabled: bool
public_participation_policy_enforcement_available: bool | None = None
public_participation_policy_enforcement_reason: str | None = None
participant_invitation_delivery_available: bool | None = None
effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
@@ -302,6 +332,7 @@ class SchedulingRequestResponse(BaseModel):
calendar_event_id: str | None = None
handed_off_at: datetime | None = None
cancelled_at: datetime | None = None
cancellation_notice_until: datetime | None = None
created_at: datetime
updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict)
@@ -409,6 +440,9 @@ class SchedulingPublicParticipationResponse(BaseModel):
timezone: str
status: str
deadline_at: datetime | None = None
cancelled_at: datetime | None = None
cancellation_notice_until: datetime | None = None
cancellation_notice_only: bool = False
participant_email_required: bool
anonymous_password_required: bool
single_choice: bool
@@ -483,21 +517,68 @@ class SchedulingNotificationCreateRequest(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingAddressLookupCandidate(BaseModel):
contact_id: str
address_book_id: str
class SchedulingInvitationActionRequest(BaseModel):
"""Explicitly issue one fresh participant-specific participation link."""
model_config = ConfigDict(extra="forbid")
action: Literal["copy", "send"]
participant_revision: str = Field(
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
description=(
"Semantic revision from the participant management projection; "
"stale actions are rejected before rotating or delivering a link."
),
)
class SchedulingInvitationRevokeRequest(BaseModel):
"""Revoke the link represented by one current participant projection."""
model_config = ConfigDict(extra="forbid")
participant_revision: str = Field(
min_length=64,
max_length=64,
pattern=r"^[0-9a-f]{64}$",
description=(
"Semantic revision from the participant management projection; "
"stale revocations are rejected before changing access."
),
)
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 SchedulingPeopleSearchCandidate(BaseModel):
"""Opaque, task-safe projection of a visible directory candidate."""
selection_key: str
kind: str
reference_id: str
display_name: str
email: str | None = None
email_label: str | None = None
organization: str | None = None
role_title: str | None = None
tags: list[str] = Field(default_factory=list)
source_kind: str = "local"
source_ref: str | None = None
source_module: str | None = None
source_label: str | None = None
source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
description: str | None = None
class SchedulingAddressLookupResponse(BaseModel):
available: bool = False
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list)
class SchedulingPeopleSearchGroup(BaseModel):
key: str
label: str
candidates: list[SchedulingPeopleSearchCandidate] = Field(default_factory=list)
class SchedulingPeopleSearchResponse(BaseModel):
groups: list[SchedulingPeopleSearchGroup] = Field(default_factory=list)

File diff suppressed because it is too large Load Diff

View File

@@ -25,16 +25,23 @@ class SchedulingManifestTests(unittest.TestCase):
self.assertIn("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities)
self.assertIn("access.people_search", manifest.optional_capabilities)
self.assertIn("addresses.people_search", manifest.optional_capabilities)
self.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend)
self.assertEqual(
["/scheduling/public/:requestId/:token"],
[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.response_collection", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("access.people_search", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("addresses.people_search", {interface.name for interface in manifest.requires_interfaces})
self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
for interface_name in (
@@ -43,7 +50,7 @@ class SchedulingManifestTests(unittest.TestCase):
"poll.workflow_context",
"poll.governed_participation",
):
self.assertEqual("0.1.10", required_interfaces[interface_name].version_min)
self.assertEqual("0.1.11", required_interfaces[interface_name].version_min)
if __name__ == "__main__":

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
_SCHEDULING_HEAD = "be8f4d2c1a70"
_SCHEDULING_HEAD = "c9d4e7f1a2b3"
_SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10"
_ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
@@ -146,6 +146,7 @@ class SchedulingMigrationTests(unittest.TestCase):
self.assertIn(_SCHEDULING_HEAD, heads)
self.assertIn("participant_visibility", columns)
self.assertIn("cancellation_notice_until", columns)
self.assertIn("max_participants_per_option", columns)
self.assertIn("response_comment", participant_columns)
self.assertIn("participation_gateway", participant_columns)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
import ast
import pathlib
import unittest
ROOT = pathlib.Path(__file__).resolve().parents[1]
class SchedulingModuleBoundaryTests(unittest.TestCase):
def test_runtime_source_does_not_import_poll_implementation_internals(self) -> None:
offenders: list[str] = []
source_root = ROOT / "src" / "govoplan_scheduling"
for path in source_root.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
imported_modules = [
node.module
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom) and node.module is not None
]
imported_modules.extend(
alias.name
for node in ast.walk(tree)
if isinstance(node, ast.Import)
for alias in node.names
)
if any(module.startswith("govoplan_poll") for module in imported_modules):
offenders.append(str(path.relative_to(ROOT)))
self.assertEqual([], offenders)
if __name__ == "__main__":
unittest.main()

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import unittest
from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -184,7 +185,11 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None:
request = self._request()
payload = self._participant_projection(request)
with patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=object(),
):
payload = self._participant_projection(request)
response = SchedulingRequestResponse.model_validate(payload)
self.assertEqual(request.participant_visibility, "aggregates_only")
@@ -212,6 +217,7 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id)
self.assertIsNone(response.public_participation_policy_enforcement_available)
self.assertIsNone(response.participant_invitation_delivery_available)
self.assertEqual(response.metadata, {})
slot = response.slots[0]
self.assertIsNone(slot.poll_option_id)
@@ -287,6 +293,22 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"connector-uid-internal",
)
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-internal")
self.assertFalse(response.participant_invitation_delivery_available)
with patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=object(),
):
delivery_enabled = SchedulingRequestResponse.model_validate(
scheduling_request_response(
request,
actor_ids=("manager-1",),
actor_user_id="manager-1",
can_manage=True,
)
)
self.assertTrue(delivery_enabled.participant_invitation_delivery_available)
def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
restricting_policy = _PrivacyPolicy("aggregates_only")

138
tests/test_people_search.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from types import SimpleNamespace
import unittest
from fastapi import HTTPException
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
PeopleSearchGroup,
PersonSearchCandidate,
)
from govoplan_scheduling.backend.manifest import WRITE_SCOPE
from govoplan_scheduling.backend.router import api_search_scheduling_people
from govoplan_scheduling.backend.runtime import configure_runtime
class _Registry:
def __init__(self, capabilities: dict[str, object] | None = None) -> None:
self.capabilities = capabilities or {}
def has_capability(self, name: str) -> bool:
return name in self.capabilities
def capability(self, name: str) -> object:
return self.capabilities[name]
class _PeopleProvider:
def __init__(self) -> None:
self.calls: list[tuple[object, object, str, int]] = []
def search_people(
self,
session: object,
principal: object,
*,
query: str,
limit: int = 25,
) -> tuple[PeopleSearchGroup, ...]:
self.calls.append((session, principal, query, limit))
return (
PeopleSearchGroup(
key="accounts",
label="Accounts",
candidates=(
PersonSearchCandidate(
selection_key="account:account-2",
kind="account",
reference_id="account-2",
display_name="Ada Lovelace",
email="ada@example.test",
source_module="access",
source_label="Accounts",
source_ref="access:account:account-2",
source_revision="revision-1",
description="Research",
provenance={"tenant_id": "tenant-1", "internal": "secret"},
metadata={"internal_group_ids": ["group-1"]},
),
),
),
)
def _principal(*scopes: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
email="organizer@example.test",
display_name="Organizer",
scopes=frozenset(scopes),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
class SchedulingPeopleSearchTests(unittest.TestCase):
def tearDown(self) -> None:
configure_runtime(registry=_Registry())
def test_search_uses_principal_aware_core_aggregator_and_redacts_provider_internals(self) -> None:
provider = _PeopleProvider()
registry = _Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider})
configure_runtime(registry=registry)
session = object()
principal = _principal(WRITE_SCOPE)
response = api_search_scheduling_people(
query="ada",
limit=12,
session=session, # type: ignore[arg-type] - provider contract is intentionally generic
principal=principal,
)
self.assertEqual([(session, principal, "ada", 12)], provider.calls)
payload = response.model_dump()
self.assertEqual("account:account-2", payload["groups"][0]["candidates"][0]["selection_key"])
self.assertEqual("revision-1", payload["groups"][0]["candidates"][0]["source_revision"])
self.assertNotIn("source_ref", payload["groups"][0]["candidates"][0])
self.assertNotIn("provenance", payload["groups"][0]["candidates"][0])
self.assertNotIn("metadata", payload["groups"][0]["candidates"][0])
def test_search_is_empty_when_no_optional_directory_provider_is_installed(self) -> None:
configure_runtime(registry=_Registry())
response = api_search_scheduling_people(
query="ada",
limit=25,
session=object(), # type: ignore[arg-type]
principal=_principal(WRITE_SCOPE),
)
self.assertEqual([], response.groups)
def test_search_requires_scheduling_write_or_admin_access(self) -> None:
provider = _PeopleProvider()
configure_runtime(registry=_Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider}))
with self.assertRaises(HTTPException) as raised:
api_search_scheduling_people(
query="ada",
limit=25,
session=object(), # type: ignore[arg-type]
principal=_principal("scheduling:schedule:read"),
)
self.assertEqual(403, raised.exception.status_code)
self.assertEqual([], provider.calls)
if __name__ == "__main__":
unittest.main()

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.poll import PollCapabilityError
from govoplan_core.core.registry import PlatformRegistry
@@ -19,6 +20,7 @@ from govoplan_core.db.base import Base
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
PollLifecycleTransition,
PollOption,
PollParticipationSubmission,
PollResponse,
@@ -60,7 +62,9 @@ from govoplan_scheduling.backend.service import (
cancel_scheduling_request,
create_scheduling_request,
get_public_scheduling_participation,
issue_scheduling_participant_invitation,
scheduling_request_summary,
scheduling_participant_revision,
scheduling_slot_revision,
submit_scheduling_availability,
submit_public_scheduling_participation,
@@ -83,6 +87,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
PollResponse.__table__,
PollInvitation.__table__,
PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
ChangeSequenceEntry.__table__,
SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__,
@@ -103,6 +109,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
SchedulingRequest.__table__,
PollParticipationSubmission.__table__,
PollInvitation.__table__,
PollLifecycleTransition.__table__,
ChangeSequenceEntry.__table__,
PollResponse.__table__,
PollOption.__table__,
Poll.__table__,
@@ -149,8 +157,9 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants: list[SchedulingParticipantInput] | None = None,
**settings,
) -> tuple[SchedulingRequest, dict[str, str]]:
issue_links = bool(settings.pop("create_participant_invitations", True))
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
return create_scheduling_request(
request, automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="organizer-1",
@@ -184,6 +193,32 @@ class SchedulingResponseEditingTests(unittest.TestCase):
**settings,
),
)
self.assertEqual(automatic_tokens, {})
tokens = (
{
participant.id: self._issue_copy(request, participant)
for participant in request.participants
}
if issue_links
else {}
)
return request, tokens
def _issue_copy(
self,
request: SchedulingRequest,
participant: SchedulingParticipant,
) -> str:
result = issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="copy",
)
self.assertIsNotNone(result.action_url)
return str(result.action_url).rsplit("/", 1)[-1]
def _answer(
self,
@@ -475,7 +510,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
],
),
)
token = tokens[public_request.participants[0].id]
self.assertEqual(tokens, {})
token = self._issue_copy(public_request, public_request.participants[0])
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == public_request.participants[0].poll_invitation_id
).one()
@@ -568,9 +604,21 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants=[SchedulingParticipantInput(display_name="Guest")],
),
)
token = tokens[request.participants[0].id]
self.assertEqual(tokens, {})
token = self._issue_copy(request, request.participants[0])
wrong = SchedulingPublicParticipationAccessRequest(password="wrong password")
for _attempt in range(20):
with self.assertRaises(SchedulingPublicParticipationError) as missing:
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.20",
)
self.assertEqual(missing.exception.retry_after_seconds, 0)
for attempt in range(10):
with self.assertRaises(SchedulingPublicParticipationError) as raised:
get_public_scheduling_participation(
@@ -623,7 +671,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
),
)
participant = request.participants[0]
token = tokens[participant.id]
self.assertEqual(tokens, {})
token = self._issue_copy(request, participant)
answer = SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id,
value="available",
@@ -1010,6 +1059,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants=[
SchedulingParticipantReconcileInput(
id=alice.id,
revision=scheduling_participant_revision(alice),
respondent_id=alice.respondent_id,
display_name=alice.display_name,
email=alice.email,
@@ -1036,8 +1086,16 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(removed_slot.deleted_at)
bob = next(item for item in response.participants if item.display_name == "Bob")
self.assertEqual(bob.email, "bob@example.test")
self.assertIsNotNone(bob.poll_invitation_id)
self.assertIsNotNone(bob.invitation_token)
self.assertIsNone(bob.poll_invitation_id)
self.assertIsNone(bob.invitation_token)
bob_model = next(
item
for item in request.participants
if item.deleted_at is None and item.display_name == "Bob"
)
self._issue_copy(request, bob_model)
bob_invitation_id = bob_model.poll_invitation_id
self.assertIsNotNone(bob_invitation_id)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
@@ -1073,6 +1131,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants=[
SchedulingParticipantReconcileInput(
id=alice.id,
revision=scheduling_participant_revision(alice),
respondent_id=alice.respondent_id,
display_name=alice.display_name,
email=alice.email,
@@ -1091,7 +1150,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
),
)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == bob.poll_invitation_id
PollInvitation.id == bob_invitation_id
).one()
self.assertIsNotNone(invitation.revoked_at)
@@ -1128,6 +1187,334 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertEqual(raised.exception.status_code, 409)
def test_full_edit_reorders_slots_and_poll_options_without_losing_answers(self) -> None:
request = self._request(create_participant_invitations=False)
self._submit_both(request)
first_slot, second_slot = request.slots
first_slot.tentative_hold_event_id = "calendar-hold-1"
self.session.flush()
def reconcile_input(
slot: SchedulingCandidateSlot,
) -> SchedulingCandidateSlotReconcileInput:
return SchedulingCandidateSlotReconcileInput(
id=slot.id,
revision=scheduling_slot_revision(slot),
label=slot.label,
description=slot.description,
start_at=slot.start_at.replace(tzinfo=timezone.utc),
end_at=slot.end_at.replace(tzinfo=timezone.utc),
timezone=slot.timezone,
location=slot.location,
metadata=slot.metadata_ or {},
)
response = api_update_scheduling_request(
request.id,
SchedulingRequestUpdateRequest(
slots=[reconcile_input(second_slot), reconcile_input(first_slot)]
),
session=self.session,
principal=self._principal(
"organizer-1",
email=None,
scopes={WRITE_SCOPE},
),
)
self.assertEqual(
[(slot.id, slot.position) for slot in response.slots],
[(second_slot.id, 0), (first_slot.id, 1)],
)
poll_options = (
self.session.query(PollOption)
.filter(
PollOption.poll_id == request.poll_id,
PollOption.deleted_at.is_(None),
)
.order_by(PollOption.position.asc())
.all()
)
self.assertEqual(
[option.id for option in poll_options],
[second_slot.poll_option_id, first_slot.poll_option_id],
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
),
)
self.assertEqual(
{answer.slot_id: answer.value for answer in current.answers},
{first_slot.id: "available", second_slot.id: "maybe"},
)
self.assertEqual(first_slot.tentative_hold_event_id, "calendar-hold-1")
replayed = api_update_scheduling_request(
request.id,
SchedulingRequestUpdateRequest(
slots=[reconcile_input(second_slot), reconcile_input(first_slot)]
),
session=self.session,
principal=self._principal(
"organizer-1",
email=None,
scopes={WRITE_SCOPE},
),
)
self.assertEqual(
[(slot.id, slot.position) for slot in replayed.slots],
[(second_slot.id, 0), (first_slot.id, 1)],
)
current = api_get_my_scheduling_availability(
request.id,
session=self.session,
principal=self._principal(
"alice-account",
email="alice@example.test",
scopes={RESPOND_SCOPE},
),
)
self.assertEqual(
{answer.slot_id: answer.value for answer in current.answers},
{first_slot.id: "available", second_slot.id: "maybe"},
)
def test_identity_replacement_revokes_access_retires_response_and_notifies(self) -> None:
request = self._request()
self._submit_both(request)
original = request.participants[0]
invitation_id = original.poll_invitation_id
self.assertIsNotNone(invitation_id)
active_response = (
self.session.query(PollResponse)
.filter(
PollResponse.poll_id == request.poll_id,
PollResponse.deleted_at.is_(None),
)
.one()
)
original_answers = [dict(answer) for answer in active_response.answers]
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
updated = api_update_scheduling_request(
request.id,
SchedulingRequestUpdateRequest(
participants=[
SchedulingParticipantReconcileInput(
id=original.id,
revision=scheduling_participant_revision(original),
respondent_id=original.respondent_id,
display_name="Alice Replacement",
email="replacement@example.test",
participant_type=original.participant_type,
required=original.required,
metadata=original.metadata_ or {},
)
]
),
session=self.session,
principal=self._principal(
"organizer-1",
email=None,
scopes={WRITE_SCOPE},
),
)
self.assertEqual(len(updated.participants), 1)
replacement = updated.participants[0]
self.assertNotEqual(replacement.id, original.id)
self.assertEqual(replacement.email, "replacement@example.test")
self.assertIsNone(replacement.poll_invitation_id)
self.assertEqual(original.status, "removed")
self.assertIsNotNone(original.deleted_at)
self.assertEqual(
original.metadata_["participant_retirement"][
"replacement_participant_id"
],
replacement.id,
)
invitation = (
self.session.query(PollInvitation)
.filter(PollInvitation.id == invitation_id)
.one()
)
self.assertIsNotNone(invitation.revoked_at)
self.assertIsNotNone(active_response.deleted_at)
self.assertEqual(active_response.answers, original_answers)
self.assertEqual(
active_response.metadata_["response_retirement"]["reason"],
"scheduling_participant_replaced",
)
self.assertEqual(
scheduling_request_summary(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
)["response_count"],
0,
)
notice = (
self.session.query(SchedulingNotification)
.filter(
SchedulingNotification.participant_id == original.id,
SchedulingNotification.event_kind == "participant_replaced",
)
.one()
)
self.assertEqual(notice.recipient, "alice@example.test")
audit_calls = [call.kwargs for call in audit.call_args_list]
replacement_audit = next(
item
for item in audit_calls
if item["action"] == "scheduling.participant_identity_replaced"
)
self.assertEqual(
replacement_audit["details"]["replacement_participant_id"],
replacement.id,
)
self.assertEqual(replacement_audit["details"]["retired_response_count"], 1)
self.assertNotIn("alice@example.test", repr(audit_calls))
self.assertNotIn("replacement@example.test", repr(audit_calls))
def test_stable_account_corrections_keep_identity_and_only_revoke_stale_link(self) -> None:
request = self._request(
participants=[
SchedulingParticipantInput(
respondent_id="alice-account",
display_name="Ailce",
email="alice@example.test",
participant_type="internal",
)
]
)
self._submit_both(request)
participant = request.participants[0]
invitation_id = participant.poll_invitation_id
self.assertIsNotNone(invitation_id)
renamed = api_update_scheduling_request(
request.id,
SchedulingRequestUpdateRequest(
participants=[
SchedulingParticipantReconcileInput(
id=participant.id,
revision=scheduling_participant_revision(participant),
respondent_id=participant.respondent_id,
display_name="Alice",
email=participant.email,
participant_type=participant.participant_type,
required=participant.required,
metadata=participant.metadata_ or {},
)
]
),
session=self.session,
principal=self._principal(
"organizer-1",
email=None,
scopes={WRITE_SCOPE},
),
)
self.assertEqual(renamed.participants[0].id, participant.id)
self.assertEqual(participant.display_name, "Alice")
self.assertEqual(participant.poll_invitation_id, invitation_id)
self.assertIsNone(
self.session.query(PollInvitation)
.filter(PollInvitation.id == invitation_id)
.one()
.revoked_at
)
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
corrected = api_update_scheduling_request(
request.id,
SchedulingRequestUpdateRequest(
participants=[
SchedulingParticipantReconcileInput(
id=participant.id,
revision=scheduling_participant_revision(participant),
respondent_id=participant.respondent_id,
display_name=participant.display_name,
email="alice.corrected@example.test",
participant_type=participant.participant_type,
required=participant.required,
metadata=participant.metadata_ or {},
)
]
),
session=self.session,
principal=self._principal(
"organizer-1",
email=None,
scopes={WRITE_SCOPE},
),
)
self.assertEqual(corrected.participants[0].id, participant.id)
self.assertEqual(participant.email, "alice.corrected@example.test")
self.assertIsNone(participant.poll_invitation_id)
self.assertIsNotNone(
self.session.query(PollInvitation)
.filter(PollInvitation.id == invitation_id)
.one()
.revoked_at
)
self.assertEqual(
self.session.query(PollResponse)
.filter(
PollResponse.poll_id == request.poll_id,
PollResponse.deleted_at.is_(None),
)
.count(),
1,
)
contact_audit = next(
call.kwargs
for call in audit.call_args_list
if call.kwargs["action"] == "scheduling.participant_contact_updated"
)
self.assertTrue(contact_audit["details"]["invitation_revoked"])
def test_stale_participant_revision_is_rejected_without_revoking_access(self) -> None:
request = self._request()
participant = request.participants[0]
invitation_id = participant.poll_invitation_id
with self.assertRaises(HTTPException) as raised:
api_update_scheduling_request(
request.id,
SchedulingRequestUpdateRequest(
participants=[
SchedulingParticipantReconcileInput(
id=participant.id,
revision="0" * 64,
respondent_id=participant.respondent_id,
display_name="Stale update",
email=participant.email,
participant_type=participant.participant_type,
required=participant.required,
metadata=participant.metadata_ or {},
)
]
),
session=self.session,
principal=self._principal(
"organizer-1",
email=None,
scopes={WRITE_SCOPE},
),
)
self.assertEqual(raised.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
self.assertIsNone(participant.deleted_at)
def test_draft_edit_does_not_issue_link_for_added_participant(self) -> None:
request = self._request(
status="draft",
@@ -1284,6 +1671,93 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft")
def test_cancellation_link_becomes_bounded_notice_without_request_details(self) -> None:
request, tokens = self._request_and_tokens()
participant = request.participants[0]
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == participant.poll_invitation_id
).one()
token = tokens[participant.id]
cancelled_at = datetime(2026, 7, 22, 12, tzinfo=timezone.utc)
with (
patch.object(scheduling_service, "_now", return_value=cancelled_at),
patch.object(
scheduling_service,
"get_settings",
return_value=SimpleNamespace(
scheduling_cancellation_notice_days=7
),
),
):
cancelled = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
)
notice_until = cancelled_at + timedelta(days=7)
self.assertEqual(
scheduling_service.response_datetime(
cancelled.cancellation_notice_until
),
notice_until,
)
self.assertEqual(
scheduling_service.response_datetime(invitation.expires_at),
notice_until,
)
with patch.object(
scheduling_service,
"_now",
return_value=cancelled_at + timedelta(days=1),
):
notice = get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.30",
)
self.assertTrue(notice["cancellation_notice_only"])
self.assertEqual(notice["status"], "cancelled")
for private_field in (
"description",
"location",
"deadline_at",
"comment",
"slots",
"answers",
):
self.assertNotIn(private_field, notice)
with patch.object(
scheduling_service,
"_now",
return_value=notice_until + timedelta(seconds=1),
):
with self.assertRaises(SchedulingPublicParticipationError):
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="192.0.2.30",
)
with self.assertRaisesRegex(
SchedulingError,
"cancellation notice has expired",
):
issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="copy",
)
def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request()
alice = self._principal(

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import HTTPException
from fastapi import HTTPException, Response
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
@@ -42,7 +42,10 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCalendarPreferences,
SchedulingCandidateSlotInput,
SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationRevokeRequest,
SchedulingParticipantInput,
SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationSubmitRequest,
SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest,
@@ -55,9 +58,11 @@ from govoplan_scheduling.backend.router import (
api_evaluate_calendar_freebusy,
api_get_my_scheduling_availability,
api_get_scheduling_request,
api_issue_scheduling_participant_invitation,
api_list_scheduling_requests,
api_scheduling_summary,
api_submit_scheduling_availability,
api_revoke_scheduling_participant_invitation,
)
from govoplan_scheduling.backend.service import (
SchedulingError,
@@ -70,12 +75,15 @@ from govoplan_scheduling.backend.service import (
decide_scheduling_request,
evaluate_calendar_freebusy,
get_visible_scheduling_request,
get_public_scheduling_participation,
issue_scheduling_participant_invitation,
list_scheduling_notifications,
list_visible_scheduling_notifications,
list_visible_scheduling_requests,
open_scheduling_request,
require_visible_scheduling_results,
scheduling_request_summary,
scheduling_participant_revision,
scheduling_slot_revision,
submit_public_scheduling_participation,
update_scheduling_request,
@@ -206,7 +214,23 @@ class SchedulingServiceTests(unittest.TestCase):
],
)
def test_create_request_creates_poll_slots_and_signed_invitations(self) -> None:
def _issue_copy(
self,
request: SchedulingRequest,
participant: SchedulingParticipant,
) -> str:
result = issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="copy",
)
self.assertIsNotNone(result.action_url)
return str(result.action_url).rsplit("/", 1)[-1]
def test_create_request_creates_poll_slots_without_implicit_invitations(self) -> None:
request, tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
@@ -224,8 +248,20 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(poll.context_resource_id, request.id)
self.assertEqual(len(request.slots), 2)
self.assertTrue(all(slot.poll_option_id for slot in request.slots))
self.assertEqual(len(tokens), 2)
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants))
self.assertEqual(tokens, {})
self.assertTrue(
all(
participant.status == "draft"
and participant.poll_invitation_id is None
for participant in request.participants
)
)
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id
).count(),
0,
)
def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
class RejectingNotificationProvider:
@@ -331,11 +367,13 @@ class SchedulingServiceTests(unittest.TestCase):
first_participant = request.participants[0]
first_slot = request.slots[0]
second_slot = request.slots[1]
self.assertEqual(tokens, {})
token = self._issue_copy(request, first_participant)
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[first_participant.id],
token=token,
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
SchedulingAvailabilityAnswerInput(
@@ -584,7 +622,7 @@ class SchedulingServiceTests(unittest.TestCase):
all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id)
self.assertEqual(len(reminder_jobs), 2)
self.assertGreaterEqual(len(all_jobs), 4)
self.assertEqual(len(all_jobs), 2)
self.assertTrue(all(job.status == "pending" for job in reminder_jobs))
organizer_jobs = list_visible_scheduling_notifications(
@@ -1057,6 +1095,7 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload,
)
target = request.participants[1]
self._issue_copy(request, target)
attacker = self._principal(
"attacker",
email="alice@example.test",
@@ -1095,7 +1134,7 @@ class SchedulingServiceTests(unittest.TestCase):
principal=attacker,
)
self.assertEqual(direct_response.exception.status_code, 400)
self.assertEqual(direct_response.exception.status_code, 404)
self.assertEqual([request.id], [item.id for item in listed.requests])
self.assertFalse(current.has_response)
@@ -1225,10 +1264,12 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload,
)
participant = request.participants[0]
self.assertEqual(tokens, {})
token = self._issue_copy(request, participant)
submit_public_scheduling_participation(
self.session,
request_id=request.id,
token=tokens[participant.id],
token=token,
payload=SchedulingPublicParticipationSubmitRequest(
answers=[
SchedulingAvailabilityAnswerInput(
@@ -1436,7 +1477,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertIsNone(request.selected_slot_id)
self.assertIsNone(request.calendar_event_id)
def test_initial_invitation_notifications_use_signed_poll_link_and_verified_recipient_id(self) -> None:
def test_explicit_invitation_send_uses_signed_link_and_verified_recipient_id(self) -> None:
class CapturingNotificationProvider:
def __init__(self) -> None:
self.requests = []
@@ -1466,22 +1507,42 @@ class SchedulingServiceTests(unittest.TestCase):
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=provider,
):
request, tokens = create_scheduling_request(
request, automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=payload,
)
self.assertEqual(provider.requests, [])
results = [
issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="send",
)
for participant in request.participants
]
self.assertEqual(automatic_tokens, {})
self.assertTrue(all(result.action_url is None for result in results))
self.assertTrue(all(result.status == "queued" for result in results))
self.assertEqual(len(provider.requests), 2)
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
self.assertEqual(
{item.action_url for item in provider.requests},
{
f"/scheduling/public/{request.id}/{token}"
for token in tokens.values()
},
action_urls = {item.action_url for item in provider.requests}
self.assertTrue(
all(
isinstance(action_url, str)
and action_url.startswith(f"/scheduling/public/{request.id}/")
for action_url in action_urls
)
)
tokens = {
str(action_url).rsplit("/", 1)[-1]
for action_url in action_urls
}
local_notifications = list_scheduling_notifications(
self.session,
tenant_id="tenant-1",
@@ -1489,7 +1550,485 @@ class SchedulingServiceTests(unittest.TestCase):
)
for notification in local_notifications:
serialized = repr({"payload": notification.payload, "metadata": notification.metadata_})
self.assertTrue(all(token not in serialized for token in tokens.values()))
self.assertTrue(all(token not in serialized for token in tokens))
def test_send_without_delivery_provider_preserves_existing_invitation(self) -> None:
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
token = self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
with (
patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=None,
),
self.assertRaisesRegex(
SchedulingError,
"Notification delivery is unavailable; copy the link instead",
),
):
issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="send",
)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
self.assertEqual(
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="127.0.0.1",
)["request_id"],
request.id,
)
def test_send_without_delivery_target_preserves_existing_invitation(self) -> None:
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
token = self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
participant.email = None
self.session.flush()
with (
patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=object(),
),
self.assertRaisesRegex(
SchedulingError,
"Participant has no deliverable email address or account",
),
):
issue_scheduling_participant_invitation(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
participant_id=participant.id,
participant_revision=scheduling_participant_revision(participant),
action="send",
)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
self.assertEqual(
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="127.0.0.1",
)["request_id"],
request.id,
)
def test_stale_copy_is_rejected_before_rotating_invitation(self) -> None:
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
stale_revision = scheduling_participant_revision(participant)
self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE})
with (
patch("govoplan_scheduling.backend.router.audit_event") as audit,
self.assertRaises(HTTPException) as conflict,
):
api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="copy",
participant_revision=stale_revision,
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(conflict.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
audit.assert_not_called()
def test_stale_send_is_rejected_before_delivery_or_rotation(self) -> None:
class CapturingNotificationProvider:
def __init__(self) -> None:
self.requests = []
def enqueue_notification(self, _session, request, *, enqueue_delivery):
self.requests.append(request)
return {"id": "notification-1", "status": "queued"}
provider = CapturingNotificationProvider()
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
stale_revision = scheduling_participant_revision(participant)
self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE})
with (
patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=provider,
),
patch("govoplan_scheduling.backend.router.audit_event") as audit,
self.assertRaises(HTTPException) as conflict,
):
api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="send",
participant_revision=stale_revision,
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(conflict.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
self.assertEqual(provider.requests, [])
self.assertEqual(
self.session.query(SchedulingNotification).filter(
SchedulingNotification.request_id == request.id
).count(),
0,
)
audit.assert_not_called()
def test_stale_revoke_is_rejected_before_changing_access(self) -> None:
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
stale_revision = scheduling_participant_revision(participant)
self._issue_copy(request, participant)
invitation_id = participant.poll_invitation_id
organizer = self._principal("user-1", scopes={SCHEDULING_WRITE_SCOPE})
with (
patch("govoplan_scheduling.backend.router.audit_event") as audit,
self.assertRaises(HTTPException) as conflict,
):
api_revoke_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationRevokeRequest(
participant_revision=stale_revision,
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(conflict.exception.status_code, 409)
self.assertEqual(participant.poll_invitation_id, invitation_id)
invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == invitation_id
).one()
self.assertIsNone(invitation.revoked_at)
audit.assert_not_called()
def test_invitation_router_rotates_revokes_and_enforces_management_policy(self) -> None:
request, automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
organizer = self._principal(
"user-1",
scopes={SCHEDULING_WRITE_SCOPE},
)
response_headers = Response()
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
first = api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(participant),
),
response_headers,
session=self.session,
principal=organizer,
)
self.assertEqual(automatic_tokens, {})
self.assertEqual(first.action, "copy")
self.assertEqual(first.status, "issued")
self.assertIsNotNone(first.action_url)
self.assertEqual(
response_headers.headers["cache-control"],
"no-store, private",
)
first_token = str(first.action_url).rsplit("/", 1)[-1]
self.assertNotIn(first_token, repr([call.kwargs for call in audit.call_args_list]))
unrelated_writer = self._principal(
"unrelated",
scopes={SCHEDULING_WRITE_SCOPE},
)
with self.assertRaises(HTTPException) as denied:
api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(participant),
),
Response(),
session=self.session,
principal=unrelated_writer,
)
self.assertEqual(denied.exception.status_code, 403)
self.assertEqual(
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=first_token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="127.0.0.1",
)["request_id"],
request.id,
)
administrator = self._principal(
"administrator",
scopes={SCHEDULING_ADMIN_SCOPE},
)
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
rotated = api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(participant),
),
Response(),
session=self.session,
principal=administrator,
)
rotated_token = str(rotated.action_url).rsplit("/", 1)[-1]
self.assertNotEqual(rotated_token, first_token)
self.assertNotIn(rotated_token, repr([call.kwargs for call in audit.call_args_list]))
with self.assertRaisesRegex(
Exception,
"Scheduling participation link or credentials are invalid",
):
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=first_token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="127.0.0.1",
)
with patch("govoplan_scheduling.backend.router.audit_event") as audit:
revoked = api_revoke_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationRevokeRequest(
participant_revision=scheduling_participant_revision(participant),
),
Response(),
session=self.session,
principal=organizer,
)
replayed = api_revoke_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationRevokeRequest(
participant_revision=scheduling_participant_revision(participant),
),
Response(),
session=self.session,
principal=organizer,
)
self.assertFalse(revoked.replayed)
self.assertTrue(replayed.replayed)
self.assertNotIn(rotated_token, repr([call.kwargs for call in audit.call_args_list]))
with self.assertRaisesRegex(
Exception,
"Scheduling participation link or credentials are invalid",
):
get_public_scheduling_participation(
self.session,
request_id=request.id,
token=rotated_token,
payload=SchedulingPublicParticipationAccessRequest(),
client_address="127.0.0.1",
)
def test_links_can_be_issued_for_draft_and_closed_requests(self) -> None:
organizer = self._principal(
"user-1",
scopes={SCHEDULING_WRITE_SCOPE},
)
for request_status in ("draft", "collecting"):
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={
"status": request_status,
"calendar": SchedulingCalendarPreferences(),
}
),
)
if request_status == "collecting":
close_scheduling_request(
self.session,
tenant_id=request.tenant_id,
request_id=request.id,
)
expected_status = "closed"
else:
expected_status = "draft"
with patch("govoplan_scheduling.backend.router.audit_event"):
issued = api_issue_scheduling_participant_invitation(
request.id,
request.participants[0].id,
SchedulingInvitationActionRequest(
action="copy",
participant_revision=scheduling_participant_revision(
request.participants[0]
),
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(request.status, expected_status)
self.assertTrue(
str(issued.action_url).startswith(
f"/scheduling/public/{request.id}/"
)
)
def test_send_response_audit_error_and_durable_job_never_store_token(self) -> None:
class EchoingFailureNotificationProvider:
def __init__(self) -> None:
self.requests = []
def enqueue_notification(self, _session, request, *, enqueue_delivery):
self.requests.append(request)
raise RuntimeError(f"failed dispatch for {request.action_url}")
provider = EchoingFailureNotificationProvider()
request, _automatic_tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload().model_copy(
update={"calendar": SchedulingCalendarPreferences()}
),
)
participant = request.participants[0]
organizer = self._principal(
"user-1",
scopes={SCHEDULING_WRITE_SCOPE},
)
with (
patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=provider,
),
patch("govoplan_scheduling.backend.router.audit_event") as audit,
):
result = api_issue_scheduling_participant_invitation(
request.id,
participant.id,
SchedulingInvitationActionRequest(
action="send",
participant_revision=scheduling_participant_revision(participant),
),
Response(),
session=self.session,
principal=organizer,
)
self.assertEqual(len(provider.requests), 1)
action_url = provider.requests[0].action_url
self.assertIsInstance(action_url, str)
token = str(action_url).rsplit("/", 1)[-1]
self.assertIsNone(result.action_url)
self.assertEqual(result.status, "failed")
self.assertEqual(result.notification.error, "Notification center enqueue failed")
self.assertNotIn(token, repr(result.model_dump()))
self.assertNotIn(token, repr([call.kwargs for call in audit.call_args_list]))
notification = self.session.query(SchedulingNotification).filter(
SchedulingNotification.id == result.notification.id
).one()
durable_projection = repr(
{
"payload": notification.payload,
"metadata": notification.metadata_,
"error": notification.error,
}
)
self.assertNotIn(token, durable_projection)
def test_external_participants_can_be_rejected(self) -> None:
payload = self._payload().model_copy(update={"allow_external_participants": False})

View File

@@ -1,6 +1,6 @@
{
"name": "@govoplan/scheduling-webui",
"version": "0.1.10",
"version": "0.1.11",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.10",
"@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -3,15 +3,19 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
const page = readFileSync(pagePath, "utf8");
const publicPage = readFileSync(publicPagePath, "utf8");
const api = readFileSync(apiPath, "utf8");
const moduleSource = readFileSync(modulePath, "utf8");
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*PeoplePicker,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/);
assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-workspace-layout"/);
@@ -23,6 +27,7 @@ assert.match(page, /title=\{I18N\.invitedRequests\}/);
assert.ok(page.indexOf("title={I18N.myRequests}") < page.indexOf("title={I18N.invitedRequests}"));
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/);
assert.match(page, /<SelectionListItem[\s\S]*selected=\{selectedId === request\.id\}[\s\S]*className="scheduling-list-item"/);
assert.match(page, /className="scheduling-list-item"[\s\S]{0,100}disabled=\{disabled\}/);
assert.match(page, /editorMode \? \(/);
assert.match(page, /id="scheduling-editor-form"/);
@@ -59,26 +64,49 @@ for (const setting of [
assert.match(page, /<PasswordField[\s\S]*minLength=\{8\}/);
assert.match(page, /type="number"[\s\S]*min=\{1\}/);
assert.match(page, /min=\{addLocalMinutes\(slot\.start_at, 1\)\}/);
assert.match(page, /create_participant_invitations: true/);
assert.match(page, /allow_external_participants: allowExternalParticipants/);
assert.doesNotMatch(page, /usesGatewayPolicy|updateSchedulingCandidateSlot/);
assert.match(page, /public_participation_policy_enforcement_available/);
assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/);
assert.match(page, /policyLocked=\{participationPolicyLocked\}/);
assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
assert.match(page, /id="scheduling-create-participants-grid"/);
assert.match(page, /id="scheduling-participant-picker"/);
assert.match(page, /id="scheduling-candidate-slots-grid"/);
assert.match(page, /id="scheduling-participants-grid"/);
assert.match(page, /<DataGridRowActions/);
assert.match(page, /<DataGridEmptyAction/);
assert.match(page, /disabled=\{!canCreateOrWrite\}[\s\S]{0,80}reorderable/);
assert.doesNotMatch(page, /reorderable=\{editorMode === "create"\}/);
assert.doesNotMatch(page, /EmailAddressInput|MailboxAddress|addressSuggestions|addressLookupQuery/);
assert.match(page, /type="email"[\s\S]*aria-label=\{I18N\.participantEmail\}/);
assert.doesNotMatch(page, /<input[\s\S]{0,220}aria-label=\{I18N\.participantEmail\}/);
assert.match(page, /allowManualExternal=\{allowExternalParticipants\}/);
assert.match(page, /search=\{participantSearch\}/);
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
assert.doesNotMatch(page, /AdminIconButton/);
const participantGridStart = page.indexOf("function ParticipantsGrid(");
const participantGridEnd = page.indexOf("function invitationActionDisabledReason", participantGridStart);
const participantGrid = page.slice(participantGridStart, participantGridEnd);
assert.match(participantGrid, /\.\.\.\(canManage \? \[\{/);
assert.match(participantGrid, /minimumSlots=\{3\}/);
assert.ok(participantGrid.indexOf('id: "copy-invitation"') < participantGrid.indexOf('id: "send-invitation"'));
assert.ok(participantGrid.indexOf('id: "send-invitation"') < participantGrid.indexOf('id: "revoke-invitation"'));
assert.match(participantGrid, /schedulingInvitationActionBlocks\(request, participant, now\)/);
assert.match(participantGrid, /disabledReason: copyDisabledReason/);
assert.match(participantGrid, /disabledReason: deliveryDisabledReason/);
assert.match(participantGrid, /disabledReason: revokeDisabledReason/);
assert.match(page, /<ConfirmDialog[\s\S]*title=\{I18N\.revokeInvitationLabel\}[\s\S]*tone="danger"/);
assert.match(page, /navigator\.clipboard\.writeText\(value\)/);
assert.match(page, /navigator\.clipboard\.write\(\[new ClipboardItem/);
assert.match(page, /schedulingPublicInvitationUrl\(response\.action_url, window\.location\.origin\)/);
assert.match(page, /\["failed", "skipped"\]\.includes\(result\.status\)/);
assert.match(page, /isApiError\(err, 409\)/);
assert.match(page, /scheduleExpiryRefresh/);
assert.doesNotMatch(page, /(?:localStorage|sessionStorage).*action_url|action_url.*(?:localStorage|sessionStorage)/);
assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /option_revision: slot\.revision/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
@@ -99,17 +127,33 @@ for (const field of [
"allow_comments",
"participant_email_required",
"anonymous_password_protection_enabled",
"public_participation_policy_enforcement_available"
"public_participation_policy_enforcement_available",
"participant_invitation_delivery_available"
]) {
assert.match(api, new RegExp(`${field}:`));
}
assert.match(api, /method: "PATCH"/);
assert.match(api, /\/api\/v1\/scheduling\/people\?/);
assert.doesNotMatch(api, /address-lookup/);
assert.match(page, /slots: slots\.map\(\(slot\) => \(\{/);
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/);
assert.match(api, /issueSchedulingParticipantInvitation\([\s\S]*json\(\{ action, 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, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
assert.match(page, /useSearchParams\(\)/);
assert.match(page, /Promise\.allSettled/);
console.log("Scheduling page structure satisfies the two-pane editor, response, and policy contract.");
assert.match(moduleSource, /publicRoutes:[\s\S]*path: "\/scheduling\/public\/:requestId\/:token"/);
assert.match(moduleSource, /SchedulingPublicPage/);
assert.match(publicPage, /Card,[\s\S]*DismissibleAlert,[\s\S]*FormField,[\s\S]*LoadingFrame,[\s\S]*from "@govoplan\/core-webui"/);
assert.match(publicPage, /getPublicSchedulingParticipation\(settings, requestId, token, \{\}\)/);
assert.match(publicPage, /applySchedulingAvailabilityChoice\(/);
assert.match(publicPage, /option_revision: slot\.revision/);
assert.match(publicPage, /idempotency_key: newIdempotencyKey\(\)/);
assert.doesNotMatch(publicPage, /window\.(?:alert|confirm)\(/);
assert.doesNotMatch(publicPage, /(?:localStorage|sessionStorage).*token|token.*(?:localStorage|sessionStorage)/);
console.log("Scheduling pages satisfy the two-pane editor, public response, and policy contracts.");

View File

@@ -1,4 +1,8 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
import {
apiFetch,
type ApiSettings,
type PeoplePickerSearchGroup
} from "@govoplan/core-webui";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
@@ -23,6 +27,7 @@ export type SchedulingCandidateSlot = {
export type SchedulingParticipant = {
id: string;
revision?: string | null;
is_current_participant: boolean;
respondent_id?: string | null;
display_name?: string | null;
@@ -79,6 +84,7 @@ export type SchedulingRequest = {
anonymous_password_protection_enabled: boolean;
public_participation_policy_enforcement_available: boolean | null;
public_participation_policy_enforcement_reason?: string | null;
participant_invitation_delivery_available: boolean | null;
calendar_integration_enabled: boolean | null;
calendar_id?: string | null;
calendar_freebusy_enabled: boolean | null;
@@ -87,6 +93,7 @@ export type SchedulingRequest = {
calendar_event_id?: string | null;
handed_off_at?: string | null;
cancelled_at?: string | null;
cancellation_notice_until?: string | null;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown>;
@@ -163,6 +170,7 @@ export type SchedulingCandidateSlotReconcilePayload = SchedulingCandidateSlotPay
export type SchedulingParticipantReconcilePayload = SchedulingParticipantPayload & {
id?: string;
revision?: string;
};
export type SchedulingCandidateSlotUpdatePayload = {
@@ -224,6 +232,9 @@ export type SchedulingPublicParticipationResponse = {
timezone: string;
status: SchedulingStatus;
deadline_at?: string | null;
cancelled_at?: string | null;
cancellation_notice_until?: string | null;
cancellation_notice_only: boolean;
participant_email_required: boolean;
anonymous_password_required: boolean;
single_choice: boolean;
@@ -278,6 +289,18 @@ export type SchedulingNotification = {
export type SchedulingNotificationListResponse = { notifications: SchedulingNotification[] };
export type SchedulingInvitationAction = "copy" | "send" | "revoke";
export type SchedulingInvitationActionResponse = {
participant_id: string;
action: SchedulingInvitationAction;
status: string;
action_url?: string | null;
issued_at?: string | null;
replayed: boolean;
notification?: SchedulingNotification | null;
};
export type SchedulingPollOptionResult = {
option_id: string;
option_key: string;
@@ -299,31 +322,25 @@ export type SchedulingSummaryResponse = {
};
};
export type SchedulingAddressLookupCandidate = {
contact_id: string;
address_book_id: string;
display_name: string;
email?: string | null;
email_label?: string | null;
organization?: string | null;
role_title?: string | null;
tags: string[];
source_kind: string;
source_ref?: string | null;
source_revision?: string | null;
provenance: Record<string, unknown>;
};
export type SchedulingAddressLookupResponse = {
available: boolean;
candidates: SchedulingAddressLookupCandidate[];
export type SchedulingPeopleSearchResponse = {
groups: PeoplePickerSearchGroup[];
};
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise<SchedulingAddressLookupResponse> {
export async function searchSchedulingPeople(
settings: ApiSettings,
query: string,
limit = 25,
signal?: AbortSignal
): Promise<PeoplePickerSearchGroup[]> {
const params = new URLSearchParams({ query, limit: String(limit) });
return apiFetch<SchedulingAddressLookupResponse>(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`);
const response = await apiFetch<SchedulingPeopleSearchResponse>(
settings,
`/api/v1/scheduling/people?${params.toString()}`,
{ signal }
);
return response.groups;
}
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
@@ -442,3 +459,30 @@ export function listSchedulingNotifications(settings: ApiSettings, requestId?: s
const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`);
}
export function issueSchedulingParticipantInvitation(
settings: ApiSettings,
requestId: string,
participantId: string,
participantRevision: string,
action: Exclude<SchedulingInvitationAction, "revoke">
): Promise<SchedulingInvitationActionResponse> {
return apiFetch<SchedulingInvitationActionResponse>(
settings,
`/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`,
json({ action, participant_revision: participantRevision })
);
}
export function revokeSchedulingParticipantInvitation(
settings: ApiSettings,
requestId: string,
participantId: string,
participantRevision: string
): Promise<SchedulingInvitationActionResponse> {
return apiFetch<SchedulingInvitationActionResponse>(
settings,
`/api/v1/scheduling/requests/${encodeURIComponent(requestId)}/participants/${encodeURIComponent(participantId)}/invitation`,
{ method: "DELETE", body: JSON.stringify({ participant_revision: participantRevision }) }
);
}

View File

@@ -1,10 +1,12 @@
import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react";
import { useSearchParams } from "react-router-dom";
import {
Bell,
CalendarCheck,
Check,
Clock,
Copy,
Link2Off,
Pencil,
Plus,
RefreshCw,
@@ -15,8 +17,8 @@ import {
import {
Button,
Card,
ConfirmDialog,
DataGrid,
DataGridEmptyAction,
DataGridRowActions,
DateTimeField,
DismissibleAlert,
@@ -25,6 +27,7 @@ import {
IconButton,
PageTitle,
PasswordField,
PeoplePicker,
formatDateTime,
SelectionList,
SelectionListItem,
@@ -33,6 +36,7 @@ import {
StatusBadge,
hasScope,
i18nMessage,
isApiError,
usePlatformLanguage,
usePlatformUiCapability,
useUnsavedChanges,
@@ -41,7 +45,9 @@ import {
type AuthInfo,
type CalendarPickerUiCapability,
type DataGridColumn,
type FormatDateTimeOptions
type FormatDateTimeOptions,
type PeoplePickerItem,
type PeoplePickerSearch
} from "@govoplan/core-webui";
import {
closeSchedulingRequest,
@@ -52,14 +58,18 @@ import {
decideSchedulingRequest,
evaluateSchedulingFreeBusy,
getSchedulingAvailabilityResponse,
issueSchedulingParticipantInvitation,
listSchedulingNotifications,
listSchedulingRequests,
openSchedulingRequest,
revokeSchedulingParticipantInvitation,
searchSchedulingPeople,
schedulingSummary,
submitSchedulingAvailability,
updateSchedulingRequest,
type SchedulingCandidateSlot,
type SchedulingAvailabilityValue,
type SchedulingInvitationActionResponse,
type SchedulingNotification,
type SchedulingParticipant,
type SchedulingPollOptionResult,
@@ -70,10 +80,17 @@ import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
schedulingParticipantForActor,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingPublicInvitationUrl,
schedulingRelevantTimestamp,
schedulingRequestIsOwned,
schedulingSortPhase,
type SchedulingActor,
type SchedulingInvitationActionBlock,
type SchedulingParticipantDraft,
type SchedulingRequestGroups
} from "./schedulingViewModel";
@@ -89,16 +106,10 @@ type SlotDraft = {
timezone: string;
metadata?: Record<string, unknown>;
};
type ParticipantDraft = {
draftId: string;
sourceId?: string;
respondent_id?: string | null;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
type ParticipantDraft = SchedulingParticipantDraft;
type InvitationRevokeTarget = {
requestId: string;
participant: SchedulingParticipant;
};
const I18N = {
@@ -121,10 +132,13 @@ const I18N = {
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
checkFreeBusy: "i18n:govoplan-scheduling.check_free_busy.e9700e00",
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6",
clipboardUnavailable: "i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc",
closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
closed: "i18n:govoplan-scheduling.closed.88d86b77",
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
description: "i18n:govoplan-scheduling.description.55f8ebc8",
determined: "i18n:govoplan-scheduling.determined.9f23293d",
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d",
@@ -136,7 +150,13 @@ const I18N = {
generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
free: "i18n:govoplan-scheduling.free.75f52718",
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e",
invitationDeliveryUnavailable: "i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3",
invitationDeliveryFailed: "i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306",
invitationDeliveryRequested: "i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba",
invitationChanged: "i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533",
invitedRequests: "i18n:govoplan-scheduling.scheduling_requests_for_me.1d521aba",
invitationLinkCopied: "i18n:govoplan-scheduling.invitation_link_copied.332973ec",
invitationLinkRevoked: "i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4",
loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d",
location: "i18n:govoplan-scheduling.location.d219c681",
allowComments: "i18n:govoplan-scheduling.allow_comments.d63202a6",
@@ -158,6 +178,8 @@ const I18N = {
myRequests: "i18n:govoplan-scheduling.my_scheduling_requests.d28ef235",
name: "i18n:govoplan-scheduling.name.709a2322",
newRequest: "i18n:govoplan-scheduling.new_scheduling_request.2080f675",
noActiveInvitation: "i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc",
noDeliveryTarget: "i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180",
noNotifications: "i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3",
notificationsUnavailable: "i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3",
noParticipants: "i18n:govoplan-scheduling.no_participants.73a89101",
@@ -168,12 +190,16 @@ const I18N = {
notifyOnAnswersHelp: "i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8",
open: "i18n:govoplan-scheduling.open.cf9b7706",
openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7",
participant: "i18n:govoplan-scheduling.participant.554f4235",
participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a",
participantPrivacy: "i18n:govoplan-scheduling.participant_privacy.108c470f",
participantRosterVisibility: "i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0",
participantRosterVisibilityHelp: "i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361",
participants: "i18n:govoplan-scheduling.participants.cd56e083",
participantPickerHelp: "i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44",
allowExternalParticipants: "i18n:govoplan-scheduling.allow_external_participants.a9efcb52",
allowExternalParticipantsHelp: "i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735",
participation: "i18n:govoplan-scheduling.participation.9ad70cc4",
participationRate: "i18n:govoplan-scheduling.participation_rate.46bc5504",
responses: "i18n:govoplan-scheduling.responses.3427e3ab",
@@ -181,7 +207,12 @@ const I18N = {
capacity: "i18n:govoplan-scheduling.capacity.d3c375f8",
past: "i18n:govoplan-scheduling.past.405c12fb",
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
reloadInvitation: "i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4",
reminder: "i18n:govoplan-scheduling.send_reminder.cf5eb3bf",
revokeInvitation: "i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa",
revokeInvitationConfirm: "i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817",
revokeInvitationLabel: "i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf",
revokeLink: "i18n:govoplan-scheduling.revoke_link.da371ee1",
requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c",
publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0",
policyLocked: "i18n:govoplan-scheduling.participation_controls_are_locked_after_invitation_links_are_issued.66f5a740",
@@ -199,6 +230,7 @@ const I18N = {
unsavedResponse: "i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1",
singleChoice: "i18n:govoplan-scheduling.participants_can_choose_only_one_option.4311f51c",
singleChoiceHelp: "i18n:govoplan-scheduling.each_participant_can_answer_yes_to_at_most_one_candidate.5313a465",
sendInvitation: "i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea",
sendResponse: "i18n:govoplan-scheduling.submit_response.a5f0c053",
start: "i18n:govoplan-scheduling.start.952f3754",
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
@@ -235,7 +267,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [calendarId, setCalendarId] = useState("");
const [calendarEnabled, setCalendarEnabled] = useState(false);
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
const [participants, setParticipants] = useState<ParticipantDraft[]>([]);
const [allowExternalParticipants, setAllowExternalParticipants] = useState(true);
const [participantRosterVisible, setParticipantRosterVisible] = useState(false);
const [notifyOnAnswers, setNotifyOnAnswers] = useState(true);
const [singleChoice, setSingleChoice] = useState(false);
@@ -254,6 +287,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [summaryUnavailable, setSummaryUnavailable] = useState(false);
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
const [detailsLoading, setDetailsLoading] = useState(false);
const [revokeInvitationTarget, setRevokeInvitationTarget] = useState<InvitationRevokeTarget | null>(null);
const [invitationActionClock, setInvitationActionClock] = useState(() => new Date());
const detailLoadSequence = useRef(0);
const canWrite = hasScope(auth, "scheduling:schedule:write");
@@ -323,6 +358,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const participationPolicyLocked = Boolean(
editorOriginal?.participants.some((participant) => participant.poll_invitation_id)
);
const participantSearch = useCallback<PeoplePickerSearch>(
(query, options) => searchSchedulingPeople(settings, query, options.limit, options.signal),
[settings]
);
useEffect(() => {
void loadRequests();
@@ -351,6 +390,29 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
void loadDetails(selected.id);
}, [editorMode, selected?.id]);
useEffect(() => {
setInvitationActionClock(new Date());
if (selected?.status !== "cancelled" || !selected.cancellation_notice_until) return undefined;
const expiresAt = Date.parse(selected.cancellation_notice_until);
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return undefined;
let timer: number | undefined;
const scheduleExpiryRefresh = () => {
const remaining = expiresAt - Date.now();
if (remaining <= 0) {
setInvitationActionClock(new Date());
return;
}
timer = window.setTimeout(
scheduleExpiryRefresh,
Math.min(remaining + 25, 2_147_483_647)
);
};
scheduleExpiryRefresh();
return () => {
if (timer !== undefined) window.clearTimeout(timer);
};
}, [selected?.cancellation_notice_until, selected?.status]);
useEffect(() => {
if (!selected?.id || !selectedParticipant || !canRespond || editorMode || selected.status !== "collecting") {
setAvailabilityLoading(false);
@@ -482,17 +544,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
timezone: slot.timezone,
metadata: slot.metadata
})));
setParticipants(request.participants.map((participant) => ({
draftId: nextDraftId("participant"),
sourceId: participant.id,
respondent_id: participant.respondent_id,
display_name: participant.display_name ?? "",
email: participant.email ?? "",
participant_type: normalizeParticipantType(participant.participant_type),
required: participant.required ?? true,
metadata: participant.metadata,
identityLocked: Boolean(participant.poll_invitation_id)
})));
setParticipants(request.participants.map((participant) => (
participantDraftFromResponse(participant, nextDraftId("participant"))
)));
setAllowExternalParticipants(request.allow_external_participants);
setParticipantRosterVisible(request.participant_visibility === "names_and_statuses");
setNotifyOnAnswers(request.notify_on_answers);
setSingleChoice(request.single_choice);
@@ -555,6 +610,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
title: title.trim(),
description: description.trim() || null,
location: location.trim() || null,
allow_external_participants: allowExternalParticipants,
participant_visibility: participantRosterVisible ? "names_and_statuses" : "aggregates_only",
notify_on_answers: notifyOnAnswers,
single_choice: singleChoice,
@@ -587,16 +643,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null,
metadata: slot.metadata ?? {}
})),
participants: participants
.filter((participant) => participant.email.trim() || participant.display_name.trim())
.map((participant) => ({
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
required: participant.required,
participant_type: participant.participant_type,
metadata: participant.metadata ?? {}
})),
participants: participants.map(participantPayload),
create_participant_invitations: true
});
setRequests((items) => [request, ...items]);
@@ -617,17 +664,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null,
metadata: slot.metadata ?? {}
})),
participants: participants
.filter((participant) => participant.sourceId || participant.email.trim() || participant.display_name.trim())
.map((participant) => ({
...(participant.sourceId ? { id: participant.sourceId } : {}),
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
participant_type: participant.participant_type,
required: participant.required,
metadata: participant.metadata ?? {}
})),
participants: participants.map(participantPayload),
create_participant_invitations: true
});
replaceRequest(request);
@@ -665,6 +702,91 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
}
}
async function runParticipantInvitationAction(
requestId: string,
participant: SchedulingParticipant,
action: "copy" | "send" | "revoke"
): Promise<boolean> {
const request = requests.find((item) => item.id === requestId);
if (!request || !(canAdminister || (canWrite && schedulingRequestIsOwned(request, actor)))) return false;
if (!participant.revision) {
setError(I18N.reloadInvitation);
return false;
}
let succeeded = false;
let failureMessage = "";
let completionMessage = "";
setSaving(true);
setError("");
setSuccess("");
try {
let copied = true;
let result: SchedulingInvitationActionResponse;
if (action === "copy") {
const copyResult = await copySchedulingInvitationLink(
issueSchedulingParticipantInvitation(
settings,
requestId,
participant.id,
participant.revision,
action
)
);
result = copyResult.response;
copied = copyResult.copied;
} else if (action === "send") {
result = await issueSchedulingParticipantInvitation(
settings,
requestId,
participant.id,
participant.revision,
action
);
} else {
result = await revokeSchedulingParticipantInvitation(
settings,
requestId,
participant.id,
participant.revision
);
}
if (action === "copy") {
if (!copied) {
throw new Error(translateText(I18N.clipboardUnavailable));
}
completionMessage = I18N.invitationLinkCopied;
} else if (action === "send") {
if (["failed", "skipped"].includes(result.status)) {
throw new Error(translateText(I18N.invitationDeliveryFailed));
}
completionMessage = I18N.invitationDeliveryRequested;
} else {
completionMessage = I18N.invitationLinkRevoked;
}
succeeded = true;
} catch (err) {
if (isApiError(err, 409)) {
failureMessage = I18N.invitationChanged;
} else {
failureMessage = errorMessage(err, translateText(I18N.requestFailed));
}
} finally {
await loadRequests(requestId);
await loadDetails(requestId);
setSaving(false);
if (failureMessage) setError(failureMessage);
if (completionMessage) setSuccess(completionMessage);
}
return succeeded;
}
async function confirmRevokeInvitation() {
if (!revokeInvitationTarget) return;
const target = revokeInvitationTarget;
setRevokeInvitationTarget(null);
await runParticipantInvitationAction(target.requestId, target.participant, "revoke");
}
async function sendAvailability(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await persistAvailability();
@@ -726,9 +848,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.refresh}
icon={<RefreshCw aria-hidden="true" size={16} />}
onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
disabled={loading} />
disabled={loading || saving} />
{canCreateOrWrite ? (
<Button type="button" variant="primary" onClick={beginCreate}>
<Button type="button" variant="primary" onClick={beginCreate} disabled={saving}>
<Plus aria-hidden="true" size={16} /> {I18N.add}
</Button>
) : null}
@@ -740,12 +862,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
requests={groups.owned}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
<RequestGroup
title={I18N.invitedRequests}
requests={groups.invited}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
{canAdminister && groups.other.length ? (
<RequestGroup
@@ -753,6 +877,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
requests={groups.other}
selectedId={editorMode ? undefined : selected?.id}
actor={actor}
disabled={saving}
onSelect={selectRequest} />
) : null}
</Card>
@@ -831,7 +956,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<EditableSlots
slots={slots}
disabled={!canCreateOrWrite}
reorderable={editorMode === "create"}
reorderable
onAddBelow={(index) => {
setSlots((items) => editorMode === "edit"
? [...items, newSlot(items.length + 1, translateText)]
@@ -851,16 +976,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<EditableParticipants
participants={participants}
disabled={!canCreateOrWrite}
onAddBelow={(index) => {
setParticipants((items) => insertAfter(items, index, emptyParticipant()));
allowExternalParticipants={allowExternalParticipants}
search={participantSearch}
onAllowExternalParticipantsChange={(value) => {
setAllowExternalParticipants(value);
setDraftDirty(true);
}}
onChange={(index, patch) => {
setParticipants((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item));
setDraftDirty(true);
}}
onRemove={(index) => {
setParticipants((items) => items.filter((_item, itemIndex) => itemIndex !== index));
onChange={(value) => {
setParticipants((current) => participantDraftsFromPicker(
value,
current,
() => nextDraftId("participant")
));
setDraftDirty(true);
}} />
@@ -1078,7 +1205,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
))}
</div>
{selected.effective_participant_visibility === "names_and_statuses" ? (
<ParticipantsGrid participants={selected.participants} />
<ParticipantsGrid
request={selected}
participants={selected.participants}
canManage={canManageSelected}
saving={saving}
now={invitationActionClock}
onCopy={(participant) => void runParticipantInvitationAction(selected.id, participant, "copy")}
onSend={(participant) => void runParticipantInvitationAction(selected.id, participant, "send")}
onRevoke={(participant) => setRevokeInvitationTarget({
requestId: selected.id,
participant
})} />
) : <p className="scheduling-capability-note">{I18N.participantRosterHidden}</p>}
</Card>
<Card title={I18N.notifications}>
@@ -1108,6 +1246,17 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</section>
</div>
</section>
<ConfirmDialog
open={Boolean(revokeInvitationTarget)}
title={I18N.revokeInvitationLabel}
message={i18nMessage(I18N.revokeInvitationConfirm, {
value0: participantDisplayLabel(revokeInvitationTarget?.participant ?? null)
})}
confirmLabel={I18N.revokeLink}
tone="danger"
busy={saving}
onCancel={() => setRevokeInvitationTarget(null)}
onConfirm={() => void confirmRevokeInvitation()} />
</main>
);
@@ -1174,7 +1323,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
setCalendarId("");
setCalendarEnabled(false);
setSlots(initialSlots(translateText));
setParticipants([emptyParticipant()]);
setParticipants([]);
setAllowExternalParticipants(true);
setParticipantRosterVisible(false);
setNotifyOnAnswers(true);
setSingleChoice(false);
@@ -1194,12 +1344,14 @@ function RequestGroup({
requests,
selectedId,
actor,
disabled,
onSelect
}: {
title: string;
requests: SchedulingRequest[];
selectedId?: string;
actor: SchedulingActor;
disabled: boolean;
onSelect: (requestId: string) => void;
}) {
return (
@@ -1213,6 +1365,7 @@ function RequestGroup({
key={request.id}
selected={selectedId === request.id}
className="scheduling-list-item"
disabled={disabled}
onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<StatusBadge status={request.status} label={requestStatusLabel(request, actor)} />
@@ -1518,82 +1671,40 @@ function EditableSlots({
function EditableParticipants({
participants,
disabled,
onAddBelow,
onChange,
onRemove
allowExternalParticipants,
search,
onAllowExternalParticipantsChange,
onChange
}: {
participants: ParticipantDraft[];
disabled: boolean;
onAddBelow: (index: number) => void;
onChange: (index: number, patch: Partial<ParticipantDraft>) => void;
onRemove: (index: number) => void;
allowExternalParticipants: boolean;
search: PeoplePickerSearch;
onAllowExternalParticipantsChange: (value: boolean) => void;
onChange: (participants: PeoplePickerItem[]) => void;
}) {
const lockedIdentityHelpId = "scheduling-invited-participant-identity-help";
const columns: DataGridColumn<ParticipantDraft>[] = [
{
id: "name",
header: I18N.name,
minWidth: 180,
resizable: true,
value: (participant) => participant.display_name,
render: (participant, index) => (
<input
disabled={disabled || participant.identityLocked}
maxLength={500}
aria-describedby={participant.identityLocked ? lockedIdentityHelpId : undefined}
aria-label={I18N.name}
value={participant.display_name}
onChange={(event) => onChange(index, { display_name: event.target.value })} />
)
},
{
id: "email",
header: I18N.participantEmail,
minWidth: 260,
resizable: true,
value: (participant) => participant.email,
render: (participant, index) => (
<input
disabled={disabled || participant.identityLocked}
type="email"
maxLength={320}
aria-describedby={participant.identityLocked ? lockedIdentityHelpId : undefined}
aria-label={I18N.participantEmail}
value={participant.email}
onChange={(event) => onChange(index, { email: event.target.value })} />
)
},
{
id: "actions",
header: I18N.actions,
width: 112,
sticky: "end",
render: (_participant, index) => (
<DataGridRowActions
disabled={disabled}
reorderable={false}
onAddBelow={() => onAddBelow(index)}
onRemove={() => onRemove(index)}
addLabel={I18N.addParticipant}
removeLabel={i18nMessage("i18n:govoplan-scheduling.remove_participant_value.e55f2b70", { value0: index + 1 })} />
)
}
];
return (
<Card title={I18N.participants}>
{participants.some((participant) => participant.identityLocked) ? (
<p id={lockedIdentityHelpId} className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
<p className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
) : null}
<DataGrid
id="scheduling-create-participants-grid"
rows={participants}
columns={columns}
getRowKey={(participant) => participant.draftId}
initialFit="container"
emptyText={I18N.noParticipants}
emptyActionColumnId="actions"
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={() => onAddBelow(-1)} label={I18N.addParticipant} />} />
<ToggleSwitch
label={I18N.allowExternalParticipants}
help={I18N.allowExternalParticipantsHelp}
checked={allowExternalParticipants}
disabled={disabled}
onChange={onAllowExternalParticipantsChange} />
<PeoplePicker
id="scheduling-participant-picker"
label={I18N.addParticipant}
help={I18N.participantPickerHelp}
selectedLabel={I18N.participants}
value={participants}
search={search}
disabled={disabled}
allowManualExternal={allowExternalParticipants}
manualEmailRequired
onChange={onChange} />
</Card>
);
}
@@ -1695,7 +1806,25 @@ function CandidateSlotsGrid({
);
}
function ParticipantsGrid({ participants }: { participants: SchedulingParticipant[] }) {
function ParticipantsGrid({
request,
participants,
canManage,
saving,
now,
onCopy,
onSend,
onRevoke
}: {
request: SchedulingRequest;
participants: SchedulingParticipant[];
canManage: boolean;
saving: boolean;
now: Date;
onCopy: (participant: SchedulingParticipant) => void;
onSend: (participant: SchedulingParticipant) => void;
onRevoke: (participant: SchedulingParticipant) => void;
}) {
const columns: DataGridColumn<SchedulingParticipant>[] = [
{ id: "name", header: I18N.name, minWidth: 180, resizable: true, value: (participant) => participant.display_name ?? "" },
{ id: "email", header: I18N.participantEmail, minWidth: 220, resizable: true, value: (participant) => participant.email ?? "" },
@@ -1705,7 +1834,52 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
width: 140,
value: (participant) => participantStatusLabel(participant.status),
render: (participant) => <StatusBadge status={participant.status} label={participantStatusLabel(participant.status)} />
}
},
...(canManage ? [{
id: "actions",
header: I18N.actions,
width: 132,
sticky: "end",
align: "right",
render: (participant) => {
const label = participantDisplayLabel(participant);
const blocks = schedulingInvitationActionBlocks(request, participant, now);
const copyDisabledReason = invitationActionDisabledReason(blocks.copy, request);
const deliveryDisabledReason = invitationActionDisabledReason(blocks.send, request);
const revokeDisabledReason = invitationActionDisabledReason(blocks.revoke, request);
return (
<TableActionGroup
minimumSlots={3}
actions={[
{
id: "copy-invitation",
label: i18nMessage(I18N.copyInvitationLink, { value0: label }),
icon: <Copy aria-hidden="true" size={16} />,
disabled: saving || Boolean(copyDisabledReason),
disabledReason: copyDisabledReason,
onClick: () => onCopy(participant)
},
{
id: "send-invitation",
label: i18nMessage(I18N.sendInvitation, { value0: label }),
icon: <Send aria-hidden="true" size={16} />,
disabled: saving || Boolean(deliveryDisabledReason),
disabledReason: deliveryDisabledReason,
onClick: () => onSend(participant)
},
{
id: "revoke-invitation",
label: i18nMessage(I18N.revokeInvitation, { value0: label }),
icon: <Link2Off aria-hidden="true" size={16} />,
variant: "danger",
disabled: saving || Boolean(revokeDisabledReason),
disabledReason: revokeDisabledReason,
onClick: () => onRevoke(participant)
}
]} />
);
}
} satisfies DataGridColumn<SchedulingParticipant>] : [])
];
return (
@@ -1719,6 +1893,68 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
);
}
function invitationActionDisabledReason(
block: SchedulingInvitationActionBlock | null,
request: SchedulingRequest
): string | undefined {
if (block === "participation_policy_unavailable") {
return request.public_participation_policy_enforcement_reason || I18N.publicPolicyUnavailable;
}
if (block === "cancellation_notice_expired") return I18N.cancellationNoticeExpired;
if (block === "delivery_unavailable") return I18N.invitationDeliveryUnavailable;
if (block === "no_delivery_target") return I18N.noDeliveryTarget;
if (block === "no_active_invitation") return I18N.noActiveInvitation;
if (block === "participant_revision_unavailable") return I18N.reloadInvitation;
return undefined;
}
function participantDisplayLabel(participant: SchedulingParticipant | null): string {
return participant?.display_name?.trim() || participant?.email?.trim() || I18N.participant;
}
async function copySchedulingInvitationLink(
responsePromise: Promise<SchedulingInvitationActionResponse>
): Promise<{ response: SchedulingInvitationActionResponse; copied: boolean }> {
if (
typeof navigator !== "undefined" &&
navigator.clipboard?.write &&
typeof ClipboardItem !== "undefined"
) {
try {
const content = responsePromise.then((response) => {
const value = response.action_url
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
: null;
return new Blob([value ?? ""], { type: "text/plain" });
}).catch(() => new Blob([], { type: "text/plain" }));
await navigator.clipboard.write([new ClipboardItem({ "text/plain": content })]);
const response = await responsePromise;
const copied = Boolean(
response.action_url &&
schedulingPublicInvitationUrl(response.action_url, window.location.origin)
);
return { response, copied };
} catch {
// The request result is reused below; no second invitation is issued.
}
}
const response = await responsePromise;
const value = response.action_url
? schedulingPublicInvitationUrl(response.action_url, window.location.origin)
: null;
if (!value) return { response, copied: false };
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return { response, copied: true };
}
} catch {
// The caller reports the bounded clipboard failure without exposing the URL.
}
return { response, copied: false };
}
function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean): SchedulingRequest | null {
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
}
@@ -1755,7 +1991,9 @@ function notificationKindLabel(kind: string): string {
invitation: "i18n:govoplan-scheduling.invitation.6306ef74",
reminder: "i18n:govoplan-scheduling.reminder.b87a1929",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1",
cancellation: "i18n:govoplan-scheduling.cancellation.319aaae4"
cancellation: "i18n:govoplan-scheduling.cancellation.319aaae4",
participant_removed: "i18n:govoplan-scheduling.participant_removed.0cf4ec4c",
participant_replaced: "i18n:govoplan-scheduling.participant_replaced.2623752d"
} as Record<string, string>)[kind] ?? kind;
}
@@ -1792,21 +2030,6 @@ function newSlot(position: number, translateText: (value: string) => string): Sl
};
}
function emptyParticipant(): ParticipantDraft {
return {
draftId: nextDraftId("participant"),
display_name: "",
email: "",
participant_type: "external",
required: true,
metadata: {}
};
}
function normalizeParticipantType(value: string | null): ParticipantDraft["participant_type"] {
return value === "internal" || value === "resource" ? value : "external";
}
let draftIdSequence = 0;
function nextDraftId(prefix: string): string {

View File

@@ -0,0 +1,275 @@
import { useEffect, useMemo, useState, type FormEvent } from "react";
import { Link, useParams } from "react-router-dom";
import {
Button,
Card,
DismissibleAlert,
FormField,
LoadingFrame,
formatDateTime,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
getPublicSchedulingParticipation,
submitPublicSchedulingParticipation,
type SchedulingAvailabilityValue,
type SchedulingPublicParticipationAccessPayload,
type SchedulingPublicParticipationResponse
} from "../../api/scheduling";
import { applySchedulingAvailabilityChoice } from "./schedulingViewModel";
type SchedulingPublicPageProps = {
settings: ApiSettings;
auth: AuthInfo | null;
};
const I18N = {
accessDetails: "i18n:govoplan-scheduling.access_details.79c06b89",
accessHelp: "i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c",
accessRequest: "i18n:govoplan-scheduling.open_scheduling_request.31829cce",
alreadySubmitted: "i18n:govoplan-scheduling.responses_may_be_updated_while_this_request_remains_open.4faecbbe",
answerRequired: "i18n:govoplan-scheduling.choose_availability_for_at_least_one_candidate_slot.28d2111f",
available: "i18n:govoplan-scheduling.available.7c62a142",
backToScheduling: "i18n:govoplan-scheduling.open_in_scheduling.48df1541",
comment: "i18n:govoplan-scheduling.comment.d03495b1",
cancelled: "i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e",
cancellationNoticeUntil: "i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6",
deadline: "i18n:govoplan-scheduling.response_deadline.7fd9e3aa",
email: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
invalidAccess: "i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197",
loading: "i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b",
maybe: "i18n:govoplan-scheduling.maybe.56dd8d0b",
noLongerOpen: "i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a",
password: "i18n:govoplan-scheduling.guest_password.94545e82",
response: "i18n:govoplan-scheduling.your_availability.f86c8215",
saved: "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d",
submit: "i18n:govoplan-scheduling.submit_response.a5f0c053",
unavailable: "i18n:govoplan-scheduling.unavailable.2c9c1f79"
} as const;
function accessPayload(email: string, password: string): SchedulingPublicParticipationAccessPayload {
return {
participant_email: email.trim() || null,
password: password || null
};
}
function initialAvailability(response: SchedulingPublicParticipationResponse): Record<string, SchedulingAvailabilityValue | ""> {
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) ?? ""]));
}
function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return `scheduling-response-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export default function SchedulingPublicPage({ settings, auth }: SchedulingPublicPageProps) {
const { requestId = "", token = "" } = useParams();
const [response, setResponse] = useState<SchedulingPublicParticipationResponse | null>(null);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [comment, setComment] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [accessAttempted, setAccessAttempted] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const slotIds = useMemo(() => response?.slots.map((slot) => slot.id) ?? [], [response]);
const collecting = response?.status === "collecting";
function applyResponse(next: SchedulingPublicParticipationResponse) {
setResponse(next);
setAvailability(initialAvailability(next));
setComment(next.comment ?? "");
setError("");
}
useEffect(() => {
let cancelled = false;
setLoading(true);
setResponse(null);
setAccessAttempted(false);
setError("");
void getPublicSchedulingParticipation(settings, requestId, token, {})
.then((next) => {
if (!cancelled) applyResponse(next);
})
.catch(() => undefined)
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [requestId, settings.apiBaseUrl, settings.apiKey, token]);
async function openRequest(event: FormEvent) {
event.preventDefault();
setLoading(true);
setAccessAttempted(true);
setError("");
try {
applyResponse(await getPublicSchedulingParticipation(settings, requestId, token, accessPayload(email, password)));
} catch {
setResponse(null);
setError(I18N.invalidAccess);
} finally {
setLoading(false);
}
}
async function saveResponse(event: FormEvent) {
event.preventDefault();
if (!response) return;
if (!response.slots.some((slot) => availability[slot.id])) {
setError(I18N.answerRequired);
return;
}
setSaving(true);
setError("");
setSuccess("");
try {
const next = await submitPublicSchedulingParticipation(settings, requestId, token, {
...accessPayload(email, password),
answers: response.slots
.filter((slot) => availability[slot.id])
.map((slot) => ({
slot_id: slot.id,
value: availability[slot.id] as SchedulingAvailabilityValue,
option_revision: slot.revision
})),
comment: response.allow_comments ? comment : null,
idempotency_key: newIdempotencyKey()
});
applyResponse(next);
setSuccess(I18N.saved);
} catch {
setError(I18N.invalidAccess);
} 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.backToScheduling}
</Link>
</div>
)}
{!response && !loading && (
<Card title={I18N.accessDetails}>
<form className="scheduling-public-access-form" onSubmit={openRequest}>
<p className="muted">{I18N.accessHelp}</p>
{accessAttempted && error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
<div className="form-grid two-col">
<FormField label={I18N.email}>
<input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</FormField>
<FormField label={I18N.password}>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</FormField>
</div>
<div className="scheduling-public-actions">
<Button type="submit" variant="primary">{I18N.accessRequest}</Button>
</div>
</form>
</Card>
)}
{response && (
<form className="scheduling-public-content" onSubmit={saveResponse}>
<Card title={response.title}>
{response.description && <p className="scheduling-public-description">{response.description}</p>}
<dl className="scheduling-public-summary">
{response.location && <><dt>i18n:govoplan-scheduling.location.d219c681</dt><dd>{response.location}</dd></>}
{response.deadline_at && <><dt>{I18N.deadline}</dt><dd>{formatDateTime(response.deadline_at)}</dd></>}
</dl>
{response.cancellation_notice_only
? <DismissibleAlert tone="warning" dismissible={false}>{I18N.cancelled}</DismissibleAlert>
: !collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.noLongerOpen}</DismissibleAlert>}
{response.has_response && collecting && <DismissibleAlert tone="info" dismissible={false}>{I18N.alreadySubmitted}</DismissibleAlert>}
{response.cancellation_notice_only && response.cancellation_notice_until && (
<p className="muted">{I18N.cancellationNoticeUntil}: {formatDateTime(response.cancellation_notice_until)}</p>
)}
</Card>
{error && <DismissibleAlert tone="danger">{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success">{success}</DismissibleAlert>}
{!response.cancellation_notice_only && <Card title={I18N.response}>
<div className="scheduling-public-slots">
{response.slots.map((slot) => (
<fieldset className="scheduling-public-slot" key={slot.id} disabled={!collecting || saving}>
<legend>{slot.label}</legend>
<p>{formatDateTime(slot.start_at)} {formatDateTime(slot.end_at)}</p>
{slot.description && <p className="muted">{slot.description}</p>}
{(slot.location || response.location) && <p className="muted">{slot.location || response.location}</p>}
<div className="scheduling-public-choice-group">
{([
["available", I18N.available],
...(response.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}`}
value={value}
checked={availability[slot.id] === value}
onChange={() => setAvailability((current) => applySchedulingAvailabilityChoice(
slotIds,
current,
slot.id,
value,
response.single_choice
))}
/>
<span>{label}</span>
</label>
))}
</div>
</fieldset>
))}
</div>
{response.allow_comments && (
<FormField label={I18N.comment}>
<textarea
rows={4}
maxLength={4000}
disabled={!collecting || saving}
value={comment}
onChange={(event) => setComment(event.target.value)}
/>
</FormField>
)}
{collecting && (
<div className="scheduling-public-actions">
<Button type="submit" variant="primary" disabled={saving}>{I18N.submit}</Button>
</div>
)}
</Card>}
</form>
)}
</LoadingFrame>
</main>
);
}

View File

@@ -1,8 +1,25 @@
import type {
SchedulingAvailabilityValue,
SchedulingParticipant,
SchedulingParticipantPayload,
SchedulingRequest
} from "../../api/scheduling";
import type { PeoplePickerItem } from "@govoplan/core-webui";
const DIRECTORY_SELECTION_METADATA_KEY = "directory_selection";
export type SchedulingParticipantDraft = PeoplePickerItem & {
draftId: string;
sourceId?: string;
revision?: string;
respondent_id?: string | null;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
};
export type SchedulingActor = {
accountId?: string | null;
@@ -25,6 +42,123 @@ export type SchedulingSortPhase =
| "determined"
| "past";
export type SchedulingInvitationActionBlock =
| "participation_policy_unavailable"
| "cancellation_notice_expired"
| "delivery_unavailable"
| "no_delivery_target"
| "no_active_invitation"
| "participant_revision_unavailable";
export type SchedulingInvitationActionBlocks = {
copy: SchedulingInvitationActionBlock | null;
send: SchedulingInvitationActionBlock | null;
revoke: SchedulingInvitationActionBlock | null;
};
type DirectorySelection = {
selection_key?: string;
kind?: PeoplePickerItem["kind"];
reference_id?: string | null;
source_module?: string | null;
source_label?: string | null;
source_revision?: string | null;
};
function directorySelection(metadata?: Record<string, unknown>): DirectorySelection | null {
const value = metadata?.[DIRECTORY_SELECTION_METADATA_KEY];
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as DirectorySelection;
}
function normalizedParticipantType(value: string | null): SchedulingParticipantDraft["participant_type"] {
return value === "internal" || value === "resource" ? value : "external";
}
function selectionMetadata(item: PeoplePickerItem): Record<string, unknown> {
if (item.kind === "external") return {};
return {
[DIRECTORY_SELECTION_METADATA_KEY]: {
selection_key: item.selection_key,
kind: item.kind,
reference_id: item.reference_id ?? null,
source_module: item.source_module ?? null,
source_label: item.source_label ?? null,
source_revision: item.source_revision ?? null
}
};
}
export function participantDraftFromResponse(
participant: SchedulingParticipant,
draftId: string
): SchedulingParticipantDraft {
const selection = directorySelection(participant.metadata);
const kind = selection?.kind ?? (participant.respondent_id ? "account" : "external");
const referenceId = selection?.reference_id
?? (kind === "account" ? participant.respondent_id : null);
const email = participant.email?.trim().toLowerCase() || null;
return {
selection_key: selection?.selection_key
?? (email ? `${kind}:${email}` : `${kind}:participant:${participant.id}`),
kind,
reference_id: referenceId,
display_name: participant.display_name?.trim() || email || "—",
email: email ?? "",
source_module: selection?.source_module ?? null,
source_label: selection?.source_label ?? null,
source_revision: selection?.source_revision ?? null,
draftId,
sourceId: participant.id,
revision: participant.revision ?? undefined,
respondent_id: participant.respondent_id,
participant_type: normalizedParticipantType(participant.participant_type),
required: participant.required ?? true,
metadata: participant.metadata ?? {},
identityLocked: Boolean(participant.poll_invitation_id)
};
}
export function participantDraftsFromPicker(
selected: PeoplePickerItem[],
current: SchedulingParticipantDraft[],
nextDraftId: () => string
): SchedulingParticipantDraft[] {
const currentByKey = new Map(current.map((participant) => [participant.selection_key, participant]));
return selected.map((item) => {
const existing = currentByKey.get(item.selection_key);
if (existing) return existing;
const kind = item.kind === "account" ? "account" : item.kind === "contact" ? "contact" : "external";
return {
...item,
kind,
email: item.email?.trim().toLowerCase() || "",
draftId: nextDraftId(),
respondent_id: kind === "account" ? item.reference_id ?? null : null,
participant_type: kind === "account" ? "internal" : "external",
required: true,
metadata: selectionMetadata(item),
identityLocked: false
};
});
}
export function participantPayload(
participant: SchedulingParticipantDraft
): SchedulingParticipantPayload & { id?: string; revision?: string } {
return {
...(participant.sourceId
? { id: participant.sourceId, revision: participant.revision }
: {}),
respondent_id: participant.respondent_id ?? null,
display_name: participant.display_name.trim() || null,
email: participant.email.trim() || null,
participant_type: participant.participant_type,
required: participant.required,
metadata: participant.metadata ?? {}
};
}
export function schedulingActorIds(actor: SchedulingActor): string[] {
return Array.from(new Set([
actor.accountId,
@@ -148,6 +282,65 @@ export function schedulingRequestIsPast(
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime());
}
export function schedulingInvitationActionBlocks(
request: SchedulingRequest,
participant: SchedulingParticipant,
now = new Date()
): SchedulingInvitationActionBlocks {
if (!participant.revision) {
return {
copy: "participant_revision_unavailable",
send: "participant_revision_unavailable",
revoke: "participant_revision_unavailable"
};
}
const policyAvailable = Boolean(
request.poll_id &&
request.public_participation_policy_enforcement_available === true
);
let issueBlock: SchedulingInvitationActionBlock | null = policyAvailable
? null
: "participation_policy_unavailable";
if (!issueBlock && request.status === "cancelled") {
const noticeUntil = request.cancellation_notice_until
? Date.parse(request.cancellation_notice_until)
: Number.NaN;
if (!Number.isFinite(noticeUntil) || noticeUntil <= now.getTime()) {
issueBlock = "cancellation_notice_expired";
}
}
const respondentId = participant.respondent_id?.trim() ?? "";
const hasDeliveryTarget = Boolean(
participant.email?.trim() ||
(respondentId && !respondentId.startsWith("scheduling-participant:"))
);
const sendBlock = issueBlock
?? (request.participant_invitation_delivery_available === true
? null
: "delivery_unavailable")
?? (hasDeliveryTarget ? null : "no_delivery_target");
const revokeBlock = participant.poll_invitation_id
? (policyAvailable ? null : "participation_policy_unavailable")
: "no_active_invitation";
return { copy: issueBlock, send: sendBlock, revoke: revokeBlock };
}
export function schedulingPublicInvitationUrl(
actionUrl: string,
applicationOrigin: string
): string | null {
try {
const origin = new URL(applicationOrigin);
const url = new URL(actionUrl, origin);
if (url.origin !== origin.origin || !url.pathname.startsWith("/scheduling/public/")) return null;
return url.toString();
} catch {
return null;
}
}
export function applySchedulingAvailabilityChoice(
slotIds: string[],
current: Record<string, SchedulingAvailabilityValue | "">,

View File

@@ -1,9 +1,39 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
"i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79": "Copy a fresh invitation link for {value0}",
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Enter the details supplied with the invitation. For privacy, invalid and expired links use the same response.",
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Loading scheduling request…",
"i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306": "Invitation delivery failed. The link was created but was not delivered.",
"i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba": "Invitation delivery requested.",
"i18n:govoplan-scheduling.invitation_link_copied.332973ec": "Invitation link copied.",
"i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4": "Invitation link revoked.",
"i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc": "No active invitation link to revoke.",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "Open in Scheduling",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Open scheduling request",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Response deadline",
"i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4": "Reload the request before changing this invitation.",
"i18n:govoplan-scheduling.participant.554f4235": "Participant",
"i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf": "Revoke invitation link",
"i18n:govoplan-scheduling.revoke_link.da371ee1": "Revoke link",
"i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817": "Revoke the current invitation link for {value0}? It will stop working immediately.",
"i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa": "Revoke the invitation link for {value0}",
"i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea": "Send a fresh invitation to {value0}",
"i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c": "The cancellation notice has expired; a new link cannot be issued.",
"i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc": "The invitation link could not be copied. Check browser clipboard permissions and try again.",
"i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180": "This participant has no deliverable email address or account.",
"i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533": "This invitation changed. The request was reloaded; try again.",
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "This scheduling link is invalid, expired, or the access details do not match.",
"i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e": "This scheduling request was cancelled.",
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "This scheduling request is no longer accepting responses.",
"i18n:govoplan-scheduling.your_availability.f86c8215": "Your availability",
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Save or discard your unsent availability changes before leaving.",
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "A slot can be selected after the request is closed.",
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.",
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Allow comments",
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Allow external participants",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity",
"i18n:govoplan-scheduling.comment.d03495b1": "Comment",
@@ -26,6 +56,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Password-protect guest access",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "People responding without an account must provide an email address.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "People who are not signed in must enter this password before viewing the request.",
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Search accounts and contacts you are allowed to discover. If external participants are enabled, you can also add a name and email address manually.",
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "When enabled, people outside the configured accounts and contacts can be added manually.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Provide a Maybe option",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "Require an email address from guests",
"i18n:govoplan-scheduling.responses.3427e3ab": "Responses",
@@ -140,10 +172,40 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
de: {
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",
"i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79": "Einen neuen Einladungslink für {value0} kopieren",
"i18n:govoplan-scheduling.enter_the_details_supplied_with_the_invitation_for_privacy.81ba419c": "Geben Sie die mit der Einladung übermittelten Daten ein. Aus Datenschutzgründen wird für ungültige und abgelaufene Links dieselbe Meldung angezeigt.",
"i18n:govoplan-scheduling.loading_scheduling_request.43c39c1b": "Terminanfrage wird geladen …",
"i18n:govoplan-scheduling.invitation_delivery_failed_the_link_was_created_but_was_not_delivered.8db0c306": "Die Zustellung der Einladung ist fehlgeschlagen. Der Link wurde erstellt, aber nicht zugestellt.",
"i18n:govoplan-scheduling.invitation_delivery_requested.1aaa78ba": "Die Zustellung der Einladung wurde angefordert.",
"i18n:govoplan-scheduling.invitation_link_copied.332973ec": "Einladungslink kopiert.",
"i18n:govoplan-scheduling.invitation_link_revoked.c7dd20d4": "Einladungslink widerrufen.",
"i18n:govoplan-scheduling.no_active_invitation_link_to_revoke.4ad0f0cc": "Kein aktiver Einladungslink zum Widerrufen vorhanden.",
"i18n:govoplan-scheduling.open_in_scheduling.48df1541": "In der Terminplanung öffnen",
"i18n:govoplan-scheduling.open_scheduling_request.31829cce": "Terminanfrage öffnen",
"i18n:govoplan-scheduling.response_deadline.7fd9e3aa": "Antwortfrist",
"i18n:govoplan-scheduling.reload_the_request_before_changing_this_invitation.9e685df4": "Laden Sie die Anfrage neu, bevor Sie diese Einladung ändern.",
"i18n:govoplan-scheduling.participant.554f4235": "Teilnehmende Person",
"i18n:govoplan-scheduling.revoke_invitation_link.87bf89cf": "Einladungslink widerrufen",
"i18n:govoplan-scheduling.revoke_link.da371ee1": "Link widerrufen",
"i18n:govoplan-scheduling.revoke_the_current_invitation_link_for_value0_it_will_stop_working_immediately.3cdc5817": "Den aktuellen Einladungslink für {value0} widerrufen? Er funktioniert danach sofort nicht mehr.",
"i18n:govoplan-scheduling.revoke_the_invitation_link_for_value0.15a9c9fa": "Den Einladungslink für {value0} widerrufen",
"i18n:govoplan-scheduling.send_a_fresh_invitation_to_value0.fd8d9dea": "Eine neue Einladung an {value0} senden",
"i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c": "Der Stornierungshinweis ist abgelaufen; ein neuer Link kann nicht ausgestellt werden.",
"i18n:govoplan-scheduling.the_invitation_link_could_not_be_copied_check_browser_clipboard_permissions_and_try_again.a8b17cbc": "Der Einladungslink konnte nicht kopiert werden. Prüfen Sie die Zwischenablageberechtigungen des Browsers und versuchen Sie es erneut.",
"i18n:govoplan-scheduling.this_participant_has_no_deliverable_email_address_or_account.dbe14180": "Für diese teilnehmende Person ist weder eine zustellbare E-Mail-Adresse noch ein Konto hinterlegt.",
"i18n:govoplan-scheduling.this_invitation_changed_the_request_was_reloaded_try_again.c7095533": "Diese Einladung wurde zwischenzeitlich geändert. Die Anfrage wurde neu geladen; versuchen Sie es erneut.",
"i18n:govoplan-scheduling.this_scheduling_link_is_invalid_expired_or_the_access_details.8e7aa197": "Dieser Terminlink ist ungültig oder abgelaufen, oder die Zugangsdaten stimmen nicht überein.",
"i18n:govoplan-scheduling.this_scheduling_request_was_cancelled.1af3c85e": "Diese Terminanfrage wurde storniert.",
"i18n:govoplan-scheduling.this_scheduling_request_is_no_longer_accepting_responses.c612e78a": "Diese Terminanfrage nimmt keine Antworten mehr an.",
"i18n:govoplan-scheduling.your_availability.f86c8215": "Ihre Verfügbarkeit",
"i18n:govoplan-scheduling.save_or_discard_your_unsent_availability_changes_before_leaving.97e10df1": "Speichern oder verwerfen Sie Ihre noch nicht gesendeten Verfügbarkeitsänderungen, bevor Sie die Ansicht verlassen.",
"i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d": "Ein Terminvorschlag kann ausgewählt werden, nachdem die Anfrage geschlossen wurde.",
"i18n:govoplan-scheduling.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.",
"i18n:govoplan-scheduling.allow_comments.d63202a6": "Kommentare erlauben",
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Externe Teilnehmende erlauben",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität",
"i18n:govoplan-scheduling.comment.d03495b1": "Kommentar",
@@ -166,6 +228,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.",
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Suchen Sie nach Konten und Kontakten, die Sie sehen dürfen. Wenn externe Teilnehmende erlaubt sind, können Sie Name und E-Mail-Adresse auch manuell hinzufügen.",
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "Wenn diese Option aktiviert ist, können Personen außerhalb der eingerichteten Konten und Kontakte manuell hinzugefügt werden.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",

View File

@@ -4,19 +4,27 @@ import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/scheduling.css";
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const scheduleRead = ["scheduling:schedule:read"];
export const schedulingModule: PlatformWebModule = {
id: "scheduling",
label: "Scheduling",
version: "0.1.10",
version: "0.1.11",
dependencies: ["poll"],
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations,
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
],
publicRoutes: [
{
path: "/scheduling/public/:requestId/:token",
order: 10,
render: ({ settings, auth }) => createElement(SchedulingPublicPage, { settings, auth })
}
]
};

View File

@@ -7,6 +7,110 @@
background: var(--bg);
}
.scheduling-public-page {
width: min(920px, calc(100% - 32px));
margin: 0 auto;
padding: 24px 0 48px;
}
.scheduling-public-page .loading-frame {
min-height: 240px;
}
.scheduling-public-content,
.scheduling-public-access-form,
.scheduling-public-slots {
display: grid;
gap: 14px;
}
.scheduling-public-deep-link,
.scheduling-public-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.scheduling-public-actions {
margin: 0;
}
.scheduling-public-description {
white-space: pre-wrap;
}
.scheduling-public-summary {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 6px 14px;
margin: 12px 0;
}
.scheduling-public-summary dt {
color: var(--muted);
}
.scheduling-public-summary dd {
margin: 0;
}
.scheduling-public-slot {
min-width: 0;
margin: 0;
padding: 14px;
border: var(--border-line);
border-radius: var(--radius-md);
}
.scheduling-public-slot legend {
padding: 0 4px;
color: var(--text-strong);
font-weight: 650;
}
.scheduling-public-slot p {
margin: 4px 0;
}
.scheduling-public-choice-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.scheduling-public-choice-group label {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
padding: 7px 12px;
border: var(--border-line);
border-radius: var(--radius-md);
background: var(--panel-soft);
cursor: pointer;
}
.scheduling-public-choice-group label:has(input:checked) {
border-color: var(--accent);
background: var(--accent-soft);
}
@media (max-width: 680px) {
.scheduling-public-page {
width: min(100% - 20px, 920px);
padding-top: 12px;
}
.scheduling-public-page .form-grid.two-col {
grid-template-columns: 1fr;
}
.scheduling-public-summary {
grid-template-columns: 1fr;
}
}
.scheduling-page *,
.scheduling-page *::before,
.scheduling-page *::after {

View File

@@ -4,10 +4,103 @@ import type { SchedulingRequest } from "../src/api/scheduling.ts";
import {
applySchedulingAvailabilityChoice,
groupSchedulingRequests,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingPublicInvitationUrl,
schedulingSortPhase,
type SchedulingActor
} from "../src/features/scheduling/schedulingViewModel.ts";
test("maps visible account and contact selections into bounded scheduling participant payloads", () => {
let sequence = 0;
const selected = participantDraftsFromPicker([
{
selection_key: "account:account-2",
kind: "account",
reference_id: "account-2",
display_name: "Ada Account",
email: "ADA@EXAMPLE.TEST",
source_module: "access",
source_label: "Accounts",
provenance: { tenant_id: "must-not-be-persisted" },
metadata: { group_ids: ["must-not-be-persisted"] }
},
{
selection_key: "contact:contact-3:contact@example.test",
kind: "contact",
reference_id: "contact-3",
display_name: "Contact Person",
email: "contact@example.test",
source_module: "addresses",
source_label: "Contacts",
source_revision: "revision-3",
provenance: { address_book_id: "must-not-be-persisted" }
}
], [], () => `participant-${++sequence}`);
assert.equal(selected[0].respondent_id, "account-2");
assert.equal(selected[0].participant_type, "internal");
assert.equal(selected[0].email, "ada@example.test");
assert.deepEqual(selected[0].metadata, {
directory_selection: {
selection_key: "account:account-2",
kind: "account",
reference_id: "account-2",
source_module: "access",
source_label: "Accounts",
source_revision: null
}
});
assert.equal(selected[1].respondent_id, null);
assert.equal(selected[1].participant_type, "external");
assert.equal(
(selected[1].metadata?.directory_selection as { source_revision: string }).source_revision,
"revision-3"
);
assert.deepEqual(participantPayload(selected[1]), {
respondent_id: null,
display_name: "Contact Person",
email: "contact@example.test",
participant_type: "external",
required: true,
metadata: selected[1].metadata
});
});
test("reconstructs saved directory selections and preserves existing reconciliation identity", () => {
const responseParticipant = {
id: "stored-participant",
revision: "a".repeat(64),
is_current_participant: false,
respondent_id: "account-2",
display_name: "Ada Account",
email: "ada@example.test",
participant_type: "internal",
required: true,
status: "invited",
poll_invitation_id: "invitation-1",
metadata: {
directory_selection: {
selection_key: "account:account-2",
kind: "account",
reference_id: "account-2",
source_module: "access",
source_label: "Accounts"
}
}
};
const draft = participantDraftFromResponse(responseParticipant, "draft-1");
const remapped = participantDraftsFromPicker([draft], [draft], () => "unexpected");
assert.equal(remapped[0], draft);
assert.equal(draft.sourceId, "stored-participant");
assert.equal(draft.identityLocked, true);
assert.equal(participantPayload(draft).id, "stored-participant");
assert.equal(participantPayload(draft).revision, "a".repeat(64));
});
const now = new Date("2026-07-20T10:00:00Z");
const actor: SchedulingActor = {
accountId: "account-1",
@@ -57,6 +150,7 @@ function request(
anonymous_password_protection_enabled: false,
public_participation_policy_enforcement_available: false,
public_participation_policy_enforcement_reason: "Public participation gateway not installed",
participant_invitation_delivery_available: false,
calendar_integration_enabled: false,
calendar_freebusy_enabled: false,
calendar_hold_enabled: false,
@@ -158,6 +252,105 @@ test("orders unanswered by nearest slot before answered, closed, determined, and
assert.equal(schedulingSortPhase(groups.invited.at(-1)!, actor, now), "past");
});
test("derives stable invitation action blocks from policy, delivery, and participant state", () => {
const managed = request("invitation-actions", { participantStatus: "invited" });
managed.poll_id = "poll-1";
managed.public_participation_policy_enforcement_available = true;
managed.public_participation_policy_enforcement_reason = null;
managed.participant_invitation_delivery_available = true;
const participant = managed.participants[0];
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
copy: "participant_revision_unavailable",
send: "participant_revision_unavailable",
revoke: "participant_revision_unavailable"
});
participant.revision = "a".repeat(64);
participant.poll_invitation_id = "invitation-1";
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
copy: null,
send: null,
revoke: null
});
managed.participant_invitation_delivery_available = false;
assert.equal(
schedulingInvitationActionBlocks(managed, participant, now).send,
"delivery_unavailable"
);
managed.participant_invitation_delivery_available = true;
participant.email = null;
participant.respondent_id = `scheduling-participant:${participant.id}`;
assert.equal(
schedulingInvitationActionBlocks(managed, participant, now).send,
"no_delivery_target"
);
managed.public_participation_policy_enforcement_available = false;
assert.deepEqual(schedulingInvitationActionBlocks(managed, participant, now), {
copy: "participation_policy_unavailable",
send: "participation_policy_unavailable",
revoke: "participation_policy_unavailable"
});
participant.poll_invitation_id = null;
assert.equal(
schedulingInvitationActionBlocks(managed, participant, now).revoke,
"no_active_invitation"
);
});
test("allows bounded cancelled-request links until the notice expires without blocking revocation", () => {
const cancelled = request("cancelled-invitation", {
participantStatus: "invited",
status: "cancelled"
});
cancelled.poll_id = "poll-1";
cancelled.public_participation_policy_enforcement_available = true;
cancelled.participant_invitation_delivery_available = true;
cancelled.cancellation_notice_until = "2026-07-20T11:00:00Z";
cancelled.participants[0].revision = "b".repeat(64);
cancelled.participants[0].poll_invitation_id = "invitation-1";
assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), {
copy: null,
send: null,
revoke: null
});
cancelled.cancellation_notice_until = "2026-07-20T09:00:00Z";
assert.deepEqual(schedulingInvitationActionBlocks(cancelled, cancelled.participants[0], now), {
copy: "cancellation_notice_expired",
send: "cancellation_notice_expired",
revoke: null
});
});
test("accepts only same-origin Scheduling public invitation URLs", () => {
assert.equal(
schedulingPublicInvitationUrl(
"/scheduling/public/request-1/token-1",
"https://govoplan.example"
),
"https://govoplan.example/scheduling/public/request-1/token-1"
);
assert.equal(
schedulingPublicInvitationUrl(
"https://attacker.example/scheduling/public/request-1/token-1",
"https://govoplan.example"
),
null
);
assert.equal(
schedulingPublicInvitationUrl(
"/scheduling/publicity/request-1/token-1",
"https://govoplan.example"
),
null
);
});
test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
const next = applySchedulingAvailabilityChoice(
["slot-a", "slot-b", "slot-c"],