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

+176
View File
@@ -239,6 +239,43 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/calendar/feed": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Feed tokens for the tenant and every staff member
* @description Returns the read-only ICS feed token for the whole tenant plus one per staff member. Tokens are secrets — anyone holding a feed URL can read that calendar, same model as private Google/Outlook calendar links.
*/
get: operations["getCalendarFeedInfo"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/calendar/feed/rotate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invalidate a feed URL and issue a fresh token */
post: operations["rotateCalendarFeedToken"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/blocked-days": {
parameters: {
query?: never;
@@ -719,6 +756,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/public/calendar/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Read-only ICS calendar feed
* @description Token-as-secret calendar subscription endpoint. `{token}` may carry a `.ics` suffix, which is ignored. The feed covers bookings from 30 days back to 180 days ahead; cancelled bookings appear with STATUS:CANCELLED and waitlisted entries as TENTATIVE/TRANSPARENT.
*/
get: operations["getCalendarFeed"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/public/contact": {
parameters: {
query?: never;
@@ -1003,6 +1060,16 @@ export interface components {
primaryColor?: string;
siteUrl?: string;
};
CalendarFeedEntry: {
/** @description API-relative path ending in .ics; prefix with the API base URL */
feedPath?: string;
/** @description Feed secret — embed in the subscription URL */
token?: string;
};
CalendarFeedInfo: {
staff?: components["schemas"]["StaffCalendarFeedEntry"][];
tenant?: components["schemas"]["CalendarFeedEntry"];
};
/** @description Stripe checkout launch payload — redirect the user to checkoutUrl. */
CheckoutLaunchResponse: {
/**
@@ -1332,6 +1399,13 @@ export interface components {
status: string;
waitlistPosition?: number;
};
StaffCalendarFeedEntry: {
displayName?: string;
feedPath?: string;
/** Format: uuid */
staffId?: string;
token?: string;
};
StaffMember: {
active: boolean;
/** Format: date-time */
@@ -2000,6 +2074,79 @@ export interface operations {
};
};
};
getCalendarFeedInfo: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Current feed tokens */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarFeedInfo"];
};
};
/** @description Tenant membership not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
rotateCalendarFeedToken: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
scope: "tenant" | "staff";
/**
* Format: uuid
* @description Required when scope is staff; must belong to the tenant
*/
staffId?: string;
};
};
};
responses: {
/** @description Newly issued token and feed path */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarFeedEntry"];
};
};
/** @description Invalid scope or foreign staff member */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Tenant membership not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listBlockedDays: {
parameters: {
query?: never;
@@ -3319,6 +3466,35 @@ export interface operations {
};
};
};
getCalendarFeed: {
parameters: {
query?: never;
header?: never;
path: {
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description iCalendar document */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/calendar": string;
};
};
/** @description Unknown or rotated feed token */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
submitContactForm: {
parameters: {
query?: never;