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

+84
View File
@@ -0,0 +1,84 @@
package db
import (
"context"
)
const tenantColumns = "id, slug, name, preset, locale, timezone, plan_code, subscription_status, billing_provider, billing_customer_id, billing_subscription_id"
func (r *PGRepository) GetTenantByFeedToken(ctx context.Context, token string) (TenantRecord, error) {
var record TenantRecord
err := r.pool.QueryRow(ctx, `
SELECT `+tenantColumns+`
FROM tenants
WHERE feed_token = $1
`, token).Scan(
&record.ID, &record.Slug, &record.Name, &record.Preset, &record.Locale, &record.Timezone,
&record.PlanCode, &record.SubscriptionStatus, &record.BillingProvider,
&record.BillingCustomerID, &record.BillingSubscription,
)
return record, err
}
func (r *PGRepository) GetStaffByFeedToken(ctx context.Context, token string) (StaffRecord, error) {
var rec StaffRecord
err := r.pool.QueryRow(ctx, `
SELECT `+staffColumns+`
FROM staff
WHERE feed_token = $1
`, token).Scan(&rec.ID, &rec.TenantID, &rec.LocationID, &rec.DisplayName, &rec.Active, &rec.CreatedAt)
return rec, err
}
func (r *PGRepository) GetTenantFeedToken(ctx context.Context, tenantID string) (string, error) {
var token string
err := r.pool.QueryRow(ctx, `
SELECT feed_token FROM tenants WHERE id = $1
`, tenantID).Scan(&token)
return token, err
}
func (r *PGRepository) ListStaffFeedTokens(ctx context.Context, tenantID string) ([]StaffFeedTokenRecord, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, display_name, feed_token
FROM staff
WHERE tenant_id = $1
ORDER BY created_at ASC
`, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
var records []StaffFeedTokenRecord
for rows.Next() {
var rec StaffFeedTokenRecord
if err := rows.Scan(&rec.StaffID, &rec.DisplayName, &rec.FeedToken); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
func (r *PGRepository) RotateTenantFeedToken(ctx context.Context, tenantID string) (string, error) {
var token string
err := r.pool.QueryRow(ctx, `
UPDATE tenants
SET feed_token = encode(gen_random_bytes(16), 'hex'), updated_at = now()
WHERE id = $1
RETURNING feed_token
`, tenantID).Scan(&token)
return token, err
}
func (r *PGRepository) RotateStaffFeedToken(ctx context.Context, staffID string) (string, error) {
var token string
err := r.pool.QueryRow(ctx, `
UPDATE staff
SET feed_token = encode(gen_random_bytes(16), 'hex')
WHERE id = $1
RETURNING feed_token
`, staffID).Scan(&token)
return token, err
}
+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