diff --git a/main.go b/main.go index 89da688..302a218 100644 --- a/main.go +++ b/main.go @@ -68,11 +68,15 @@ func main() { http.HandleFunc("GET /trip/{tripID}", serveHTML("trip.html")) http.HandleFunc("GET /trip.js", serveJS("trip.js")) http.HandleFunc("GET /api/trips/{tripID}", handleGetTrip(db)) + http.HandleFunc("PATCH /api/trips/{tripID}", handleUpdateTrip(db)) http.HandleFunc("GET /api/trips/{tripID}/students", handleListStudents(db)) http.HandleFunc("POST /api/trips/{tripID}/students", handleCreateStudent(db)) http.HandleFunc("DELETE /api/trips/{tripID}/students/{studentID}", handleDeleteStudent(db)) http.HandleFunc("POST /api/trips/{tripID}/students/{studentID}/parents", handleAddParent(db)) http.HandleFunc("DELETE /api/trips/{tripID}/students/{studentID}/parents/{parentID}", handleRemoveParent(db)) + http.HandleFunc("GET /api/trips/{tripID}/constraints", handleListConstraints(db)) + http.HandleFunc("POST /api/trips/{tripID}/constraints", handleCreateConstraint(db)) + http.HandleFunc("DELETE /api/trips/{tripID}/constraints/{constraintID}", handleDeleteConstraint(db)) http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { if err := db.Ping(); err != nil { http.Error(w, "db unhealthy", http.StatusServiceUnavailable) @@ -232,13 +236,13 @@ func handleListTrips(db *sql.DB) http.HandlerFunc { return } rows, err := db.Query(` - SELECT t.id, t.name, COALESCE( + SELECT t.id, t.name, t.room_size, COALESCE( json_agg(json_build_object('id', ta.id, 'email', ta.email)) FILTER (WHERE ta.id IS NOT NULL), '[]' ) FROM trips t LEFT JOIN trip_admins ta ON ta.trip_id = t.id - GROUP BY t.id, t.name + GROUP BY t.id, t.name, t.room_size ORDER BY t.id`) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) @@ -251,16 +255,17 @@ func handleListTrips(db *sql.DB) http.HandlerFunc { Email string `json:"email"` } type trip struct { - ID int64 `json:"id"` - Name string `json:"name"` - Admins []tripAdmin `json:"admins"` + ID int64 `json:"id"` + Name string `json:"name"` + RoomSize int `json:"room_size"` + Admins []tripAdmin `json:"admins"` } var trips []trip for rows.Next() { var t trip var adminsJSON string - if err := rows.Scan(&t.ID, &t.Name, &adminsJSON); err != nil { + if err := rows.Scan(&t.ID, &t.Name, &t.RoomSize, &adminsJSON); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -379,13 +384,14 @@ func handleGetTrip(db *sql.DB) http.HandlerFunc { return } var name string - err := db.QueryRow("SELECT name FROM trips WHERE id = $1", tripID).Scan(&name) + var roomSize int + err := db.QueryRow("SELECT name, room_size FROM trips WHERE id = $1", tripID).Scan(&name, &roomSize) if err != nil { http.Error(w, "trip not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{"id": tripID, "name": name}) + json.NewEncoder(w).Encode(map[string]any{"id": tripID, "name": name, "room_size": roomSize}) } } @@ -543,3 +549,138 @@ func handleRemoveParent(db *sql.DB) http.HandlerFunc { w.WriteHeader(http.StatusNoContent) } } + +func handleUpdateTrip(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, tripID, ok := requireTripAdmin(db, w, r) + if !ok { + return + } + var body struct { + RoomSize int `json:"room_size"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RoomSize < 1 { + http.Error(w, "room_size must be at least 1", http.StatusBadRequest) + return + } + _, err := db.Exec("UPDATE trips SET room_size = $1 WHERE id = $2", body.RoomSize, tripID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func handleListConstraints(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, tripID, ok := requireTripAdmin(db, w, r) + if !ok { + return + } + rows, err := db.Query(` + SELECT rc.id, rc.student_a_id, sa.name, rc.student_b_id, sb.name, rc.kind::text, rc.level::text + FROM roommate_constraints rc + JOIN students sa ON sa.id = rc.student_a_id + JOIN students sb ON sb.id = rc.student_b_id + WHERE sa.trip_id = $1 + ORDER BY rc.id`, tripID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer rows.Close() + + type constraint struct { + ID int64 `json:"id"` + StudentAID int64 `json:"student_a_id"` + StudentAName string `json:"student_a_name"` + StudentBID int64 `json:"student_b_id"` + StudentBName string `json:"student_b_name"` + Kind string `json:"kind"` + Level string `json:"level"` + } + + var constraints []constraint + for rows.Next() { + var c constraint + if err := rows.Scan(&c.ID, &c.StudentAID, &c.StudentAName, &c.StudentBID, &c.StudentBName, &c.Kind, &c.Level); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + constraints = append(constraints, c) + } + if constraints == nil { + constraints = []constraint{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(constraints) + } +} + +func handleCreateConstraint(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, tripID, ok := requireTripAdmin(db, w, r) + if !ok { + return + } + var body struct { + StudentAID int64 `json:"student_a_id"` + StudentBID int64 `json:"student_b_id"` + Kind string `json:"kind"` + Level string `json:"level"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + if body.StudentAID == body.StudentBID { + http.Error(w, "students must be different", http.StatusBadRequest) + return + } + a, b := body.StudentAID, body.StudentBID + if a > b { + a, b = b, a + } + var id int64 + err := db.QueryRow(` + INSERT INTO roommate_constraints (student_a_id, student_b_id, kind, level) + SELECT $1, $2, $3::constraint_kind, $4::constraint_level + FROM students sa + JOIN students sb ON sb.id = $2 AND sb.trip_id = $5 + WHERE sa.id = $1 AND sa.trip_id = $5 + ON CONFLICT (student_a_id, student_b_id, level) DO UPDATE SET kind = EXCLUDED.kind + RETURNING id`, a, b, body.Kind, body.Level, tripID).Scan(&id) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"id": id}) + } +} + +func handleDeleteConstraint(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _, tripID, ok := requireTripAdmin(db, w, r) + if !ok { + return + } + constraintID, err := strconv.ParseInt(r.PathValue("constraintID"), 10, 64) + if err != nil { + http.Error(w, "invalid constraint ID", http.StatusBadRequest) + return + } + result, err := db.Exec(`DELETE FROM roommate_constraints WHERE id = $1 + AND student_a_id IN (SELECT id FROM students WHERE trip_id = $2)`, constraintID, tripID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if n, _ := result.RowsAffected(); n == 0 { + http.Error(w, "constraint not found", http.StatusNotFound) + return + } + w.WriteHeader(http.StatusNoContent) + } +} diff --git a/schema.sql b/schema.sql index dd6d95c..98d12f9 100644 --- a/schema.sql +++ b/schema.sql @@ -1,5 +1,8 @@ +DROP TABLE IF EXISTS roommate_constraints; +DROP TYPE IF EXISTS constraint_kind; + DO $$ BEGIN - CREATE TYPE constraint_kind AS ENUM ('happy', 'ok', 'never'); + CREATE TYPE constraint_kind AS ENUM ('must', 'prefer', 'prefer_not', 'must_not'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; @@ -13,6 +16,8 @@ CREATE TABLE IF NOT EXISTS trips ( name TEXT NOT NULL ); +ALTER TABLE trips ADD COLUMN IF NOT EXISTS room_size INTEGER NOT NULL DEFAULT 2; + CREATE TABLE IF NOT EXISTS trip_admins ( id BIGSERIAL PRIMARY KEY, trip_id BIGINT NOT NULL REFERENCES trips(id) ON DELETE CASCADE, @@ -41,5 +46,6 @@ CREATE TABLE IF NOT EXISTS roommate_constraints ( student_b_id BIGINT NOT NULL REFERENCES students(id) ON DELETE CASCADE, kind constraint_kind NOT NULL, level constraint_level NOT NULL, + CHECK(student_a_id < student_b_id), UNIQUE(student_a_id, student_b_id, level) ); diff --git a/static/app.js b/static/app.js index 0ddfdf1..abebf2d 100644 --- a/static/app.js +++ b/static/app.js @@ -56,6 +56,7 @@ export async function api(method, path, body) { if (!res.ok) { throw new Error(await res.text()); } + if (res.status === 204) return null; return res.json(); } diff --git a/static/trip.html b/static/trip.html index 1e2c0ed..97d9045 100644 --- a/static/trip.html +++ b/static/trip.html @@ -54,6 +54,12 @@ padding: 0 0.2rem; } .input-action:hover { opacity: 1; } + #trip-settings { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem; } + #room-size { width: 3.5rem; font-size: 0.85rem; padding: 0.2rem; border: 1px solid var(--wa-color-neutral-300, #ccc); border-radius: 0.25rem; } + .constraint-group { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; margin-bottom: 0.2rem; } + .constraint-level { font-size: 0.7rem; font-weight: bold; opacity: 0.6; } + .constraint-add { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.3rem; } + .constraint-add select { font-size: 0.75rem; padding: 0.15rem; border: 1px solid var(--wa-color-neutral-300, #ccc); border-radius: 0.25rem; }
@@ -66,6 +72,10 @@