Add paged orders API and infinite scroll, short order numbers, and align the guest invite card with the logged-in hero shell. Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* 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<string>;
|
|
reportPath: string;
|
|
}
|
|
|
|
/* ───────────── report ───────────── */
|
|
|
|
interface Section {
|
|
counts: Map<string, number>;
|
|
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<string, Section>();
|
|
|
|
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<string, Map<string, { target: string; how: How }>>();
|
|
|
|
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<boolean>;
|
|
find: () => Promise<{ id: string } | null>;
|
|
create: () => Promise<{ id: string }>;
|
|
update: (id: string, how: How) => Promise<void>;
|
|
},
|
|
): 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<T extends Record<string, unknown>>(current: Record<string, unknown>, patch: T): Partial<T> {
|
|
const out: Partial<T> = {};
|
|
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<string, unknown>)[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<T = Record<string, unknown>>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
/** Public URL for a legacy `image` row, copying its file when a source dir is given. */
|
|
imageUrl(imageId: unknown, step: string): Promise<string | null>;
|
|
/**
|
|
* 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<string | null>;
|
|
}
|
|
|
|
const MIME: Record<string, string> = {
|
|
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<string, string | null>();
|
|
|
|
const ctx: Ctx = {
|
|
src,
|
|
db,
|
|
map,
|
|
report,
|
|
opt,
|
|
async q<T>(sql: string, params: unknown[] = []) {
|
|
const [rows] = await src.query<RowDataPacket[]>(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<string>();
|
|
|
|
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');
|
|
}
|