Compare commits
16 Commits
95928e0585
...
v0.1.11
| Author | SHA1 | Date | |
|---|---|---|---|
| ffb30a7aa3 | |||
| b328df67a3 | |||
| fc356e22c6 | |||
| f512784dd3 | |||
| ed39f83688 | |||
| 95c9f654e1 | |||
| 55447bd05c | |||
| 27fa24cf4d | |||
| 448546e487 | |||
| 835ad82916 | |||
| a316341226 | |||
| 58619484b6 | |||
| ea2f721377 | |||
| 2cb86c90dc | |||
| ed828685f6 | |||
| 4279ea2827 |
132
README.md
132
README.md
@@ -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 1–90 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
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
|
|
||||||
__all__ = ["__version__"]
|
__all__ = ["__version__"]
|
||||||
|
|
||||||
__version__ = "0.1.10"
|
__version__ = "0.1.11"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -15,17 +15,22 @@ 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.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 +142,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 +154,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,6 +171,13 @@ manifest = ModuleManifest(
|
|||||||
frontend=FrontendModule(
|
frontend=FrontendModule(
|
||||||
module_id=MODULE_ID,
|
module_id=MODULE_ID,
|
||||||
package_name="@govoplan/scheduling-webui",
|
package_name="@govoplan/scheduling-webui",
|
||||||
|
public_routes=(
|
||||||
|
PublicFrontendRoute(
|
||||||
|
path="/scheduling/public/:requestId/:token",
|
||||||
|
component="SchedulingPublicPage",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||||
),
|
),
|
||||||
route_factory=_scheduling_router,
|
route_factory=_scheduling_router,
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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,22 @@ 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,
|
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 +160,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,21 +254,43 @@ 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
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -311,7 +332,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 +340,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
|
||||||
|
|
||||||
@@ -411,7 +432,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 +440,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(
|
||||||
principal=principal,
|
session,
|
||||||
invitation_tokens=invitation_tokens,
|
principal=principal,
|
||||||
)
|
request_id=request_id,
|
||||||
|
participant_id=mutation.participant_id,
|
||||||
|
action=mutation.action,
|
||||||
|
details={
|
||||||
|
"replacement_participant_id": mutation.replacement_participant_id,
|
||||||
|
"changed_fields": list(mutation.changed_fields),
|
||||||
|
"invitation_revoked": mutation.invitation_revoked,
|
||||||
|
"retired_response_count": mutation.retired_response_count,
|
||||||
|
"notification_id": mutation.notification_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = _request_response(request, principal=principal)
|
||||||
session.commit()
|
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,
|
||||||
|
|||||||
@@ -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
@@ -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__":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
35
tests/test_module_boundary.py
Normal file
35
tests/test_module_boundary.py
Normal 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()
|
||||||
@@ -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,7 +185,11 @@ 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()
|
||||||
|
|
||||||
payload = self._participant_projection(request)
|
with patch(
|
||||||
|
"govoplan_scheduling.backend.service.notification_dispatch_provider",
|
||||||
|
return_value=object(),
|
||||||
|
):
|
||||||
|
payload = self._participant_projection(request)
|
||||||
response = SchedulingRequestResponse.model_validate(payload)
|
response = SchedulingRequestResponse.model_validate(payload)
|
||||||
|
|
||||||
self.assertEqual(request.participant_visibility, "aggregates_only")
|
self.assertEqual(request.participant_visibility, "aggregates_only")
|
||||||
@@ -212,6 +217,7 @@ class SchedulingParticipantPrivacyTests(unittest.TestCase):
|
|||||||
self.assertIsNone(response.create_calendar_event_on_decision)
|
self.assertIsNone(response.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
138
tests/test_people_search.py
Normal 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()
|
||||||
@@ -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,93 @@ 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),
|
||||||
|
):
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -206,7 +214,23 @@ class SchedulingServiceTests(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_create_request_creates_poll_slots_and_signed_invitations(self) -> None:
|
def _issue_copy(
|
||||||
|
self,
|
||||||
|
request: SchedulingRequest,
|
||||||
|
participant: SchedulingParticipant,
|
||||||
|
) -> str:
|
||||||
|
result = issue_scheduling_participant_invitation(
|
||||||
|
self.session,
|
||||||
|
tenant_id=request.tenant_id,
|
||||||
|
request_id=request.id,
|
||||||
|
participant_id=participant.id,
|
||||||
|
participant_revision=scheduling_participant_revision(participant),
|
||||||
|
action="copy",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(result.action_url)
|
||||||
|
return str(result.action_url).rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
def test_create_request_creates_poll_slots_without_implicit_invitations(self) -> None:
|
||||||
request, tokens = create_scheduling_request(
|
request, tokens = create_scheduling_request(
|
||||||
self.session,
|
self.session,
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -224,8 +248,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 +367,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 +622,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 +1095,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 +1134,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 +1264,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 +1477,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 +1507,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 +1550,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})
|
||||||
|
|||||||
@@ -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,7 +18,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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 }) }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
275
webui/src/features/scheduling/SchedulingPublicPage.tsx
Normal file
275
webui/src/features/scheduling/SchedulingPublicPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 | "">,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -4,19 +4,27 @@ 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"];
|
||||||
|
|
||||||
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,
|
||||||
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 })
|
||||||
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
|||||||
Reference in New Issue
Block a user