mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
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>
253 lines
6.9 KiB
Go
253 lines
6.9 KiB
Go
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
|
|
}
|