/** * Shared state for one import run: both connections, the id map and the report. */ import { copyFileSync, existsSync, mkdirSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { PrismaClient } from '@prisma/client'; import { createPool, type Pool, type RowDataPacket } from 'mysql2/promise'; export interface Options { sourceUrl: string; targetUrl: string; /** Legacy system's `storage/` folder holding the image files (optional). */ imagesDir?: string; /** Target UPLOAD_DIR the files are copied into (required with imagesDir). */ uploadDir?: string; /** Run only these steps (by name); all when unset. */ only?: Set; reportPath: string; } /* ───────────── report ───────────── */ interface Section { counts: Map; notes: string[]; } /** * What the run did, step by step. Counts say how many rows went which way; * notes are the things a person must look at. Rendered to markdown at the end. */ export class Report { readonly startedAt = new Date(); private readonly sections = new Map(); private section(step: string): Section { let s = this.sections.get(step); if (!s) { s = { counts: new Map(), notes: [] }; this.sections.set(step, s); } return s; } count(step: string, key: string, n = 1) { const s = this.section(step); s.counts.set(key, (s.counts.get(key) ?? 0) + n); } note(step: string, message: string) { this.section(step).notes.push(message); } get(step: string, key: string): number { return this.sections.get(step)?.counts.get(key) ?? 0; } render(header: string[]): string { const out: string[] = ['# legacy (oapi) → tehran-taj import report', '', ...header, '']; for (const [step, s] of this.sections) { out.push(`## ${step}`, ''); if (s.counts.size) { out.push('| | |', '|---|---:|'); for (const [k, v] of s.counts) out.push(`| ${k} | ${v} |`); out.push(''); } for (const n of s.notes) out.push(`- ${n}`); if (s.notes.length) out.push(''); } return out.join('\n'); } } /* ───────────── id map ───────────── */ /** * Legacy id -> target id, per entity, persisted in the TARGET database under * its own schema `legacy_import` — outside the `public` schema Prisma manages, * so it is no app-schema change and drops cleanly after cutover * (`DROP SCHEMA legacy_import CASCADE`). * * It is what makes a re-run safe (a mapped row is updated, never re-created) * and what later redirects old links such as /product/123 (GO-LIVE-PLAN §7). * `how` records whether the row was created by the import or matched to a row * that already existed in the target (merge, §10.5). */ export type How = 'created' | 'matched' | 'merged'; export class IdMap { private readonly cache = new Map>(); constructor(private readonly db: PrismaClient) {} static async ensureSchema(db: PrismaClient) { await db.$executeRawUnsafe(`CREATE SCHEMA IF NOT EXISTS legacy_import`); await db.$executeRawUnsafe(` CREATE TABLE IF NOT EXISTS legacy_import.id_map ( entity text NOT NULL, legacy_id text NOT NULL, target_id text NOT NULL, how text NOT NULL, mapped_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (entity, legacy_id) )`); } async load(entity: string) { const rows = await this.db.$queryRawUnsafe<{ legacy_id: string; target_id: string; how: How }[]>( `SELECT legacy_id, target_id, how FROM legacy_import.id_map WHERE entity = $1`, entity, ); this.cache.set(entity, new Map(rows.map((r) => [r.legacy_id, { target: r.target_id, how: r.how }]))); } get(entity: string, legacyId: unknown): string | undefined { if (legacyId === null || legacyId === undefined) return undefined; return this.cache.get(entity)?.get(String(legacyId))?.target; } how(entity: string, legacyId: unknown): How | undefined { return this.cache.get(entity)?.get(String(legacyId))?.how; } async set(entity: string, legacyId: unknown, targetId: string, how: How) { const key = String(legacyId); await this.db.$executeRawUnsafe( `INSERT INTO legacy_import.id_map (entity, legacy_id, target_id, how) VALUES ($1, $2, $3, $4) ON CONFLICT (entity, legacy_id) DO UPDATE SET target_id = EXCLUDED.target_id, how = EXCLUDED.how`, entity, key, targetId, how, ); let m = this.cache.get(entity); if (!m) { m = new Map(); this.cache.set(entity, m); } m.set(key, { target: targetId, how }); } async drop(entity: string, legacyId: unknown) { await this.db.$executeRawUnsafe( `DELETE FROM legacy_import.id_map WHERE entity = $1 AND legacy_id = $2`, entity, String(legacyId), ); this.cache.get(entity)?.delete(String(legacyId)); } } /* ───────────── upsert discipline ───────────── */ /** * The one way a step writes a row (§10.5: this is a MERGE into a live target): * * - already mapped -> `update(id, how)`; the step decides what it may touch * - matches a target row -> mapped as 'matched', then `update(id, 'matched')` * - otherwise -> `create()`, mapped as 'created' * * A mapping whose target row has since been deleted is dropped and redone, so a * re-run heals instead of failing on a dangling id. */ export async function upsert( ctx: Ctx, entity: string, legacyId: unknown, ops: { exists: (id: string) => Promise; find: () => Promise<{ id: string } | null>; create: () => Promise<{ id: string }>; update: (id: string, how: How) => Promise; }, ): Promise<{ id: string; how: How; isNew: boolean }> { const mapped = ctx.map.get(entity, legacyId); if (mapped) { if (await ops.exists(mapped)) { const how = ctx.map.how(entity, legacyId) ?? 'created'; await ops.update(mapped, how); return { id: mapped, how, isNew: false }; } await ctx.map.drop(entity, legacyId); } const found = await ops.find(); if (found) { await ctx.map.set(entity, legacyId, found.id, 'matched'); await ops.update(found.id, 'matched'); return { id: found.id, how: 'matched', isNew: false }; } const created = await ops.create(); await ctx.map.set(entity, legacyId, created.id, 'created'); return { id: created.id, how: 'created', isNew: true }; } /** * For a row that already existed in the target (`matched`): only the fields * the target left empty are filled. The target's own values win — the owner * confirmed its data is real (§10.5). */ export function fillEmpty>(current: Record, patch: T): Partial { const out: Partial = {}; for (const [k, v] of Object.entries(patch)) { const cur = current[k]; if ((cur === null || cur === undefined || cur === '') && v !== null && v !== undefined) { (out as Record)[k] = v; } } return out; } /* ───────────── context ───────────── */ export interface Ctx { src: Pool; db: PrismaClient; map: IdMap; report: Report; opt: Options; /** A read-only query against the legacy MySQL. */ q>(sql: string, params?: unknown[]): Promise; /** Public URL for a legacy `image` row, copying its file when a source dir is given. */ imageUrl(imageId: unknown, step: string): Promise; /** * An `UploadedFile` row for a legacy image — what avatars point at * (Customer/AdminUser.avatarFileId are FKs, not URLs). Mapped as 'file'. */ uploadedFile(imageId: unknown, purpose: string, step: string): Promise; } const MIME: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', }; export function openSource(url: string): Pool { return createPool({ uri: url, connectionLimit: 4, charset: 'utf8mb4', // Timestamps come back as the wall-clock text legacy stored, and are // converted from Tehran time explicitly (normalize.tehran). The session // time zone is left at the server's own, which is what Laravel and the // Navicat dump both used. dateStrings: true, supportBigNumbers: true, bigNumberStrings: true, }); } export function makeContext(opt: Options, src: Pool, db: PrismaClient): Ctx { const map = new IdMap(db); const report = new Report(); const imageRows = new Map(); const ctx: Ctx = { src, db, map, report, opt, async q(sql: string, params: unknown[] = []) { const [rows] = await src.query(sql, params); return rows as unknown as T[]; }, async imageUrl(imageId: unknown, step: string) { if (imageId === null || imageId === undefined || imageId === '') return null; const key = String(imageId); if (!imageRows.has(key)) { const [row] = await ctx.q<{ image: string | null }>('SELECT image FROM image WHERE id = ?', [key]); imageRows.set(key, row?.image ? String(row.image).trim() : null); } const file = imageRows.get(key); if (!file) { report.count(step, 'image reference with no `image` row'); return null; } copyImage(ctx, file, step); // Same shape the admin upload writes (admin-uploads.service.ts:61). return `/uploads/${file}`; }, async uploadedFile(imageId: unknown, purpose: string, step: string) { if (imageId === null || imageId === undefined || imageId === '') return null; // Resolve first even when mapped: resolving copies the file (see productImages). const url = await ctx.imageUrl(imageId, step); const mapped = map.get('file', imageId); if (mapped) return mapped; if (!url) return null; const [row] = await ctx.q<{ original_name: string | null; ext: string | null }>( 'SELECT original_name, ext FROM image WHERE id = ?', [String(imageId)], ); const file = url.slice('/uploads/'.length); const ext = (row?.ext ?? file.split('.').pop() ?? '').toLowerCase().trim(); const src = ctx.opt.imagesDir ? join(ctx.opt.imagesDir, file) : null; const created = await db.uploadedFile.create({ data: { url, originalName: (row?.original_name ?? '').trim() || file, mimeType: MIME[ext] ?? 'application/octet-stream', sizeBytes: src && existsSync(src) ? statSync(src).size : 0, purpose, }, }); await map.set('file', imageId, created.id, 'created'); return created.id; }, }; return ctx; } const copied = new Set(); function copyImage(ctx: Ctx, file: string, step: string) { const { imagesDir, uploadDir } = ctx.opt; if (!imagesDir || !uploadDir || copied.has(file)) return; copied.add(file); const from = join(imagesDir, file); const to = join(uploadDir, file); if (existsSync(to)) { ctx.report.count('images', 'already in upload dir'); return; } if (!existsSync(from) || !statSync(from).isFile()) { ctx.report.count('images', 'missing in legacy storage'); ctx.report.note('images', `missing file ${file} (referenced from ${step})`); return; } mkdirSync(uploadDir, { recursive: true }); copyFileSync(from, to); ctx.report.count('images', 'copied'); }