a4fbd60441
The core of #4, and what #1 is for: terdut stops being one shared space. A team owns its incidents, alerts, schedule and integrations; a user sees exactly the teams they are in. Everything that existed moves into one Default team and every existing user becomes an owner of it, so the upgrade is a no-op for the people using it. Ingestion is the load-bearing half. An alert arrives on a team's integration key, and the key is both the credential and the routing: it says that the sender may post, and which team the alerts belong to. That also closes the unauthenticated webhook -- the old path stays for one release, deprecated and routed to the oldest team, so an upgrade does not stop delivering while somebody edits the Alertmanager config. Scoping is enforced in as few places as possible, because the failure mode is silent. serveAs loads the caller's memberships once; list queries carry `team_id = ANY(...)`; and every incident route goes through incidentIDParam, which now parses the id AND checks the team in the same call, so a new handler cannot remember the first half and forget the second. Anything in another team is 404, never 403: whether an incident exists is that team's business. Two bugs this found, both of which would have been silent: * upsertAlerts decided "is this a new occurrence" by looking up the fingerprint alone. Across teams that made team B's first alert look like a re-send of team A's, so it opened no incident at all. The lookups are keyed on (team_id, fingerprint) now, as the index is. * Every uniqueness rule was written for one tenant. Two teams watching two clusters legitimately see the same fingerprint, the same groupKey, and want somebody on call on the same day; all three constraints move to include team_id. Roles inside a team are separate from the system administrator flag: an owner configures the team, a member works its incidents, and an admin is NOT implicitly in every team -- administration is about accounts, not about reading other people's incidents. An admin can still repair a team whose owner has left, which is why requireTeamOwner lets them through. A shift can only be given to somebody in the team. Paging a person who cannot open the incident is worse than paging nobody. The UI is updated only as far as keeping it working: it loads the viewer's teams with the session and uses the first one, since nobody has a second yet. "On call now" shows every team the viewer is in, named only when there is more than one, so the common case reads exactly as before. The team switcher, badges and per-team settings pages are the next step. Breaking for API clients: the schedule endpoints moved under the team, and /api/schedule/current returns an array rather than an object or a 404. terdut-tui will need a version for that. Per-team dead-man configuration is deliberately not here. A heartbeat's incident already opens in the team whose key received it, which is the part that matters for isolation; moving the matchers out of env into per-team rows is a change to how deadman.go is configured rather than to who sees what. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
262 lines
7.8 KiB
Go
262 lines
7.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/models"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// The schedule is per team: each team keeps its own rota, so two teams can have
|
|
// two different people on call on the same day. Editing it is an owner's job,
|
|
// like the rest of a team's configuration; reading it is any member's.
|
|
func handleCreateSchedule(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
// The person taking the shift has to be in the team: paging somebody
|
|
// who cannot open the incident is worse than paging nobody.
|
|
var exists int
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2",
|
|
teamID, req.UserID).Scan(&exists); err != nil {
|
|
respond(w, http.StatusNotFound, errResp("user is not a member of this team"))
|
|
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 team_id = $1 AND date = $2",
|
|
teamID, d); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(r.Context(),
|
|
"INSERT INTO schedule_entries (team_id, user_id, date) VALUES ($1, $2, $3)",
|
|
teamID, req.UserID, d); err != nil {
|
|
if isUniqueViolation(err) {
|
|
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, teamID, 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) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamMember(w, r, teamID) {
|
|
return
|
|
}
|
|
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, teamID, 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) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
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 = $1 AND team_id = $2", id, teamID)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// handleCurrentSchedule answers "who is on call right now" for every team the
|
|
// caller belongs to — one entry per team, so somebody on two rotas sees both.
|
|
// A team with nobody scheduled today simply does not appear.
|
|
func handleCurrentSchedule(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
today := time.Now().UTC().Format("2006-01-02")
|
|
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
|
FROM schedule_entries s
|
|
JOIN users u ON u.id = s.user_id
|
|
JOIN teams t ON t.id = s.team_id
|
|
WHERE s.date = $1 AND s.team_id = ANY($2)
|
|
ORDER BY t.name`, today, callerTeamIDs(r.Context()))
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
entries := []models.ScheduleEntry{}
|
|
for rows.Next() {
|
|
var e models.ScheduleEntry
|
|
var ts int64
|
|
if err := rows.Scan(&e.ID, &e.TeamID, &e.TeamName, &e.UserID, &e.Username, &e.Date, &ts); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
e.CreatedAt = time.Unix(ts, 0).UTC()
|
|
entries = append(entries, e)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, entries)
|
|
}
|
|
}
|
|
|
|
// 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, teamID int64, from, to string) ([]models.ScheduleEntry, error) {
|
|
args := &sqlArgs{}
|
|
where := []string{"s.team_id = " + args.add(teamID)}
|
|
if from != "" {
|
|
where = append(where, "s.date >= "+args.add(from))
|
|
}
|
|
if to != "" {
|
|
where = append(where, "s.date <= "+args.add(to))
|
|
}
|
|
|
|
clause := strings.Join(where, " AND ")
|
|
|
|
rows, err := db.QueryContext(ctx, `
|
|
SELECT s.id, s.team_id, t.name, s.user_id, u.username, s.date, s.created_at
|
|
FROM schedule_entries s
|
|
JOIN users u ON u.id = s.user_id
|
|
JOIN teams t ON t.id = s.team_id
|
|
WHERE `+clause+`
|
|
ORDER BY s.date ASC`, args.all()...)
|
|
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.TeamID, &e.TeamName, &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()
|
|
}
|