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() }