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

+56
View File
@@ -8,12 +8,14 @@ import (
"log"
"net/http"
"strconv"
"strings"
"time"
"bookra/apps/backend/internal/admin"
"bookra/apps/backend/internal/auth"
"bookra/apps/backend/internal/billing"
"bookra/apps/backend/internal/bookings"
"bookra/apps/backend/internal/calendar"
"bookra/apps/backend/internal/catalog"
"bookra/apps/backend/internal/config"
"bookra/apps/backend/internal/db"
@@ -54,6 +56,7 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
tenantService := tenancy.NewService(repository)
billingService := billing.NewService(cfg, repository)
catalogService := catalog.NewService(repository, billingService, notificationService)
calendarService := calendar.NewService(repository)
authService := auth.NewService(repository, cfg.AuthJWTSecret)
adminService := admin.NewService(repository, cfg.AdminEmail, cfg.AdminKey)
smsService := sms.NewService(cfg, repository)
@@ -1173,6 +1176,59 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
})
// Read-only ICS feed. The path token is the only credential — same trust
// model as private calendar URLs from Google/Outlook.
server.router.GET("/v1/public/calendar/:token", publicRateLimiter.Middleware(), func(c *gin.Context) {
token := strings.TrimSuffix(c.Param("token"), ".ics")
body, err := calendarService.RenderFeed(c.Request.Context(), token)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, calendar.ErrFeedNotFound) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "text/calendar; charset=utf-8", []byte(body))
})
protected.GET("/calendar/feed", func(c *gin.Context) {
response, err := calendarService.FeedInfo(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, calendar.ErrTenantMembership) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
protected.POST("/calendar/feed/rotate", func(c *gin.Context) {
var request struct {
Scope string `json:"scope" binding:"required"`
StaffID string `json:"staffId"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
token, err := calendarService.RotateToken(c.Request.Context(), auth.PrincipalFromContext(c), request.Scope, request.StaffID)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, calendar.ErrTenantMembership):
status = http.StatusNotFound
case errors.Is(err, calendar.ErrInvalidScope):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"token": token, "feedPath": "/v1/public/calendar/" + token + ".ics"})
})
protected.GET("/billing/subscription", func(c *gin.Context) {
response, err := billingService.GetSubscription(c.Request.Context(), auth.PrincipalFromContext(c))
if err != nil {