Files
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

274 lines
8.9 KiB
Go

package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// The notifications inbox. Rows are written by internal/notifications;
// this surface lists them, counts the unread, marks them read, and holds each
// user's per-kind settings.
const (
notificationsDefaultLimit = 30
notificationsMaxLimit = 100
)
// notificationResp is one inbox row, rendered server-side so every client
// says the same thing (notifications.Render).
type notificationResp struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body string `json:"body"`
Link string `json:"link"`
CreatedAt time.Time `json:"created_at"`
ReadAt *time.Time `json:"read_at"`
}
type notificationsPageResp struct {
Items []notificationResp `json:"items"`
UnreadCount int64 `json:"unread_count"`
// NextBefore is the cursor for the next page, absent on the last one.
NextBefore string `json:"next_before,omitempty"`
}
type unreadCountResp struct {
UnreadCount int64 `json:"unread_count"`
}
// notificationSettingsResp is every kind the caller can receive, plus whether
// email can be delivered at all, so a client can say why before anyone tries.
type notificationSettingsResp struct {
Kinds []notifications.KindSetting `json:"kinds"`
EmailAvailable bool `json:"email_available"`
// EmailUnavailableReason is "no_address" or "smtp_not_configured" when
// EmailAvailable is false.
EmailUnavailableReason string `json:"email_unavailable_reason,omitempty"`
}
type notificationSettingsReq struct {
Kinds []notifications.SettingChange `json:"kinds"`
}
// handleListMyNotifications implements GET /api/me/notifications?limit&before.
func (h *handlers) handleListMyNotifications(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
limit := notificationsDefaultLimit
if raw := r.URL.Query().Get("limit"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_limit", "limit must be a positive integer"))
return
}
limit = min(n, notificationsMaxLimit)
}
params := dbq.ListNotificationsParams{UserID: user.ID, PageLimit: int32(limit)}
if raw := r.URL.Query().Get("before"); raw != "" {
at, id, ok := parseNotificationCursor(raw)
if !ok {
writeErr(w, apierror.BadRequest("bad_cursor", "before is not a cursor this server issued"))
return
}
params.BeforeCreatedAt, params.BeforeID = at, id
}
q := dbq.New(h.pool)
rows, err := q.ListNotifications(r.Context(), params)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: list", apierror.Internal(err))
return
}
unread, err := q.CountUnreadNotifications(r.Context(), user.ID)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
return
}
out := notificationsPageResp{Items: make([]notificationResp, 0, len(rows)), UnreadCount: unread}
for _, row := range rows {
rendered := notifications.Render(notifications.Kind(row.Kind), row.Payload)
item := notificationResp{
ID: uuidToString(row.ID),
Kind: row.Kind,
Title: rendered.Title,
Body: rendered.Body,
Link: rendered.Link,
CreatedAt: row.CreatedAt.Time,
}
if row.ReadAt.Valid {
t := row.ReadAt.Time
item.ReadAt = &t
}
out.Items = append(out.Items, item)
}
if len(rows) == limit {
last := rows[len(rows)-1]
out.NextBefore = formatNotificationCursor(last.CreatedAt, last.ID)
}
writeJSON(w, http.StatusOK, out)
}
// handleMyUnreadNotificationCount implements GET
// /api/me/notifications/unread-count, the cheap call behind the badge.
func (h *handlers) handleMyUnreadNotificationCount(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
n, err := dbq.New(h.pool).CountUnreadNotifications(r.Context(), user.ID)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: count", apierror.Internal(err))
return
}
writeJSON(w, http.StatusOK, unreadCountResp{UnreadCount: n})
}
// handleMarkMyNotificationRead implements POST /api/me/notifications/{id}/read.
// Repeating it is harmless; another user's id is a 404, the same answer as a
// malformed one, so ids can't be probed.
func (h *handlers) handleMarkMyNotificationRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
id, ok := parseUUID(chi.URLParam(r, "id"))
if !ok {
writeErr(w, apierror.NotFound("notification"))
return
}
n, err := dbq.New(h.pool).MarkNotificationRead(r.Context(), dbq.MarkNotificationReadParams{ID: id, UserID: user.ID})
if err != nil {
writeErrWithLog(w, h.logger, "notifications: mark read", apierror.Internal(err))
return
}
if n == 0 {
writeErr(w, apierror.NotFound("notification"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleMarkAllMyNotificationsRead implements POST
// /api/me/notifications/read-all.
func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
// Optional {"up_to": RFC3339}: only what existed then. A client replaying
// an offline "mark all read" sends the moment the user asked.
var body struct {
UpTo *time.Time `json:"up_to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
params := dbq.MarkAllNotificationsReadParams{UserID: user.ID}
if body.UpTo != nil {
params.UpTo = pgtype.Timestamptz{Time: *body.UpTo, Valid: true}
}
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), params); err != nil {
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleGetMyNotificationSettings implements GET /api/me/notification-settings.
func (h *handlers) handleGetMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
q := dbq.New(h.pool)
kinds, err := notifications.LoadSettings(r.Context(), q, user.ID, user.IsAdmin)
if err != nil {
writeErrWithLog(w, h.logger, "notifications: load settings", apierror.Internal(err))
return
}
h.writeNotificationSettings(w, r, q, user, kinds)
}
// handlePutMyNotificationSettings implements PUT /api/me/notification-settings.
// A partial update: only the kinds and channels named change.
func (h *handlers) handlePutMyNotificationSettings(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var body notificationSettingsReq
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
q := dbq.New(h.pool)
kinds, err := notifications.SaveSettings(r.Context(), q, user.ID, user.IsAdmin, body.Kinds)
if errors.Is(err, notifications.ErrSettingInvalid) {
writeErr(w, apierror.BadRequest("invalid_notification_setting", err.Error()))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "notifications: save settings", apierror.Internal(err))
return
}
h.writeNotificationSettings(w, r, q, user, kinds)
}
func (h *handlers) writeNotificationSettings(w http.ResponseWriter, r *http.Request, q *dbq.Queries, user dbq.User, kinds []notifications.KindSetting) {
resp := notificationSettingsResp{Kinds: kinds, EmailAvailable: true}
if user.Email == nil || strings.TrimSpace(*user.Email) == "" {
resp.EmailAvailable, resp.EmailUnavailableReason = false, "no_address"
} else {
// A failed read is an error, not "not configured": that would tell
// the user something about the server the read never established.
cfg, err := q.GetSMTPConfig(r.Context())
if err != nil {
writeErrWithLog(w, h.logger, "notifications: read smtp config", apierror.Internal(err))
return
}
if !mailer.Configured(cfg) {
resp.EmailAvailable, resp.EmailUnavailableReason = false, "smtp_not_configured"
}
}
writeJSON(w, http.StatusOK, resp)
}
// The cursor is the last row's (created_at, id), opaque to clients.
func formatNotificationCursor(at pgtype.Timestamptz, id pgtype.UUID) string {
return at.Time.UTC().Format(time.RFC3339Nano) + "_" + uuidToString(id)
}
func parseNotificationCursor(raw string) (pgtype.Timestamptz, pgtype.UUID, bool) {
ts, idStr, found := strings.Cut(raw, "_")
if !found {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
at, err := time.Parse(time.RFC3339Nano, ts)
if err != nil {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
id, ok := parseUUID(idStr)
if !ok {
return pgtype.Timestamptz{}, pgtype.UUID{}, false
}
return pgtype.Timestamptz{Time: at, Valid: true}, id, true
}