mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(measurements): delete a blood-pressure reading as one
Systolic, diastolic and the cuff's pulse are three rows with no link column, and the single DELETE tombstoned only the row it was given, so deleting a reading's systolic left its diastolic and pulse behind as half a reading. The bulk delete had the same gap. A reading is the rows with the same owner, the same source and the identical measuredAt: that is how the entry form, the MCP write, a Withings measure group and a HealthKit correlation all store one, and how the FHIR read pairs a diastolic onto its systolic. The looser five-minute pairing of the chart reads is right for drawing a point and wrong for taking rows away. Deleting a systolic or diastolic now tombstones the rest of its reading in the same transaction with the same deletedAt, each row with its own syncVersion bump so paired clients see every tombstone. The restore route brings back the rows deleted together with a named half (identical deletedAt), so the undo, which names only the row that was tapped, puts the whole reading back, from the web list and from any client that calls it the same way. A pulse is still deleted on its own, and a row deleted at another moment stays deleted. The response shapes do not change; the OpenAPI descriptions say what the counts now include.
This commit is contained in:
1 parent
d88313e929
commit
e2998183c8
9 files changed
+430
-48
No files matched your search
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* One blood-pressure reading is up to three rows: systolic, diastolic and
|
||||
* the pulse the cuff measured with them. No column links them. What makes
|
||||
* them one reading is how every writer stores it: the entry form, the MCP
|
||||
* write, a Withings measure group and a HealthKit correlation all write the
|
||||
* halves with the same source at the same instant, and the FHIR read pairs a
|
||||
* diastolic onto its systolic by exactly that instant.
|
||||
*
|
||||
* So the rule here is the strict one: same owner, same source, identical
|
||||
* `measuredAt`. The series and in-target reads pair more loosely (nearest
|
||||
* diastolic within five minutes) because they only draw a point; a delete
|
||||
* takes rows away, so it takes only the rows that can be nothing else.
|
||||
*
|
||||
* Deleting a systolic or a diastolic takes the whole reading with it.
|
||||
* Deleting a pulse takes only the pulse: a pulse is also a value of its own,
|
||||
* and removing one is no reason to remove a blood pressure.
|
||||
*/
|
||||
import type { MeasurementType, Prisma } from "@/generated/prisma/client";
|
||||
|
||||
/** The types that anchor a reading: deleting one deletes the reading. */
|
||||
const BP_HALVES: ReadonlySet<string> = new Set([
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
]);
|
||||
|
||||
/** Every type a reading's rows can carry. */
|
||||
const BP_READING_TYPES: MeasurementType[] = [
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
];
|
||||
|
||||
export interface ReadingAnchor {
|
||||
id: string;
|
||||
type: MeasurementType | string;
|
||||
source: string;
|
||||
measuredAt: Date;
|
||||
deletedAt?: Date | null;
|
||||
}
|
||||
|
||||
type MeasurementReader = {
|
||||
measurement: Pick<Prisma.TransactionClient["measurement"], "findMany">;
|
||||
};
|
||||
|
||||
export interface ReadingSibling {
|
||||
id: string;
|
||||
type: MeasurementType;
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* The other rows of the readings `rows` belong to, not counting `rows`
|
||||
* themselves. `state: "live"` finds the rows a delete takes with it;
|
||||
* `state: "deletedWith"` finds the rows that were tombstoned in the same
|
||||
* statement as their anchor (the identical `deletedAt`), which is what a
|
||||
* restore brings back with it, and never a row deleted on its own.
|
||||
*/
|
||||
export async function bloodPressureSiblings(
|
||||
db: MeasurementReader,
|
||||
userId: string,
|
||||
rows: readonly ReadingAnchor[],
|
||||
state: "live" | "deletedWith",
|
||||
): Promise<ReadingSibling[]> {
|
||||
const anchors = rows.filter(
|
||||
(r) => BP_HALVES.has(r.type) && (state === "live" || r.deletedAt),
|
||||
);
|
||||
if (anchors.length === 0) return [];
|
||||
const found = await db.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
id: { notIn: rows.map((r) => r.id) },
|
||||
type: { in: BP_READING_TYPES },
|
||||
OR: anchors.map((a) => ({
|
||||
source: a.source as Prisma.MeasurementWhereInput["source"],
|
||||
measuredAt: a.measuredAt,
|
||||
deletedAt: state === "live" ? null : a.deletedAt,
|
||||
})),
|
||||
},
|
||||
select: { id: true, type: true, measuredAt: true },
|
||||
});
|
||||
return found;
|
||||
}
|
||||
@@ -1055,7 +1055,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Delete a measurement",
|
||||
description:
|
||||
"Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.",
|
||||
"Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. A blood-pressure systolic or diastolic takes the rest of its reading with it: the other half and the pulse with the same source and the identical `measuredAt`, tombstoned in the same transaction with the same `deletedAt`, each with its own `syncVersion` bump. A pulse is deleted on its own.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
@@ -1145,7 +1145,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Bulk soft-delete measurements",
|
||||
description:
|
||||
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned. The deletions surface in `/api/sync/changes` as tombstones.",
|
||||
"Soft-deletes (tombstones) up to 200 of the caller's measurement rows in one call, mirroring the single-DELETE contract. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:bulk-delete:<userId>`. Forged / foreign / already-deleted ids are silently skipped — `deleted` is the count of rows actually tombstoned, which includes the rest of any blood-pressure reading a selected systolic or diastolic belongs to (as on the single DELETE). The deletions surface in `/api/sync/changes` as tombstones.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1176,7 +1176,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Restore soft-deleted measurements",
|
||||
description:
|
||||
"Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. The restored rows re-surface in `/api/sync/changes` as upserts.",
|
||||
"Un-tombstones up to 200 of the caller's soft-deleted measurement rows in one call — the delete-Undo counterpart to `/api/measurements/bulk-delete`. Idempotent via the `Idempotency-Key` header; rate-limited `measurements:restore:<userId>`. Forged / foreign / not-deleted ids are silently skipped — `restored` is the count of rows actually un-tombstoned. A blood-pressure systolic or diastolic also restores the rows of its reading that were deleted with it (same source, identical `measuredAt` and `deletedAt`), so naming one half undoes the whole reading; a row deleted at another moment stays deleted. The restored rows re-surface in `/api/sync/changes` as upserts.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
|
||||
Reference in new issue
Block a user