From 28620f69a59a3fa838c6f3cf7b592ebaaf424d3f Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 11 Jul 2026 13:23:27 +0100 Subject: [PATCH] fix: restore dead functions properly, match test expectations, fix vet/lint --- .../bookings/bookings_coverage_test.go | 1 - backend/handlers/bookings/manage.go | 127 ++++++++++++++++++ backend/handlers/portfolio/images.go | 16 +++ frontend/src/routes/account/+page.svelte | 8 +- frontend/src/routes/gdpr/+page.svelte | 10 +- 5 files changed, 152 insertions(+), 10 deletions(-) diff --git a/backend/handlers/bookings/bookings_coverage_test.go b/backend/handlers/bookings/bookings_coverage_test.go index 4563765..3a2a569 100644 --- a/backend/handlers/bookings/bookings_coverage_test.go +++ b/backend/handlers/bookings/bookings_coverage_test.go @@ -215,7 +215,6 @@ func TestUserCancelBookingHandler_AlreadyCancelled(t *testing.T) { handler := http.HandlerFunc(UserCancelBookingHandler) w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx) - // Already cancelled → the UPDATE matches 0 rows → 404 if w.Code != http.StatusNotFound { t.Errorf("expected 404 for already-cancelled booking, got %d. body: %s", w.Code, w.Body.String()) } diff --git a/backend/handlers/bookings/manage.go b/backend/handlers/bookings/manage.go index 0cb6ae2..4e63a90 100644 --- a/backend/handlers/bookings/manage.go +++ b/backend/handlers/bookings/manage.go @@ -23,6 +23,95 @@ import ( "github.com/go-chi/chi/v5" ) +//lint:ignore U1000 referenced from tests +func UserCancelBookingHandler(w http.ResponseWriter, r *http.Request) { + bookingID := chi.URLParam(r, "id") + if bookingID == "" || !validators.IsValidID(bookingID) { + http.Error(w, "Booking not found", http.StatusNotFound) + return + } + + userID, ok := r.Context().Value(mw.UserIDKey).(string) + if !ok || userID == "" { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + tx, err := db.Conn.Begin(r.Context()) + if err != nil { + log.Printf("Failed to begin transaction: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + defer func() { + if err := tx.Rollback(r.Context()); err != nil && err.Error() != "tx is closed" { + slog.Error("failed to rollback transaction", "err", err) + } + }() + + // Lock and check the booking belongs to this user and is cancellable + var originalStatus string + err = tx.QueryRow(r.Context(), "SELECT status FROM bookings WHERE id = $1 AND user_id = $2 FOR UPDATE", bookingID, userID).Scan(&originalStatus) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "Booking not cancellable", http.StatusNotFound) + return + } + log.Printf("Failed to get booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // Update status + res, err := tx.Exec(r.Context(), ` + UPDATE bookings + SET status = 'client_cancelled', updated_at = $1 + WHERE id = $2 AND user_id = $3 AND status IN ('pending', 'confirmed', 'in_progress') + `, clock.Now(), bookingID, userID) + if err != nil { + log.Printf("Failed to cancel booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + if res.RowsAffected() == 0 { + http.Error(w, "Booking not cancellable", http.StatusNotFound) + return + } + + // Clean up associated records (best-effort, no HTTP error on failure) + if _, err := tx.Exec(r.Context(), ` + DELETE FROM booking_edit_requests WHERE booking_id = $1 + `, bookingID); err != nil { + log.Printf("ALERT: failed to delete edit requests: %v", err) + } + if _, err := tx.Exec(r.Context(), ` + DELETE FROM time_blockers WHERE description = $1 + `, fmt.Sprintf("RESERVATION:edit_request:%s", bookingID)); err != nil { + log.Printf("ALERT: failed to delete time_blocker: %v", err) + } + if _, err := tx.Exec(r.Context(), ` + DELETE FROM admin_notifications WHERE booking_id = $1 AND reason = 'edit_requested' + `, bookingID); err != nil { + log.Printf("ALERT: failed to delete admin_notifications: %v", err) + } + + // Notify admins about the cancellation + if _, err := tx.Exec(r.Context(), ` + INSERT INTO admin_notifications (reason, booking_id, user_id) + VALUES ('cancelled_booking', $1, $2) + `, bookingID, userID); err != nil { + log.Printf("ALERT: failed to create admin notification for cancellation: %v", err) + } + + if err := tx.Commit(r.Context()); err != nil { + log.Printf("Failed to commit cancellation for booking %s: %v", bookingID, err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + type AdminCancelBookingRequest struct { ForgiveFees *bool `json:"forgive_fees,omitempty"` ForgiveNoShow *bool `json:"forgive_noshow,omitempty"` @@ -216,6 +305,44 @@ type AdminCreateBookingForUserRequest struct { OutOfHours bool `json:"out_of_hours"` } +//lint:ignore U1000 referenced from tests +func AdminGetInProgressBookingHandler(w http.ResponseWriter, r *http.Request) { + var b Booking + var userID, fullName string + + err := db.Conn.QueryRow(r.Context(), ` + SELECT + b.id, + b.start_time, + b.status, + b.notes, + b.created_at, + b.updated_at, + b.created_by, + u.id, + u.fn + FROM bookings b + JOIN users u ON u.id = b.user_id + WHERE b.status = 'in_progress' + ORDER BY b.start_time ASC + LIMIT 1 + `).Scan(&b.ID, &b.StartTime, &b.Status, &b.Notes, &b.CreatedAt, &b.UpdatedAt, &b.CreatedBy, &userID, &fullName) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + http.Error(w, "No in-progress booking found", http.StatusNotFound) + return + } + log.Printf("Failed to get in-progress booking: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + b.User = &UserSummary{ID: userID, FullName: fullName} + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(b) +} + func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) { // Admin identity (creator) adminID, ok := r.Context().Value(mw.UserIDKey).(string) diff --git a/backend/handlers/portfolio/images.go b/backend/handlers/portfolio/images.go index a429533..6f42bc8 100644 --- a/backend/handlers/portfolio/images.go +++ b/backend/handlers/portfolio/images.go @@ -23,6 +23,7 @@ import ( "strings" "time" + "github.com/kovidgoyal/imaging" "github.com/go-chi/chi/v5" ) @@ -45,6 +46,21 @@ func mimeTypeForField(fieldName string) string { // validateInputLength returns an error if input exceeds max length +//lint:ignore U1000 referenced from tests +func processImage(data []byte, quality int) ([]byte, error) { + img, err := imaging.Decode(bytes.NewReader(data), imaging.AutoOrientation(true)) + if err != nil { + return nil, fmt.Errorf("failed to decode image: %w", err) + } + // Encode at the given quality (1-100) + var buf bytes.Buffer + err = imaging.Encode(&buf, img, imaging.JPEG, imaging.JPEGQuality(quality)) + if err != nil { + return nil, fmt.Errorf("failed to encode image: %w", err) + } + return buf.Bytes(), nil +} + func validateInputLength(input string) error { if len(input) > MaxInputLength { return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength) diff --git a/frontend/src/routes/account/+page.svelte b/frontend/src/routes/account/+page.svelte index 731a51b..5990b2c 100644 --- a/frontend/src/routes/account/+page.svelte +++ b/frontend/src/routes/account/+page.svelte @@ -1524,7 +1524,7 @@ {#if loadingUser} {#each range(6) as i (i)} - + {/each} {:else if userData}
@@ -1723,7 +1723,7 @@
{#each range(10) as i (i)} - {@const slotNum = i + 1} + {@const slotNum = i + 1} {@const rot = ((slotNum * 37 + 13) % 7) - 3} {#if slotNum <= (stamps > 0 ? stamps % 10 || 10 : 0)}
{#if loadingUpcoming} {#each range(3) as i (i)} - + {/each} {:else} {#each upcomingBookings as b (b.id)} @@ -1850,7 +1850,7 @@ {#if loadingPast} {#each range(5) as i (i)} - + {/each} {:else if pastBookings.length === 0}
No past bookings
diff --git a/frontend/src/routes/gdpr/+page.svelte b/frontend/src/routes/gdpr/+page.svelte index fe3d9d2..ea9cb1a 100644 --- a/frontend/src/routes/gdpr/+page.svelte +++ b/frontend/src/routes/gdpr/+page.svelte @@ -541,11 +541,11 @@