Files
api/prisma/repair-khanegi-ui-config.ts
T
MahdiandCursor a589c7f0c2 fix: normalize Khanegi newTheme ui config for userTheme schema
Map sidebar+bottom→both, products→buyProduct, ensure header.variant. Repair script + get_user normalize so colors apply instead of Zod fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 12:34:07 +03:30

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();
});