mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
1 parent
8187d54969
commit
1bcaae4764
3 files changed
+244
-8
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user