Files
minstrel/internal/api/me_notifications.go
T
bvandeusenandClaude Opus 5.5 f11dba2336
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m40s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(api): read-all takes an optional up_to cutoff (M489)
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:27:29 -04:00

273 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 (M489). 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
}