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)
|
handler := http.HandlerFunc(UserCancelBookingHandler)
|
||||||
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx)
|
w := makeRequest(handler, "DELETE", "/api/bookings/"+bookingID, nil, token, ctx)
|
||||||
|
|
||||||
// Already cancelled → the UPDATE matches 0 rows → 404
|
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusNotFound {
|
||||||
t.Errorf("expected 404 for already-cancelled booking, got %d. body: %s", w.Code, w.Body.String())
|
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"
|
"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 {
|
type AdminCancelBookingRequest struct {
|
||||||
ForgiveFees *bool `json:"forgive_fees,omitempty"`
|
ForgiveFees *bool `json:"forgive_fees,omitempty"`
|
||||||
ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
|
ForgiveNoShow *bool `json:"forgive_noshow,omitempty"`
|
||||||
@@ -216,6 +305,44 @@ type AdminCreateBookingForUserRequest struct {
|
|||||||
OutOfHours bool `json:"out_of_hours"`
|
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) {
|
func AdminCreateBookingForUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
// Admin identity (creator)
|
// Admin identity (creator)
|
||||||
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
adminID, ok := r.Context().Value(mw.UserIDKey).(string)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/kovidgoyal/imaging"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -45,6 +46,21 @@ func mimeTypeForField(fieldName string) string {
|
|||||||
|
|
||||||
// validateInputLength returns an error if input exceeds max length
|
// 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 {
|
func validateInputLength(input string) error {
|
||||||
if len(input) > MaxInputLength {
|
if len(input) > MaxInputLength {
|
||||||
return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength)
|
return fmt.Errorf("input exceeds maximum length of %d characters", MaxInputLength)
|
||||||
|
|||||||
Reference in New Issue
Block a user