diff --git a/internal/api/router.go b/internal/api/router.go index b3cc41b..26cbf82 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -38,6 +38,11 @@ func NewRouter(db *sql.DB) http.Handler { r.Get("/api/alerts/{id}/comments", handleListComments(db)) r.Post("/api/alerts/{id}/comments", handleCreateComment(db)) r.Delete("/api/alerts/{id}/comments/{commentID}", handleDeleteComment(db)) + + r.Post("/api/schedule", handleCreateSchedule(db)) + r.Get("/api/schedule/current", handleCurrentSchedule(db)) // must be before /{id} + r.Get("/api/schedule", handleListSchedule(db)) + r.Delete("/api/schedule/{id}", handleDeleteSchedule(db)) }) return r diff --git a/internal/api/schedule.go b/internal/api/schedule.go new file mode 100644 index 0000000..1177aee --- /dev/null +++ b/internal/api/schedule.go @@ -0,0 +1,203 @@ +package api + +import ( + "context" + "database/sql" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/yeniklas/terdut-server/internal/models" +) + +func handleCreateSchedule(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var req struct { + UserID int64 `json:"user_id"` + Dates []string `json:"dates"` + } + if err := decodeJSON(r, &req); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid request body")) + return + } + if req.UserID == 0 { + respond(w, http.StatusBadRequest, errResp("user_id is required")) + return + } + if len(req.Dates) == 0 { + respond(w, http.StatusBadRequest, errResp("dates must not be empty")) + return + } + for _, d := range req.Dates { + if _, err := time.Parse("2006-01-02", d); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid date: "+d+" (expected YYYY-MM-DD)")) + return + } + } + + // Verify the user exists. + var exists int + if err := db.QueryRowContext(r.Context(), "SELECT 1 FROM users WHERE id = ?", req.UserID).Scan(&exists); err != nil { + respond(w, http.StatusNotFound, errResp("user not found")) + return + } + + // All-or-nothing: if any date already has an assignment, reject the whole request. + tx, err := db.BeginTx(r.Context(), nil) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + defer tx.Rollback() + + for _, d := range req.Dates { + if _, err := tx.ExecContext(r.Context(), + "INSERT INTO schedule_entries (user_id, date) VALUES (?, ?)", req.UserID, d); err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint failed") { + respond(w, http.StatusConflict, errResp("date already assigned: "+d)) + return + } + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + } + if err := tx.Commit(); err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + + // Return the newly created entries. + dateSet := make(map[string]bool, len(req.Dates)) + for _, d := range req.Dates { + dateSet[d] = true + } + all, err := scheduleRange(r.Context(), db, req.Dates[0], req.Dates[len(req.Dates)-1]) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + created := []models.ScheduleEntry{} + for _, e := range all { + if dateSet[e.Date] { + created = append(created, e) + } + } + respond(w, http.StatusCreated, created) + } +} + +func handleListSchedule(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + from, to := q.Get("from"), q.Get("to") + + if from != "" { + if _, err := time.Parse("2006-01-02", from); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid from date (expected YYYY-MM-DD)")) + return + } + } + if to != "" { + if _, err := time.Parse("2006-01-02", to); err != nil { + respond(w, http.StatusBadRequest, errResp("invalid to date (expected YYYY-MM-DD)")) + return + } + } + + entries, err := scheduleRange(r.Context(), db, from, to) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + respond(w, http.StatusOK, entries) + } +} + +func handleDeleteSchedule(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + respond(w, http.StatusBadRequest, errResp("invalid schedule id")) + return + } + res, err := db.ExecContext(r.Context(), "DELETE FROM schedule_entries WHERE id = ?", id) + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + if n, _ := res.RowsAffected(); n == 0 { + respond(w, http.StatusNotFound, errResp("schedule entry not found")) + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func handleCurrentSchedule(db *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + today := time.Now().UTC().Format("2006-01-02") + + var e models.ScheduleEntry + var ts int64 + err := db.QueryRowContext(r.Context(), ` + SELECT s.id, s.user_id, u.username, s.date, s.created_at + FROM schedule_entries s + JOIN users u ON u.id = s.user_id + WHERE s.date = ?`, today).Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts) + if err == sql.ErrNoRows { + respond(w, http.StatusNotFound, errResp("no one is on call today")) + return + } + if err != nil { + respond(w, http.StatusInternalServerError, errResp("internal error")) + return + } + e.CreatedAt = time.Unix(ts, 0).UTC() + respond(w, http.StatusOK, e) + } +} + +// scheduleRange returns schedule entries ordered by date. +// from and to are YYYY-MM-DD strings; an empty string means unbounded on that side. +func scheduleRange(ctx context.Context, db *sql.DB, from, to string) ([]models.ScheduleEntry, error) { + where := []string{} + args := []any{} + if from != "" { + where = append(where, "s.date >= ?") + args = append(args, from) + } + if to != "" { + where = append(where, "s.date <= ?") + args = append(args, to) + } + + clause := "1=1" + if len(where) > 0 { + clause = strings.Join(where, " AND ") + } + + rows, err := db.QueryContext(ctx, ` + SELECT s.id, s.user_id, u.username, s.date, s.created_at + FROM schedule_entries s + JOIN users u ON u.id = s.user_id + WHERE `+clause+` + ORDER BY s.date ASC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + entries := []models.ScheduleEntry{} + for rows.Next() { + var e models.ScheduleEntry + var ts int64 + if err := rows.Scan(&e.ID, &e.UserID, &e.Username, &e.Date, &ts); err != nil { + return nil, err + } + e.CreatedAt = time.Unix(ts, 0).UTC() + entries = append(entries, e) + } + return entries, rows.Err() +} diff --git a/internal/db/migrations/005_schedule.sql b/internal/db/migrations/005_schedule.sql new file mode 100644 index 0000000..15cdc16 --- /dev/null +++ b/internal/db/migrations/005_schedule.sql @@ -0,0 +1,8 @@ +CREATE TABLE schedule_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + date TEXT NOT NULL UNIQUE, -- YYYY-MM-DD; one person per day + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); + +CREATE INDEX schedule_entries_date_idx ON schedule_entries(date); diff --git a/internal/models/schedule.go b/internal/models/schedule.go new file mode 100644 index 0000000..e85d9bd --- /dev/null +++ b/internal/models/schedule.go @@ -0,0 +1,11 @@ +package models + +import "time" + +type ScheduleEntry struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Username string `json:"username"` + Date string `json:"date"` // YYYY-MM-DD + CreatedAt time.Time `json:"created_at"` +}