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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user