Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
ea38db9e94 feat(caldav): Slice 2c MKCALENDAR + Google-degrade (t-paliad-212)
Final Slice 2 sub-slice: users on iCloud / Fastmail / Nextcloud /
Radicale / Baikal / SOGo can now create a brand-new calendar from the
Paliad UI with one click; users on Google CalDAV (and any future
no-MKCALENDAR provider) get a clean degrade UX that nudges them to
create the calendar in their provider's app and paste the URL back.
Per m's Q2 pick, the capability lives on user_caldav_config so the
probe runs once per server change, not per modal open.

Schema (mig 108)
- paliad.user_caldav_config.supports_mkcalendar boolean — NULL =
  unprobed, TRUE = supported, FALSE = degrade.
- paliad.user_caldav_config.mkcalendar_probed_at timestamptz — used
  by the next round of probes after SaveConfig invalidates.
- Idempotent (information_schema column-exists checks) + assertion.

CalDAV client
- ProbeMKCalendar: OPTIONS Allow header first; on absence of
  MKCALENDAR, falls back to a synthetic MKCALENDAR against a
  random .paliad-probe-XX/ path (with DELETE cleanup) to catch
  legacy SOGo / misconfigured Radicale (design §4.2).
- MakeCalendar: issues MKCALENDAR with displayname + VEVENT-only
  supported-components; returns ErrCalendarNameTaken on 405 so
  the service layer can retry with a disambiguating suffix.
- Sentinel errors ErrCalendarNameTaken, ErrMKCalendarUnsupported.

Service
- CalDAVService.ensureMKCalendarProbed: lazy probe on first
  /api/caldav-discover call after credential change; result persisted
  via UPDATE on user_caldav_config. DiscoverCalendars response now
  carries supports_mkcalendar so the UI can show / hide the create-new
  radio.
- CalDAVService.MakeCalendar: re-probes if needed, issues MKCALENDAR
  via the client (with 3-try -XX-suffix retry on name collision),
  creates the matching binding, kicks off PushBindingNow. Returns
  the partial result on push failure so the UI can show "created but
  initial sync failed".
- InvalidateDiscoveryCache now also clears supports_mkcalendar so a
  re-configured server gets re-probed on next open.

HTTP API
- POST /api/caldav-mkcalendar — {display_name, scope_kind, scope_id?,
  include_personal?} → 201 {calendar_path, binding, initial_pushed}.
  Errors: 501 supports_mkcalendar=false, 409 name conflict, 5xx
  upstream. Partial-success (binding created, push failed) carries
  initial_sync_error in the body so the UI can surface both bits.

Frontend
- Add-modal source picker becomes a 3-way radio: "Existierenden
  wählen" / "Neuen Kalender erstellen" / "Eigene URL eingeben".
  Create radio is visible only when supports_mkcalendar=true;
  when false, the bilingual Google-degrade notice is shown
  beneath the source picker.
- Submit dispatches to /api/caldav-mkcalendar (create) or
  /api/caldav-bindings (existing / custom).
- 6 new i18n keys DE+EN under caldav.bindings.modal.source.*
  + caldav.bindings.error.create_*.

Verification
- mig 108 dry-run against live Supabase: both columns added, nullable,
  no constraint surprise.
- go build ./... + go test ./internal/services/ ./internal/handlers/ +
  bun run build all clean.

Slice 2 complete (2a + 2b + 2c). Slice 3 (hierarchy scopes:
client/litigation/patent/case) and Slice 4 (drop legacy scalar
caldav_uid/caldav_etag) remain.
2026-05-20 13:25:46 +02:00
11 changed files with 707 additions and 71 deletions

View File

@@ -1700,7 +1700,10 @@ const translations: Record<Lang, Record<string, string>> = {
"caldav.bindings.modal.edit_title": "Kalender bearbeiten",
"caldav.bindings.modal.source": "Kalender",
"caldav.bindings.modal.source.loading": "Lädt …",
"caldav.bindings.modal.source.existing": "Vorhandenen Kalender wählen",
"caldav.bindings.modal.source.create": "Neuen Kalender erstellen",
"caldav.bindings.modal.source.custom": "Eigene URL eingeben",
"caldav.bindings.modal.source.degrade": "Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV. Erstelle den Kalender direkt in der Anbieter-Oberfläche und füge ihn hier per URL hinzu.",
"caldav.bindings.modal.source.discover_failed": "Kalender konnten nicht ermittelt werden — eigene URL eingeben.",
"caldav.bindings.modal.source.discover_empty": "Keine Kalender gefunden — eigene URL eingeben.",
"caldav.bindings.modal.display_name": "Anzeigename (optional)",
@@ -1717,6 +1720,9 @@ const translations: Record<Lang, Record<string, string>> = {
"caldav.bindings.error.scope": "Bitte einen Inhaltsbereich wählen.",
"caldav.bindings.error.scope_project": "Bitte ein Projekt auswählen.",
"caldav.bindings.error.path": "Bitte einen Kalender wählen oder eine URL eingeben.",
"caldav.bindings.error.create_name_required": "Bitte einen Anzeigenamen eingeben.",
"caldav.bindings.error.create_name_taken": "Name bereits vergeben — bitte einen anderen Anzeigenamen wählen.",
"caldav.bindings.error.create_unsupported": "Dein Anbieter unterstützt das Erstellen neuer Kalender nicht. Bitte 'Eigene URL eingeben' verwenden.",
// Notizen (polymorphic notes — Phase I)
"notes.section.title": "Notizen",
@@ -4360,7 +4366,10 @@ const translations: Record<Lang, Record<string, string>> = {
"caldav.bindings.modal.edit_title": "Edit calendar",
"caldav.bindings.modal.source": "Calendar",
"caldav.bindings.modal.source.loading": "Loading…",
"caldav.bindings.modal.source.existing": "Pick existing calendar",
"caldav.bindings.modal.source.create": "Create new calendar",
"caldav.bindings.modal.source.custom": "Enter custom URL",
"caldav.bindings.modal.source.degrade": "This provider doesn't allow creating calendars via CalDAV. Please create the calendar in your provider's UI and add it here by URL.",
"caldav.bindings.modal.source.discover_failed": "Couldn't discover calendars — enter URL manually.",
"caldav.bindings.modal.source.discover_empty": "No calendars found — enter URL manually.",
"caldav.bindings.modal.display_name": "Display name (optional)",
@@ -4377,6 +4386,9 @@ const translations: Record<Lang, Record<string, string>> = {
"caldav.bindings.error.scope": "Please pick a content scope.",
"caldav.bindings.error.scope_project": "Please pick a project.",
"caldav.bindings.error.path": "Please pick a calendar or enter a URL.",
"caldav.bindings.error.create_name_required": "Please enter a display name.",
"caldav.bindings.error.create_name_taken": "Name already in use — please pick a different display name.",
"caldav.bindings.error.create_unsupported": "Your provider doesn't support creating calendars. Please use 'Enter custom URL' instead.",
// Notizen (polymorphic notes — Phase I)
"notes.section.title": "Notes",

View File

@@ -634,6 +634,10 @@ let bindings: UserCalendarBinding[] = [];
let discoveredCalendars: DiscoveredCalendar[] = [];
let bindingProjects: ProjectListItem[] = [];
let editingBindingID: string | null = null;
// Slice 2c — capability cached from /api/caldav-discover. null = unprobed,
// true = MKCALENDAR supported (show "Create new calendar" radio),
// false = degrade UX (hide radio, surface bilingual notice).
let supportsMKCalendar: boolean | null = null;
async function loadBindings(): Promise<void> {
const section = document.getElementById("caldav-bindings-section");
@@ -731,29 +735,75 @@ async function loadDiscoveredCalendars(): Promise<void> {
const resp = await fetch("/api/caldav-discover");
if (!resp.ok) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
return;
}
const data = (await resp.json()) as { calendars: DiscoveredCalendar[] };
const data = (await resp.json()) as {
calendars: DiscoveredCalendar[];
supports_mkcalendar?: boolean | null;
};
discoveredCalendars = data.calendars || [];
supportsMKCalendar = data.supports_mkcalendar ?? null;
if (!discoveredCalendars.length) {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_empty"))}</option>`;
return;
} else {
sel.innerHTML = discoveredCalendars
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
.join("");
}
sel.innerHTML = discoveredCalendars
.map((c) => `<option value="${esc(c.href)}">${esc(c.display_name || c.href)}</option>`)
.join("");
syncBindingSourceModeUI();
} catch {
sel.innerHTML = `<option value="">${esc(t("caldav.bindings.modal.source.discover_failed"))}</option>`;
supportsMKCalendar = null;
syncBindingSourceModeUI();
}
}
// syncBindingSourceModeUI shows / hides the "Neuen Kalender erstellen"
// radio + the Google-degrade notice based on the cached
// supports_mkcalendar capability. Also flips the visible input
// (dropdown vs URL text box) to match the currently selected mode.
function syncBindingSourceModeUI(): void {
const createRow = document.getElementById("caldav-binding-source-mode-create-row");
const degrade = document.getElementById("caldav-binding-degrade-notice");
if (createRow) createRow.style.display = supportsMKCalendar === true ? "" : "none";
if (degrade) degrade.style.display = supportsMKCalendar === false ? "" : "none";
// If supports_mkcalendar flipped to false while "create" was selected,
// fall back to "existing" so the user isn't staring at a hidden radio.
if (supportsMKCalendar !== true) {
const createRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="create"]',
) as HTMLInputElement | null;
if (createRadio?.checked) {
const existing = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existing) existing.checked = true;
}
}
const mode = currentBindingSourceMode();
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
sel.style.display = mode === "existing" ? "" : "none";
customInput.style.display = mode === "custom" ? "" : "none";
}
function currentBindingSourceMode(): "existing" | "create" | "custom" {
const checked = document.querySelector(
'input[name="caldav-binding-source-mode"]:checked',
) as HTMLInputElement | null;
return (checked?.value as "existing" | "create" | "custom") ?? "existing";
}
function openBindingModal(b: UserCalendarBinding | null) {
editingBindingID = b ? b.id : null;
const modal = document.getElementById("caldav-binding-modal")!;
const title = document.getElementById("caldav-binding-modal-title")!;
const submitBtn = document.getElementById("caldav-binding-submit-btn")!;
const sourceField = document.getElementById("caldav-binding-source-field")!;
const customToggle = document.getElementById("caldav-binding-custom-toggle") as HTMLInputElement;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
const projectSel = document.getElementById("caldav-binding-project-select") as HTMLSelectElement;
@@ -771,8 +821,11 @@ function openBindingModal(b: UserCalendarBinding | null) {
title.textContent = t("caldav.bindings.modal.add_title");
submitBtn.textContent = t("caldav.bindings.modal.submit_add");
sourceField.style.display = "";
customToggle.checked = false;
customInput.style.display = "none";
// Reset the 3-way source-mode radio to "existing" (most common path).
const existingRadio = document.querySelector(
'input[name="caldav-binding-source-mode"][value="existing"]',
) as HTMLInputElement | null;
if (existingRadio) existingRadio.checked = true;
customInput.value = "";
nameInput.value = "";
const radio = document.querySelector(`input[name="caldav-binding-scope"][value="all_visible"]`) as HTMLInputElement;
@@ -789,6 +842,7 @@ function openBindingModal(b: UserCalendarBinding | null) {
projectSel.disabled = false;
}
syncBindingScopeUI();
syncBindingSourceModeUI();
modal.style.display = "flex";
}
@@ -808,7 +862,6 @@ async function submitBindingModal(ev: Event): Promise<void> {
ev.preventDefault();
const msg = document.getElementById("caldav-binding-msg")!;
msg.textContent = "";
const customToggle = document.getElementById("caldav-binding-custom-toggle") as HTMLInputElement;
const customInput = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
const nameInput = document.getElementById("caldav-binding-display-name") as HTMLInputElement;
@@ -821,27 +874,25 @@ async function submitBindingModal(ev: Event): Promise<void> {
msg.className = "form-msg form-msg-error";
return;
}
const payload: Record<string, unknown> = {
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") {
if (!projectSel.value) {
msg.textContent = t("caldav.bindings.error.scope_project");
msg.className = "form-msg form-msg-error";
return;
}
payload.scope_id = projectSel.value;
if (scope === "project" && !projectSel.value) {
msg.textContent = t("caldav.bindings.error.scope_project");
msg.className = "form-msg form-msg-error";
return;
}
submitBtn.disabled = true;
try {
if (editingBindingID) {
const patchPayload: Record<string, unknown> = {
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") patchPayload.scope_id = projectSel.value;
const resp = await fetch(`/api/caldav-bindings/${editingBindingID}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
body: JSON.stringify(patchPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
@@ -850,27 +901,75 @@ async function submitBindingModal(ev: Event): Promise<void> {
return;
}
} else {
const path = customToggle.checked ? customInput.value.trim() : sel.value;
if (!path) {
msg.textContent = t("caldav.bindings.error.path");
msg.className = "form-msg form-msg-error";
return;
}
payload.calendar_path = path;
if (!payload.display_name && !customToggle.checked) {
const opt = sel.options[sel.selectedIndex];
payload.display_name = opt ? opt.text : "";
}
const resp = await fetch("/api/caldav-bindings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
const mode = currentBindingSourceMode();
if (mode === "create") {
// Slice 2c MKCALENDAR path.
const displayName = nameInput.value.trim();
if (!displayName) {
msg.textContent = t("caldav.bindings.error.create_name_required");
msg.className = "form-msg form-msg-error";
return;
}
const createPayload: Record<string, unknown> = {
display_name: displayName,
scope_kind: scope,
};
if (scope === "project") createPayload.scope_id = projectSel.value;
const resp = await fetch("/api/caldav-mkcalendar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(createPayload),
});
if (resp.status === 501) {
// Race: probe flipped to false between modal-open and submit.
// Re-sync the UI and surface a helpful message.
supportsMKCalendar = false;
syncBindingSourceModeUI();
msg.textContent = t("caldav.bindings.error.create_unsupported");
msg.className = "form-msg form-msg-error";
return;
}
if (resp.status === 409) {
msg.textContent = t("caldav.bindings.error.create_name_taken");
msg.className = "form-msg form-msg-error";
return;
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
} else {
// existing | custom — POST /api/caldav-bindings with the path.
const path = mode === "custom" ? customInput.value.trim() : sel.value;
if (!path) {
msg.textContent = t("caldav.bindings.error.path");
msg.className = "form-msg form-msg-error";
return;
}
const postPayload: Record<string, unknown> = {
calendar_path: path,
display_name: nameInput.value.trim(),
scope_kind: scope,
enabled: true,
};
if (scope === "project") postPayload.scope_id = projectSel.value;
if (!postPayload.display_name && mode === "existing") {
const opt = sel.options[sel.selectedIndex];
postPayload.display_name = opt ? opt.text : "";
}
const resp = await fetch("/api/caldav-bindings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(postPayload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = err.error || t("caldav.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
}
}
closeBindingModal();
@@ -1033,17 +1132,13 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("caldav-test-btn")!.addEventListener("click", testCalDAVConnection);
document.getElementById("caldav-delete-btn")!.addEventListener("click", deleteCalDAVConfig);
// CalDAV bindings (Slice 2b) — add/edit modal wiring.
// CalDAV bindings (Slice 2b + 2c) — add/edit modal wiring.
document.getElementById("caldav-bindings-add-btn")?.addEventListener("click", () => openBindingModal(null));
document.getElementById("caldav-binding-modal-close")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-cancel-btn")?.addEventListener("click", closeBindingModal);
document.getElementById("caldav-binding-form")?.addEventListener("submit", submitBindingModal);
const customToggle = document.getElementById("caldav-binding-custom-toggle") as HTMLInputElement | null;
customToggle?.addEventListener("change", () => {
const path = document.getElementById("caldav-binding-custom-path") as HTMLInputElement;
const sel = document.getElementById("caldav-binding-discover-select") as HTMLSelectElement;
path.style.display = customToggle.checked ? "" : "none";
sel.style.display = customToggle.checked ? "none" : "";
document.querySelectorAll('input[name="caldav-binding-source-mode"]').forEach((el) => {
el.addEventListener("change", syncBindingSourceModeUI);
});
document.querySelectorAll('input[name="caldav-binding-scope"]').forEach((el) => {
el.addEventListener("change", syncBindingScopeUI);

View File

@@ -716,6 +716,9 @@ export type I18nKey =
| "caldav.bindings.delete.confirm"
| "caldav.bindings.delete.failed"
| "caldav.bindings.empty"
| "caldav.bindings.error.create_name_required"
| "caldav.bindings.error.create_name_taken"
| "caldav.bindings.error.create_unsupported"
| "caldav.bindings.error.path"
| "caldav.bindings.error.scope"
| "caldav.bindings.error.scope_project"
@@ -731,9 +734,12 @@ export type I18nKey =
| "caldav.bindings.modal.scope.project"
| "caldav.bindings.modal.scope.project.loading"
| "caldav.bindings.modal.source"
| "caldav.bindings.modal.source.create"
| "caldav.bindings.modal.source.custom"
| "caldav.bindings.modal.source.degrade"
| "caldav.bindings.modal.source.discover_empty"
| "caldav.bindings.modal.source.discover_failed"
| "caldav.bindings.modal.source.existing"
| "caldav.bindings.modal.source.loading"
| "caldav.bindings.modal.submit_add"
| "caldav.bindings.modal.submit_edit"

View File

@@ -425,19 +425,37 @@ export function renderSettings(): string {
<form id="caldav-binding-form" className="entity-form modal-body" autocomplete="off">
<div className="form-field" id="caldav-binding-source-field">
<label data-i18n="caldav.bindings.modal.source">Kalender</label>
<div className="caldav-binding-source-modes" id="caldav-binding-source-modes">
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-source-mode" value="existing" checked />
<span data-i18n="caldav.bindings.modal.source.existing">Vorhandenen Kalender w&auml;hlen</span>
</label>
<label className="caldav-toggle-label" id="caldav-binding-source-mode-create-row" style="display:none">
<input type="radio" name="caldav-binding-source-mode" value="create" />
<span data-i18n="caldav.bindings.modal.source.create">Neuen Kalender erstellen</span>
</label>
<label className="caldav-toggle-label">
<input type="radio" name="caldav-binding-source-mode" value="custom" />
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
</label>
</div>
<select id="caldav-binding-discover-select">
<option value="" data-i18n="caldav.bindings.modal.source.loading">L&auml;dt&hellip;</option>
</select>
<label className="caldav-toggle-label caldav-binding-custom-toggle">
<input type="checkbox" id="caldav-binding-custom-toggle" />
<span data-i18n="caldav.bindings.modal.source.custom">Eigene URL eingeben</span>
</label>
<input
type="text"
id="caldav-binding-custom-path"
placeholder="https://..."
style="display:none"
/>
{/* Slice 2c — Google-degrade notice. Shown when
supports_mkcalendar=false; the create-new radio is
hidden in that state, so users are nudged to the
custom-URL path. */}
<p className="form-hint caldav-binding-degrade-notice" id="caldav-binding-degrade-notice" style="display:none" data-i18n="caldav.bindings.modal.source.degrade">
Dieser Anbieter erlaubt das Erstellen neuer Kalender nicht via CalDAV.
Erstelle den Kalender direkt in der Anbieter-Oberfl&auml;che und f&uuml;ge ihn hier per URL hinzu.
</p>
</div>
<div className="form-field">

View File

@@ -0,0 +1,5 @@
-- Reverse of 108: drop the capability columns.
ALTER TABLE paliad.user_caldav_config
DROP COLUMN IF EXISTS supports_mkcalendar,
DROP COLUMN IF EXISTS mkcalendar_probed_at;

View File

@@ -0,0 +1,67 @@
-- t-paliad-212 — Slice 2c of CalDAV multi-calendar.
--
-- Adds the MKCALENDAR-capability tri-state to paliad.user_caldav_config:
-- * supports_mkcalendar = NULL → unprobed (probe runs lazily on
-- the first /api/caldav-discover or
-- /api/caldav-mkcalendar call).
-- * supports_mkcalendar = TRUE → server accepts MKCALENDAR; the
-- "Create new calendar" affordance
-- in the picker is visible.
-- * supports_mkcalendar = FALSE → Google-style degrade; UI hides the
-- create button and surfaces the
-- "create it in your provider's UI"
-- notice with a manual-URL input.
-- The probed_at timestamp lets us re-probe stale-cached results when
-- the user changes credentials (SaveConfig invalidates by SetNull in
-- the Go service layer; the column is here so the next round of
-- probing has somewhere to land).
--
-- Idempotent (column-exists DO block) + assertion at the bottom.
SELECT set_config(
'paliad.audit_reason',
'mig 108: add user_caldav_config.supports_mkcalendar tri-state for t-paliad-212 Slice 2c capability probe',
true);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'user_caldav_config'
AND column_name = 'supports_mkcalendar'
) THEN
ALTER TABLE paliad.user_caldav_config
ADD COLUMN supports_mkcalendar boolean;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = 'user_caldav_config'
AND column_name = 'mkcalendar_probed_at'
) THEN
ALTER TABLE paliad.user_caldav_config
ADD COLUMN mkcalendar_probed_at timestamptz;
END IF;
END $$;
-- Assertion — both columns present and nullable.
DO $$
DECLARE
sup_nullable text;
probed_nullable text;
BEGIN
SELECT is_nullable INTO sup_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
AND column_name = 'supports_mkcalendar';
SELECT is_nullable INTO probed_nullable
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'user_caldav_config'
AND column_name = 'mkcalendar_probed_at';
IF sup_nullable <> 'YES' OR probed_nullable <> 'YES' THEN
RAISE EXCEPTION
'mig 108 assertion failed: expected both columns nullable, got supports=% probed=%',
sup_nullable, probed_nullable;
END IF;
END $$;

View File

@@ -461,6 +461,58 @@ func handleDeleteCalDAVBinding(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// POST /api/caldav-mkcalendar — creates a new calendar on the user's
// CalDAV server via MKCALENDAR + a matching binding row in one logical
// transaction. Slice 2c only — visible when /api/caldav-discover
// reports supports_mkcalendar=true. Errors:
// - 501 when supports_mkcalendar=false (caller should show the
// Google-degrade UX with the manual-URL input).
// - 409 when the slugified name + 3 retries all collide on the
// server. UI should ask the user to type their own name.
func handleCalDAVMakeCalendar(w http.ResponseWriter, r *http.Request) {
if !requireCalDAV(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateCalendarInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
result, err := dbSvc.caldav.MakeCalendar(r.Context(), uid, input)
if err != nil {
switch {
case errors.Is(err, services.ErrMKCalendarUnsupported):
writeJSON(w, http.StatusNotImplemented, map[string]any{
"error": err.Error(),
"supports_mkcalendar": false,
})
case errors.Is(err, services.ErrCalendarNameTaken):
writeJSON(w, http.StatusConflict, map[string]any{
"error": err.Error(),
})
default:
// Binding-create / push errors carry the partial result so
// the UI can surface "created remotely but binding failed".
if result != nil {
writeJSON(w, http.StatusCreated, map[string]any{
"calendar_path": result.CalendarPath,
"binding": result.Binding,
"initial_pushed": result.InitialPushed,
"initial_sync_error": err.Error(),
})
return
}
writeCalDAVError(w, err)
}
return
}
writeJSON(w, http.StatusCreated, result)
}
// GET /api/caldav-discover — walks the calendar-home-set chain on the
// user's CalDAV server and returns the calendars they own. Cached
// server-side for 5 minutes per user (Q4 of Slice 2 brief).

View File

@@ -363,6 +363,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/caldav-bindings/{id}", handleDeleteCalDAVBinding)
// /api/caldav-discover — calendar-home-set walk (RFC 6764) for picker.
protected.HandleFunc("GET /api/caldav-discover", handleCalDAVDiscover)
// Slice 2c — MKCALENDAR ("Create new calendar" affordance in picker).
protected.HandleFunc("POST /api/caldav-mkcalendar", handleCalDAVMakeCalendar)
// t-paliad-088 — Event Types (categorization for Deadlines).
protected.HandleFunc("GET /api/event-types", handleListEventTypes)

View File

@@ -425,16 +425,19 @@ type ChecklistInstanceWithProject struct {
// UserCalDAVConfig holds one user's external CalDAV connection. The password
// is never returned in API responses; only the public fields are exposed.
type UserCalDAVConfig struct {
UserID uuid.UUID `db:"user_id" json:"user_id"`
URL string `db:"url" json:"url"`
Username string `db:"username" json:"username"`
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
URL string `db:"url" json:"url"`
Username string `db:"username" json:"username"`
PasswordEncrypted []byte `db:"password_encrypted" json:"-"`
CalendarPath string `db:"calendar_path" json:"calendar_path"`
Enabled bool `db:"enabled" json:"enabled"`
LastSyncAt *time.Time `db:"last_sync_at" json:"last_sync_at,omitempty"`
LastSyncError *string `db:"last_sync_error" json:"last_sync_error,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// MKCALENDAR-capability tri-state (mig 108, Slice 2c). NULL = unprobed.
SupportsMKCalendar *bool `db:"supports_mkcalendar" json:"supports_mkcalendar,omitempty"`
MKCalendarProbedAt *time.Time `db:"mkcalendar_probed_at" json:"mkcalendar_probed_at,omitempty"`
}
// CalDAVSyncLogEntry is one historical sync record. BindingID is populated

View File

@@ -2,7 +2,10 @@ package services
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
@@ -12,6 +15,15 @@ import (
"time"
)
// ErrCalendarNameTaken is returned by MakeCalendar when the server
// rejects MKCALENDAR with 405 — name already in use.
var ErrCalendarNameTaken = errors.New("calendar name already taken on server")
// ErrMKCalendarUnsupported is returned by MakeCalendar when the server
// outright rejects MKCALENDAR (403/501) — should never fire after a
// successful probe, but kept as a defence so we don't loop.
var ErrMKCalendarUnsupported = errors.New("server does not support MKCALENDAR")
// Tiny CalDAV HTTP client — only the verbs Paliad needs:
// - PUT (create / replace event)
// - GET (fetch event by path)
@@ -443,6 +455,165 @@ func (c *calDAVClient) propfindHrefs(ctx context.Context, urlPath, depth, body,
return out, nil
}
// --- MKCALENDAR capability probe + provisioning (Slice 2c) ---
// ProbeMKCalendar reports whether the CalDAV server accepts MKCALENDAR
// against the calendar-home-set. Two-step per design §4.2:
//
// 1. OPTIONS on the home URL — if the server returns `Allow:` listing
// MKCALENDAR, we're done.
// 2. Synthetic probe — issue MKCALENDAR against a random
// `.paliad-probe-<short>/` path and DELETE it. Catches legacy SOGo
// and misconfigured Radicales that don't list MKCALENDAR in Allow
// but still accept it. Servers that 405/501 the synthetic probe
// are recorded as no-MKCALENDAR; further attempts skip the probe.
//
// The probe never persists state — that's the service-layer's job via
// CalDAVService.MakeCalendar.
func (c *calDAVClient) ProbeMKCalendar(ctx context.Context, homePath string) (bool, error) {
if allows, err := c.optionsAllows(ctx, homePath); err == nil {
if slices.Contains(allows, "MKCALENDAR") {
return true, nil
}
// OPTIONS responded but doesn't list MKCALENDAR — fall through to
// synthetic probe; some servers omit MKCALENDAR from Allow even
// when they accept it. OPTIONS-returns-no-MKCALENDAR is not a
// hard negative.
}
// Synthetic probe — a single MKCALENDAR against a randomised name
// that the server is overwhelmingly unlikely to already have.
probePath := joinPath(homePath, ".paliad-probe-"+randomToken(6)+"/")
mkBody := `<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set><D:prop><D:displayname>paliad-probe</D:displayname></D:prop></D:set>
</C:mkcalendar>`
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(probePath), strings.NewReader(mkBody))
if err != nil {
return false, err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return false, fmt.Errorf("MKCALENDAR probe: %w", err)
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
// Server accepted the probe. Tear down the probe collection so
// we don't leak a junk calendar; if the DELETE fails we shrug
// (best effort — the user's calendar list will have one
// .paliad-probe-* entry; not the end of the world).
_ = c.deleteCollection(ctx, probePath)
return true, nil
case http.StatusMethodNotAllowed, http.StatusNotImplemented, http.StatusForbidden:
return false, nil
default:
// Unknown — treat as no-MKCALENDAR to be safe; the user can
// still bind by URL.
return false, nil
}
}
// MakeCalendar issues MKCALENDAR against home/<calendarName>/ and
// returns the absolute path that was created. The caller is
// responsible for picking a free slug; 405 from the server means
// "name already taken — pick another".
func (c *calDAVClient) MakeCalendar(ctx context.Context, homePath, calendarName, displayName string) (string, error) {
path := joinPath(homePath, calendarName+"/")
body := mkcalendarBody(displayName)
req, err := http.NewRequestWithContext(ctx, "MKCALENDAR", c.absURL(path), strings.NewReader(body))
if err != nil {
return "", err
}
req.SetBasicAuth(c.username, c.password)
req.Header.Set("Content-Type", "application/xml; charset=utf-8")
resp, err := c.hc.Do(req)
if err != nil {
return "", fmt.Errorf("MKCALENDAR: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusCreated, http.StatusOK:
return path, nil
case http.StatusMethodNotAllowed:
return "", ErrCalendarNameTaken
case http.StatusForbidden, http.StatusNotImplemented:
return "", ErrMKCalendarUnsupported
default:
raw, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("MKCALENDAR %s: %d %s — %s", path, resp.StatusCode, resp.Status, string(raw))
}
}
func mkcalendarBody(displayName string) string {
var b strings.Builder
b.WriteString(`<?xml version="1.0" encoding="utf-8"?>
<C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:set>
<D:prop>
<D:displayname>`)
_ = xml.EscapeText(&b, []byte(displayName))
b.WriteString(`</D:displayname>
<C:supported-calendar-component-set>
<C:comp name="VEVENT"/>
</C:supported-calendar-component-set>
</D:prop>
</D:set>
</C:mkcalendar>`)
return b.String()
}
// optionsAllows returns the methods listed in the Allow header of an
// OPTIONS response. Caseless match per RFC 7231 §7.4.1.
func (c *calDAVClient) optionsAllows(ctx context.Context, path string) ([]string, error) {
req, err := http.NewRequestWithContext(ctx, "OPTIONS", c.absURL(path), nil)
if err != nil {
return nil, err
}
req.SetBasicAuth(c.username, c.password)
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("OPTIONS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("OPTIONS %s: %d", path, resp.StatusCode)
}
out := []string{}
for _, h := range resp.Header.Values("Allow") {
for _, m := range strings.Split(h, ",") {
out = append(out, strings.ToUpper(strings.TrimSpace(m)))
}
}
return out, nil
}
// deleteCollection sends a DELETE that doesn't care about 404.
func (c *calDAVClient) deleteCollection(ctx context.Context, path string) error {
req, err := http.NewRequestWithContext(ctx, "DELETE", c.absURL(path), nil)
if err != nil {
return err
}
req.SetBasicAuth(c.username, c.password)
resp, err := c.hc.Do(req)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
// randomToken returns a short hex string of `n` bytes. Used for the
// synthetic MKCALENDAR probe path; doesn't need to be cryptographically
// strong (the worst-case is a collision with an existing calendar of
// the same name, which we catch as ErrCalendarNameTaken upstream).
func randomToken(n int) string {
buf := make([]byte, n)
_, _ = rand.Read(buf)
return hex.EncodeToString(buf)
}
// joinPath cleans up double slashes between calendar path and uid.
func joinPath(base, name string) string {
base = strings.TrimRight(base, "/")

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
@@ -143,7 +144,8 @@ func (s *CalDAVService) GetConfig(ctx context.Context, userID uuid.UUID) (*Publi
var c models.UserCalDAVConfig
err := s.db.GetContext(ctx, &c,
`SELECT user_id, url, username, password_encrypted, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at
enabled, last_sync_at, last_sync_error, created_at, updated_at,
supports_mkcalendar, mkcalendar_probed_at
FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
@@ -313,11 +315,12 @@ func (s *CalDAVService) SyncLog(ctx context.Context, userID uuid.UUID, n int) ([
// --- Discovery (Slice 2b) ---
// DiscoveredCalendars is the response shape of /api/caldav-discover.
// Slice 2c adds a SupportsMKCALENDAR field; in Slice 2b the field is
// always omitted (clients should treat its absence as "unknown").
// SupportsMKCalendar is nil-on-unprobed and TRUE/FALSE after the
// Slice 2c probe runs (lazily on the first discovery call).
type DiscoveredCalendars struct {
Calendars []DiscoveredCalendarOut `json:"calendars"`
CalendarHome string `json:"calendar_home,omitempty"`
Calendars []DiscoveredCalendarOut `json:"calendars"`
CalendarHome string `json:"calendar_home,omitempty"`
SupportsMKCalendar *bool `json:"supports_mkcalendar,omitempty"`
}
// DiscoveredCalendarOut is the JSON shape returned to the picker.
@@ -370,6 +373,18 @@ func (s *CalDAVService) DiscoverCalendars(ctx context.Context, userID uuid.UUID)
})
}
// Slice 2c — lazy MKCALENDAR capability probe. If we've never
// probed this user (supports_mkcalendar IS NULL) and we just
// learned the calendar-home-set URL, run the probe and stash
// the result. Probe failure is non-fatal — the field just stays
// NULL and the next discovery will retry.
if home != "" {
probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
if perr == nil {
out.SupportsMKCalendar = probed
}
}
s.discoveryMu.Lock()
if s.discoveryCache == nil {
s.discoveryCache = map[uuid.UUID]discoveryCacheEntry{}
@@ -384,11 +399,200 @@ func (s *CalDAVService) DiscoverCalendars(ctx context.Context, userID uuid.UUID)
// InvalidateDiscoveryCache clears the cached discovery result for the
// user. Called from SaveConfig so a credential change forces a fresh
// PROPFIND on the next picker open.
// PROPFIND on the next picker open. Also clears the persisted
// supports_mkcalendar capability since credentials may point at a
// different server.
func (s *CalDAVService) InvalidateDiscoveryCache(userID uuid.UUID) {
s.discoveryMu.Lock()
delete(s.discoveryCache, userID)
s.discoveryMu.Unlock()
_, _ = s.db.ExecContext(context.Background(),
`UPDATE paliad.user_caldav_config
SET supports_mkcalendar = NULL, mkcalendar_probed_at = NULL
WHERE user_id = $1`, userID)
}
// ensureMKCalendarProbed runs the probe iff the user_caldav_config
// row's supports_mkcalendar is NULL. Returns the cached value when set.
// Probe failure leaves the column NULL (next call retries) and returns
// (nil, err) — caller treats that as "unknown" and omits the field
// from the JSON response.
func (s *CalDAVService) ensureMKCalendarProbed(ctx context.Context, userID uuid.UUID, homePath string, cli *calDAVClient) (*bool, error) {
var current *bool
if err := s.db.GetContext(ctx, &current,
`SELECT supports_mkcalendar
FROM paliad.user_caldav_config
WHERE user_id = $1`, userID); err != nil {
return nil, fmt.Errorf("read mkcalendar capability: %w", err)
}
if current != nil {
return current, nil
}
ok, err := cli.ProbeMKCalendar(ctx, homePath)
if err != nil {
// Don't persist a negative on a transient error — leave NULL so
// the next round retries; just surface "unknown" to caller.
return nil, err
}
if _, err := s.db.ExecContext(ctx,
`UPDATE paliad.user_caldav_config
SET supports_mkcalendar = $1, mkcalendar_probed_at = NOW(),
updated_at = NOW()
WHERE user_id = $2`, ok, userID); err != nil {
slog.Warn("CalDAV: persist mkcalendar capability failed", "user_id", userID, "error", err)
}
return &ok, nil
}
// CreateCalendarInput is the payload for POST /api/caldav-mkcalendar.
type CreateCalendarInput struct {
DisplayName string `json:"display_name"`
ScopeKind string `json:"scope_kind"`
ScopeID *uuid.UUID `json:"scope_id,omitempty"`
IncludePersonal bool `json:"include_personal"`
}
// CreateCalendarResult is the response shape for POST
// /api/caldav-mkcalendar — the new remote calendar path plus the
// binding that was created in the same transaction.
type CreateCalendarResult struct {
CalendarPath string `json:"calendar_path"`
Binding *models.UserCalendarBinding `json:"binding"`
InitialPushed int `json:"initial_pushed"`
}
// MakeCalendar issues MKCALENDAR against the user's calendar-home-set,
// then creates a paliad.user_calendar_bindings row pointing at the new
// path. On MKCALENDAR success but binding-create failure we leave the
// remote calendar in place; on MKCALENDAR failure we never touch the
// binding table. Slice 2c API.
//
// Errors:
// - ErrCalDAVNoKey when cipher is missing.
// - ErrInvalidInput on missing display name / disallowed scope.
// - ErrMKCalendarUnsupported (or persisted supports_mkcalendar=false)
// → the caller maps this to 501.
// - ErrCalendarNameTaken → 409.
func (s *CalDAVService) MakeCalendar(ctx context.Context, userID uuid.UUID, in CreateCalendarInput) (*CreateCalendarResult, error) {
if s.cipher == nil {
return nil, ErrCalDAVNoKey
}
if strings.TrimSpace(in.DisplayName) == "" {
return nil, fmt.Errorf("%w: display_name is required", ErrInvalidInput)
}
cfg, err := s.loadDecryptedConfig(ctx, userID)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("%w: no CalDAV config", ErrInvalidInput)
}
cli := newCalDAVClient(cfg.URL, cfg.Username, cfg.Password)
// Discover home + probe capability if we haven't yet.
_, home, err := cli.DiscoverCalendars(ctx, cfg.URL)
if err != nil {
return nil, fmt.Errorf("discover home: %w", err)
}
probed, perr := s.ensureMKCalendarProbed(ctx, userID, home, cli)
if perr != nil {
return nil, fmt.Errorf("probe mkcalendar: %w", perr)
}
if probed == nil || !*probed {
return nil, ErrMKCalendarUnsupported
}
// Try the slugified name; retry with -N suffix on 405 (name taken).
slug := slugifyCalendarName(in.DisplayName)
if slug == "" {
slug = "paliad-" + randomToken(3)
}
path, err := s.mkcalendarWithRetry(ctx, cli, home, slug, in.DisplayName)
if err != nil {
return nil, err
}
// Create the matching binding row.
binding, err := s.bindings.Create(ctx, userID, CreateBindingInput{
CalendarPath: path,
DisplayName: in.DisplayName,
ScopeKind: in.ScopeKind,
ScopeID: in.ScopeID,
IncludePersonal: in.IncludePersonal,
Enabled: true,
})
if err != nil {
// MKCALENDAR already succeeded — the remote calendar exists.
// Don't try to clean it up: the user can re-bind via the
// custom-URL path the next time. Returning the error surfaces
// the validation failure to the caller.
return nil, err
}
// Synchronous first push so the user sees events immediately
// (matches POST /api/caldav-bindings Q5 semantics).
pushed, pushErr := s.PushBindingNow(ctx, userID, binding)
if pushErr != nil {
// Binding row exists; push failed — surface both bits.
return &CreateCalendarResult{
CalendarPath: path,
Binding: binding,
InitialPushed: pushed,
}, pushErr
}
s.EnsureLoop(userID)
return &CreateCalendarResult{
CalendarPath: path,
Binding: binding,
InitialPushed: pushed,
}, nil
}
// mkcalendarWithRetry issues MKCALENDAR with up to 3 disambiguating
// suffixes when the server returns 405 (name taken). Gives up after
// 3 tries with the original error so the UI can ask the user to type
// their own name.
func (s *CalDAVService) mkcalendarWithRetry(ctx context.Context, cli *calDAVClient, home, slug, displayName string) (string, error) {
tryName := slug
for attempt := 0; attempt < 3; attempt++ {
path, err := cli.MakeCalendar(ctx, home, tryName, displayName)
if err == nil {
return path, nil
}
if !errors.Is(err, ErrCalendarNameTaken) {
return "", err
}
tryName = slug + "-" + randomToken(2)
}
return "", ErrCalendarNameTaken
}
// slugifyCalendarName turns "Acme v Bosch" → "acme-v-bosch". Trims to
// 32 chars to keep the URL readable. Empty input → empty output (the
// caller falls back to a random token).
func slugifyCalendarName(name string) string {
const maxLen = 32
var b strings.Builder
prevDash := true
for _, r := range name {
switch {
case r >= 'A' && r <= 'Z':
b.WriteRune(r - 'A' + 'a')
prevDash = false
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
b.WriteRune(r)
prevDash = false
case prevDash:
// skip
default:
b.WriteRune('-')
prevDash = true
}
if b.Len() >= maxLen {
break
}
}
return strings.Trim(b.String(), "-")
}
// --- Binding lifecycle hooks (Slice 2b) ---
@@ -950,7 +1154,8 @@ func (s *CalDAVService) loadDecryptedConfig(ctx context.Context, userID uuid.UUI
var c models.UserCalDAVConfig
err := s.db.GetContext(ctx, &c,
`SELECT user_id, url, username, password_encrypted, calendar_path,
enabled, last_sync_at, last_sync_error, created_at, updated_at
enabled, last_sync_at, last_sync_error, created_at, updated_at,
supports_mkcalendar, mkcalendar_probed_at
FROM paliad.user_caldav_config WHERE user_id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil