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>
103 lines
3.1 KiB
Go
103 lines
3.1 KiB
Go
package notifications
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// KindSetting is one row of a user's notification settings.
|
|
type KindSetting struct {
|
|
Kind Kind `json:"kind"`
|
|
AdminOnly bool `json:"admin_only"`
|
|
Inbox bool `json:"inbox"`
|
|
Phone bool `json:"phone"`
|
|
Email bool `json:"email"`
|
|
}
|
|
|
|
// SettingChange changes one kind. A nil field is left as it is, so a client
|
|
// replaying a queued toggle offline sends only what the user touched.
|
|
type SettingChange struct {
|
|
Kind Kind `json:"kind"`
|
|
Inbox *bool `json:"inbox,omitempty"`
|
|
Phone *bool `json:"phone,omitempty"`
|
|
Email *bool `json:"email,omitempty"`
|
|
}
|
|
|
|
// ErrSettingInvalid is a change naming an unknown kind, or an admin kind for
|
|
// a user who is not an admin.
|
|
var ErrSettingInvalid = errors.New("notification setting invalid")
|
|
|
|
// LoadSettings returns every kind the user can receive, in display order,
|
|
// with their stored channels or the kind's defaults. Admin kinds are listed
|
|
// only for admins.
|
|
//
|
|
// The stored values are returned as set, not as Effective: a user who turns
|
|
// the inbox off and on again gets back the phone and email choices they had.
|
|
func LoadSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool) ([]KindSetting, error) {
|
|
rows, err := q.ListNotificationPrefsForUser(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("notifications: load settings: %w", err)
|
|
}
|
|
stored := make(map[Kind]Channels, len(rows))
|
|
for _, r := range rows {
|
|
stored[Kind(r.Kind)] = Channels{Inbox: r.Inbox, Phone: r.Phone, Email: r.Email}
|
|
}
|
|
out := make([]KindSetting, 0, len(order))
|
|
for _, k := range order {
|
|
if k.AdminOnly() && !isAdmin {
|
|
continue
|
|
}
|
|
c, ok := stored[k]
|
|
if !ok {
|
|
c = k.Defaults()
|
|
}
|
|
out = append(out, KindSetting{Kind: k, AdminOnly: k.AdminOnly(), Inbox: c.Inbox, Phone: c.Phone, Email: c.Email})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// SaveSettings applies changes and returns the settings as they now stand.
|
|
// The whole batch is validated before anything is written.
|
|
func SaveSettings(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, isAdmin bool, changes []SettingChange) ([]KindSetting, error) {
|
|
for _, c := range changes {
|
|
if !c.Kind.Valid() {
|
|
return nil, fmt.Errorf("%w: unknown kind %q", ErrSettingInvalid, c.Kind)
|
|
}
|
|
if c.Kind.AdminOnly() && !isAdmin {
|
|
return nil, fmt.Errorf("%w: %s is for admins", ErrSettingInvalid, c.Kind)
|
|
}
|
|
}
|
|
current, err := LoadSettings(ctx, q, userID, isAdmin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
byKind := make(map[Kind]KindSetting, len(current))
|
|
for _, s := range current {
|
|
byKind[s.Kind] = s
|
|
}
|
|
for _, c := range changes {
|
|
s := byKind[c.Kind]
|
|
if c.Inbox != nil {
|
|
s.Inbox = *c.Inbox
|
|
}
|
|
if c.Phone != nil {
|
|
s.Phone = *c.Phone
|
|
}
|
|
if c.Email != nil {
|
|
s.Email = *c.Email
|
|
}
|
|
byKind[c.Kind] = s
|
|
if err := q.UpsertNotificationPref(ctx, dbq.UpsertNotificationPrefParams{
|
|
UserID: userID, Kind: string(c.Kind), Inbox: s.Inbox, Phone: s.Phone, Email: s.Email,
|
|
}); err != nil {
|
|
return nil, fmt.Errorf("notifications: save %s: %w", c.Kind, err)
|
|
}
|
|
}
|
|
return LoadSettings(ctx, q, userID, isAdmin)
|
|
}
|