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`).
This commit is contained in:
Marc-André Bombeck committed 2026-05-16 20:24:31 +02:00
1 parent 2a3fb0e913
commit 3d309bb91b
2 files changed
+105

No files matched your search

@@ -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;
+60
View File
@@ -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 {