feat: add data migration from the original Java BSK SQLite database

npm run db:migrate-upstream imports a BSK.db file into the bsk schema via
node:sqlite: re-keys all ids, maps shift 0/1 to morning/afternoon, converts
RTF template content to field labels, keeps pre-1970 birth dates, folds
suggestion/service-notes/ultrasound-doctor into visit notes, and warns on
every dropped or unmappable value. Pure transforms live in
scripts/upstream-transforms.mjs with a unit suite. Also grants service_role
the table privileges PostgREST needs for this and the existing seed scripts.
This commit is contained in:
2026-08-18 20:41:31 +07:00
parent 2bae0b7649
commit deec3d69c0
6 changed files with 1277 additions and 0 deletions
+14
View File
@@ -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)
+1
View File
@@ -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": {
+847
View File
@@ -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<string, SqlValue>} 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<any, any, "bsk">} 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<string, unknown>[]} rows
* @returns {Promise<number[]>}
*/
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<string, unknown>[]} rows
* @param {{ upsert?: boolean }} [opts]
* @returns {Promise<void>}
*/
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<number>}
*/
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<void>} */
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<number, number>, nameOf: Map<number, string> }>}
*/
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<Map<number, number>>} */
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<Map<number, number>>} */
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<void>} */
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<string, "any" | "male" | "female" | "other">} */
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<Map<number, number>>} */
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<number, string[]>} upstream checkup_id → note lines
*/
function collectServiceNoteLines() {
/** @type {Map<number, string>} */
const serviceNameOf = new Map(
readTable("Service").map((s) => [
Number(s.service_id),
str(s.service_name) ?? `#${s.service_id}`,
]),
);
/** @type {Map<number, string[]>} */
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<number, number>} customerMap
* @param {{ map: Map<number, number>, nameOf: Map<number, string> }} doctors
* @param {Map<number, string[]>} serviceNoteLines upstream checkup_id → lines
* @returns {Promise<{ map: Map<number, number>, dateOf: Map<number, string> }>}
*/
async function migrateCheckups(customerMap, doctors, serviceNoteLines) {
const doctorMap = doctors.map;
const rows = readTable("Checkup");
/** @type {Row[]} */
const usable = [];
/** @type {Set<string>} */
const unknownStatuses = new Set();
/** @type {Set<number>} */
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<number, string>} */
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<number, number>} checkupMap
* @param {Map<number, number>} medicineMap
* @returns {Promise<void>}
*/
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<number, number>} */
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<string, unknown>[]} */
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<number, number>} checkupMap
* @param {Map<number, number>} serviceMap
* @returns {Promise<void>}
*/
async function migrateCheckupServices(checkupMap, serviceMap) {
const rows = readTable("CheckupService");
/** @type {Record<string, unknown>[]} */
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<number, number>} checkupMap
* @param {Map<number, string>} checkupDateOf
* @returns {Promise<void>}
*/
async function migrateMedicineOrders(checkupMap, checkupDateOf) {
const rows = readTable("MedicineOrder");
/** @type {Map<number, Record<string, unknown>>} */
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<void>} */
async function migrateQueueCounters() {
const rows = readTable("DailyQueueCounter");
/** @type {Set<number>} */
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<string, { day: string, shift_id: number, last_number: number }>} */
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());
+254
View File
@@ -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 19002100 → 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<string, "waiting" | "in_progress" | "done">} */
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<string, "male" | "female" | "other">} */
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<number, number>} */
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<number>} 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;
}
@@ -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;
+145
View File
@@ -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 19701973 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();
});
});