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 @@ Switch User

+
+ + +
diff --git a/static/trip.js b/static/trip.js index 88d9ee7..54cbd49 100644 --- a/static/trip.js +++ b/static/trip.js @@ -14,15 +14,31 @@ try { } document.getElementById('trip-name').textContent = trip.name; +document.getElementById('room-size').value = trip.room_size; document.getElementById('main').style.display = 'block'; document.getElementById('logout-btn').addEventListener('click', logout); +document.getElementById('room-size').addEventListener('change', async () => { + const size = parseInt(document.getElementById('room-size').value); + if (size >= 1) await api('PATCH', '/api/trips/' + tripID, { room_size: size }); +}); async function loadStudents() { - const students = await api('GET', '/api/trips/' + tripID + '/students'); + const [students, constraints] = await Promise.all([ + api('GET', '/api/trips/' + tripID + '/students'), + api('GET', '/api/trips/' + tripID + '/constraints') + ]); const container = document.getElementById('students'); + const openStates = {}; + for (const card of container.children) { + const sid = card.dataset.studentId; + if (!sid) continue; + openStates[sid] = {}; + for (const det of card.querySelectorAll('wa-details')) openStates[sid][det.summary] = det.open; + } container.innerHTML = ''; for (const student of students) { const card = document.createElement('wa-card'); + card.dataset.studentId = student.id; const nameRow = document.createElement('div'); nameRow.style.display = 'flex'; @@ -84,6 +100,99 @@ async function loadStudents() { card.appendChild(details); + const cDetails = document.createElement('wa-details'); + cDetails.summary = 'Constraints'; + + const kindVariant = { must: 'success', prefer: 'brand', prefer_not: 'warning', must_not: 'danger' }; + const kindLabels = { must: 'Must', prefer: 'Prefer', prefer_not: 'Prefer Not', must_not: 'Must Not' }; + const kindOrder = { must: 0, prefer: 1, prefer_not: 2, must_not: 3 }; + const myConstraints = constraints.filter(c => c.student_a_id === student.id || c.student_b_id === student.id); + + for (const level of ['admin', 'parent', 'student']) { + const lc = myConstraints.filter(c => c.level === level); + if (lc.length === 0) continue; + lc.sort((a, b) => { + const kd = kindOrder[a.kind] - kindOrder[b.kind]; + if (kd !== 0) return kd; + const na = a.student_a_id === student.id ? a.student_b_name : a.student_a_name; + const nb = b.student_a_id === student.id ? b.student_b_name : b.student_a_name; + return na.localeCompare(nb); + }); + const group = document.createElement('div'); + group.className = 'constraint-group'; + const levelLabel = document.createElement('span'); + levelLabel.className = 'constraint-level'; + levelLabel.textContent = level.charAt(0).toUpperCase() + level.slice(1); + group.appendChild(levelLabel); + for (const c of lc) { + const otherName = c.student_a_id === student.id ? c.student_b_name : c.student_a_name; + const tag = document.createElement('wa-tag'); + tag.size = 'small'; + tag.variant = kindVariant[c.kind]; + tag.setAttribute('with-remove', ''); + tag.textContent = kindLabels[c.kind] + ': ' + otherName; + tag.addEventListener('wa-remove', async () => { + await api('DELETE', '/api/trips/' + tripID + '/constraints/' + c.id); + loadStudents(); + }); + group.appendChild(tag); + } + cDetails.appendChild(group); + } + + const addRow = document.createElement('div'); + addRow.className = 'constraint-add'; + const studentSelect = document.createElement('select'); + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = 'Student\u2026'; + studentSelect.appendChild(defaultOpt); + for (const other of students) { + if (other.id === student.id) continue; + const opt = document.createElement('option'); + opt.value = other.id; + opt.textContent = other.name; + studentSelect.appendChild(opt); + } + const kindSelect = document.createElement('select'); + for (const kind of ['must', 'prefer', 'prefer_not', 'must_not']) { + const opt = document.createElement('option'); + opt.value = kind; + opt.textContent = kindLabels[kind]; + kindSelect.appendChild(opt); + } + const levelSelect = document.createElement('select'); + for (const level of ['student', 'parent', 'admin']) { + const opt = document.createElement('option'); + opt.value = level; + opt.textContent = level.charAt(0).toUpperCase() + level.slice(1); + levelSelect.appendChild(opt); + } + const cAddBtn = document.createElement('button'); + cAddBtn.className = 'input-action'; + cAddBtn.textContent = '+'; + cAddBtn.addEventListener('click', async () => { + const otherID = parseInt(studentSelect.value); + if (!otherID) return; + await api('POST', '/api/trips/' + tripID + '/constraints', { + student_a_id: student.id, + student_b_id: otherID, + kind: kindSelect.value, + level: levelSelect.value + }); + loadStudents(); + }); + addRow.appendChild(studentSelect); + addRow.appendChild(kindSelect); + addRow.appendChild(levelSelect); + addRow.appendChild(cAddBtn); + cDetails.appendChild(addRow); + + card.appendChild(cDetails); + + const saved = openStates[student.id]; + if (saved) for (const det of card.querySelectorAll('wa-details')) if (saved[det.summary]) det.open = true; + container.appendChild(card); } }