feat(scheduling): use central people picker

This commit is contained in:
2026-07-22 03:17:13 +02:00
parent 2cb86c90dc
commit ea2f721377
13 changed files with 529 additions and 231 deletions

View File

@@ -82,10 +82,12 @@ uses Poll context fields to point back to its request or proposal resource.
Typical workflow steps are collect availability, rank candidates, decide, notify Typical workflow steps are collect availability, rank candidates, decide, notify
participants, and hand off to Calendar or Appointments. participants, and hand off to Calendar or Appointments.
The manifest declares `access` and `evaluation` as optional dependencies. The manifest declares `access`, `addresses`, and `evaluation` as optional
Scheduling may use Access for identity, groups, and permissions, and may trigger dependencies. Scheduling uses Core's principal-aware people-search boundary to
post-event or post-appointment feedback through Evaluation. It must not require combine only the account and contact records visible to the current organizer;
either module just to find a meeting time. it never calls the instance-wide Identity search. It may trigger post-event or
post-appointment feedback through Evaluation, and must not require any of these
optional modules just to find a meeting time.
## Expected Integrations ## Expected Integrations
@@ -98,6 +100,22 @@ either module just to find a meeting time.
- `govoplan-portal`: external participant scheduling flows - `govoplan-portal`: external participant scheduling flows
- `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected - `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected
## Participant selection boundary
The editor uses Core's shared `PeoplePicker`. Its server-side search aggregates
the optional `access.people_search` and `addresses.people_search` capabilities,
and each provider applies the active principal and tenant visibility rules
before returning a candidate. Scheduling exposes only the fields needed to
select a person; provider provenance, address-book topology, group membership,
and other internals are not returned by its picker endpoint.
An account selection becomes an internal participant bound to that account.
A visible address-book contact becomes an external participant with a bounded
directory-selection reference and revision for later organizer editing. Manual
name-and-email entry is available only while the request allows external
participants. The picker stores neither provider-internal provenance nor a
global Identity record reference in participant metadata.
## First Package Scaffold Decision ## First Package Scaffold Decision
The first backend implementation slice adds runtime APIs and storage around The first backend implementation slice adds runtime APIs and storage around

View File

@@ -20,6 +20,10 @@ from govoplan_core.core.modules import (
) )
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING from govoplan_core.core.poll import CAPABILITY_POLL_SCHEDULING
from govoplan_core.core.people import (
CAPABILITY_ACCESS_PEOPLE_SEARCH,
CAPABILITY_ADDRESSES_PEOPLE_SEARCH,
)
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_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY
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
@@ -138,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,
@@ -154,7 +160,8 @@ manifest = ModuleManifest(
ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.governed_participation", version_min="0.1.10", version_max_exclusive="0.2.0"),
ModuleInterfaceRequirement(name="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,

View File

@@ -1,6 +1,5 @@
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, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
@@ -9,11 +8,10 @@ from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event 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,
@@ -24,6 +22,9 @@ from govoplan_scheduling.backend.schemas import (
SchedulingNotificationCreateRequest, SchedulingNotificationCreateRequest,
SchedulingNotificationListResponse, SchedulingNotificationListResponse,
SchedulingNotificationResponse, SchedulingNotificationResponse,
SchedulingPeopleSearchCandidate,
SchedulingPeopleSearchGroup,
SchedulingPeopleSearchResponse,
SchedulingRequestCreateRequest, SchedulingRequestCreateRequest,
SchedulingRequestListResponse, SchedulingRequestListResponse,
SchedulingRequestResponse, SchedulingRequestResponse,
@@ -71,39 +72,6 @@ from govoplan_scheduling.backend.service import (
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:
@@ -285,21 +253,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
]
) )

View File

@@ -515,21 +515,25 @@ class SchedulingInvitationActionResponse(BaseModel):
notification: SchedulingNotificationResponse | None = None notification: SchedulingNotificationResponse | None = None
class SchedulingAddressLookupCandidate(BaseModel): class SchedulingPeopleSearchCandidate(BaseModel):
contact_id: str """Opaque, task-safe projection of a visible directory candidate."""
address_book_id: str
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)

View File

@@ -25,6 +25,8 @@ 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)
@@ -38,7 +40,8 @@ class SchedulingManifestTests(unittest.TestCase):
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 (

138
tests/test_people_search.py Normal file
View File

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

View File

@@ -15,7 +15,7 @@ assert.match(page, /usePlatformUiCapability<CalendarPickerUiCapability>\("calend
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"/);
@@ -63,20 +63,21 @@ 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.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\}/);
@@ -108,6 +109,8 @@ for (const field of [
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[\s\S]*create_participant_invitations: true/);
assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/);

View File

@@ -1,4 +1,8 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; import {
apiFetch,
type ApiSettings,
type PeoplePickerSearchGroup
} from "@govoplan/core-webui";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived"; export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses"; export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
@@ -299,31 +303,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> {

View File

@@ -1,4 +1,4 @@
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,
@@ -16,7 +16,6 @@ import {
Button, Button,
Card, Card,
DataGrid, DataGrid,
DataGridEmptyAction,
DataGridRowActions, DataGridRowActions,
DateTimeField, DateTimeField,
DismissibleAlert, DismissibleAlert,
@@ -25,6 +24,7 @@ import {
IconButton, IconButton,
PageTitle, PageTitle,
PasswordField, PasswordField,
PeoplePicker,
formatDateTime, formatDateTime,
SelectionList, SelectionList,
SelectionListItem, SelectionListItem,
@@ -41,7 +41,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,
@@ -55,6 +57,7 @@ import {
listSchedulingNotifications, listSchedulingNotifications,
listSchedulingRequests, listSchedulingRequests,
openSchedulingRequest, openSchedulingRequest,
searchSchedulingPeople,
schedulingSummary, schedulingSummary,
submitSchedulingAvailability, submitSchedulingAvailability,
updateSchedulingRequest, updateSchedulingRequest,
@@ -70,10 +73,14 @@ import {
applySchedulingAvailabilityChoice, applySchedulingAvailabilityChoice,
groupSchedulingRequests, groupSchedulingRequests,
schedulingParticipantForActor, schedulingParticipantForActor,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
schedulingRelevantTimestamp, schedulingRelevantTimestamp,
schedulingRequestIsOwned, schedulingRequestIsOwned,
schedulingSortPhase, schedulingSortPhase,
type SchedulingActor, type SchedulingActor,
type SchedulingParticipantDraft,
type SchedulingRequestGroups type SchedulingRequestGroups
} from "./schedulingViewModel"; } from "./schedulingViewModel";
@@ -89,17 +96,7 @@ type SlotDraft = {
timezone: string; timezone: string;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
type ParticipantDraft = { type ParticipantDraft = SchedulingParticipantDraft;
draftId: string;
sourceId?: string;
respondent_id?: string | null;
display_name: string;
email: string;
participant_type: "internal" | "external" | "resource";
required: boolean;
metadata?: Record<string, unknown>;
identityLocked?: boolean;
};
const I18N = { const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a", actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -174,6 +171,9 @@ const I18N = {
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",
@@ -235,7 +235,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);
@@ -323,6 +324,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();
@@ -482,17 +487,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 +553,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,16 +586,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 create_participant_invitations: true
}); });
setRequests((items) => [request, ...items]); setRequests((items) => [request, ...items]);
@@ -617,17 +607,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 create_participant_invitations: true
}); });
replaceRequest(request); replaceRequest(request);
@@ -851,16 +831,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);
}} /> }} />
@@ -1174,7 +1156,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);
@@ -1518,82 +1501,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>
); );
} }
@@ -1792,21 +1733,6 @@ function newSlot(position: number, translateText: (value: string) => string): Sl
}; };
} }
function emptyParticipant(): ParticipantDraft {
return {
draftId: nextDraftId("participant"),
display_name: "",
email: "",
participant_type: "external",
required: true,
metadata: {}
};
}
function normalizeParticipantType(value: string | null): ParticipantDraft["participant_type"] {
return value === "internal" || value === "resource" ? value : "external";
}
let draftIdSequence = 0; let draftIdSequence = 0;
function nextDraftId(prefix: string): string { function nextDraftId(prefix: string): string {

View File

@@ -1,8 +1,24 @@
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;
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 +41,106 @@ export type SchedulingSortPhase =
| "determined" | "determined"
| "past"; | "past";
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,
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 } {
return {
...(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 ?? {}
};
}
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,

View File

@@ -13,6 +13,7 @@ export const generatedTranslations = {
"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",
@@ -35,6 +36,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",
@@ -162,6 +165,7 @@ export const generatedTranslations = {
"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",
@@ -184,6 +188,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen", "i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen",
"i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.", "i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.",
"i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.", "i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.",
"i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Suchen Sie nach Konten und Kontakten, die Sie sehen dürfen. Wenn externe Teilnehmende erlaubt sind, können Sie Name und E-Mail-Adresse auch manuell hinzufügen.",
"i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "Wenn diese Option aktiviert ist, können Personen außerhalb der eingerichteten Konten und Kontakte manuell hinzugefügt werden.",
"i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten", "i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten",
"i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen", "i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen",
"i18n:govoplan-scheduling.responses.3427e3ab": "Antworten", "i18n:govoplan-scheduling.responses.3427e3ab": "Antworten",

View File

@@ -13,7 +13,7 @@ export const schedulingModule: PlatformWebModule = {
label: "Scheduling", label: "Scheduling",
version: "0.1.10", version: "0.1.10",
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: [

View File

@@ -4,10 +4,99 @@ import type { SchedulingRequest } from "../src/api/scheduling.ts";
import { import {
applySchedulingAvailabilityChoice, applySchedulingAvailabilityChoice,
groupSchedulingRequests, groupSchedulingRequests,
participantDraftFromResponse,
participantDraftsFromPicker,
participantPayload,
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",
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");
});
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",