/** * Normalize stored Khanegi appUiConfig so userTheme Zod schema accepts it. * * Fixes common backfill issues: * navigation.type: "sidebar+bottom" → "both" * header.variant missing → "default" * route "products" → "buyProduct" * * Dry-run: * KHANEGI_UI_REPAIR_DRY_RUN=1 npm run prisma:repair-khanegi-ui-config * * Apply: * npm run prisma:repair-khanegi-ui-config */ import { Prisma, PrismaClient, ProductType } from '@prisma/client'; const prisma = new PrismaClient(); function env(name: string, fallback = '') { return String(process.env[name] ?? fallback).trim(); } function bumpAppVersion(current?: string | null): string { const v = String(current || '1').trim() || '1'; if (/^\d+$/.test(v)) return String(Number(v) + 1); const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/); if (semver) return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`; return `${Date.now()}`; } function mapNavType(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; if (value === 'sidebar+bottom' || value === 'both') return 'both'; if (value === 'bottom-navigation' || value === 'sidebar') return value; return value; } function mapRoute(value: unknown): unknown { if (value === 'products') return 'buyProduct'; return value; } function mapNavItems(items: unknown): unknown { if (!Array.isArray(items)) return items; return items.map((item) => { if (!item || typeof item !== 'object' || Array.isArray(item)) return item; const row = { ...(item as Record) }; if ('route' in row) row.route = mapRoute(row.route); return row; }); } function normalizeUiConfig(raw: unknown): { next: Record | null; changed: boolean; fixes: string[]; } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { next: null, changed: false, fixes: [] }; } const root = JSON.parse(JSON.stringify(raw)) as Record; const fixes: string[] = []; const app = root.app && typeof root.app === 'object' && !Array.isArray(root.app) ? (root.app as Record) : null; if (!app) { return { next: root, changed: false, fixes }; } const navigation = app.navigation && typeof app.navigation === 'object' && !Array.isArray(app.navigation) ? (app.navigation as Record) : null; if (navigation) { const mapped = mapNavType(navigation.type); if (mapped && mapped !== navigation.type) { fixes.push(`navigation.type:${String(navigation.type)}→${mapped}`); navigation.type = mapped; } if ('sidebar' in navigation) { const before = JSON.stringify(navigation.sidebar); navigation.sidebar = mapNavItems(navigation.sidebar); if (JSON.stringify(navigation.sidebar) !== before) { fixes.push('navigation.sidebar.routes'); } } if ('bottom' in navigation) { const before = JSON.stringify(navigation.bottom); navigation.bottom = mapNavItems(navigation.bottom); if (JSON.stringify(navigation.bottom) !== before) { fixes.push('navigation.bottom.routes'); } } const referral = navigation.sidebarReferral && typeof navigation.sidebarReferral === 'object' && !Array.isArray(navigation.sidebarReferral) ? (navigation.sidebarReferral as Record) : null; if (referral && 'route' in referral) { const mappedRoute = mapRoute(referral.route); if (mappedRoute !== referral.route) { fixes.push('navigation.sidebarReferral.route'); referral.route = mappedRoute; } } } const header = app.header && typeof app.header === 'object' && !Array.isArray(app.header) ? (app.header as Record) : null; if (header && header.variant !== 'default') { fixes.push('header.variant→default'); header.variant = 'default'; } if (app.startRoute === 'products') { fixes.push('startRoute:products→buyProduct'); app.startRoute = 'buyProduct'; } // Keep factory snapshot consistent if present const snap = root.defaultSnapshot && typeof root.defaultSnapshot === 'object' && !Array.isArray(root.defaultSnapshot) ? (root.defaultSnapshot as Record) : null; if (snap?.app && typeof snap.app === 'object' && !Array.isArray(snap.app)) { const nested = normalizeUiConfig({ schemaId: 'ui-config', version: '1.0.0', app: snap.app, pages: snap.pages || {}, }); if (nested.next?.app) { snap.app = nested.next.app; if (nested.fixes.length) fixes.push(...nested.fixes.map((f) => `snapshot.${f}`)); } } return { next: root, changed: fixes.length > 0, fixes }; } async function main() { const dryRun = env('KHANEGI_UI_REPAIR_DRY_RUN') === '1'; const tenants = await prisma.tenant.findMany({ where: { product: ProductType.khanegi, isActive: true }, select: { id: true, domain: true, nameFa: true, uiVariant: true, appUiConfig: true, appVersion: true, }, orderBy: { domain: 'asc' }, }); let updated = 0; for (const t of tenants) { if (!t.appUiConfig) continue; const { next, changed, fixes } = normalizeUiConfig(t.appUiConfig); if (!changed || !next) continue; const nextVersion = bumpAppVersion(t.appVersion); console.log( JSON.stringify({ domain: t.domain, nameFa: t.nameFa, uiVariant: t.uiVariant, fixes, appVersion: { from: t.appVersion, to: nextVersion }, dryRun, }), ); if (dryRun) continue; await prisma.tenant.update({ where: { id: t.id }, data: { appUiConfig: next as Prisma.InputJsonValue, appVersion: nextVersion, }, }); updated += 1; } console.log(JSON.stringify({ dryRun, updated, scanned: tenants.length, done: true })); } main() .catch((err) => { console.error(err); process.exitCode = 1; }) .finally(async () => { await prisma.$disconnect(); });