diff --git a/src/govoplan_scheduling/backend/manifest.py b/src/govoplan_scheduling/backend/manifest.py index 38f7069..9fb223e 100644 --- a/src/govoplan_scheduling/backend/manifest.py +++ b/src/govoplan_scheduling/backend/manifest.py @@ -114,9 +114,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]: } -def _scheduling_router(_context: ModuleContext): +def _scheduling_router(context: ModuleContext): + from govoplan_scheduling.backend.runtime import configure_runtime from govoplan_scheduling.backend.router import router + configure_runtime(registry=context.registry, settings=context.settings) return router @@ -125,7 +127,7 @@ manifest = ModuleManifest( name=MODULE_NAME, version=MODULE_VERSION, dependencies=("poll",), - optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations"), + optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"), optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), provides_interfaces=( ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"), @@ -136,14 +138,16 @@ manifest = ModuleManifest( ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"), ModuleInterfaceRequirement(name="poll.signed_participation", 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="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, - nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),), + nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), frontend=FrontendModule( module_id=MODULE_ID, package_name="@govoplan/scheduling-webui", - nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),), + nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),), ), route_factory=_scheduling_router, tenant_summary_providers=(_tenant_summary,), diff --git a/src/govoplan_scheduling/backend/router.py b/src/govoplan_scheduling/backend/router.py index 38a73a5..7bb4740 100644 --- a/src/govoplan_scheduling/backend/router.py +++ b/src/govoplan_scheduling/backend/router.py @@ -1,5 +1,8 @@ from __future__ import annotations +import dataclasses +from typing import Any + from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session @@ -8,6 +11,8 @@ from govoplan_core.db.session import get_session from govoplan_poll.backend.schemas import PollResultSummaryResponse from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE from govoplan_scheduling.backend.schemas import ( + SchedulingAddressLookupCandidate, + SchedulingAddressLookupResponse, SchedulingCalendarActionResponse, SchedulingDecisionRequest, SchedulingNotificationCreateRequest, @@ -20,6 +25,7 @@ from govoplan_scheduling.backend.schemas import ( SchedulingStatusResponse, SchedulingSummaryResponse, ) +from govoplan_scheduling.backend.runtime import get_registry from govoplan_scheduling.backend.service import ( SchedulingError, cancel_scheduling_request, @@ -42,6 +48,39 @@ 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: @@ -61,6 +100,24 @@ def _request_response(request, *, invitation_tokens: dict[str, str] | None = Non ) +@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse) +def api_lookup_scheduling_addresses( + 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: + _require_scope(principal, WRITE_SCOPE) + 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], + ) + + @router.get("/requests", response_model=SchedulingRequestListResponse) def api_list_scheduling_requests( status_filter: str | None = Query(default=None, alias="status"), diff --git a/src/govoplan_scheduling/backend/runtime.py b/src/govoplan_scheduling/backend/runtime.py new file mode 100644 index 0000000..fb5a864 --- /dev/null +++ b/src/govoplan_scheduling/backend/runtime.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +_runtime_registry: object | None = None +_runtime_settings: object | None = None + + +def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None: + global _runtime_registry, _runtime_settings + if registry is not None: + _runtime_registry = registry + if settings is not None: + _runtime_settings = settings + + +def get_registry() -> object | None: + return _runtime_registry + + +def get_settings() -> object | None: + return _runtime_settings diff --git a/src/govoplan_scheduling/backend/schemas.py b/src/govoplan_scheduling/backend/schemas.py index 16ea404..2e3918c 100644 --- a/src/govoplan_scheduling/backend/schemas.py +++ b/src/govoplan_scheduling/backend/schemas.py @@ -202,3 +202,23 @@ class SchedulingNotificationCreateRequest(BaseModel): event_kind: Literal["invitation", "reminder", "decision", "cancellation"] channel: str = Field(default="mail", max_length=40) metadata: dict[str, Any] = Field(default_factory=dict) + + +class SchedulingAddressLookupCandidate(BaseModel): + contact_id: str + address_book_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_revision: str | None = None + provenance: dict[str, Any] = Field(default_factory=dict) + + +class SchedulingAddressLookupResponse(BaseModel): + available: bool = False + candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list) diff --git a/src/govoplan_scheduling/backend/service.py b/src/govoplan_scheduling/backend/service.py index b46b07c..8ada1a6 100644 --- a/src/govoplan_scheduling/backend/service.py +++ b/src/govoplan_scheduling/backend/service.py @@ -5,6 +5,7 @@ from typing import Any from sqlalchemy.orm import Session +from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider from govoplan_core.db.base import utcnow from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput from govoplan_poll.backend.db.models import PollResponse @@ -24,6 +25,7 @@ from govoplan_scheduling.backend.schemas import ( SchedulingRequestCreateRequest, SchedulingRequestUpdateRequest, ) +from govoplan_scheduling.backend.runtime import get_registry class SchedulingError(ValueError): @@ -348,6 +350,7 @@ def create_scheduling_notification_jobs( raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}") request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id) notifications: list[SchedulingNotification] = [] + base_metadata = metadata or {} for participant in _active_participants(request): recipient = participant.email or participant.respondent_id status = "pending" if recipient else "skipped" @@ -367,16 +370,178 @@ def create_scheduling_notification_jobs( "selected_slot_id": request.selected_slot_id, "calendar_event_id": request.calendar_event_id, }, - metadata_=metadata or {}, + metadata_=dict(base_metadata), ) if status == "skipped": notification.error = "Participant has no deliverable recipient address or id" session.add(notification) notifications.append(notification) session.flush() + _mirror_scheduling_notifications_to_center(session, request=request, notifications=notifications) return notifications +def _mirror_scheduling_notifications_to_center( + session: Session, + *, + request: SchedulingRequest, + notifications: list[SchedulingNotification], +) -> None: + provider = notification_dispatch_provider(get_registry()) + if provider is None: + return + participants_by_id = {participant.id: participant for participant in _active_participants(request)} + for notification in notifications: + if notification.status == "skipped": + continue + participant = participants_by_id.get(notification.participant_id or "") + try: + response = provider.enqueue_notification( + session, + _notification_dispatch_request(request=request, participant=participant, notification=notification), + enqueue_delivery=True, + ) + except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow. + notification.status = "failed" + notification.error = f"Notification center enqueue failed: {exc}" + continue + notification.metadata_ = { + **(notification.metadata_ or {}), + "notification_id": response.get("id"), + "notification_status": response.get("status"), + } + if response.get("status") in {"queued", "sending", "sent"}: + notification.status = str(response["status"]) + elif response.get("status") == "skipped": + notification.status = "skipped" + notification.error = str(response.get("last_error") or "Notification center skipped delivery") + session.flush() + + +def _notification_dispatch_request( + *, + request: SchedulingRequest, + participant: SchedulingParticipant | None, + notification: SchedulingNotification, +) -> NotificationDispatchRequest: + payload = { + **(notification.payload or {}), + "scheduling_notification_id": notification.id, + "participant": { + "id": participant.id, + "display_name": participant.display_name, + "email": participant.email, + "required": participant.required, + "participant_type": participant.participant_type, + } + if participant + else None, + } + return NotificationDispatchRequest( + tenant_id=request.tenant_id, + source_module="scheduling", + source_resource_type="request", + source_resource_id=request.id, + event_kind=notification.event_kind, + channel=notification.channel, + recipient=notification.recipient, + recipient_type="scheduling_participant", + recipient_id=participant.id if participant else notification.participant_id, + recipient_label=participant.display_name if participant else None, + subject=_notification_subject(request, notification.event_kind), + body_text=_notification_body_text(request, participant, notification.event_kind), + action_url=f"/scheduling?request_id={request.id}", + payload=_jsonable(payload), + metadata={ + **(notification.metadata_ or {}), + "scheduling_notification_id": notification.id, + "scheduling_request_id": request.id, + }, + ) + + +def _notification_subject(request: SchedulingRequest, event_kind: str) -> str: + labels = { + "invitation": "Scheduling invitation", + "reminder": "Scheduling reminder", + "decision": "Scheduling decision", + "cancellation": "Scheduling cancelled", + } + return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}" + + +def _notification_body_text( + request: SchedulingRequest, + participant: SchedulingParticipant | None, + event_kind: str, +) -> str: + greeting = f"Hello {participant.display_name}," if participant and participant.display_name else "Hello," + lines = [greeting, "", _notification_body_intro(request, event_kind)] + selected_slot = next((slot for slot in _active_slots(request) if slot.id == request.selected_slot_id), None) + if selected_slot is not None: + lines.append(f"Selected slot: {_slot_label(selected_slot.start_at, selected_slot.end_at, timezone_name=selected_slot.timezone)}") + if request.location: + lines.append(f"Location: {request.location}") + lines.append(f"Scheduling request: {request.title}") + return "\n".join(lines) + + +def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str: + if event_kind == "invitation": + return f"You are invited to respond to the scheduling poll for {request.title}." + if event_kind == "reminder": + return f"This is a reminder to respond to the scheduling poll for {request.title}." + if event_kind == "decision": + return f"A final slot was selected for {request.title}." + if event_kind == "cancellation": + return f"The scheduling request {request.title} was cancelled." + return f"There is an update for {request.title}." + + +def _emit_scheduling_center_notification( + session: Session, + *, + request: SchedulingRequest, + event_kind: str, + subject: str, + body_text: str, + participant: SchedulingParticipant | None = None, + priority: int = 0, +) -> None: + provider = notification_dispatch_provider(get_registry()) + if provider is None: + return + try: + provider.enqueue_notification( + session, + NotificationDispatchRequest( + tenant_id=request.tenant_id, + source_module="scheduling", + source_resource_type="request", + source_resource_id=request.id, + event_kind=event_kind, + channel="inbox", + recipient_type="user" if request.organizer_user_id else None, + recipient_id=request.organizer_user_id, + subject=subject, + body_text=body_text, + action_url=f"/scheduling?request_id={request.id}", + priority=priority, + payload=_jsonable( + { + "request_id": request.id, + "poll_id": request.poll_id, + "participant_id": participant.id if participant else None, + } + ), + metadata={"scheduling_request_id": request.id}, + ), + enqueue_delivery=False, + ) + except Exception: + return + + def list_scheduling_notifications( session: Session, *, @@ -414,6 +579,14 @@ def refresh_participant_response_state(session: Session, *, request: SchedulingR if participant is not None and participant.status != "responded": participant.status = "responded" participant.responded_at = response_datetime(response.submitted_at) + _emit_scheduling_center_notification( + session, + request=request, + participant=participant, + event_kind="scheduling.participant_responded", + subject=f"Scheduling response: {request.title}", + body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.", + ) changed = True if changed: session.flush() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 14b3085..a578033 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -15,6 +15,7 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("poll", manifest.dependencies) self.assertNotIn("access", manifest.dependencies) self.assertIn("access", manifest.optional_dependencies) + self.assertIn("addresses", manifest.optional_dependencies) self.assertFalse(manifest.required_capabilities) self.assertIn("auth.principalResolver", manifest.optional_capabilities) self.assertIn("evaluation", manifest.optional_dependencies) @@ -24,6 +25,8 @@ class SchedulingManifestTests(unittest.TestCase): self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces}) self.assertIn("poll.signed_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}) if __name__ == "__main__": diff --git a/webui/src/api/scheduling.ts b/webui/src/api/scheduling.ts index 45f6659..421eff4 100644 --- a/webui/src/api/scheduling.ts +++ b/webui/src/api/scheduling.ts @@ -161,8 +161,33 @@ 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[]; +}; + const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) }); +export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise { + const params = new URLSearchParams({ query, limit: String(limit) }); + return apiFetch(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`); +} + export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise { const query = status ? `?status=${encodeURIComponent(status)}` : ""; return apiFetch(settings, `/api/v1/scheduling/requests${query}`); diff --git a/webui/src/features/scheduling/SchedulingPage.tsx b/webui/src/features/scheduling/SchedulingPage.tsx index a659a9a..ab71fc0 100644 --- a/webui/src/features/scheduling/SchedulingPage.tsx +++ b/webui/src/features/scheduling/SchedulingPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react"; -import { Button, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; +import { Button, EmailAddressInput, hasScope, type ApiSettings, type AuthInfo, type MailboxAddress } from "@govoplan/core-webui"; import { closeSchedulingRequest, createSchedulingCalendarEvent, @@ -11,6 +11,7 @@ import { evaluateSchedulingFreeBusy, listSchedulingNotifications, listSchedulingRequests, + lookupSchedulingAddresses, openSchedulingRequest, schedulingSummary, type SchedulingNotification, @@ -51,6 +52,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin { label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) } ]); const [participants, setParticipants] = useState([{ display_name: "", email: "", required: true }]); + const [addressLookupQuery, setAddressLookupQuery] = useState(""); + const [addressSuggestions, setAddressSuggestions] = useState([]); const canWrite = hasScope(auth, "scheduling:schedule:write"); const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]); @@ -73,6 +76,37 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin void loadDetails(selected.id); }, [selected?.id]); + useEffect(() => { + const query = addressLookupQuery.trim(); + if (!canWrite || query.length < 2) { + setAddressSuggestions([]); + return; + } + let cancelled = false; + const timer = window.setTimeout(() => { + void lookupSchedulingAddresses(settings, query). + then((response) => { + if (cancelled) return; + if (!response.available) { + setAddressSuggestions([]); + return; + } + setAddressSuggestions( + response.candidates + .map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null) + .filter((address): address is MailboxAddress => Boolean(address?.email)) + ); + }). + catch(() => { + if (!cancelled) setAddressSuggestions([]); + }); + }, 200); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [addressLookupQuery, canWrite, settings.accessToken, settings.apiBaseUrl, settings.apiKey]); + async function loadRequests() { setLoading(true); setError(""); @@ -258,7 +292,22 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin {participants.map((participant, index) => (
updateParticipant(index, "display_name", event.target.value)} /> - updateParticipant(index, "email", event.target.value)} /> + { + const address = addresses[0]; + updateParticipantDraft(index, { + display_name: address?.name ?? participant.display_name, + email: address?.email ?? "" + }); + }} />
))} @@ -333,6 +382,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) { setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item))); } + + function updateParticipantDraft(index: number, patch: Partial) { + setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item))); + } } function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } { diff --git a/webui/src/module.ts b/webui/src/module.ts index d5028c3..fdd8afc 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -12,9 +12,9 @@ export const schedulingModule: PlatformWebModule = { label: "Scheduling", version: "1.0.0", dependencies: ["poll"], - optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments"], + optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"], translations: generatedTranslations, - navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar", anyOf: scheduleRead, order: 56 }], + navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }], routes: [ { path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) } ]