diff --git a/apps/Backend/src/routes/database-management.ts b/apps/Backend/src/routes/database-management.ts index ead25605..e42796cf 100755 --- a/apps/Backend/src/routes/database-management.ts +++ b/apps/Backend/src/routes/database-management.ts @@ -21,51 +21,29 @@ import { importLatestBackup } from "../services/autoImportService"; const UPLOADS_DIR = path.join(process.cwd(), "uploads"); -const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations"); +const SCHEMA_PATH = path.resolve(__dirname, "../../../../packages/db/prisma/schema.prisma"); -// Applies migration SQL files that are missing from the database. -// Reads each folder in the migrations directory in sorted order, checks -// whether the migration is already recorded as successfully applied in -// _prisma_migrations, and runs the SQL if not. Safe to call after any -// restore because it uses IF NOT EXISTS semantics or checks first. -async function applyMissingMigrations() { - let folders: string[]; - try { - folders = fs.readdirSync(MIGRATIONS_DIR) - .filter((name) => fs.statSync(path.join(MIGRATIONS_DIR, name)).isDirectory()) - .sort(); - } catch { - console.warn("Could not read migrations directory, skipping post-restore migration."); - return; - } +function runDbPush(): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("npx", ["prisma", "db", "push", "--config", path.resolve(__dirname, "../../../../packages/db/prisma/prisma.config.ts"), "--schema", SCHEMA_PATH, "--accept-data-loss"], { + cwd: process.cwd(), + env: process.env, + }); - // Fetch the set of successfully-applied migration names from the DB. - let applied: Set; - try { - const rows = await prisma.$queryRaw<{ migration_name: string }[]>` - SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL - `; - applied = new Set(rows.map((r) => r.migration_name)); - } catch { - // _prisma_migrations may not exist in very old backups; proceed anyway. - applied = new Set(); - } - - for (const folder of folders) { - if (applied.has(folder)) continue; - const sqlFile = path.join(MIGRATIONS_DIR, folder, "migration.sql"); - if (!fs.existsSync(sqlFile)) continue; - - const sql = fs.readFileSync(sqlFile, "utf8"); - try { - await prisma.$executeRawUnsafe(sql); - console.log(`Applied migration: ${folder}`); - } catch (err: any) { - // Log but continue — some statements may already exist (e.g. after a - // partial restore) and that is acceptable. - console.warn(`Migration ${folder} had errors (may already be applied):`, err.message); - } - } + let stderr = ""; + proc.stdout.on("data", (d) => console.log(`[db-push] ${d.toString().trim()}`)); + proc.stderr.on("data", (d) => { + const line = d.toString().trim(); + stderr += line + "\n"; + console.log(`[db-push] ${line}`); + }); + proc.on("error", (err) => reject(new Error(`Failed to run prisma db push: ${err.message}`))); + proc.on("close", (code) => { + if (code !== 0) return reject(new Error(`prisma db push failed (exit ${code}): ${stderr}`)); + console.log("[db-push] Schema synced successfully"); + resolve(); + }); + }); } const restoreUpload = multer({ @@ -459,9 +437,9 @@ router.post("/restore", restoreUpload.single("file"), async (req: Request, res: // restored _prisma_migrations table may contain orphaned entries (migration // names that have no matching file) which cause Prisma CLI to abort. try { - await applyMissingMigrations(); + await runDbPush(); } catch (err) { - console.error("applyMissingMigrations failed after restore:", err); + console.error("[restore] db push failed after restore:", err); } res.json({ success: true }); diff --git a/apps/Backend/src/services/autoImportService.ts b/apps/Backend/src/services/autoImportService.ts index 039e1ce4..c309a928 100644 --- a/apps/Backend/src/services/autoImportService.ts +++ b/apps/Backend/src/services/autoImportService.ts @@ -5,48 +5,36 @@ import { prisma } from "@repo/db/client"; const LOCAL_BACKUP_DIR = path.resolve(process.cwd(), "backups"); -const MIGRATIONS_DIR = path.resolve(__dirname, "../../../../packages/db/prisma/migrations"); +const SCHEMA_PATH = path.resolve(__dirname, "../../../../packages/db/prisma/schema.prisma"); -async function applyMissingMigrations() { - let folders: string[]; - try { - folders = fs.readdirSync(MIGRATIONS_DIR) - .filter((name) => fs.statSync(path.join(MIGRATIONS_DIR, name)).isDirectory()) - .sort(); - } catch { - console.warn("Could not read migrations directory, skipping post-import migration."); - return; - } +function runDbPush(): Promise { + return new Promise((resolve, reject) => { + const proc = spawn("npx", ["prisma", "db", "push", "--config", path.resolve(__dirname, "../../../../packages/db/prisma/prisma.config.ts"), "--schema", SCHEMA_PATH, "--accept-data-loss"], { + cwd: process.cwd(), + env: process.env, + }); - let applied: Set; - try { - const rows = await prisma.$queryRaw<{ migration_name: string }[]>` - SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL - `; - applied = new Set(rows.map((r: { migration_name: string }) => r.migration_name)); - } catch { - applied = new Set(); - } - - for (const folder of folders) { - if (applied.has(folder)) continue; - const sqlFile = path.join(MIGRATIONS_DIR, folder, "migration.sql"); - if (!fs.existsSync(sqlFile)) continue; - const sql = fs.readFileSync(sqlFile, "utf8"); - try { - await prisma.$executeRawUnsafe(sql); - console.log(`Applied migration: ${folder}`); - } catch (err: any) { - console.warn(`Migration ${folder} had errors (may already be applied):`, err.message); - } - } + let stderr = ""; + proc.stdout.on("data", (d) => console.log(`[db-push] ${d.toString().trim()}`)); + proc.stderr.on("data", (d) => { + const line = d.toString().trim(); + stderr += line + "\n"; + console.log(`[db-push] ${line}`); + }); + proc.on("error", (err) => reject(new Error(`Failed to run prisma db push: ${err.message}`))); + proc.on("close", (code) => { + if (code !== 0) return reject(new Error(`prisma db push failed (exit ${code}): ${stderr}`)); + console.log("[db-push] Schema synced successfully"); + resolve(); + }); + }); } function getLatestBackupFile(): string | null { if (!fs.existsSync(LOCAL_BACKUP_DIR)) return null; const files = fs.readdirSync(LOCAL_BACKUP_DIR) - .filter((f) => f.endsWith(".zip")) + .filter((f) => f.endsWith(".zip") || f.endsWith(".sql")) .map((f) => ({ name: f, mtime: fs.statSync(path.join(LOCAL_BACKUP_DIR, f)).mtimeMs })) .sort((a, b) => b.mtime - a.mtime); @@ -98,20 +86,28 @@ export async function importLatestBackup(): Promise { } catch (_) {} try { - await applyMissingMigrations(); + await runDbPush(); } catch (err) { - console.error("applyMissingMigrations failed after auto-import:", err); + console.error("[auto-import] db push failed after import:", err); } console.log(`[auto-import] Successfully imported ${path.basename(backupFile)}`); resolve(); }); - const unzip = spawn("unzip", ["-p", backupFile, "*.sql"]); - unzip.stderr.on("data", (d) => console.warn(`[auto-import] unzip: ${d.toString().trim()}`)); - unzip.on("error", (err) => { - reject(new Error(`Failed to extract backup zip: ${err.message}`)); - }); - unzip.stdout.pipe(psql.stdin); + if (backupFile.endsWith(".zip")) { + const unzip = spawn("unzip", ["-p", backupFile, "*.sql"]); + unzip.stderr.on("data", (d) => console.warn(`[auto-import] unzip: ${d.toString().trim()}`)); + unzip.on("error", (err) => { + reject(new Error(`Failed to extract backup zip: ${err.message}`)); + }); + unzip.stdout.pipe(psql.stdin); + } else { + const sqlStream = fs.createReadStream(backupFile); + sqlStream.on("error", (err) => { + reject(new Error(`Failed to read backup file: ${err.message}`)); + }); + sqlStream.pipe(psql.stdin); + } }); } diff --git a/packages/db/prisma/migrations/20260625044902/migration.sql b/packages/db/prisma/migrations/20260625044902/migration.sql new file mode 100644 index 00000000..decf2150 --- /dev/null +++ b/packages/db/prisma/migrations/20260625044902/migration.sql @@ -0,0 +1,266 @@ +/* + Warnings: + + - You are about to drop the column `oralCavityArea` on the `ServiceLine` table. All the data in the column will be lost. + - Added the required column `updatedAt` to the `Patient` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterEnum +ALTER TYPE "ClaimStatus" ADD VALUE 'PREAUTH'; + +-- AlterEnum +ALTER TYPE "PatientStatus" ADD VALUE 'PLAN_NOT_ACCEPTED'; + +-- AlterTable +ALTER TABLE "Appointment" ADD COLUMN "movedByAi" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "typeLocked" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "AppointmentProcedure" ADD COLUMN "npiProviderId" INTEGER; + +-- AlterTable +ALTER TABLE "Claim" ADD COLUMN "claimNumber" TEXT, +ADD COLUMN "npiProviderId" INTEGER, +ADD COLUMN "preAuthNumber" TEXT, +ALTER COLUMN "appointmentId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "ClaimFile" ADD COLUMN "filePath" TEXT; + +-- AlterTable +ALTER TABLE "CloudFile" ADD COLUMN "diskPath" TEXT; + +-- AlterTable +ALTER TABLE "CloudFolder" ADD COLUMN "patientId" INTEGER; + +-- AlterTable +ALTER TABLE "Patient" ADD COLUMN "preferredLanguage" TEXT DEFAULT 'English', +ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +ALTER COLUMN "dateOfBirth" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "Payment" ADD COLUMN "adjustment" DECIMAL(10,2) NOT NULL DEFAULT 0.00, +ADD COLUMN "copayment" DECIMAL(10,2) NOT NULL DEFAULT 0.00, +ADD COLUMN "npiProviderId" INTEGER; + +-- AlterTable +ALTER TABLE "ServiceLine" DROP COLUMN "oralCavityArea", +ADD COLUMN "allowedAmount" DECIMAL(10,2), +ADD COLUMN "arch" TEXT, +ADD COLUMN "icn" TEXT, +ADD COLUMN "paidCode" TEXT, +ADD COLUMN "quad" TEXT; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "autoBackupHour" INTEGER NOT NULL DEFAULT 20, +ADD COLUMN "usbBackupHour" INTEGER NOT NULL DEFAULT 21; + +-- CreateTable +CREATE TABLE "AppointmentFile" ( + "id" SERIAL NOT NULL, + "appointmentId" INTEGER NOT NULL, + "filename" TEXT NOT NULL, + "mimeType" TEXT, + "filePath" TEXT, + + CONSTRAINT "AppointmentFile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ShoppingVendor" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "vendorName" TEXT NOT NULL, + "websiteUrl" TEXT NOT NULL, + "loginUsername" TEXT NOT NULL, + "loginPassword" TEXT NOT NULL, + + CONSTRAINT "ShoppingVendor_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CronJobLog" ( + "id" SERIAL NOT NULL, + "jobName" TEXT NOT NULL, + "status" TEXT NOT NULL, + "startedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "durationMs" INTEGER, + "errorMessage" TEXT, + + CONSTRAINT "CronJobLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PatientDocument" ( + "id" SERIAL NOT NULL, + "patientId" INTEGER NOT NULL, + "filename" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "fileSize" BIGINT NOT NULL, + "filePath" TEXT NOT NULL, + "uploadedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PatientDocument_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "office_hours" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "data" JSONB NOT NULL, + + CONSTRAINT "office_hours_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "office_contact" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "officeName" TEXT, + "receptionistName" TEXT, + "dentistName" TEXT, + "phoneNumber" TEXT, + "email" TEXT, + "fax" TEXT, + "streetAddress" TEXT, + "city" TEXT, + "state" TEXT, + "zipCode" TEXT, + + CONSTRAINT "office_contact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "insurance_contact" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "phoneNumber" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "insurance_contact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "patient_conversation" ( + "id" SERIAL NOT NULL, + "patientId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "stage" TEXT NOT NULL DEFAULT 'initial', + "aiHandoff" BOOLEAN NOT NULL DEFAULT true, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "patient_conversation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommissionBatch" ( + "id" SERIAL NOT NULL, + "npiProviderId" INTEGER NOT NULL, + "totalCollection" DECIMAL(14,2) NOT NULL, + "commissionAmount" DECIMAL(14,2) NOT NULL, + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CommissionBatch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CommissionBatchItem" ( + "id" SERIAL NOT NULL, + "commissionBatchId" INTEGER NOT NULL, + "paymentId" INTEGER NOT NULL, + "collectionAmount" DECIMAL(14,2) NOT NULL, + + CONSTRAINT "CommissionBatchItem_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AppointmentFile_appointmentId_idx" ON "AppointmentFile"("appointmentId"); + +-- CreateIndex +CREATE INDEX "ShoppingVendor_userId_idx" ON "ShoppingVendor"("userId"); + +-- CreateIndex +CREATE INDEX "CronJobLog_jobName_idx" ON "CronJobLog"("jobName"); + +-- CreateIndex +CREATE INDEX "CronJobLog_startedAt_idx" ON "CronJobLog"("startedAt"); + +-- CreateIndex +CREATE INDEX "CronJobLog_status_idx" ON "CronJobLog"("status"); + +-- CreateIndex +CREATE INDEX "PatientDocument_patientId_idx" ON "PatientDocument"("patientId"); + +-- CreateIndex +CREATE INDEX "PatientDocument_uploadedAt_idx" ON "PatientDocument"("uploadedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "office_hours_userId_key" ON "office_hours"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "office_contact_userId_key" ON "office_contact"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "patient_conversation_patientId_key" ON "patient_conversation"("patientId"); + +-- CreateIndex +CREATE INDEX "CommissionBatch_npiProviderId_idx" ON "CommissionBatch"("npiProviderId"); + +-- CreateIndex +CREATE INDEX "CommissionBatchItem_paymentId_idx" ON "CommissionBatchItem"("paymentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "CommissionBatchItem_commissionBatchId_paymentId_key" ON "CommissionBatchItem"("commissionBatchId", "paymentId"); + +-- CreateIndex +CREATE INDEX "CloudFolder_patientId_idx" ON "CloudFolder"("patientId"); + +-- AddForeignKey +ALTER TABLE "AppointmentFile" ADD CONSTRAINT "AppointmentFile_appointmentId_fkey" FOREIGN KEY ("appointmentId") REFERENCES "Appointment"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AppointmentProcedure" ADD CONSTRAINT "AppointmentProcedure_npiProviderId_fkey" FOREIGN KEY ("npiProviderId") REFERENCES "NpiProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Claim" ADD CONSTRAINT "Claim_npiProviderId_fkey" FOREIGN KEY ("npiProviderId") REFERENCES "NpiProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ShoppingVendor" ADD CONSTRAINT "ShoppingVendor_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_npiProviderId_fkey" FOREIGN KEY ("npiProviderId") REFERENCES "NpiProvider"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CloudFolder" ADD CONSTRAINT "CloudFolder_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PatientDocument" ADD CONSTRAINT "PatientDocument_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "office_hours" ADD CONSTRAINT "office_hours_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "office_contact" ADD CONSTRAINT "office_contact_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "insurance_contact" ADD CONSTRAINT "insurance_contact_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "patient_conversation" ADD CONSTRAINT "patient_conversation_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "patient_conversation" ADD CONSTRAINT "patient_conversation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionBatch" ADD CONSTRAINT "CommissionBatch_npiProviderId_fkey" FOREIGN KEY ("npiProviderId") REFERENCES "NpiProvider"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionBatchItem" ADD CONSTRAINT "CommissionBatchItem_commissionBatchId_fkey" FOREIGN KEY ("commissionBatchId") REFERENCES "CommissionBatch"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CommissionBatchItem" ADD CONSTRAINT "CommissionBatchItem_paymentId_fkey" FOREIGN KEY ("paymentId") REFERENCES "Payment"("id") ON DELETE RESTRICT ON UPDATE CASCADE;