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)}
-