Add CalDAV outbox recovery UI

This commit is contained in:
2026-08-02 15:57:01 +02:00
parent 4e05ab2c3e
commit 77a40ec2f9
14 changed files with 737 additions and 14 deletions
+3 -1
View File
@@ -70,9 +70,11 @@ write local creates, updates, and deletes back with CalDAV `PUT`/`DELETE` and
ETag preconditions. If a remote resource changed, the local mutation is rejected ETag preconditions. If a remote resource changed, the local mutation is rejected
and the user must sync before retrying. A calendar-owned task, and the user must sync before retrying. A calendar-owned task,
`govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or `govoplan_calendar.sync_due_caldav_sources`, can run due sources in a worker or
cron-style scheduler. cron-style scheduler. Due-source and outbox-dispatch HTTP routes require a
service-account principal and are not interactive administration actions.
Calendar exposes collection and credential management, sync status/actions, Calendar exposes collection and credential management, sync status/actions,
bounded outbound-change diagnostics and guarded retry/reconcile/discard recovery,
durable per-user view preferences, recurrence expansion, detached occurrence durable per-user view preferences, recurrence expansion, detached occurrence
overrides, instance/series editing, and a free/busy API primitive for scheduling overrides, instance/series editing, and a free/busy API primitive for scheduling
and appointment modules. It is not yet a CalDAV network server: external clients and appointment modules. It is not yet a CalDAV network server: external clients
+11 -2
View File
@@ -89,14 +89,23 @@ per-source throughput for a simple ordering guarantee: a stale inbound report
cannot land after a newer outbound result, and queued leases do not expire while cannot land after a newer outbound result, and queued leases do not expire while
waiting behind another network request for the same source. waiting behind another network request for the same source.
Operators can list, dispatch, retry, reconcile, and discard tenant operations Operators open **Outbound changes** from a synchronized calendar's settings to
through the `/calendar/caldav/outbox` administration endpoints. Terminal ETag inspect a bounded queue and use only the retry, reconcile, or discard actions
that are valid for the latest resource generation. Dispatch and due-source
routes are worker-only and reject interactive sessions even when the account is
an administrator. Terminal ETag
conflicts and exhausted retries remain the current local desired state and conflicts and exhausted retries remain the current local desired state and
shield that resource from inbound overwrite until explicitly resolved. shield that resource from inbound overwrite until explicitly resolved.
Discarding is the administrator's accept-remote transition: it is allowed only Discarding is the administrator's accept-remote transition: it is allowed only
for the latest generation, atomically cancels its unresolved predecessor chain, for the latest generation, atomically cancels its unresolved predecessor chain,
marks the event projection as discarded, clears the sync token, and schedules a marks the event projection as discarded, clears the sync token, and schedules a
full inbound reconciliation so an unchanged remote object is not missed. full inbound reconciliation so an unchanged remote object is not missed.
The UI requires a separate destructive confirmation and explains that accepting
remote may lose the unresolved local desired state. Disabled and inbound-only
sources still permit discard when the retained source belongs to the tenant,
but they cannot be retried or reconciled until made writable again. Missing
sources, active worker leases, stale generations, and already-resolved rows
expose diagnostics instead of unsafe action buttons.
Disabling outbound delivery or switching to inbound-only is rejected while Disabling outbound delivery or switching to inbound-only is rejected while
unresolved desired state exists; retirement is the explicit exception and unresolved desired state exists; retirement is the explicit exception and
+17
View File
@@ -554,6 +554,23 @@ manifest = ModuleManifest(
related_modules=("connectors", "audit", "ops"), related_modules=("connectors", "audit", "ops"),
metadata={"kind": "reference"}, metadata={"kind": "reference"},
), ),
DocumentationTopic(
id="calendar.outbound-change-recovery",
title="Recover synchronized calendar writes",
summary="Inspect unresolved CalDAV writes and recover only the latest safe resource generation.",
body=(
"Calendar administrators open Outbound changes from a synchronized calendar's settings. "
"The bounded history explains attempts, conflicts, dead work, stale generations, disabled sources, "
"and active worker leases. Retry schedules a failed desired state, reconcile compares it with the "
"remote resource, and discard abandons the local desired state after a separate warning so the next "
"full sync can accept remote. Automatic dispatch and due-source execution remain service-account-only "
"worker operations and are not exposed as interactive controls."
),
documentation_types=("admin", "user"),
audience=("calendar_manager", "operator", "tenant_admin"),
related_modules=("ops", "audit"),
metadata={"kind": "runbook"},
),
), ),
capability_factories={ capability_factories={
CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider, CAPABILITY_CALENDAR_OUTBOX: _calendar_outbox_provider,
+129 -3
View File
@@ -7,7 +7,7 @@ from collections.abc import Callable, Mapping
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import and_, event, or_ from sqlalchemy import and_, event, or_, tuple_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from govoplan_calendar.backend.caldav import ( from govoplan_calendar.backend.caldav import (
@@ -394,6 +394,119 @@ def list_calendar_outbox_operations(
).limit(max(1, min(limit, 500))).all() ).limit(max(1, min(limit, 500))).all()
def calendar_outbox_operation_action_states(
session: Session,
operations: list[CalendarOutboxOperation],
) -> dict[str, dict[str, dict[str, object]]]:
"""Return recovery actions without per-row source or generation queries."""
if not operations:
return {}
source_ids = {operation.source_id for operation in operations}
sources = {
source.id: source
for source in session.query(CalendarSyncSource)
.filter(CalendarSyncSource.id.in_(sorted(source_ids)))
.all()
}
resource_keys = {
(operation.source_id, operation.resource_href) for operation in operations
}
generations = (
session.query(
CalendarOutboxOperation.id,
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.resource_href,
CalendarOutboxOperation.created_at,
)
.filter(
tuple_(
CalendarOutboxOperation.source_id,
CalendarOutboxOperation.resource_href,
).in_(sorted(resource_keys))
)
.all()
)
latest_by_resource: dict[tuple[str, str], tuple[datetime, str]] = {}
for operation_id, source_id, resource_href, created_at in generations:
key = (source_id, resource_href)
candidate = (created_at, operation_id)
if key not in latest_by_resource or candidate > latest_by_resource[key]:
latest_by_resource[key] = candidate
return {
operation.id: _calendar_outbox_action_state(
operation,
source=sources.get(operation.source_id),
latest_operation_id=latest_by_resource[
(operation.source_id, operation.resource_href)
][1],
)
for operation in operations
}
def _calendar_outbox_action_state(
operation: CalendarOutboxOperation,
*,
source: CalendarSyncSource | None,
latest_operation_id: str,
) -> dict[str, dict[str, object]]:
actions = {
action: {"allowed": False, "reason": None}
for action in ("retry", "reconcile", "discard")
}
def block_all(reason: str) -> dict[str, dict[str, object]]:
for state in actions.values():
state["reason"] = reason
return actions
if operation.id != latest_operation_id:
return block_all(
"A newer desired state exists for this resource; recover the latest operation instead."
)
if operation.status in {"succeeded", "superseded", "cancelled"}:
return block_all(f"This operation is already {operation.status}.")
if (
operation.status == "in_progress"
and operation.lease_expires_at is not None
and _as_utc(operation.lease_expires_at) > utcnow()
):
return block_all("A worker currently holds the delivery lease.")
unavailable_reason = _operation_source_unavailable_reason(operation, source)
if operation.status in {"retry", "dead", "conflict"}:
actions["retry"] = {
"allowed": unavailable_reason is None,
"reason": unavailable_reason,
}
else:
actions["retry"]["reason"] = (
"Retry is available after an automatic attempt reports a retryable or terminal failure."
)
if operation.status in {"pending", "retry", "dead", "conflict", "in_progress"}:
actions["reconcile"] = {
"allowed": unavailable_reason is None,
"reason": unavailable_reason,
}
discard_unavailable = (
"CalDAV source no longer exists"
if source is None
else (
"Calendar outbox source tenant does not match the operation tenant"
if source.tenant_id != operation.tenant_id
else None
)
)
actions["discard"] = {
"allowed": discard_unavailable is None,
"reason": discard_unavailable,
}
return actions
def get_calendar_outbox_operation( def get_calendar_outbox_operation(
session: Session, session: Session,
*, *,
@@ -1273,7 +1386,13 @@ def retry_calendar_outbox_operation(
tenant_id=tenant_id, tenant_id=tenant_id,
operation_id=operation_id, operation_id=operation_id,
) )
if operation.status in {"succeeded", "in_progress", "superseded", "cancelled"}: if operation.status in {
"pending",
"succeeded",
"in_progress",
"superseded",
"cancelled",
}:
raise ValueError(f"A {operation.status} Calendar outbox operation cannot be retried") raise ValueError(f"A {operation.status} Calendar outbox operation cannot be retried")
_assert_latest_resource_generation(session, operation) _assert_latest_resource_generation(session, operation)
unavailable_reason = _operation_source_unavailable_reason(operation, source) unavailable_reason = _operation_source_unavailable_reason(operation, source)
@@ -1553,7 +1672,11 @@ def calendar_outbox_has_unresolved_desired_state(
return False return False
def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> dict[str, Any]: def calendar_outbox_operation_response(
operation: CalendarOutboxOperation,
*,
actions: Mapping[str, Mapping[str, object]] | None = None,
) -> dict[str, Any]:
return { return {
"id": operation.id, "id": operation.id,
"tenant_id": operation.tenant_id, "tenant_id": operation.tenant_id,
@@ -1577,6 +1700,9 @@ def calendar_outbox_operation_response(operation: CalendarOutboxOperation) -> di
"created_at": operation.created_at, "created_at": operation.created_at,
"updated_at": operation.updated_at, "updated_at": operation.updated_at,
"metadata": dict(operation.metadata_ or {}), "metadata": dict(operation.metadata_ or {}),
"actions": {
str(action): dict(state) for action, state in (actions or {}).items()
},
} }
+34 -6
View File
@@ -55,6 +55,7 @@ from govoplan_calendar.backend.schemas import (
CalendarViewPreferencesUpdateRequest, CalendarViewPreferencesUpdateRequest,
) )
from govoplan_calendar.backend.outbox import ( from govoplan_calendar.backend.outbox import (
calendar_outbox_operation_action_states,
calendar_outbox_operation_response, calendar_outbox_operation_response,
discard_calendar_outbox_operation, discard_calendar_outbox_operation,
dispatch_calendar_outbox, dispatch_calendar_outbox,
@@ -127,6 +128,14 @@ def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes)) raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Requires one of: " + ", ".join(scopes))
def _require_worker_principal(principal: ApiPrincipal) -> None:
if principal.auth_method != "service_account":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="This endpoint is restricted to service-account workers.",
)
def _calendar_response(calendar) -> CalendarCollectionResponse: def _calendar_response(calendar) -> CalendarCollectionResponse:
return CalendarCollectionResponse.model_validate(calendar_response(calendar)) return CalendarCollectionResponse.model_validate(calendar_response(calendar))
@@ -271,9 +280,16 @@ def _sync_source_response(source) -> CalendarSyncSourceResponse:
return CalendarSyncSourceResponse.model_validate(caldav_source_response(source)) return CalendarSyncSourceResponse.model_validate(caldav_source_response(source))
def _outbox_operation_response(operation) -> CalendarOutboxOperationResponse: def _outbox_operation_response(
session: Session,
operation,
) -> CalendarOutboxOperationResponse:
actions = calendar_outbox_operation_action_states(session, [operation])
return CalendarOutboxOperationResponse.model_validate( return CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(operation) calendar_outbox_operation_response(
operation,
actions=actions.get(operation.id),
)
) )
@@ -468,6 +484,7 @@ def api_sync_due_sources(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:event:import") _require_scope(principal, "calendar:event:import")
_require_worker_principal(principal)
results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) results = sync_due_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit() session.commit()
return CalendarSyncDueSyncResponse( return CalendarSyncDueSyncResponse(
@@ -616,6 +633,7 @@ def api_sync_due_caldav_sources(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:event:import") _require_scope(principal, "calendar:event:import")
_require_worker_principal(principal)
results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit) results = sync_due_caldav_sources(session, tenant_id=principal.tenant_id, user_id=principal.user.id, limit=limit)
session.commit() session.commit()
return CalendarCalDavDueSyncResponse( return CalendarCalDavDueSyncResponse(
@@ -652,8 +670,17 @@ def api_list_caldav_outbox(
source_id=source_id, source_id=source_id,
limit=limit, limit=limit,
) )
action_states = calendar_outbox_operation_action_states(session, operations)
return CalendarOutboxOperationListResponse( return CalendarOutboxOperationListResponse(
operations=[_outbox_operation_response(operation) for operation in operations] operations=[
CalendarOutboxOperationResponse.model_validate(
calendar_outbox_operation_response(
operation,
actions=action_states.get(operation.id),
)
)
for operation in operations
]
) )
@@ -664,6 +691,7 @@ def api_dispatch_caldav_outbox(
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
_require_scope(principal, "calendar:calendar:admin") _require_scope(principal, "calendar:calendar:admin")
_require_worker_principal(principal)
return CalendarOutboxDispatchResponse.model_validate( return CalendarOutboxDispatchResponse.model_validate(
dispatch_calendar_outbox( dispatch_calendar_outbox(
session, session,
@@ -691,7 +719,7 @@ def api_retry_caldav_outbox_operation(
) )
session.commit() session.commit()
session.refresh(operation) session.refresh(operation)
return _outbox_operation_response(operation) return _outbox_operation_response(session, operation)
except ValueError as exc: except ValueError as exc:
session.rollback() session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
@@ -715,7 +743,7 @@ def api_reconcile_caldav_outbox_operation(
) )
session.commit() session.commit()
session.refresh(operation) session.refresh(operation)
return _outbox_operation_response(operation) return _outbox_operation_response(session, operation)
except (ValueError, CalDAVError, CalendarError) as exc: except (ValueError, CalDAVError, CalendarError) as exc:
session.rollback() session.rollback()
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
@@ -741,7 +769,7 @@ def api_discard_caldav_outbox_operation(
) )
session.commit() session.commit()
session.refresh(operation) session.refresh(operation)
return _outbox_operation_response(operation) return _outbox_operation_response(session, operation)
except ValueError as exc: except ValueError as exc:
session.rollback() session.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
+8
View File
@@ -265,6 +265,11 @@ class CalendarCalDavDueSyncResponse(BaseModel):
results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list) results: list[CalendarCalDavDueSyncItemResponse] = Field(default_factory=list)
class CalendarOutboxActionAvailability(BaseModel):
allowed: bool = False
reason: str | None = None
class CalendarOutboxOperationResponse(BaseModel): class CalendarOutboxOperationResponse(BaseModel):
id: str id: str
tenant_id: str tenant_id: str
@@ -288,6 +293,9 @@ class CalendarOutboxOperationResponse(BaseModel):
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
actions: dict[str, CalendarOutboxActionAvailability] = Field(
default_factory=dict
)
class CalendarOutboxOperationListResponse(BaseModel): class CalendarOutboxOperationListResponse(BaseModel):
+13
View File
@@ -7,6 +7,8 @@ from collections.abc import Iterator
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from govoplan_calendar.backend.service import CalendarError, http_request from govoplan_calendar.backend.service import CalendarError, http_request
from govoplan_calendar.backend.router import _require_worker_principal
from fastapi import HTTPException
@contextlib.contextmanager @contextlib.contextmanager
@@ -24,6 +26,17 @@ def running_http_server(handler: type[BaseHTTPRequestHandler]) -> Iterator[str]:
class CalendarHttpSecurityTests(unittest.TestCase): class CalendarHttpSecurityTests(unittest.TestCase):
def test_worker_routes_reject_interactive_principals(self) -> None:
class Principal:
auth_method = "session"
with self.assertRaises(HTTPException) as raised:
_require_worker_principal(Principal()) # type: ignore[arg-type]
self.assertEqual(403, raised.exception.status_code)
Principal.auth_method = "service_account"
_require_worker_principal(Principal()) # type: ignore[arg-type]
def test_cross_origin_redirect_does_not_forward_authorization(self) -> None: def test_cross_origin_redirect_does_not_forward_authorization(self) -> None:
forwarded_authorization: list[str | None] = [] forwarded_authorization: list[str | None] = []
+61
View File
@@ -18,6 +18,7 @@ from govoplan_calendar.backend.caldav import (
from govoplan_calendar.backend.db.models import CalendarOutboxOperation from govoplan_calendar.backend.db.models import CalendarOutboxOperation
from govoplan_calendar.backend.outbox import ( from govoplan_calendar.backend.outbox import (
SqlCalendarOutboxProvider, SqlCalendarOutboxProvider,
calendar_outbox_operation_action_states,
claim_next_calendar_outbox_operation, claim_next_calendar_outbox_operation,
discard_calendar_outbox_operation, discard_calendar_outbox_operation,
dispatch_calendar_outbox, dispatch_calendar_outbox,
@@ -508,6 +509,66 @@ class CalendarOutboxTests(unittest.TestCase):
operation_id=first.id, operation_id=first.id,
) )
def test_recovery_action_state_is_batched_and_respects_source_state(self) -> None:
event = self.create_local_event(summary="First")
self.session.commit()
operation = self.session.query(CalendarOutboxOperation).one()
state = calendar_outbox_operation_action_states(
self.session,
[operation],
)[operation.id]
self.assertFalse(state["retry"]["allowed"])
self.assertTrue(state["reconcile"]["allowed"])
self.assertTrue(state["discard"]["allowed"])
operation.status = "conflict"
self.source.sync_enabled = False
self.session.flush()
disabled = calendar_outbox_operation_action_states(
self.session,
[operation],
)[operation.id]
self.assertFalse(disabled["retry"]["allowed"])
self.assertIn("disabled", str(disabled["retry"]["reason"]))
self.assertFalse(disabled["reconcile"]["allowed"])
self.assertTrue(disabled["discard"]["allowed"])
self.source.sync_enabled = True
update_event(
self.session,
tenant_id="tenant-1",
user_id=None,
event_id=event.id,
payload=CalendarEventUpdateRequest(summary="Second"),
)
self.session.flush()
stale = calendar_outbox_operation_action_states(
self.session,
[operation],
)[operation.id]
self.assertFalse(any(item["allowed"] for item in stale.values()))
self.assertIn("newer desired state", str(stale["discard"]["reason"]))
def test_manual_retry_cannot_be_repeated_while_pending(self) -> None:
self.create_local_event()
self.session.commit()
operation = self.session.query(CalendarOutboxOperation).one()
operation.status = "dead"
self.session.flush()
retry_calendar_outbox_operation(
self.session,
tenant_id="tenant-1",
operation_id=operation.id,
)
with self.assertRaisesRegex(ValueError, "pending"):
retry_calendar_outbox_operation(
self.session,
tenant_id="tenant-1",
operation_id=operation.id,
)
def test_discard_latest_cancels_attempted_predecessor_chain(self) -> None: def test_discard_latest_cancels_attempted_predecessor_chain(self) -> None:
event = self.create_local_event(summary="First") event = self.create_local_event(summary="First")
self.session.commit() self.session.commit()
+51
View File
@@ -191,6 +191,35 @@ export type CalendarSyncSourceSyncResponse = {
export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse; export type CalendarCalDavSyncResponse = CalendarSyncSourceSyncResponse;
export type CalendarOutboxActionAvailability = {
allowed: boolean;
reason?: string | null;
};
export type CalendarOutboxOperation = {
id: string;
source_id: string;
event_id?: string | null;
operation_kind: "put" | "delete" | string;
resource_href: string;
status: string;
attempt_count: number;
max_attempts: number;
available_at: string;
last_attempt_at?: string | null;
lease_expires_at?: string | null;
completed_at?: string | null;
reconciled_at?: string | null;
last_error?: string | null;
created_at: string;
updated_at: string;
actions: Record<string, CalendarOutboxActionAvailability>;
};
export type CalendarOutboxOperationListResponse = {
operations: CalendarOutboxOperation[];
};
export type CalendarCollectionCreatePayload = { export type CalendarCollectionCreatePayload = {
name: string; name: string;
slug?: string | null; slug?: string | null;
@@ -345,6 +374,28 @@ export function syncCalDavSource(settings: ApiSettings, sourceId: string, payloa
return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) }); return apiFetch<CalendarCalDavSyncResponse>(settings, `/api/v1/calendar/caldav/sources/${sourceId}/sync`, { method: "POST", body: JSON.stringify(payload) });
} }
export function listCalendarOutbox(
settings: ApiSettings,
params: { source_id?: string; status?: string; limit?: number } = {},
): Promise<CalendarOutboxOperationListResponse> {
const search = new URLSearchParams();
if (params.source_id) search.set("source_id", params.source_id);
if (params.status) search.set("status", params.status);
if (params.limit) search.set("limit", String(params.limit));
const suffix = search.toString() ? `?${search.toString()}` : "";
return apiFetch<CalendarOutboxOperationListResponse>(settings, `/api/v1/calendar/caldav/outbox${suffix}`);
}
export function recoverCalendarOutboxOperation(
settings: ApiSettings,
operationId: string,
action: "retry" | "reconcile" | "discard",
): Promise<CalendarOutboxOperation> {
return apiFetch<CalendarOutboxOperation>(settings, `/api/v1/calendar/caldav/outbox/${operationId}/${action}`, {
method: "POST",
});
}
export function listCalendarEvents( export function listCalendarEvents(
settings: ApiSettings, settings: ApiSettings,
params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {} params: { calendar_id?: string; start_at?: string; end_at?: string; expand_recurring?: boolean } = {}
@@ -5,7 +5,7 @@ import {
type ChangeEvent, type ChangeEvent,
type FormEvent, type FormEvent,
} from "react"; } from "react";
import { RefreshCw, Trash2 } from "lucide-react"; import { ListChecks, RefreshCw, Trash2 } from "lucide-react";
import { import {
Button, Button,
ColorPickerField, ColorPickerField,
@@ -101,6 +101,7 @@ export function CalendarCollectionDialog({
onSave, onSave,
onRequestDelete, onRequestDelete,
onSync, onSync,
onOpenOutbox,
onDiscover onDiscover
@@ -116,7 +117,7 @@ export function CalendarCollectionDialog({
}: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) { }: {state: CalendarCollectionDialogState;settings: ApiSettings;source: CalendarSyncSource | null;saving: boolean;syncingSourceId: string;canWrite: boolean;canDelete: boolean;canManageSources: boolean;canSyncSources: boolean;onCancel: () => void;onSave: (payload: CalendarCollectionFormPayload) => Promise<boolean>;onRequestDelete: (calendar: CalendarCollection, eventCount: number | null, loadingEventCount: boolean) => void;onSync: (source: CalendarSyncSource, payload?: {password?: string | null;bearer_token?: string | null;force_full?: boolean;}) => Promise<void>;onOpenOutbox: (source: CalendarSyncSource) => void;onDiscover: (payload: CalendarCalDavDiscoveryPayload) => Promise<{calendars: CalendarCalDavDiscoveryCandidate[];}>;}) {
const calendar = state.kind === "edit" ? state.calendar : null; const calendar = state.kind === "edit" ? state.calendar : null;
const isEdit = Boolean(calendar); const isEdit = Boolean(calendar);
const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local"); const [sourceMode, setSourceMode] = useState<CalendarSourceMode>(source ? calendarSourceModeForSource(source) : "local");
@@ -559,6 +560,11 @@ export function CalendarCollectionDialog({
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}> <Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || !canSyncSources}>
i18n:govoplan-calendar.full_sync.21b89c76 i18n:govoplan-calendar.full_sync.21b89c76
</Button> </Button>
{source.source_kind === "caldav" && canManageSources && (
<Button type="button" onClick={() => onOpenOutbox(source)} disabled={saving || syncing}>
<ListChecks size={16} /> i18n:govoplan-calendar.outbound_changes.7038a839
</Button>
)}
</div> </div>
</div> </div>
} }
@@ -0,0 +1,213 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
import {
Button,
ConfirmDialog,
Dialog,
DismissibleAlert,
SegmentedControl,
StatusBadge,
type ApiSettings,
} from "@govoplan/core-webui";
import {
listCalendarOutbox,
recoverCalendarOutboxOperation,
type CalendarCollection,
type CalendarOutboxOperation,
type CalendarSyncSource,
} from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel";
type OutboxFilter = "unresolved" | "all";
type RecoveryAction = "retry" | "reconcile" | "discard";
const RESOLVED_STATUSES = new Set(["succeeded", "superseded", "cancelled"]);
export function CalendarOutboxDialog({
settings,
calendar,
source,
onClose,
}: {
settings: ApiSettings;
calendar: CalendarCollection;
source: CalendarSyncSource;
onClose: () => void;
}) {
const [operations, setOperations] = useState<CalendarOutboxOperation[]>([]);
const [filter, setFilter] = useState<OutboxFilter>("unresolved");
const [loading, setLoading] = useState(true);
const [busyOperationId, setBusyOperationId] = useState("");
const [error, setError] = useState("");
const [discardOperation, setDiscardOperation] = useState<CalendarOutboxOperation | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const response = await listCalendarOutbox(settings, {
source_id: source.id,
limit: 100,
});
setOperations(response.operations);
} catch (err) {
setError(errorText(err));
} finally {
setLoading(false);
}
}, [settings, source.id]);
useEffect(() => {
void load();
}, [load]);
const visibleOperations = useMemo(
() => filter === "all"
? operations
: operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)),
[filter, operations],
);
const unresolvedCount = operations.filter((operation) => !RESOLVED_STATUSES.has(operation.status)).length;
const conflictCount = operations.filter((operation) => ["conflict", "dead"].includes(operation.status)).length;
async function recover(operation: CalendarOutboxOperation, action: RecoveryAction) {
setBusyOperationId(operation.id);
setError("");
try {
await recoverCalendarOutboxOperation(settings, operation.id, action);
setDiscardOperation(null);
await load();
} catch (err) {
setError(errorText(err));
} finally {
setBusyOperationId("");
}
}
return (
<>
<Dialog
open
title="i18n:govoplan-calendar.outbound_changes.7038a839"
className="calendar-outbox-dialog"
closeDisabled={Boolean(busyOperationId)}
onClose={onClose}
footer={
<>
<Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)}>
<RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc
</Button>
<Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}>
i18n:govoplan-core.close.bbfa773e
</Button>
</>
}
>
<div className="calendar-outbox-body">
<p className="calendar-outbox-calendar-name">{calendar.name}</p>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="calendar-outbox-toolbar">
<SegmentedControl<OutboxFilter>
value={filter}
ariaLabel="i18n:govoplan-calendar.outbox_filter.8305af40"
onChange={setFilter}
options={[
{ id: "unresolved", label: "i18n:govoplan-calendar.unresolved.11c41de4" },
{ id: "all", label: "i18n:govoplan-calendar.all_history.8829c44e" },
]}
/>
<dl className="calendar-outbox-summary">
<div><dt>i18n:govoplan-calendar.unresolved.11c41de4</dt><dd>{unresolvedCount}</dd></div>
<div><dt>i18n:govoplan-calendar.conflicts_dead.31252c3a</dt><dd>{conflictCount}</dd></div>
<div><dt>i18n:govoplan-calendar.shown.498e85a1</dt><dd>{visibleOperations.length}</dd></div>
</dl>
</div>
{loading && !operations.length
? <p className="calendar-form-note">i18n:govoplan-calendar.loading_outbound_changes.3fc59656</p>
: visibleOperations.length
? (
<ol className="calendar-outbox-list">
{visibleOperations.map((operation) => (
<li key={operation.id} className="calendar-outbox-item">
<div className="calendar-outbox-item-heading">
<div>
<StatusBadge status={operation.status} />
<strong>{operation.operation_kind.toUpperCase()}</strong>
</div>
<time dateTime={operation.updated_at}>{dateTimeLabel(new Date(operation.updated_at))}</time>
</div>
<code title={operation.resource_href}>{operation.resource_href}</code>
<dl className="calendar-outbox-item-facts">
<div><dt>i18n:govoplan-calendar.attempts.448fa12e</dt><dd>{operation.attempt_count}/{operation.max_attempts}</dd></div>
<div><dt>i18n:govoplan-calendar.available.17bc146b</dt><dd>{dateTimeLabel(new Date(operation.available_at))}</dd></div>
</dl>
{operation.last_error && <p className="calendar-outbox-error">{operation.last_error}</p>}
<RecoveryActions
operation={operation}
busy={busyOperationId === operation.id}
onRecover={(action) => action === "discard"
? setDiscardOperation(operation)
: void recover(operation, action)}
/>
</li>
))}
</ol>
)
: <p className="calendar-form-note">i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64</p>}
{operations.length === 100 && (
<p className="calendar-form-note">i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365</p>
)}
</div>
</Dialog>
<ConfirmDialog
open={Boolean(discardOperation)}
title="i18n:govoplan-calendar.discard_local_desired_state.952f3be1"
message="i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d"
confirmLabel="i18n:govoplan-calendar.discard_change.85474ec4"
tone="danger"
busy={Boolean(busyOperationId)}
onCancel={() => setDiscardOperation(null)}
onConfirm={() => discardOperation && void recover(discardOperation, "discard")}
/>
</>
);
}
function RecoveryActions({
operation,
busy,
onRecover,
}: {
operation: CalendarOutboxOperation;
busy: boolean;
onRecover: (action: RecoveryAction) => void;
}) {
const available = (["retry", "reconcile", "discard"] as const).filter(
(action) => operation.actions[action]?.allowed,
);
const unavailableReason = Object.values(operation.actions).find((action) => action.reason)?.reason;
return (
<div className="calendar-outbox-recovery">
{available.length > 0 && (
<div className="calendar-outbox-actions">
{available.includes("retry") && (
<Button type="button" onClick={() => onRecover("retry")} disabled={busy}>
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
</Button>
)}
{available.includes("reconcile") && (
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy}>
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
</Button>
)}
{available.includes("discard") && (
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy}>
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
</Button>
)}
</div>
)}
{!available.length && unavailableReason && <p className="calendar-form-note">{unavailableReason}</p>}
</div>
);
}
@@ -57,6 +57,7 @@ import {
type CalendarDeleteDialogState, type CalendarDeleteDialogState,
} from "./CalendarCollectionDialogs"; } from "./CalendarCollectionDialogs";
import { CalendarEventDialog } from "./CalendarEventDialog"; import { CalendarEventDialog } from "./CalendarEventDialog";
import { CalendarOutboxDialog } from "./CalendarOutboxDialog";
import { import {
CalendarTimeGrid, CalendarTimeGrid,
CalendarWeekRows, CalendarWeekRows,
@@ -132,6 +133,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null); const [eventDialog, setEventDialog] = useState<EventDialogState | null>(null);
const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null); const [calendarDialog, setCalendarDialog] = useState<CalendarCollectionDialogState | null>(null);
const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null); const [calendarDeleteDialog, setCalendarDeleteDialog] = useState<CalendarDeleteDialogState | null>(null);
const [outboxDialog, setOutboxDialog] = useState<{ calendar: CalendarCollection; source: CalendarSyncSource } | null>(null);
const [syncingSourceId, setSyncingSourceId] = useState(""); const [syncingSourceId, setSyncingSourceId] = useState("");
const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 }); const [continuousViewport, setContinuousViewport] = useState<ContinuousViewport>({ scrollTop: 0, height: 0 });
const [draggingEventId, setDraggingEventId] = useState(""); const [draggingEventId, setDraggingEventId] = useState("");
@@ -1005,8 +1007,21 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
onSave={handleCalendarSave} onSave={handleCalendarSave}
onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)} onRequestDelete={(calendar, eventCount, loadingEventCount) => openCalendarDelete(calendar, eventCount, loadingEventCount)}
onSync={handleSyncSource} onSync={handleSyncSource}
onOpenOutbox={(source) => {
if (calendarDialog.kind === "edit") {
setOutboxDialog({ calendar: calendarDialog.calendar, source });
}
}}
onDiscover={handleCalDavDiscovery} /> onDiscover={handleCalDavDiscovery} />
}
{outboxDialog &&
<CalendarOutboxDialog
settings={settings}
calendar={outboxDialog.calendar}
source={outboxDialog.source}
onClose={() => setOutboxDialog(null)} />
} }
{calendarDeleteDialog && {calendarDeleteDialog &&
<CalendarCollectionDeleteDialog <CalendarCollectionDeleteDialog
+34
View File
@@ -193,6 +193,23 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.week.f82be68a": "Week", "i18n:govoplan-calendar.week.f82be68a": "Week",
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day", "i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
"i18n:govoplan-calendar.working.049ac820": "Working...", "i18n:govoplan-calendar.working.049ac820": "Working...",
"i18n:govoplan-calendar.all_history.8829c44e": "All history",
"i18n:govoplan-calendar.attempts.448fa12e": "Attempts",
"i18n:govoplan-calendar.available.17bc146b": "Available",
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Conflicts / dead",
"i18n:govoplan-calendar.discard.23a76911": "Discard",
"i18n:govoplan-calendar.discard_change.85474ec4": "Discard change",
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Discard local desired state?",
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Discarding cancels this local outbound change and its unresolved predecessors. The next full sync accepts the remote state, so local changes may be lost.",
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Loading outbound changes...",
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "No outbound changes match this filter.",
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Only the 100 most recent outbound changes are shown.",
"i18n:govoplan-calendar.outbound_changes.7038a839": "Outbound changes",
"i18n:govoplan-calendar.outbox_filter.8305af40": "Outbound change filter",
"i18n:govoplan-calendar.reconcile.6c595f64": "Reconcile",
"i18n:govoplan-calendar.retry.3fda8f1c": "Retry",
"i18n:govoplan-calendar.shown.498e85a1": "Shown",
"i18n:govoplan-calendar.unresolved.11c41de4": "Unresolved",
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek" "i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
}, },
"de": { "de": {
@@ -387,6 +404,23 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.week.f82be68a": "Woche", "i18n:govoplan-calendar.week.f82be68a": "Woche",
"i18n:govoplan-calendar.whole_day.951c82d1": "Whole day", "i18n:govoplan-calendar.whole_day.951c82d1": "Whole day",
"i18n:govoplan-calendar.working.049ac820": "Working...", "i18n:govoplan-calendar.working.049ac820": "Working...",
"i18n:govoplan-calendar.all_history.8829c44e": "Gesamter Verlauf",
"i18n:govoplan-calendar.attempts.448fa12e": "Versuche",
"i18n:govoplan-calendar.available.17bc146b": "Verfügbar",
"i18n:govoplan-calendar.conflicts_dead.31252c3a": "Konflikte / endgültig fehlgeschlagen",
"i18n:govoplan-calendar.discard.23a76911": "Verwerfen",
"i18n:govoplan-calendar.discard_change.85474ec4": "Änderung verwerfen",
"i18n:govoplan-calendar.discard_local_desired_state.952f3be1": "Lokalen Sollzustand verwerfen?",
"i18n:govoplan-calendar.discarding_cancels_this_local_outbound_change_and_its.0ed7148d": "Das Verwerfen bricht diese lokale ausgehende Änderung und ihre ungelösten Vorgänger ab. Die nächste vollständige Synchronisierung übernimmt den entfernten Stand; lokale Änderungen können verloren gehen.",
"i18n:govoplan-calendar.loading_outbound_changes.3fc59656": "Ausgehende Änderungen werden geladen...",
"i18n:govoplan-calendar.no_outbound_changes_match_this_filter.43d3aa64": "Keine ausgehenden Änderungen entsprechen diesem Filter.",
"i18n:govoplan-calendar.only_the_100_most_recent_outbound_changes_are_shown.94bd8365": "Es werden nur die 100 neuesten ausgehenden Änderungen angezeigt.",
"i18n:govoplan-calendar.outbound_changes.7038a839": "Ausgehende Änderungen",
"i18n:govoplan-calendar.outbox_filter.8305af40": "Filter für ausgehende Änderungen",
"i18n:govoplan-calendar.reconcile.6c595f64": "Abgleichen",
"i18n:govoplan-calendar.retry.3fda8f1c": "Erneut versuchen",
"i18n:govoplan-calendar.shown.498e85a1": "Angezeigt",
"i18n:govoplan-calendar.unresolved.11c41de4": "Ungelöst",
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek" "i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
} }
}; };
+140
View File
@@ -1099,6 +1099,146 @@
white-space: nowrap; white-space: nowrap;
} }
.calendar-outbox-dialog {
width: min(860px, 100%);
}
.calendar-outbox-dialog .dialog-body {
min-height: 360px;
max-height: min(70vh, 720px);
overflow: hidden;
}
.calendar-outbox-body {
min-height: 0;
height: 100%;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
gap: 12px;
}
.calendar-outbox-calendar-name {
margin: 0;
color: var(--muted);
font-size: 13px;
font-weight: 700;
}
.calendar-outbox-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.calendar-outbox-summary {
display: flex;
align-items: center;
gap: 14px;
margin: 0;
}
.calendar-outbox-summary div {
display: flex;
align-items: baseline;
gap: 5px;
}
.calendar-outbox-summary dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.calendar-outbox-summary dd {
margin: 0;
color: var(--text-strong);
font-weight: 800;
}
.calendar-outbox-list {
min-height: 0;
display: grid;
align-content: start;
gap: 8px;
margin: 0;
padding: 0 4px 0 0;
overflow: auto;
list-style: none;
}
.calendar-outbox-item {
display: grid;
gap: 8px;
padding: 11px 12px;
border: var(--border-line);
border-radius: 6px;
background: var(--surface);
}
.calendar-outbox-item-heading,
.calendar-outbox-item-heading > div,
.calendar-outbox-actions {
display: flex;
align-items: center;
gap: 8px;
}
.calendar-outbox-item-heading {
justify-content: space-between;
}
.calendar-outbox-item-heading time {
color: var(--muted);
font-size: 12px;
}
.calendar-outbox-item code {
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.calendar-outbox-item-facts {
display: flex;
gap: 16px;
margin: 0;
}
.calendar-outbox-item-facts div {
display: flex;
gap: 5px;
}
.calendar-outbox-item-facts dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.calendar-outbox-item-facts dd {
margin: 0;
font-size: 12px;
}
.calendar-outbox-error {
margin: 0;
padding: 8px 9px;
border-left: 3px solid var(--red);
background: var(--calendar-danger-bg);
color: var(--red);
font-size: 12px;
}
.calendar-outbox-actions {
justify-content: flex-end;
flex-wrap: wrap;
}
.calendar-form-error { .calendar-form-error {
margin: 0; margin: 0;
padding: 9px 10px; padding: 9px 10px;