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