feat(scheduling): use central people picker
This commit is contained in:
26
README.md
26
README.md
@@ -82,10 +82,12 @@ uses Poll context fields to point back to its request or proposal resource.
|
|||||||
Typical workflow steps are collect availability, rank candidates, decide, notify
|
Typical workflow steps are collect availability, rank candidates, decide, notify
|
||||||
participants, and hand off to Calendar or Appointments.
|
participants, and hand off to Calendar or Appointments.
|
||||||
|
|
||||||
The manifest declares `access` and `evaluation` as optional dependencies.
|
The manifest declares `access`, `addresses`, and `evaluation` as optional
|
||||||
Scheduling may use Access for identity, groups, and permissions, and may trigger
|
dependencies. Scheduling uses Core's principal-aware people-search boundary to
|
||||||
post-event or post-appointment feedback through Evaluation. It must not require
|
combine only the account and contact records visible to the current organizer;
|
||||||
either module just to find a meeting time.
|
it never calls the instance-wide Identity search. It may trigger post-event or
|
||||||
|
post-appointment feedback through Evaluation, and must not require any of these
|
||||||
|
optional modules just to find a meeting time.
|
||||||
|
|
||||||
## Expected Integrations
|
## Expected Integrations
|
||||||
|
|
||||||
@@ -98,6 +100,22 @@ either module just to find a meeting time.
|
|||||||
- `govoplan-portal`: external participant scheduling flows
|
- `govoplan-portal`: external participant scheduling flows
|
||||||
- `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected
|
- `govoplan-workflow` and `govoplan-tasks`: follow-up work after a time is selected
|
||||||
|
|
||||||
|
## Participant selection boundary
|
||||||
|
|
||||||
|
The editor uses Core's shared `PeoplePicker`. Its server-side search aggregates
|
||||||
|
the optional `access.people_search` and `addresses.people_search` capabilities,
|
||||||
|
and each provider applies the active principal and tenant visibility rules
|
||||||
|
before returning a candidate. Scheduling exposes only the fields needed to
|
||||||
|
select a person; provider provenance, address-book topology, group membership,
|
||||||
|
and other internals are not returned by its picker endpoint.
|
||||||
|
|
||||||
|
An account selection becomes an internal participant bound to that account.
|
||||||
|
A visible address-book contact becomes an external participant with a bounded
|
||||||
|
directory-selection reference and revision for later organizer editing. Manual
|
||||||
|
name-and-email entry is available only while the request allows external
|
||||||
|
participants. The picker stores neither provider-internal provenance nor a
|
||||||
|
global Identity record reference in participant metadata.
|
||||||
|
|
||||||
## First Package Scaffold Decision
|
## First Package Scaffold Decision
|
||||||
|
|
||||||
The first backend implementation slice adds runtime APIs and storage around
|
The first backend implementation slice adds runtime APIs and storage around
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
138
tests/test_people_search.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.people import (
|
||||||
|
CAPABILITY_ACCESS_PEOPLE_SEARCH,
|
||||||
|
PeopleSearchGroup,
|
||||||
|
PersonSearchCandidate,
|
||||||
|
)
|
||||||
|
from govoplan_scheduling.backend.manifest import WRITE_SCOPE
|
||||||
|
from govoplan_scheduling.backend.router import api_search_scheduling_people
|
||||||
|
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||||
|
|
||||||
|
|
||||||
|
class _Registry:
|
||||||
|
def __init__(self, capabilities: dict[str, object] | None = None) -> None:
|
||||||
|
self.capabilities = capabilities or {}
|
||||||
|
|
||||||
|
def has_capability(self, name: str) -> bool:
|
||||||
|
return name in self.capabilities
|
||||||
|
|
||||||
|
def capability(self, name: str) -> object:
|
||||||
|
return self.capabilities[name]
|
||||||
|
|
||||||
|
|
||||||
|
class _PeopleProvider:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[object, object, str, int]] = []
|
||||||
|
|
||||||
|
def search_people(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
query: str,
|
||||||
|
limit: int = 25,
|
||||||
|
) -> tuple[PeopleSearchGroup, ...]:
|
||||||
|
self.calls.append((session, principal, query, limit))
|
||||||
|
return (
|
||||||
|
PeopleSearchGroup(
|
||||||
|
key="accounts",
|
||||||
|
label="Accounts",
|
||||||
|
candidates=(
|
||||||
|
PersonSearchCandidate(
|
||||||
|
selection_key="account:account-2",
|
||||||
|
kind="account",
|
||||||
|
reference_id="account-2",
|
||||||
|
display_name="Ada Lovelace",
|
||||||
|
email="ada@example.test",
|
||||||
|
source_module="access",
|
||||||
|
source_label="Accounts",
|
||||||
|
source_ref="access:account:account-2",
|
||||||
|
source_revision="revision-1",
|
||||||
|
description="Research",
|
||||||
|
provenance={"tenant_id": "tenant-1", "internal": "secret"},
|
||||||
|
metadata={"internal_group_ids": ["group-1"]},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _principal(*scopes: str) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
email="organizer@example.test",
|
||||||
|
display_name="Organizer",
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=SimpleNamespace(id="account-1"),
|
||||||
|
user=SimpleNamespace(id="membership-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SchedulingPeopleSearchTests(unittest.TestCase):
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
configure_runtime(registry=_Registry())
|
||||||
|
|
||||||
|
def test_search_uses_principal_aware_core_aggregator_and_redacts_provider_internals(self) -> None:
|
||||||
|
provider = _PeopleProvider()
|
||||||
|
registry = _Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider})
|
||||||
|
configure_runtime(registry=registry)
|
||||||
|
session = object()
|
||||||
|
principal = _principal(WRITE_SCOPE)
|
||||||
|
|
||||||
|
response = api_search_scheduling_people(
|
||||||
|
query="ada",
|
||||||
|
limit=12,
|
||||||
|
session=session, # type: ignore[arg-type] - provider contract is intentionally generic
|
||||||
|
principal=principal,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([(session, principal, "ada", 12)], provider.calls)
|
||||||
|
payload = response.model_dump()
|
||||||
|
self.assertEqual("account:account-2", payload["groups"][0]["candidates"][0]["selection_key"])
|
||||||
|
self.assertEqual("revision-1", payload["groups"][0]["candidates"][0]["source_revision"])
|
||||||
|
self.assertNotIn("source_ref", payload["groups"][0]["candidates"][0])
|
||||||
|
self.assertNotIn("provenance", payload["groups"][0]["candidates"][0])
|
||||||
|
self.assertNotIn("metadata", payload["groups"][0]["candidates"][0])
|
||||||
|
|
||||||
|
def test_search_is_empty_when_no_optional_directory_provider_is_installed(self) -> None:
|
||||||
|
configure_runtime(registry=_Registry())
|
||||||
|
|
||||||
|
response = api_search_scheduling_people(
|
||||||
|
query="ada",
|
||||||
|
limit=25,
|
||||||
|
session=object(), # type: ignore[arg-type]
|
||||||
|
principal=_principal(WRITE_SCOPE),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual([], response.groups)
|
||||||
|
|
||||||
|
def test_search_requires_scheduling_write_or_admin_access(self) -> None:
|
||||||
|
provider = _PeopleProvider()
|
||||||
|
configure_runtime(registry=_Registry({CAPABILITY_ACCESS_PEOPLE_SEARCH: provider}))
|
||||||
|
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
api_search_scheduling_people(
|
||||||
|
query="ada",
|
||||||
|
limit=25,
|
||||||
|
session=object(), # type: ignore[arg-type]
|
||||||
|
principal=_principal("scheduling:schedule:read"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(403, raised.exception.status_code)
|
||||||
|
self.assertEqual([], provider.calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user