Files
Crussell/backend/handlers/scheduling/exceptional-hours.go
T
popertotsandSisyphus e4b9003439 refactor(handlers): migrate remaining backend handlers to clock.Now() and transaction patterns
Apply clock.Now() migration, transaction wrapping, and minor refactors across admin, scheduling, today, user, auth handler, notifications, webhooks, services, portfolio, ratelimit, testutils, and main.go.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 23:43:50 +01:00

350 lines
9.8 KiB
Go

package scheduling
import (
"encoding/json"
"net/http"
"strconv"
"time"
"crussell/db"
"crussell/internal/validators"
)
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,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
}
}
json.NewEncoder(w).Encode(groups)
}
// --- 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 {
http.Error(w, err.Error(), 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 tx.Rollback(r.Context())
// 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)
json.NewEncoder(w).Encode(g)
}
// --- 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 tx.Rollback(r.Context())
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,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 {
http.Error(w, err.Error(), 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 tx.Rollback(r.Context())
// 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)
}