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
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>
273 lines
8.9 KiB
Go
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
|
|
}
|