fix(api): pass aggregate grain as a SQL literal to date_trunc

The `aggregate=daily|weekly|monthly` branch on `GET /api/measurements`
500'd in production for every grain. Two root causes that the
mocked-`$queryRaw` unit suite hid end-to-end:

1. `date_trunc(${truncUnit}, …)` passed the grain as a bound
   parameter; Postgres rejects — the unit argument must be a SQL
   literal. Switch to `Prisma.raw` for the unit string; safe because
   the grain is upstream-restricted to the `AggregateGrain` Zod enum.
2. The `daily → day` mapping ignored the `weekly` and `monthly`
   forms; Postgres rejects those as units. Map all three grains to
   the singular Postgres unit (`day` / `week` / `month`).

The user-id cast `${type}::"MeasurementType"` also referenced a
non-existent type name; the actual Postgres enum is `measurement_type`.
Corrected alongside.

Add `tests/integration/measurements-aggregate-daily.test.ts` against a
real Postgres so every grain compiles + executes end-to-end. The
mocked unit suite stays for the route-shape contracts.
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 10:34:46 +02:00
1 parent 8a3bea3415
commit 6e75463fef
2 files changed
+213 -3

No files matched your search

+17 -3
View File
@@ -74,20 +74,34 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (aggregate && aggregate !== "raw" && from && to) {
const grain: AggregateGrain = aggregate;
const cap = Math.min(limit, BUCKET_CAP[grain]);
const truncUnit = grain === "daily" ? "day" : grain;
// Postgres `date_trunc` requires the unit argument to be a SQL
// literal — it cannot be supplied as a prepared-statement parameter.
// The previous `${truncUnit}` (bound) interpolation 500'd in
// production for every grain; the grain string is restricted to
// the `AggregateGrain` enum upstream via the Zod schema, so
// injecting the mapped unit via `Prisma.raw` is safe. The mapping
// also fixes the "weekly"/"monthly" passthrough — Postgres expects
// the singular forms `week`/`month`.
const TRUNC_UNIT: Record<Exclude<AggregateGrain, "raw">, string> = {
daily: "day",
weekly: "week",
monthly: "month",
};
const truncUnit = TRUNC_UNIT[grain];
const truncUnitLiteral = Prisma.raw(`'${truncUnit}'`);
const buckets = await prisma.$queryRaw<
Array<{ type: string; bucket_start: Date; avg: number; cnt: number }>
>`
SELECT
m."type"::text AS type,
date_trunc(${truncUnit}, m."measured_at") AS bucket_start,
date_trunc(${truncUnitLiteral}, m."measured_at") AS bucket_start,
AVG(m."value")::double precision AS avg,
COUNT(*)::int AS cnt
FROM measurements m
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
${type ? Prisma.sql`AND m."type" = ${type}::"MeasurementType"` : Prisma.empty}
${type ? Prisma.sql`AND m."type" = ${type}::measurement_type` : Prisma.empty}
GROUP BY m."type", bucket_start
ORDER BY bucket_start ASC
LIMIT ${cap}