From ea2f72137793bda222c2003f3c6b8077884ab0b2 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Wed, 22 Jul 2026 03:17:13 +0200 Subject: [PATCH] feat(scheduling): use central people picker --- README.md | 26 ++- src/govoplan_scheduling/backend/manifest.py | 9 +- src/govoplan_scheduling/backend/router.py | 82 ++++--- src/govoplan_scheduling/backend/schemas.py | 30 +-- tests/test_manifest.py | 5 +- tests/test_people_search.py | 138 ++++++++++++ .../test-scheduling-page-structure.mjs | 13 +- webui/src/api/scheduling.ts | 40 ++-- .../features/scheduling/SchedulingPage.tsx | 204 ++++++------------ .../scheduling/schedulingViewModel.ts | 116 ++++++++++ webui/src/i18n/generatedTranslations.ts | 6 + webui/src/module.ts | 2 +- webui/tests/scheduling-view-model.test.ts | 89 ++++++++ 13 files changed, 529 insertions(+), 231 deletions(-) create mode 100644 tests/test_people_search.py diff --git a/README.md b/README.md index 21b4a20..d3ea533 100644 --- a/README.md +++ b/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 participants, and hand off to Calendar or Appointments. -The manifest declares `access` and `evaluation` as optional dependencies. -Scheduling may use Access for identity, groups, and permissions, and may trigger -post-event or post-appointment feedback through Evaluation. It must not require -either module just to find a meeting time. +The manifest declares `access`, `addresses`, and `evaluation` as optional +dependencies. Scheduling uses Core's principal-aware people-search boundary to +combine only the account and contact records visible to the current organizer; +it never calls the instance-wide Identity search. It may trigger post-event or +post-appointment feedback through Evaluation, and must not require any of these +optional modules just to find a meeting time. ## Expected Integrations @@ -98,6 +100,22 @@ either module just to find a meeting time. - `govoplan-portal`: external participant scheduling flows - `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected +## Participant selection boundary + +The editor uses Core's shared `PeoplePicker`. Its server-side search aggregates +the optional `access.people_search` and `addresses.people_search` capabilities, +and each provider applies the active principal and tenant visibility rules +before returning a candidate. Scheduling exposes only the fields needed to +select a person; provider provenance, address-book topology, group membership, +and other internals are not returned by its picker endpoint. + +An account selection becomes an internal participant bound to that account. +A visible address-book contact becomes an external participant with a bounded +directory-selection reference and revision for later organizer editing. Manual +name-and-email entry is available only while the request allows external +participants. The picker stores neither provider-internal provenance nor a +global Identity record reference in participant metadata. + ## First Package Scaffold Decision The first backend implementation slice adds runtime APIs and storage around diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 5dedb5e..92e1de6 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -20,6 +20,10 @@ from govoplan_core.core.modules import ( ) from govoplan_core.db.base import Base 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_poll.backend.participation import CAPABILITY_POLL_PARTICIPATION_GATEWAY 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_CALENDAR_SCHEDULING, CAPABILITY_POLICY_SCHEDULING_PARTICIPANT_PRIVACY, + CAPABILITY_ACCESS_PEOPLE_SEARCH, + CAPABILITY_ADDRESSES_PEOPLE_SEARCH, ), required_capabilities=( 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="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), - ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name=CAPABILITY_ACCESS_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), + ModuleInterfaceRequirement(name=CAPABILITY_ADDRESSES_PEOPLE_SEARCH, version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ModuleInterfaceRequirement(name="calendar.scheduling", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True), ), permissions=PERMISSIONS, diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 44d7189..d0c3c30 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -1,6 +1,5 @@ from __future__ import annotations -import dataclasses from typing import Any 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.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE +from govoplan_core.core.people import search_visible_people from govoplan_core.db.session import get_session from govoplan_scheduling.backend.manifest import ADMIN_SCOPE, READ_SCOPE, RESPOND_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( - SchedulingAddressLookupCandidate, - SchedulingAddressLookupResponse, SchedulingAvailabilityResponse, SchedulingAvailabilityResponseRequest, SchedulingCalendarActionResponse, @@ -24,6 +22,9 @@ from govoplan_scheduling.backend.schemas import ( SchedulingNotificationCreateRequest, SchedulingNotificationListResponse, SchedulingNotificationResponse, + SchedulingPeopleSearchCandidate, + SchedulingPeopleSearchGroup, + SchedulingPeopleSearchResponse, SchedulingRequestCreateRequest, SchedulingRequestListResponse, SchedulingRequestResponse, @@ -71,39 +72,6 @@ from govoplan_scheduling.backend.service import ( 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: @@ -285,21 +253,43 @@ def api_submit_public_scheduling_participation( return validated -@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse) -def api_lookup_scheduling_addresses( +@router.get("/people", response_model=SchedulingPeopleSearchResponse) +def api_search_scheduling_people( query: str = Query(min_length=1), limit: int = Query(default=25, ge=1, le=100), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), -) -> SchedulingAddressLookupResponse: +) -> SchedulingPeopleSearchResponse: _require_scheduling_writer(principal) - capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP) - if capability is None or not hasattr(capability, "lookup"): - return SchedulingAddressLookupResponse(available=False, candidates=[]) - candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit) - return SchedulingAddressLookupResponse( - available=True, - candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates], + groups = search_visible_people( + get_registry(), + session, + principal, + query=query, + limit=limit, + ) + return SchedulingPeopleSearchResponse( + groups=[ + SchedulingPeopleSearchGroup( + key=group.key, + label=group.label, + candidates=[ + SchedulingPeopleSearchCandidate( + selection_key=candidate.selection_key, + kind=candidate.kind, + reference_id=candidate.reference_id, + display_name=candidate.display_name, + email=candidate.email, + source_module=candidate.source_module, + source_label=candidate.source_label, + source_revision=candidate.source_revision, + description=candidate.description, + ) + for candidate in group.candidates + ], + ) + for group in groups + ] ) diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 74fd932..ab6b59e 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -515,21 +515,25 @@ class SchedulingInvitationActionResponse(BaseModel): notification: SchedulingNotificationResponse | None = None -class SchedulingAddressLookupCandidate(BaseModel): - contact_id: str - address_book_id: str +class SchedulingPeopleSearchCandidate(BaseModel): + """Opaque, task-safe projection of a visible directory candidate.""" + + selection_key: str + kind: str + reference_id: str display_name: str email: str | None = None - email_label: str | None = None - organization: str | None = None - role_title: str | None = None - tags: list[str] = Field(default_factory=list) - source_kind: str = "local" - source_ref: str | None = None + source_module: str | None = None + source_label: str | None = None source_revision: str | None = None - provenance: dict[str, Any] = Field(default_factory=dict) + description: str | None = None -class SchedulingAddressLookupResponse(BaseModel): - available: bool = False - candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list) +class SchedulingPeopleSearchGroup(BaseModel): + key: str + label: str + candidates: list[SchedulingPeopleSearchCandidate] = Field(default_factory=list) + + +class SchedulingPeopleSearchResponse(BaseModel): + groups: list[SchedulingPeopleSearchGroup] = Field(default_factory=list) diff --git a/tests/test_manifest.py b/tests/test_manifest.py index d34c59f..83295ee 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -25,6 +25,8 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("poll.scheduling", manifest.required_capabilities) self.assertIn("calendar.scheduling", manifest.optional_capabilities) self.assertIn("policy.schedulingParticipantPrivacy", manifest.optional_capabilities) + self.assertIn("access.people_search", manifest.optional_capabilities) + self.assertIn("addresses.people_search", manifest.optional_capabilities) self.assertIn("evaluation", manifest.optional_dependencies) self.assertIsNotNone(manifest.route_factory) self.assertIsNotNone(manifest.migration_spec) @@ -38,7 +40,8 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.governed_participation", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces}) - self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces}) + self.assertIn("access.people_search", {interface.name for interface in manifest.requires_interfaces}) + self.assertIn("addresses.people_search", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("calendar.scheduling", {interface.name for interface in manifest.requires_interfaces}) required_interfaces = {interface.name: interface for interface in manifest.requires_interfaces} for interface_name in ( diff --git a/tests/test_people_search.py b/tests/test_people_search.py new file mode 100644 index 0000000..1624191 --- /dev/null +++ b/tests/test_people_search.py @@ -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() diff --git a/webui/scripts/test-scheduling-page-structure.mjs b/webui/scripts/test-scheduling-page-structure.mjs index 84a4cfe..3e30679 100644 --- a/webui/scripts/test-scheduling-page-structure.mjs +++ b/webui/scripts/test-scheduling-page-structure.mjs @@ -15,7 +15,7 @@ assert.match(page, /usePlatformUiCapability\("calend assert.match(page, /hasScope\(auth, "calendar:calendar:read"\)/); assert.match(page, /Boolean\(calendarPickerCapability\) && canReadCalendars && canReadAvailability && canWriteCalendarEvent/); assert.doesNotMatch(page, /@govoplan\/calendar-webui|govoplan-calendar\/webui/); -assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridEmptyAction,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/); +assert.match(page, /Card,[\s\S]*DataGrid,[\s\S]*DataGridRowActions,[\s\S]*FormField,[\s\S]*MetricCard,[\s\S]*PasswordField,[\s\S]*PeoplePicker,[\s\S]*SelectionList,[\s\S]*ToggleSwitch,[\s\S]*from "@govoplan\/core-webui"/); assert.doesNotMatch(page, /@govoplan\/core-webui\/src\//); assert.match(page, /className="scheduling-workspace-layout"/); @@ -63,20 +63,21 @@ for (const setting of [ assert.match(page, / \(\{/); assert.match(page, /participants: participants[\s\S]*create_participant_invitations: true/); assert.match(api, /\/api\/v1\/scheduling\/requests\/\$\{requestId\}\/responses/); diff --git a/webui/src/api/scheduling.ts b/webui/src/api/scheduling.ts index 50b31a1..73fa193 100644 --- a/webui/src/api/scheduling.ts +++ b/webui/src/api/scheduling.ts @@ -1,4 +1,8 @@ -import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; +import { + apiFetch, + type ApiSettings, + type PeoplePickerSearchGroup +} from "@govoplan/core-webui"; export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived"; export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses"; @@ -299,31 +303,25 @@ export type SchedulingSummaryResponse = { }; }; -export type SchedulingAddressLookupCandidate = { - contact_id: string; - address_book_id: string; - display_name: string; - email?: string | null; - email_label?: string | null; - organization?: string | null; - role_title?: string | null; - tags: string[]; - source_kind: string; - source_ref?: string | null; - source_revision?: string | null; - provenance: Record; -}; - -export type SchedulingAddressLookupResponse = { - available: boolean; - candidates: SchedulingAddressLookupCandidate[]; +export type SchedulingPeopleSearchResponse = { + groups: PeoplePickerSearchGroup[]; }; const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) }); -export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise { +export async function searchSchedulingPeople( + settings: ApiSettings, + query: string, + limit = 25, + signal?: AbortSignal +): Promise { const params = new URLSearchParams({ query, limit: String(limit) }); - return apiFetch(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`); + const response = await apiFetch( + settings, + `/api/v1/scheduling/people?${params.toString()}`, + { signal } + ); + return response.groups; } export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise { diff --git a/webui/src/features/scheduling/SchedulingPage.tsx b/webui/src/features/scheduling/SchedulingPage.tsx index 4111c54..086946f 100644 --- a/webui/src/features/scheduling/SchedulingPage.tsx +++ b/webui/src/features/scheduling/SchedulingPage.tsx @@ -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 { Bell, @@ -16,7 +16,6 @@ import { Button, Card, DataGrid, - DataGridEmptyAction, DataGridRowActions, DateTimeField, DismissibleAlert, @@ -25,6 +24,7 @@ import { IconButton, PageTitle, PasswordField, + PeoplePicker, formatDateTime, SelectionList, SelectionListItem, @@ -41,7 +41,9 @@ import { type AuthInfo, type CalendarPickerUiCapability, type DataGridColumn, - type FormatDateTimeOptions + type FormatDateTimeOptions, + type PeoplePickerItem, + type PeoplePickerSearch } from "@govoplan/core-webui"; import { closeSchedulingRequest, @@ -55,6 +57,7 @@ import { listSchedulingNotifications, listSchedulingRequests, openSchedulingRequest, + searchSchedulingPeople, schedulingSummary, submitSchedulingAvailability, updateSchedulingRequest, @@ -70,10 +73,14 @@ import { applySchedulingAvailabilityChoice, groupSchedulingRequests, schedulingParticipantForActor, + participantDraftFromResponse, + participantDraftsFromPicker, + participantPayload, schedulingRelevantTimestamp, schedulingRequestIsOwned, schedulingSortPhase, type SchedulingActor, + type SchedulingParticipantDraft, type SchedulingRequestGroups } from "./schedulingViewModel"; @@ -89,17 +96,7 @@ type SlotDraft = { timezone: string; metadata?: Record; }; -type ParticipantDraft = { - draftId: string; - sourceId?: string; - respondent_id?: string | null; - display_name: string; - email: string; - participant_type: "internal" | "external" | "resource"; - required: boolean; - metadata?: Record; - identityLocked?: boolean; -}; +type ParticipantDraft = SchedulingParticipantDraft; const I18N = { actions: "i18n:govoplan-core.actions.c3cd636a", @@ -174,6 +171,9 @@ const I18N = { participantRosterVisibility: "i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0", participantRosterVisibilityHelp: "i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361", participants: "i18n:govoplan-scheduling.participants.cd56e083", + participantPickerHelp: "i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44", + allowExternalParticipants: "i18n:govoplan-scheduling.allow_external_participants.a9efcb52", + allowExternalParticipantsHelp: "i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735", participation: "i18n:govoplan-scheduling.participation.9ad70cc4", participationRate: "i18n:govoplan-scheduling.participation_rate.46bc5504", responses: "i18n:govoplan-scheduling.responses.3427e3ab", @@ -235,7 +235,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin const [calendarId, setCalendarId] = useState(""); const [calendarEnabled, setCalendarEnabled] = useState(false); const [slots, setSlots] = useState(() => initialSlots(translateText)); - const [participants, setParticipants] = useState(() => [emptyParticipant()]); + const [participants, setParticipants] = useState([]); + const [allowExternalParticipants, setAllowExternalParticipants] = useState(true); const [participantRosterVisible, setParticipantRosterVisible] = useState(false); const [notifyOnAnswers, setNotifyOnAnswers] = useState(true); const [singleChoice, setSingleChoice] = useState(false); @@ -323,6 +324,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin const participationPolicyLocked = Boolean( editorOriginal?.participants.some((participant) => participant.poll_invitation_id) ); + const participantSearch = useCallback( + (query, options) => searchSchedulingPeople(settings, query, options.limit, options.signal), + [settings] + ); useEffect(() => { void loadRequests(); @@ -482,17 +487,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin timezone: slot.timezone, metadata: slot.metadata }))); - setParticipants(request.participants.map((participant) => ({ - draftId: nextDraftId("participant"), - sourceId: participant.id, - respondent_id: participant.respondent_id, - display_name: participant.display_name ?? "", - email: participant.email ?? "", - participant_type: normalizeParticipantType(participant.participant_type), - required: participant.required ?? true, - metadata: participant.metadata, - identityLocked: Boolean(participant.poll_invitation_id) - }))); + setParticipants(request.participants.map((participant) => ( + participantDraftFromResponse(participant, nextDraftId("participant")) + ))); + setAllowExternalParticipants(request.allow_external_participants); setParticipantRosterVisible(request.participant_visibility === "names_and_statuses"); setNotifyOnAnswers(request.notify_on_answers); setSingleChoice(request.single_choice); @@ -555,6 +553,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin title: title.trim(), description: description.trim() || null, location: location.trim() || null, + allow_external_participants: allowExternalParticipants, participant_visibility: participantRosterVisible ? "names_and_statuses" : "aggregates_only", notify_on_answers: notifyOnAnswers, single_choice: singleChoice, @@ -587,16 +586,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin location: location.trim() || null, metadata: slot.metadata ?? {} })), - participants: participants - .filter((participant) => participant.email.trim() || participant.display_name.trim()) - .map((participant) => ({ - respondent_id: participant.respondent_id ?? null, - display_name: participant.display_name.trim() || null, - email: participant.email.trim() || null, - required: participant.required, - participant_type: participant.participant_type, - metadata: participant.metadata ?? {} - })), + participants: participants.map(participantPayload), create_participant_invitations: true }); setRequests((items) => [request, ...items]); @@ -617,17 +607,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin location: location.trim() || null, metadata: slot.metadata ?? {} })), - participants: participants - .filter((participant) => participant.sourceId || participant.email.trim() || participant.display_name.trim()) - .map((participant) => ({ - ...(participant.sourceId ? { id: participant.sourceId } : {}), - respondent_id: participant.respondent_id ?? null, - display_name: participant.display_name.trim() || null, - email: participant.email.trim() || null, - participant_type: participant.participant_type, - required: participant.required, - metadata: participant.metadata ?? {} - })), + participants: participants.map(participantPayload), create_participant_invitations: true }); replaceRequest(request); @@ -851,16 +831,18 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin { - setParticipants((items) => insertAfter(items, index, emptyParticipant())); + allowExternalParticipants={allowExternalParticipants} + search={participantSearch} + onAllowExternalParticipantsChange={(value) => { + setAllowExternalParticipants(value); setDraftDirty(true); }} - onChange={(index, patch) => { - setParticipants((items) => items.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch } : item)); - setDraftDirty(true); - }} - onRemove={(index) => { - setParticipants((items) => items.filter((_item, itemIndex) => itemIndex !== index)); + onChange={(value) => { + setParticipants((current) => participantDraftsFromPicker( + value, + current, + () => nextDraftId("participant") + )); setDraftDirty(true); }} /> @@ -1174,7 +1156,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin setCalendarId(""); setCalendarEnabled(false); setSlots(initialSlots(translateText)); - setParticipants([emptyParticipant()]); + setParticipants([]); + setAllowExternalParticipants(true); setParticipantRosterVisible(false); setNotifyOnAnswers(true); setSingleChoice(false); @@ -1518,82 +1501,40 @@ function EditableSlots({ function EditableParticipants({ participants, disabled, - onAddBelow, - onChange, - onRemove + allowExternalParticipants, + search, + onAllowExternalParticipantsChange, + onChange }: { participants: ParticipantDraft[]; disabled: boolean; - onAddBelow: (index: number) => void; - onChange: (index: number, patch: Partial) => void; - onRemove: (index: number) => void; + allowExternalParticipants: boolean; + search: PeoplePickerSearch; + onAllowExternalParticipantsChange: (value: boolean) => void; + onChange: (participants: PeoplePickerItem[]) => void; }) { - const lockedIdentityHelpId = "scheduling-invited-participant-identity-help"; - const columns: DataGridColumn[] = [ - { - id: "name", - header: I18N.name, - minWidth: 180, - resizable: true, - value: (participant) => participant.display_name, - render: (participant, index) => ( - onChange(index, { display_name: event.target.value })} /> - ) - }, - { - id: "email", - header: I18N.participantEmail, - minWidth: 260, - resizable: true, - value: (participant) => participant.email, - render: (participant, index) => ( - onChange(index, { email: event.target.value })} /> - ) - }, - { - id: "actions", - header: I18N.actions, - width: 112, - sticky: "end", - render: (_participant, index) => ( - onAddBelow(index)} - onRemove={() => onRemove(index)} - addLabel={I18N.addParticipant} - removeLabel={i18nMessage("i18n:govoplan-scheduling.remove_participant_value.e55f2b70", { value0: index + 1 })} /> - ) - } - ]; - return ( {participants.some((participant) => participant.identityLocked) ? ( -

{I18N.invitedIdentityLocked}

+

{I18N.invitedIdentityLocked}

) : null} - participant.draftId} - initialFit="container" - emptyText={I18N.noParticipants} - emptyActionColumnId="actions" - emptyAction={ onAddBelow(-1)} label={I18N.addParticipant} />} /> + +
); } @@ -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; function nextDraftId(prefix: string): string { diff --git a/webui/src/features/scheduling/schedulingViewModel.ts b/webui/src/features/scheduling/schedulingViewModel.ts index 1ad5484..a15e833 100644 --- a/webui/src/features/scheduling/schedulingViewModel.ts +++ b/webui/src/features/scheduling/schedulingViewModel.ts @@ -1,8 +1,24 @@ import type { SchedulingAvailabilityValue, SchedulingParticipant, + SchedulingParticipantPayload, SchedulingRequest } from "../../api/scheduling"; +import type { PeoplePickerItem } from "@govoplan/core-webui"; + +const DIRECTORY_SELECTION_METADATA_KEY = "directory_selection"; + +export type SchedulingParticipantDraft = PeoplePickerItem & { + draftId: string; + sourceId?: string; + respondent_id?: string | null; + display_name: string; + email: string; + participant_type: "internal" | "external" | "resource"; + required: boolean; + metadata?: Record; + identityLocked?: boolean; +}; export type SchedulingActor = { accountId?: string | null; @@ -25,6 +41,106 @@ export type SchedulingSortPhase = | "determined" | "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): 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 { + 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[] { return Array.from(new Set([ actor.accountId, diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index 4961c90..1b00b01 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -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.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Add Maybe between Available and Unavailable for each candidate slot.", "i18n:govoplan-scheduling.allow_comments.d63202a6": "Allow comments", + "i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Allow external participants", "i18n:govoplan-scheduling.awaiting.42aa82e0": "Awaiting", "i18n:govoplan-scheduling.capacity.d3c375f8": "Capacity", "i18n:govoplan-scheduling.comment.d03495b1": "Comment", @@ -35,6 +36,8 @@ export const generatedTranslations = { "i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Password-protect guest access", "i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "People responding without an account must provide an email address.", "i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "People who are not signed in must enter this password before viewing the request.", + "i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Search accounts and contacts you are allowed to discover. If external participants are enabled, you can also add a name and email address manually.", + "i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "When enabled, people outside the configured accounts and contacts can be added manually.", "i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Provide a Maybe option", "i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "Require an email address from guests", "i18n:govoplan-scheduling.responses.3427e3ab": "Responses", @@ -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.add_maybe_between_yes_and_no_for_each_candidate_slot.74dc9db6": "Für jeden Terminvorschlag Vielleicht zwischen Verfügbar und Nicht verfügbar anbieten.", "i18n:govoplan-scheduling.allow_comments.d63202a6": "Kommentare erlauben", + "i18n:govoplan-scheduling.allow_external_participants.a9efcb52": "Externe Teilnehmende erlauben", "i18n:govoplan-scheduling.awaiting.42aa82e0": "Ausstehend", "i18n:govoplan-scheduling.capacity.d3c375f8": "Kapazität", "i18n:govoplan-scheduling.comment.d03495b1": "Kommentar", @@ -184,6 +188,8 @@ export const generatedTranslations = { "i18n:govoplan-scheduling.password_protect_guest_access.13d7f08b": "Gastzugang mit Passwort schützen", "i18n:govoplan-scheduling.people_responding_without_an_account_must_provide_an_email.19fd3dc8": "Personen ohne Konto müssen für ihre Antwort eine E-Mail-Adresse angeben.", "i18n:govoplan-scheduling.people_who_are_not_signed_in_must_enter_this_password.82bcc4ce": "Nicht angemeldete Personen müssen dieses Passwort eingeben, bevor sie die Anfrage sehen können.", + "i18n:govoplan-scheduling.search_visible_accounts_and_contacts_or_add_an_external_perso.877f6b44": "Suchen Sie nach Konten und Kontakten, die Sie sehen dürfen. Wenn externe Teilnehmende erlaubt sind, können Sie Name und E-Mail-Adresse auch manuell hinzufügen.", + "i18n:govoplan-scheduling.when_enabled_people_outside_the_configured_accounts_and.78829735": "Wenn diese Option aktiviert ist, können Personen außerhalb der eingerichteten Konten und Kontakte manuell hinzugefügt werden.", "i18n:govoplan-scheduling.provide_a_maybe_option.e39da57a": "Antwort Vielleicht anbieten", "i18n:govoplan-scheduling.require_an_email_address_from_guests.c2289a58": "E-Mail-Adresse von Gästen verlangen", "i18n:govoplan-scheduling.responses.3427e3ab": "Antworten", diff --git a/webui/src/module.ts b/webui/src/module.ts index 048efe6..30886be 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -13,7 +13,7 @@ export const schedulingModule: PlatformWebModule = { label: "Scheduling", version: "0.1.10", dependencies: ["poll"], - optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"], + optionalDependencies: ["access", "calendar", "mail", "notifications", "workflow", "appointments", "addresses"], translations: generatedTranslations, navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }], routes: [ diff --git a/webui/tests/scheduling-view-model.test.ts b/webui/tests/scheduling-view-model.test.ts index f1be5fe..e44c0ea 100644 --- a/webui/tests/scheduling-view-model.test.ts +++ b/webui/tests/scheduling-view-model.test.ts @@ -4,10 +4,99 @@ import type { SchedulingRequest } from "../src/api/scheduling.ts"; import { applySchedulingAvailabilityChoice, groupSchedulingRequests, + participantDraftFromResponse, + participantDraftsFromPicker, + participantPayload, schedulingSortPhase, type SchedulingActor } from "../src/features/scheduling/schedulingViewModel.ts"; +test("maps visible account and contact selections into bounded scheduling participant payloads", () => { + let sequence = 0; + const selected = participantDraftsFromPicker([ + { + selection_key: "account:account-2", + kind: "account", + reference_id: "account-2", + display_name: "Ada Account", + email: "ADA@EXAMPLE.TEST", + source_module: "access", + source_label: "Accounts", + provenance: { tenant_id: "must-not-be-persisted" }, + metadata: { group_ids: ["must-not-be-persisted"] } + }, + { + selection_key: "contact:contact-3:contact@example.test", + kind: "contact", + reference_id: "contact-3", + display_name: "Contact Person", + email: "contact@example.test", + source_module: "addresses", + source_label: "Contacts", + source_revision: "revision-3", + provenance: { address_book_id: "must-not-be-persisted" } + } + ], [], () => `participant-${++sequence}`); + + assert.equal(selected[0].respondent_id, "account-2"); + assert.equal(selected[0].participant_type, "internal"); + assert.equal(selected[0].email, "ada@example.test"); + assert.deepEqual(selected[0].metadata, { + directory_selection: { + selection_key: "account:account-2", + kind: "account", + reference_id: "account-2", + source_module: "access", + source_label: "Accounts", + source_revision: null + } + }); + assert.equal(selected[1].respondent_id, null); + assert.equal(selected[1].participant_type, "external"); + assert.equal( + (selected[1].metadata?.directory_selection as { source_revision: string }).source_revision, + "revision-3" + ); + assert.deepEqual(participantPayload(selected[1]), { + respondent_id: null, + display_name: "Contact Person", + email: "contact@example.test", + participant_type: "external", + required: true, + metadata: selected[1].metadata + }); +}); + +test("reconstructs saved directory selections and preserves existing reconciliation identity", () => { + const responseParticipant = { + id: "stored-participant", + 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 actor: SchedulingActor = { accountId: "account-1",