mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
`instant.toISOString().slice(0, 10)` and parsing a key back as UTC midnight are the idioms behind every day-boundary fix this year: the evening west of UTC and the small hours east of it land on the neighbouring day. A new rule, healthlog/no-utc-day-key, refuses them in src/ outside src/lib/tz and tests, and refuses a disable directive for itself that is not next-line or gives no reason. The time zone module now names every legitimate form: userDayKey for a person's day, dateOnlyKey / dateOnlyAtNoonUtc / dayKeyAsUtcMidnight for date-only values and day labels, daysBetweenDateKeys / weekdayOfDateKey / isCalendarDateKey / statedDateKey for key arithmetic and parsing. Of the 133 sites the rule found, 84 are gone. Most moved onto a named helper with no change in behaviour; these changed behaviour: - cycle calendar, insights, Coach snapshot and prediction refresh start their lookbacks at the first instant of the user's day - a model-transcribed date like "June 10, 2026" no longer lands a day early in a process east of UTC (documents and lab scans) - a custom report range covers exactly the picked days in the profile zone instead of starting the evening before west of UTC - date-of-birth pickers cap at the user's today, not the UTC one - an allergy onset date is stored at noon UTC like other date-only values - the report's cycle summary, the Coach's usual BP range and the intake projector's rollup recompute use the user's day - vaccination dates render as dates instead of the previous evening 49 remain, each with its reason on the line above: 39 UTC by design (15 file name stamps, rollup and object keys, the UTC-day score engines) and 10 marked baseline. A guard test requires a classified reason on every directive and holds the baseline to a ceiling that only goes down.
102 lines
3.1 KiB
TypeScript
102 lines
3.1 KiB
TypeScript
/**
|
|
* GET /api/export/mood
|
|
*
|
|
* v1.4.16 phase B7. Per-type CSV download for the consolidated
|
|
* `/settings/export` UI. Filterable by `since` / `until` (inclusive
|
|
* date strings parsed via the standard Date constructor — invalid
|
|
* values are silently dropped from the where-clause).
|
|
*
|
|
* Response is `text/csv` with an attachment filename ending in `.csv`.
|
|
*
|
|
* Auth: cookie session OR Bearer token (`requireAuth`).
|
|
* Rate-limit: shared `export:<userId>` bucket (10/h).
|
|
* Audit: `user.export.mood` with the resolved filter.
|
|
*/
|
|
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
|
import { annotate } from "@/lib/logging/context";
|
|
import { auditLog } from "@/lib/auth/audit";
|
|
import { apiError, getClientIp } from "@/lib/api-response";
|
|
import { checkRateLimit } from "@/lib/rate-limit";
|
|
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
|
|
import { shapeMoodNote } from "@/lib/crypto/note-cipher";
|
|
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
|
|
export const GET = apiHandler(async (request: NextRequest) => {
|
|
const { user } = await requireAuth();
|
|
annotate({ action: { name: "user.export.mood" } });
|
|
|
|
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
|
|
if (!rl.allowed) {
|
|
return apiError("Maximum 10 exports per hour", 429);
|
|
}
|
|
|
|
const params = new URL(request.url).searchParams;
|
|
const since = params.get("since")
|
|
? safeDate(params.get("since")!)
|
|
: undefined;
|
|
const until = params.get("until")
|
|
? safeDate(params.get("until")!)
|
|
: undefined;
|
|
|
|
const where: {
|
|
userId: string;
|
|
deletedAt: null;
|
|
moodLoggedAt?: { gte?: Date; lte?: Date };
|
|
} = {
|
|
userId: user.id,
|
|
// v1.7.0 sync — exclude tombstoned rows from the export.
|
|
deletedAt: null,
|
|
};
|
|
if (since || until) {
|
|
where.moodLoggedAt = {};
|
|
if (since) where.moodLoggedAt.gte = since;
|
|
if (until) where.moodLoggedAt.lte = until;
|
|
}
|
|
|
|
const [entries, userTz] = await Promise.all([
|
|
prisma.moodEntry.findMany({
|
|
where,
|
|
orderBy: { moodLoggedAt: "desc" },
|
|
}),
|
|
resolveUserTimezone(user.id),
|
|
]);
|
|
|
|
const csv = toCSV(
|
|
formatMoodEntriesForExport(entries.map(shapeMoodNote), userTz),
|
|
);
|
|
|
|
await auditLog("user.export.mood", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: {
|
|
count: entries.length,
|
|
since: since?.toISOString() ?? null,
|
|
until: until?.toISOString() ?? null,
|
|
},
|
|
});
|
|
|
|
annotate({
|
|
meta: {
|
|
export_count: entries.length,
|
|
},
|
|
});
|
|
|
|
// eslint-disable-next-line healthlog/no-utc-day-key -- UTC by design: file name stamp, not a day shown or compared
|
|
const stamp = new Date().toISOString().slice(0, 10);
|
|
return new NextResponse(csv, {
|
|
status: 200,
|
|
headers: {
|
|
"Content-Type": "text/csv; charset=utf-8",
|
|
"Content-Disposition": `attachment; filename="healthlog-mood-${user.id}-${stamp}.csv"`,
|
|
"Cache-Control": "no-store",
|
|
},
|
|
});
|
|
});
|
|
|
|
function safeDate(value: string): Date | undefined {
|
|
const d = new Date(value);
|
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
|
}
|