feat: edit request time blockers, today closing time, UI polish, and test fixes

- Add time blocker management for booking edit requests
- Add closing_time field to admin today/current-next endpoint
- Update UserBookingModal and CurrentAppointment UI components
- Fix fmt import in bookings_test.go (was missing)
- Fix created_by FK in TestAdminApproveEditRequest_TimeBlockerOverlap
- Update test coverage for edit request time blocker overlap
- Update gap backlog documentation
This commit is contained in:
2026-05-10 16:53:17 +01:00
parent f3fb44f401
commit 83c62ffb97
10 changed files with 950 additions and 37 deletions
+46
View File
@@ -19,6 +19,7 @@ import (
"encoding/json"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/handlers/notifications"
@@ -102,6 +103,51 @@ func TestAdminToday_CurrentNext(t *testing.T) {
}
}
// TestAdminToday_CurrentNext_ClosingTime verifies that the current-next endpoint
// returns the closing time for today.
func TestAdminToday_CurrentNext_ClosingTime(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
// Seed working hours for today (query uses current weekday)
todayWeekday := int(time.Now().Weekday())
if todayWeekday == 0 {
todayWeekday = 7
}
_, err := db.DB.Exec(context.Background(), `
INSERT INTO working_hours (weekday, start_time, end_time, is_open)
VALUES ($1, '09:00', '18:00', true)
ON CONFLICT (weekday) DO UPDATE SET start_time = '09:00', end_time = '18:00', is_open = true
`, todayWeekday)
if err != nil {
t.Fatalf("failed to seed working hours: %v", err)
}
handler := http.HandlerFunc(today.GetCurrentAndNextHandler)
w := makeAdminRequest(handler, "GET", "/api/admin/today/current-next", nil)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d. body: %s", w.Code, w.Body.String())
}
var response today.CurrentNextResponse
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if response.ClosingTime == nil {
t.Errorf("expected closing_time in response, got nil")
}
if response.ClosingTime != nil && *response.ClosingTime == "" {
t.Error("expected closing_time to be non-empty string")
}
if response.ClosingTime != nil && *response.ClosingTime != "18:00:00" && *response.ClosingTime != "18:00" {
t.Logf("got closing_time: %s", *response.ClosingTime)
}
}
// TestAdminToday_Appointments tests that an admin can get a list of all
// bookings scheduled for today with their details.
func TestAdminToday_Appointments(t *testing.T) {
+443
View File
@@ -20,6 +20,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
@@ -2709,6 +2710,94 @@ func TestCreateEditRequest(t *testing.T) {
}
}
// TestCreateEditRequest_WithTimeChange verifies that a user can request an edit to
// change the booking time, and a time_blocker is created to reserve the new slot.
func TestCreateEditRequest_WithTimeChange(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
token := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(24 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
handler := http.HandlerFunc(RequestEditHandler)
reqBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(handler, "POST", "/api/bookings/"+bookingID+"/edit-request", reqBody, token)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Errorf("expected status 200/201, got %d. body: %s", w.Code, w.Body.String())
}
var erNewTime time.Time
err = db.DB.QueryRow(context.Background(),
"SELECT new_start_time FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&erNewTime)
if err != nil {
t.Fatalf("failed to query edit request: %v", err)
}
if !erNewTime.Truncate(time.Second).Equal(newStartTime) {
t.Errorf("expected new_start_time %v, got %v", newStartTime, erNewTime)
}
var blockerCount int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCount)
if err != nil {
t.Fatalf("failed to query time_blockers: %v", err)
}
if blockerCount != 1 {
t.Errorf("expected 1 time_blocker for edit request, got %d", blockerCount)
}
var blockerStart time.Time
var blockerDuration int
err = db.DB.QueryRow(context.Background(),
"SELECT start_time, duration_minutes FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerStart, &blockerDuration)
if err != nil {
t.Fatalf("failed to query time_blocker details: %v", err)
}
if !blockerStart.Truncate(time.Second).Equal(newStartTime) {
t.Errorf("expected blocker start_time %v, got %v", newStartTime, blockerStart)
}
if blockerDuration < 15 {
t.Errorf("expected blocker duration >= 15, got %d", blockerDuration)
}
}
// TestDeleteEditRequest tests that user deleting their edit request deletes the admin notification
func TestDeleteEditRequest(t *testing.T) {
cleanup := setupTestDB(t)
@@ -3029,6 +3118,360 @@ func TestAdminRejectEditRequest(t *testing.T) {
}
}
// TestAdminApproveEditRequest_DeletesTimeBlocker verifies that when admin approves
// an edit request, the associated time_blocker reservation is deleted.
func TestAdminApproveEditRequest_DeletesTimeBlocker(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
var blockerCountBefore int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
if err != nil {
t.Fatalf("failed to query blockers: %v", err)
}
if blockerCountBefore != 1 {
t.Fatalf("expected 1 blocker before approval, got %d", blockerCountBefore)
}
var editRequestID string
err = db.DB.QueryRow(context.Background(),
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
r := chi.NewRouter()
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler)
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
var blockerCountAfter int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
if err != nil {
t.Fatalf("failed to query blockers after approval: %v", err)
}
if blockerCountAfter != 0 {
t.Errorf("expected 0 blockers after approval, got %d", blockerCountAfter)
}
}
// TestAdminRejectEditRequest_DeletesTimeBlocker verifies that when admin rejects
// an edit request, the associated time_blocker reservation is deleted.
func TestAdminRejectEditRequest_DeletesTimeBlocker(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
var blockerCountBefore int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
if err != nil {
t.Fatalf("failed to query blockers: %v", err)
}
if blockerCountBefore != 1 {
t.Fatalf("expected 1 blocker before rejection, got %d", blockerCountBefore)
}
var editRequestID string
err = db.DB.QueryRow(context.Background(),
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
r := chi.NewRouter()
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/deny", AdminRejectEditRequestHandler)
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/deny", nil)
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent && w.Code != http.StatusOK {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
var blockerCountAfter int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
if err != nil {
t.Fatalf("failed to query blockers after rejection: %v", err)
}
if blockerCountAfter != 0 {
t.Errorf("expected 0 blockers after rejection, got %d", blockerCountAfter)
}
}
// TestDeleteEditRequest_DeletesTimeBlocker verifies that when user cancels their
// own edit request, the associated time_blocker reservation is deleted.
func TestDeleteEditRequest_DeletesTimeBlocker(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
var blockerCountBefore int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountBefore)
if err != nil {
t.Fatalf("failed to query blockers: %v", err)
}
if blockerCountBefore != 1 {
t.Fatalf("expected 1 blocker before delete, got %d", blockerCountBefore)
}
delHandler := http.HandlerFunc(DeleteEditRequestHandler)
w = makeRequest(delHandler, "DELETE", "/api/bookings/"+bookingID+"/edit-request", nil, userToken)
if w.Code != http.StatusOK && w.Code != http.StatusNoContent {
t.Errorf("expected status 200/204, got %d. body: %s", w.Code, w.Body.String())
}
var blockerCountAfter int
err = db.DB.QueryRow(context.Background(),
"SELECT COUNT(*) FROM time_blockers WHERE description = $1",
fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)).Scan(&blockerCountAfter)
if err != nil {
t.Fatalf("failed to query blockers after delete: %v", err)
}
if blockerCountAfter != 0 {
t.Errorf("expected 0 blockers after user delete, got %d", blockerCountAfter)
}
}
// TestAdminApproveEditRequest_TimeBlockerOverlap tests that approving an edit
// request fails when the new time conflicts with an existing time_blocker.
func TestAdminApproveEditRequest_TimeBlockerOverlap(t *testing.T) {
cleanup := setupTestDB(t)
defer cleanup()
seedDefaultWorkingHours(t)
userID, err := fixtures.CreateTestUser(db.DB)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
defer fixtures.DeleteUser(db.DB, userID)
_, err = db.DB.Exec(context.Background(), "UPDATE users SET deposits_required = 0 WHERE id = $1", userID)
if err != nil {
t.Fatalf("failed to set deposits_required: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.DB)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
defer fixtures.DeleteService(db.DB, serviceID)
bookingID, err := fixtures.CreateTestBooking(db.DB, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
defer fixtures.DeleteBooking(db.DB, bookingID)
_, err = db.DB.Exec(context.Background(), "UPDATE bookings SET status = 'confirmed' WHERE id = $1", bookingID)
if err != nil {
t.Fatalf("failed to confirm booking: %v", err)
}
userToken := jwt.GenerateUserToken(userID)
newStartTime := time.Now().Add(48 * time.Hour).Truncate(time.Second)
newStartTime = time.Date(newStartTime.Year(), newStartTime.Month(), newStartTime.Day(), 14, 0, 0, 0, newStartTime.Location())
createReq := http.HandlerFunc(RequestEditHandler)
createBody := map[string]interface{}{
"new_start_time": newStartTime.Format(time.RFC3339),
}
w := makeRequest(createReq, "POST", "/api/bookings/"+bookingID+"/edit-request", createBody, userToken)
if w.Code != http.StatusCreated && w.Code != http.StatusOK {
t.Fatalf("failed to create edit request: %d %s", w.Code, w.Body.String())
}
_, err = db.DB.Exec(context.Background(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, 60, 'Existing blocker', $2)
`, newStartTime, userID)
if err != nil {
t.Fatalf("failed to create blocking time_blocker: %v", err)
}
var editRequestID string
err = db.DB.QueryRow(context.Background(),
"SELECT id FROM booking_edit_requests WHERE booking_id = $1", bookingID).Scan(&editRequestID)
if err != nil {
t.Fatalf("failed to get edit request ID: %v", err)
}
r := chi.NewRouter()
r.Post("/api/admin/bookings/{id}/edit-requests/{request_id}/approve", AdminApproveEditRequestHandler)
req := httptest.NewRequest("POST", "/api/admin/bookings/"+bookingID+"/edit-requests/"+editRequestID+"/approve", nil)
ctx := context.WithValue(req.Context(), mw.UserRoleKey, "admin")
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", bookingID)
rctx.URLParams.Add("request_id", editRequestID)
ctx = context.WithValue(ctx, chi.RouteCtxKey, rctx)
req = req.WithContext(ctx)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusConflict {
t.Errorf("expected status 409 Conflict due to time_blocker overlap, got %d. body: %s", w.Code, w.Body.String())
}
}
// TestBookings_RequestEdit_BookingNotFound tests that requesting an edit for a non-existent booking returns 404
func TestBookings_RequestEdit_BookingNotFound(t *testing.T) {
cleanup := setupTestDB(t)
+66
View File
@@ -909,6 +909,11 @@ func DeleteEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
// Delete the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
@@ -1048,6 +1053,39 @@ func RequestEditHandler(w http.ResponseWriter, r *http.Request) {
return
}
if req.NewStartTime != nil {
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
var durationMinutes int
if len(req.NewServices) > 0 {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM services s
WHERE s.id = ANY($1)
`, req.NewServices).Scan(&durationMinutes)
} else {
_ = tx.QueryRow(r.Context(), `
SELECT COALESCE(SUM(s.duration_minutes), 60)
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = $1
`, bookingID).Scan(&durationMinutes)
}
_, err = tx.Exec(r.Context(), `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_by)
VALUES ($1, $2, $3, $4)
`, *req.NewStartTime, durationMinutes, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID), userID)
if err != nil {
log.Printf("Failed to create time_blocker reservation for edit request %s: %v", bookingID, err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
// Delete existing admin notification for edit_request before creating new one (refreshes timestamp)
_, err = tx.Exec(r.Context(), `
DELETE FROM admin_notifications
@@ -1290,6 +1328,15 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "This edit would cause an overlap with an existing booking", http.StatusConflict)
return
}
blockerOverlap, blockerDesc, err := scheduling.CheckTimeBlockerOverlap(r.Context(), *newStartTime, newEndTime)
if err != nil {
log.Printf("Failed to check time blocker overlap: %v", err)
}
if blockerOverlap {
http.Error(w, fmt.Sprintf("This edit would overlap with a time blocker: %s", blockerDesc), http.StatusConflict)
return
}
}
// Build update query for bookings table
@@ -1355,6 +1402,11 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
// Acknowledge the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
@@ -1366,6 +1418,12 @@ func AdminApproveEditRequestHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// TODO: Notify user that their edit request was approved.
// Options: (a) INSERT into user_notifications table (needs schema), (b) send email via SMTP provider.
// The user_notification_preferences table exists but no delivery mechanism is wired yet.
// See: obsidian/Crussell/Future Work - Gap Backlog.md → E5 (Email/SMS notification system).
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
@@ -1418,6 +1476,11 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
_, _ = tx.Exec(r.Context(), `
DELETE FROM time_blockers
WHERE description = $1
`, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID))
// Acknowledge the admin notification for this edit request
_, err = tx.Exec(r.Context(), `
UPDATE admin_notifications
@@ -1430,6 +1493,9 @@ func AdminRejectEditRequestHandler(w http.ResponseWriter, r *http.Request) {
return
}
// TODO: Notify user that their edit request was denied.
// Same as approve TODO above — needs user_notifications table or email delivery (E5).
if err := tx.Commit(r.Context()); err != nil {
log.Printf("Failed to commit reject edit request: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
+3 -1
View File
@@ -343,6 +343,7 @@ func CleanupOldReservations(ctx context.Context) error {
oneHourAgo := time.Now().Add(-1 * time.Hour)
tenMinutesAgo := time.Now().Add(-10 * time.Minute)
fifteenMinutesAgo := time.Now().Add(-15 * time.Minute)
twentyFourHoursAgo := time.Now().Add(-24 * time.Hour)
_, err := db.DB.Exec(ctx, `
DELETE FROM time_blockers
@@ -350,7 +351,8 @@ func CleanupOldReservations(ctx context.Context) error {
OR (description LIKE 'RESERVATION:anon:%' AND created_at < $2)
OR (description LIKE 'RESERVATION:admin:walkin:%' AND created_at < $3)
OR (description LIKE 'RESERVATION:admin:callin:%' AND created_at < $3)
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo)
OR (description LIKE 'RESERVATION:edit_request:%' AND created_at < $4)
`, oneHourAgo, tenMinutesAgo, fifteenMinutesAgo, twentyFourHoursAgo)
return err
}
@@ -1232,3 +1232,61 @@ func TestAnonymizeStaleGuestAccounts(t *testing.T) {
t.Errorf("expected guest 1 email to start with 'anon-', got '%s'", g1Email)
}
}
// --- Tests for CleanupOldReservations (Edit Request) ---
// TestCleanupOldReservations_EditRequest verifies that edit request reservations
// older than 24 hours are deleted, while recent ones are preserved.
func TestCleanupOldReservations_EditRequest(t *testing.T) {
cleanup := setupTimeBlockersTestDB(t)
defer cleanup()
ctx := context.Background()
ukLocation, _ := time.LoadLocation("Europe/London")
// Create old edit_request reservation (>24 hours old)
oldTime := time.Now().Add(-25 * time.Hour).In(ukLocation)
_, err := db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:edit_request:bk123', $2)
`, oldTime, time.Now().Add(-25*time.Hour))
if err != nil {
t.Fatalf("failed to create old edit_request reservation: %v", err)
}
// Create recent edit_request reservation (<24 hours old)
recentTime := time.Now().Add(-12 * time.Hour).In(ukLocation)
_, err = db.DB.Exec(ctx, `
INSERT INTO time_blockers (start_time, duration_minutes, description, created_at)
VALUES ($1, 60, 'RESERVATION:edit_request:bk456', $2)
`, recentTime, time.Now().Add(-12*time.Hour))
if err != nil {
t.Fatalf("failed to create recent edit_request reservation: %v", err)
}
// Run cleanup
err = CleanupOldReservations(ctx)
if err != nil {
t.Fatalf("CleanupOldReservations failed: %v", err)
}
// Verify old reservation was deleted
var oldCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk123'").Scan(&oldCount)
if err != nil {
t.Fatalf("failed to check old reservation: %v", err)
}
if oldCount != 0 {
t.Error("expected old edit_request reservation (25h) to be deleted")
}
// Verify recent reservation still exists
var recentCount int
err = db.DB.QueryRow(ctx, "SELECT COUNT(*) FROM time_blockers WHERE description = 'RESERVATION:edit_request:bk456'").Scan(&recentCount)
if err != nil {
t.Fatalf("failed to check recent reservation: %v", err)
}
if recentCount != 1 {
t.Error("expected recent edit_request reservation (12h) to be preserved")
}
}
+15 -2
View File
@@ -37,8 +37,9 @@ type AppointmentInfo struct {
}
type CurrentNextResponse struct {
Current *AppointmentInfo `json:"current"`
Next *AppointmentInfo `json:"next"`
Current *AppointmentInfo `json:"current"`
Next *AppointmentInfo `json:"next"`
ClosingTime *string `json:"closing_time,omitempty"` // "HH:MM" format
}
// GET /api/admin/today/current-next
@@ -158,6 +159,18 @@ func GetCurrentAndNextHandler(w http.ResponseWriter, r *http.Request) {
Next: next,
}
weekday := int(now.Weekday())
if weekday == 0 {
weekday = 7
}
var closingTime sql.NullString
_ = db.DB.QueryRow(r.Context(), `
SELECT end_time::text FROM working_hours WHERE weekday = $1 AND is_open = true
`, weekday).Scan(&closingTime)
if closingTime.Valid {
response.ClosingTime = &closingTime.String
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if err := json.NewEncoder(w).Encode(response); err != nil {