Files
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

253 lines
6.9 KiB
Go

package calendar
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
var (
ErrFeedNotFound = errors.New("calendar feed not found")
ErrTenantMembership = errors.New("tenant membership not found")
ErrInvalidScope = errors.New("invalid feed scope")
)
const (
feedLookbackDays = 30
feedLookaheadDays = 180
)
type Service struct {
repo db.Repository
now func() time.Time
}
func NewService(repo db.Repository) *Service {
return &Service{repo: repo, now: time.Now}
}
type FeedEntry struct {
Token string `json:"token"`
FeedPath string `json:"feedPath"`
}
type StaffFeedEntry struct {
StaffID string `json:"staffId"`
DisplayName string `json:"displayName"`
Token string `json:"token"`
FeedPath string `json:"feedPath"`
}
type FeedInfo struct {
Tenant FeedEntry `json:"tenant"`
Staff []StaffFeedEntry `json:"staff"`
}
func feedPath(token string) string {
return "/v1/public/calendar/" + token + ".ics"
}
func (s *Service) tenantFor(ctx context.Context, principal domain.Principal) (db.TenantRecord, error) {
membership, err := s.repo.GetTenantMembershipBySubject(ctx, principal.Subject)
if err != nil {
return db.TenantRecord{}, ErrTenantMembership
}
return membership.Tenant, nil
}
// FeedInfo returns the current feed tokens for the tenant and every staff
// member. Tokens are secrets — this stays behind auth.
func (s *Service) FeedInfo(ctx context.Context, principal domain.Principal) (FeedInfo, error) {
tenant, err := s.tenantFor(ctx, principal)
if err != nil {
return FeedInfo{}, err
}
tenantToken, err := s.repo.GetTenantFeedToken(ctx, tenant.ID)
if err != nil {
return FeedInfo{}, err
}
staffTokens, err := s.repo.ListStaffFeedTokens(ctx, tenant.ID)
if err != nil {
return FeedInfo{}, err
}
info := FeedInfo{
Tenant: FeedEntry{Token: tenantToken, FeedPath: feedPath(tenantToken)},
Staff: []StaffFeedEntry{},
}
for _, entry := range staffTokens {
info.Staff = append(info.Staff, StaffFeedEntry{
StaffID: entry.StaffID,
DisplayName: entry.DisplayName,
Token: entry.FeedToken,
FeedPath: feedPath(entry.FeedToken),
})
}
return info, nil
}
// RotateToken issues a fresh feed token, invalidating the previous URL.
// Scope is "tenant" or "staff"; staff rotation requires staffID.
func (s *Service) RotateToken(ctx context.Context, principal domain.Principal, scope string, staffID string) (string, error) {
tenant, err := s.tenantFor(ctx, principal)
if err != nil {
return "", err
}
switch scope {
case "tenant":
return s.repo.RotateTenantFeedToken(ctx, tenant.ID)
case "staff":
staffID = strings.TrimSpace(staffID)
if staffID == "" {
return "", ErrInvalidScope
}
member, err := s.repo.GetStaffByID(ctx, staffID)
if err != nil || member.TenantID != tenant.ID {
return "", ErrInvalidScope
}
return s.repo.RotateStaffFeedToken(ctx, staffID)
default:
return "", ErrInvalidScope
}
}
// RenderFeed resolves a public feed token to an ICS body. Staff tokens take
// precedence; a miss on both lookups is a 404.
func (s *Service) RenderFeed(ctx context.Context, token string) (string, error) {
if member, err := s.repo.GetStaffByFeedToken(ctx, token); err == nil {
return s.render(ctx, member.TenantID, &member)
}
if tenant, err := s.repo.GetTenantByFeedToken(ctx, token); err == nil {
return s.render(ctx, tenant.ID, nil)
}
return "", ErrFeedNotFound
}
func (s *Service) render(ctx context.Context, tenantID string, member *db.StaffRecord) (string, error) {
tenant, err := s.repo.GetTenantByID(ctx, tenantID)
if err != nil {
return "", err
}
now := s.now()
bookings, err := s.repo.ListBookingsByTenantBetween(ctx, tenantID, now.AddDate(0, 0, -feedLookbackDays), now.AddDate(0, 0, feedLookaheadDays))
if err != nil {
return "", err
}
services, err := s.repo.ListServicesByTenant(ctx, tenantID)
if err != nil {
return "", err
}
staffList, err := s.repo.ListStaffByTenant(ctx, tenantID)
if err != nil {
return "", err
}
locations, err := s.repo.ListLocationsByTenant(ctx, tenantID)
if err != nil {
return "", err
}
sessions, err := s.repo.ListClassSessionsByTenant(ctx, tenantID, now.AddDate(0, 0, -feedLookbackDays), 500)
if err != nil {
return "", err
}
serviceNames := map[string]string{}
for _, service := range services {
serviceNames[service.ID] = service.Name
}
staffNames := map[string]string{}
for _, staff := range staffList {
staffNames[staff.ID] = staff.DisplayName
}
locationNames := map[string]string{}
for _, location := range locations {
locationNames[location.ID] = location.Name
}
sessionTitles := map[string]string{}
for _, session := range sessions {
sessionTitles[session.ID] = session.Title
}
czech := strings.HasPrefix(tenant.Locale, "cs")
labels := map[bool][3]string{
true: {"Telefon", "E-mail", "Poznámky"},
false: {"Phone", "Email", "Notes"},
}[czech]
waitlistLabel := map[bool]string{true: "čekací listina", false: "waitlist"}[czech]
var events []Event
for _, booking := range bookings {
if member != nil {
if booking.StaffID == nil || *booking.StaffID != member.ID {
continue
}
}
title := ""
if booking.ServiceID != nil {
title = serviceNames[*booking.ServiceID]
}
if title == "" && booking.ClassSessionID != nil {
title = sessionTitles[*booking.ClassSessionID]
}
if title == "" {
title = "Bookra"
}
summary := title + " · " + booking.CustomerName
if member == nil && booking.StaffID != nil {
if name := staffNames[*booking.StaffID]; name != "" {
summary += " · " + name
}
}
var description []string
if booking.CustomerPhone != "" {
description = append(description, labels[0]+": "+booking.CustomerPhone)
}
if booking.CustomerEmail != "" {
description = append(description, labels[1]+": "+booking.CustomerEmail)
}
if booking.Notes != "" {
description = append(description, labels[2]+": "+booking.Notes)
}
location := ""
if booking.LocationID != nil {
location = locationNames[*booking.LocationID]
}
event := Event{
UID: booking.ID + "@bookra",
StartsAt: booking.StartsAt,
EndsAt: booking.EndsAt,
Stamp: booking.CreatedAt,
Summary: summary,
Description: strings.Join(description, "\n"),
Location: location,
Status: "confirmed",
}
switch booking.Status {
case "cancelled":
event.Status = "cancelled"
case "pending", "waitlisted":
event.Status = "tentative"
event.Transparent = true
}
if booking.Status == "waitlisted" {
event.Summary += " (" + waitlistLabel + ")"
}
events = append(events, event)
}
sort.Slice(events, func(i, j int) bool { return events[i].StartsAt.Before(events[j].StartsAt) })
name := fmt.Sprintf("Bookra — %s", tenant.Name)
if member != nil {
name = fmt.Sprintf("Bookra — %s · %s", tenant.Name, member.DisplayName)
}
return RenderCalendar(name, tenant.Timezone, events), nil
}