feat(api): read-all takes an optional up_to cutoff (M489)
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
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>
This commit is contained in:
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -170,7 +171,20 @@ func (h *handlers) handleMarkAllMyNotificationsRead(w http.ResponseWriter, r *ht
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := dbq.New(h.pool).MarkAllNotificationsRead(r.Context(), user.ID); err != nil {
|
// 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))
|
writeErrWithLog(w, h.logger, "notifications: mark all read", apierror.Internal(err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -127,6 +128,33 @@ func TestMyNotifications_MarkReadIsOwnerScopedAndCountsDown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A "mark all read" replayed from an offline queue carries the moment the
|
||||||
|
// user asked; what arrived after it stays unread.
|
||||||
|
func TestMyNotifications_ReadAllUpToLeavesLaterOnesUnread(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "notif-upto", "pw", false)
|
||||||
|
r := notificationsRouter(h)
|
||||||
|
|
||||||
|
notifyN(t, pool, alice, 1)
|
||||||
|
var page notificationsPageResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications", "", &page)
|
||||||
|
seen := page.Items[0].CreatedAt
|
||||||
|
notifyN(t, pool, alice, 1)
|
||||||
|
|
||||||
|
body := `{"up_to":"` + seen.Format(time.RFC3339Nano) + `"}`
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", body, nil); code != http.StatusNoContent {
|
||||||
|
t.Fatalf("read-all up_to = %d", code)
|
||||||
|
}
|
||||||
|
var count unreadCountResp
|
||||||
|
callAs(t, r, alice, http.MethodGet, "/api/me/notifications/unread-count", "", &count)
|
||||||
|
if count.UnreadCount != 1 {
|
||||||
|
t.Errorf("unread = %d, want 1 (the one that arrived later)", count.UnreadCount)
|
||||||
|
}
|
||||||
|
if code := callAs(t, r, alice, http.MethodPost, "/api/me/notifications/read-all", `{"up_to":"yesterday"}`, nil); code != http.StatusBadRequest {
|
||||||
|
t.Errorf("malformed up_to = %d, want 400", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func strPtr(s string) *string { return &s }
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
|
func setSMTP(t *testing.T, pool *pgxpool.Pool, enabled bool) {
|
||||||
|
|||||||
@@ -214,11 +214,22 @@ func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsPa
|
|||||||
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
|
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
|
||||||
UPDATE user_notifications
|
UPDATE user_notifications
|
||||||
SET read_at = now()
|
SET read_at = now()
|
||||||
WHERE user_id = $1 AND read_at IS NULL
|
WHERE user_id = $1
|
||||||
|
AND read_at IS NULL
|
||||||
|
AND ($2::timestamptz IS NULL OR created_at <= $2)
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
type MarkAllNotificationsReadParams struct {
|
||||||
result, err := q.db.Exec(ctx, markAllNotificationsRead, userID)
|
UserID pgtype.UUID
|
||||||
|
UpTo pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// up_to, when set, limits it to what existed when the user asked: a "mark
|
||||||
|
// all read" queued offline and replayed later must not mark notices that
|
||||||
|
// arrived in between, which the user never saw. A coalesced row updated
|
||||||
|
// since then carries a newer created_at, so it stays unread too.
|
||||||
|
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,9 +57,15 @@ UPDATE user_notifications
|
|||||||
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
|
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
|
||||||
|
|
||||||
-- name: MarkAllNotificationsRead :execrows
|
-- name: MarkAllNotificationsRead :execrows
|
||||||
|
-- up_to, when set, limits it to what existed when the user asked: a "mark
|
||||||
|
-- all read" queued offline and replayed later must not mark notices that
|
||||||
|
-- arrived in between, which the user never saw. A coalesced row updated
|
||||||
|
-- since then carries a newer created_at, so it stays unread too.
|
||||||
UPDATE user_notifications
|
UPDATE user_notifications
|
||||||
SET read_at = now()
|
SET read_at = now()
|
||||||
WHERE user_id = $1 AND read_at IS NULL;
|
WHERE user_id = sqlc.arg(user_id)
|
||||||
|
AND read_at IS NULL
|
||||||
|
AND (sqlc.narg(up_to)::timestamptz IS NULL OR created_at <= sqlc.narg(up_to));
|
||||||
|
|
||||||
-- name: TrimNotifications :execrows
|
-- name: TrimNotifications :execrows
|
||||||
-- Retention: read rows go after read_cutoff, and anything at all after
|
-- Retention: read rows go after read_cutoff, and anything at all after
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ func TestDuplicateSweep_NotifiesOnlyWhenSomethingNewIsProposed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
markAllRead := func() {
|
markAllRead := func() {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := q.MarkAllNotificationsRead(ctx, admin.ID); err != nil {
|
if _, err := q.MarkAllNotificationsRead(ctx, dbq.MarkAllNotificationsReadParams{UserID: admin.ID}); err != nil {
|
||||||
t.Fatalf("mark read: %v", err)
|
t.Fatalf("mark read: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user