diff --git a/FRONTEND_ISSUES.md b/FRONTEND_ISSUES.md index 7f4cb98..c8e8c33 100644 --- a/FRONTEND_ISSUES.md +++ b/FRONTEND_ISSUES.md @@ -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. diff --git a/apps/backend/internal/api/server.go b/apps/backend/internal/api/server.go index 33463bc..99d511c 100644 --- a/apps/backend/internal/api/server.go +++ b/apps/backend/internal/api/server.go @@ -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 { diff --git a/apps/backend/internal/calendar/ics.go b/apps/backend/internal/calendar/ics.go new file mode 100644 index 0000000..41a7c51 --- /dev/null +++ b/apps/backend/internal/calendar/ics.go @@ -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") +} diff --git a/apps/backend/internal/calendar/service.go b/apps/backend/internal/calendar/service.go new file mode 100644 index 0000000..8039723 --- /dev/null +++ b/apps/backend/internal/calendar/service.go @@ -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 +} diff --git a/apps/backend/internal/calendar/service_test.go b/apps/backend/internal/calendar/service_test.go new file mode 100644 index 0000000..fb35f6b --- /dev/null +++ b/apps/backend/internal/calendar/service_test.go @@ -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: "demo@bookra.dev", 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: "feed@example.com", + 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: "queued@example.com", + 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) + } +} diff --git a/apps/backend/internal/db/calendar_feed.go b/apps/backend/internal/db/calendar_feed.go new file mode 100644 index 0000000..ea96a37 --- /dev/null +++ b/apps/backend/internal/db/calendar_feed.go @@ -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 +} diff --git a/apps/backend/internal/db/repository.go b/apps/backend/internal/db/repository.go index 3ad7d99..3a4cc17 100644 --- a/apps/backend/internal/db/repository.go +++ b/apps/backend/internal/db/repository.go @@ -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 diff --git a/apps/backend/migrations/00015_calendar_feed_tokens.sql b/apps/backend/migrations/00015_calendar_feed_tokens.sql new file mode 100644 index 0000000..bb95e95 --- /dev/null +++ b/apps/backend/migrations/00015_calendar_feed_tokens.sql @@ -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; diff --git a/apps/backend/openapi/bookra.openapi.yaml b/apps/backend/openapi/bookra.openapi.yaml index 5a40d63..a5afcf0 100644 --- a/apps/backend/openapi/bookra.openapi.yaml +++ b/apps/backend/openapi/bookra.openapi.yaml @@ -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] diff --git a/apps/frontend/src/components/calendar-feed.tsx b/apps/frontend/src/components/calendar-feed.tsx new file mode 100644 index 0000000..da2fcfe --- /dev/null +++ b/apps/frontend/src/components/calendar-feed.tsx @@ -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 = () => ( + + + + +); + +const RotateIcon = () => ( + + + + +); + +interface CalendarFeedProps { + token: Accessor; + 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(null); + const [armedRotate, setArmedRotate] = createSignal(null); + const [rotating, setRotating] = createSignal(false); + const [notice, setNotice] = createSignal(null); + const [error, setError] = createSignal(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 ( +
+
+
+

{rowProps.label}

+

{rowProps.hint}

+
+ +
+
+ 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" + /> + +
+
+ ); + }; + + return ( +
+
+
+
+

{cs() ? "Kalendář (ICS)" : "Calendar feed (ICS)"}

+

+ {cs() + ? "Zobrazte rezervace ve svém kalendáři — Google, Apple nebo Outlook." + : "See bookings in your own calendar — Google, Apple or Outlook."} +

+
+
+

+ {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."} +

+ + + {(info) => ( +
+ + + {(staff) => ( + + )} + +
+ )} +
+ + +

{notice()}

+
+ +

{error()}

+
+
+ ); +} diff --git a/apps/frontend/src/lib/api-client.ts b/apps/frontend/src/lib/api-client.ts index 752abf4..c20ee64 100644 --- a/apps/frontend/src/lib/api-client.ts +++ b/apps/frontend/src/lib/api-client.ts @@ -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({ - baseUrl, + baseUrl: apiBaseUrl, }); diff --git a/apps/frontend/src/routes/dashboard/settings-page.tsx b/apps/frontend/src/routes/dashboard/settings-page.tsx index de540f3..a66168c 100644 --- a/apps/frontend/src/routes/dashboard/settings-page.tsx +++ b/apps/frontend/src/routes/dashboard/settings-page.tsx @@ -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() { +