From 3d309bb91b1a01bbffaa896ca85e210e642b0bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 16 May 2026 20:24:31 +0200 Subject: [PATCH] feat(import): add ImportJob model for Apple Health export ingest Additive Prisma model mirroring a `pg-boss` `apple-health-import` job's lifecycle. The polling endpoint reads `progress` and `result` live from this row rather than the one-shot terminal `output` column on `pg_boss.job` (pg-boss v12 only populates `output` on completion). Carries `pgBossJobId` for the worker's reverse lookup, an upload sha-256 for content-hash idempotency on re-upload, and per-phase status (`queued | unpacking | parsing | upserting | done | failed`). --- .../0066_v1434_import_jobs/migration.sql | 45 ++++++++++++++ prisma/schema.prisma | 60 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 prisma/migrations/0066_v1434_import_jobs/migration.sql diff --git a/prisma/migrations/0066_v1434_import_jobs/migration.sql b/prisma/migrations/0066_v1434_import_jobs/migration.sql new file mode 100644 index 000000000..8cfd958f0 --- /dev/null +++ b/prisma/migrations/0066_v1434_import_jobs/migration.sql @@ -0,0 +1,45 @@ +-- v1.4.34 — Apple Health export.zip import jobs +-- +-- Additive-only — one new table mirroring the lifecycle of a pg-boss +-- `apple-health-import` job. The polling endpoint reads from this +-- table so the operator sees live per-phase progress rather than the +-- one-shot terminal `output` column on `pg_boss.job` (v12 contract). +-- +-- `pg_boss_job_id` is nullable so the `INSERT` can land before the +-- kick-off route gets the boss id back from `boss.send`. The unique +-- index covers the populated rows; NULL values are distinct under +-- the default Postgres semantics so two pre-send rows never collide. +-- +-- Locks per `.planning/research/v1434-r-1-xml-import.md` §5.2. + +CREATE TABLE IF NOT EXISTS "import_jobs" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "triggered_by_admin_id" TEXT, + "pg_boss_job_id" TEXT, + "status" TEXT NOT NULL DEFAULT 'queued', + "failure_reason" TEXT, + "upload_bytes" INTEGER NOT NULL, + "upload_sha256" TEXT, + "exported_at" TIMESTAMP(3), + "started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completed_at" TIMESTAMP(3), + "progress" JSONB NOT NULL DEFAULT '{}'::jsonb, + "result" JSONB, + + CONSTRAINT "import_jobs_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "import_jobs_pg_boss_job_id_key" + ON "import_jobs" ("pg_boss_job_id"); + +CREATE INDEX IF NOT EXISTS "import_jobs_user_id_started_at_idx" + ON "import_jobs" ("user_id", "started_at"); + +CREATE INDEX IF NOT EXISTS "import_jobs_status_idx" + ON "import_jobs" ("status"); + +ALTER TABLE "import_jobs" + ADD CONSTRAINT "import_jobs_user_id_fkey" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81c15483f..1f30aa215 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -246,6 +246,10 @@ model User { // capture (not per-shot) so delayed-onset symptoms map to their // actual occurrence time. medicationSideEffects MedicationSideEffect[] + // v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the + // pg-boss job state so the polling endpoint can read live per- + // phase progress without round-tripping through pg-boss internals. + importJobs ImportJob[] @@map("users") } @@ -1382,6 +1386,62 @@ model DataBackup { @@map("data_backups") } +// ─── Apple Health export.zip import (v1.4.34) ─────────────── +// +// Mirrors a `pg-boss` `apple-health-import` job's lifecycle so the +// polling endpoint can read live per-phase progress without +// round-tripping through pg-boss internals (`output` only populates +// on completion in v12). The `pgBossJobId` link lets the worker +// reconcile a row by the boss id it just received from `boss.send`. +// +// Status state machine: +// queued → unpacking → parsing → upserting → done +// ↘ failed +// +// `progress` carries the live snapshot the worker writes every +// 1 000 records (see `ImportJobProgress` in +// `src/lib/measurements/import-apple-health-export.ts`). `result` +// carries the terminal `ImportJobResult` envelope. +model ImportJob { + id String @id @default(cuid()) + userId String @map("user_id") + /// Admin who triggered the import on behalf of `userId` (admin + /// variant only). NULL when the user kicked off their own import. + triggeredByAdminId String? @map("triggered_by_admin_id") + /// `pg-boss` job id assigned by `boss.send`. Allows the worker to + /// look up its mirror row by the boss id rather than passing the + /// HealthLog id through the payload. + pgBossJobId String? @unique @map("pg_boss_job_id") + /// `queued | unpacking | parsing | upserting | done | failed`. + status String @default("queued") + /// Free-text reason on `failed`; null otherwise. + failureReason String? @map("failure_reason") + /// Bytes uploaded — surfaced to the operator audit trail. + uploadBytes Int @map("upload_bytes") + /// SHA-256 hex digest of the uploaded `export.zip`. Lets the + /// kick-off endpoint short-circuit a re-upload of the exact same + /// file back to the existing job id without re-queueing. + uploadSha256 String? @map("upload_sha256") + /// Apple's `ExportDate` attribute string-formatted, parsed during + /// the unpack phase. NULL until the parser sees it. + exportedAt DateTime? @map("exported_at") + startedAt DateTime @default(now()) @map("started_at") + completedAt DateTime? @map("completed_at") + /// JSON envelope mirroring `ImportJobProgress` (`src/lib/measurements/ + /// import-apple-health-export.ts`). Updated mid-run. + progress Json @default("{}") + /// JSON envelope mirroring `ImportJobResult`. NULL until terminal + /// state. Carries per-type counts, workout counts, clinical / + /// deferred / unknown buckets. + result Json? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, startedAt]) + @@index([status]) + @@map("import_jobs") +} + // ─── Rate Limiting ────────────────────────────────────────── model RateLimit {