Files
HealthLog/src/lib/export/environment-backup.ts
T
Marc-André Bombeck f2e32afb24 fix(privacy): store environment coordinates at 1 decimal instead of 2
The home location of the environment module was rounded to 2 decimals,
about 1 km, which places a home in a neighbourhood rather than the town the
schema comment promised. The weather archive it feeds is gridded at 9 to
25 km, so the extra precision bought nothing.

Every stored coordinate of the module now goes through the same 1-decimal
rounding (about 11 km north-south, 7 km east-west at 50° N): the home, the
travel periods, the geocoder's results, the per-day weather rows, and a
restore of a file written with finer values. Migration 0361 rounds the
existing users, travel periods and per-day rows; running it twice changes
nothing more. The answers keep their shape, plain numbers. The API
descriptions and the schema comment say 1 decimal now.
2026-09-27 10:37:37 +02:00

362 lines
13 KiB
TypeScript

/**
* The per-day environmental readings and the location periods that explain
* them, with both backup ends in one file.
*
* Same arrangement as `reminders-backup.ts` and `coach-backup.ts`, for the same
* reason: a reader asking "is this carried at both ends?" answers it here, and
* a reader who greps only the restore ROUTE gets a false negative because the
* route delegates.
*
* ## The two travel as a pair, and the pairing is not decoration
*
* The register listed them separately, as "per-day environmental readings joined
* to the record", "where the person was on a given day, which is what makes
* the environmental readings mean anything", and the second line is the one
* that decides the design.
*
* A reading carries the coarse location it was fetched for (`lat`, `lon`,
* `locationLabel`) and the precedence rule that chose it (`source`). A
* `TRAVEL` reading exists because an explicit dated location period covered
* that day. `resolveLocationForDay` in `src/lib/environment/service.ts` reads
* those periods live, every time; nothing else can produce a `TRAVEL`
* verdict.
*
* So a restore that carried the readings and not the periods would not merely
* hand back readings with less context. The nightly refresh runs a seven-day
* lookback and an operator backfill re-resolves whatever range it is given;
* both UPSERT. With the periods gone, every trip day inside the range
* re-resolves to the home location and the upsert overwrites the row: the
* coordinates, the label, the source and every weather field. The account
* would end up with a fortnight abroad recorded as a fortnight of weather at
* home, written by the app itself, some days after a restore that reported
* success. That is why these two land together or not at all.
*
* The other direction is a plain loss with no rewrite behind it: periods
* without readings leave the correlation surfaces empty for the history, and
* the archive feed only reaches back so far before the older days are simply
* unfetchable.
*
* ## Day keys stay strings
*
* `date`, `startDate` and `endDate` are `YYYY-MM-DD`, and they cross the wire
* as the strings they are stored as. The resolver compares them
* lexicographically against other day keys, so nothing here needs a `Date`,
* and parsing one into a `Date` and formatting it back is exactly how a day
* key loses a day under a negative UTC offset. The row stamps
* (`fetchedAt`, `createdAt`, `updatedAt`) are real instants and ride as
* ISO-8601.
*
* `fetchedAt` is carried verbatim rather than stamped on the way in. It says
* when the upstream feed was last read for that day, and the archive feed
* settles over a few days after the fact, so a restore that wrote "now" would
* claim a provisional reading from two years ago had just been confirmed.
*
* ## One wire shape, both purposes
*
* Neither model has a tombstone column and neither holds ciphertext, so a
* portable export and a disaster-recovery payload carry byte-identical
* sections. This builder therefore takes no `purpose`: a parameter that
* changes nothing would advertise a distinction the file does not have.
*/
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
import { roundCoarse } from "@/lib/environment/open-meteo";
/** One day's environmental observation, at the location resolved for that day. */
export interface EnvironmentContextBackupEntry {
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
date: string;
lat: number;
lon: number;
locationLabel: string;
/**
* Which precedence rule chose the location. Carried because it is the only
* record that this day was NOT the home city, and because a re-resolve
* cannot recover it once the period behind it is gone.
*/
source: EnvironmentLocationSource;
tempMin: number | null;
tempMax: number | null;
tempMean: number | null;
apparentMean: number | null;
sunshineSec: number | null;
daylightSec: number | null;
precipSum: number | null;
pressureMean: number | null;
pressureDelta: number | null;
humidityMean: number | null;
cloudMean: number | null;
weatherCode: number | null;
/** When the feed was last read for this day. Verbatim; see the file header. */
fetchedAt: string;
createdAt: string;
updatedAt: string;
}
/** One declared stretch spent somewhere other than home. */
export interface EnvironmentTravelLocationBackupEntry {
/** Inclusive `YYYY-MM-DD` bounds. Strings end to end; see the file header. */
startDate: string;
endDate: string;
lat: number;
lon: number;
label: string;
createdAt: string;
updatedAt: string;
}
export interface EnvironmentBackupSection {
environmentContexts: EnvironmentContextBackupEntry[];
environmentTravelLocations: EnvironmentTravelLocationBackupEntry[];
}
export interface EnvironmentBackupCounts {
environmentContexts: number;
environmentTravelLocations: number;
}
/**
* Named select constants, one per model, because a structural matcher binds a model
* to the literal beside its delegate call, matching the other section files.
*
* No `id` on either: nothing in the file or in the database addresses one of
* these rows, and a reading is identified by its day.
*/
const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
date: true,
lat: true,
lon: true,
locationLabel: true,
source: true,
tempMin: true,
tempMax: true,
tempMean: true,
apparentMean: true,
sunshineSec: true,
daylightSec: true,
precipSum: true,
pressureMean: true,
pressureDelta: true,
humidityMean: true,
cloudMean: true,
weatherCode: true,
fetchedAt: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.EnvironmentContextSelect;
const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
startDate: true,
endDate: true,
lat: true,
lon: true,
label: true,
createdAt: true,
updatedAt: true,
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
/**
* Build the environment slice of a user's full backup.
*
* Takes the delegates it uses rather than a whole client, matching the other
* section builders.
*/
export async function buildEnvironmentBackupSection(
prisma: Pick<
PrismaClient,
"environmentContext" | "environmentTravelLocation"
>,
userId: string,
): Promise<EnvironmentBackupSection> {
const [contextRows, travelRows] = await Promise.all([
prisma.environmentContext.findMany({
where: { userId },
orderBy: { date: "asc" },
select: ENVIRONMENT_CONTEXT_BACKUP_SELECT,
}),
prisma.environmentTravelLocation.findMany({
where: { userId },
orderBy: { startDate: "asc" },
select: ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT,
}),
]);
return {
environmentContexts: contextRows.map((row) => ({
date: row.date,
lat: row.lat,
lon: row.lon,
locationLabel: row.locationLabel,
source: row.source,
tempMin: row.tempMin,
tempMax: row.tempMax,
tempMean: row.tempMean,
apparentMean: row.apparentMean,
sunshineSec: row.sunshineSec,
daylightSec: row.daylightSec,
precipSum: row.precipSum,
pressureMean: row.pressureMean,
pressureDelta: row.pressureDelta,
humidityMean: row.humidityMean,
cloudMean: row.cloudMean,
weatherCode: row.weatherCode,
fetchedAt: row.fetchedAt.toISOString(),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
environmentTravelLocations: travelRows.map((row) => ({
startDate: row.startDate,
endDate: row.endDate,
lat: row.lat,
lon: row.lon,
label: row.label,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
};
}
/** Row counts for the audit trail, mirroring the other section counters. */
export function countEnvironmentBackupSection(
section: EnvironmentBackupSection,
): EnvironmentBackupCounts {
return {
environmentContexts: section.environmentContexts.length,
environmentTravelLocations: section.environmentTravelLocations.length,
};
}
/** Counts the environment restore wiped, for the audit trail. */
export interface EnvironmentRestoreCleared {
environmentContexts: number;
environmentTravelLocations: number;
}
/**
* What the restore reads, as the parsed file actually presents it. Wider than
* what this release writes, because every weather column arrives as
* `undefined` rather than `null` from a file written before it existed.
*
* `source` stays strictly required. It is the field that says this day was not
* the home city, and defaulting it would quietly re-attribute a trip.
*/
type OptionalNullable<T> = { [K in keyof T]?: T[K] | undefined };
export type RestoredEnvironmentContext = Pick<
EnvironmentContextBackupEntry,
"date" | "lat" | "lon" | "locationLabel" | "source"
> &
OptionalNullable<
Pick<
EnvironmentContextBackupEntry,
| "tempMin"
| "tempMax"
| "tempMean"
| "apparentMean"
| "sunshineSec"
| "daylightSec"
| "precipSum"
| "pressureMean"
| "pressureDelta"
| "humidityMean"
| "cloudMean"
| "weatherCode"
| "fetchedAt"
| "createdAt"
| "updatedAt"
>
>;
export type RestoredEnvironmentTravelLocation = Pick<
EnvironmentTravelLocationBackupEntry,
"startDate" | "endDate" | "lat" | "lon" | "label"
> &
OptionalNullable<
Pick<EnvironmentTravelLocationBackupEntry, "createdAt" | "updatedAt">
>;
export interface EnvironmentRestoreInput {
environmentContexts: RestoredEnvironmentContext[];
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
}
/**
* Re-create the account's environmental history and its location periods.
*
* Delete-then-recreate inside the caller's transaction, matching every other
* section.
*
* Neither model references anything but the account, so this section has no
* ordering constraint against any other one and can run anywhere in the
* restore. The ordering that does matter is INSIDE it, and it is the reason
* the two live in one function: the periods and the readings must land in the
* same transaction, because a set of readings whose periods are missing is a
* history the next refresh will silently rewrite to the home location. The
* periods go first so a reader of this function meets the explanation before
* the thing it explains.
*/
export async function restoreEnvironmentData(
tx: Prisma.TransactionClient,
ownerId: string,
payload: EnvironmentRestoreInput,
): Promise<EnvironmentRestoreCleared> {
const [clearedTravel, clearedContexts] = await Promise.all([
tx.environmentTravelLocation.deleteMany({ where: { userId: ownerId } }),
tx.environmentContext.deleteMany({ where: { userId: ownerId } }),
]);
if (payload.environmentTravelLocations.length > 0) {
await tx.environmentTravelLocation.createMany({
data: payload.environmentTravelLocations.map((entry) => ({
userId: ownerId,
// Day keys, written as they were read. See the file header for why
// neither bound goes near a `Date`.
startDate: entry.startDate,
endDate: entry.endDate,
// A file written before v1.39.4 carries 2-decimal coordinates; they
// come back at today's privacy floor.
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
label: entry.label,
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
});
}
if (payload.environmentContexts.length > 0) {
await tx.environmentContext.createMany({
data: payload.environmentContexts.map((entry) => ({
userId: ownerId,
date: entry.date,
lat: roundCoarse(entry.lat),
lon: roundCoarse(entry.lon),
locationLabel: entry.locationLabel,
source: entry.source,
tempMin: entry.tempMin ?? null,
tempMax: entry.tempMax ?? null,
tempMean: entry.tempMean ?? null,
apparentMean: entry.apparentMean ?? null,
sunshineSec: entry.sunshineSec ?? null,
daylightSec: entry.daylightSec ?? null,
precipSum: entry.precipSum ?? null,
pressureMean: entry.pressureMean ?? null,
pressureDelta: entry.pressureDelta ?? null,
humidityMean: entry.humidityMean ?? null,
cloudMean: entry.cloudMean ?? null,
weatherCode: entry.weatherCode ?? null,
// Verbatim, not stamped: this says when the feed was read, not when
// the row was written back.
...(entry.fetchedAt ? { fetchedAt: new Date(entry.fetchedAt) } : {}),
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
})),
});
}
return {
environmentContexts: clearedContexts.count,
environmentTravelLocations: clearedTravel.count,
};
}