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

+5
View File
@@ -46,3 +46,8 @@
- Waitlisted roster entries have a **Confirm seat** action; when the session is full the backend returns 409 and the button re-arms into an explicit "confirm anyway" over-capacity step rather than silently overriding.
- The booking detail modal now shows the waitlist position for waitlisted bookings and offers the same promote action (accent button above cancel); the bookings list badges waitlisted rows with their `#N` position.
- Demo mode seeds a class session with a populated roster; mutation attempts surface the demo notice instead of failing. Verified in-browser in both Czech and English with a clean console.
## Fixed This Session (customer rebooking + ICS calendar feeds)
- Manage-booking reschedule dialog replaces free-form date/time inputs with a real slot picker: pick a date → live availability filtered by the booking's service/staff → time buttons carrying the slot's own duration. Reschedule action is status-gated (confirmed/pending appointments only); class bookings get an explanatory note instead of a broken button.
- Settings gains a "Kalendář (ICS)" card: copyable feed URLs for the whole business and each staff member, two-step rotate-to-invalidate control, demo-mode rows backed by real seeded tokens.
+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 {
+104
View File
@@ -0,0 +1,104 @@
package calendar
import (
"strings"
"time"
)
// Event is a single entry rendered into an ICS feed. Fields are already
// resolved to display text; the renderer handles escaping and folding.
type Event struct {
UID string
StartsAt time.Time
EndsAt time.Time
Stamp time.Time
Summary string
Description string
Location string
Status string // "confirmed" | "tentative" | "cancelled"
Transparent bool // TRANSP:TRANSPARENT — does not block free/busy
}
const icsTimestampLayout = "20060102T150405Z"
// RenderCalendar produces an RFC 5545 VCALENDAR body with CRLF line endings
// and 75-octet folding.
func RenderCalendar(name string, timezone string, events []Event) string {
var b strings.Builder
writeLine(&b, "BEGIN:VCALENDAR")
writeLine(&b, "VERSION:2.0")
writeLine(&b, "PRODID:-//Bookra//Bookra//EN")
writeLine(&b, "CALSCALE:GREGORIAN")
writeLine(&b, "METHOD:PUBLISH")
writeLine(&b, "X-WR-CALNAME:"+escapeText(name))
if timezone != "" {
writeLine(&b, "X-WR-TIMEZONE:"+timezone)
}
for _, event := range events {
writeLine(&b, "BEGIN:VEVENT")
writeLine(&b, "UID:"+event.UID)
stamp := event.Stamp
if stamp.IsZero() {
stamp = event.StartsAt
}
writeLine(&b, "DTSTAMP:"+stamp.UTC().Format(icsTimestampLayout))
writeLine(&b, "DTSTART:"+event.StartsAt.UTC().Format(icsTimestampLayout))
writeLine(&b, "DTEND:"+event.EndsAt.UTC().Format(icsTimestampLayout))
writeLine(&b, "SUMMARY:"+escapeText(event.Summary))
if event.Description != "" {
writeLine(&b, "DESCRIPTION:"+escapeText(event.Description))
}
if event.Location != "" {
writeLine(&b, "LOCATION:"+escapeText(event.Location))
}
switch event.Status {
case "cancelled":
writeLine(&b, "STATUS:CANCELLED")
case "tentative":
writeLine(&b, "STATUS:TENTATIVE")
}
if event.Transparent {
writeLine(&b, "TRANSP:TRANSPARENT")
}
writeLine(&b, "END:VEVENT")
}
writeLine(&b, "END:VCALENDAR")
return b.String()
}
// escapeText applies RFC 5545 §3.3.11 TEXT escaping.
func escapeText(value string) string {
replacer := strings.NewReplacer(
`\`, `\\`,
";", `\;`,
",", `\,`,
"\n", `\n`,
"\r", "",
)
return replacer.Replace(value)
}
// writeLine emits one content line folded at 75 octets per RFC 5545 §3.1.
// Continuation lines start with a single space; folds happen on rune
// boundaries so multi-byte characters are never split.
func writeLine(b *strings.Builder, line string) {
const maxOctets = 75
octets := 0
first := true
for _, r := range line {
width := len(string(r))
limit := maxOctets
if !first {
limit-- // continuation lines carry a leading space
}
if octets+width > limit {
b.WriteString("\r\n ")
octets = 0
first = false
limit = maxOctets - 1
}
b.WriteRune(r)
octets += width
}
b.WriteString("\r\n")
}
+252
View File
@@ -0,0 +1,252 @@
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
}
@@ -0,0 +1,249 @@
package calendar
import (
"context"
"strings"
"testing"
"time"
"bookra/apps/backend/internal/db"
"bookra/apps/backend/internal/domain"
)
const (
demoTenantID = "5d6b3551-0a3e-4b86-bdf0-e9df20a47148"
demoStaffID = "6936c444-c7d0-4a7d-b596-a9b72d2f4fc0"
demoServiceID = "d5d76a61-3d49-467c-8dd4-bf61ee754e39"
demoLocationID = "659f1cc0-a850-46d6-b3b8-cb15d55d8daf"
demoSessionID = "4bf74c12-44dd-45ca-86bb-b104f16f2435"
)
var owner = domain.Principal{Subject: "demo-owner", Email: "[email protected]", Role: "owner"}
func seedAppointment(t *testing.T, repo *db.MemoryRepository, staffID *string, status string) db.CreatedBooking {
t.Helper()
startsAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Minute)
created, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
TenantID: demoTenantID,
ServiceID: &[]string{demoServiceID}[0],
StaffID: staffID,
LocationID: &[]string{demoLocationID}[0],
BookingMode: "appointment",
CustomerName: "Feed Customer",
CustomerEmail: "[email protected]",
CustomerPhone: "+420111222333",
StartsAt: startsAt,
EndsAt: startsAt.Add(time.Hour),
Status: status,
Reference: "BK-FEED-" + status,
ManageToken: "feed-token-" + status,
})
if err != nil {
t.Fatalf("seed booking: %v", err)
}
return created
}
func TestRenderCalendarEscapesAndFolds(t *testing.T) {
body := RenderCalendar("Studio, Atelier; test", "Europe/Prague", []Event{{
UID: "abc@bookra",
StartsAt: time.Date(2026, 9, 20, 7, 0, 0, 0, time.UTC),
EndsAt: time.Date(2026, 9, 20, 8, 0, 0, 0, time.UTC),
Summary: "Massage; comma, back\\slash\nnewline",
Description: "Line one\nLine two",
Status: "cancelled",
}})
for _, want := range []string{
"BEGIN:VCALENDAR", "END:VCALENDAR", "BEGIN:VEVENT", "END:VEVENT",
"DTSTART:20260920T070000Z", "DTEND:20260920T080000Z",
"SUMMARY:Massage\\; comma\\, back\\\\slash\\nnewline",
"DESCRIPTION:Line one\\nLine two",
"STATUS:CANCELLED",
"X-WR-CALNAME:Studio\\, Atelier\\; test",
"X-WR-TIMEZONE:Europe/Prague",
} {
if !strings.Contains(body, want) {
t.Fatalf("expected %q in feed:\n%s", want, body)
}
}
if strings.Contains(strings.ReplaceAll(body, "\r\n", ""), "\n") || !strings.Contains(body, "\r\n") {
t.Fatalf("expected CRLF line endings only")
}
for _, line := range strings.Split(strings.TrimRight(body, "\r\n"), "\r\n") {
if len(line) > 75 {
t.Fatalf("line exceeds 75 octets (%d): %q", len(line), line)
}
}
}
func TestRenderCalendarFoldsLongLines(t *testing.T) {
long := strings.Repeat("x", 200)
body := RenderCalendar("test", "", []Event{{
UID: "a@b", StartsAt: time.Now(), EndsAt: time.Now(), Summary: long, Status: "confirmed",
}})
for _, line := range strings.Split(strings.TrimRight(body, "\r\n"), "\r\n") {
if len(line) > 75 {
t.Fatalf("line exceeds 75 octets (%d): %q", len(line), line)
}
}
if !strings.Contains(body, "\r\n ") {
t.Fatalf("expected folded continuation lines")
}
}
func TestFeedInfoReturnsTokens(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
info, err := svc.FeedInfo(context.Background(), owner)
if err != nil {
t.Fatalf("feed info: %v", err)
}
if info.Tenant.Token == "" || !strings.HasPrefix(info.Tenant.FeedPath, "/v1/public/calendar/") {
t.Fatalf("bad tenant entry: %+v", info.Tenant)
}
if len(info.Staff) != 1 || info.Staff[0].StaffID != demoStaffID || info.Staff[0].Token == "" {
t.Fatalf("bad staff entries: %+v", info.Staff)
}
}
func TestRenderFeedTenantIncludesAllBookings(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
ctx := context.Background()
seedAppointment(t, repo, &[]string{demoStaffID}[0], "confirmed")
seedAppointment(t, repo, nil, "cancelled")
ics, err := svc.RenderFeed(ctx, "demo-tenant-feed-token")
if err != nil {
t.Fatalf("render feed: %v", err)
}
if count := strings.Count(ics, "BEGIN:VEVENT"); count != 2 {
t.Fatalf("expected 2 events, got %d", count)
}
if !strings.Contains(ics, "Signature treatment · Feed Customer · Demo Stylist") {
t.Fatalf("expected service+customer+staff summary:\n%s", ics)
}
if !strings.Contains(ics, "STATUS:CANCELLED") {
t.Fatalf("expected cancelled event status:\n%s", ics)
}
if !strings.Contains(ics, "Telefon: +420111222333") {
t.Fatalf("expected czech description labels:\n%s", ics)
}
if !strings.Contains(ics, "LOCATION:Main Studio") {
t.Fatalf("expected location:\n%s", ics)
}
}
func TestRenderFeedStaffFilters(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
ctx := context.Background()
seedAppointment(t, repo, &[]string{demoStaffID}[0], "confirmed")
seedAppointment(t, repo, nil, "confirmed") // unassigned — not in staff feed
seedAppointment(t, repo, &[]string{"other-staff"}[0], "confirmed")
ics, err := svc.RenderFeed(ctx, "demo-staff-feed-token")
if err != nil {
t.Fatalf("render feed: %v", err)
}
if count := strings.Count(ics, "BEGIN:VEVENT"); count != 1 {
t.Fatalf("expected 1 event in staff feed, got %d", count)
}
if !strings.Contains(ics, "Studio Atelier · Demo Stylist") {
t.Fatalf("expected staff feed name:\n%s", ics)
}
}
func TestRenderFeedUnknownToken(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
if _, err := svc.RenderFeed(context.Background(), "nonsense"); err != ErrFeedNotFound {
t.Fatalf("expected ErrFeedNotFound, got %v", err)
}
}
func TestRotateTokenInvalidatesOldFeed(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
ctx := context.Background()
newToken, err := svc.RotateToken(ctx, owner, "tenant", "")
if err != nil {
t.Fatalf("rotate tenant token: %v", err)
}
if newToken == "" || newToken == "demo-tenant-feed-token" {
t.Fatalf("expected fresh token, got %q", newToken)
}
if _, err := svc.RenderFeed(ctx, "demo-tenant-feed-token"); err != ErrFeedNotFound {
t.Fatalf("old token should 404, got %v", err)
}
if _, err := svc.RenderFeed(ctx, newToken); err != nil {
t.Fatalf("new token should render: %v", err)
}
}
func TestRotateStaffTokenAndScopeValidation(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
ctx := context.Background()
newToken, err := svc.RotateToken(ctx, owner, "staff", demoStaffID)
if err != nil {
t.Fatalf("rotate staff token: %v", err)
}
if _, err := svc.RenderFeed(ctx, newToken); err != nil {
t.Fatalf("new staff token should render: %v", err)
}
if _, err := svc.RotateToken(ctx, owner, "staff", "not-our-staff"); err != ErrInvalidScope {
t.Fatalf("expected ErrInvalidScope for foreign staff, got %v", err)
}
if _, err := svc.RotateToken(ctx, owner, "bogus", ""); err != ErrInvalidScope {
t.Fatalf("expected ErrInvalidScope for bad scope, got %v", err)
}
if _, err := svc.RotateToken(ctx, domain.Principal{Subject: "stranger", Role: "owner"}, "tenant", ""); err != ErrTenantMembership {
t.Fatalf("expected ErrTenantMembership, got %v", err)
}
}
func TestRenderFeedWaitlistedIsTransparent(t *testing.T) {
repo := db.NewMemoryRepository()
svc := NewService(repo)
ctx := context.Background()
session, err := repo.GetClassSessionByID(ctx, demoSessionID)
if err != nil {
t.Fatalf("get session: %v", err)
}
_, err = repo.CreateBooking(ctx, db.CreateBookingParams{
TenantID: demoTenantID,
ClassSessionID: &[]string{demoSessionID}[0],
BookingMode: "class",
CustomerName: "Queued Person",
CustomerEmail: "[email protected]",
StartsAt: session.StartsAt,
EndsAt: session.EndsAt,
Status: "waitlisted",
Reference: "BK-WAIT",
ManageToken: "wait-token",
})
if err != nil {
t.Fatalf("seed waitlisted: %v", err)
}
ics, err := svc.RenderFeed(ctx, "demo-tenant-feed-token")
if err != nil {
t.Fatalf("render feed: %v", err)
}
if !strings.Contains(ics, "STATUS:TENTATIVE") || !strings.Contains(ics, "TRANSP:TRANSPARENT") {
t.Fatalf("expected tentative+transparent waitlist event:\n%s", ics)
}
if !strings.Contains(ics, "(čekací listina)") {
t.Fatalf("expected czech waitlist marker:\n%s", ics)
}
}
+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
@@ -0,0 +1,18 @@
-- +goose Up
-- Read-only ICS calendar feeds: one token per tenant (all bookings) and one
-- per staff member (their bookings only). The token is the secret — anyone
-- holding the URL can read the feed, so tokens are rotatable from settings.
-- Postgres backfills existing rows with random values via the DEFAULT.
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS feed_token text NOT NULL DEFAULT encode(gen_random_bytes(16), 'hex');
ALTER TABLE tenants
ADD CONSTRAINT tenants_feed_token_key UNIQUE (feed_token);
ALTER TABLE staff
ADD COLUMN IF NOT EXISTS feed_token text NOT NULL DEFAULT encode(gen_random_bytes(16), 'hex');
ALTER TABLE staff
ADD CONSTRAINT staff_feed_token_key UNIQUE (feed_token);
-- +goose Down
ALTER TABLE staff DROP COLUMN IF EXISTS feed_token;
ALTER TABLE tenants DROP COLUMN IF EXISTS feed_token;
+110
View File
@@ -16,6 +16,7 @@ tags:
- name: Tenant
- name: Catalog
- name: Billing
- name: Calendar
- name: SMS
- name: Jobs
paths:
@@ -1258,6 +1259,85 @@ paths:
$ref: "#/components/schemas/SessionRoster"
"404":
description: Class session not found
# ---------------- Calendar ----------------
/v1/calendar/feed:
get:
tags: [Calendar]
operationId: getCalendarFeedInfo
security:
- bearerAuth: []
summary: Feed tokens for the tenant and every staff member
description: >-
Returns the read-only ICS feed token for the whole tenant plus one per
staff member. Tokens are secrets — anyone holding a feed URL can read
that calendar, same model as private Google/Outlook calendar links.
responses:
"200":
description: Current feed tokens
content:
application/json:
schema:
$ref: "#/components/schemas/CalendarFeedInfo"
"404":
description: Tenant membership not found
/v1/calendar/feed/rotate:
post:
tags: [Calendar]
operationId: rotateCalendarFeedToken
security:
- bearerAuth: []
summary: Invalidate a feed URL and issue a fresh token
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [scope]
properties:
scope:
type: string
enum: [tenant, staff]
staffId:
type: string
format: uuid
description: Required when scope is staff; must belong to the tenant
responses:
"200":
description: Newly issued token and feed path
content:
application/json:
schema:
$ref: "#/components/schemas/CalendarFeedEntry"
"400":
description: Invalid scope or foreign staff member
"404":
description: Tenant membership not found
/v1/public/calendar/{token}:
get:
tags: [Calendar]
operationId: getCalendarFeed
summary: Read-only ICS calendar feed
description: >-
Token-as-secret calendar subscription endpoint. `{token}` may carry a
`.ics` suffix, which is ignored. The feed covers bookings from 30 days
back to 180 days ahead; cancelled bookings appear with
STATUS:CANCELLED and waitlisted entries as TENTATIVE/TRANSPARENT.
parameters:
- in: path
name: token
required: true
schema:
type: string
responses:
"200":
description: iCalendar document
content:
text/calendar:
schema:
type: string
"404":
description: Unknown or rotated feed token
# ---------------- Billing ----------------
/v1/billing/subscription:
get:
@@ -1822,6 +1902,36 @@ components:
waitlistPosition:
type: integer
description: 1-based queue spot for waitlisted class bookings; absent otherwise
CalendarFeedEntry:
type: object
properties:
token:
type: string
description: Feed secret — embed in the subscription URL
feedPath:
type: string
description: API-relative path ending in .ics; prefix with the API base URL
StaffCalendarFeedEntry:
type: object
properties:
staffId:
type: string
format: uuid
displayName:
type: string
token:
type: string
feedPath:
type: string
CalendarFeedInfo:
type: object
properties:
tenant:
$ref: "#/components/schemas/CalendarFeedEntry"
staff:
type: array
items:
$ref: "#/components/schemas/StaffCalendarFeedEntry"
DashboardBookingRequest:
type: object
required: [customerName, customerEmail, startsAt]
@@ -0,0 +1,197 @@
import { createResource, createSignal, Show, For, Accessor } from "solid-js";
import { apiClient, apiBaseUrl } from "../lib/api-client";
import { useI18n } from "../providers/i18n-provider";
import { CalendarDaysIcon } from "./dashboard/icons";
interface FeedEntry {
token: string;
feedPath: string;
}
interface StaffFeedEntry {
staffId: string;
displayName: string;
token: string;
feedPath: string;
}
interface FeedInfo {
tenant: FeedEntry;
staff: StaffFeedEntry[];
}
const CopyIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect width="14" height="14" x="8" y="8" rx="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
);
const RotateIcon = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
</svg>
);
interface CalendarFeedProps {
token: Accessor<string | null | undefined>;
onDemoNotice?: (message: string) => void;
}
export function CalendarFeed(props: CalendarFeedProps) {
const i18n = useI18n();
const token = props.token;
const cs = () => i18n.locale() === "cs";
const [copiedKey, setCopiedKey] = createSignal<string | null>(null);
const [armedRotate, setArmedRotate] = createSignal<string | null>(null);
const [rotating, setRotating] = createSignal(false);
const [notice, setNotice] = createSignal<string | null>(null);
const [error, setError] = createSignal<string | null>(null);
const [feedInfo, { refetch }] = createResource(token, async (bearer) => {
if (!bearer || bearer.startsWith("demo.")) {
return {
tenant: { token: "demo-tenant-feed-token", feedPath: "/v1/public/calendar/demo-tenant-feed-token.ics" },
staff: [{ staffId: "demo-staff", displayName: "Demo Stylist", token: "demo-staff-feed-token", feedPath: "/v1/public/calendar/demo-staff-feed-token.ics" }],
} as FeedInfo;
}
const response = await (apiClient as any).GET("/v1/calendar/feed", {
headers: { Authorization: `Bearer ${bearer}` },
});
return response.data as FeedInfo;
});
const feedUrl = (entry: FeedEntry) => `${apiBaseUrl}${entry.feedPath}`;
const handleCopy = async (key: string, entry: FeedEntry) => {
try {
await navigator.clipboard.writeText(feedUrl(entry));
setCopiedKey(key);
setTimeout(() => setCopiedKey((current) => (current === key ? null : current)), 2000);
} catch {
setError(cs() ? "Kopírování se nezdařilo." : "Copy failed.");
}
};
const handleRotate = async (key: string, scope: "tenant" | "staff", staffId?: string) => {
const bearer = token();
if (!bearer || bearer.startsWith("demo.")) {
props.onDemoNotice?.(cs() ? "V demo režimu nelze měnit." : "Cannot change in demo mode.");
return;
}
if (armedRotate() !== key) {
setArmedRotate(key);
return;
}
setArmedRotate(null);
setRotating(true);
setError(null);
setNotice(null);
try {
await (apiClient as any).POST("/v1/calendar/feed/rotate", {
headers: { Authorization: `Bearer ${bearer}` },
body: { scope, staffId },
});
await refetch();
setNotice(cs() ? "Adresa kalendáře obnovena — starý odkaz přestal fungovat." : "Calendar address rotated — the old link stopped working.");
} catch {
setError(cs() ? "Obnovení adresy se nezdařilo." : "Failed to rotate the feed link.");
} finally {
setRotating(false);
}
};
const FeedRow = (rowProps: { label: string; hint: string; entry: FeedEntry; scope: "tenant" | "staff"; staffId?: string }) => {
const key = () => rowProps.scope + ":" + (rowProps.staffId ?? "tenant");
return (
<div class="p-4 bg-canvas-subtle rounded-xl">
<div class="flex items-start justify-between gap-3 mb-2">
<div>
<p class="text-sm font-semibold text-ink">{rowProps.label}</p>
<p class="text-xs text-ink-muted mt-0.5">{rowProps.hint}</p>
</div>
<button
onClick={() => void handleRotate(key(), rowProps.scope, rowProps.staffId)}
disabled={rotating()}
class={`shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${armedRotate() === key() ? "bg-error text-canvas" : "bg-canvas text-ink-muted hover:text-ink border border-border"}`}
>
<RotateIcon />
{armedRotate() === key()
? (cs() ? "Potvrdit?" : "Confirm?")
: (cs() ? "Nová adresa" : "New address")}
</button>
</div>
<div class="flex items-center gap-2">
<input
type="text"
readOnly
value={feedUrl(rowProps.entry)}
onFocus={(e) => e.currentTarget.select()}
class="flex-1 min-w-0 px-3 py-2 bg-canvas border border-border rounded-lg text-xs font-mono text-ink-muted"
/>
<button
onClick={() => void handleCopy(key(), rowProps.entry)}
class={`shrink-0 flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${copiedKey() === key() ? "bg-success text-canvas" : "bg-accent text-canvas hover:bg-accent-hover"}`}
>
<CopyIcon />
{copiedKey() === key() ? (cs() ? "Zkopírováno" : "Copied") : (cs() ? "Kopírovat" : "Copy")}
</button>
</div>
</div>
);
};
return (
<div class="surface-card p-6">
<div class="flex items-center gap-3 mb-2">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent"><CalendarDaysIcon /></div>
<div>
<h3 class="text-lg font-semibold text-ink">{cs() ? "Kalendář (ICS)" : "Calendar feed (ICS)"}</h3>
<p class="text-sm text-ink-muted">
{cs()
? "Zobrazte rezervace ve svém kalendáři — Google, Apple nebo Outlook."
: "See bookings in your own calendar — Google, Apple or Outlook."}
</p>
</div>
</div>
<p class="text-xs text-ink-muted mb-5">
{cs()
? "Zkopírujte adresu a přidejte ji v kalendáři jako „Odebírat z URL“. Odkaz je tajný — kdokoliv s ním vidí rezervace. Pokud odkaz unikne, vygenerujte nový."
: "Copy the address and add it in your calendar as “Subscribe from URL”. The link is secret — anyone holding it can see bookings. If it leaks, generate a new one."}
</p>
<Show when={feedInfo()}>
{(info) => (
<div class="space-y-3">
<FeedRow
label={cs() ? "Celý podnik" : "Whole business"}
hint={cs() ? "Všechny rezervace všech pracovníků." : "Every booking across all staff."}
entry={info().tenant}
scope="tenant"
/>
<For each={info().staff}>
{(staff) => (
<FeedRow
label={staff.displayName}
hint={cs() ? "Pouze rezervace tohoto pracovníka." : "Only this staff member's bookings."}
entry={staff}
scope="staff"
staffId={staff.staffId}
/>
)}
</For>
</div>
)}
</Show>
<Show when={notice()}>
<p class="mt-4 text-sm text-success">{notice()}</p>
</Show>
<Show when={error()}>
<p class="mt-4 text-sm text-error">{error()}</p>
</Show>
</div>
);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import createClient from "openapi-fetch";
import type { paths } from "@bookra/api-client/generated/types";
const baseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
export const apiBaseUrl = import.meta.env.VITE_BOOKRA_API_URL ?? "http://localhost:8080";
export const apiClient = createClient<paths>({
baseUrl,
baseUrl: apiBaseUrl,
});
@@ -4,6 +4,7 @@ import { Input } from "../../components/ui/input";
import { Textarea } from "../../components/ui/textarea";
import { PaletteIcon, MailIcon, CheckCircleIcon, BellIcon, XCircleIcon, CalendarDaysIcon } from "../../components/dashboard/icons";
import { SMSSettings } from "../../components/sms-settings";
import { CalendarFeed } from "../../components/calendar-feed";
import { WidgetBuilder } from "../../components/widget-builder";
import { DashboardLayout, useDashboardData } from "./layout";
@@ -142,6 +143,7 @@ function SettingsPage() {
</div>
<SMSSettings token={data.token} />
<CalendarFeed token={data.token} onDemoNotice={data.setDemoNotice} />
<WidgetBuilder config={{
tenantSlug: data.resolvedSummary()?.tenantSlug || "demo-studio",
publicBookingUrl: data.resolvedSummary()?.publicBookingUrl || "https://bookra.eu/book/demo-studio",
+176
View File
@@ -239,6 +239,43 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/calendar/feed": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Feed tokens for the tenant and every staff member
* @description Returns the read-only ICS feed token for the whole tenant plus one per staff member. Tokens are secrets — anyone holding a feed URL can read that calendar, same model as private Google/Outlook calendar links.
*/
get: operations["getCalendarFeedInfo"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/calendar/feed/rotate": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Invalidate a feed URL and issue a fresh token */
post: operations["rotateCalendarFeedToken"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/catalog/blocked-days": {
parameters: {
query?: never;
@@ -719,6 +756,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/public/calendar/{token}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Read-only ICS calendar feed
* @description Token-as-secret calendar subscription endpoint. `{token}` may carry a `.ics` suffix, which is ignored. The feed covers bookings from 30 days back to 180 days ahead; cancelled bookings appear with STATUS:CANCELLED and waitlisted entries as TENTATIVE/TRANSPARENT.
*/
get: operations["getCalendarFeed"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/public/contact": {
parameters: {
query?: never;
@@ -1003,6 +1060,16 @@ export interface components {
primaryColor?: string;
siteUrl?: string;
};
CalendarFeedEntry: {
/** @description API-relative path ending in .ics; prefix with the API base URL */
feedPath?: string;
/** @description Feed secret — embed in the subscription URL */
token?: string;
};
CalendarFeedInfo: {
staff?: components["schemas"]["StaffCalendarFeedEntry"][];
tenant?: components["schemas"]["CalendarFeedEntry"];
};
/** @description Stripe checkout launch payload — redirect the user to checkoutUrl. */
CheckoutLaunchResponse: {
/**
@@ -1332,6 +1399,13 @@ export interface components {
status: string;
waitlistPosition?: number;
};
StaffCalendarFeedEntry: {
displayName?: string;
feedPath?: string;
/** Format: uuid */
staffId?: string;
token?: string;
};
StaffMember: {
active: boolean;
/** Format: date-time */
@@ -2000,6 +2074,79 @@ export interface operations {
};
};
};
getCalendarFeedInfo: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Current feed tokens */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarFeedInfo"];
};
};
/** @description Tenant membership not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
rotateCalendarFeedToken: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
scope: "tenant" | "staff";
/**
* Format: uuid
* @description Required when scope is staff; must belong to the tenant
*/
staffId?: string;
};
};
};
responses: {
/** @description Newly issued token and feed path */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["CalendarFeedEntry"];
};
};
/** @description Invalid scope or foreign staff member */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Tenant membership not found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
listBlockedDays: {
parameters: {
query?: never;
@@ -3319,6 +3466,35 @@ export interface operations {
};
};
};
getCalendarFeed: {
parameters: {
query?: never;
header?: never;
path: {
token: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description iCalendar document */
200: {
headers: {
[name: string]: unknown;
};
content: {
"text/calendar": string;
};
};
/** @description Unknown or rotated feed token */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
submitContactForm: {
parameters: {
query?: never;
+9 -1
View File
@@ -146,6 +146,14 @@
- `CustomerBookingView` schema added to OpenAPI and referenced from `GET /v1/public/bookings/{reference}`; the reschedule contract documents the class-booking 400 and slot-conflict 409
- verified live: booked an appointment via public API, rescheduled through the real manage page in a browser (slot grid → 200 → card shows new time); class booking reschedule returns 400
- remaining: live staging validation on Neon + pgx repository path
- `[done/partial]` Milestone 22: ICS calendar feeds
- `GET /v1/public/calendar/{token}` serves an RFC 5545 feed — token-as-secret (Google/Outlook private-link model), `.ics` suffix tolerated, bookings from −30d to +180d; cancelled → `STATUS:CANCELLED`, waitlisted/pending → `TENTATIVE` + `TRANSP:TRANSPARENT`, waitlist entries marked in the summary
- per-staff tokens (`staff.feed_token`) and one tenant-wide token (`tenants.feed_token`, migration 00015); staff feeds contain only their bookings, tenant feeds tag each event with the staff member; summaries resolve service or class-session titles, descriptions carry localized contact labels (cs/en by tenant locale), events get location names
- custom ICS renderer — no dependency: TEXT escaping, CRLF, 75-octet folding on rune boundaries
- `GET /v1/calendar/feed` lists tenant + staff tokens behind auth; `POST /v1/calendar/feed/rotate` invalidates a leaked URL (tenant or staff scope, staff verified against the tenant)
- settings page "Kalendář (ICS)" card: copyable feed URLs per business/staff member, two-step rotate with confirmation, demo-mode placeholders pointing at real seeded tokens
- verified live: feed info returns tenant+staff tokens, tenant feed renders the seeded booking with staff name in the summary, staff feed omits it, bogus token 404s, rotation kills the old URL and the new one serves the same feed
- remaining: live staging validation on Neon + pgx repository path; Google Calendar two-way sync stays deferred (the ICS read direction covers the common ask)
## Overview
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
@@ -397,7 +405,7 @@ Shortlist of worthwhile directions once v1 stabilizes — kept deliberately smal
- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability.
- **Session-to-session move for classes**: M21 covered appointment rebooking; class customers currently must cancel and book another session. A "move to another session of the same template" flow (target session picker + capacity/waitlist handling) would close the gap.
- **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed.
- **Google Calendar sync**: M22 shipped read-only ICS feeds; two-way sync (push bookings into Google, pull busy blocks back as availability exceptions) needs OAuth and conflict rules — larger commitment, defer until a concrete request.
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
- **Persistent notification center**: the dropdown currently derives notifications from summary data; a `notifications` table would enable read/unread state and delivery history in-app.