Files
terdut-server/internal/api/schedule.go
T
Niklas Ye e5916d522a
Release / build (amd64, darwin) (push) Has been skipped
Release / build (arm64, darwin) (push) Has been skipped
Release / docker (push) Has been skipped
Release / release (push) Has been skipped
Release / test (push) Failing after 6s
Release / build (amd64, linux) (push) Has been skipped
Release / build (arm64, linux) (push) Has been skipped
Release / chart (push) Has been skipped
Let an on-call day be handed to somebody else
A date is held by exactly one person and POST /api/schedule plain-inserts,
so any date that was already taken came back 409. That made reassignment
impossible through the API: the only route was to delete the entry first,
and for a week that meant seven separate deletions. Worse, the reject is
all-or-nothing across the request, so assigning a week where a single day
happened to be taken failed entirely and placed none of the other six.

The refusal itself is worth keeping. Moving a shift off the person
expecting to be paged for it should not be something a plain call does by
accident, so the fix is to make it possible to ask for rather than to
remove the guard: "replace": true takes the dates anyway, and the flag
defaults to off so every existing caller behaves exactly as before.

The delete and the insert share the transaction that was already there.
That matters more than the flag does — a week of free and taken days now
lands as a unit, and a failure part way through leaves the rota as it was
instead of with a shift deleted and nothing put back. A rota with a hole
in it is worse than a rota that refused to change.

One consequence worth naming: under replace a date repeated inside one
request is idempotent rather than a conflict, because the second pass
clears what the first wrote.
2026-08-07 14:04:11 +02:00

221 lines
6.3 KiB
Go

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"`
// Replace takes dates that somebody else already holds. It defaults
// to off so that the plain call cannot quietly move a shift off the
// person expecting to be paged for it — reassigning has to be asked
// for.
Replace bool `json:"replace"`
}
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, in both directions: without replace, one taken date
// rejects the whole request; with it, either every date moves or none
// does. The rota must never be left with a hole where a shift used to
// be, so the delete and the insert share one transaction.
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 req.Replace {
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM schedule_entries WHERE date = ?", d); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
}
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+" (pass replace to take it)"))
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()
}