mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
Merge pull request #5 from Dvorinka/feat/calendar-feeds
feat(calendar): read-only ICS feeds for tenant and staff
This commit is contained in:
14 files changed
+1354
-3
No files matched your search
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user