Files
Crussell/backend/handlers/scheduling/exceptional-hours.go
T
popertots b7122be3a0 fix: SCA review round + gitea pipeline green — GDPR audit scrub, backend test gaps, frontend SCA/Square-API, docs parity
7 review agents (pipeline run, self-review, codebase-context, frontend-placement,
backend testing-gaps, Square-API, docs-parity) audited the SCA-primary work.
ALL findings fixed, including every pre-existing red CI job:

GDPR (HIGH):
- anonymize_user() now scrubs admin_audit_log.target_user_id (mirrors
  delete_guest_user) so 2fa_fallback_charge rows (customer id + card_last4 PII)
  no longer survive registered-user account deletion; gdpr test added

BACKEND TEST GAPS (all 10):
- delivery-unavailable 503 branch: prod-tag predicate test + dev-variant marker
- twoFactorFallbackEnabled alias/case/default matrix tests + exported wrapper
- insertTwoFAFallbackAudit details-JSON shape + audit-row assertions for all
  6 gate sites (booking/tip/gift-card/payment-method/terminal/till, both actors)
- CreateTerminalPayment.VerificationToken: passthrough, too-long 400, 2FA-skip,
  token-less fallback + SCA-required (new terminal_sca_test.go)
- isVerificationRequiredError at all 5 charge sites (402 + code:verification_required)
- customer_initiated handler-level assertions (MIT false admin / CIT true customer)
- Mock: ApprovePendingVerification, ChallengeResult auto/deny, _deny token suffix,
  parseVerifyToken unit tests

FRONTEND SCA + Square-API (CRITICAL):
- tokenizeSavedCardWithVerification reads result.token (the verified token) not
  result.verificationResult (deprecated verifyBuyer shape — saved-card SCA could
  never succeed in production before); parseTokenizeVerificationResult pure fn
  extracted + pinned in square.test.ts; 'verified' with no token proceeds tokenless
- HIGH: saved-card idempotency key regenerated after a definitive 402 (fresh token
  under the same key = IDEMPOTENCY_KEY_REUSED dead-loop); kept on 503/cancelled
- challenge-cancelled copy no longer promises a 2FA fallback the UI doesn't show;
  'waiting for approval in your banking app' state on CIT surfaces
- sca-unavailable demotion resets per attempt; card selection disabled mid-challenge;
  genuine saved-card declines no longer relabeled 'requires verification';
  modal-close guard during processing; retry affordance standardized

PIPELINE (every red job now green):
- prod-tag build break fixed (shared square stub + test_helpers_test.go, prod-safe)
- govulncheck: x/image 0.45.0 bumped (x/text resolved); go mod tidy clean
- race: TestDeleteAccount_InvalidatesSquareCustomerCache made deterministic
- DAV_ADMIN_PASSWORD placeholder in .env.example (compose config passes)
- frontend: prettier 28 files, eslint, a11y 38 errors, knip (currentZIndex),
  deps in-range, audit vulns (nanoid/postcss) — all fixed; 67 vitest cases

DOCS PARITY (6 DRIFTs + 5 GAPs): payments doc Ch4/Ch14/Appendix A, Technical
Manual 2FA + counter-reset + payment sections, README test counts + SNAPSHOT_ENC_KEY,
Feature Catalog, .env.example REQUIRE_2FA — SCA-primary/2FA-backup posture verified
against code everywhere

Verified: 26/26 dev + 24/24 prod packages, both vet tags, golangci-lint/staticcheck/
gosec 0 on both tags, gitleaks clean, 2,464 backend + 67 frontend tests.
2026-08-22 00:34:50 +01:00

594 lines
18 KiB
Go

package scheduling
import (
"encoding/json"
"errors"
"log"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"crussell/db"
"crussell/internal/validators"
"github.com/jackc/pgx/v5"
)
type ExceptionalHours struct {
ID int `json:"id"`
GroupID int `json:"groupId"`
Weekday int `json:"weekday" validate:"gte=0,lte=6"`
StartTime string `json:"startTime" validate:"required"`
EndTime string `json:"endTime" validate:"required"`
IsOpen bool `json:"isOpen"`
}
type ExceptionalGroup struct {
ID int `json:"id"`
Name string `json:"name" validate:"required"`
Description string `json:"description" validate:"required"`
Hours []ExceptionalHours `json:"hours,omitempty" validate:"required,min=7,max=7,dive"`
WeekStarts []string `json:"weekStarts,omitempty" validate:"required,min=1,max=52,dive,required"`
}
// --- List Groups with Hours and Applications ---
func ListExceptionalGroups(w http.ResponseWriter, r *http.Request) {
rows, err := db.Conn.Query(r.Context(), `
SELECT id, name, description
FROM exceptional_working_hours_groups
ORDER BY id DESC
`)
if err != nil {
http.Error(w, "failed to fetch groups", http.StatusInternalServerError)
return
}
defer rows.Close()
// Collect all groups first, then close rows to avoid "conn busy" when
// the context carries a test transaction (single connection).
var groups []ExceptionalGroup
for rows.Next() {
var g ExceptionalGroup
if err := rows.Scan(&g.ID, &g.Name, &g.Description); err != nil {
rows.Close()
http.Error(w, "failed to scan group", http.StatusInternalServerError)
return
}
groups = append(groups, g)
}
rows.Close() // explicit close before subsequent queries (hours/apps below); defer covers error path
if err := rows.Err(); err != nil {
http.Error(w, "error iterating groups", http.StatusInternalServerError)
return
}
// Load hours and applications for each group (separate queries after rows are closed)
for i := range groups {
// Load 7-day hours
hoursRows, err := db.Conn.Query(r.Context(), `
SELECT id, weekday, start_time::text, end_time, is_open
FROM exceptional_working_hours
WHERE group_id=$1 ORDER BY weekday
`, groups[i].ID)
if err != nil {
http.Error(w, "failed to fetch group hours", http.StatusInternalServerError)
return
}
for hoursRows.Next() {
var h ExceptionalHours
if err := hoursRows.Scan(&h.ID, &h.Weekday, &h.StartTime, &h.EndTime, &h.IsOpen); err != nil {
hoursRows.Close()
http.Error(w, "failed to scan hours", http.StatusInternalServerError)
return
}
h.GroupID = groups[i].ID
groups[i].Hours = append(groups[i].Hours, h)
}
hoursRows.Close()
if err := hoursRows.Err(); err != nil {
http.Error(w, "error iterating hours", http.StatusInternalServerError)
return
}
// Load applied week starts
weekRows, err := db.Conn.Query(r.Context(), `
SELECT week_start
FROM exceptional_group_applications
WHERE group_id=$1 ORDER BY week_start
`, groups[i].ID)
if err != nil {
http.Error(w, "failed to fetch applications", http.StatusInternalServerError)
return
}
for weekRows.Next() {
var weekStart time.Time
if err := weekRows.Scan(&weekStart); err != nil {
weekRows.Close()
http.Error(w, "failed to scan week_start", http.StatusInternalServerError)
return
}
groups[i].WeekStarts = append(groups[i].WeekStarts, weekStart.Format("2006-01-02"))
}
weekRows.Close()
if err := weekRows.Err(); err != nil {
http.Error(w, "error iterating applications", http.StatusInternalServerError)
return
}
}
if err := json.NewEncoder(w).Encode(groups); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// --- Create Group with Hours and Applications (bulk) ---
func CreateExceptionalGroup(w http.ResponseWriter, r *http.Request) {
var g ExceptionalGroup
if err := json.NewDecoder(r.Body).Decode(&g); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&g); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// M8
// L5
if len(g.Hours) != 7 {
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
return
}
// Validate weekdays and parse week_starts
weekdaysSeen := make(map[int]bool)
for _, h := range g.Hours {
if h.Weekday < 0 || h.Weekday > 6 {
http.Error(w, "weekday must be 0-6", http.StatusBadRequest)
return
}
if weekdaysSeen[h.Weekday] {
http.Error(w, "duplicate weekday entries", http.StatusBadRequest)
return
}
weekdaysSeen[h.Weekday] = true
if !isValidTime15Min(h.StartTime) {
http.Error(w, "start_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
return
}
if !isValidTime15Min(h.EndTime) {
http.Error(w, "end_time must be in 15-minute intervals (00, 15, 30, 45)", http.StatusBadRequest)
return
}
}
var parsedWeeks []time.Time
for _, ws := range g.WeekStarts {
weekStart, err := time.Parse("2006-01-02", ws)
if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
if weekStart.Weekday() != time.Monday {
http.Error(w, "week_start must be a Monday", http.StatusBadRequest)
return
}
parsedWeeks = append(parsedWeeks, weekStart)
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to start transaction", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Create group
err = tx.QueryRow(r.Context(), `
INSERT INTO exceptional_working_hours_groups (name, description)
VALUES ($1, $2) RETURNING id
`, g.Name, g.Description).Scan(&g.ID)
if err != nil {
http.Error(w, "failed to create group", http.StatusInternalServerError)
return
}
// Insert hours and collect their IDs
inputHours := g.Hours
g.Hours = []ExceptionalHours{} // Clear and rebuild with IDs
for _, h := range inputHours {
var id int
err := tx.QueryRow(r.Context(), `
INSERT INTO exceptional_working_hours (group_id, weekday, start_time, end_time, is_open)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`, g.ID, h.Weekday, h.StartTime, h.EndTime, h.IsOpen).Scan(&id)
if err != nil {
http.Error(w, "failed to insert hours", http.StatusInternalServerError)
return
}
h.ID = id
h.GroupID = g.ID
g.Hours = append(g.Hours, h)
}
// Insert applications
if _, err := tx.Exec(r.Context(), `
INSERT INTO exceptional_group_applications (group_id, week_start)
SELECT $1, unnest($2::date[])
`, g.ID, parsedWeeks); err != nil {
http.Error(w, "failed to insert applications", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "failed to commit transaction", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(g); err != nil {
log.Printf("Failed to encode JSON response: %v", err)
}
}
// --- Delete Group (cascades to hours and applications) ---
func DeleteExceptionalGroup(w http.ResponseWriter, r *http.Request) {
// Extract group ID from URL path or query params
// Assuming you have a router that provides this, e.g. chi, mux, etc.
// For this example, we'll use query param: DELETE /exceptional-groups?id=123
idStr := r.URL.Query().Get("id")
if idStr == "" {
http.Error(w, "missing id parameter", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "invalid id parameter", http.StatusBadRequest)
return
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
result, err := tx.Exec(r.Context(), `
DELETE FROM exceptional_working_hours_groups WHERE id=$1
`, id)
if err != nil {
http.Error(w, "failed to delete group", http.StatusInternalServerError)
return
}
rowsAffected := result.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "group not found", http.StatusNotFound)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// --- Update Applied Weeks (replaces all applications for a group) ---
func UpdateExceptionalApplications(w http.ResponseWriter, r *http.Request) {
var req struct {
GroupID int `json:"groupId" validate:"required"`
WeekStarts []string `json:"weekStarts" validate:"required,min=1,max=52,dive,required"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// M8
// L5
// Validate and parse weeks
var parsedWeeks []time.Time
for _, ws := range req.WeekStarts {
weekStart, err := time.Parse("2006-01-02", ws)
if err != nil {
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
if weekStart.Weekday() != time.Monday {
http.Error(w, "week_start must be a Monday", http.StatusBadRequest)
return
}
parsedWeeks = append(parsedWeeks, weekStart)
}
tx, err := db.Conn.Begin(r.Context())
if err != nil {
http.Error(w, "failed to start transaction", http.StatusInternalServerError)
return
}
defer func() {
if err := tx.Rollback(r.Context()); err != nil && !errors.Is(err, pgx.ErrTxClosed) {
slog.Error("failed to rollback transaction", "err", err)
}
}()
// Delete existing applications for this group
_, err = tx.Exec(r.Context(), `
DELETE FROM exceptional_group_applications WHERE group_id=$1
`, req.GroupID)
if err != nil {
http.Error(w, "failed to delete existing applications", http.StatusInternalServerError)
return
}
// Insert new applications
if _, err := tx.Exec(r.Context(), `
INSERT INTO exceptional_group_applications (group_id, week_start)
SELECT $1, unnest($2::date[])
`, req.GroupID, parsedWeeks); err != nil {
http.Error(w, "failed to insert applications", http.StatusInternalServerError)
return
}
if err := tx.Commit(r.Context()); err != nil {
http.Error(w, "failed to commit transaction", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Response types mirroring bookings.OverlappingBooking, bookings.OverlappingBookingsResponse, and bookings.UserSummary
// (defined locally to avoid circular import: bookings → scheduling → bookings)
type (
UserSummary struct {
ID string `json:"id"`
FullName string `json:"full_name"`
Email *string `json:"email,omitempty"`
Phone *string `json:"phone,omitempty"`
PreviousFirstName *string `json:"previous_first_name,omitempty"`
PreviousLastName *string `json:"previous_last_name,omitempty"`
}
OverlappingBooking struct {
ID string `json:"id"`
StartTime time.Time `json:"start_time"`
Duration int `json:"duration_minutes"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
User *UserSummary `json:"user,omitempty"`
Services []string `json:"services,omitempty"`
}
OverlappingBookingsResponse struct {
Bookings []OverlappingBooking `json:"bookings"`
}
ConflictingBookingsRequest struct {
WeekStarts []string `json:"weekStarts" validate:"required,max=52,dive,required"`
ProposedHours []ExceptionalHours `json:"proposedHours" validate:"required,len=7,dive"`
}
)
// GetConflictingBookingsForExceptionHandler accepts POST with proposed exceptional hours
// and week starts, queries active bookings that would conflict (bookings that overlap
// with the affected weeks but fall OUTSIDE the proposed open hours), and returns them
// in the existing OverlappingBookingsResponse format.
func GetConflictingBookingsForExceptionHandler(w http.ResponseWriter, r *http.Request) {
var req ConflictingBookingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
if err := validators.Validate.Struct(&req); err != nil {
log.Printf("Failed to process request: %v", err)
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if len(req.ProposedHours) != 7 {
http.Error(w, "must provide exactly 7 weekday entries (0-6)", http.StatusBadRequest)
return
}
var allConflicts []OverlappingBooking
// Build weekday map for robust lookup (not relying on array index = weekday)
proposedByWeekday := map[int]ExceptionalHours{}
for _, h := range req.ProposedHours {
proposedByWeekday[h.Weekday] = h
}
for _, weekStart := range req.WeekStarts {
ws, err := time.Parse("2006-01-02", weekStart)
if err != nil {
log.Printf("Invalid week_start format: %v", err)
http.Error(w, "invalid week_start format, expected YYYY-MM-DD", http.StatusBadRequest)
return
}
wsLondon := ws.In(londonLocation)
rangeStart := time.Date(wsLondon.Year(), wsLondon.Month(), wsLondon.Day(), 0, 0, 0, 0, londonLocation)
rangeEnd := rangeStart.AddDate(0, 0, 7).Add(-time.Nanosecond) // Sun 23:59:59.999999999
rows, err := db.Conn.Query(r.Context(), `
SELECT
b.id,
b.start_time,
b.status,
b.created_at,
b.total_duration_minutes as duration,
u.id as user_id,
u.fn,
u.email,
u.phone
FROM bookings b
LEFT JOIN users u ON b.user_id = u.id
WHERE `+ActiveBookingStatuses+`
AND b.start_time < $2
AND b.end_time > $1
ORDER BY b.start_time ASC
`, rangeStart, rangeEnd)
if err != nil {
log.Printf("Failed to query conflicting bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
for rows.Next() {
var ob OverlappingBooking
ob.User = &UserSummary{}
if err := rows.Scan(&ob.ID, &ob.StartTime, &ob.Status, &ob.CreatedAt, &ob.Duration, &ob.User.ID, &ob.User.FullName, &ob.User.Email, &ob.User.Phone); err != nil {
log.Printf("Failed to scan conflicting booking row: %v", err)
rows.Close()
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
// Determine which weekday this booking falls on (London wall-clock time)
bookingLondon := ob.StartTime.In(londonLocation)
// Go: Sun=0,Mon=1,...,Sat=6 → Our convention: Mon=0,...,Sun=6
ourWeekday := int((bookingLondon.Weekday() + 6) % 7)
proposed := proposedByWeekday[ourWeekday]
isConflict := false
if !proposed.IsOpen {
// Salon is closed on this day — any active booking conflicts
isConflict = true
} else {
startMinutes := bookingLondon.Hour()*60 + bookingLondon.Minute()
endLondon := ob.StartTime.Add(time.Duration(ob.Duration) * time.Minute).In(londonLocation)
endMinutes := endLondon.Hour()*60 + endLondon.Minute()
propStartMinutes := parseTimeToMinutes(proposed.StartTime)
propEndMinutes := parseTimeToMinutes(proposed.EndTime)
// Check if booking starts before opening or ends after closing.
// For midnight-crossing bookings (endMinutes < startMinutes), the booking
// extends past midnight and always conflicts with daily hours since the
// day's open window cannot span past midnight.
switch {
case startMinutes < 0 || endMinutes < 0:
// parse error — treat as conflict
isConflict = true
case endMinutes < startMinutes:
// Booking crosses midnight — always a conflict with daily hours
isConflict = true
case startMinutes < propStartMinutes || endMinutes > propEndMinutes:
isConflict = true
}
}
if isConflict {
allConflicts = append(allConflicts, ob)
}
}
rows.Close()
if err := rows.Err(); err != nil {
log.Printf("Error iterating conflicting bookings: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
}
if len(allConflicts) > 0 {
ids := make([]string, len(allConflicts))
for i, ob := range allConflicts {
ids[i] = ob.ID
}
serviceRows, err := db.Conn.Query(r.Context(), `
SELECT booking_id, name FROM (
SELECT bs.booking_id, s.name
FROM booking_services bs
JOIN services s ON bs.service_id = s.id
WHERE bs.booking_id = ANY($1)
UNION ALL
SELECT bcs.booking_id, cs.name
FROM booking_custom_services bcs
JOIN custom_services cs ON bcs.custom_service_id = cs.id
WHERE bcs.booking_id = ANY($1)
) sub ORDER BY booking_id, name
`, ids)
if err != nil {
log.Printf("Failed to batch-load services for conflicting bookings: %v", err)
} else {
serviceMap := map[string][]string{}
for serviceRows.Next() {
var bookingID, name string
if err := serviceRows.Scan(&bookingID, &name); err != nil {
log.Printf("Failed to scan service row: %v", err)
continue
}
serviceMap[bookingID] = append(serviceMap[bookingID], name)
}
serviceRows.Close()
if err := serviceRows.Err(); err != nil {
log.Printf("Error iterating services for conflicting bookings: %v", err)
}
for i, ob := range allConflicts {
allConflicts[i].Services = serviceMap[ob.ID]
}
}
}
if allConflicts == nil {
allConflicts = []OverlappingBooking{}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(OverlappingBookingsResponse{Bookings: allConflicts}); err != nil {
log.Printf("Failed to encode conflicting bookings response: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// parseTimeToMinutes converts a "HH:MM" string to minutes since midnight.
// Returns -1 on parse failure.
func parseTimeToMinutes(t string) int {
parts := strings.Split(t, ":")
if len(parts) < 2 {
return -1
}
h, err1 := strconv.Atoi(parts[0])
m, err2 := strconv.Atoi(parts[1])
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return -1
}
return h*60 + m
}