mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
Merge environment data into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
7bcb6f71d9
73 files changed
+6061
-385
No files matched your search
@@ -6,6 +6,7 @@
|
||||
* a person instead of ciphertext. Every refusal here leaves the account's
|
||||
* current value in place and names the column in the restore's skip report.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
buildAccountSettingsBackupSection,
|
||||
} from "@/lib/export/account-settings-backup";
|
||||
import { UNREADABLE_EXPORT_MARKER } from "@/lib/export/unreadable-marker";
|
||||
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import { reasoningEffortFor } from "@/lib/ai/reasoning-effort";
|
||||
import {
|
||||
coachReasoningLevel,
|
||||
@@ -324,3 +326,74 @@ describe("admitAccountSettings", () => {
|
||||
expect(data).toEqual({ heightCm: 180 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the environment home (v1.42, sealed at rest)", () => {
|
||||
it("carries the home readable in a portable file, opened from its sealed copy", async () => {
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(
|
||||
rowWith({
|
||||
homeLocationEncrypted: sealLocation({
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
label: "Bochum, Germany",
|
||||
}),
|
||||
}),
|
||||
),
|
||||
"u1",
|
||||
{ purpose: "portable-export" },
|
||||
);
|
||||
expect(accountSettings).toMatchObject({
|
||||
homeLat: 51.5,
|
||||
homeLon: 7.2,
|
||||
homeLabel: "Bochum, Germany",
|
||||
});
|
||||
expect(accountSettings).not.toHaveProperty("homeLocationEncrypted");
|
||||
});
|
||||
|
||||
it("carries the sealed copy verbatim in a disaster-recovery file", async () => {
|
||||
const sealed = sealLocation({ lat: 51.5, lon: 7.2, label: "Bochum" });
|
||||
const { accountSettings } = await buildAccountSettingsBackupSection(
|
||||
prismaReturning(rowWith({ homeLocationEncrypted: sealed })),
|
||||
"u1",
|
||||
{ purpose: "disaster-recovery" },
|
||||
);
|
||||
expect(accountSettings!.homeLocationEncrypted).toBe(
|
||||
Buffer.from(sealed).toString("base64"),
|
||||
);
|
||||
});
|
||||
|
||||
it("seals a readable home from a file and writes the readable columns empty", () => {
|
||||
const { data, refused } = admitAccountSettings(
|
||||
{ homeLat: 48.137, homeLon: 11.575, homeLabel: "Munich" },
|
||||
ctx,
|
||||
);
|
||||
expect(refused).toEqual([]);
|
||||
expect(data).toMatchObject({
|
||||
homeLat: null,
|
||||
homeLon: null,
|
||||
homeLabel: null,
|
||||
});
|
||||
expect(openLocation(data.homeLocationEncrypted as Uint8Array)).toEqual({
|
||||
lat: 48.1,
|
||||
lon: 11.6,
|
||||
label: "Munich",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes a sealed home as it came, and clears the home when the file has none", () => {
|
||||
const sealed = sealLocation({ lat: 1, lon: 2, label: "x" });
|
||||
const restored = admitAccountSettings(
|
||||
{ homeLocationEncrypted: Buffer.from(sealed).toString("base64") },
|
||||
ctx,
|
||||
).data.homeLocationEncrypted as Uint8Array;
|
||||
expect(Buffer.from(restored).equals(Buffer.from(sealed))).toBe(true);
|
||||
expect(
|
||||
admitAccountSettings({ homeLat: null, homeLon: null }, ctx).data,
|
||||
).toEqual({
|
||||
homeLocationEncrypted: null,
|
||||
homeLat: null,
|
||||
homeLon: null,
|
||||
homeLabel: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,8 +32,8 @@
|
||||
*
|
||||
* ── Encryption ─────────────────────────────────────────────────────────────
|
||||
*
|
||||
* One column is sealed, the insurance number, and it follows the split every
|
||||
* other section uses: a disaster-recovery file carries the ciphertext
|
||||
* Two values are sealed, the insurance number and (v1.42) the environment
|
||||
* home, and both follow the split every other section uses: a disaster-recovery file carries the ciphertext
|
||||
* verbatim (so the restore's key check sees it), a portable file carries the
|
||||
* readable value and the restore seals it under this host's key.
|
||||
*/
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
readAvatarDimensions,
|
||||
} from "@/lib/avatar";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import {
|
||||
USER_COLUMN_BACKUP_CLASS,
|
||||
type AccountSettingColumn,
|
||||
@@ -382,36 +383,100 @@ const insuranceNumber: SettingCodec = {
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.42 (#615) — the sealed home location. A disaster-recovery file carries
|
||||
* the ciphertext verbatim, like the insurance number, because it is restored
|
||||
* onto a host holding the same keys. A portable file leaves it out: the
|
||||
* readable home rides `homeLat` / `homeLon` / `homeLabel` until those columns
|
||||
* drop, and the receiving host's encryption backfill seals it again.
|
||||
* v1.42 (#615) — the environment home, sealed at rest as one value
|
||||
* (`homeLocationEncrypted`, see `src/lib/environment/location-cipher.ts`)
|
||||
* beside the readable `homeLat` / `homeLon` / `homeLabel` that the
|
||||
* encryption backfill empties. The four columns travel as one, the way the
|
||||
* avatar's three do:
|
||||
*
|
||||
* - a portable file carries the home readable, opened from the sealed copy
|
||||
* when there is one, and no sealed value;
|
||||
* - a disaster-recovery file carries the sealed value verbatim (base64)
|
||||
* beside whatever the readable columns still hold.
|
||||
*
|
||||
* The restore writes the sealed value as it came, or seals a readable home
|
||||
* under this host's key, and writes the readable columns empty either way. A
|
||||
* file whose home is null clears the account's home. A home without a label
|
||||
* (allowed since v1.25) is sealed with an empty label, which every reader
|
||||
* shows as no label.
|
||||
*/
|
||||
const homeLocationSealed: SettingCodec = {
|
||||
const homeLocation: SettingCodec = {
|
||||
write: (row, disasterRecovery) => {
|
||||
if (!disasterRecovery) return {};
|
||||
const sealed = row.homeLocationEncrypted;
|
||||
return {
|
||||
homeLocationEncrypted:
|
||||
sealed instanceof Uint8Array
|
||||
? Buffer.from(sealed).toString("base64")
|
||||
: null,
|
||||
};
|
||||
const sealed =
|
||||
row.homeLocationEncrypted instanceof Uint8Array
|
||||
? row.homeLocationEncrypted
|
||||
: null;
|
||||
if (disasterRecovery) {
|
||||
return {
|
||||
homeLat: row.homeLat,
|
||||
homeLon: row.homeLon,
|
||||
homeLabel: row.homeLabel,
|
||||
homeLocationEncrypted:
|
||||
sealed && sealed.byteLength > 0
|
||||
? Buffer.from(sealed).toString("base64")
|
||||
: null,
|
||||
};
|
||||
}
|
||||
const home = readLocation({
|
||||
sealed,
|
||||
lat: row.homeLat as number | null,
|
||||
lon: row.homeLon as number | null,
|
||||
label: (row.homeLabel as string | null) ?? "",
|
||||
});
|
||||
return home
|
||||
? {
|
||||
homeLat: home.lat,
|
||||
homeLon: home.lon,
|
||||
homeLabel: home.label === "" ? null : home.label,
|
||||
}
|
||||
: { homeLat: null, homeLon: null, homeLabel: null };
|
||||
},
|
||||
read: (entry) => {
|
||||
if (!has(entry, "homeLocationEncrypted")) return undefined;
|
||||
const value = entry.homeLocationEncrypted;
|
||||
if (value === null) return null;
|
||||
if (typeof value !== "string") return new Refused("homeLocationEncrypted");
|
||||
const buffer = Buffer.from(value, "base64");
|
||||
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
return bytes;
|
||||
const cleared = {
|
||||
homeLocationEncrypted: null,
|
||||
homeLat: null,
|
||||
homeLon: null,
|
||||
homeLabel: null,
|
||||
};
|
||||
const sealedValue = entry.homeLocationEncrypted;
|
||||
if (has(entry, "homeLocationEncrypted") && sealedValue !== null) {
|
||||
if (typeof sealedValue !== "string") {
|
||||
return new Refused("homeLocationEncrypted");
|
||||
}
|
||||
const buffer = Buffer.from(sealedValue, "base64");
|
||||
if (buffer.byteLength === 0) return new Refused("homeLocationEncrypted");
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
return { ...cleared, homeLocationEncrypted: bytes };
|
||||
}
|
||||
if (!has(entry, "homeLat") && !has(entry, "homeLon")) {
|
||||
// Neither form in the file: a sealed null alone still says "no home".
|
||||
return has(entry, "homeLocationEncrypted") ? cleared : undefined;
|
||||
}
|
||||
const lat = entry.homeLat ?? null;
|
||||
const lon = entry.homeLon ?? null;
|
||||
const label = entry.homeLabel ?? null;
|
||||
if (lat === null || lon === null) return cleared;
|
||||
if (
|
||||
typeof lat !== "number" ||
|
||||
typeof lon !== "number" ||
|
||||
(label !== null && typeof label !== "string")
|
||||
) {
|
||||
return new Refused("homeLat");
|
||||
}
|
||||
return {
|
||||
...cleared,
|
||||
homeLocationEncrypted: sealLocation({ lat, lon, label: label ?? "" }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Carried by the home codec above. */
|
||||
const ridesWithHome: SettingCodec = {
|
||||
write: () => ({}),
|
||||
read: () => undefined,
|
||||
};
|
||||
|
||||
const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
Record<AccountSettingColumn, SettingCodec>
|
||||
> = {
|
||||
@@ -428,12 +493,12 @@ const ACCOUNT_SETTING_CODECS: Readonly<
|
||||
avatarBytes: avatar,
|
||||
avatarContentType: ridesWithAvatar,
|
||||
avatarUpdatedAt: ridesWithAvatar,
|
||||
homeLat: plain("homeLat"),
|
||||
homeLon: plain("homeLon"),
|
||||
homeLabel: plain("homeLabel"),
|
||||
homeLat: ridesWithHome,
|
||||
homeLon: ridesWithHome,
|
||||
homeLabel: ridesWithHome,
|
||||
homeTimezone: timeZone("homeTimezone", true),
|
||||
homeSince: instant("homeSince"),
|
||||
homeLocationEncrypted: homeLocationSealed,
|
||||
homeLocationEncrypted: homeLocation,
|
||||
environmentAirQualityEnabled: plain("environmentAirQualityEnabled"),
|
||||
timezone: timeZone("timezone", false),
|
||||
locale: oneOf("locale", locales, true),
|
||||
@@ -560,8 +625,9 @@ export function admitAccountSettings(
|
||||
refused.push(...value.refused);
|
||||
continue;
|
||||
}
|
||||
if (column === "avatarBytes") {
|
||||
// The avatar codec answers for all three of its columns at once.
|
||||
if (column === "avatarBytes" || column === "homeLocationEncrypted") {
|
||||
// The avatar codec answers for all three of its columns at once, the
|
||||
// home codec for all four of its.
|
||||
Object.assign(data, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,19 +51,73 @@
|
||||
* 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
|
||||
* ## Two purposes since v1.42
|
||||
*
|
||||
* 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.
|
||||
* The coarse locations are sealed at rest from v1.42 (`locationEncrypted`,
|
||||
* see `src/lib/environment/location-cipher.ts`), so the two purposes differ
|
||||
* the way every other section with a sealed column does:
|
||||
*
|
||||
* - a portable export opens the sealed location and writes the readable
|
||||
* `lat` / `lon` / label, and no `locationEncrypted` at all, so the file
|
||||
* reads anywhere;
|
||||
* - a disaster-recovery file carries `locationEncrypted` verbatim (base64)
|
||||
* beside whatever readable columns the row still holds, for a host with
|
||||
* the same keys.
|
||||
*
|
||||
* The restore takes either: a sealed value is written as it came, a readable
|
||||
* one is sealed under this host's key, and the readable columns are written
|
||||
* empty in both cases. A row that holds neither restores without a location;
|
||||
* it is never invented.
|
||||
*
|
||||
* The air-quality columns (v1.42) are plain values and ride both purposes
|
||||
* alike. Every one is optional on the way in: a file written before them
|
||||
* says nothing about air quality, and the restored day then reads as never
|
||||
* fetched (`aqFetchedAt` absent), so the nightly gap fill asks for it.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
|
||||
import { roundCoarse } from "@/lib/environment/open-meteo";
|
||||
import type { AirQualityDay } from "@/lib/environment/air-quality-contract";
|
||||
import { readLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
|
||||
export interface EnvironmentBackupOptions {
|
||||
purpose?: "portable-export" | "disaster-recovery";
|
||||
}
|
||||
|
||||
/** The air-quality columns of a stored day, as they ride the file. */
|
||||
type AirQualityBackupColumns = Omit<AirQualityDay, "aqFetchedAt"> & {
|
||||
/** When the air-quality part was fetched; null = not yet. Verbatim. */
|
||||
aqFetchedAt: string | null;
|
||||
};
|
||||
|
||||
/** The air-quality column names, in file order. */
|
||||
const AIR_QUALITY_COLUMNS = [
|
||||
"apparentMax",
|
||||
"pm25Mean",
|
||||
"pm25Max",
|
||||
"pm10Mean",
|
||||
"no2Mean",
|
||||
"so2Mean",
|
||||
"coMean",
|
||||
"o3Max8h",
|
||||
"eaqiMax",
|
||||
"usaqiMax",
|
||||
"uvIndexMax",
|
||||
"dustMax",
|
||||
"aodMax",
|
||||
"pollenAlderMax",
|
||||
"pollenBirchMax",
|
||||
"pollenGrassMax",
|
||||
"pollenMugwortMax",
|
||||
"pollenOliveMax",
|
||||
"pollenRagweedMax",
|
||||
"aqDomain",
|
||||
"aqHours",
|
||||
] as const satisfies ReadonlyArray<keyof AirQualityBackupColumns>;
|
||||
|
||||
/** One day's environmental observation, at the location resolved for that day. */
|
||||
export interface EnvironmentContextBackupEntry {
|
||||
export interface EnvironmentContextBackupEntry extends AirQualityBackupColumns {
|
||||
/** `YYYY-MM-DD`, anchored to the resolved location's timezone. */
|
||||
date: string;
|
||||
/**
|
||||
@@ -73,6 +127,11 @@ export interface EnvironmentContextBackupEntry {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
locationLabel: string | null;
|
||||
/**
|
||||
* The sealed location, base64. Disaster-recovery files only; a portable
|
||||
* file carries the readable columns instead.
|
||||
*/
|
||||
locationEncrypted?: string | null;
|
||||
/**
|
||||
* 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
|
||||
@@ -106,6 +165,8 @@ export interface EnvironmentTravelLocationBackupEntry {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
label: string | null;
|
||||
/** The sealed location, base64; disaster-recovery files only. */
|
||||
locationEncrypted?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -132,6 +193,7 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
|
||||
lat: true,
|
||||
lon: true,
|
||||
locationLabel: true,
|
||||
locationEncrypted: true,
|
||||
source: true,
|
||||
tempMin: true,
|
||||
tempMax: true,
|
||||
@@ -145,6 +207,28 @@ const ENVIRONMENT_CONTEXT_BACKUP_SELECT = {
|
||||
humidityMean: true,
|
||||
cloudMean: true,
|
||||
weatherCode: true,
|
||||
apparentMax: true,
|
||||
pm25Mean: true,
|
||||
pm25Max: true,
|
||||
pm10Mean: true,
|
||||
no2Mean: true,
|
||||
so2Mean: true,
|
||||
coMean: true,
|
||||
o3Max8h: true,
|
||||
eaqiMax: true,
|
||||
usaqiMax: true,
|
||||
uvIndexMax: true,
|
||||
dustMax: true,
|
||||
aodMax: true,
|
||||
pollenAlderMax: true,
|
||||
pollenBirchMax: true,
|
||||
pollenGrassMax: true,
|
||||
pollenMugwortMax: true,
|
||||
pollenOliveMax: true,
|
||||
pollenRagweedMax: true,
|
||||
aqDomain: true,
|
||||
aqHours: true,
|
||||
aqFetchedAt: true,
|
||||
fetchedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
@@ -156,10 +240,53 @@ const ENVIRONMENT_TRAVEL_LOCATION_BACKUP_SELECT = {
|
||||
lat: true,
|
||||
lon: true,
|
||||
label: true,
|
||||
locationEncrypted: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} as const satisfies Prisma.EnvironmentTravelLocationSelect;
|
||||
|
||||
/** A sealed column as it rides a disaster-recovery file. */
|
||||
function toBase64(bytes: Uint8Array | null): string | null {
|
||||
return bytes && bytes.byteLength > 0
|
||||
? Buffer.from(bytes).toString("base64")
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The location columns of one entry: opened and readable for a portable
|
||||
* file, as stored plus the sealed value for a disaster-recovery file.
|
||||
*/
|
||||
function locationFields(
|
||||
row: {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
label: string | null;
|
||||
locationEncrypted: Uint8Array | null;
|
||||
},
|
||||
disasterRecovery: boolean,
|
||||
): {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
label: string | null;
|
||||
locationEncrypted?: string | null;
|
||||
} {
|
||||
if (disasterRecovery) {
|
||||
return {
|
||||
lat: row.lat,
|
||||
lon: row.lon,
|
||||
label: row.label,
|
||||
locationEncrypted: toBase64(row.locationEncrypted),
|
||||
};
|
||||
}
|
||||
const location = readLocation({
|
||||
sealed: row.locationEncrypted,
|
||||
lat: row.lat,
|
||||
lon: row.lon,
|
||||
label: row.label,
|
||||
});
|
||||
return location ?? { lat: null, lon: null, label: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the environment slice of a user's full backup.
|
||||
*
|
||||
@@ -172,7 +299,9 @@ export async function buildEnvironmentBackupSection(
|
||||
"environmentContext" | "environmentTravelLocation"
|
||||
>,
|
||||
userId: string,
|
||||
options: EnvironmentBackupOptions = {},
|
||||
): Promise<EnvironmentBackupSection> {
|
||||
const disasterRecovery = options.purpose === "disaster-recovery";
|
||||
const [contextRows, travelRows] = await Promise.all([
|
||||
prisma.environmentContext.findMany({
|
||||
where: { userId },
|
||||
@@ -187,34 +316,64 @@ export async function buildEnvironmentBackupSection(
|
||||
]);
|
||||
|
||||
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(),
|
||||
})),
|
||||
environmentContexts: contextRows.map((row) => {
|
||||
const { label, ...location } = locationFields(
|
||||
{
|
||||
lat: row.lat,
|
||||
lon: row.lon,
|
||||
label: row.locationLabel,
|
||||
locationEncrypted: row.locationEncrypted,
|
||||
},
|
||||
disasterRecovery,
|
||||
);
|
||||
return {
|
||||
date: row.date,
|
||||
...location,
|
||||
locationLabel: label,
|
||||
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,
|
||||
apparentMax: row.apparentMax,
|
||||
pm25Mean: row.pm25Mean,
|
||||
pm25Max: row.pm25Max,
|
||||
pm10Mean: row.pm10Mean,
|
||||
no2Mean: row.no2Mean,
|
||||
so2Mean: row.so2Mean,
|
||||
coMean: row.coMean,
|
||||
o3Max8h: row.o3Max8h,
|
||||
eaqiMax: row.eaqiMax,
|
||||
usaqiMax: row.usaqiMax,
|
||||
uvIndexMax: row.uvIndexMax,
|
||||
dustMax: row.dustMax,
|
||||
aodMax: row.aodMax,
|
||||
pollenAlderMax: row.pollenAlderMax,
|
||||
pollenBirchMax: row.pollenBirchMax,
|
||||
pollenGrassMax: row.pollenGrassMax,
|
||||
pollenMugwortMax: row.pollenMugwortMax,
|
||||
pollenOliveMax: row.pollenOliveMax,
|
||||
pollenRagweedMax: row.pollenRagweedMax,
|
||||
aqDomain: row.aqDomain,
|
||||
aqHours: row.aqHours,
|
||||
aqFetchedAt: row.aqFetchedAt?.toISOString() ?? null,
|
||||
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,
|
||||
...locationFields(row, disasterRecovery),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
})),
|
||||
@@ -254,6 +413,9 @@ export type RestoredEnvironmentContext = Pick<
|
||||
OptionalNullable<
|
||||
Pick<
|
||||
EnvironmentContextBackupEntry,
|
||||
| "locationEncrypted"
|
||||
| (typeof AIR_QUALITY_COLUMNS)[number]
|
||||
| "aqFetchedAt"
|
||||
| "tempMin"
|
||||
| "tempMax"
|
||||
| "tempMean"
|
||||
@@ -277,9 +439,38 @@ export type RestoredEnvironmentTravelLocation = Pick<
|
||||
"startDate" | "endDate" | "lat" | "lon" | "label"
|
||||
> &
|
||||
OptionalNullable<
|
||||
Pick<EnvironmentTravelLocationBackupEntry, "createdAt" | "updatedAt">
|
||||
Pick<
|
||||
EnvironmentTravelLocationBackupEntry,
|
||||
"locationEncrypted" | "createdAt" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
/**
|
||||
* The sealed location a restored row is written with: the file's sealed
|
||||
* value as it came, or the file's readable location sealed under this host's
|
||||
* key (rounded to the privacy floor first, so a file written before v1.39.4
|
||||
* with two decimals comes back coarse). Null when the file carries neither.
|
||||
*/
|
||||
function restoredLocation(entry: {
|
||||
locationEncrypted?: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
label: string | null;
|
||||
}): Uint8Array<ArrayBuffer> | null {
|
||||
if (typeof entry.locationEncrypted === "string") {
|
||||
const buffer = Buffer.from(entry.locationEncrypted, "base64");
|
||||
if (buffer.byteLength > 0) {
|
||||
const bytes = new Uint8Array(new ArrayBuffer(buffer.byteLength));
|
||||
bytes.set(buffer);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
if (entry.lat == null || entry.lon == null || entry.label == null) {
|
||||
return null;
|
||||
}
|
||||
return sealLocation({ lat: entry.lat, lon: entry.lon, label: entry.label });
|
||||
}
|
||||
|
||||
export interface EnvironmentRestoreInput {
|
||||
environmentContexts: RestoredEnvironmentContext[];
|
||||
environmentTravelLocations: RestoredEnvironmentTravelLocation[];
|
||||
@@ -318,11 +509,11 @@ export async function restoreEnvironmentData(
|
||||
// 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: entry.lat == null ? null : roundCoarse(entry.lat),
|
||||
lon: entry.lon == null ? null : roundCoarse(entry.lon),
|
||||
label: entry.label,
|
||||
// Sealed, never readable: see `restoredLocation`.
|
||||
locationEncrypted: restoredLocation(entry),
|
||||
lat: null,
|
||||
lon: null,
|
||||
label: null,
|
||||
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
|
||||
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
|
||||
})),
|
||||
@@ -334,9 +525,15 @@ export async function restoreEnvironmentData(
|
||||
data: payload.environmentContexts.map((entry) => ({
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
lat: entry.lat == null ? null : roundCoarse(entry.lat),
|
||||
lon: entry.lon == null ? null : roundCoarse(entry.lon),
|
||||
locationLabel: entry.locationLabel,
|
||||
locationEncrypted: restoredLocation({
|
||||
locationEncrypted: entry.locationEncrypted,
|
||||
lat: entry.lat,
|
||||
lon: entry.lon,
|
||||
label: entry.locationLabel,
|
||||
}),
|
||||
lat: null,
|
||||
lon: null,
|
||||
locationLabel: null,
|
||||
source: entry.source,
|
||||
tempMin: entry.tempMin ?? null,
|
||||
tempMax: entry.tempMax ?? null,
|
||||
@@ -350,6 +547,12 @@ export async function restoreEnvironmentData(
|
||||
humidityMean: entry.humidityMean ?? null,
|
||||
cloudMean: entry.cloudMean ?? null,
|
||||
weatherCode: entry.weatherCode ?? null,
|
||||
...Object.fromEntries(
|
||||
AIR_QUALITY_COLUMNS.map((column) => [column, entry[column] ?? null]),
|
||||
),
|
||||
// Verbatim, like `fetchedAt`; absent means the air-quality part was
|
||||
// never fetched, and the nightly gap fill will ask for it.
|
||||
aqFetchedAt: entry.aqFetchedAt ? new Date(entry.aqFetchedAt) : 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) } : {}),
|
||||
|
||||
@@ -886,9 +886,11 @@ export async function buildFullBackupPayload(
|
||||
buildAwardsBackupSection(prisma, userId),
|
||||
// The per-day readings and the location periods that explain them, which
|
||||
// is why one section carries both. Both ends live in
|
||||
// `src/lib/export/environment-backup.ts`, and the purpose is absent for
|
||||
// the same reason as the awards above.
|
||||
buildEnvironmentBackupSection(prisma, userId),
|
||||
// `src/lib/export/environment-backup.ts`. Since v1.42 the locations are
|
||||
// sealed, so the purpose decides whether they ride opened or sealed.
|
||||
buildEnvironmentBackupSection(prisma, userId, {
|
||||
purpose: disasterRecovery ? "disaster-recovery" : "portable-export",
|
||||
}),
|
||||
// The needs-based setup answers. One small row, and the only copy of what
|
||||
// the person told the flow. Both ends live in
|
||||
// `src/lib/export/onboarding-backup.ts` beside each other.
|
||||
|
||||
Reference in new issue
Block a user