mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
feat(calendar): read-only ICS feeds for tenant and staff
GET /v1/public/calendar/{token} serves an RFC 5545 calendar under a
token-as-secret URL (private Google/Outlook link model). Staff feeds
contain only that member's bookings; the tenant feed tags each event
with the staff member. Window is -30d to +180d; cancelled bookings map
to STATUS:CANCELLED, waitlisted/pending to TENTATIVE + TRANSPARENT.
Summaries resolve service or class-session titles, descriptions carry
localized contact labels, locations resolve to names.
Feed tokens live on tenants/staff (migration 00015) and are managed via
GET /v1/calendar/feed plus POST /v1/calendar/feed/rotate — rotating a
scope invalidates the old URL. ICS rendering is hand-rolled: TEXT
escaping, CRLF, 75-octet folding on rune boundaries.
Settings gains a "Kalendář (ICS)" card with copyable URLs and two-step
rotation per scope; demo mode points at the real seeded tokens.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
796c06ae66
commit
6c3b2bf6bf
14 files changed
+1354
-3
No files matched your search
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user