release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Successful in 4m35s
release / android (push) Successful in 4m58s
release / Build signed APK (releases and dev) (push) Successful in 5m14s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
M489 step 2.
- GET /api/me/notifications?limit&before: newest first, keyset-paged on
(created_at, id) with an opaque cursor, plus the unread count.
- GET /api/me/notifications/unread-count: the badge's cheap call.
- POST /api/me/notifications/{id}/read and /read-all. Mark-read is
idempotent; another user's id is a 404, the same as a malformed one.
- GET/PUT /api/me/notification-settings: every kind the caller can receive
(admin kinds only for admins) with inbox/phone/email. PUT is partial, so an
offline replay sends only what was touched, and a batch with any invalid
change applies nothing. The response says whether email can be delivered
at all: no address on file, or SMTP not configured. A failed SMTP config
read is a 500, not "not configured".
notifications.Render turns kind + payload into title, body and link on the
server, so the web inbox, the Android inbox, the phone's shade and the email
digest all say the same thing. mailer.Configured lifts Send's readiness
check out so settings can report it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
8.4 KiB
Go
259 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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
|
|
}
|
|
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), user.ID); 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
|
|
}
|