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
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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user