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

+90
View File
@@ -133,6 +133,14 @@ type Repository interface {
UpsertSMSMonthlyReport(ctx context.Context, params SMSMonthlyReportRecord) error
MarkSMSReportInvoiceSent(ctx context.Context, tenantID string, yearMonth string) error
ListTenantsWithSMSUsage(ctx context.Context, yearMonth string) ([]TenantRecord, error)
// Calendar feeds
GetTenantByFeedToken(ctx context.Context, token string) (TenantRecord, error)
GetStaffByFeedToken(ctx context.Context, token string) (StaffRecord, error)
GetTenantFeedToken(ctx context.Context, tenantID string) (string, error)
ListStaffFeedTokens(ctx context.Context, tenantID string) ([]StaffFeedTokenRecord, error)
RotateTenantFeedToken(ctx context.Context, tenantID string) (string, error)
RotateStaffFeedToken(ctx context.Context, staffID string) (string, error)
}
type TenantRecord struct {
@@ -283,6 +291,12 @@ type StaffRecord struct {
CreatedAt time.Time
}
type StaffFeedTokenRecord struct {
StaffID string
DisplayName string
FeedToken string
}
type CreateStaffParams struct {
TenantID string
LocationID *string
@@ -793,6 +807,8 @@ type MemoryRepository struct {
smsLogs []SMSUsageLogRecord
smsReports []SMSMonthlyReportRecord
emailTemplates []EmailTemplateRecord
tenantFeedToken string
staffFeedTokens map[string]string
}
func NewMemoryRepository() *MemoryRepository {
@@ -915,6 +931,10 @@ func NewMemoryRepository() *MemoryRepository {
{TenantID: tenantID, DayOfWeek: 6, StartsLocal: "10:00", EndsLocal: "14:00"},
{TenantID: tenantID, DayOfWeek: 0, StartsLocal: "", EndsLocal: ""},
},
tenantFeedToken: "demo-tenant-feed-token",
staffFeedTokens: map[string]string{
staffID: "demo-staff-feed-token",
},
}
}
@@ -1215,6 +1235,76 @@ func (r *MemoryRepository) DeleteStaff(_ context.Context, staffID string) error
return pgx.ErrNoRows
}
func (r *MemoryRepository) GetTenantByFeedToken(_ context.Context, token string) (TenantRecord, error) {
if token != r.tenantFeedToken {
return TenantRecord{}, pgx.ErrNoRows
}
return r.tenant, nil
}
func (r *MemoryRepository) GetStaffByFeedToken(_ context.Context, token string) (StaffRecord, error) {
for staffID, feedToken := range r.staffFeedTokens {
if feedToken != token {
continue
}
for _, member := range r.staff {
if member.ID == staffID {
return member, nil
}
}
}
return StaffRecord{}, pgx.ErrNoRows
}
func (r *MemoryRepository) GetTenantFeedToken(_ context.Context, tenantID string) (string, error) {
if tenantID != r.tenant.ID {
return "", pgx.ErrNoRows
}
return r.tenantFeedToken, nil
}
func (r *MemoryRepository) ListStaffFeedTokens(_ context.Context, tenantID string) ([]StaffFeedTokenRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil
}
records := make([]StaffFeedTokenRecord, 0, len(r.staff))
for _, member := range r.staff {
token := r.staffFeedTokens[member.ID]
if token == "" {
token = strings.ReplaceAll(uuid.NewString(), "-", "")
if r.staffFeedTokens == nil {
r.staffFeedTokens = map[string]string{}
}
r.staffFeedTokens[member.ID] = token
}
records = append(records, StaffFeedTokenRecord{StaffID: member.ID, DisplayName: member.DisplayName, FeedToken: token})
}
return records, nil
}
func (r *MemoryRepository) RotateTenantFeedToken(_ context.Context, tenantID string) (string, error) {
if tenantID != r.tenant.ID {
return "", pgx.ErrNoRows
}
r.tenantFeedToken = strings.ReplaceAll(uuid.NewString(), "-", "")
return r.tenantFeedToken, nil
}
func (r *MemoryRepository) RotateStaffFeedToken(_ context.Context, staffID string) (string, error) {
for _, member := range r.staff {
if member.ID != staffID {
continue
}
token := strings.ReplaceAll(uuid.NewString(), "-", "")
if r.staffFeedTokens == nil {
r.staffFeedTokens = map[string]string{}
}
r.staffFeedTokens[staffID] = token
return token, nil
}
return "", pgx.ErrNoRows
}
func (r *MemoryRepository) ListAvailabilityRulesByTenant(_ context.Context, tenantID string) ([]AvailabilityRuleRecord, error) {
if tenantID != r.tenant.ID {
return nil, nil