mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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
|
FROM measurements m
|
||||||
WHERE m."user_id" = ${userId}
|
WHERE m."user_id" = ${userId}
|
||||||
AND m."measured_at" >= ${sevenDaysAgo}
|
AND m."measured_at" >= ${sevenDaysAgo}
|
||||||
|
AND m."deleted_at" IS NULL
|
||||||
ORDER BY m."type", m."measured_at" DESC
|
ORDER BY m."type", m."measured_at" DESC
|
||||||
`,
|
`,
|
||||||
),
|
),
|
||||||
@@ -440,7 +441,7 @@ async function buildDashboardSummary(
|
|||||||
time("allTime", () =>
|
time("allTime", () =>
|
||||||
prisma.measurement.groupBy({
|
prisma.measurement.groupBy({
|
||||||
by: ["type"],
|
by: ["type"],
|
||||||
where: { userId, type: { in: measurementTypes } },
|
where: { userId, type: { in: measurementTypes }, deletedAt: null },
|
||||||
_count: { _all: true },
|
_count: { _all: true },
|
||||||
_max: { measuredAt: true },
|
_max: { measuredAt: true },
|
||||||
}),
|
}),
|
||||||
@@ -485,6 +486,7 @@ async function buildDashboardSummary(
|
|||||||
FROM measurements m
|
FROM measurements m
|
||||||
WHERE m."user_id" = ${userId}
|
WHERE m."user_id" = ${userId}
|
||||||
AND m."measured_at" >= ${streakWindowStart}
|
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 { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||||
import { annotate } from "@/lib/logging/context";
|
import { annotate } from "@/lib/logging/context";
|
||||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||||
import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
|
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
|
||||||
import type { ChannelType } from "@/lib/notifications/types";
|
import type { ChannelType, EventType } from "@/lib/notifications/types";
|
||||||
import { reEnableChannel } from "@/lib/notifications/channel-state";
|
import { reEnableChannel } from "@/lib/notifications/channel-state";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -45,21 +45,74 @@ function deriveState(channel: {
|
|||||||
return "active";
|
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
|
* GET /api/notifications/status
|
||||||
*
|
*
|
||||||
* Returns the per-channel reliability state — what the Settings →
|
* Returns the per-channel reliability state — what the Settings →
|
||||||
* Notifications page paints into "Active / Auto-disabled / Sending paused"
|
* Notifications page paints into "Active / Auto-disabled / Sending paused"
|
||||||
* badges + last-send / last-failure timestamps.
|
* 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 () => {
|
export const GET = apiHandler(async () => {
|
||||||
const { user } = await requireAuth();
|
const { user } = await requireAuth();
|
||||||
annotate({ action: { name: "notifications.status.get" } });
|
annotate({ action: { name: "notifications.status.get" } });
|
||||||
|
|
||||||
const channels = await prisma.notificationChannel.findMany({
|
const [channels, events] = await Promise.all([
|
||||||
where: { userId: user.id },
|
prisma.notificationChannel.findMany({
|
||||||
orderBy: { type: "asc" },
|
where: { userId: user.id },
|
||||||
});
|
orderBy: { type: "asc" },
|
||||||
|
}),
|
||||||
|
loadEventStatuses(user.id),
|
||||||
|
]);
|
||||||
|
|
||||||
const data: ChannelStatus[] = channels.map((ch) => ({
|
const data: ChannelStatus[] = channels.map((ch) => ({
|
||||||
id: ch.id,
|
id: ch.id,
|
||||||
@@ -75,7 +128,7 @@ export const GET = apiHandler(async () => {
|
|||||||
nextRetryAt: ch.nextRetryAt?.toISOString() ?? null,
|
nextRetryAt: ch.nextRetryAt?.toISOString() ?? null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return apiSuccess({ channels: data });
|
return apiSuccess({ channels: data, events });
|
||||||
});
|
});
|
||||||
|
|
||||||
const reEnableSchema = z.object({
|
const reEnableSchema = z.object({
|
||||||
|
|||||||
Reference in new issue
Block a user