19 Commits

Author SHA1 Message Date
9b029c8edf Refactor scheduling reconciliation into change plans 2026-07-29 17:21:13 +02:00
b9f557e95b perf: batch scheduling reconciliation and add widget 2026-07-29 14:16:29 +02:00
60b01e5a16 Declare scheduling route View surface 2026-07-28 21:04:55 +02:00
ffb30a7aa3 fix(scheduling-webui): keep invitation delivery explicit 2026-07-22 04:37:08 +02:00
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
28 changed files with 5401 additions and 1078 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 Typical workflow steps are collect availability, rank candidates, decide, notify
participants, and hand off to Calendar or Appointments. participants, and hand off to Calendar or Appointments.
The manifest declares `access` and `evaluation` as optional dependencies. The manifest declares `access`, `addresses`, and `evaluation` as optional
Scheduling may use Access for identity, groups, and permissions, and may trigger dependencies. Scheduling uses Core's principal-aware people-search boundary to
post-event or post-appointment feedback through Evaluation. It must not require combine only the account and contact records visible to the current organizer;
either module just to find a meeting time. 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 ## Expected Integrations
@@ -98,6 +100,22 @@ either module just to find a meeting time.
- `govoplan-portal`: external participant scheduling flows - `govoplan-portal`: external participant scheduling flows
- `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected - `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 ## First Package Scaffold Decision
The first backend implementation slice adds runtime APIs and storage around 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 - request lifecycle APIs: draft, collecting, closed, decided, handed off, cancelled
- result summaries sourced from Poll response aggregation - result summaries sourced from Poll response aggregation
- optional Calendar free/busy checks, tentative holds, and final event creation - 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 - a first Scheduling WebUI package with request creation, slot matrix, Calendar
actions, decisions, and notification-job creation actions, decisions, and notification-job creation
The next slices should add real notification delivery workers, richer public The next slices should add generic self-enrolment links after their abuse and
participant pages, Calendar hold cleanup after decision, and advanced scoring identity policy is agreed, Calendar hold cleanup after decision, and advanced
constraints such as required participants and quorum rules. scoring constraints such as required participants and quorum rules.
The active backlog lives in Gitea issues. 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 replacement token crosses the PATCH response, and existing responses plus
participant status remain attached to the same durable respondent identity. participant status remain attached to the same durable respondent identity.
Open lifecycle decision: cancellation closes the backing Poll, so submission Cancellation closes the backing Poll, so submission fails, while active links
fails, but an otherwise valid invitation can still resolve the reduced public remain usable as a reduced cancellation notice for a bounded period. The
view and show that the request was cancelled. Decide whether cancellation deployment setting `SCHEDULING_CANCELLATION_NOTICE_DAYS` defaults to 30 and is
should revoke links immediately or retain that acknowledgement view for a bounded to 190 days. Cancellation transactionally aligns governed invitation
bounded period; links without a deadline would otherwise remain readable. 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 If the governed capability is absent, API responses advertise that policy
enforcement is unavailable and restricted links fail closed. Plaintext 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 email, response settings, aggregates, and any roster names/statuses permitted
by the configured privacy policy. by the configured privacy policy.
The WebUI package exposes typed clients for the public access and submission The WebUI package registers `/scheduling/public/{request}/{token}` through
endpoints. A signed-out browser page cannot yet be registered by a module: Core's explicit, backend-allowlisted public-route contract. The guest page uses
Core's `App` renders `PublicLandingPage` directly whenever `auth` is absent and the shared UI components, prompts for email/password only when needed, prefills
only mounts module route contributions inside the authenticated branch. Until an existing response, and enforces the snapshotted response rules. The token
Core gains an explicit, allowlisted `publicRoutes` contract, notification action stays in the path and is never copied into query parameters or browser storage.
URLs under `/scheduling/public/{request}/{token}` must be treated as a blocked Signed-in users also receive an in-app deep link without weakening the signed
frontend handoff rather than a working guest page. The token is never moved into guest-link boundary.
query parameters, browser storage, or an authenticated API contract while that
shell boundary is unresolved.
Draft saves never issue public tokens or enqueue invitation delivery, even when Creating, editing, and opening a request never issue public tokens or enqueue
`create_participant_invitations` is left at its compatibility default. A invitation delivery. The deprecated `create_participant_invitations` request
collecting request may explicitly issue invitations; authenticated in-module field remains accepted for compatibility, defaults to `false`, and has no side
responses lazily create a gateway-bound invitation and discard its token. The effect. Authenticated in-module responses can still lazily create a
draft-to-open transition therefore supports authenticated lazy responses, but gateway-bound invitation whose token is discarded.
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 Organizers and Scheduling administrators use the participant-specific
links after a draft is opened and, when Mail is installed, whether that action invitation action instead:
should also enqueue delivery or return links for separate distribution. Until
that workflow is agreed, opening a draft does not silently send anything. - `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 ## FieldLabel omission register

View File

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

View File

@@ -2,4 +2,4 @@
__all__ = ["__version__"] __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) 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) 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) 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) 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) metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True)

View File

@@ -8,6 +8,7 @@ from govoplan_core.core.module_guards import drop_table_retirement_provider, per
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
FrontendModule, FrontendModule,
FrontendRoute,
MigrationSpec, MigrationSpec,
ModuleContext, ModuleContext,
ModuleInterfaceProvider, ModuleInterfaceProvider,
@@ -15,17 +16,23 @@ from govoplan_core.core.modules import (
ModuleManifest, ModuleManifest,
NavItem, NavItem,
PermissionDefinition, PermissionDefinition,
PublicFrontendRoute,
RoleTemplate, 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 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_core.core.policy import CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY
from govoplan_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata from govoplan_scheduling.backend.db import models as scheduling_models # noqa: F401 - populate Scheduling ORM metadata
MODULE_ID = "scheduling" MODULE_ID = "scheduling"
MODULE_NAME = "Scheduling" MODULE_NAME = "Scheduling"
MODULE_VERSION = "0.1.10" MODULE_VERSION = "0.1.11"
READ_SCOPE = "scheduling:schedule:read" READ_SCOPE = "scheduling:schedule:read"
WRITE_SCOPE = "scheduling:schedule:write" WRITE_SCOPE = "scheduling:schedule:write"
ADMIN_SCOPE = "scheduling:schedule:admin" ADMIN_SCOPE = "scheduling:schedule:admin"
@@ -137,6 +144,8 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_CALENDAR_SCHEDULING, CAPABILITY_CALENDAR_SCHEDULING,
CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY,
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
), ),
required_capabilities=( required_capabilities=(
CAPABILITY_POLL_SCHEDULING, CAPABILITY_POLL_SCHEDULING,
@@ -147,13 +156,15 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION), ModuleInterfaceProvider(name="scheduling.decision_handoff", version=MODULE_VERSION),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement(name="poll.availability_matrix", 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.response_collection", version_min="0.1.10", 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.workflow_context", version_min="0.1.10", 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.governed_participation", version_min="0.1.10", 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="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="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), ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
@@ -162,7 +173,31 @@ manifest = ModuleManifest(
frontend=FrontendModule( frontend=FrontendModule(
module_id=MODULE_ID, module_id=MODULE_ID,
package_name="@govoplan/scheduling-webui", package_name="@govoplan/scheduling-webui",
routes=(
FrontendRoute(
path="/scheduling",
component="SchedulingPage",
required_any=(READ_SCOPE,),
order=56,
),
),
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),), nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
view_surfaces=(
ViewSurface(
id="scheduling.widget.open-requests",
module_id=MODULE_ID,
kind="section",
label="Scheduling requests widget",
order=45,
),
),
), ),
route_factory=_scheduling_router, route_factory=_scheduling_router,
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),

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 from __future__ import annotations
import dataclasses
from typing import Any 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 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.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.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_core.db.session import get_session
from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE
from govoplan_scheduling.backend.schemas import ( from govoplan_scheduling.backend.schemas import (
SchedulingAddressLookupCandidate,
SchedulingAddressLookupResponse,
SchedulingAvailabilityResponse, SchedulingAvailabilityResponse,
SchedulingAvailabilityResponseRequest, SchedulingAvailabilityResponseRequest,
SchedulingCalendarActionResponse, SchedulingCalendarActionResponse,
SchedulingCandidateSlotUpdateRequest, SchedulingCandidateSlotUpdateRequest,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationActionResponse,
SchedulingInvitationRevokeRequest,
SchedulingNotificationCreateRequest, SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse, SchedulingNotificationListResponse,
SchedulingNotificationResponse, SchedulingNotificationResponse,
SchedulingPeopleSearchCandidate,
SchedulingPeopleSearchGroup,
SchedulingPeopleSearchResponse,
SchedulingRequestCreateRequest, SchedulingRequestCreateRequest,
SchedulingRequestListResponse, SchedulingRequestListResponse,
SchedulingRequestResponse, SchedulingRequestResponse,
@@ -52,53 +57,21 @@ from govoplan_scheduling.backend.service import (
get_visible_scheduling_request, get_visible_scheduling_request,
list_visible_scheduling_notifications, list_visible_scheduling_notifications,
list_visible_scheduling_requests, list_visible_scheduling_requests,
issue_scheduling_participant_invitation,
open_scheduling_request, open_scheduling_request,
refresh_participant_response_state,
require_visible_scheduling_results, require_visible_scheduling_results,
revoke_scheduling_participant_invitation,
scheduling_notification_response, scheduling_notification_response,
scheduling_request_response, scheduling_request_response,
scheduling_request_summary, scheduling_request_summary,
submit_scheduling_availability, submit_scheduling_availability,
submit_public_scheduling_participation, submit_public_scheduling_participation,
update_scheduling_candidate_slot, update_scheduling_candidate_slot,
update_scheduling_request_with_invitation_tokens, update_scheduling_request_with_change_log,
) )
router = APIRouter(prefix="/scheduling", tags=["scheduling"]) 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: def _require_scope(principal: ApiPrincipal, scope: str) -> None:
@@ -186,16 +159,41 @@ def _client_address(request: Request) -> str | None:
return request.client.host if request.client is not None else 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( def _request_response(
request, request,
*, *,
principal: ApiPrincipal, principal: ApiPrincipal,
invitation_tokens: dict[str, str] | None = None,
) -> SchedulingRequestResponse: ) -> SchedulingRequestResponse:
return SchedulingRequestResponse.model_validate( return SchedulingRequestResponse.model_validate(
scheduling_request_response( scheduling_request_response(
request, request,
invitation_tokens=invitation_tokens,
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
actor_user_id=principal.account_id, actor_user_id=principal.account_id,
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
@@ -255,27 +253,50 @@ def api_submit_public_scheduling_participation(
return validated return validated
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse) @router.get("/people", response_model=SchedulingPeopleSearchResponse)
def api_lookup_scheduling_addresses( def api_search_scheduling_people(
query: str = Query(min_length=1), query: str = Query(min_length=1),
limit: int = Query(default=25, ge=1, le=100), limit: int = Query(default=25, ge=1, le=100),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingAddressLookupResponse: ) -> SchedulingPeopleSearchResponse:
_require_scheduling_writer(principal) _require_scheduling_writer(principal)
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP) groups = search_visible_people(
if capability is None or not hasattr(capability, "lookup"): get_registry(),
return SchedulingAddressLookupResponse(available=False, candidates=[]) session,
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit) principal,
return SchedulingAddressLookupResponse( query=query,
available=True, limit=limit,
candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates], )
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
]
) )
@router.get("/requests", response_model=SchedulingRequestListResponse) @router.get("/requests", response_model=SchedulingRequestListResponse)
def api_list_scheduling_requests( def api_list_scheduling_requests(
status_filter: str | None = Query(default=None, alias="status"), status_filter: str | None = Query(default=None, alias="status"),
limit: int = 100,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> SchedulingRequestListResponse: ) -> SchedulingRequestListResponse:
@@ -286,21 +307,11 @@ def api_list_scheduling_requests(
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
status=status_filter, status=status_filter,
limit=limit,
) )
actor_ids = _principal_actor_ids(principal) return SchedulingRequestListResponse(
for request in requests:
refresh_participant_response_state(
session,
request=request,
actor_ids=actor_ids,
)
response = SchedulingRequestListResponse(
requests=[_request_response(request, principal=principal) for request in requests] requests=[_request_response(request, principal=principal) for request in requests]
) )
# Poll responses are authoritative, while Scheduling keeps a durable
# participant projection used by its task-oriented list.
session.commit()
return response
@router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED) @router.post("/requests", response_model=SchedulingRequestResponse, status_code=status.HTTP_201_CREATED)
@@ -311,7 +322,7 @@ def api_create_scheduling_request(
) -> SchedulingRequestResponse: ) -> SchedulingRequestResponse:
_require_scheduling_writer(principal) _require_scheduling_writer(principal)
try: try:
request, invitation_tokens = create_scheduling_request( request, _invitation_tokens = create_scheduling_request(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
user_id=principal.account_id, user_id=principal.account_id,
@@ -319,7 +330,7 @@ def api_create_scheduling_request(
) )
except SchedulingError as exc: except SchedulingError as exc:
raise _scheduling_http_error(exc) from 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() session.commit()
return response return response
@@ -386,16 +397,9 @@ def api_get_scheduling_request(
actor_ids=_principal_actor_ids(principal), actor_ids=_principal_actor_ids(principal),
can_manage=_can_manage_scheduling(principal), can_manage=_can_manage_scheduling(principal),
) )
refresh_participant_response_state(
session,
request=request,
actor_ids=_principal_actor_ids(principal),
)
except SchedulingError as exc: except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc raise _scheduling_http_error(exc) from exc
response = _request_response(request, principal=principal) return _request_response(request, principal=principal)
session.commit()
return response
@router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse) @router.patch("/requests/{request_id}", response_model=SchedulingRequestResponse)
@@ -411,7 +415,7 @@ def api_update_scheduling_request(
request_id=request_id, request_id=request_id,
) )
try: try:
request, invitation_tokens = update_scheduling_request_with_invitation_tokens( request, participant_mutations = update_scheduling_request_with_change_log(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
request_id=request_id, request_id=request_id,
@@ -419,15 +423,153 @@ def api_update_scheduling_request(
) )
except SchedulingError as exc: except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc raise _scheduling_http_error(exc) from exc
response = _request_response( for mutation in participant_mutations:
request, _audit_invitation_action(
session,
principal=principal, principal=principal,
invitation_tokens=invitation_tokens, 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() session.commit()
return response 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) @router.patch("/requests/{request_id}/slots/{slot_id}", response_model=SchedulingRequestResponse)
def api_update_scheduling_candidate_slot( def api_update_scheduling_candidate_slot(
request_id: str, request_id: str,

View File

@@ -132,6 +132,20 @@ class SchedulingCandidateSlotReconcileInput(SchedulingCandidateSlotInput):
class SchedulingParticipantReconcileInput(SchedulingParticipantInput): class SchedulingParticipantReconcileInput(SchedulingParticipantInput):
id: str | None = Field(default=None, max_length=36) 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): class SchedulingRequestCreateRequest(BaseModel):
@@ -158,7 +172,14 @@ class SchedulingRequestCreateRequest(BaseModel):
calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences) calendar: SchedulingCalendarPreferences = Field(default_factory=SchedulingCalendarPreferences)
slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1) slots: list[SchedulingCandidateSlotInput] = Field(default_factory=list, min_length=1)
participants: list[SchedulingParticipantInput] = Field(default_factory=list) 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) metadata: dict[str, Any] = Field(default_factory=dict)
_validate_timezone = field_validator("timezone")(_known_timezone) _validate_timezone = field_validator("timezone")(_known_timezone)
@@ -197,7 +218,14 @@ class SchedulingRequestUpdateRequest(BaseModel):
min_length=1, min_length=1,
) )
participants: list[SchedulingParticipantReconcileInput] | None = None 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 metadata: dict[str, Any] | None = None
@model_validator(mode="after") @model_validator(mode="after")
@@ -238,6 +266,7 @@ class SchedulingCandidateSlotResponse(BaseModel):
class SchedulingParticipantResponse(BaseModel): class SchedulingParticipantResponse(BaseModel):
id: str id: str
revision: str | None = None
is_current_participant: bool = False is_current_participant: bool = False
respondent_id: str | None = None respondent_id: str | None = None
display_name: str | None = None display_name: str | None = None
@@ -291,6 +320,7 @@ class SchedulingRequestResponse(BaseModel):
anonymous_password_protection_enabled: bool anonymous_password_protection_enabled: bool
public_participation_policy_enforcement_available: bool | None = None public_participation_policy_enforcement_available: bool | None = None
public_participation_policy_enforcement_reason: str | None = None public_participation_policy_enforcement_reason: str | None = None
participant_invitation_delivery_available: bool | None = None
effective_participant_visibility: SchedulingParticipantVisibility effective_participant_visibility: SchedulingParticipantVisibility
participant_aggregate: SchedulingParticipantAggregateResponse participant_aggregate: SchedulingParticipantAggregateResponse
participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse participant_visibility_decision: SchedulingParticipantVisibilityDecisionResponse
@@ -302,6 +332,7 @@ class SchedulingRequestResponse(BaseModel):
calendar_event_id: str | None = None calendar_event_id: str | None = None
handed_off_at: datetime | None = None handed_off_at: datetime | None = None
cancelled_at: datetime | None = None cancelled_at: datetime | None = None
cancellation_notice_until: datetime | None = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
@@ -409,6 +440,9 @@ class SchedulingPublicParticipationResponse(BaseModel):
timezone: str timezone: str
status: str status: str
deadline_at: datetime | None = None 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 participant_email_required: bool
anonymous_password_required: bool anonymous_password_required: bool
single_choice: bool single_choice: bool
@@ -483,21 +517,68 @@ class SchedulingNotificationCreateRequest(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
class SchedulingAddressLookupCandidate(BaseModel): class SchedulingInvitationActionRequest(BaseModel):
contact_id: str """Explicitly issue one fresh participant-specific participation link."""
address_book_id: str
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 display_name: str
email: str | None = None email: str | None = None
email_label: str | None = None source_module: str | None = None
organization: str | None = None source_label: 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_revision: str | None = None source_revision: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict) description: str | None = None
class SchedulingAddressLookupResponse(BaseModel): class SchedulingPeopleSearchGroup(BaseModel):
available: bool = False key: str
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list) 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("poll.scheduling", manifest.required_capabilities)
self.assertIn("calendar.scheduling", manifest.optional_capabilities) self.assertIn("calendar.scheduling", manifest.optional_capabilities)
self.assertIn("policy.schedulingParticipantPrivacy", 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.assertIn("evaluation", manifest.optional_dependencies)
self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.route_factory)
self.assertIsNotNone(manifest.migration_spec) self.assertIsNotNone(manifest.migration_spec)
self.assertIsNotNone(manifest.frontend) 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.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.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.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("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("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}) self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces})
required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces} required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces}
for interface_name in ( for interface_name in (
@@ -43,7 +50,7 @@ class SchedulingManifestTests(unittest.TestCase):
"poll.workflow_context", "poll.workflow_context",
"poll.governed_participation", "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__": 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 from govoplan_scheduling.backend.manifest import get_manifest as get_scheduling_manifest
_SCHEDULING_HEAD = "be8f4d2c1a70" _SCHEDULING_HEAD = "c9d4e7f1a2b3"
_SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10" _SCHEDULING_RESPONSE_SETTINGS_REVISION = "ad7e3c9b2f10"
_ENABLED_MODULES = ("poll", "scheduling") _ENABLED_MODULES = ("poll", "scheduling")
_MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest) _MANIFEST_FACTORIES = (get_poll_manifest, get_scheduling_manifest)
@@ -146,6 +146,7 @@ class SchedulingMigrationTests(unittest.TestCase):
self.assertIn(_SCHEDULING_HEAD, heads) self.assertIn(_SCHEDULING_HEAD, heads)
self.assertIn("participant_visibility", columns) self.assertIn("participant_visibility", columns)
self.assertIn("cancellation_notice_until", columns)
self.assertIn("max_participants_per_option", columns) self.assertIn("max_participants_per_option", columns)
self.assertIn("response_comment", participant_columns) self.assertIn("response_comment", participant_columns)
self.assertIn("participation_gateway", participant_columns) self.assertIn("participation_gateway", participant_columns)

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 import unittest
from datetime import datetime, timezone from datetime import datetime, timezone
from unittest.mock import patch
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -184,6 +185,10 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None: def test_secure_default_returns_own_row_and_aggregate_counts(self) -> None:
request = self._request() request = self._request()
with patch(
"govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=object(),
):
payload = self._participant_projection(request) payload = self._participant_projection(request)
response = SchedulingRequestResponse.model_validate(payload) response = SchedulingRequestResponse.model_validate(payload)
@@ -212,6 +217,7 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
self.assertIsNone(response.create_calendar_event_on_decision) self.assertIsNone(response.create_calendar_event_on_decision)
self.assertIsNone(response.calendar_event_id) self.assertIsNone(response.calendar_event_id)
self.assertIsNone(response.public_participation_policy_enforcement_available) self.assertIsNone(response.public_participation_policy_enforcement_available)
self.assertIsNone(response.participant_invitation_delivery_available)
self.assertEqual(response.metadata, {}) self.assertEqual(response.metadata, {})
slot = response.slots[0] slot = response.slots[0]
self.assertIsNone(slot.poll_option_id) self.assertIsNone(slot.poll_option_id)
@@ -287,6 +293,22 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
"connector-uid-internal", "connector-uid-internal",
) )
self.assertEqual(response.slots[0].tentative_hold_event_id, "hold-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: def test_optional_policy_can_reduce_but_cannot_broaden_visibility(self) -> None:
restricting_policy = _PrivacyPolicy("aggregates_only") 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

@@ -0,0 +1,283 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from govoplan_scheduling.backend.db.models import (
SchedulingCandidateSlot,
SchedulingParticipant,
SchedulingRequest,
)
from govoplan_scheduling.backend.schemas import (
SchedulingCandidateSlotReconcileInput,
SchedulingParticipantReconcileInput,
SchedulingRequestUpdateRequest,
)
from govoplan_scheduling.backend.service import (
SchedulingError,
_plan_scheduling_participant_reconciliation,
_plan_scheduling_request_update,
_plan_scheduling_slot_reconciliation,
scheduling_participant_revision,
scheduling_slot_revision,
)
NOW = datetime(2026, 7, 29, 9, tzinfo=timezone.utc)
def _request() -> SchedulingRequest:
return SchedulingRequest(
id="request-1",
tenant_id="tenant-1",
title="Steering group",
timezone="Europe/Berlin",
status="collecting",
poll_id="poll-1",
allow_external_participants=True,
allow_participant_updates=True,
result_visibility="after_close",
participant_visibility="aggregates_only",
notify_on_answers=True,
single_choice=False,
max_participants_per_option=None,
allow_maybe=True,
allow_comments=False,
participant_email_required=False,
anonymous_password_protection_enabled=False,
)
def _slot(
request: SchedulingRequest,
*,
slot_id: str,
position: int,
start_offset: int,
) -> SchedulingCandidateSlot:
slot = SchedulingCandidateSlot(
id=slot_id,
tenant_id=request.tenant_id,
request=request,
poll_option_id=f"option-{slot_id}",
label=f"Slot {position + 1}",
start_at=NOW + timedelta(hours=start_offset),
end_at=NOW + timedelta(hours=start_offset + 1),
timezone=request.timezone,
position=position,
freebusy_conflicts=[],
metadata_={},
)
return slot
def _slot_input(
slot: SchedulingCandidateSlot,
*,
label: str | None = None,
) -> SchedulingCandidateSlotReconcileInput:
return SchedulingCandidateSlotReconcileInput(
id=slot.id,
revision=scheduling_slot_revision(slot),
label=label or slot.label,
start_at=slot.start_at,
end_at=slot.end_at,
timezone=slot.timezone,
location=slot.location,
metadata=slot.metadata_ or {},
)
def _participant(
request: SchedulingRequest,
*,
participant_id: str,
respondent_id: str,
email: str,
required: bool,
status: str = "invited",
invitation_id: str | None = None,
) -> SchedulingParticipant:
return SchedulingParticipant(
id=participant_id,
tenant_id=request.tenant_id,
request=request,
respondent_id=respondent_id,
display_name=participant_id.title(),
email=email,
participant_type="internal",
required=required,
status=status,
poll_invitation_id=invitation_id,
participation_gateway="scheduling" if invitation_id else None,
metadata_={},
)
def _participant_input(
participant: SchedulingParticipant,
**changes: object,
) -> SchedulingParticipantReconcileInput:
values = {
"id": participant.id,
"revision": scheduling_participant_revision(participant),
"respondent_id": participant.respondent_id,
"display_name": participant.display_name,
"email": participant.email,
"participant_type": participant.participant_type,
"required": participant.required,
"metadata": participant.metadata_ or {},
}
values.update(changes)
return SchedulingParticipantReconcileInput.model_validate(values)
def test_slot_plan_is_inspectable_and_does_not_mutate_models() -> None:
request = _request()
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
second = _slot(request, slot_id="slot-2", position=1, start_offset=3)
supplied = [
_slot_input(first, label="Updated first slot"),
SchedulingCandidateSlotReconcileInput(
label="New slot",
start_at=NOW + timedelta(hours=5),
end_at=NOW + timedelta(hours=6),
timezone=request.timezone,
),
]
plan = _plan_scheduling_slot_reconciliation(
request=request,
supplied_slots=supplied,
option_mutation_available=True,
)
assert [update.slot_id for update in plan.updates] == ["slot-1"]
assert plan.updates[0].changes.label == "Updated first slot"
assert len(plan.additions) == 1
assert plan.removals == ("slot-2",)
assert plan.changed is True
assert first.label == "Slot 1"
assert second.deleted_at is None
assert len(request.slots) == 2
def test_exact_slot_replay_produces_a_noop_plan() -> None:
request = _request()
first = _slot(request, slot_id="slot-1", position=0, start_offset=1)
plan = _plan_scheduling_slot_reconciliation(
request=request,
supplied_slots=[_slot_input(first)],
option_mutation_available=True,
)
assert plan.changed is False
assert plan.updates == ()
assert plan.additions == ()
assert plan.removals == ()
def test_slot_plan_rejects_removal_of_a_tentative_calendar_hold() -> None:
request = _request()
held = _slot(request, slot_id="slot-held", position=0, start_offset=1)
held.tentative_hold_event_id = "event-1"
with pytest.raises(SchedulingError, match="tentative calendar hold"):
_plan_scheduling_slot_reconciliation(
request=request,
supplied_slots=[],
option_mutation_available=True,
)
def test_participant_plan_distinguishes_updates_additions_and_retirements() -> None:
request = _request()
alice = _participant(
request,
participant_id="alice",
respondent_id="user-alice",
email="alice@example.test",
required=True,
invitation_id="invitation-alice",
)
bob = _participant(
request,
participant_id="bob",
respondent_id="user-bob",
email="bob@example.test",
required=False,
status="responded",
)
supplied = [
_participant_input(alice, email="alice.new@example.test", required=False),
SchedulingParticipantReconcileInput(
respondent_id="user-charlie",
display_name="Charlie",
email="charlie@example.test",
participant_type="internal",
required=True,
),
]
plan = _plan_scheduling_participant_reconciliation(
request=request,
supplied_participants=supplied,
participation_available=True,
retirement_available=True,
)
assert len(plan.updates) == 1
assert plan.updates[0].participant_id == "alice"
assert plan.updates[0].revoke_invitation is True
assert set(plan.updates[0].changed_fields) == {"email", "required"}
assert plan.creations[0].replaces_participant_id is None
assert plan.creations[0].supplied.required is True
assert plan.retirements == ("bob",)
assert alice.email == "alice@example.test"
assert alice.required is True
assert bob.status == "responded"
def test_request_plan_records_invitation_expiry_work_without_tokens() -> None:
request = _request()
participant = _participant(
request,
participant_id="alice",
respondent_id="user-alice",
email="alice@example.test",
required=True,
invitation_id="invitation-alice",
)
deadline = NOW + timedelta(days=2)
plan = _plan_scheduling_request_update(
request=request,
payload=SchedulingRequestUpdateRequest(deadline_at=deadline),
participation_available=True,
)
assert plan.deadline_changed is True
assert plan.retained_invitation_ids == ((participant.id, "invitation-alice"),)
assert "token" not in repr(plan).casefold()
assert request.deadline_at is None
def test_request_plan_rejects_policy_change_after_link_issuance() -> None:
request = _request()
_participant(
request,
participant_id="alice",
respondent_id="user-alice",
email="alice@example.test",
required=True,
invitation_id="invitation-alice",
)
with pytest.raises(SchedulingError, match="cannot change"):
_plan_scheduling_request_update(
request=request,
payload=SchedulingRequestUpdateRequest(single_choice=True),
participation_available=True,
)

View File

@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef 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.modules import ModuleContext
from govoplan_core.core.poll import PollCapabilityError from govoplan_core.core.poll import PollCapabilityError
from govoplan_core.core.registry import PlatformRegistry 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 ( from govoplan_poll.backend.db.models import (
Poll, Poll,
PollInvitation, PollInvitation,
PollLifecycleTransition,
PollOption, PollOption,
PollParticipationSubmission, PollParticipationSubmission,
PollResponse, PollResponse,
@@ -60,7 +62,9 @@ from govoplan_scheduling.backend.service import (
cancel_scheduling_request, cancel_scheduling_request,
create_scheduling_request, create_scheduling_request,
get_public_scheduling_participation, get_public_scheduling_participation,
issue_scheduling_participant_invitation,
scheduling_request_summary, scheduling_request_summary,
scheduling_participant_revision,
scheduling_slot_revision, scheduling_slot_revision,
submit_scheduling_availability, submit_scheduling_availability,
submit_public_scheduling_participation, submit_public_scheduling_participation,
@@ -83,6 +87,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
PollResponse.__table__, PollResponse.__table__,
PollInvitation.__table__, PollInvitation.__table__,
PollParticipationSubmission.__table__, PollParticipationSubmission.__table__,
PollLifecycleTransition.__table__,
ChangeSequenceEntry.__table__,
SchedulingRequest.__table__, SchedulingRequest.__table__,
SchedulingCandidateSlot.__table__, SchedulingCandidateSlot.__table__,
SchedulingParticipant.__table__, SchedulingParticipant.__table__,
@@ -103,6 +109,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
SchedulingRequest.__table__, SchedulingRequest.__table__,
PollParticipationSubmission.__table__, PollParticipationSubmission.__table__,
PollInvitation.__table__, PollInvitation.__table__,
PollLifecycleTransition.__table__,
ChangeSequenceEntry.__table__,
PollResponse.__table__, PollResponse.__table__,
PollOption.__table__, PollOption.__table__,
Poll.__table__, Poll.__table__,
@@ -149,8 +157,9 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants: list[SchedulingParticipantInput] | None = None, participants: list[SchedulingParticipantInput] | None = None,
**settings, **settings,
) -> tuple[SchedulingRequest, dict[str, str]]: ) -> tuple[SchedulingRequest, dict[str, str]]:
issue_links = bool(settings.pop("create_participant_invitations", True))
start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc) start = datetime(2026, 7, 20, 9, tzinfo=timezone.utc)
return create_scheduling_request( request, automatic_tokens = create_scheduling_request(
self.session, self.session,
tenant_id="tenant-1", tenant_id="tenant-1",
user_id="organizer-1", user_id="organizer-1",
@@ -184,6 +193,32 @@ class SchedulingResponseEditingTests(unittest.TestCase):
**settings, **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( def _answer(
self, 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( invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == public_request.participants[0].poll_invitation_id PollInvitation.id == public_request.participants[0].poll_invitation_id
).one() ).one()
@@ -568,9 +604,21 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants=[SchedulingParticipantInput(display_name="Guest")], 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") 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): for attempt in range(10):
with self.assertRaises(SchedulingPublicParticipationError) as raised: with self.assertRaises(SchedulingPublicParticipationError) as raised:
get_public_scheduling_participation( get_public_scheduling_participation(
@@ -623,7 +671,8 @@ class SchedulingResponseEditingTests(unittest.TestCase):
), ),
) )
participant = request.participants[0] participant = request.participants[0]
token = tokens[participant.id] self.assertEqual(tokens, {})
token = self._issue_copy(request, participant)
answer = SchedulingAvailabilityAnswerInput( answer = SchedulingAvailabilityAnswerInput(
slot_id=request.slots[0].id, slot_id=request.slots[0].id,
value="available", value="available",
@@ -1010,6 +1059,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants=[ participants=[
SchedulingParticipantReconcileInput( SchedulingParticipantReconcileInput(
id=alice.id, id=alice.id,
revision=scheduling_participant_revision(alice),
respondent_id=alice.respondent_id, respondent_id=alice.respondent_id,
display_name=alice.display_name, display_name=alice.display_name,
email=alice.email, email=alice.email,
@@ -1036,8 +1086,16 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(removed_slot.deleted_at) self.assertIsNotNone(removed_slot.deleted_at)
bob = next(item for item in response.participants if item.display_name == "Bob") bob = next(item for item in response.participants if item.display_name == "Bob")
self.assertEqual(bob.email, "bob@example.test") self.assertEqual(bob.email, "bob@example.test")
self.assertIsNotNone(bob.poll_invitation_id) self.assertIsNone(bob.poll_invitation_id)
self.assertIsNotNone(bob.invitation_token) 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( current = api_get_my_scheduling_availability(
request.id, request.id,
session=self.session, session=self.session,
@@ -1073,6 +1131,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
participants=[ participants=[
SchedulingParticipantReconcileInput( SchedulingParticipantReconcileInput(
id=alice.id, id=alice.id,
revision=scheduling_participant_revision(alice),
respondent_id=alice.respondent_id, respondent_id=alice.respondent_id,
display_name=alice.display_name, display_name=alice.display_name,
email=alice.email, email=alice.email,
@@ -1091,7 +1150,7 @@ class SchedulingResponseEditingTests(unittest.TestCase):
), ),
) )
invitation = self.session.query(PollInvitation).filter( invitation = self.session.query(PollInvitation).filter(
PollInvitation.id == bob.poll_invitation_id PollInvitation.id == bob_invitation_id
).one() ).one()
self.assertIsNotNone(invitation.revoked_at) self.assertIsNotNone(invitation.revoked_at)
@@ -1128,6 +1187,334 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertEqual(raised.exception.status_code, 409) 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: def test_draft_edit_does_not_issue_link_for_added_participant(self) -> None:
request = self._request( request = self._request(
status="draft", status="draft",
@@ -1284,6 +1671,103 @@ class SchedulingResponseEditingTests(unittest.TestCase):
self.assertIsNotNone(cancelled.cancelled_at) self.assertIsNotNone(cancelled.cancelled_at)
self.assertEqual(poll.status, "draft") 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),
),
patch(
"govoplan_poll.backend.service._now",
return_value=cancelled_at + timedelta(days=1),
),
patch(
"govoplan_poll.backend.participation_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: def test_fully_invalidated_response_becomes_unanswered(self) -> None:
request = self._request() request = self._request()
alice = self._principal( alice = self._principal(

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from fastapi import HTTPException from fastapi import HTTPException, Response
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -42,7 +42,10 @@ from govoplan_scheduling.backend.schemas import (
SchedulingCalendarPreferences, SchedulingCalendarPreferences,
SchedulingCandidateSlotInput, SchedulingCandidateSlotInput,
SchedulingDecisionRequest, SchedulingDecisionRequest,
SchedulingInvitationActionRequest,
SchedulingInvitationRevokeRequest,
SchedulingParticipantInput, SchedulingParticipantInput,
SchedulingPublicParticipationAccessRequest,
SchedulingPublicParticipationSubmitRequest, SchedulingPublicParticipationSubmitRequest,
SchedulingRequestCreateRequest, SchedulingRequestCreateRequest,
SchedulingRequestUpdateRequest, SchedulingRequestUpdateRequest,
@@ -55,9 +58,11 @@ from govoplan_scheduling.backend.router import (
api_evaluate_calendar_freebusy, api_evaluate_calendar_freebusy,
api_get_my_scheduling_availability, api_get_my_scheduling_availability,
api_get_scheduling_request, api_get_scheduling_request,
api_issue_scheduling_participant_invitation,
api_list_scheduling_requests, api_list_scheduling_requests,
api_scheduling_summary, api_scheduling_summary,
api_submit_scheduling_availability, api_submit_scheduling_availability,
api_revoke_scheduling_participant_invitation,
) )
from govoplan_scheduling.backend.service import ( from govoplan_scheduling.backend.service import (
SchedulingError, SchedulingError,
@@ -70,12 +75,15 @@ from govoplan_scheduling.backend.service import (
decide_scheduling_request, decide_scheduling_request,
evaluate_calendar_freebusy, evaluate_calendar_freebusy,
get_visible_scheduling_request, get_visible_scheduling_request,
get_public_scheduling_participation,
issue_scheduling_participant_invitation,
list_scheduling_notifications, list_scheduling_notifications,
list_visible_scheduling_notifications, list_visible_scheduling_notifications,
list_visible_scheduling_requests, list_visible_scheduling_requests,
open_scheduling_request, open_scheduling_request,
require_visible_scheduling_results, require_visible_scheduling_results,
scheduling_request_summary, scheduling_request_summary,
scheduling_participant_revision,
scheduling_slot_revision, scheduling_slot_revision,
submit_public_scheduling_participation, submit_public_scheduling_participation,
update_scheduling_request, update_scheduling_request,
@@ -85,6 +93,23 @@ from govoplan_scheduling.backend.runtime import configure_runtime
class SchedulingServiceTests(unittest.TestCase): class SchedulingServiceTests(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
fixed_now = datetime(2026, 7, 19, 12, tzinfo=timezone.utc)
self.now_patches = (
patch(
"govoplan_scheduling.backend.service._now",
return_value=fixed_now,
),
patch(
"govoplan_poll.backend.service._now",
return_value=fixed_now,
),
patch(
"govoplan_poll.backend.participation_service._now",
return_value=fixed_now,
),
)
for now_patch in self.now_patches:
now_patch.start()
registry = PlatformRegistry() registry = PlatformRegistry()
registry.register(get_poll_manifest()) registry.register(get_poll_manifest())
registry.register(get_calendar_manifest()) registry.register(get_calendar_manifest())
@@ -117,6 +142,8 @@ class SchedulingServiceTests(unittest.TestCase):
self.session: Session = self.Session() self.session: Session = self.Session()
def tearDown(self) -> None: def tearDown(self) -> None:
for now_patch in reversed(self.now_patches):
now_patch.stop()
self.session.close() self.session.close()
Base.metadata.drop_all( Base.metadata.drop_all(
self.engine, self.engine,
@@ -206,7 +233,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( request, tokens = create_scheduling_request(
self.session, self.session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -224,8 +267,20 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertEqual(poll.context_resource_id, request.id) self.assertEqual(poll.context_resource_id, request.id)
self.assertEqual(len(request.slots), 2) self.assertEqual(len(request.slots), 2)
self.assertTrue(all(slot.poll_option_id for slot in request.slots)) self.assertTrue(all(slot.poll_option_id for slot in request.slots))
self.assertEqual(len(tokens), 2) self.assertEqual(tokens, {})
self.assertTrue(all(participant.poll_invitation_id for participant in request.participants)) 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: def test_draft_save_does_not_issue_or_deliver_public_invitations(self) -> None:
class RejectingNotificationProvider: class RejectingNotificationProvider:
@@ -331,11 +386,13 @@ class SchedulingServiceTests(unittest.TestCase):
first_participant = request.participants[0] first_participant = request.participants[0]
first_slot = request.slots[0] first_slot = request.slots[0]
second_slot = request.slots[1] second_slot = request.slots[1]
self.assertEqual(tokens, {})
token = self._issue_copy(request, first_participant)
submit_public_scheduling_participation( submit_public_scheduling_participation(
self.session, self.session,
request_id=request.id, request_id=request.id,
token=tokens[first_participant.id], token=token,
payload=SchedulingPublicParticipationSubmitRequest( payload=SchedulingPublicParticipationSubmitRequest(
answers=[ answers=[
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
@@ -584,7 +641,7 @@ class SchedulingServiceTests(unittest.TestCase):
all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id) all_jobs = list_scheduling_notifications(self.session, tenant_id="tenant-1", request_id=request.id)
self.assertEqual(len(reminder_jobs), 2) 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)) self.assertTrue(all(job.status == "pending" for job in reminder_jobs))
organizer_jobs = list_visible_scheduling_notifications( organizer_jobs = list_visible_scheduling_notifications(
@@ -1057,6 +1114,7 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload, payload=payload,
) )
target = request.participants[1] target = request.participants[1]
self._issue_copy(request, target)
attacker = self._principal( attacker = self._principal(
"attacker", "attacker",
email="alice@example.test", email="alice@example.test",
@@ -1095,7 +1153,7 @@ class SchedulingServiceTests(unittest.TestCase):
principal=attacker, 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.assertEqual([request.id], [item.id for item in listed.requests])
self.assertFalse(current.has_response) self.assertFalse(current.has_response)
@@ -1225,10 +1283,12 @@ class SchedulingServiceTests(unittest.TestCase):
payload=payload, payload=payload,
) )
participant = request.participants[0] participant = request.participants[0]
self.assertEqual(tokens, {})
token = self._issue_copy(request, participant)
submit_public_scheduling_participation( submit_public_scheduling_participation(
self.session, self.session,
request_id=request.id, request_id=request.id,
token=tokens[participant.id], token=token,
payload=SchedulingPublicParticipationSubmitRequest( payload=SchedulingPublicParticipationSubmitRequest(
answers=[ answers=[
SchedulingAvailabilityAnswerInput( SchedulingAvailabilityAnswerInput(
@@ -1436,7 +1496,7 @@ class SchedulingServiceTests(unittest.TestCase):
self.assertIsNone(request.selected_slot_id) self.assertIsNone(request.selected_slot_id)
self.assertIsNone(request.calendar_event_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: class CapturingNotificationProvider:
def __init__(self) -> None: def __init__(self) -> None:
self.requests = [] self.requests = []
@@ -1466,22 +1526,42 @@ class SchedulingServiceTests(unittest.TestCase):
"govoplan_scheduling.backend.service.notification_dispatch_provider", "govoplan_scheduling.backend.service.notification_dispatch_provider",
return_value=provider, return_value=provider,
): ):
request, tokens = create_scheduling_request( request, automatic_tokens = create_scheduling_request(
self.session, self.session,
tenant_id="tenant-1", tenant_id="tenant-1",
user_id="user-1", user_id="user-1",
payload=payload, 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(len(provider.requests), 2)
self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"}) self.assertEqual({item.recipient_id for item in provider.requests}, {"alice-id", "bob-id"})
self.assertEqual( action_urls = {item.action_url for item in provider.requests}
{item.action_url for item in provider.requests}, self.assertTrue(
{ all(
f"/scheduling/public/{request.id}/{token}" isinstance(action_url, str)
for token in tokens.values() 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( local_notifications = list_scheduling_notifications(
self.session, self.session,
tenant_id="tenant-1", tenant_id="tenant-1",
@@ -1489,7 +1569,485 @@ class SchedulingServiceTests(unittest.TestCase):
) )
for notification in local_notifications: for notification in local_notifications:
serialized = repr({"payload": notification.payload, "metadata": notification.metadata_}) 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: def test_external_participants_can_be_rejected(self) -> None:
payload = self._payload().model_copy(update={"allow_external_participants": False}) payload = self._payload().model_copy(update={"allow_external_participants": False})

View File

@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/scheduling-webui", "name": "@govoplan/scheduling-webui",
"version": "0.1.10", "version": "0.1.11",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
@@ -18,11 +18,11 @@
"test:ui-structure": "node scripts/test-scheduling-page-structure.mjs" "test:ui-structure": "node scripts/test-scheduling-page-structure.mjs"
}, },
"peerDependencies": { "peerDependencies": {
"@govoplan/core-webui": "^0.1.10", "@govoplan/core-webui": "^0.1.11",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^7.1.1", "react-router-dom": ">=7.18.2 <8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.0.6" "vite": "^6.0.6"

View File

@@ -3,15 +3,19 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url)); const pagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPage.tsx", import.meta.url));
const publicPagePath = fileURLToPath(new URL("../src/features/scheduling/SchedulingPublicPage.tsx", import.meta.url));
const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url)); const apiPath = fileURLToPath(new URL("../src/api/scheduling.ts", import.meta.url));
const modulePath = fileURLToPath(new URL("../src/module.ts", import.meta.url));
const page = readFileSync(pagePath, "utf8"); const page = readFileSync(pagePath, "utf8");
const publicPage = readFileSync(publicPagePath, "utf8");
const api = readFileSync(apiPath, "utf8"); const api = readFileSync(apiPath, "utf8");
const moduleSource = readFileSync(modulePath, "utf8");
assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/); assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calendar\.picker"\)/);
assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/); assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/);
assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/); assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/);
assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/); 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.doesNotMatch(page, /@govoplan\/core-webui\/src\//);
assert.match(page, /className="scheduling-workspace-layout"/); 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.ok(page.indexOf("title={I18N.myRequests}") < page.indexOf("title={I18N.invitedRequests}"));
assert.match(page, /<SelectionList label=\{title\} className="scheduling-request-list">/); 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, /<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, /editorMode \? \(/);
assert.match(page, /id="scheduling-editor-form"/); 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, /<PasswordField[\s\S]*minLength=\{8\}/);
assert.match(page, /type="number"[\s\S]*min=\{1\}/); assert.match(page, /type="number"[\s\S]*min=\{1\}/);
assert.match(page, /min=\{addLocalMinutes\(slot\.start_at, 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.doesNotMatch(page, /usesGatewayPolicy|updateSchedulingCandidateSlot/);
assert.match(page, /public_participation_policy_enforcement_available/); assert.match(page, /public_participation_policy_enforcement_available/);
assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/); assert.match(page, /const canCreateOrWrite = canWrite \|\| canAdminister/);
assert.match(page, /policyLocked=\{participationPolicyLocked\}/); assert.match(page, /policyLocked=\{participationPolicyLocked\}/);
assert.match(page, /id="scheduling-create-candidate-slots-grid"/); 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-candidate-slots-grid"/);
assert.match(page, /id="scheduling-participants-grid"/); assert.match(page, /id="scheduling-participants-grid"/);
assert.match(page, /<DataGridRowActions/); 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.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.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/); assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
assert.match(page, /showDecisionAction=\{canManageSelected\}/); assert.match(page, /showDecisionAction=\{canManageSelected\}/);
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/); assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
assert.doesNotMatch(page, /AdminIconButton/); 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, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /option_revision: slot\.revision/); assert.match(page, /option_revision: slot\.revision/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/); assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
@@ -99,17 +127,34 @@ for (const field of [
"allow_comments", "allow_comments",
"participant_email_required", "participant_email_required",
"anonymous_password_protection_enabled", "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, new RegExp(`${field}:`));
} }
assert.match(api, /method: "PATCH"/); 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, /slots: slots\.map\(\(slot\) => \(\{/);
assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/); assert.match(page, /participants: participants\.map\(participantPayload\)/);
assert.doesNotMatch(page, /create_participant_invitations/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses\/me/); 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(api, /\/api\/v1\/scheduling\/public\/\$\{encodeURIComponent\(requestId\)\}\/\$\{encodeURIComponent\(token\)\}/);
assert.match(page, /useSearchParams\(\)/); assert.match(page, /useSearchParams\(\)/);
assert.match(page, /Promise\.allSettled/); 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 SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses"; export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
@@ -23,6 +27,7 @@ export type SchedulingCandidateSlot = {
export type SchedulingParticipant = { export type SchedulingParticipant = {
id: string; id: string;
revision?: string | null;
is_current_participant: boolean; is_current_participant: boolean;
respondent_id?: string | null; respondent_id?: string | null;
display_name?: string | null; display_name?: string | null;
@@ -79,6 +84,7 @@ export type SchedulingRequest = {
anonymous_password_protection_enabled: boolean; anonymous_password_protection_enabled: boolean;
public_participation_policy_enforcement_available: boolean | null; public_participation_policy_enforcement_available: boolean | null;
public_participation_policy_enforcement_reason?: string | null; public_participation_policy_enforcement_reason?: string | null;
participant_invitation_delivery_available: boolean | null;
calendar_integration_enabled: boolean | null; calendar_integration_enabled: boolean | null;
calendar_id?: string | null; calendar_id?: string | null;
calendar_freebusy_enabled: boolean | null; calendar_freebusy_enabled: boolean | null;
@@ -87,6 +93,7 @@ export type SchedulingRequest = {
calendar_event_id?: string | null; calendar_event_id?: string | null;
handed_off_at?: string | null; handed_off_at?: string | null;
cancelled_at?: string | null; cancelled_at?: string | null;
cancellation_notice_until?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
@@ -163,6 +170,7 @@ export type SchedulingCandidateSlotReconcilePayload = SchedulingCandidateSlotPay
export type SchedulingParticipantReconcilePayload = SchedulingParticipantPayload & { export type SchedulingParticipantReconcilePayload = SchedulingParticipantPayload & {
id?: string; id?: string;
revision?: string;
}; };
export type SchedulingCandidateSlotUpdatePayload = { export type SchedulingCandidateSlotUpdatePayload = {
@@ -224,6 +232,9 @@ export type SchedulingPublicParticipationResponse = {
timezone: string; timezone: string;
status: SchedulingStatus; status: SchedulingStatus;
deadline_at?: string | null; deadline_at?: string | null;
cancelled_at?: string | null;
cancellation_notice_until?: string | null;
cancellation_notice_only: boolean;
participant_email_required: boolean; participant_email_required: boolean;
anonymous_password_required: boolean; anonymous_password_required: boolean;
single_choice: boolean; single_choice: boolean;
@@ -278,6 +289,18 @@ export type SchedulingNotification = {
export type SchedulingNotificationListResponse = { notifications: 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 = { export type SchedulingPollOptionResult = {
option_id: string; option_id: string;
option_key: string; option_key: string;
@@ -299,31 +322,25 @@ export type SchedulingSummaryResponse = {
}; };
}; };
export type SchedulingAddressLookupCandidate = { export type SchedulingPeopleSearchResponse = {
contact_id: string; groups: PeoplePickerSearchGroup[];
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[];
}; };
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) }); 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) }); 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> { 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)}` : ""; const query = requestId ? `?request_id=${encodeURIComponent(requestId)}` : "";
return apiFetch<SchedulingNotificationListResponse>(settings, `/api/v1/scheduling/notifications${query}`); 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 { useSearchParams } from "react-router-dom";
import { import {
Bell, Bell,
CalendarCheck, CalendarCheck,
Check, Check,
Clock, Clock,
Copy,
Link2Off,
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -15,8 +17,8 @@ import {
import { import {
Button, Button,
Card, Card,
ConfirmDialog,
DataGrid, DataGrid,
DataGridEmptyAction,
DataGridRowActions, DataGridRowActions,
DateTimeField, DateTimeField,
DismissibleAlert, DismissibleAlert,
@@ -25,6 +27,7 @@ import {
IconButton, IconButton,
PageTitle, PageTitle,
PasswordField, PasswordField,
PeoplePicker,
formatDateTime, formatDateTime,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
@@ -33,6 +36,7 @@ import {
StatusBadge, StatusBadge,
hasScope, hasScope,
i18nMessage, i18nMessage,
isApiError,
usePlatformLanguage, usePlatformLanguage,
usePlatformUiCapability, usePlatformUiCapability,
useUnsavedChanges, useUnsavedChanges,
@@ -41,7 +45,9 @@ import {
type AuthInfo, type AuthInfo,
type CalendarPickerUiCapability, type CalendarPickerUiCapability,
type DataGridColumn, type DataGridColumn,
type FormatDateTimeOptions type FormatDateTimeOptions,
type PeoplePickerItem,
type PeoplePickerSearch
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
closeSchedulingRequest, closeSchedulingRequest,
@@ -52,14 +58,18 @@ import {
decideSchedulingRequest, decideSchedulingRequest,
evaluateSchedulingFreeBusy, evaluateSchedulingFreeBusy,
getSchedulingAvailabilityResponse, getSchedulingAvailabilityResponse,
issueSchedulingParticipantInvitation,
listSchedulingNotifications, listSchedulingNotifications,
listSchedulingRequests, listSchedulingRequests,
openSchedulingRequest, openSchedulingRequest,
revokeSchedulingParticipantInvitation,
searchSchedulingPeople,
schedulingSummary, schedulingSummary,
submitSchedulingAvailability, submitSchedulingAvailability,
updateSchedulingRequest, updateSchedulingRequest,
type SchedulingCandidateSlot, type SchedulingCandidateSlot,
type SchedulingAvailabilityValue, type SchedulingAvailabilityValue,
type SchedulingInvitationActionResponse,
type SchedulingNotification, type SchedulingNotification,
type SchedulingParticipant, type SchedulingParticipant,
type SchedulingPollOptionResult, type SchedulingPollOptionResult,
@@ -70,10 +80,17 @@ import {
applySchedulingAvailabilityChoice, applySchedulingAvailabilityChoice,
groupSchedulingRequests, groupSchedulingRequests,
schedulingParticipantForActor, schedulingParticipantForActor,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingPublicInvitationUrl,
schedulingRelevantTimestamp, schedulingRelevantTimestamp,
schedulingRequestIsOwned, schedulingRequestIsOwned,
schedulingSortPhase, schedulingSortPhase,
type SchedulingActor, type SchedulingActor,
type SchedulingInvitationActionBlock,
type SchedulingParticipantDraft,
type SchedulingRequestGroups type SchedulingRequestGroups
} from "./schedulingViewModel"; } from "./schedulingViewModel";
@@ -89,16 +106,10 @@ type SlotDraft = {
timezone: string; timezone: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
type ParticipantDraft = { type ParticipantDraft = SchedulingParticipantDraft;
draftId: string; type InvitationRevokeTarget = {
sourceId?: string; requestId: string;
respondent_id?: string | null; participant: SchedulingParticipant;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
}; };
const I18N = { const I18N = {
@@ -121,10 +132,13 @@ const I18N = {
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e", calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5", candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b", 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", checkFreeBusy: "i18n:govoplan-scheduling.check_free_busy.e9700e00",
chooseAvailability: "i18n:govoplan-scheduling.choose_availability.ac95b8f6", 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", closePoll: "i18n:govoplan-scheduling.close_poll.a6a18916",
closed: "i18n:govoplan-scheduling.closed.88d86b77", closed: "i18n:govoplan-scheduling.closed.88d86b77",
copyInvitationLink: "i18n:govoplan-scheduling.copy_a_fresh_invitation_link_for_value0.e3799c79",
description: "i18n:govoplan-scheduling.description.55f8ebc8", description: "i18n:govoplan-scheduling.description.55f8ebc8",
determined: "i18n:govoplan-scheduling.determined.9f23293d", determined: "i18n:govoplan-scheduling.determined.9f23293d",
decideUnavailable: "i18n:govoplan-scheduling.a_slot_can_be_selected_after_the_request_is_closed.f91ec02d", 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", generalSettings: "i18n:govoplan-scheduling.participation_settings.8dc6f62c",
free: "i18n:govoplan-scheduling.free.75f52718", free: "i18n:govoplan-scheduling.free.75f52718",
holds: "i18n:govoplan-scheduling.create_tentative_holds.51c4744e", 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", 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", loading: "i18n:govoplan-scheduling.loading_scheduling_requests.f42be95d",
location: "i18n:govoplan-scheduling.location.d219c681", location: "i18n:govoplan-scheduling.location.d219c681",
allowComments: "i18n:govoplan-scheduling.allow_comments.d63202a6", allowComments: "i18n:govoplan-scheduling.allow_comments.d63202a6",
@@ -158,6 +178,8 @@ const I18N = {
myRequests: "i18n:govoplan-scheduling.my_scheduling_requests.d28ef235", myRequests: "i18n:govoplan-scheduling.my_scheduling_requests.d28ef235",
name: "i18n:govoplan-scheduling.name.709a2322", name: "i18n:govoplan-scheduling.name.709a2322",
newRequest: "i18n:govoplan-scheduling.new_scheduling_request.2080f675", 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", noNotifications: "i18n:govoplan-scheduling.no_notifications_have_been_created.c8d43ca3",
notificationsUnavailable: "i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3", notificationsUnavailable: "i18n:govoplan-scheduling.notifications_could_not_be_loaded.f0e1b2c3",
noParticipants: "i18n:govoplan-scheduling.no_participants.73a89101", 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", notifyOnAnswersHelp: "i18n:govoplan-scheduling.create_an_organizer_notification_whenever_a_response_is.253ddca8",
open: "i18n:govoplan-scheduling.open.cf9b7706", open: "i18n:govoplan-scheduling.open.cf9b7706",
openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7", openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7",
participant: "i18n:govoplan-scheduling.participant.554f4235",
participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e", participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a", participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a",
participantPrivacy: "i18n:govoplan-scheduling.participant_privacy.108c470f", participantPrivacy: "i18n:govoplan-scheduling.participant_privacy.108c470f",
participantRosterVisibility: "i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0", participantRosterVisibility: "i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0",
participantRosterVisibilityHelp: "i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361", participantRosterVisibilityHelp: "i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361",
participants: "i18n:govoplan-scheduling.participants.cd56e083", 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", participation: "i18n:govoplan-scheduling.participation.9ad70cc4",
participationRate: "i18n:govoplan-scheduling.participation_rate.46bc5504", participationRate: "i18n:govoplan-scheduling.participation_rate.46bc5504",
responses: "i18n:govoplan-scheduling.responses.3427e3ab", responses: "i18n:govoplan-scheduling.responses.3427e3ab",
@@ -181,7 +207,12 @@ const I18N = {
capacity: "i18n:govoplan-scheduling.capacity.d3c375f8", capacity: "i18n:govoplan-scheduling.capacity.d3c375f8",
past: "i18n:govoplan-scheduling.past.405c12fb", past: "i18n:govoplan-scheduling.past.405c12fb",
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1", 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", 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", requestFailed: "i18n:govoplan-scheduling.request_failed.9fcda32c",
publicPolicyUnavailable: "i18n:govoplan-scheduling.guest_links_are_not_issued_while_the_configured_participation.0794ebf0", 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", 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", 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", 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", 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", sendResponse: "i18n:govoplan-scheduling.submit_response.a5f0c053",
start: "i18n:govoplan-scheduling.start.952f3754", start: "i18n:govoplan-scheduling.start.952f3754",
statusLabel: "i18n:govoplan-scheduling.status.bae7d5be", statusLabel: "i18n:govoplan-scheduling.status.bae7d5be",
@@ -235,7 +267,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [calendarId, setCalendarId] = useState(""); const [calendarId, setCalendarId] = useState("");
const [calendarEnabled, setCalendarEnabled] = useState(false); const [calendarEnabled, setCalendarEnabled] = useState(false);
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText)); 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 [participantRosterVisible, setParticipantRosterVisible] = useState(false);
const [notifyOnAnswers, setNotifyOnAnswers] = useState(true); const [notifyOnAnswers, setNotifyOnAnswers] = useState(true);
const [singleChoice, setSingleChoice] = useState(false); const [singleChoice, setSingleChoice] = useState(false);
@@ -254,6 +287,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [summaryUnavailable, setSummaryUnavailable] = useState(false); const [summaryUnavailable, setSummaryUnavailable] = useState(false);
const [notificationsUnavailable, setNotificationsUnavailable] = useState(false); const [notificationsUnavailable, setNotificationsUnavailable] = useState(false);
const [detailsLoading, setDetailsLoading] = 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 detailLoadSequence = useRef(0);
const canWrite = hasScope(auth, "scheduling:schedule:write"); const canWrite = hasScope(auth, "scheduling:schedule:write");
@@ -323,6 +358,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const participationPolicyLocked = Boolean( const participationPolicyLocked = Boolean(
editorOriginal?.participants.some((participant) => participant.poll_invitation_id) editorOriginal?.participants.some((participant) => participant.poll_invitation_id)
); );
const participantSearch = useCallback<PeoplePickerSearch>(
(query, options) => searchSchedulingPeople(settings, query, options.limit, options.signal),
[settings]
);
useEffect(() => { useEffect(() => {
void loadRequests(); void loadRequests();
@@ -351,6 +390,29 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
void loadDetails(selected.id); void loadDetails(selected.id);
}, [editorMode, 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(() => { useEffect(() => {
if (!selected?.id || !selectedParticipant || !canRespond || editorMode || selected.status !== "collecting") { if (!selected?.id || !selectedParticipant || !canRespond || editorMode || selected.status !== "collecting") {
setAvailabilityLoading(false); setAvailabilityLoading(false);
@@ -482,17 +544,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
timezone: slot.timezone, timezone: slot.timezone,
metadata: slot.metadata metadata: slot.metadata
}))); })));
setParticipants(request.participants.map((participant) => ({ setParticipants(request.participants.map((participant) => (
draftId: nextDraftId("participant"), participantDraftFromResponse(participant, nextDraftId("participant"))
sourceId: participant.id, )));
respondent_id: participant.respondent_id, setAllowExternalParticipants(request.allow_external_participants);
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)
})));
setParticipantRosterVisible(request.participant_visibility === "names_and_statuses"); setParticipantRosterVisible(request.participant_visibility === "names_and_statuses");
setNotifyOnAnswers(request.notify_on_answers); setNotifyOnAnswers(request.notify_on_answers);
setSingleChoice(request.single_choice); setSingleChoice(request.single_choice);
@@ -555,6 +610,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
title: title.trim(), title: title.trim(),
description: description.trim() || null, description: description.trim() || null,
location: location.trim() || null, location: location.trim() || null,
allow_external_participants: allowExternalParticipants,
participant_visibility: participantRosterVisible ? "names_and_statuses" : "aggregates_only", participant_visibility: participantRosterVisible ? "names_and_statuses" : "aggregates_only",
notify_on_answers: notifyOnAnswers, notify_on_answers: notifyOnAnswers,
single_choice: singleChoice, single_choice: singleChoice,
@@ -587,17 +643,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null, location: location.trim() || null,
metadata: slot.metadata ?? {} metadata: slot.metadata ?? {}
})), })),
participants: participants participants: participants.map(participantPayload)
.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 ?? {}
})),
create_participant_invitations: true
}); });
setRequests((items) => [request, ...items]); setRequests((items) => [request, ...items]);
} else { } else {
@@ -617,18 +663,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null, location: location.trim() || null,
metadata: slot.metadata ?? {} metadata: slot.metadata ?? {}
})), })),
participants: participants participants: participants.map(participantPayload)
.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 ?? {}
})),
create_participant_invitations: true
}); });
replaceRequest(request); replaceRequest(request);
} }
@@ -665,6 +700,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>) { async function sendAvailability(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
await persistAvailability(); await persistAvailability();
@@ -726,9 +846,9 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.refresh} label={I18N.refresh}
icon={<RefreshCw aria-hidden="true" size={16} />} icon={<RefreshCw aria-hidden="true" size={16} />}
onClick={() => requestNavigation(() => void loadRequests(selected?.id))} onClick={() => requestNavigation(() => void loadRequests(selected?.id))}
disabled={loading} /> disabled={loading || saving} />
{canCreateOrWrite ? ( {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} <Plus aria-hidden="true" size={16} /> {I18N.add}
</Button> </Button>
) : null} ) : null}
@@ -740,12 +860,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
requests={groups.owned} requests={groups.owned}
selectedId={editorMode ? undefined : selected?.id} selectedId={editorMode ? undefined : selected?.id}
actor={actor} actor={actor}
disabled={saving}
onSelect={selectRequest} /> onSelect={selectRequest} />
<RequestGroup <RequestGroup
title={I18N.invitedRequests} title={I18N.invitedRequests}
requests={groups.invited} requests={groups.invited}
selectedId={editorMode ? undefined : selected?.id} selectedId={editorMode ? undefined : selected?.id}
actor={actor} actor={actor}
disabled={saving}
onSelect={selectRequest} /> onSelect={selectRequest} />
{canAdminister && groups.other.length ? ( {canAdminister && groups.other.length ? (
<RequestGroup <RequestGroup
@@ -753,6 +875,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
requests={groups.other} requests={groups.other}
selectedId={editorMode ? undefined : selected?.id} selectedId={editorMode ? undefined : selected?.id}
actor={actor} actor={actor}
disabled={saving}
onSelect={selectRequest} /> onSelect={selectRequest} />
) : null} ) : null}
</Card> </Card>
@@ -831,7 +954,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<EditableSlots <EditableSlots
slots={slots} slots={slots}
disabled={!canCreateOrWrite} disabled={!canCreateOrWrite}
reorderable={editorMode === "create"} reorderable
onAddBelow={(index) => { onAddBelow={(index) => {
setSlots((items) => editorMode === "edit" setSlots((items) => editorMode === "edit"
? [...items, newSlot(items.length + 1, translateText)] ? [...items, newSlot(items.length + 1, translateText)]
@@ -851,16 +974,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<EditableParticipants <EditableParticipants
participants={participants} participants={participants}
disabled={!canCreateOrWrite} disabled={!canCreateOrWrite}
onAddBelow={(index) => { allowExternalParticipants={allowExternalParticipants}
setParticipants((items) => insertAfter(items, index, emptyParticipant())); search={participantSearch}
onAllowExternalParticipantsChange={(value) => {
setAllowExternalParticipants(value);
setDraftDirty(true); setDraftDirty(true);
}} }}
onChange={(index, patch) => { onChange={(value) => {
setParticipants((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item)); setParticipants((current) => participantDraftsFromPicker(
setDraftDirty(true); value,
}} current,
onRemove={(index) => { () => nextDraftId("participant")
setParticipants((items) => items.filter((_item, itemIndex) => itemIndex !== index)); ));
setDraftDirty(true); setDraftDirty(true);
}} /> }} />
@@ -1078,7 +1203,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
))} ))}
</div> </div>
{selected.effective_participant_visibility === "names_and_statuses" ? ( {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>} ) : <p className="scheduling-capability-note">{I18N.participantRosterHidden}</p>}
</Card> </Card>
<Card title={I18N.notifications}> <Card title={I18N.notifications}>
@@ -1108,6 +1244,17 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
</section> </section>
</div> </div>
</section> </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> </main>
); );
@@ -1174,7 +1321,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
setCalendarId(""); setCalendarId("");
setCalendarEnabled(false); setCalendarEnabled(false);
setSlots(initialSlots(translateText)); setSlots(initialSlots(translateText));
setParticipants([emptyParticipant()]); setParticipants([]);
setAllowExternalParticipants(true);
setParticipantRosterVisible(false); setParticipantRosterVisible(false);
setNotifyOnAnswers(true); setNotifyOnAnswers(true);
setSingleChoice(false); setSingleChoice(false);
@@ -1194,12 +1342,14 @@ function RequestGroup({
requests, requests,
selectedId, selectedId,
actor, actor,
disabled,
onSelect onSelect
}: { }: {
title: string; title: string;
requests: SchedulingRequest[]; requests: SchedulingRequest[];
selectedId?: string; selectedId?: string;
actor: SchedulingActor; actor: SchedulingActor;
disabled: boolean;
onSelect: (requestId: string) => void; onSelect: (requestId: string) => void;
}) { }) {
return ( return (
@@ -1213,6 +1363,7 @@ function RequestGroup({
key={request.id} key={request.id}
selected={selectedId === request.id} selected={selectedId === request.id}
className="scheduling-list-item" className="scheduling-list-item"
disabled={disabled}
onClick={() => onSelect(request.id)}> onClick={() => onSelect(request.id)}>
<span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span> <span><strong>{request.title}</strong><small>{formatRelevantDate(request)}</small></span>
<StatusBadge status={request.status} label={requestStatusLabel(request, actor)} /> <StatusBadge status={request.status} label={requestStatusLabel(request, actor)} />
@@ -1518,82 +1669,40 @@ function EditableSlots({
function EditableParticipants({ function EditableParticipants({
participants, participants,
disabled, disabled,
onAddBelow, allowExternalParticipants,
onChange, search,
onRemove onAllowExternalParticipantsChange,
onChange
}: { }: {
participants: ParticipantDraft[]; participants: ParticipantDraft[];
disabled: boolean; disabled: boolean;
onAddBelow: (index: number) => void; allowExternalParticipants: boolean;
onChange: (index: number, patch: Partial<ParticipantDraft>) => void; search: PeoplePickerSearch;
onRemove: (index: number) => void; 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 ( return (
<Card title={I18N.participants}> <Card title={I18N.participants}>
{participants.some((participant) => participant.identityLocked) ? ( {participants.some((participant) => participant.identityLocked) ? (
<p id={lockedIdentityHelpId} className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p> <p className="scheduling-capability-note">{I18N.invitedIdentityLocked}</p>
) : null} ) : null}
<DataGrid <ToggleSwitch
id="scheduling-create-participants-grid" label={I18N.allowExternalParticipants}
rows={participants} help={I18N.allowExternalParticipantsHelp}
columns={columns} checked={allowExternalParticipants}
getRowKey={(participant) => participant.draftId} disabled={disabled}
initialFit="container" onChange={onAllowExternalParticipantsChange} />
emptyText={I18N.noParticipants} <PeoplePicker
emptyActionColumnId="actions" id="scheduling-participant-picker"
emptyAction={<DataGridEmptyAction disabled={disabled} reorderable={false} onAdd={() => onAddBelow(-1)} label={I18N.addParticipant} />} /> label={I18N.addParticipant}
help={I18N.participantPickerHelp}
selectedLabel={I18N.participants}
value={participants}
search={search}
disabled={disabled}
allowManualExternal={allowExternalParticipants}
manualEmailRequired
onChange={onChange} />
</Card> </Card>
); );
} }
@@ -1695,7 +1804,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>[] = [ const columns: DataGridColumn<SchedulingParticipant>[] = [
{ id: "name", header: I18N.name, minWidth: 180, resizable: true, value: (participant) => participant.display_name ?? "" }, { 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 ?? "" }, { id: "email", header: I18N.participantEmail, minWidth: 220, resizable: true, value: (participant) => participant.email ?? "" },
@@ -1705,7 +1832,52 @@ function ParticipantsGrid({ participants }: { participants: SchedulingParticipan
width: 140, width: 140,
value: (participant) => participantStatusLabel(participant.status), value: (participant) => participantStatusLabel(participant.status),
render: (participant) => <StatusBadge status={participant.status} label={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 ( return (
@@ -1719,6 +1891,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 { function firstRequest(groups: SchedulingRequestGroups, includeManaged: boolean): SchedulingRequest | null {
return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null; return groups.owned[0] ?? groups.invited[0] ?? (includeManaged ? groups.other[0] : null) ?? null;
} }
@@ -1755,7 +1989,9 @@ function notificationKindLabel(kind: string): string {
invitation: "i18n:govoplan-scheduling.invitation.6306ef74", invitation: "i18n:govoplan-scheduling.invitation.6306ef74",
reminder: "i18n:govoplan-scheduling.reminder.b87a1929", reminder: "i18n:govoplan-scheduling.reminder.b87a1929",
decision: "i18n:govoplan-scheduling.decision.7f59a1f1", 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; } as Record<string, string>)[kind] ?? kind;
} }
@@ -1792,21 +2028,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; let draftIdSequence = 0;
function nextDraftId(prefix: string): string { 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

@@ -0,0 +1,120 @@
import { useCallback } from "react";
import { CalendarClock } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
StatusBadge,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listSchedulingRequests,
type SchedulingRequest
} from "../../api/scheduling";
export default function SchedulingRequestsWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const includeDrafts = configuration.includeDrafts === true;
const load = useCallback(async () => {
const response = await listSchedulingRequests(settings);
return response.requests
.filter(
(request) =>
request.status === "collecting"
|| (includeDrafts && request.status === "draft")
)
.sort(compareRequests)
.slice(0, maxItems);
}, [includeDrafts, maxItems, settings]);
const { data: requests, loading, error } = useDashboardWidgetData(
load,
refreshKey
);
return (
<LoadingFrame loading={loading} label="Loading scheduling requests">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No scheduling requests are awaiting responses."
items={(requests ?? []).map((request) => ({
id: request.id,
title: request.title,
detail: responseLabel(request),
meta: deadlineLabel(request),
leading: <CalendarClock size={17} aria-hidden="true" />,
trailing: (
<StatusBadge
status={request.status}
label={request.status === "collecting" ? "Open" : "Draft"}
/>
),
to: "/scheduling"
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/scheduling">
Open scheduling
</Link>
</div>
</LoadingFrame>
);
}
function compareRequests(
left: SchedulingRequest,
right: SchedulingRequest
): number {
if (left.status !== right.status) {
return left.status === "collecting" ? -1 : 1;
}
const leftDeadline = left.deadline_at
? new Date(left.deadline_at).getTime()
: Number.POSITIVE_INFINITY;
const rightDeadline = right.deadline_at
? new Date(right.deadline_at).getTime()
: Number.POSITIVE_INFINITY;
return (
leftDeadline - rightDeadline
|| new Date(right.updated_at).getTime() - new Date(left.updated_at).getTime()
);
}
function responseLabel(request: SchedulingRequest): string {
const responded = request.participant_aggregate.status_counts.responded ?? 0;
return `${responded} of ${request.participant_aggregate.total} responded`;
}
function deadlineLabel(request: SchedulingRequest): string {
if (!request.deadline_at) return `${request.slots.length} options`;
return `Due ${new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short"
}).format(new Date(request.deadline_at))}`;
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -1,8 +1,25 @@
import type { import type {
SchedulingAvailabilityValue, SchedulingAvailabilityValue,
SchedulingParticipant, SchedulingParticipant,
SchedulingParticipantPayload,
SchedulingRequest SchedulingRequest
} from "../../api/scheduling"; } 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 = { export type SchedulingActor = {
accountId?: string | null; accountId?: string | null;
@@ -25,6 +42,123 @@ export type SchedulingSortPhase =
| "determined" | "determined"
| "past"; | "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[] { export function schedulingActorIds(actor: SchedulingActor): string[] {
return Array.from(new Set([ return Array.from(new Set([
actor.accountId, actor.accountId,
@@ -148,6 +282,65 @@ export function schedulingRequestIsPast(
return slotEnds.every((value) => Number.isFinite(value) && value < now.getTime()); 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( export function applySchedulingAvailabilityChoice(
slotIds: string[], slotIds: string[],
current: Record<string, SchedulingAvailabilityValue | "">, current: Record<string, SchedulingAvailabilityValue | "">,

View File

@@ -1,9 +1,39 @@
export const generatedTranslations = { export const generatedTranslations = {
en: { 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.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.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.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_comments.d63202a6": "Allow comments",
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Allow external participants",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting", "i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity", "i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity",
"i18n:govoplan-scheduling.comment.d03495b1": "Comment", "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.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_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.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.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.require_an_email_address_from_guests.c2289a58": "Require an email address from guests",
"i18n:govoplan-scheduling.responses.3427e3ab": "Responses", "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." "i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
}, },
de: { 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.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.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.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_comments.d63202a6": "Kommentare erlauben",
"i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Externe Teilnehmende erlauben",
"i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend", "i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend",
"i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität", "i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität",
"i18n:govoplan-scheduling.comment.d03495b1": "Kommentar", "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.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_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.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.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.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten", "i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",

View File

@@ -1,23 +1,91 @@
import { createElement, lazy } from "react"; import { createElement, lazy } from "react";
import type { PlatformWebModule } from "@govoplan/core-webui"; import type {
DashboardWidgetsUiCapability,
PlatformWebModule
} from "@govoplan/core-webui";
import SchedulingRequestsWidget from "./features/scheduling/SchedulingRequestsWidget";
import { generatedTranslations } from "./i18n/generatedTranslations"; import { generatedTranslations } from "./i18n/generatedTranslations";
import "./styles/scheduling.css"; import "./styles/scheduling.css";
const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage")); const SchedulingPage = lazy(() => import("./features/scheduling/SchedulingPage"));
const SchedulingPublicPage = lazy(() => import("./features/scheduling/SchedulingPublicPage"));
const scheduleRead = ["scheduling:schedule:read"]; const scheduleRead = ["scheduling:schedule:read"];
const schedulingDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "scheduling.open-requests",
surfaceId: "scheduling.widget.open-requests",
title: "Scheduling requests",
description: "Open scheduling polls and their response progress.",
moduleId: "scheduling",
category: "Planning",
order: 45,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: scheduleRead,
refreshIntervalMs: 60_000,
defaultConfiguration: {
maxItems: 5,
includeDrafts: false
},
configurationFields: [
{
id: "maxItems",
label: "Maximum requests",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
},
{
id: "includeDrafts",
label: "Include drafts",
kind: "boolean"
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(SchedulingRequestsWidget, {
settings,
refreshKey,
configuration
})
}
]
};
export const schedulingModule: PlatformWebModule = { export const schedulingModule: PlatformWebModule = {
id: "scheduling", id: "scheduling",
label: "Scheduling", label: "Scheduling",
version: "0.1.10", version: "0.1.11",
dependencies: ["poll"], dependencies: ["poll"],
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"], optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
translations: generatedTranslations, translations: generatedTranslations,
viewSurfaces: [
{
id: "scheduling.widget.open-requests",
moduleId: "scheduling",
kind: "section",
label: "Scheduling requests widget",
order: 45
}
],
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }], navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
routes: [ routes: [
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) } { 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 })
}
],
uiCapabilities: {
"dashboard.widgets": schedulingDashboardWidgets
}
}; };
export default schedulingModule; export default schedulingModule;

View File

@@ -7,6 +7,110 @@
background: var(--bg); 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 *,
.scheduling-page *::before, .scheduling-page *::before,
.scheduling-page *::after { .scheduling-page *::after {

View File

@@ -4,10 +4,103 @@ import type { SchedulingRequest } from "../src/api/scheduling.ts";
import { import {
applySchedulingAvailabilityChoice, applySchedulingAvailabilityChoice,
groupSchedulingRequests, groupSchedulingRequests,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingInvitationActionBlocks,
schedulingPublicInvitationUrl,
schedulingSortPhase, schedulingSortPhase,
type SchedulingActor type SchedulingActor
} from "../src/features/scheduling/schedulingViewModel.ts"; } 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 now = new Date("2026-07-20T10:00:00Z");
const actor: SchedulingActor = { const actor: SchedulingActor = {
accountId: "account-1", accountId: "account-1",
@@ -57,6 +150,7 @@ function request(
anonymous_password_protection_enabled: false, anonymous_password_protection_enabled: false,
public_participation_policy_enforcement_available: false, public_participation_policy_enforcement_available: false,
public_participation_policy_enforcement_reason: "Public participation gateway not installed", public_participation_policy_enforcement_reason: "Public participation gateway not installed",
participant_invitation_delivery_available: false,
calendar_integration_enabled: false, calendar_integration_enabled: false,
calendar_freebusy_enabled: false, calendar_freebusy_enabled: false,
calendar_hold_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"); 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", () => { test("single-choice availability keeps one positive choice while preserving explicit no answers", () => {
const next = applySchedulingAvailabilityChoice( const next = applySchedulingAvailabilityChoice(
["slot-a", "slot-b", "slot-c"], ["slot-a", "slot-b", "slot-c"],