diff --git a/README.md b/README.md index f852b06..36e4b64 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,20 @@ After provisioning Supabase/Upstash/Vercel and `npm run db:push`: See [docs/supabase-shared-config.md](./docs/supabase-shared-config.md) for the shared-project rules and [docs/design-guidelines.md](./docs/design-guidelines.md) for UI conventions. +### Migrating data from the original BSK app + +An existing install of the upstream Java app can be imported from its SQLite file (`database/BSK.db`): + +```bash +npm run db:migrate-upstream -- /path/to/BSK.db --dry-run # preview counts + warnings, no writes +NEXT_PUBLIC_SUPABASE_URL=... SUPABASE_SECRET_KEY=... \ + npm run db:migrate-upstream -- /path/to/BSK.db +``` + +**Copying the file:** the upstream server runs SQLite in WAL mode — stop the Java server first and copy `BSK.db` together with its `BSK.db-wal`/`BSK.db-shm` siblings (or checkpoint first), otherwise the newest visits are silently missing. + +Run it once, after `npm run db:push`, against an otherwise empty `bsk` schema (it re-keys all ids and refuses a non-empty target). Migrated: clinic settings, doctors, medicines, services, checkup templates (upstream RTF content converted to plain-text field labels — review them in the admin UI afterwards), patients (including pre-1970 birth dates), visits (shift 0/1 → morning/afternoon; suggestion, per-service notes, and the ultrasound doctor are folded into the visit notes), prescriptions, service lines, payment state, queue counters. **Not** migrated: staff accounts (re-invite via staff management — upstream passwords are never reused), patient images (Google Drive stays where it is), medicine descriptions/preferred notes (warned when present), and patient province/ward codes (the names remain readable at the end of each address string; backfill codes in the UI after `npm run db:seed-geo`). The script header and the reports `researcher-260818-1712-…-migration-mapping` and `code-reviewer-260818-1749-…-upstream-compat-audit` under `plans/reports/` document the full field mapping and its source-level verification. + ## Stack - **npm** + **Next.js 16** (App Router) + **JavaScript with JSDoc types** (checked by tsc) diff --git a/package.json b/package.json index 088c9c9..eda935a 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "db:push": "npm run db:preflight && supabase db push", "db:gen-types": "supabase gen types typescript --schema bsk > types/supabase-bsk.d.ts", "db:seed-geo": "node scripts/seed-geo.mjs", + "db:migrate-upstream": "node scripts/migrate-from-upstream.mjs", "check:no-secret-leak": "node scripts/check-no-secret-leak.mjs" }, "dependencies": { diff --git a/scripts/migrate-from-upstream.mjs b/scripts/migrate-from-upstream.mjs new file mode 100644 index 0000000..6787d67 --- /dev/null +++ b/scripts/migrate-from-upstream.mjs @@ -0,0 +1,847 @@ +#!/usr/bin/env node +/** + * Migrate data from an original BSK desktop install (Java/Swing + SQLite, + * https://github.com/lds217/BSK-All-in-One-Clinic-Management-System) into this + * rewrite's Supabase `bsk` schema. + * + * Usage: + * NEXT_PUBLIC_SUPABASE_URL=... SUPABASE_SECRET_KEY=... \ + * npm run db:migrate-upstream -- /path/to/BSK.db [--dry-run] [--allow-nonempty] + * + * --dry-run Read + transform only; prints what would be migrated and + * every warning. Needs no Supabase env vars. + * --allow-nonempty Skip the "target must be empty" preflight (the script + * re-keys all ids, so re-running WILL duplicate rows — + * only use this when you know what you are doing). + * + * IMPORTANT — the upstream server runs SQLite in WAL mode with periodic + * checkpoints. Stop the Java server before copying, and copy BSK.db TOGETHER + * with its BSK.db-wal / BSK.db-shm siblings (or run `PRAGMA wal_checkpoint;` + * first) — otherwise the most recent visits are silently missing. + * + * Field-level mapping decisions are documented in + * plans/reports/researcher-260818-1712-upstream-sqlite-to-supabase-migration- + * mapping-report.md, and every assumption below was verified against the real + * Java source in plans/reports/code-reviewer-260818-1749-migration-script- + * upstream-compat-audit-report.md. Highlights: + * - All primary keys are re-keyed (target uses GENERATED ALWAYS AS IDENTITY); + * foreign keys are remapped via in-memory old→new id maps. + * - Money: upstream DOUBLE VND → integer VND (rounded, clamped to >= 0). + * - Dates: upstream stores epoch millis (negative for pre-1970 birth dates) + * → clinic-local (UTC+7) DATE. + * - Shifts: upstream 0=morning / 1=afternoon → target shift_id 1 / 2. + * - Enum-ish text (status, gender, payment) is translated from the exact + * labels the Java app writes; unknown values fall back with a warning. + * - Template `content` is RTF (Swing RTFEditorKit); it is converted to plain + * text and each line becomes one field label — review templates after. + * - Folded into the visit's notes (no dedicated target column): the + * "suggestion" text, per-service-line notes, and the ultrasound doctor. + * remind_date is used as recheck_date when reCheckupDate is empty. + * - Dropped (no target column; warned when populated): medicine + * preferred_note / description / supplement flag, order processed_by / + * total_amount (total is recomputed from line items by the app). + * - Staff accounts are NOT migrated (auth lives in Supabase now, upstream + * passwords must not be reused). The upstream roster is printed so the + * admin can re-invite staff via the app's staff-management UI. + * - Patient media (Google Drive ids) stays behind. province/ward codes are + * left NULL — upstream appends ", ward, province" NAMES to the address + * string, so they remain readable there; backfill codes in the UI. Geo + * reference data always comes from `npm run db:seed-geo`, never upstream. + * + * Requires Node >= 24 (uses the built-in node:sqlite driver, read-only). + */ + +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { createClient } from "@supabase/supabase-js"; +import { + GENDER_MAP, + PAID_LABELS, + STATUS_MAP, + bool, + int, + mapShift, + money, + norm, + rtfToText, + setOnWarn, + str, + vnDate, +} from "./upstream-transforms.mjs"; + +/** @typedef {string | number | bigint | Uint8Array | null} SqlValue */ +/** @typedef {Record} Row */ + +/** + * @param {string} msg + * @returns {never} + */ +function die(msg) { + process.stderr.write(`\n[migrate-upstream] ERROR: ${msg}\n\n`); + process.exit(1); +} + +/** @type {string[]} */ +const warnings = []; +/** @param {string} msg */ +function warn(msg) { + warnings.push(msg); +} +setOnWarn(warn); // transforms report dropped/odd values through the same sink + +/** @param {string} msg */ +function log(msg) { + process.stdout.write(`[migrate-upstream] ${msg}\n`); +} + +// ─── CLI / env ──────────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const allowNonempty = args.includes("--allow-nonempty"); +const dbArg = args.find((a) => !a.startsWith("--")); +if (!dbArg) { + die("Usage: npm run db:migrate-upstream -- /path/to/BSK.db [--dry-run] [--allow-nonempty]"); +} +const dbPath = resolve(process.cwd(), dbArg); +if (!existsSync(dbPath)) die(`SQLite file not found: ${dbPath}`); + +/** @typedef {import("@supabase/supabase-js").SupabaseClient} BskClient */ + +/** @type {BskClient | null} */ +let supabase = null; +if (!dryRun) { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SECRET_KEY; + if (!url || !key) { + die( + "Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SECRET_KEY in the environment " + + "(or pass --dry-run to preview without writing).", + ); + } + supabase = createClient(url, key, { + db: { schema: "bsk" }, + auth: { persistSession: false }, + }); +} + +// ─── SQLite helpers (schema-tolerant: the upstream app evolved, so we probe +// tables/columns instead of assuming the exact shape) ───────────────────── + +const sqlite = new DatabaseSync(dbPath, { readOnly: true }); + +/** + * Actual table name in the file for a case-insensitive lookup, or null. + * @param {string} name + * @returns {string | null} + */ +function findTable(name) { + const row = /** @type {Row | undefined} */ ( + sqlite + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND lower(name) = lower(?)") + .get(name) + ); + return row ? String(row.name) : null; +} + +/** + * All rows of a table with keys lowercased, or [] when the table is absent. + * @param {string} name + * @returns {Row[]} + */ +function readTable(name) { + const actual = findTable(name); + if (!actual) return []; + const rows = /** @type {Row[]} */ (sqlite.prepare(`SELECT * FROM "${actual}"`).all()); + return rows.map((r) => { + /** @type {Row} */ + const out = {}; + for (const [k, v] of Object.entries(r)) out[k.toLowerCase()] = v; + return out; + }); +} + +// ─── Supabase write helpers ─────────────────────────────────────────────────── + +let dryRunIdCounter = -1; +const CHUNK = 400; + +/** + * Requires the live client (never called in --dry-run). + * @returns {BskClient} + */ +function sb() { + if (!supabase) die("internal: supabase client used in dry-run"); + return supabase; +} + +/** + * Inserts rows in chunks and returns the generated ids in input order + * (PostgREST returns representations in insertion order). Dry-run hands back + * placeholder negative ids so dependent transforms still run. + * @param {string} table + * @param {Record[]} rows + * @returns {Promise} + */ +async function insertReturningIds(table, rows) { + if (dryRun) return rows.map(() => dryRunIdCounter--); + /** @type {number[]} */ + const ids = []; + for (let i = 0; i < rows.length; i += CHUNK) { + const batch = rows.slice(i, i + CHUNK); + const { data, error } = await sb() + .from(table) + .insert(/** @type {never[]} */ (batch)) + .select("id"); + if (error) die(`Insert into ${table} failed at row ${i}: ${error.message}`); + for (const r of /** @type {{ id: number }[]} */ (data ?? [])) ids.push(r.id); + } + if (ids.length !== rows.length) { + die(`Insert into ${table}: expected ${rows.length} returned ids, got ${ids.length}`); + } + return ids; +} + +/** + * Chunked insert without id mapping (line items, counters). + * @param {string} table + * @param {Record[]} rows + * @param {{ upsert?: boolean }} [opts] + * @returns {Promise} + */ +async function insertRows(table, rows, opts = {}) { + if (dryRun) return; + for (let i = 0; i < rows.length; i += CHUNK) { + const batch = /** @type {never[]} */ (rows.slice(i, i + CHUNK)); + const { error } = opts.upsert + ? await sb().from(table).upsert(batch) + : await sb().from(table).insert(batch); + if (error) die(`Write into ${table} failed at row ${i}: ${error.message}`); + } +} + +/** + * Row count of a target table (0 in dry-run: no remote access). + * @param {string} table + * @returns {Promise} + */ +async function targetCount(table) { + if (dryRun) return 0; + const { count, error } = await sb().from(table).select("*", { count: "exact", head: true }); + if (error) die(`Counting bsk.${table} failed: ${error.message}`); + return count ?? 0; +} + +// ─── Migration steps (FK dependency order) ──────────────────────────────────── + +/** @returns {Promise} */ +async function migrateClinicSettings() { + const rows = readTable("Clinic"); + const first = rows[0]; + if (!first) { + log("clinic settings: none found upstream — skipped"); + return; + } + if (rows.length > 1) warn(`Clinic has ${rows.length} rows; only the first was migrated.`); + await insertRows( + "clinic_settings", + [ + { + id: true, + name: str(first.name), + address: str(first.address), + phone: str(first.phone), + prefix: str(first.prefix), + }, + ], + { upsert: true }, + ); + log("clinic settings: 1 migrated"); +} + +// NOTE: upstream provinces/wards tables are deliberately NOT migrated. No +// upstream customer row references them (the address is one flat string), and +// their vintage/code space would collide with the operator-chosen dataset — +// geo reference data always comes from `npm run db:seed-geo`. + +/** + * @returns {Promise<{ map: Map, nameOf: Map }>} + */ +async function migrateDoctors() { + const rows = readTable("Doctor"); + const usable = rows.filter((r) => int(r.doctor_id) != null); + const ids = await insertReturningIds( + "doctors", + usable.map((r) => ({ + first_name: str(r.doctor_first_name) ?? "", + last_name: str(r.doctor_last_name) ?? "", + deleted: bool(r.deleted), + })), + ); + const map = new Map(usable.map((r, i) => [Number(r.doctor_id), /** @type {number} */ (ids[i])])); + // Display names ("last first", Vietnamese order) for notes annotations. + const nameOf = new Map( + usable.map((r) => [ + Number(r.doctor_id), + [str(r.doctor_last_name), str(r.doctor_first_name)].filter(Boolean).join(" "), + ]), + ); + log(`doctors: ${usable.length} migrated`); + return { map, nameOf }; +} + +/** @returns {Promise>} */ +async function migrateMedicines() { + const rows = readTable("Medicine"); + const usable = rows.filter((r) => { + if (int(r.med_id) == null || !str(r.med_name)) { + warn(`Medicine row skipped (missing med_id or med_name): ${JSON.stringify(r).slice(0, 120)}`); + return false; + } + return true; + }); + const ids = await insertReturningIds( + "medicines", + usable.map((r) => ({ + name: str(r.med_name), + unit: str(r.med_unit), + sale_price: money(r.med_selling_price), + company: str(r.med_company), + route: str(r.route), + deleted: bool(r.deleted), + })), + ); + const map = new Map(usable.map((r, i) => [Number(r.med_id), /** @type {number} */ (ids[i])])); + // The target catalog deliberately has no description/preferred-note columns; + // surface how much of that metadata exists so the operator can copy what + // matters (e.g. preferred dosage notes) into the new catalog by hand. + const withMeta = usable.filter( + (r) => str(r.preferred_note) ?? str(r.med_description) ?? (bool(r.supplement) || null), + ).length; + if (withMeta > 0) { + warn( + `${withMeta} medicine(s) carry preferred_note/description/supplement metadata ` + + "that has no target column — review the upstream catalog before retiring it", + ); + } + log(`medicines: ${usable.length} migrated (${rows.length - usable.length} skipped)`); + return map; +} + +/** @returns {Promise>} */ +async function migrateServices() { + const rows = readTable("Service"); + const usable = rows.filter((r) => { + if (int(r.service_id) == null || !str(r.service_name)) { + warn( + `Service row skipped (missing service_id or service_name): ${JSON.stringify(r).slice(0, 120)}`, + ); + return false; + } + return true; + }); + const ids = await insertReturningIds( + "services", + usable.map((r) => ({ + name: str(r.service_name), + price: money(r.service_cost), + deleted: bool(r.deleted), + })), + ); + const map = new Map(usable.map((r, i) => [Number(r.service_id), /** @type {number} */ (ids[i])])); + log(`services: ${usable.length} migrated (${rows.length - usable.length} skipped)`); + return map; +} + +/** @returns {Promise} */ +async function migrateTemplates() { + const rows = readTable("CheckupTemplate"); + if (rows.length === 0) { + log("checkup templates: none found upstream — skipped"); + return; + } + rows.sort((a, b) => (int(a.stt) ?? 0) - (int(b.stt) ?? 0)); + /** @type {Record} */ + const tplGender = { ...GENDER_MAP, ANY: "any", ALL: "any", "CẢ HAI": "any", CHUNG: "any" }; + await insertRows( + "checkup_templates", + rows.map((r, i) => ({ + name: str(r.template_name) ?? str(r.template_title) ?? `Template ${i + 1}`, + title: str(r.template_title), + gender: tplGender[norm(r.template_gender)] ?? "any", + photo_num: Math.max(0, int(r.photo_num) ?? 0), + // Target stores fields as an ordered [{ label }] jsonb array + // (lib/templates/template-schema.js). Upstream `content` is RTF from the + // Swing editor (legacy rows may be plain text) that pre-filled the notes + // body; after RTF→text each non-empty line becomes one field label. + // print_type/conclusion/suggestion/diagnosis defaults have no target. + fields: rtfToText(str(r.content) ?? "") + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .map((label) => ({ label })), + deleted: bool(r.deleted) || (r.visible != null && !bool(r.visible)), + })), + ); + warn( + "Checkup templates were converted from the upstream RTF editor — review their " + + "field lists in the admin UI before first use.", + ); + // The default-text columns (print_type, conclusion, suggestion, diagnosis) + // have no target — surface how many templates carry them so the operator + // can copy anything worth keeping into the new template fields by hand. + const withDefaults = rows.filter( + (r) => str(r.print_type) ?? str(r.conclusion) ?? str(r.suggestion) ?? str(r.diagnosis), + ).length; + if (withDefaults > 0) { + warn( + `${withDefaults} template(s) carry print_type/conclusion/suggestion/diagnosis ` + + "default text that has no target column — copy what matters from the upstream app", + ); + } + log(`checkup templates: ${rows.length} migrated`); +} + +/** @returns {Promise>} */ +async function migrateCustomers() { + const rows = readTable("Customer"); + const usable = rows.filter((r) => int(r.customer_id) != null); + const ids = await insertReturningIds( + "customers", + usable.map((r) => { + const gender = GENDER_MAP[norm(r.customer_gender)] ?? null; + if (gender === null && str(r.customer_gender)) { + warn(`Customer ${r.customer_id}: unknown gender "${r.customer_gender}" → NULL`); + } + return { + first_name: str(r.customer_first_name) ?? "", + last_name: str(r.customer_last_name) ?? "", + dob: vnDate(r.customer_dob), + gender, + cccd: str(r.cccd_ddcn), + phone: str(r.customer_number), + // Upstream stores one flat address string; province/ward codes have no + // source and stay NULL for the admin to backfill in the UI. + address_detail: str(r.customer_address), + }; + }), + ); + const map = new Map( + usable.map((r, i) => [Number(r.customer_id), /** @type {number} */ (ids[i])]), + ); + log(`customers: ${usable.length} migrated`); + return map; +} + +/** + * Per-service-line notes from CheckupService (the target table has no notes + * column, so they are folded into the visit's notes instead of being lost). + * @returns {Map} upstream checkup_id → note lines + */ +function collectServiceNoteLines() { + /** @type {Map} */ + const serviceNameOf = new Map( + readTable("Service").map((s) => [ + Number(s.service_id), + str(s.service_name) ?? `#${s.service_id}`, + ]), + ); + /** @type {Map} */ + const byCheckup = new Map(); + for (const r of readTable("CheckupService")) { + const checkupId = int(r.checkup_id); + const note = str(r.notes); + if (checkupId == null || !note) continue; + const name = serviceNameOf.get(Number(r.service_id)) ?? `#${r.service_id}`; + const lines = byCheckup.get(checkupId) ?? []; + lines.push(`Dịch vụ ${name}: ${note}`); + byCheckup.set(checkupId, lines); + } + return byCheckup; +} + +/** + * @param {Map} customerMap + * @param {{ map: Map, nameOf: Map }} doctors + * @param {Map} serviceNoteLines upstream checkup_id → lines + * @returns {Promise<{ map: Map, dateOf: Map }>} + */ +async function migrateCheckups(customerMap, doctors, serviceNoteLines) { + const doctorMap = doctors.map; + const rows = readTable("Checkup"); + /** @type {Row[]} */ + const usable = []; + /** @type {Set} */ + const unknownStatuses = new Set(); + /** @type {Set} */ + const unknownShifts = new Set(); + for (const r of rows) { + if (int(r.checkup_id) == null) continue; + if (!customerMap.has(Number(r.customer_id))) { + warn(`Checkup ${r.checkup_id}: customer ${r.customer_id} not migrated — row skipped`); + continue; + } + usable.push(r); + } + /** @type {Map} */ + const dateOf = new Map(); + const payload = usable.map((r) => { + const oldId = Number(r.checkup_id); + const date = vnDate(r.checkup_date); + if (date) dateOf.set(oldId, date); + else warn(`Checkup ${oldId}: unparseable checkup_date "${r.checkup_date}" → defaults to today`); + + let status = STATUS_MAP[norm(r.status)]; + if (!status) { + if (str(r.status)) unknownStatuses.add(String(r.status)); + status = "done"; // migrated history is overwhelmingly completed visits + } + + const doctorId = int(r.doctor_id); + if (doctorId != null && !doctorMap.has(doctorId)) { + warn(`Checkup ${oldId}: doctor ${doctorId} not migrated → doctor left empty`); + } + + /** @param {SqlValue | undefined} v */ + const vital = (v) => { + const n = Number(v); + if (!Number.isFinite(n) || n <= 0) return null; + // numeric(5,2) tops out at 999.99; values from 999.995 round past it. + if (n >= 999.995) { + warn(`Checkup ${oldId}: vital value ${n} out of range → NULL`); + return null; + } + return n; + }; + + // Data with no dedicated target column is folded into the visit notes so + // nothing clinically visible in the old app is lost. + const ultrasoundDoctorId = int(r.doctor_ultrasound_id); + const ultrasoundName = + ultrasoundDoctorId != null ? doctors.nameOf.get(ultrasoundDoctorId) : null; + const suggestion = str(r.suggestion); + const noteLines = [ + str(r.notes), + suggestion ? `Đề nghị: ${suggestion}` : null, + ultrasoundName ? `BS siêu âm: ${ultrasoundName}` : null, + ...(serviceNoteLines.get(oldId) ?? []), + ].filter(Boolean); + + // Unfilled vitals are stored as 0 / "0/0" upstream — treat them as unset. + const heartBeat = str(r.heart_beat); + const bloodPressure = str(r.blood_pressure); + return { + customer_id: customerMap.get(Number(r.customer_id)), + doctor_id: doctorId != null ? (doctorMap.get(doctorId) ?? null) : null, + shift_id: mapShift(r.shift, unknownShifts), + queue_number: int(r.queue_number), + ...(date + ? { + checkup_date: date, + // Anchor audit timestamps to the visit day (noon VN) so history + // and exports sort sensibly instead of piling up on migration day. + created_at: `${date}T12:00:00+07:00`, + updated_at: `${date}T12:00:00+07:00`, + } + : {}), + status, + checkup_type: str(r.checkup_type), + diagnosis: str(r.diagnosis), + conclusion: str(r.conclusion), + notes: noteLines.length > 0 ? noteLines.join("\n") : null, + heart_beat: heartBeat === "0" ? null : heartBeat, + blood_pressure: bloodPressure === "0/0" ? null : bloodPressure, + weight: vital(r.customer_weight), + height: vital(r.customer_height), + // remind_date is the upstream recall reminder; it fills recheck_date + // when no explicit re-checkup date was set (the rewrite's reminders + // feature reads recheck_date). + recheck_date: vnDate(r.recheckupdate) ?? vnDate(r.remind_date), + deleted: bool(r.deleted), + }; + }); + const ids = await insertReturningIds("checkups", payload); + const map = new Map(usable.map((r, i) => [Number(r.checkup_id), /** @type {number} */ (ids[i])])); + if (unknownStatuses.size > 0) { + warn( + `Checkup.status values with no mapping (defaulted to 'done'): ` + + [...unknownStatuses].join(", "), + ); + } + log(`checkups: ${usable.length} migrated (${rows.length - usable.length} skipped)`); + return { map, dateOf }; +} + +/** + * @param {Map} checkupMap + * @param {Map} medicineMap + * @returns {Promise} + */ +async function migrateOrderItems(checkupMap, medicineMap) { + const rows = readTable("OrderItem"); + // Legacy OrderItem rows predate the checkup_id column; resolve them through + // MedicineOrder's prescription_id → checkup_id, mirroring the upstream + // DeleteCheckup join. + /** @type {Map} */ + const prescToCheckup = new Map(); + for (const o of readTable("MedicineOrder")) { + const presc = int(o.prescription_id); + const checkup = int(o.checkup_id); + if (presc != null && checkup != null) prescToCheckup.set(presc, checkup); + } + /** @type {Record[]} */ + const payload = []; + for (const r of rows) { + const presc = int(r.prescription_id); + const oldCheckupId = + int(r.checkup_id) ?? (presc != null ? (prescToCheckup.get(presc) ?? null) : null); + const checkupId = oldCheckupId != null ? checkupMap.get(oldCheckupId) : undefined; + const medicineId = medicineMap.get(Number(r.med_id)); + const quantity = int(r.quantity_ordered) ?? 0; + if (!checkupId || !medicineId || quantity <= 0) { + warn( + `OrderItem skipped (checkup ${r.checkup_id}, med ${r.med_id}, qty ${r.quantity_ordered}): ` + + "missing reference or non-positive quantity", + ); + continue; + } + const unitPrice = money(r.price_per_unit); + // Trust the stored total only when it actually parses as a number — + // legacy TEXT values like "" would otherwise become 0 and silently + // falsify billing history. Anything unparseable is recomputed. + const storedTotal = str(r.total_price) != null ? Number(r.total_price) : NaN; + payload.push({ + checkup_id: checkupId, + medicine_id: medicineId, + quantity, + dosage: str(r.dosage), + unit_price: unitPrice, + line_total: Number.isFinite(storedTotal) ? money(storedTotal) : quantity * unitPrice, + notes: str(r.notes), + }); + } + await insertRows("order_items", payload); + log(`order items: ${payload.length} migrated (${rows.length - payload.length} skipped)`); +} + +/** + * @param {Map} checkupMap + * @param {Map} serviceMap + * @returns {Promise} + */ +async function migrateCheckupServices(checkupMap, serviceMap) { + const rows = readTable("CheckupService"); + /** @type {Record[]} */ + const payload = []; + for (const r of rows) { + const checkupId = checkupMap.get(Number(r.checkup_id)); + const serviceId = serviceMap.get(Number(r.service_id)); + const quantity = int(r.quantity) ?? 0; + if (!checkupId || !serviceId || quantity <= 0) { + warn( + `CheckupService skipped (checkup ${r.checkup_id}, service ${r.service_id}, ` + + `qty ${r.quantity}): missing reference or non-positive quantity`, + ); + continue; + } + // Upstream stores only the line total; the unit price is derived so the + // stored total is preserved exactly (rounding differences land on the + // unit price, which is display-only for migrated history). + const lineTotal = money(r.total_cost); + payload.push({ + checkup_id: checkupId, + service_id: serviceId, + quantity, + unit_price: Math.round(lineTotal / quantity), + line_total: lineTotal, + }); + } + await insertRows("checkup_services", payload); + log(`checkup services: ${payload.length} migrated (${rows.length - payload.length} skipped)`); +} + +/** + * @param {Map} checkupMap + * @param {Map} checkupDateOf + * @returns {Promise} + */ +async function migrateMedicineOrders(checkupMap, checkupDateOf) { + const rows = readTable("MedicineOrder"); + /** @type {Map>} */ + const byCheckup = new Map(); + let skipped = 0; + for (const r of rows) { + const oldCheckupId = Number(r.checkup_id); + const checkupId = checkupMap.get(oldCheckupId); + if (!checkupId) { + warn(`MedicineOrder skipped: checkup ${r.checkup_id} not migrated`); + skipped++; + continue; + } + const paid = PAID_LABELS.has(norm(r.payment_status)); + const visitDate = checkupDateOf.get(oldCheckupId); + // Later rows win on duplicates (the upstream app had two INSERT paths). + byCheckup.set(checkupId, { + checkup_id: checkupId, + payment_status: paid ? "paid" : "unpaid", + // Upstream never recorded when an order was paid; the visit day (noon + // VN) keeps history plausible without inventing precision. + paid_at: paid && visitDate ? `${visitDate}T12:00:00+07:00` : null, + }); + } + await insertRows("medicine_orders", [...byCheckup.values()], { upsert: true }); + log(`medicine orders: ${byCheckup.size} migrated (${skipped} skipped)`); +} + +/** @returns {Promise} */ +async function migrateQueueCounters() { + const rows = readTable("DailyQueueCounter"); + /** @type {Set} */ + const unknownShifts = new Set(); + // Deduplicate on the target PK (day, shift_id) keeping the highest counter — + // duplicate source rows would make the upsert fail with "ON CONFLICT ... + // cannot affect row a second time". + /** @type {Map} */ + const byKey = new Map(); + for (const r of rows) { + const day = vnDate(r.date); + const shiftId = mapShift(r.shift, unknownShifts); + if (!day || shiftId == null) { + warn(`DailyQueueCounter skipped (date ${r.date}, shift ${r.shift})`); + continue; + } + const key = `${day}|${shiftId}`; + const lastNumber = Math.max(0, int(r.current_count) ?? 0); + const existing = byKey.get(key); + if (existing) existing.last_number = Math.max(existing.last_number, lastNumber); + else byKey.set(key, { day, shift_id: shiftId, last_number: lastNumber }); + } + const payload = [...byKey.values()]; + await insertRows("daily_queue_counters", payload, { upsert: true }); + log(`queue counters: ${payload.length} migrated (${rows.length - payload.length} skipped)`); +} + +/** + * Staff accounts are intentionally not migrated: auth is delegated to Supabase + * and upstream passwords must not be reused. Print the roster so the admin can + * re-invite everyone through the staff-management UI. + * @returns {void} + */ +function reportUpstreamUsers() { + const rows = readTable("User"); + if (rows.length === 0) return; + log(""); + log(`staff accounts are NOT migrated — re-invite these ${rows.length} upstream users`); + log("via the app's staff management (Supabase auth replaces upstream passwords):"); + for (const r of rows) { + const name = [str(r.last_name), str(r.first_name)].filter(Boolean).join(" ") || "(no name)"; + const flags = bool(r.deleted) ? " [deleted upstream — probably skip]" : ""; + log(` - ${str(r.user_name) ?? "?"} — ${name}, role: ${str(r.role_name) ?? "?"}${flags}`); + } +} + +/** + * Prints what the source actually contains before anything is written — the + * value spaces this script's mappings depend on (older app versions may have + * written variants the current Java source no longer shows). + * @returns {void} + */ +function sourceOverview() { + if (existsSync(`${dbPath}-wal`)) { + log("note: a BSK.db-wal sibling exists and will be read through — good"); + } else { + warn( + "No BSK.db-wal sibling found. The upstream server runs WAL mode: if this file was " + + "copied while the server was running, the newest visits may be missing — stop the " + + "server and re-copy BSK.db together with its -wal/-shm files if unsure.", + ); + } + /** + * @param {string} table + * @param {string} column + * @returns {string[]} + */ + const distinct = (table, column) => { + const actual = findTable(table); + if (!actual) return []; + return /** @type {Row[]} */ ( + sqlite.prepare(`SELECT DISTINCT "${column}" AS v FROM "${actual}"`).all() + ).map((r) => String(r.v)); + }; + const statuses = distinct("Checkup", "status"); + const shifts = distinct("Checkup", "shift"); + if (statuses.length > 0) log(`source Checkup.status values: ${statuses.join(", ")}`); + if (shifts.length > 0) log(`source Checkup.shift values: ${shifts.join(", ")}`); + const orderItem = findTable("OrderItem"); + if (orderItem) { + const row = /** @type {Row | undefined} */ ( + sqlite.prepare(`SELECT COUNT(*) AS n FROM "${orderItem}" WHERE checkup_id IS NULL`).get() + ); + const n = Number(row?.n ?? 0); + if (n > 0) log(`source OrderItem rows without checkup_id (legacy): ${n}`); + } +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +async function main() { + log(`source: ${dbPath}${dryRun ? " (dry run — nothing will be written)" : ""}`); + sourceOverview(); + + if (!dryRun && !allowNonempty) { + // Ids are re-keyed on insert, so running twice would duplicate every row. + // Every insert-target table is checked (upsert-keyed tables are exempt). + for (const table of [ + "customers", + "checkups", + "medicines", + "services", + "doctors", + "checkup_templates", + "order_items", + "checkup_services", + ]) { + const count = await targetCount(table); + if (count > 0) { + die( + `Target table bsk.${table} already has ${count} rows. This script expects an ` + + "empty target (re-running duplicates data). Pass --allow-nonempty to override.", + ); + } + } + } + + await migrateClinicSettings(); + const doctors = await migrateDoctors(); + const medicineMap = await migrateMedicines(); + const serviceMap = await migrateServices(); + await migrateTemplates(); + const customerMap = await migrateCustomers(); + const serviceNoteLines = collectServiceNoteLines(); + const { map: checkupMap, dateOf } = await migrateCheckups(customerMap, doctors, serviceNoteLines); + await migrateOrderItems(checkupMap, medicineMap); + await migrateCheckupServices(checkupMap, serviceMap); + await migrateMedicineOrders(checkupMap, dateOf); + await migrateQueueCounters(); + reportUpstreamUsers(); + + if (warnings.length > 0) { + log(""); + log(`${warnings.length} warning(s):`); + const MAX = 50; + for (const w of warnings.slice(0, MAX)) log(` ! ${w}`); + if (warnings.length > MAX) log(` … and ${warnings.length - MAX} more`); + } + log(""); + log( + dryRun + ? "dry run complete — re-run without --dry-run to write." + : "done. Post-migration: run `npm run db:seed-geo`, re-invite staff, review the " + + "converted checkup templates, and backfill patient province/ward in the UI.", + ); +} + +main() + .catch((e) => die(e instanceof Error ? (e.stack ?? e.message) : String(e))) + .finally(() => sqlite.close()); diff --git a/scripts/upstream-transforms.mjs b/scripts/upstream-transforms.mjs new file mode 100644 index 0000000..e5d2b88 --- /dev/null +++ b/scripts/upstream-transforms.mjs @@ -0,0 +1,254 @@ +/** + * Pure value transforms for migrating an original BSK desktop install's + * SQLite data (see scripts/migrate-from-upstream.mjs, which orchestrates the + * actual migration). Kept dependency-free so tests/unit can exercise the + * trickiest conversions (RTF templates, epoch-millis dates, enum labels) + * without touching a database. + * + * Every mapping here was verified against the upstream Java source — + * plans/reports/code-reviewer-260818-1749-migration-script-upstream-compat- + * audit-report.md has the file:line evidence. + */ + +/** @typedef {string | number | bigint | Uint8Array | null} SqlValue */ + +/** @type {(msg: string) => void} */ +let onWarn = () => {}; + +/** + * Registers the warning sink used by transforms that can drop data. + * @param {(msg: string) => void} fn + */ +export function setOnWarn(fn) { + onWarn = fn; +} + +/** + * Trimmed string or null. + * @param {SqlValue | undefined} v + * @returns {string | null} + */ +export function str(v) { + if (v == null) return null; + const s = String(v).trim(); + return s === "" ? null : s; +} + +/** + * Integer VND from an upstream DOUBLE (rounded, clamped to >= 0 to satisfy the + * target CHECK constraints). + * @param {SqlValue | undefined} v + * @returns {number} + */ +export function money(v) { + const n = Number(v); + if (!Number.isFinite(n)) return 0; + return Math.max(0, Math.round(n)); +} + +/** + * @param {SqlValue | undefined} v + * @returns {number | null} + */ +export function int(v) { + if (v == null) return null; // Number(null) is 0, which must stay "unset" + const n = Number(v); + return Number.isFinite(n) ? Math.trunc(n) : null; +} + +/** + * @param {SqlValue | undefined} v + * @returns {boolean} + */ +export function bool(v) { + return v === 1 || v === 1n || v === "1" || v === "true"; +} + +const vnDayFmt = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Ho_Chi_Minh", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +// Sanity window for epoch-millis dates: 1900-01-01 … 2100-01-01. Upstream +// writes Date.getTime() millis, which are NEGATIVE for pre-1970 birth dates +// (verified: upstream AddDialog parses dd/MM/yyyy and expects "-?\d+"). +const MS_MIN = Date.UTC(1900, 0, 1); +const MS_MAX = Date.UTC(2100, 0, 1); + +/** + * Clinic-local (UTC+7) YYYY-MM-DD from an upstream date value: Java epoch + * millis (possibly negative), an ISO string, or Vietnamese dd/MM/yyyy. + * Exactly 0 is treated as "unset" (a real 1970-01-01 VN date is -25200000). + * @param {SqlValue | undefined} v + * @returns {string | null} + */ +export function vnDate(v) { + if (v == null) return null; + if (typeof v === "number" || typeof v === "bigint") { + const ms = Number(v); + if (!Number.isFinite(ms) || ms === 0) return null; + if (ms < MS_MIN || ms > MS_MAX) { + onWarn(`Date value ${ms} outside 1900–2100 → NULL`); + return null; + } + return vnDayFmt.format(new Date(ms)); + } + const s = String(v).trim(); + if (!s) return null; + const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (iso) return `${iso[1]}-${iso[2]}-${iso[3]}`; + const dmy = s.match(/^(\d{1,2})[/-](\d{1,2})[/-](\d{4})/); + if (dmy) { + // Validate the calendar date — Postgres rejects e.g. 1985-02-31, which + // would abort a live migration mid-run. + const day = Number(dmy[1]); + const mon = Number(dmy[2]); + const year = Number(dmy[3]); + const probe = new Date(Date.UTC(year, mon - 1, day)); + if ( + probe.getUTCFullYear() !== year || + probe.getUTCMonth() !== mon - 1 || + probe.getUTCDate() !== day + ) { + onWarn(`Invalid calendar date "${s}" → NULL`); + return null; + } + return `${year}-${String(mon).padStart(2, "0")}-${String(day).padStart(2, "0")}`; + } + if (/^-?\d{1,13}$/.test(s)) return vnDate(Number(s)); + return null; +} + +/** + * Normalizes an enum-ish label for dictionary lookup. + * @param {SqlValue | undefined} v + * @returns {string} + */ +export function norm(v) { + return String(v ?? "") + .normalize("NFC") + .toUpperCase() + .replace(/\s+/g, " ") + .trim(); +} + +/** @type {Record} */ +export const STATUS_MAP = { + WAITING: "waiting", + PENDING: "waiting", + "CHỜ KHÁM": "waiting", + "ĐANG CHỜ": "waiting", + "CHƯA KHÁM": "waiting", + IN_PROGRESS: "in_progress", + "IN PROGRESS": "in_progress", + PROCESSING: "in_progress", + "ĐANG KHÁM": "in_progress", + DONE: "done", + COMPLETED: "done", + FINISHED: "done", + "ĐÃ KHÁM": "done", + "HOÀN THÀNH": "done", +}; + +/** @type {Record} */ +export const GENDER_MAP = { + M: "male", + MALE: "male", + NAM: "male", + F: "female", + FEMALE: "female", + NỮ: "female", + NU: "female", + OTHER: "other", + KHÁC: "other", +}; + +// The upstream Java app only ever writes "Unpaid" (no paid path exists in its +// code) — this set is belt-and-braces for hand-edited databases. +export const PAID_LABELS = new Set(["PAID", "ĐÃ THANH TOÁN", "ĐÃ THU", "ĐÃ TRẢ", "ĐÃ TRẢ TIỀN"]); + +// Upstream shifts: 0 = morning, 1 = afternoon (LocalStorage.currentShift). +// Target bsk.shifts: 1 = morning, 2 = afternoon, 3 = evening. +/** @type {Record} */ +export const SHIFT_MAP = { 0: 1, 1: 2 }; + +/** + * Target shift_id for an upstream shift value, or null (with one warning per + * distinct unknown value). + * @param {SqlValue | undefined} v + * @param {Set} unknownSeen + * @returns {number | null} + */ +export function mapShift(v, unknownSeen) { + const s = int(v); + if (s == null) return null; + const mapped = SHIFT_MAP[s]; + if (mapped == null && !unknownSeen.has(s)) { + unknownSeen.add(s); + onWarn(`Unknown upstream shift value ${s} → NULL (expected 0=morning, 1=afternoon)`); + } + return mapped ?? null; +} + +/** + * Best-effort plain text from Swing RTFEditorKit output. Upstream stores + * checkup-template `content` as RTF ({\rtf1...}); this decodes \uN?/\'hh + * escapes, turns \par|\line into newlines, skips header destination groups + * (fonttbl, colortbl, …), and drops all other control words. Legacy templates + * saved before the RTF editor are plain text and pass through untouched. + * @param {string} content + * @returns {string} + */ +export function rtfToText(content) { + if (!content.startsWith("{\\rtf")) return content; + let out = ""; + let skipDepth = 0; // inside a destination group whose text is not content + for (let i = 0; i < content.length; i++) { + const ch = content[i]; + if (ch === "\\") { + // Escaped literals bind tighter than group tracking: consume \\ \{ \} + // even inside skipped groups so they can never corrupt depth counting. + const next = content[i + 1]; + if (next === "\\" || next === "{" || next === "}") { + if (skipDepth === 0) out += next; + i++; + continue; + } + if (skipDepth > 0) continue; + const hex = content.slice(i, i + 4).match(/^\\'([0-9a-f]{2})/i); + if (hex) { + out += String.fromCharCode(parseInt(/** @type {string} */ (hex[1]), 16)); + i += 3; + continue; + } + const word = content.slice(i).match(/^\\([a-z]+)(-?\d+)? ?/i); + if (word) { + if (word[1] === "par" || word[1] === "line") out += "\n"; + else if (word[1] === "u" && word[2]) { + out += String.fromCharCode(((Number(word[2]) % 65536) + 65536) % 65536); + // \uN is followed by a one-character ANSI fallback to skip. + i += word[0].length; + continue; + } + i += word[0].length - 1; + continue; + } + continue; + } + if (ch === "{") { + if (skipDepth > 0) skipDepth++; + else if (/^\\(?:\*|fonttbl|colortbl|stylesheet|info|pict)/.test(content.slice(i + 1, i + 12))) + skipDepth = 1; + continue; + } + if (ch === "}") { + if (skipDepth > 0) skipDepth--; + continue; + } + if (skipDepth > 0) continue; + if (ch !== "\r" && ch !== "\n") out += ch; // raw newlines are not RTF content + } + return out; +} diff --git a/supabase/migrations/20260818171800_bsk_service_role_grants.sql b/supabase/migrations/20260818171800_bsk_service_role_grants.sql new file mode 100644 index 0000000..2b554b6 --- /dev/null +++ b/supabase/migrations/20260818171800_bsk_service_role_grants.sql @@ -0,0 +1,16 @@ +-- BSK — table-level grants for service_role. +-- +-- The bsk schema only ever granted USAGE to service_role (20260525163300). +-- service_role bypasses RLS but is NOT a superuser: without table grants every +-- PostgREST request made with the secret key fails with "permission denied". +-- That breaks the server-side admin paths and the operator scripts +-- (scripts/seed-geo.mjs, scripts/migrate-from-upstream.mjs, the cron sweep). +-- +-- RLS policies are unaffected: service_role skips RLS by design, and the +-- authenticated role's grants/policies stay exactly as they were. + +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA bsk TO service_role; + +-- Tables added by future migrations get the same grants automatically. +ALTER DEFAULT PRIVILEGES IN SCHEMA bsk + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO service_role; diff --git a/tests/unit/upstream-transforms.test.js b/tests/unit/upstream-transforms.test.js new file mode 100644 index 0000000..e77da80 --- /dev/null +++ b/tests/unit/upstream-transforms.test.js @@ -0,0 +1,145 @@ +import { describe, expect, it } from "vitest"; +import { + GENDER_MAP, + STATUS_MAP, + bool, + int, + mapShift, + money, + norm, + rtfToText, + setOnWarn, + str, + vnDate, +} from "@/scripts/upstream-transforms.mjs"; + +describe("vnDate", () => { + it("converts positive epoch millis to the VN-local date", () => { + expect(vnDate(631152000000)).toBe("1990-01-01"); + }); + + it("keeps pre-1970 birth dates (negative epoch millis)", () => { + expect(vnDate(-631152000000)).toBe("1950-01-01"); + // Midnight 1970-01-01 in VN is -7h UTC — must not be treated as unset. + expect(vnDate(-25200000)).toBe("1970-01-01"); + }); + + it("does not misread 1970–1973 millis as epoch seconds", () => { + // Saigon was UTC+8 until 1975 (IANA tzdata), hence the 3rd, not the 2nd. + expect(vnDate(50000000000)).toBe("1971-08-03"); + }); + + it("treats exactly 0 as unset", () => { + expect(vnDate(0)).toBeNull(); + }); + + it("rejects out-of-window values with a warning", () => { + /** @type {string[]} */ + const warned = []; + setOnWarn((m) => warned.push(m)); + expect(vnDate(9e15)).toBeNull(); + expect(warned).toHaveLength(1); + setOnWarn(() => {}); + }); + + it("parses ISO and Vietnamese dd/MM/yyyy strings, and numeric strings", () => { + expect(vnDate("2026-08-18T10:00:00")).toBe("2026-08-18"); + expect(vnDate("18/08/2026")).toBe("2026-08-18"); + expect(vnDate("-631152000000")).toBe("1950-01-01"); + expect(vnDate("garbage")).toBeNull(); + expect(vnDate(null)).toBeNull(); + }); + + it("rejects impossible dd/MM/yyyy calendar dates instead of emitting them", () => { + /** @type {string[]} */ + const warned = []; + setOnWarn((m) => warned.push(m)); + // Postgres would reject 1985-02-31 and abort a live run mid-migration. + expect(vnDate("31/02/1985")).toBeNull(); + expect(vnDate("00/01/1985")).toBeNull(); + expect(warned).toHaveLength(2); + setOnWarn(() => {}); + expect(vnDate("29/02/2024")).toBe("2024-02-29"); // real leap day survives + }); +}); + +describe("rtfToText", () => { + it("extracts plain text from Swing RTFEditorKit output", () => { + const rtf = + "{\\rtf1\\ansi\\ansicpg1252\\deff0{\\fonttbl{\\f0\\fnil Dialog;}}" + + "\\f0\\fs24 Tim thai\\par Nhau \\u7889?i\\par\\par C\\u226?n n\\u7863?ng thai\\par}"; + expect(rtfToText(rtf)).toBe("Tim thai\nNhau ối\n\nCân nặng thai\n"); + }); + + it("passes legacy plain-text content through untouched", () => { + expect(rtfToText("Mạch\nHuyết áp")).toBe("Mạch\nHuyết áp"); + }); + + it("decodes \\'hh hex escapes and escaped braces", () => { + expect(rtfToText("{\\rtf1 a\\'41b \\{x\\}}")).toBe("aAb {x}"); + }); + + it("keeps depth tracking intact when a skipped group contains escaped braces", () => { + // The \{ inside the fonttbl group must not be counted as a group opener, + // or everything after the header would be silently swallowed. + expect(rtfToText("{\\rtf1{\\fonttbl{\\f0 F\\{oo;}}Hello}")).toBe("Hello"); + }); +}); + +describe("mapShift", () => { + it("maps upstream 0=morning/1=afternoon to target shift ids 1/2", () => { + const seen = new Set(); + expect(mapShift(0, seen)).toBe(1); + expect(mapShift(1, seen)).toBe(2); + expect(mapShift(null, seen)).toBeNull(); + }); + + it("nulls unknown shifts and warns once per distinct value", () => { + /** @type {string[]} */ + const warned = []; + setOnWarn((m) => warned.push(m)); + const seen = new Set(); + expect(mapShift(7, seen)).toBeNull(); + expect(mapShift(7, seen)).toBeNull(); + expect(warned).toHaveLength(1); + setOnWarn(() => {}); + }); +}); + +describe("scalar transforms", () => { + it("rounds and clamps money to non-negative integer VND", () => { + expect(money(1523.7)).toBe(1524); + expect(money(-5)).toBe(0); + expect(money("garbage")).toBe(0); + }); + + it("keeps SQL NULL distinct from 0 in int()", () => { + expect(int(null)).toBeNull(); + expect(int(0)).toBe(0); + expect(int("1.5")).toBe(1); + }); + + it("reads SQLite boolean-ish values", () => { + expect(bool(1)).toBe(true); + expect(bool("1")).toBe(true); + expect(bool(0)).toBe(false); + expect(bool(null)).toBe(false); + }); + + it("trims strings to null", () => { + expect(str(" x ")).toBe("x"); + expect(str(" ")).toBeNull(); + expect(str(null)).toBeNull(); + }); +}); + +describe("enum label maps", () => { + it("maps the exact Vietnamese labels the Java app writes", () => { + expect(STATUS_MAP[norm("đã khám")]).toBe("done"); + expect(STATUS_MAP[norm("ĐANG KHÁM")]).toBe("in_progress"); + expect(STATUS_MAP[norm("Chờ khám")]).toBe("waiting"); + expect(GENDER_MAP[norm("Nữ")]).toBe("female"); + expect(GENDER_MAP[norm("Nam")]).toBe("male"); + expect(GENDER_MAP[norm("Không rõ")]).toBeUndefined(); + }); +});