feat(calendar): read-only ICS feeds for tenant and staff

GET /v1/public/calendar/{token} serves an RFC 5545 calendar under a
token-as-secret URL (private Google/Outlook link model). Staff feeds
contain only that member's bookings; the tenant feed tags each event
with the staff member. Window is -30d to +180d; cancelled bookings map
to STATUS:CANCELLED, waitlisted/pending to TENTATIVE + TRANSPARENT.
Summaries resolve service or class-session titles, descriptions carry
localized contact labels, locations resolve to names.

Feed tokens live on tenants/staff (migration 00015) and are managed via
GET /v1/calendar/feed plus POST /v1/calendar/feed/rotate — rotating a
scope invalidates the old URL. ICS rendering is hand-rolled: TEXT
escaping, CRLF, 75-octet folding on rune boundaries.

Settings gains a "Kalendář (ICS)" card with copyable URLs and two-step
rotation per scope; demo mode points at the real seeded tokens.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 15:20:22 +02:00
1 parent 796c06ae66
commit 6c3b2bf6bf
14 files changed
+1354 -3

No files matched your search

@@ -0,0 +1,197 @@
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
import { apiClient, apiBaseUrl } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import { CalendarDaysIcon } from "./dashboard/icons";
interface FeedEntry {
token: string;
feedPath: string;
}
interface StaffFeedEntry {
staffId: string;
displayName: string;
token: string;
feedPath: string;
}
interface FeedInfo {
tenant: FeedEntry;
staff: StaffFeedEntry[];
}
const CopyIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
const RotateIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
interface CalendarFeedProps {
token: Accessor<string | null | undefined>;
onDemoNotice?: (message: string) => void;
}
export function CalendarFeed(props: CalendarFeedProps) {
const i18n = useI18n();
const token = props.token;
const cs = () => i18n.locale() === "cs";
const [copiedKey, setCopiedKey] = createSignal<string | null>(null);
const [armedRotate, setArmedRotate] = createSignal<string | null>(null);
const [rotating, setRotating] = createSignal(false);
const [notice, setNotice] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [feedInfo, { refetch }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) {
return {
tenant: { token: "demo-tenant-feed-token", feedPath: "/v1/public/calendar/demo-tenant-feed-token.ics" },
staff: [{ staffId: "demo-staff", displayName: "Demo Stylist", token: "demo-staff-feed-token", feedPath: "/v1/public/calendar/demo-staff-feed-token.ics" }],
} as FeedInfo;
}
const response = await (apiClient as any).GET("/v1/calendar/feed", {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.data as FeedInfo;
});
const feedUrl = (entry: FeedEntry) => `${apiBaseUrl}${entry.feedPath}`;
const handleCopy = async (key: string, entry: FeedEntry) => {
try {
await navigator.clipboard.writeText(feedUrl(entry));
setCopiedKey(key);
setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 2000);
} catch {
setError(cs() ? "Kopírování se nezdařilo." : "Copy failed.");
}
};
const handleRotate = async (key: string, scope: "tenant" | "staff", staffId?: string) => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) {
props.onDemoNotice?.(cs() ? "V demo režimu nelze měnit." : "Cannot change in demo mode.");
return;
}
if (armedRotate() !== key) {
setArmedRotate(key);
return;
}
setArmedRotate(null);
setRotating(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/calendar/feed/rotate", {
headers: { Authorization: `Bearer ${bearer}` },
body: { scope, staffId },
});
await refetch();
setNotice(cs() ? "Adresa kalendáře obnovena — starý odkaz přestal fungovat." : "Calendar address rotated — the old link stopped working.");
} catch {
setError(cs() ? "Obnovení adresy se nezdařilo." : "Failed to rotate the feed link.");
} finally {
setRotating(false);
}
};
const FeedRow = (rowProps: { label: string; hint: string; entry: FeedEntry; scope: "tenant" | "staff"; staffId?: string }) => {
const key = () => rowProps.scope + ":" + (rowProps.staffId ?? "tenant");
return (
<div class="p-4 bg-canvas-subtle rounded-xl">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<p class="text-sm font-semibold text-ink">{rowProps.label}</p>
<p class="text-xs text-ink-muted mt-0.5">{rowProps.hint}</p>
</div>
<button
onClick={() => void handleRotate(key(), rowProps.scope, rowProps.staffId)}
disabled={rotating()}
class={`shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${armedRotate() === key() ? "bg-error text-canvas" : "bg-canvas text-ink-muted hover:text-ink border border-border"}`}
>
<RotateIcon />
{armedRotate() === key()
? (cs() ? "Potvrdit?" : "Confirm?")
: (cs() ? "Nová adresa" : "New address")}
</button>
</div>
<div class="flex items-center gap-2">
<input
type="text"
readOnly
value={feedUrl(rowProps.entry)}
onFocus={(e) => e.currentTarget.select()}
class="flex-1 min-w-0 px-3 py-2 bg-canvas border border-border rounded-lg text-xs font-mono text-ink-muted"
/>
<button
onClick={() => void handleCopy(key(), rowProps.entry)}
class={`shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${copiedKey() === key() ? "bg-success text-canvas" : "bg-accent text-canvas hover:bg-accent-hover"}`}
>
<CopyIcon />
{copiedKey() === key() ? (cs() ? "Zkopírováno" : "Copied") : (cs() ? "Kopírovat" : "Copy")}
</button>
</div>
</div>
);
};
return (
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<h3 class="text-lg font-semibold text-ink">{cs() ? "Kalendář (ICS)" : "Calendar feed (ICS)"}</h3>
<p class="text-sm text-ink-muted">
{cs()
? "Zobrazte rezervace ve svém kalendáři — Google, Apple nebo Outlook."
: "See bookings in your own calendar — Google, Apple or Outlook."}
</p>
</div>
</div>
<p class="text-xs text-ink-muted mb-5">
{cs()
? "Zkopírujte adresu a přidejte ji v kalendáři jako „Odebírat z URL“. Odkaz je tajný — kdokoliv s ním vidí rezervace. Pokud odkaz unikne, vygenerujte nový."
: "Copy the address and add it in your calendar as “Subscribe from URL”. The link is secret — anyone holding it can see bookings. If it leaks, generate a new one."}
</p>
<Show when={feedInfo()}>
{(info) => (
<div class="space-y-3">
<FeedRow
label={cs() ? "Celý podnik" : "Whole business"}
hint={cs() ? "Všechny rezervace všech pracovníků." : "Every booking across all staff."}
entry={info().tenant}
scope="tenant"
/>
<For each={info().staff}>
{(staff) => (
<FeedRow
label={staff.displayName}
hint={cs() ? "Pouze rezervace tohoto pracovníka." : "Only this staff member's bookings."}
entry={staff}
scope="staff"
staffId={staff.staffId}
/>
)}
</For>
</div>
)}
</Show>
<Show when={notice()}>
<p class="mt-4 text-sm text-success">{notice()}</p>
</Show>
<Show when={error()}>
<p class="mt-4 text-sm text-error">{error()}</p>
</Show>
</div>
);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import createClient from "openapi-fetch";
import type { paths } from "@bookra/api-client/generated/types";
const baseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
export const apiBaseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
export const apiClient = createClient<paths>({
baseUrl,
baseUrl: apiBaseUrl,
});
@@ -4,6 +4,7 @@ import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { PaletteIcon, MailIcon, CheckCircleIcon, BellIcon, XCircleIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { SMSSettings } from "../../components/sms-settings";
import { CalendarFeed } from "../../components/calendar-feed";
import { WidgetBuilder } from "../../components/widget-builder";
import { DashboardLayout, useDashboardData } from "./layout";
@@ -142,6 +143,7 @@ function SettingsPage() {
</div>
<SMSSettings token={data.token} />
<CalendarFeed token={data.token} onDemoNotice={data.setDemoNotice} />
<WidgetBuilder config={{
tenantSlug: data.resolvedSummary()?.tenantSlug || "demo-studio",
publicBookingUrl: data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio",