feat: wire reserve route, dual TTL cleanup, and booking reservation deletion
Add POST /api/bookings/reserve route with OptionalAuth middleware and rate limiting. Update CleanupOldReservations to handle dual TTLs: 1hr for logged-in users, 10min for anonymous reservations. Update CreateBookingHandler to also match anon reservations by start_time for users who register mid-flow. Add comprehensive tests for reservation creation, validation, conflict detection, and cleanup. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -3,10 +3,10 @@ package bookings
|
||||
import (
|
||||
"crussell/db"
|
||||
"crussell/handlers/notifications"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/internal/dav"
|
||||
"crussell/internal/validators"
|
||||
"crussell/mw"
|
||||
"crussell/handlers/scheduling"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -138,8 +138,8 @@ type ServiceOverride struct {
|
||||
|
||||
// DeleteBookingRequest represents the request payload for deleting a booking with payment
|
||||
type DeleteBookingRequest struct {
|
||||
Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"`
|
||||
ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time
|
||||
Reason string `json:"reason" validate:"required,oneof=client_cancelled we_cancelled re-schedule no_show"`
|
||||
ForgiveNoShow *bool `json:"forgive_no_show,omitempty"` // Admin-only: forgive a no-show at cancellation time
|
||||
}
|
||||
|
||||
// AdminUserSummary represents a small user summary for admin views
|
||||
@@ -1230,10 +1230,13 @@ func CreateBookingHandler(w http.ResponseWriter, r *http.Request) {
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
// Delete any existing reservation for this user (max 1 per user)
|
||||
// Also matches anon reservations by start_time for users who register mid-flow
|
||||
_, _ = tx.Exec(r.Context(), `
|
||||
DELETE FROM time_blockers
|
||||
WHERE created_by = $1 AND description LIKE 'RESERVATION:%'
|
||||
`, userID)
|
||||
WHERE description LIKE 'RESERVATION:%'
|
||||
AND (created_by = $1
|
||||
OR (description LIKE 'RESERVATION:anon:%' AND start_time = $2))
|
||||
`, userID, req.StartTime)
|
||||
|
||||
// Insert booking with snapshotted deposit_required
|
||||
var booking Booking
|
||||
@@ -2231,17 +2234,15 @@ END:VEVENT
|
||||
END:VCALENDAR`, uid, dtstamp, dtstart, dtend, summary, description, status)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// OverlappingBooking represents a booking that overlaps with another
|
||||
type OverlappingBooking struct {
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Duration int `json:"duration_minutes"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User *UserSummary `json:"user,omitempty"`
|
||||
Services []string `json:"services,omitempty"`
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Duration int `json:"duration_minutes"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User *UserSummary `json:"user,omitempty"`
|
||||
Services []string `json:"services,omitempty"`
|
||||
}
|
||||
|
||||
// OverlappingBookingsResponse is the response for the overlapping bookings endpoint
|
||||
@@ -2353,4 +2354,4 @@ func GetOverlappingBookingsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Failed to encode overlapping bookings response: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
//go:build test
|
||||
// +build test
|
||||
|
||||
package bookings
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/scheduling"
|
||||
"crussell/mw"
|
||||
"crussell/testutils/fixtures"
|
||||
"crussell/testutils/jwt"
|
||||
"crussell/testutils/testdb"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
func setupReserveTestDB(t *testing.T) func() {
|
||||
t.Helper()
|
||||
|
||||
pool := testdb.Pool(t)
|
||||
testdb.Migrate(t, pool)
|
||||
testdb.TruncateTables(t, pool)
|
||||
|
||||
originalDB := db.DB
|
||||
db.DB = pool
|
||||
|
||||
jwt.Init()
|
||||
|
||||
seedDefaultWorkingHours(t)
|
||||
|
||||
return func() {
|
||||
db.DB = originalDB
|
||||
pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func makeReserveRequest(method, path string, body interface{}, token string) *httptest.ResponseRecorder {
|
||||
var req *http.Request
|
||||
if body != nil {
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req = httptest.NewRequest(method, path, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
} else {
|
||||
req = httptest.NewRequest(method, path, nil)
|
||||
}
|
||||
|
||||
// Add chi middleware stack for proper routing context
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.RealIP)
|
||||
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
|
||||
// Set user context from token if present
|
||||
if token != "" {
|
||||
if info := extractUserFromTestJWT(token); info != nil {
|
||||
rctx := chi.NewRouteContext()
|
||||
ctx := context.WithValue(req.Context(), chi.RouteCtxKey, rctx)
|
||||
ctx = context.WithValue(ctx, mw.UserIDKey, info.userID)
|
||||
ctx = context.WithValue(ctx, mw.UserRoleKey, info.role)
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler := http.HandlerFunc(ReserveSlotHandler)
|
||||
r.Post("/api/bookings/reserve", handler)
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
func reserveTestToken(t *testing.T, userID, role string) string {
|
||||
t.Helper()
|
||||
token, err := jwt.GenerateTestJWT(userID, role, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate test JWT: %v", err)
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
// TestReserveSlot_LoggedIn verifies logged-in users can reserve a slot.
|
||||
func TestReserveSlot_LoggedIn(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
token := reserveTestToken(t, "user-001", "verified_email")
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||
|
||||
reqBody := ReserveSlotRequest{
|
||||
StartTime: startTime,
|
||||
ServiceIDs: serviceIDs,
|
||||
}
|
||||
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, token)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response ReserveSlotResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if response.IsAnonymous {
|
||||
t.Error("expected is_anonymous=false for logged-in user")
|
||||
}
|
||||
if response.DurationMinutes == 0 {
|
||||
t.Error("expected non-zero duration")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_LoggedIn_ReplacesExisting verifies creating a second reservation
|
||||
// for the same user deletes the first one (max 1 per user).
|
||||
func TestReserveSlot_LoggedIn_ReplacesExisting(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
token := reserveTestToken(t, "user-002", "verified_email")
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
serviceIDs := []string{serviceID}
|
||||
|
||||
// First reservation
|
||||
startTime1 := time.Now().Add(48 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||
w1 := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: startTime1,
|
||||
ServiceIDs: serviceIDs,
|
||||
}, token)
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first reservation failed: %d", w1.Code)
|
||||
}
|
||||
|
||||
// Second reservation (should replace first)
|
||||
startTime2 := time.Now().Add(72 * time.Hour).Truncate(24 * time.Hour).Add(14 * time.Hour)
|
||||
w2 := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: startTime2,
|
||||
ServiceIDs: serviceIDs,
|
||||
}, token)
|
||||
if w2.Code != http.StatusCreated {
|
||||
t.Fatalf("second reservation failed: %d", w2.Code)
|
||||
}
|
||||
|
||||
// Verify only one user reservation exists
|
||||
var count int
|
||||
err := db.DB.QueryRow(context.Background(), `
|
||||
SELECT COUNT(*) FROM time_blockers
|
||||
WHERE description LIKE 'RESERVATION:user:%' AND created_by = 'user-002'
|
||||
`).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count reservations: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 reservation for user, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_Anonymous verifies anonymous users can reserve a slot.
|
||||
func TestReserveSlot_Anonymous(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
serviceIDs := []string{serviceID}
|
||||
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||
|
||||
reqBody := ReserveSlotRequest{
|
||||
StartTime: startTime,
|
||||
ServiceIDs: serviceIDs,
|
||||
}
|
||||
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", reqBody, "")
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var response ReserveSlotResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if !response.IsAnonymous {
|
||||
t.Error("expected is_anonymous=true for anonymous user")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_ValidationErrors verifies that missing or invalid
|
||||
// fields result in 400 Bad Request.
|
||||
func TestReserveSlot_ValidationErrors(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
serviceIDs := []string{serviceID}
|
||||
|
||||
// Missing start_time
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", map[string]interface{}{
|
||||
"service_ids": serviceIDs,
|
||||
}, "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing start_time, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Missing service_ids
|
||||
startTime := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||
w = makeReserveRequest("POST", "/api/bookings/reserve", map[string]interface{}{
|
||||
"start_time": startTime,
|
||||
}, "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing service_ids, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Past start_time
|
||||
w = makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: time.Now().Add(-1 * time.Hour),
|
||||
ServiceIDs: serviceIDs,
|
||||
}, "")
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for past start_time, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_BlockedByExistingBooking verifies that reserving a slot
|
||||
// that overlaps an existing booking returns 409 Conflict.
|
||||
func TestReserveSlot_BlockedByExistingBooking(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a booking at the same time
|
||||
bookingStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO bookings (user_id, start_time, status, deposit_required)
|
||||
VALUES ($1, $2, 'confirmed', false)
|
||||
`, "fixture-user", bookingStart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create booking: %v", err)
|
||||
}
|
||||
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: bookingStart,
|
||||
ServiceIDs: serviceIDs,
|
||||
}, "")
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409 for overlapping booking, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_BlockedByTimeBlocker verifies that reserving a blocked
|
||||
// time slot returns 409 Conflict.
|
||||
func TestReserveSlot_BlockedByTimeBlocker(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
serviceID, err := fixtures.CreateTestService(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create test service: %v", err)
|
||||
}
|
||||
|
||||
blockerStart := time.Now().Add(24 * time.Hour).Truncate(24 * time.Hour).Add(10 * time.Hour)
|
||||
_, err := db.DB.Exec(context.Background(), `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
|
||||
VALUES ($1, 60, 'Admin Blocked', NULL)
|
||||
`, blockerStart)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create blocker: %v", err)
|
||||
}
|
||||
|
||||
w := makeReserveRequest("POST", "/api/bookings/reserve", ReserveSlotRequest{
|
||||
StartTime: blockerStart,
|
||||
ServiceIDs: serviceIDs,
|
||||
}, "")
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409 for blocked slot, got %d. body: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestReserveSlot_DualCleanup verifies that CleanupOldReservations deletes
|
||||
// anon reservations after 10 minutes and user reservations after 1 hour.
|
||||
func TestReserveSlot_DualCleanup(t *testing.T) {
|
||||
cleanup := setupReserveTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Create old anon reservation (15 min ago)
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
VALUES ($1, 60, 'RESERVATION:anon:abc12345:1234', $2, NULL)
|
||||
`, time.Now().Add(24*time.Hour), time.Now().Add(-15*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old anon reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create recent anon reservation (5 min ago) - should survive
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
VALUES ($1, 60, 'RESERVATION:anon:def67890:1234', $2, NULL)
|
||||
`, time.Now().Add(48*time.Hour), time.Now().Add(-5*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create recent anon reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create old user reservation (45 min ago) - should survive (> 10min, < 1hr)
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
VALUES ($1, 60, 'RESERVATION:user:user-001:1234', $2, 'user-001')
|
||||
`, time.Now().Add(72*time.Hour), time.Now().Add(-45*time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create old user reservation: %v", err)
|
||||
}
|
||||
|
||||
// Create very old user reservation (2 hours ago) - should be deleted
|
||||
_, err = db.DB.Exec(ctx, `
|
||||
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at, created_by)
|
||||
VALUES ($1, 60, 'RESERVATION:user:user-002:1234', $2, 'user-002')
|
||||
`, time.Now().Add(96*time.Hour), time.Now().Add(-2*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create very old user reservation: %v", err)
|
||||
}
|
||||
|
||||
// Run cleanup
|
||||
err = scheduling.CleanupOldReservations(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanupOldReservations failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify old anon was deleted
|
||||
var anonOldCount int
|
||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:abc12345:%'`).Scan(&anonOldCount)
|
||||
if anonOldCount > 0 {
|
||||
t.Error("expected old anon reservation to be deleted")
|
||||
}
|
||||
|
||||
// Verify recent anon survived
|
||||
var anonRecentCount int
|
||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:anon:def67890:%'`).Scan(&anonRecentCount)
|
||||
if anonRecentCount != 1 {
|
||||
t.Error("expected recent anon reservation to survive")
|
||||
}
|
||||
|
||||
// Verify 45-min user reservation survived
|
||||
var user45Count int
|
||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:user-001:%'`).Scan(&user45Count)
|
||||
if user45Count != 1 {
|
||||
t.Error("expected 45-min user reservation to survive")
|
||||
}
|
||||
|
||||
// Verify 2hr user reservation was deleted
|
||||
var user2hrCount int
|
||||
db.DB.QueryRow(ctx, `SELECT COUNT(*) FROM time_blockers WHERE description LIKE 'RESERVATION:user:user-002:%'`).Scan(&user2hrCount)
|
||||
if user2hrCount > 0 {
|
||||
t.Error("expected 2-hour user reservation to be deleted")
|
||||
}
|
||||
}
|
||||
@@ -19,20 +19,20 @@ import (
|
||||
// --- Types ---
|
||||
|
||||
type TimeBlocker struct {
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CronExpression *string `json:"cron_expression,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
ID string `json:"id"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CronExpression *string `json:"cron_expression,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedBy *string `json:"created_by,omitempty"`
|
||||
}
|
||||
|
||||
type CreateTimeBlockerRequest struct {
|
||||
StartTime time.Time `json:"start_time"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CronExpression *string `json:"cron_expression,omitempty"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
DurationMinutes int `json:"duration_minutes"`
|
||||
Description string `json:"description,omitempty"`
|
||||
CronExpression *string `json:"cron_expression,omitempty"`
|
||||
}
|
||||
|
||||
// --- List Time Blockers ---
|
||||
@@ -185,7 +185,6 @@ func DeleteTimeBlocker(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
// --- Helper: Get Time Blockers in Range ---
|
||||
|
||||
// --- Helper: Get Time Blockers in Range ---
|
||||
@@ -334,14 +333,17 @@ func CheckTimeBlockerOverlap(ctx context.Context, startTime, endTime time.Time)
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
// CleanupOldReservations deletes reservations (time_blockers with RESERVATION: description prefix)
|
||||
// that are older than 1 hour.
|
||||
// CleanupOldReservations deletes expired reservations:
|
||||
// - Logged-in (RESERVATION:user): older than 1 hour
|
||||
// - Anonymous (RESERVATION:anon): older than 10 minutes
|
||||
func CleanupOldReservations(ctx context.Context) error {
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
|
||||
|
||||
_, err := db.DB.Exec(ctx, `
|
||||
DELETE FROM time_blockers
|
||||
WHERE description LIKE 'RESERVATION:%'
|
||||
AND created_at < $1
|
||||
`, oneHourAgo)
|
||||
WHERE (description LIKE 'RESERVATION:user:%' AND created_at < $1)
|
||||
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
|
||||
`, oneHourAgo, tenMinutesAgo)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -137,6 +137,13 @@ func main() {
|
||||
})
|
||||
})
|
||||
|
||||
// Public booking endpoints (optional auth for slot reservation)
|
||||
r.Route("/bookings", func(r chi.Router) {
|
||||
r.Use(mw.RateLimit(30, time.Minute))
|
||||
r.Use(mw.OptionalAuth)
|
||||
r.Post("/reserve", bookings.ReserveSlotHandler)
|
||||
})
|
||||
|
||||
// Authenticated users
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(mw.RequireAuth)
|
||||
|
||||
Reference in New Issue
Block a user