Files
Bookra/apps/backend/internal/calendar/ics.go
T
Tomas DvorakandDevin 6c3b2bf6bf 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>
2026-09-18 15:20:22 +02:00

105 lines
2.8 KiB
Go

package calendar
import (
"strings"
"time"
)
// Event is a single entry rendered into an ICS feed. Fields are already
// resolved to display text; the renderer handles escaping and folding.
type Event struct {
UID string
StartsAt time.Time
EndsAt time.Time
Stamp time.Time
Summary string
Description string
Location string
Status string // "confirmed" | "tentative" | "cancelled"
Transparent bool // TRANSP:TRANSPARENT — does not block free/busy
}
const icsTimestampLayout = "20060102T150405Z"
// RenderCalendar produces an RFC 5545 VCALENDAR body with CRLF line endings
// and 75-octet folding.
func RenderCalendar(name string, timezone string, events []Event) string {
var b strings.Builder
writeLine(&b, "BEGIN:VCALENDAR")
writeLine(&b, "VERSION:2.0")
writeLine(&b, "PRODID:-//Bookra//Bookra//EN")
writeLine(&b, "CALSCALE:GREGORIAN")
writeLine(&b, "METHOD:PUBLISH")
writeLine(&b, "X-WR-CALNAME:"+escapeText(name))
if timezone != "" {
writeLine(&b, "X-WR-TIMEZONE:"+timezone)
}
for _, event := range events {
writeLine(&b, "BEGIN:VEVENT")
writeLine(&b, "UID:"+event.UID)
stamp := event.Stamp
if stamp.IsZero() {
stamp = event.StartsAt
}
writeLine(&b, "DTSTAMP:"+stamp.UTC().Format(icsTimestampLayout))
writeLine(&b, "DTSTART:"+event.StartsAt.UTC().Format(icsTimestampLayout))
writeLine(&b, "DTEND:"+event.EndsAt.UTC().Format(icsTimestampLayout))
writeLine(&b, "SUMMARY:"+escapeText(event.Summary))
if event.Description != "" {
writeLine(&b, "DESCRIPTION:"+escapeText(event.Description))
}
if event.Location != "" {
writeLine(&b, "LOCATION:"+escapeText(event.Location))
}
switch event.Status {
case "cancelled":
writeLine(&b, "STATUS:CANCELLED")
case "tentative":
writeLine(&b, "STATUS:TENTATIVE")
}
if event.Transparent {
writeLine(&b, "TRANSP:TRANSPARENT")
}
writeLine(&b, "END:VEVENT")
}
writeLine(&b, "END:VCALENDAR")
return b.String()
}
// escapeText applies RFC 5545 §3.3.11 TEXT escaping.
func escapeText(value string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
";", `\;`,
",", `\,`,
"\n", `\n`,
"\r", "",
)
return replacer.Replace(value)
}
// writeLine emits one content line folded at 75 octets per RFC 5545 §3.1.
// Continuation lines start with a single space; folds happen on rune
// boundaries so multi-byte characters are never split.
func writeLine(b *strings.Builder, line string) {
const maxOctets = 75
octets := 0
first := true
for _, r := range line {
width := len(string(r))
limit := maxOctets
if !first {
limit-- // continuation lines carry a leading space
}
if octets+width > limit {
b.WriteString("\r\n ")
octets = 0
first = false
limit = maxOctets - 1
}
b.WriteRune(r)
octets += width
}
b.WriteString("\r\n")
}