Files
Bookra/apps/backend/internal/db/calendar_feed.go
T
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

85 lines
2.4 KiB
Go

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
}