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