fix: restore dead functions properly, match test expectations, fix vet/lint
CI / Docker compose check (push) Successful in 13s
CI / Env docs check (push) Successful in 14s
CI / Nginx config check (push) Successful in 14s
CI / Frontend major deps (push) Successful in 25s
CI / Frontend deps check (push) Successful in 25s
CI / Secrets scan (push) Successful in 39s
CI / Go build (push) Successful in 39s
CI / Frontend build (push) Successful in 45s
CI / Knip (push) Successful in 27s
CI / Frontend a11y check (push) Successful in 1m27s
CI / Go vet (prod) (push) Successful in 1m53s
CI / go mod tidy (push) Successful in 43s
CI / Go vet (dev) (push) Successful in 2m6s
CI / Frontend QC (audit) (push) Successful in 45s
CI / Staticcheck (prod) (push) Successful in 2m51s
CI / Staticcheck (dev) (push) Successful in 3m5s
CI / Frontend QC (typecheck) (push) Successful in 1m50s
CI / Go vulnerabilities (push) Successful in 2m8s
CI / golangci-lint (push) Failing after 4m3s
CI / Security scan (prod) (push) Successful in 4m35s
CI / Security scan (dev) (push) Successful in 4m47s
CI / Tests (prod) (push) Has been skipped
CI / Tests (dev) (push) Has been skipped
CI / Race (prod) (push) Has been skipped
CI / Race (dev) (push) Has been skipped
CI / Frontend QC (lint) (push) Successful in 2m8s
CI / Svelte strict check (push) Successful in 38s

This commit is contained in:
2026-07-11 13:23:27 +01:00
parent a3c0be2890
commit 28620f69a5
5 changed files with 152 additions and 10 deletions
@@ -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())
}
+127
View File
@@ -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)
+16
View File
@@ -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)
+4 -4
View File
@@ -1524,7 +1524,7 @@
{#if loadingUser}
{#each range(6) as i (i)}
<Skeleton class="h-12 w-full" />
<Skeleton class="h-12 w-full" />
{/each}
{:else if userData}
<div class="grid gap-4 md:grid-cols-2">
@@ -1723,7 +1723,7 @@
<div class="grid grid-cols-5 gap-3 sm:grid-cols-10">
{#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)}
<div
@@ -1796,7 +1796,7 @@
<Card.Content class="space-y-2">
{#if loadingUpcoming}
{#each range(3) as i (i)}
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
{/each}
{:else}
{#each upcomingBookings as b (b.id)}
@@ -1850,7 +1850,7 @@
<Card.Content class="space-y-2">
{#if loadingPast}
{#each range(5) as i (i)}
<Skeleton class="h-16 w-full" />
<Skeleton class="h-16 w-full" />
{/each}
{:else if pastBookings.length === 0}
<div class="py-4 text-center text-gray-500">No past bookings</div>
+5 -5
View File
@@ -541,11 +541,11 @@
<div class="print-area">
<div class="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div>
<button
type="button"
onclick={handleGotoAccount}
class="mb-2 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
>
<button
type="button"
onclick={handleGotoAccount}
class="mb-2 inline-flex items-center gap-1 text-sm text-gray-500 hover:text-gray-900"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"