feat(bookings): session-to-session move for class bookings

Class customers can move a booking to another future session of the
same template from the manage page. Full target sessions place the
booking on the waitlist; freeing a confirmed seat drains the old
session's queue via the existing promotion path. Reminders re-arm
for the new time.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 15:42:04 +02:00
1 parent 6c3b2bf6bf
commit e5cb9bd543
10 files changed
+927 -10

No files matched your search

+48
View File
@@ -1176,6 +1176,54 @@ func NewServer(cfg config.Config, pools *db.Pools) (*Server, error) {
c.JSON(http.StatusOK, gin.H{"status": "cancelled"})
})
// Class bookings move between sessions of the same template rather than
// rescheduling into arbitrary windows.
server.router.GET("/v1/public/bookings/:reference/sessions", func(c *gin.Context) {
token := c.Query("token")
sessions, err := customerBookingService.ListSessionMoveTargets(c.Request.Context(), c.Param("reference"), token)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"sessions": sessions})
})
server.router.POST("/v1/public/bookings/:reference/move", publicRateLimiter.Middleware(), func(c *gin.Context) {
token := c.Query("token")
var request domain.MoveBookingRequest
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid_request"})
return
}
response, err := customerBookingService.MoveBookingToSession(c.Request.Context(), c.Param("reference"), token, request.ClassSessionID)
if err != nil {
status := http.StatusInternalServerError
switch {
case errors.Is(err, bookings.ErrBookingNotFound):
status = http.StatusNotFound
case errors.Is(err, bookings.ErrUnauthorized):
status = http.StatusForbidden
case errors.Is(err, bookings.ErrBookingCancelled):
status = http.StatusConflict
case errors.Is(err, bookings.ErrInvalidReschedule):
status = http.StatusBadRequest
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// 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) {