mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 23:51:17 +00:00
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:
1 parent
796c06ae66
commit
6c3b2bf6bf
14 files changed
+1354
-3
No files matched your search
@@ -0,0 +1,104 @@
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user