Add the Recent updates sheet behind the Home bell (Feature 21)
GET /api/v1/updates lists the last 10 distinct notification events from
the outbox, newest first and one per event key whatever its channels.
POST /api/v1/updates/seen {"through": created_at} moves
household_settings.updates_seen_at forward, so /api/v1/home's
recentUpdates.unread is true only for an event stored since. The Home
bell opens a Sheet listing the updates with relative times; a row opens
its URL, and opening the sheet marks the updates seen, clearing the
grape dot optimistically and putting it back if the mark fails.
Maestro task 96e2dc3f-1f8a-4114-bcb5-e0f93c349165.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -182,6 +182,53 @@ func (q *Queries) ListNotificationPrefs(ctx context.Context) ([]NotificationPref
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRecentNotifications = `-- name: ListRecentNotifications :many
|
||||
SELECT o.event_key, o.payload, o.created_at FROM notification_outbox o
|
||||
WHERE o.id = (
|
||||
SELECT i.id FROM notification_outbox i
|
||||
WHERE i.event_key = o.event_key
|
||||
ORDER BY i.created_at DESC, i.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY o.created_at DESC, o.event_key DESC
|
||||
LIMIT ?1
|
||||
`
|
||||
|
||||
type ListRecentNotificationsRow struct {
|
||||
EventKey string
|
||||
Payload string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
func (q *Queries) ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRecentNotifications, lim)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListRecentNotificationsRow
|
||||
for rows.Next() {
|
||||
var i ListRecentNotificationsRow
|
||||
if err := rows.Scan(&i.EventKey, &i.Payload, &i.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const markNotificationSent = `-- name: MarkNotificationSent :execrows
|
||||
UPDATE notification_outbox
|
||||
SET status = 'sent', attempts = attempts + 1, next_attempt_at = NULL, updated_at = ?1
|
||||
|
||||
@@ -212,6 +212,13 @@ type Querier interface {
|
||||
// Every stored subscription with its keys, for the push sender, oldest
|
||||
// first.
|
||||
ListPushSubscriptions(ctx context.Context) ([]ListPushSubscriptionsRow, error)
|
||||
// Returns the last lim distinct events stored, newest first: one row per
|
||||
// event key, the last stored for it whatever its channel, so an event
|
||||
// stored for push and Google Chat is one row. The last, because a channel
|
||||
// switched on later can store the same event key again, and
|
||||
// HasNotificationSince counts that row: an event is as new as its newest
|
||||
// row, so marking it seen also covers that row.
|
||||
ListRecentNotifications(ctx context.Context, lim int64) ([]ListRecentNotificationsRow, error)
|
||||
// Reads for the AI household context block (internal/ai/harness).
|
||||
// The most recent names the household turned down, newest first, as
|
||||
// ListPassedNames has them.
|
||||
@@ -254,6 +261,10 @@ type Querier interface {
|
||||
// Records list id sent to Google Chat at sent_to_chat_at, its send
|
||||
// confirmed, unless it is frozen into the Supply archive.
|
||||
MarkShoppingListSentToChat(ctx context.Context, arg MarkShoppingListSentToChatParams) (int64, error)
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error)
|
||||
// Records an edit of plan id, raising its revision: a synced plan becomes
|
||||
// changed, any other keeps its status.
|
||||
MarkWeekPlanEdited(ctx context.Context, arg MarkWeekPlanEditedParams) error
|
||||
|
||||
@@ -161,6 +161,23 @@ func (q *Queries) GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSett
|
||||
return i, err
|
||||
}
|
||||
|
||||
const markUpdatesSeen = `-- name: MarkUpdatesSeen :execrows
|
||||
UPDATE household_settings
|
||||
SET updates_seen_at = CAST(?1 AS TEXT)
|
||||
WHERE id = 1 AND (updates_seen_at IS NULL OR updates_seen_at < CAST(?1 AS TEXT))
|
||||
`
|
||||
|
||||
// Moves updates_seen_at forward to seen_at; affects no row when it is
|
||||
// already at or after seen_at, so it never moves back. It is not a settings
|
||||
// edit: updated_by and updated_at are left as they are.
|
||||
func (q *Queries) MarkUpdatesSeen(ctx context.Context, seenAt string) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, markUpdatesSeen, seenAt)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const recordChatTest = `-- name: RecordChatTest :execrows
|
||||
UPDATE chat_settings
|
||||
SET last_test_at = ?, last_test_status = ?, updated_at = ?
|
||||
|
||||
@@ -195,3 +195,54 @@ func (s *Service) unread(ctx context.Context, seenAt *string) (bool, error) {
|
||||
}
|
||||
return unread, nil
|
||||
}
|
||||
|
||||
// RecentLimit is how many events the Recent updates sheet lists.
|
||||
const RecentLimit = 10
|
||||
|
||||
// Update is one event in the Recent updates sheet.
|
||||
type Update struct {
|
||||
// Type is the event type, e.g. plan_ready.
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
// URL is the path-absolute, same-origin path a tap on the update opens.
|
||||
URL string `json:"url"`
|
||||
// CreatedAt is when the event was stored, UTC RFC 3339 with
|
||||
// milliseconds; it is what POST /updates/seen takes back.
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// Updates returns the last RecentLimit distinct notification events, newest
|
||||
// first, whatever channels they went to.
|
||||
func (s *Service) Updates(ctx context.Context) ([]Update, error) {
|
||||
events, err := s.notifier.Recent(ctx, RecentLimit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("home: %w", err)
|
||||
}
|
||||
out := make([]Update, len(events))
|
||||
for i, ev := range events {
|
||||
out[i] = Update{
|
||||
Type: string(ev.Type), Title: ev.Title, Body: ev.Body, URL: ev.URL,
|
||||
CreatedAt: store.Timestamp{Time: ev.CreatedAt}.String(),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ErrSeenInFuture is returned by MarkUpdatesSeen for a time after now.
|
||||
var ErrSeenInFuture = errors.New("home: updates cannot be seen through a time after now")
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its updates through
|
||||
// through, the created_at of the newest update it was shown, so
|
||||
// recentUpdates.unread is false until an event is stored after it. A time
|
||||
// before the one already recorded changes nothing; one after now is
|
||||
// ErrSeenInFuture, since it would hide events not yet stored.
|
||||
func (s *Service) MarkUpdatesSeen(ctx context.Context, through time.Time) error {
|
||||
if through.After(s.now()) {
|
||||
return ErrSeenInFuture
|
||||
}
|
||||
if err := s.settings.MarkUpdatesSeen(ctx, through); err != nil {
|
||||
return fmt.Errorf("home: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -370,3 +372,183 @@ func TestNewServiceRequiresServices(t *testing.T) {
|
||||
t.Fatal("NewService accepted nil services")
|
||||
}
|
||||
}
|
||||
|
||||
// store stores event key of type typ in the outbox at instant at, for each
|
||||
// of channels, as notify stores an event.
|
||||
func (e *env) store(t *testing.T, typ notify.EventType, key string, at time.Time, channels ...notify.Channel) {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key, Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(e.st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Updates are the last ten distinct events, newest first: an event stored
|
||||
// for push and Google Chat is listed once, whatever became of its delivery.
|
||||
func TestUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Errorf("updates with no notifications = %#v, want an empty list", got)
|
||||
}
|
||||
|
||||
base := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
for i := range 12 {
|
||||
key := fmt.Sprintf("plan_ready:%02d", i)
|
||||
e.store(t, notify.PlanReady, key, base.Add(time.Duration(i)*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
}
|
||||
// A delivered and a failed row of one event are still one event.
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'sent' WHERE event_key = 'plan_ready:11' AND channel = 'push'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE notification_outbox SET status = 'failed' WHERE event_key = 'plan_ready:11' AND channel = 'chat'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err = e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != home.RecentLimit {
|
||||
t.Fatalf("updates = %d, want %d: %+v", len(got), home.RecentLimit, got)
|
||||
}
|
||||
for i, u := range got {
|
||||
n := 11 - i
|
||||
key := fmt.Sprintf("plan_ready:%02d", n)
|
||||
want := home.Update{
|
||||
Type: "plan_ready", Title: "Title " + key, Body: "Body " + key, URL: "/u/" + key,
|
||||
CreatedAt: store.Timestamp{Time: base.Add(time.Duration(n) * time.Minute)}.String(),
|
||||
}
|
||||
if u != want {
|
||||
t.Errorf("updates[%d] = %+v, want %+v", i, u, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An event emitted to push and Google Chat is one update.
|
||||
func TestUpdatesEmittedToTwoChannels(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
if err := e.notifier.Emit(ctx, notify.Event{Type: notify.PlanReady, Key: "plan_ready:2026-10-12", Title: "Your week is drafted", Body: "Take a look", URL: "/plan"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rows int
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT COUNT(*) FROM notification_outbox`).Scan(&rows); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rows != 2 {
|
||||
t.Fatalf("outbox rows = %d, want push and chat", rows)
|
||||
}
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Type != "plan_ready" || got[0].Title != "Your week is drafted" || got[0].Body != "Take a look" || got[0].URL != "/plan" {
|
||||
t.Errorf("updates = %+v, want the one plan_ready event", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Marking updates seen through the newest clears unread until a newer event
|
||||
// is stored; marking them seen through an older time does not move the
|
||||
// mark back, and a time after now is refused.
|
||||
func TestMarkUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
unread := func() bool {
|
||||
t.Helper()
|
||||
return e.at(t, "2026-10-13 12:00").RecentUpdates.Unread
|
||||
}
|
||||
first := time.Date(2026, 10, 13, 16, 0, 0, 0, time.UTC)
|
||||
e.store(t, notify.NewSuggestions, "new_suggestions:1", first, notify.ChannelPush)
|
||||
if !unread() {
|
||||
t.Fatal("not unread with an event never seen")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newest event seen")
|
||||
}
|
||||
|
||||
second := first.Add(time.Millisecond)
|
||||
e.store(t, notify.PlanReady, "plan_ready:2", second, notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread() {
|
||||
t.Error("not unread with an event stored after the mark")
|
||||
}
|
||||
// Seen again through the older event, as a sheet opened before the
|
||||
// newer one arrived would: nothing moves, so it stays unread.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !unread() {
|
||||
t.Error("an older mark hid the newer event")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if unread() {
|
||||
t.Error("unread after marking the newer event seen")
|
||||
}
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := household.NewSettingsService(e.st).Get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: second}).String(); settings.UpdatesSeenAt == nil || *settings.UpdatesSeenAt != want {
|
||||
t.Errorf("updates_seen_at = %v, want %s: an older mark moved it back", settings.UpdatesSeenAt, want)
|
||||
}
|
||||
if settings.UpdatedBy != nil {
|
||||
t.Errorf("updated_by = %q, want unset: marking updates seen is not a settings edit", *settings.UpdatedBy)
|
||||
}
|
||||
|
||||
// The clock reads 17:00 UTC.
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, first.Add(2*time.Hour)); !errors.Is(err, home.ErrSeenInFuture) {
|
||||
t.Errorf("mark seen through an hour from now = %v, want ErrSeenInFuture", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An event stored again for a channel switched on later is as new as that
|
||||
// row: the update shows its time, and marking it seen clears unread.
|
||||
func TestUpdateStoredAgainForAnotherChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
first := time.Date(2026, 10, 13, 15, 0, 0, 0, time.UTC)
|
||||
later := first.Add(30 * time.Minute)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", first, notify.ChannelPush)
|
||||
e.store(t, notify.PlanReady, "plan_ready:1", later, notify.ChannelChat)
|
||||
|
||||
got, err := e.svc.Updates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (store.Timestamp{Time: later}).String(); len(got) != 1 || got[0].CreatedAt != want {
|
||||
t.Fatalf("updates = %+v, want the one event at %s", got, want)
|
||||
}
|
||||
e.at(t, "2026-10-13 12:00")
|
||||
if err := e.svc.MarkUpdatesSeen(ctx, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.at(t, "2026-10-13 12:00").RecentUpdates.Unread {
|
||||
t.Error("unread after the event was marked seen through its listed time")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
@@ -200,6 +201,21 @@ func (s *SettingsService) Update(ctx context.Context, patch SettingsPatch, updat
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// MarkUpdatesSeen records that the household has seen its Recent updates
|
||||
// through seenAt: updates_seen_at moves forward to seenAt, and is left as it
|
||||
// is when it is already at or after it. It is not a settings edit, so
|
||||
// updated_by and updated_at are kept and subscribers are not told.
|
||||
func (s *SettingsService) MarkUpdatesSeen(ctx context.Context, seenAt time.Time) error {
|
||||
if seenAt.IsZero() {
|
||||
return errors.New("household: mark updates seen: zero time")
|
||||
}
|
||||
_, err := db.New(s.st.Writer).MarkUpdatesSeen(ctx, store.Timestamp{Time: seenAt.UTC().Truncate(time.Millisecond)}.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("household: mark updates seen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPatch applies a validated patch to the household settings row in q's
|
||||
// transaction, recording updatedBy, and returns the new row.
|
||||
func applyPatch(ctx context.Context, q *db.Queries, patch SettingsPatch, updatedBy string) (db.HouseholdSetting, error) {
|
||||
|
||||
@@ -205,6 +205,45 @@ func (s *Service) StoredSince(ctx context.Context, since *time.Time) (bool, erro
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// Stored is one event as it was stored, whatever channels it went to.
|
||||
type Stored struct {
|
||||
Type EventType
|
||||
Title string
|
||||
Body string
|
||||
// URL is the path-absolute, same-origin path the event opens.
|
||||
URL string
|
||||
// CreatedAt is when the event was last stored, for any channel: the
|
||||
// instant StoredSince must be given for it not to count.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Recent returns the last n distinct events stored, newest first. An event
|
||||
// stored for more than one channel is one event, as new as its newest row;
|
||||
// one whose preference switches every channel off is never stored and
|
||||
// never listed.
|
||||
func (s *Service) Recent(ctx context.Context, n int) ([]Stored, error) {
|
||||
if n <= 0 {
|
||||
return nil, fmt.Errorf("notify: list %d recent events: want a positive count", n)
|
||||
}
|
||||
rows, err := db.New(s.st.Reader).ListRecentNotifications(ctx, int64(n))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: list recent events: %w", err)
|
||||
}
|
||||
out := make([]Stored, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var p Payload
|
||||
if err := json.Unmarshal([]byte(r.Payload), &p); err != nil {
|
||||
return nil, fmt.Errorf("notify: decode payload of %s: %w", r.EventKey, err)
|
||||
}
|
||||
created, err := parseTimestamp(r.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("notify: created_at of %s: %w", r.EventKey, err)
|
||||
}
|
||||
out = append(out, Stored{Type: p.Event, Title: p.Title, Body: p.Body, URL: p.URL, CreatedAt: created})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// emitTx stores ev in tx for each channel that channels chooses from the
|
||||
// preference of ev's type.
|
||||
func (s *Service) emitTx(ctx context.Context, tx *sql.Tx, ev Event, channels func(db.NotificationPref) ([]Channel, error)) error {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/home"
|
||||
)
|
||||
@@ -20,3 +22,64 @@ func getHome(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
writeJSON(w, r, logger, http.StatusOK, h)
|
||||
}
|
||||
}
|
||||
|
||||
// updatesBody is the GET /updates answer.
|
||||
type updatesBody struct {
|
||||
Updates []home.Update `json:"updates"`
|
||||
}
|
||||
|
||||
// getUpdates answers GET /updates with the Recent updates: the last
|
||||
// home.RecentLimit distinct notification events, newest first.
|
||||
func getUpdates(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
updates, err := svc.Updates(r.Context())
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "read recent updates", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, updatesBody{Updates: updates})
|
||||
}
|
||||
}
|
||||
|
||||
// maxUpdatesSeenBody bounds a POST /updates/seen request body.
|
||||
const maxUpdatesSeenBody = 1 << 10
|
||||
|
||||
// updatesSeenRequest is the POST /updates/seen body: through is the
|
||||
// created_at of the newest update the household was shown.
|
||||
type updatesSeenRequest struct {
|
||||
Through *string `json:"through"`
|
||||
}
|
||||
|
||||
// postUpdatesSeen answers POST /updates/seen by recording that the
|
||||
// household has seen its updates through the body's through, and answers
|
||||
// 204. A through before the one already recorded changes nothing.
|
||||
func postUpdatesSeen(logger *slog.Logger, svc *home.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodePatch[updatesSeenRequest](http.MaxBytesReader(w, r.Body, maxUpdatesSeenBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
if req.Through == nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: required")
|
||||
return
|
||||
}
|
||||
through, err := time.Parse(time.RFC3339Nano, *req.Through)
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: want an RFC 3339 timestamp")
|
||||
return
|
||||
}
|
||||
err = svc.MarkUpdatesSeen(r.Context(), through)
|
||||
if errors.Is(err, home.ErrSeenInFuture) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "through: must not be after now")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.ErrorContext(r.Context(), "mark recent updates seen", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// GET /home answers the Home screen in its JSON shape; newTestHome's
|
||||
@@ -67,3 +72,173 @@ func TestGetHomeNeedsSession(t *testing.T) {
|
||||
t.Errorf("GET /api/v1/home without a session = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// homeRouter returns a router whose Home reads st, and st.
|
||||
func homeRouter(t *testing.T) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
svc := newTestHomeOn(t, st)
|
||||
return newTestRouterWithHome(t, "dev", newTestSettings(t), newTestRunner(t), svc), st
|
||||
}
|
||||
|
||||
// storeEvent stores event key in st's outbox at instant at for each of
|
||||
// channels, as notify stores an event, and returns at as stored.
|
||||
func storeEvent(t *testing.T, st *store.Store, typ notify.EventType, key, title string, at time.Time, channels ...notify.Channel) string {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(notify.Payload{Event: typ, Title: title, Body: "Body of " + key, URL: "/plan", Tag: key})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := store.Timestamp{Time: at.UTC().Truncate(time.Millisecond)}.String()
|
||||
for _, ch := range channels {
|
||||
if _, err := db.New(st.Writer).InsertNotification(t.Context(), db.InsertNotificationParams{
|
||||
ID: store.NewID(), EventKey: key, Channel: string(ch), Payload: string(payload),
|
||||
NextAttemptAt: sql.NullString{String: ts, Valid: true}, Now: ts,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return ts
|
||||
}
|
||||
|
||||
// unread reads recentUpdates.unread from GET /home.
|
||||
func unread(t *testing.T, h http.Handler) bool {
|
||||
t.Helper()
|
||||
rec, body := serve(t, h, signedIn(http.MethodGet, "/api/v1/home", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /api/v1/home = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
updates, ok := body["recentUpdates"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("GET /api/v1/home has no recentUpdates: %s", rec.Body)
|
||||
}
|
||||
return updates["unread"] == true
|
||||
}
|
||||
|
||||
// markSeen posts body to /updates/seen and returns the answer.
|
||||
func markSeen(t *testing.T, h http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
// GET /updates lists the last distinct events newest first, one per event
|
||||
// whatever its channels, in their JSON shape.
|
||||
func TestGetUpdates(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != `{"updates":[]}` {
|
||||
t.Errorf("GET /api/v1/updates with none = %d %s, want 200 {\"updates\":[]}", rec.Code, rec.Body)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
older := storeEvent(t, st, notify.NewSuggestions, "new_suggestions:1", "New Side quests", now.Add(-2*time.Minute), notify.ChannelPush)
|
||||
newer := storeEvent(t, st, notify.PlanReady, "plan_ready:2026-10-12", "Your week is drafted", now.Add(-time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, signedIn(http.MethodGet, "/api/v1/updates", nil))
|
||||
want := `{"updates":[` +
|
||||
`{"type":"plan_ready","title":"Your week is drafted","body":"Body of plan_ready:2026-10-12","url":"/plan","created_at":"` + newer + `"},` +
|
||||
`{"type":"new_suggestions","title":"New Side quests","body":"Body of new_suggestions:1","url":"/plan","created_at":"` + older + `"}]}`
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != want {
|
||||
t.Errorf("GET /api/v1/updates = %d %s, want 200 %s", rec.Code, rec.Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Unread is true while an event newer than the last seen one exists,
|
||||
// clears once POST /updates/seen marks the newest seen, and returns when a
|
||||
// newer event is stored; marking an older one seen again moves nothing.
|
||||
func TestUpdatesSeen(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
now := time.Now()
|
||||
|
||||
if unread(t, h) {
|
||||
t.Error("unread with no events")
|
||||
}
|
||||
first := storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", now.Add(-2*time.Minute), notify.ChannelPush, notify.ChannelChat)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event never seen")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent || rec.Body.Len() != 0 {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newest event was marked seen")
|
||||
}
|
||||
|
||||
second := storeEvent(t, st, notify.ListReady, "list_ready:2", "Supply run ready", now.Add(-time.Minute), notify.ChannelPush)
|
||||
if !unread(t, h) {
|
||||
t.Error("not unread with an event newer than the last seen")
|
||||
}
|
||||
// Already seen: marking the older event seen again is accepted and
|
||||
// changes nothing.
|
||||
if rec := markSeen(t, h, `{"through":"`+first+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen of an older event = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("marking an older event seen hid a newer one")
|
||||
}
|
||||
if rec := markSeen(t, h, `{"through":"`+second+`"}`); rec.Code != http.StatusNoContent {
|
||||
t.Fatalf("POST /api/v1/updates/seen = %d %s, want 204", rec.Code, rec.Body)
|
||||
}
|
||||
if unread(t, h) {
|
||||
t.Error("unread after the newer event was marked seen")
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed POST /updates/seen is 400 and records nothing.
|
||||
func TestUpdatesSeenRejectsMalformed(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st := homeRouter(t)
|
||||
storeEvent(t, st, notify.PlanReady, "plan_ready:1", "Drafted", time.Now().Add(-time.Minute), notify.ChannelPush)
|
||||
|
||||
for _, body := range []string{
|
||||
``,
|
||||
`[]`,
|
||||
`{}`,
|
||||
`{"through":null}`,
|
||||
`{"through":42}`,
|
||||
`{"through":"yesterday"}`,
|
||||
`{"through":"2026-10-13 12:00:00"}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z","extra":true}`,
|
||||
`{"through":"2026-10-13T12:00:00.000Z"} {}`,
|
||||
`{"through":"` + time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano) + `"}`,
|
||||
} {
|
||||
rec := markSeen(t, h, body)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"invalid_request"`) {
|
||||
t.Errorf("POST /api/v1/updates/seen %q = %d %s, want 400 invalid_request", body, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
if !unread(t, h) {
|
||||
t.Error("a malformed POST /updates/seen marked updates seen")
|
||||
}
|
||||
}
|
||||
|
||||
// Both endpoints need a session, and POST /updates/seen the CSRF token.
|
||||
func TestUpdatesNeedSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _ := homeRouter(t)
|
||||
body := `{"through":"2026-10-01T12:00:00.000Z"}`
|
||||
for _, req := range []*http.Request{
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/updates", nil),
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body)),
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s without a session = %d, want 401", req.Method, req.URL.Path, rec.Code)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/updates/seen", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("POST /api/v1/updates/seen without the CSRF token = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ type RouteMounter interface {
|
||||
// /api/v1/settings/notifications/test-push; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home; runner serves
|
||||
// planSvc too, /cook-tonight; homeSvc serves GET /api/v1/home, GET
|
||||
// /api/v1/updates and POST /api/v1/updates/seen; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
@@ -142,6 +143,8 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
}
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/home", getHome(logger, homeSvc))
|
||||
apiV1.Get("/updates", getUpdates(logger, homeSvc))
|
||||
apiV1.Post("/updates/seen", postUpdatesSeen(logger, homeSvc))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Get("/settings/notifications", getNotificationSettings(logger, settings))
|
||||
|
||||
@@ -52,13 +52,19 @@ func newTestRouter(t *testing.T, version string) http.Handler {
|
||||
}
|
||||
|
||||
func newTestRouterWith(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner) http.Handler {
|
||||
t.Helper()
|
||||
return newTestRouterWithHome(t, version, settings, runner, newTestHome(t))
|
||||
}
|
||||
|
||||
// newTestRouterWithHome is newTestRouterWith serving Home from homeSvc.
|
||||
func newTestRouterWithHome(t *testing.T, version string, settings *household.SettingsService, runner *jobs.Runner, homeSvc *home.Service) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
assets := fstest.MapFS{
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), newTestHome(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestChat(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestPush(t), newTestIdeas(t), homeSvc, runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -265,7 +271,13 @@ func newTestShopping(t *testing.T) *shopping.Service {
|
||||
// connection. One database keeps the routers that never ask for Home cheap.
|
||||
func newTestHome(t *testing.T) *home.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
return newTestHomeOn(t, newTestStore(t))
|
||||
}
|
||||
|
||||
// newTestHomeOn returns a home service over st, a migrated database, which
|
||||
// it seeds with zone America/New_York and the seed notification preferences.
|
||||
func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user