206 lines
6.0 KiB
TypeScript
206 lines
6.0 KiB
TypeScript
/**
|
|||
|
|
* 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<string, unknown>) };
|
||
|
|
if ('route' in row) row.route = mapRoute(row.route);
|
||
|
|
return row;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function normalizeUiConfig(raw: unknown): {
|
||
|
|
next: Record<string, unknown> | 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<string, unknown>;
|
||
|
|
const fixes: string[] = [];
|
||
|
|
const app =
|
||
|
|
root.app && typeof root.app === 'object' && !Array.isArray(root.app)
|
||
|
|
? (root.app as Record<string, unknown>)
|
||
|
|
: 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<string, unknown>)
|
||
|
|
: 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<string, unknown>)
|
||
|
|
: 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<string, unknown>)
|
||
|
|
: 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<string, unknown>)
|
||
|
|
: 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();
|
||
|
|
});
|