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 }