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 }