289eca8076
CI / test (push) Successful in 2m15s
The module path, the container image, the Helm chart and the CI pipeline all named GitHub. They now name the Gitea instance everything else already runs on. The workflows are rewritten rather than translated. Gitea's runner image is ubuntu:22.04, whose nodejs is Node 12, so no JS action runs there at all -- actions/checkout@v4 dies with a SyntaxError before it does anything. Every step is shell, checkout is a plain clone (this repo is public, so it needs no credential), and the jobs that need docker or helm run in host mode because the dind bridge a `container:` job gets cannot reach github.com or get.helm.sh. Two consequences worth naming: - upload-artifact/download-artifact are also JS actions, and there is no artifact store here, so the job that builds the binaries is the job that publishes them. Nothing is passed between jobs. - setup-qemu-action is gone with the rest, and the runner has no binfmt registration. The Dockerfile's builder stage now runs on $BUILDPLATFORM and cross-compiles from TARGETARCH instead, which is what keeps the arm64 image buildable -- and makes it native rather than emulated. The chart moves from a GitHub Pages index to an OCI artifact in Gitea's registry. Publishing stays tag-only for the reason recorded in release.yaml: a workflow triggered by the branch push cannot know the version it is about to be tagged with. The GitHub repository is left in place and untouched. Nothing pushes to it any more, but its existing release downloads and chart index keep resolving.
221 lines
6.3 KiB
Go
221 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"git.ryuvia.com/niklas/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()
|
|
}
|