feat: reconcile tentative calendar holds

This commit is contained in:
2026-08-20 07:44:15 +02:00
parent fb8d674637
commit 79cfaa951a
7 changed files with 522 additions and 29 deletions
+4 -1
View File
@@ -147,6 +147,9 @@ DOCUMENTATION = (
body=(
"Calendar coordination remains optional. It is available only when Calendar contributes its picker capability and the actor can read calendars and availability and write events. "
"A disabled Calendar control therefore names the missing integration or authority instead of silently accepting a configuration that cannot run. "
"At decision time, a selected tentative hold is promoted in place while every unused hold is submitted to Calendar for durable release. Cancellation likewise waits until Calendar accepts every release. "
"Partial or unavailable Calendar effects keep the Scheduling lifecycle incomplete and expose retry-required cleanup state; an exact retry reuses the recorded event and operation identities instead of duplicating effects. "
"Administrators recover synchronized failures through Calendar's outbound-change reconciliation and then repeat the Scheduling action. Scheduling stores only operation identifiers, last-known state, and pending slot references; Calendar remains authoritative for event and outbox evidence. "
"Administrators should enable the Calendar module and grant the bounded calendar, availability, and event permissions needed by the organizer; Scheduling never imports Calendar internals."
),
layer="configured",
@@ -290,7 +293,7 @@ manifest = ModuleManifest(
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", 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.9", version_max_exclusive="0.2.0", optional=True),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
+7 -1
View File
@@ -739,7 +739,13 @@ def api_cancel_scheduling_request(
) -> SchedulingStatusResponse:
_require_request_editor(session, principal=principal, request_id=request_id)
try:
request = cancel_scheduling_request(session, tenant_id=principal.tenant_id, request_id=request_id)
request = cancel_scheduling_request(
session,
tenant_id=principal.tenant_id,
request_id=request_id,
user_id=principal.account_id,
allow_calendar_cleanup=has_scope(principal, CALENDAR_EVENT_WRITE_SCOPE),
)
except SchedulingError as exc:
raise _scheduling_http_error(exc) from exc
response = SchedulingStatusResponse(request=_request_response(request, principal=principal))
+289 -20
View File
@@ -623,7 +623,6 @@ def create_tentative_calendar_holds(
slot.metadata_ = {
**(slot.metadata_ or {}),
"calendar_hold": {
"event_id": event.id,
# This is a creation-time snapshot. Calendar remains the
# authority for later outbox delivery transitions.
"last_known_external_state": event.external_state,
@@ -635,6 +634,104 @@ def create_tentative_calendar_holds(
return request, created_event_ids, warnings
def _calendar_cleanup_metadata(
slot: SchedulingCandidateSlot,
*,
state: str,
operation_id: str | None = None,
) -> None:
slot.metadata_ = {
**(slot.metadata_ or {}),
"calendar_hold": {
"last_known_external_state": state,
"outbox_operation_id": operation_id,
},
}
def _release_tentative_calendar_holds(
session: Session,
*,
request: SchedulingRequest,
user_id: str | None,
include_selected: bool,
) -> list[str]:
candidates = [
slot
for slot in _active_slots(request)
if slot.tentative_hold_event_id
and (include_selected or slot.id != request.selected_slot_id)
]
if not candidates:
return []
try:
provider = _calendar_provider()
except SchedulingError as exc:
for slot in candidates:
_calendar_cleanup_metadata(slot, state="retry_required")
return [str(exc)]
warnings: list[str] = []
for slot in candidates:
event_id = slot.tentative_hold_event_id
if not event_id:
continue
try:
released = provider.release_event(
session,
tenant_id=request.tenant_id,
user_id=user_id,
event_id=event_id,
)
except CalendarCapabilityError as exc:
_calendar_cleanup_metadata(slot, state="retry_required")
warnings.append(f"Slot {slot.id}: {exc}")
continue
if not released.accepted:
_calendar_cleanup_metadata(
slot,
state=released.external_state or "retry_required",
operation_id=released.outbox_operation_id,
)
warnings.append(f"Slot {slot.id}: Calendar did not accept the release operation")
continue
_calendar_cleanup_metadata(
slot,
state=released.external_state,
operation_id=released.outbox_operation_id,
)
slot.tentative_hold_event_id = None
session.flush()
return warnings
def _record_calendar_cleanup_state(
request: SchedulingRequest,
*,
warnings: list[str],
target_state: str | None = None,
) -> None:
current_cleanup = (request.metadata_ or {}).get("calendar_cleanup")
inherited_target = (
current_cleanup.get("target_state")
if isinstance(current_cleanup, dict)
else None
)
pending_slot_ids = [
slot.id
for slot in _active_slots(request)
if slot.tentative_hold_event_id
]
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_cleanup": {
"status": "retry_required" if warnings or pending_slot_ids else "accepted",
"target_state": target_state or inherited_target,
"pending_slot_ids": pending_slot_ids,
"warnings": warnings[:20],
},
}
def create_final_calendar_event(
session: Session,
*,
@@ -646,53 +743,120 @@ def create_final_calendar_event(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
_require_calendar_enabled(request)
if not request.create_calendar_event_on_decision:
raise SchedulingError("Final calendar event creation is not enabled for this scheduling request")
if request.status == "handed_off" and request.selected_slot_id and request.calendar_event_id:
if (
request.status == "handed_off"
and request.selected_slot_id
and request.calendar_event_id
and not any(slot.tentative_hold_event_id for slot in _active_slots(request))
):
return request, request.calendar_event_id, []
if request.status != "decided" or not request.selected_slot_id:
if request.status not in {"decided", "handed_off"} or not request.selected_slot_id:
raise SchedulingError(
"A final calendar event can only be created for a decided scheduling request with a selected slot"
)
if request.calendar_event_id:
return request, request.calendar_event_id, []
slot = _selected_slot(request, slot_id=request.selected_slot_id)
provider = _calendar_provider()
try:
event = provider.create_event(
warnings: list[str] = []
event = None
if not request.calendar_event_id:
try:
if slot.tentative_hold_event_id:
event = provider.promote_event(
session,
tenant_id=tenant_id,
user_id=user_id,
event_id=slot.tentative_hold_event_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
)
_calendar_cleanup_metadata(
slot,
state=event.external_state,
operation_id=event.outbox_operation_id,
)
slot.tentative_hold_event_id = None
else:
event = provider.create_event(
session,
tenant_id=tenant_id,
user_id=user_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
)
request.calendar_event_id = event.id
except CalendarCapabilityError as exc:
_calendar_cleanup_metadata(slot, state="retry_required")
warnings.append(str(exc))
warnings.extend(
_release_tentative_calendar_holds(
session,
tenant_id=tenant_id,
request=request,
user_id=user_id,
request=_calendar_event_payload(request, slot, status="CONFIRMED"),
include_selected=False,
)
)
_record_calendar_cleanup_state(
request,
warnings=warnings,
target_state="handed_off",
)
if warnings or not request.calendar_event_id:
previous_handoff = (request.metadata_ or {}).get("calendar_handoff")
previous_handoff = (
previous_handoff if isinstance(previous_handoff, dict) else {}
)
except CalendarCapabilityError as exc:
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {"status": "error", "error": str(exc), "slot_id": slot.id, "calendar_id": request.calendar_id},
"calendar_handoff": {
"status": "retry_required",
"last_known_external_state": (
event.external_state
if event
else previous_handoff.get("last_known_external_state", "retry_required")
),
"outbox_operation_id": (
event.outbox_operation_id
if event
else previous_handoff.get("outbox_operation_id")
),
"event_id": request.calendar_event_id,
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
session.flush()
return request, None, [str(exc)]
request.calendar_event_id = event.id
return request, request.calendar_event_id, warnings
request.handed_off_at = _now()
request.status = "handed_off"
previous_handoff = (request.metadata_ or {}).get("calendar_handoff")
previous_handoff = previous_handoff if isinstance(previous_handoff, dict) else {}
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "accepted",
# Scheduling stores identifiers plus an explicitly named snapshot;
# consumers must resolve the Calendar event/outbox for live state.
"last_known_external_state": event.external_state,
"outbox_operation_id": event.outbox_operation_id,
"event_id": event.id,
"last_known_external_state": (
event.external_state
if event
else previous_handoff.get("last_known_external_state", "accepted")
),
"outbox_operation_id": (
event.outbox_operation_id
if event
else previous_handoff.get("outbox_operation_id")
),
"event_id": request.calendar_event_id,
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request, event.id, []
return request, request.calendar_event_id, []
def create_scheduling_notification_jobs(
@@ -3917,6 +4081,7 @@ def decide_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
if request.poll_id is None:
raise SchedulingError("Scheduling request has no backing poll")
@@ -3927,6 +4092,44 @@ def decide_scheduling_request(
"A different slot cannot be selected after a scheduling decision; "
"explicit rescheduling semantics are required"
)
handoff_state = (request.metadata_ or {}).get("calendar_handoff")
cleanup_state = (request.metadata_ or {}).get("calendar_cleanup")
if (
request.status == "decided"
and isinstance(handoff_state, dict)
and handoff_state.get("status") == "retry_required"
):
if not allow_calendar_handoff:
raise SchedulingPermissionError(
"Calendar event promotion or release requires calendar:event:write"
)
return create_final_calendar_event(
session,
tenant_id=tenant_id,
user_id=user_id,
request_id=request.id,
)[0]
if (
request.status == "decided"
and isinstance(cleanup_state, dict)
and cleanup_state.get("status") == "retry_required"
):
if not allow_calendar_handoff:
raise SchedulingPermissionError(
"Calendar event release requires calendar:event:write"
)
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="decided",
)
session.flush()
return request
if request.status != "closed":
raise SchedulingError("Only closed scheduling requests can be decided")
@@ -3939,9 +4142,12 @@ def decide_scheduling_request(
and request.calendar_integration_enabled
and request.create_calendar_event_on_decision
)
if creates_calendar_event and not allow_calendar_handoff:
has_calendar_holds = any(
slot.tentative_hold_event_id for slot in _active_slots(request)
)
if (creates_calendar_event or has_calendar_holds) and not allow_calendar_handoff:
raise SchedulingPermissionError(
"Calendar event creation requires calendar:event:write"
"Calendar event promotion or release requires calendar:event:write"
)
try:
_poll_provider().decide_poll(
@@ -3958,6 +4164,28 @@ def decide_scheduling_request(
if request.calendar_integration_enabled and request.create_calendar_event_on_decision:
create_final_calendar_event(session, tenant_id=tenant_id, user_id=user_id, request_id=request.id)
else:
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="handed_off",
)
if cleanup_warnings:
request.metadata_ = {
**(request.metadata_ or {}),
"calendar_handoff": {
"status": "retry_required",
"slot_id": slot.id,
"calendar_id": request.calendar_id,
},
}
session.flush()
return request
request.handed_off_at = _now()
request.status = "handed_off"
request.metadata_ = {
@@ -3969,17 +4197,37 @@ def decide_scheduling_request(
"calendar_id": request.calendar_id,
},
}
elif has_calendar_holds:
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="decided",
)
create_scheduling_notification_jobs(session, tenant_id=tenant_id, request_id=request.id, event_kind="decision")
_sync_poll_workflow(session, tenant_id=tenant_id, request=request)
session.flush()
return request
def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: str) -> SchedulingRequest:
def cancel_scheduling_request(
session: Session,
*,
tenant_id: str,
request_id: str,
user_id: str | None = None,
allow_calendar_cleanup: bool = False,
) -> SchedulingRequest:
request = _lock_scheduling_request(
session,
tenant_id=tenant_id,
request_id=request_id,
lock_slots=True,
)
if request.status == "cancelled":
return request
@@ -3987,6 +4235,27 @@ def cancel_scheduling_request(session: Session, *, tenant_id: str, request_id: s
raise SchedulingError(
"Only draft, collecting, or closed scheduling requests can be cancelled"
)
has_calendar_holds = any(
slot.tentative_hold_event_id for slot in _active_slots(request)
)
if has_calendar_holds and not allow_calendar_cleanup:
raise SchedulingPermissionError(
"Calendar event release requires calendar:event:write"
)
cleanup_warnings = _release_tentative_calendar_holds(
session,
request=request,
user_id=user_id,
include_selected=True,
)
_record_calendar_cleanup_state(
request,
warnings=cleanup_warnings,
target_state="cancelled",
)
if cleanup_warnings:
session.flush()
return request
cancelled_at = _now()
request.status = "cancelled"
request.cancelled_at = cancelled_at
+180 -2
View File
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.calendar import CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.core.calendar import CalendarCapabilityError, CALENDAR_AVAILABILITY_READ_SCOPE, CALENDAR_EVENT_WRITE_SCOPE
from govoplan_core.db.base import Base
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.modules import ModuleContext
@@ -25,6 +25,7 @@ from govoplan_calendar.backend.db.models import (
CalendarSyncSource,
)
from govoplan_calendar.backend.manifest import get_manifest as get_calendar_manifest
from govoplan_calendar.backend.capabilities import SqlCalendarSchedulingProvider
from govoplan_poll.backend.db.models import (
Poll,
PollInvitation,
@@ -533,6 +534,7 @@ class SchedulingServiceTests(unittest.TestCase):
request_id=request.id,
payload=SchedulingDecisionRequest(slot_id=request.slots[1].id, handoff_to_calendar=False),
user_id="user-1",
allow_calendar_handoff=True,
)
with self.assertRaisesRegex(SchedulingError, "only available before a scheduling decision"):
evaluate_calendar_freebusy(
@@ -579,12 +581,17 @@ class SchedulingServiceTests(unittest.TestCase):
user_id="user-1",
request_id=decided.id,
)
self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots))
self.assertTrue(
all(
slot.metadata_["calendar_hold"]["last_known_external_state"] == "queued"
set(slot.metadata_["calendar_hold"]) == {
"last_known_external_state",
"outbox_operation_id",
}
for slot in request.slots
)
)
self.assertEqual(request.metadata_["calendar_cleanup"]["status"], "accepted")
generated_events = (
self.session.query(CalendarEvent)
.filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id)
@@ -632,6 +639,177 @@ class SchedulingServiceTests(unittest.TestCase):
user_id="user-1",
)
def test_decision_promotes_selected_hold_and_releases_the_rest(self) -> None:
self._calendar()
self.session.add(
CalendarSyncSource(
id="source-decision-cleanup",
tenant_id="tenant-1",
calendar_id="calendar-1",
source_kind="caldav",
collection_url="https://dav.example.test/cal/",
auth_type="none",
sync_enabled=True,
sync_interval_seconds=900,
sync_direction="two_way",
conflict_policy="etag",
metadata_={},
)
)
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
request, hold_ids, warnings = create_tentative_calendar_holds(
self.session,
tenant_id="tenant-1",
user_id="user-1",
request_id=request.id,
)
selected_slot = request.slots[0]
released_slot = request.slots[1]
selected_hold_id = selected_slot.tentative_hold_event_id
released_hold_id = released_slot.tentative_hold_event_id
self.assertEqual(len(hold_ids), 2)
self.assertEqual(warnings, [])
close_scheduling_request(self.session, tenant_id="tenant-1", request_id=request.id)
decided = decide_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
payload=SchedulingDecisionRequest(slot_id=selected_slot.id, handoff_to_calendar=True),
user_id="user-1",
allow_calendar_handoff=True,
)
self.assertEqual(decided.status, "handed_off")
self.assertEqual(decided.calendar_event_id, selected_hold_id)
self.assertEqual(decided.metadata_["calendar_cleanup"]["status"], "accepted")
self.assertIsNone(selected_slot.tentative_hold_event_id)
self.assertIsNone(released_slot.tentative_hold_event_id)
self.assertEqual(
set(selected_slot.metadata_["calendar_hold"]),
{"last_known_external_state", "outbox_operation_id"},
)
self.assertEqual(
set(released_slot.metadata_["calendar_hold"]),
{"last_known_external_state", "outbox_operation_id"},
)
self.assertIsNotNone(selected_slot.metadata_["calendar_hold"]["outbox_operation_id"])
self.assertIsNotNone(released_slot.metadata_["calendar_hold"]["outbox_operation_id"])
promoted = self.session.get(CalendarEvent, selected_hold_id)
released = self.session.get(CalendarEvent, released_hold_id)
self.assertEqual(promoted.status, "CONFIRMED")
self.assertIsNone(promoted.deleted_at)
self.assertIsNotNone(released.deleted_at)
replayed = decide_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
payload=SchedulingDecisionRequest(slot_id=selected_slot.id, handoff_to_calendar=True),
user_id="user-1",
allow_calendar_handoff=True,
)
self.assertEqual(replayed.calendar_event_id, selected_hold_id)
self.assertEqual(
self.session.query(CalendarEvent)
.filter(CalendarEvent.metadata_["scheduling_request_id"].as_string() == request.id)
.count(),
2,
)
def test_cancellation_keeps_partial_calendar_cleanup_retryable(self) -> None:
self._calendar()
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
request, _hold_ids, _warnings = create_tentative_calendar_holds(
self.session,
tenant_id="tenant-1",
user_id="user-1",
request_id=request.id,
)
failed_event_id = request.slots[1].tentative_hold_event_id
original_release = SqlCalendarSchedulingProvider.release_event
def flaky_release(provider, session, *, tenant_id, user_id, event_id):
if event_id == failed_event_id:
raise CalendarCapabilityError("temporary Calendar failure")
return original_release(
provider,
session,
tenant_id=tenant_id,
user_id=user_id,
event_id=event_id,
)
with patch.object(SqlCalendarSchedulingProvider, "release_event", new=flaky_release):
pending = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
user_id="user-1",
allow_calendar_cleanup=True,
)
self.assertEqual(pending.status, "collecting")
self.assertEqual(pending.metadata_["calendar_cleanup"]["status"], "retry_required")
self.assertEqual(
[slot.tentative_hold_event_id for slot in request.slots],
[None, failed_event_id],
)
cancelled = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
user_id="user-1",
allow_calendar_cleanup=True,
)
self.assertEqual(cancelled.status, "cancelled")
self.assertEqual(cancelled.metadata_["calendar_cleanup"]["status"], "accepted")
self.assertTrue(all(slot.tentative_hold_event_id is None for slot in request.slots))
def test_cancellation_waits_when_calendar_capability_is_unavailable(self) -> None:
self._calendar()
request, _tokens = create_scheduling_request(
self.session,
tenant_id="tenant-1",
user_id="user-1",
payload=self._payload(),
)
request, hold_ids, _warnings = create_tentative_calendar_holds(
self.session,
tenant_id="tenant-1",
user_id="user-1",
request_id=request.id,
)
with patch(
"govoplan_scheduling.backend.service._calendar_provider",
side_effect=SchedulingError("Calendar scheduling capability is unavailable"),
):
pending = cancel_scheduling_request(
self.session,
tenant_id="tenant-1",
request_id=request.id,
user_id="user-1",
allow_calendar_cleanup=True,
)
self.assertEqual(pending.status, "collecting")
self.assertEqual(pending.metadata_["calendar_cleanup"]["status"], "retry_required")
self.assertEqual(
[slot.tentative_hold_event_id for slot in request.slots],
hold_ids,
)
def test_notification_outbox_jobs_are_created_and_listed(self) -> None:
request, _tokens = create_scheduling_request(
self.session,
@@ -39,7 +39,7 @@ const editorStart = page.indexOf('<div className="scheduling-editor-surface">');
const editorEnd = page.indexOf('</form>', editorStart);
const editor = page.slice(editorStart, editorEnd);
assert.ok(editor.indexOf("I18N.discard") < editor.indexOf("I18N.save"));
assert.match(editor, /form="scheduling-editor-form"/);
assert.match(editor, /form:\s*"scheduling-editor-form"/);
assert.match(editor, /<Card title=\{I18N\.basicInformation\}>/);
assert.match(editor, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
@@ -55,6 +55,7 @@ assert.match(editor, /<FormField label=\{I18N\.title\}>/);
assert.match(page, /useUnsavedDraftGuard\(/);
assert.match(page, /requestDiscard\(exitEditor\)/);
assert.match(page, /dirty: responseDirty,[\s\S]*onSave: persistAvailability,[\s\S]*onDiscard: resetResponseDraft/);
assert.match(page, /calendarCleanup\?\.status === "retry_required"[\s\S]*I18N\.calendarCleanupRetryTitle/);
assert.doesNotMatch(page, /window\.(?:alert|confirm)\(/);
for (const setting of [
@@ -92,7 +93,7 @@ assert.match(page, /search=\{participantSearch\}/);
assert.doesNotMatch(page, /<table|scheduling-table|scheduling-card(?:\s|"|`)/);
assert.match(page, /<TableActionGroup[\s\S]*disabled: saving \|\| !decisionEnabled/);
assert.match(page, /showDecisionAction=\{canManageSelected\}/);
assert.match(page, /<IconButton[\s\S]*label=\{I18N\.refresh\}/);
assert.match(page, /<WorkspaceActionBar[\s\S]*refreshable[\s\S]*reloadAction=\{\{[\s\S]*label: I18N\.refresh/);
assert.doesNotMatch(page, /AdminIconButton/);
const participantGridStart = page.indexOf("function ParticipantsGrid(");
@@ -27,7 +27,6 @@ import { ContentGrid, FormGrid,
DocumentationHelpLink,
FormField,
MetricCard,
IconButton,
PageTitle,
PasswordField,
PeoplePicker,
@@ -127,6 +126,11 @@ type DecisionTarget = {
requestId: string;
slot: SchedulingCandidateSlot;
};
type SchedulingCalendarCleanup = {
status: "accepted" | "retry_required";
targetState: string | null;
pendingCount: number;
};
const I18N = {
actions: "i18n:govoplan-core.actions.c3cd636a",
@@ -148,6 +152,8 @@ const I18N = {
calendarIntegration: "i18n:govoplan-scheduling.calendar_integration.181ad18b",
calendarUnavailable: "i18n:govoplan-scheduling.calendar_integration_requires_the_calendar_module_plus_c.f892cb1e",
calendarRequiredAction: "i18n:govoplan-scheduling.enable_calendar_and_grant_calendar_availability_and_event_access.f1a20106",
calendarCleanupRetryTitle: "i18n:govoplan-scheduling.calendar_cleanup_retry_title",
calendarCleanupRetryMessage: "i18n:govoplan-scheduling.calendar_cleanup_retry_message",
candidateAvailability: "i18n:govoplan-scheduling.candidate_availability.9541c4b5",
candidateSlots: "i18n:govoplan-scheduling.candidate_slots.c414946b",
cancellationNoticeExpired: "i18n:govoplan-scheduling.the_cancellation_notice_has_expired_a_new_link_cannot_be_issued.9c6ccc7c",
@@ -342,6 +348,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
() => requests.find((item) => item.id === selectedId) ?? firstRequest(groups, canAdminister),
[canAdminister, groups, requests, selectedId]
);
const calendarCleanup = selected ? schedulingCalendarCleanup(selected) : null;
const selectedParticipant = useMemo(
() => selected ? schedulingParticipantForActor(selected, actor) : null,
[actor, selected]
@@ -368,7 +375,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
selectedPhase !== "past" &&
selected.status === "decided" &&
selected.selected_slot_id &&
!selected.calendar_event_id &&
(!selected.calendar_event_id || (
calendarCleanup?.status === "retry_required"
&& calendarCleanup.targetState === "handed_off"
)) &&
selected.create_calendar_event_on_decision
);
const optionResultById = useMemo(() => {
@@ -1298,7 +1308,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
onDecide={(slot) => setDecisionTarget({ requestId: selected.id, slot })} />
</Card>
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id) ? (
{selected.calendar_integration_enabled && canManageSelected && (showPlanningCalendarActions || showFinalCalendarAction || selected.calendar_event_id || calendarCleanup?.status === "retry_required") ? (
<Card
title={I18N.calendarCoordination}
actions={(
@@ -1342,6 +1352,14 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
label={I18N.configuredCalendar}
disabled />
) : <p>{I18N.configuredCalendar}</p>}
{calendarCleanup?.status === "retry_required" ? (
<DismissibleAlert tone="warning" dismissible={false} compact>
<strong>{I18N.calendarCleanupRetryTitle}</strong>{" "}
{i18nMessage(I18N.calendarCleanupRetryMessage, {
value0: calendarCleanup.pendingCount
})}
</DismissibleAlert>
) : null}
</Card>
) : null}
@@ -1714,6 +1732,20 @@ function ParticipationStats({
);
}
function schedulingCalendarCleanup(request: SchedulingRequest): SchedulingCalendarCleanup | null {
const value = request.metadata?.calendar_cleanup;
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const status = record.status === "retry_required" ? "retry_required" : record.status === "accepted" ? "accepted" : null;
if (!status) return null;
const pending = Array.isArray(record.pending_slot_ids) ? record.pending_slot_ids : [];
return {
status,
targetState: typeof record.target_state === "string" ? record.target_state : null,
pendingCount: pending.length
};
}
function schedulingActionConfirmation(kind: ConsequentialAction["kind"]): {
title: string;
message: string;
+4
View File
@@ -1,5 +1,7 @@
export const generatedTranslations = {
en: {
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Calendar cleanup requires attention.",
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} tentative hold operations remain. Reconcile failed Calendar outbound changes if necessary, then repeat the original decision or cancellation action.",
"i18n:govoplan-scheduling.access_details.79c06b89": "Access details",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Automatic invitation delivery is unavailable; copy the link instead.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Cancellation notice available until",
@@ -180,6 +182,8 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.your_response_has_been_recorded.b855088d": "Your response has been recorded."
},
de: {
"i18n:govoplan-scheduling.calendar_cleanup_retry_title": "Die Kalenderbereinigung erfordert Aufmerksamkeit.",
"i18n:govoplan-scheduling.calendar_cleanup_retry_message": "{value0} Vorgänge für vorläufige Reservierungen stehen noch aus. Gleichen Sie fehlgeschlagene ausgehende Kalenderänderungen bei Bedarf ab und wiederholen Sie anschließend die ursprüngliche Entscheidungs- oder Abbruchaktion.",
"i18n:govoplan-scheduling.access_details.79c06b89": "Zugangsdaten",
"i18n:govoplan-scheduling.automatic_invitation_delivery_is_unavailable_copy_the_link_instead.4e39d0b3": "Die automatische Einladungszustellung ist nicht verfügbar; kopieren Sie stattdessen den Link.",
"i18n:govoplan-scheduling.cancellation_notice_available_until.f840d1e6": "Stornierungshinweis verfügbar bis",