fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries

`latestIn7d` and `streakDays` are raw SQL against `measurements`; the
`allTime` aggregate is `prisma.measurement.groupBy`. All three folded
tombstoned rows into the tile-strip's latest value, the all-time count
+ last-seen-at metadata, and the measurement-streak day count. The
`sparkBuckets` query reads from `measurement_rollups` so the writer-side
filter covers it.

Add `m."deleted_at" IS NULL` to the two raw queries and `deletedAt: null`
to the Prisma groupBy.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 11:44:00 +02:00
1 parent 8187d54969
commit 1bcaae4764
3 files changed
+244 -8

No files matched your search

+3 -1
View File
@@ -419,6 +419,7 @@ async function buildDashboardSummary(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${sevenDaysAgo}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
),
@@ -440,7 +441,7 @@ async function buildDashboardSummary(
time("allTime", () =>
prisma.measurement.groupBy({
by: ["type"],
where: { userId, type: { in: measurementTypes } },
where: { userId, type: { in: measurementTypes }, deletedAt: null },
_count: { _all: true },
_max: { measuredAt: true },
}),
@@ -485,6 +486,7 @@ async function buildDashboardSummary(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${streakWindowStart}
AND m."deleted_at" IS NULL
`,
),
]);
@@ -0,0 +1,181 @@
/**
* GET /api/notifications/status route tests.
*
* Pins the SB-6 v1.4.40 contract — the response carries BOTH the
* existing `channels` array (channel reliability state for the Settings
* page) AND a new `events` map keyed by event-type whose values shape
* `{ lastDeliveredAt: ISO8601 | null }`. The iOS NotificationsScreen
* reads `events` to render "last delivered Xh ago" per category.
*
* Stability rules pinned here so a future refactor can't silently
* regress them:
* 1. 401 when unauthenticated (no DB lookup).
* 2. Every known EventType is present in the map, even for an
* empty-state user — value defaults to `{ lastDeliveredAt: null }`.
* 3. `MOOD_REMINDER.lastDeliveredAt` reads the latest
* `MoodReminderDispatch.dispatchedAt` and serialises it as ISO8601.
* 4. The existing `channels` array shape is untouched (backwards-
* compatible — the Settings card still works against this route).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findMany: vi.fn(),
},
moodReminderDispatch: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { EVENT_TYPES } from "@/lib/notifications/types";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([]);
vi.mocked(prisma.moodReminderDispatch.findFirst).mockResolvedValue(null);
});
describe("GET /api/notifications/status — auth", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(401);
// DB must not be touched before auth succeeds.
expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled();
expect(prisma.moodReminderDispatch.findFirst).not.toHaveBeenCalled();
});
});
describe("GET /api/notifications/status — events map", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("empty-state user gets every known category with null", async () => {
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
channels: unknown[];
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
expect(body.data.channels).toEqual([]);
// Every known event-type must be present so iOS doesn't have to
// special-case missing keys.
for (const ev of EVENT_TYPES) {
expect(body.data.events).toHaveProperty(ev);
expect(body.data.events[ev]).toEqual({ lastDeliveredAt: null });
}
});
it("populates MOOD_REMINDER.lastDeliveredAt from the latest dispatch row", async () => {
const dispatchedAt = new Date("2026-05-17T22:00:00.000Z");
vi.mocked(prisma.moodReminderDispatch.findFirst).mockResolvedValueOnce({
dispatchedAt,
} as never);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
expect(body.data.events.MOOD_REMINDER.lastDeliveredAt).toBe(
dispatchedAt.toISOString(),
);
// Other categories still null — no cross-contamination.
expect(body.data.events.MEDICATION_REMINDER.lastDeliveredAt).toBeNull();
expect(body.data.events.MEASUREMENT_ANOMALY.lastDeliveredAt).toBeNull();
// Scoped to the calling user.
expect(prisma.moodReminderDispatch.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { dispatchedAt: "desc" },
select: { dispatchedAt: true },
});
});
});
describe("GET /api/notifications/status — channels shape (backwards compat)", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("keeps the existing channel-array shape so the Settings card keeps working", async () => {
// Fixture mirrors the Prisma `NotificationChannel` row shape the
// route reads. The Settings card destructures `data.channels`, so
// adding `events` must NOT shift the channels payload.
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
{
id: "ch-1",
userId: "user-1",
type: "APNS",
enabled: true,
disabledReason: null,
consecutiveFailures: 0,
lastSuccessAt: new Date("2026-05-17T20:00:00.000Z"),
lastFailureAt: null,
lastFailureReason: null,
nextRetryAt: null,
},
] as never);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
channels: Array<{
id: string;
type: string;
state: string;
lastSuccessAt: string | null;
}>;
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
expect(body.data.channels).toHaveLength(1);
expect(body.data.channels[0]).toMatchObject({
id: "ch-1",
type: "APNS",
state: "active",
lastSuccessAt: "2026-05-17T20:00:00.000Z",
});
// `events` lives alongside `channels` so old consumers ignore the
// new key automatically.
expect(body.data.events).toBeDefined();
});
});
+60 -7
View File
@@ -4,8 +4,8 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
import type { ChannelType, EventType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
export const dynamic = "force-dynamic";
@@ -45,21 +45,74 @@ function deriveState(channel: {
return "active";
}
/**
* Per-event-type "last delivered" map. SB-6 v1.4.40 — drives the iOS
* NotificationsScreen "last-delivered X h ago" line so the user can tell
* at a glance whether the channel is actually firing for the category
* they care about.
*
* Source-of-truth strategy: aggregate from whichever per-event ledger
* exists today.
* - `MOOD_REMINDER` → `MoodReminderDispatch.dispatchedAt` (the
* idempotency ledger written by `mood-reminder.ts`).
* - everything else → `null` for now. As future categories grow their
* own ledger (e.g. a `MedicationReminderDispatch` table planned for a
* later phase), wire them in below — the iOS contract is stable
* around "null means no data yet", so adding sources is additive.
*
* Every known category is always present in the map (with `null` when
* no data exists) so the iOS layer can render an empty-state row
* without conditional plumbing.
*/
type EventStatus = { lastDeliveredAt: string | null };
async function loadEventStatuses(
userId: string,
): Promise<Record<EventType, EventStatus>> {
// Initialise the map with every known event-type → null so the
// response shape is stable regardless of which ledgers have rows.
const out = Object.fromEntries(
EVENT_TYPES.map((ev) => [ev, { lastDeliveredAt: null as string | null }]),
) as Record<EventType, EventStatus>;
// MOOD_REMINDER — read the latest row out of the idempotency ledger.
const lastMood = await prisma.moodReminderDispatch.findFirst({
where: { userId },
orderBy: { dispatchedAt: "desc" },
select: { dispatchedAt: true },
});
if (lastMood) {
out.MOOD_REMINDER = {
lastDeliveredAt: lastMood.dispatchedAt.toISOString(),
};
}
return out;
}
/**
* GET /api/notifications/status
*
* Returns the per-channel reliability state — what the Settings →
* Notifications page paints into "Active / Auto-disabled / Sending paused"
* badges + last-send / last-failure timestamps.
*
* SB-6 v1.4.40 — additionally returns a per-event-type `events` map so
* the iOS NotificationsScreen can render "last delivered X h ago" per
* category. `events[category].lastDeliveredAt` is null when the user
* has never received that event-type (or no ledger source exists yet).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "notifications.status.get" } });
const channels = await prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
});
const [channels, events] = await Promise.all([
prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
}),
loadEventStatuses(user.id),
]);
const data: ChannelStatus[] = channels.map((ch) => ({
id: ch.id,
@@ -75,7 +128,7 @@ export const GET = apiHandler(async () => {
nextRetryAt: ch.nextRetryAt?.toISOString() ?? null,
}));
return apiSuccess({ channels: data });
return apiSuccess({ channels: data, events });
});
const reEnableSchema = z.object({