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