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>
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
"prisma:inventory-khanegi-sms": "ts-node --transpile-only prisma/inventory-khanegi-sms.ts",
|
"prisma:inventory-khanegi-sms": "ts-node --transpile-only prisma/inventory-khanegi-sms.ts",
|
||||||
"prisma:bulk-khanegi-sms-defaults": "ts-node --transpile-only prisma/bulk-khanegi-sms-defaults.ts",
|
"prisma:bulk-khanegi-sms-defaults": "ts-node --transpile-only prisma/bulk-khanegi-sms-defaults.ts",
|
||||||
"prisma:bulk-khanegi-modular-brand": "ts-node --transpile-only prisma/bulk-khanegi-modular-brand.ts",
|
"prisma:bulk-khanegi-modular-brand": "ts-node --transpile-only prisma/bulk-khanegi-modular-brand.ts",
|
||||||
|
"prisma:repair-khanegi-ui-config": "ts-node --transpile-only prisma/repair-khanegi-ui-config.ts",
|
||||||
"prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts",
|
"prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts",
|
||||||
"prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts"
|
"prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -165,17 +165,18 @@ export function buildKhanegiUiFromColors(input: {
|
|||||||
borderRadius: { sm: 8, md: 12, lg: 16 },
|
borderRadius: { sm: 8, md: 12, lg: 16 },
|
||||||
},
|
},
|
||||||
navigation: {
|
navigation: {
|
||||||
type: 'sidebar+bottom',
|
// userTheme schema: bottom-navigation | sidebar | both
|
||||||
|
type: 'both',
|
||||||
sidebar: [
|
sidebar: [
|
||||||
{ id: 'home', label: 'داشبورد', icon: 'vuesax-linear:chart', route: 'home', order: 1, visible: true },
|
{ id: 'home', label: 'داشبورد', icon: 'vuesax-linear:chart', route: 'home', order: 1, visible: true },
|
||||||
{ id: 'products', label: 'معاملات', icon: 'vuesax-linear:shop', route: 'products', order: 2, visible: true },
|
{ id: 'products', label: 'معاملات', icon: 'vuesax-linear:shop', route: 'buyProduct', order: 2, visible: true },
|
||||||
{ id: 'transactions', label: 'تراکنشها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true },
|
{ id: 'transactions', label: 'تراکنشها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true },
|
||||||
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true },
|
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true },
|
||||||
{ id: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true },
|
{ id: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true },
|
||||||
],
|
],
|
||||||
bottom: [
|
bottom: [
|
||||||
{ id: 'home', label: 'خانه', icon: 'vuesax-linear:home-2', route: 'home', order: 1, visible: true },
|
{ id: 'home', label: 'خانه', icon: 'vuesax-linear:home-2', route: 'home', order: 1, visible: true },
|
||||||
{ id: 'products', label: 'معامله', icon: 'vuesax-linear:shop', route: 'products', order: 2, visible: true },
|
{ id: 'products', label: 'معامله', icon: 'vuesax-linear:shop', route: 'buyProduct', order: 2, visible: true },
|
||||||
{ id: 'transactions', label: 'سوابق', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true },
|
{ id: 'transactions', label: 'سوابق', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true },
|
||||||
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true },
|
{ id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true },
|
||||||
{ id: 'user', label: 'پروفایل', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true },
|
{ id: 'user', label: 'پروفایل', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true },
|
||||||
@@ -189,6 +190,7 @@ export function buildKhanegiUiFromColors(input: {
|
|||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
visible: true,
|
visible: true,
|
||||||
|
variant: 'default',
|
||||||
showLogo: true,
|
showLogo: true,
|
||||||
showNotifications: true,
|
showNotifications: true,
|
||||||
showProfile: true,
|
showProfile: true,
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
});
|
||||||
@@ -83,7 +83,7 @@ export class GetUserService {
|
|||||||
ui_variant: isKhanegi ? uiVariant : undefined,
|
ui_variant: isKhanegi ? uiVariant : undefined,
|
||||||
app_ui_config:
|
app_ui_config:
|
||||||
isKhanegi && uiVariant === 'newTheme'
|
isKhanegi && uiVariant === 'newTheme'
|
||||||
? this.stripKhanegiSaMeta(ui)
|
? this.stripKhanegiSaMeta(this.normalizeKhanegiUiConfig(ui))
|
||||||
: undefined,
|
: undefined,
|
||||||
/** Laravel-compatible overlay; Hamkar merges only when present */
|
/** Laravel-compatible overlay; Hamkar merges only when present */
|
||||||
ui_settings: uiSettings.length ? uiSettings : undefined,
|
ui_settings: uiSettings.length ? uiSettings : undefined,
|
||||||
@@ -168,6 +168,49 @@ export class GetUserService {
|
|||||||
return rest;
|
return rest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfillهای قدیمی sidebar+bottom / route=products / بدون header.variant
|
||||||
|
* را با اسکیمای userTheme همتراز میکند تا Zod رد نکند و fallback نشود.
|
||||||
|
*/
|
||||||
|
private normalizeKhanegiUiConfig(ui: Record<string, any> | null) {
|
||||||
|
if (!ui || typeof ui !== 'object') return ui;
|
||||||
|
const next = JSON.parse(JSON.stringify(ui)) as Record<string, any>;
|
||||||
|
this.normalizeKhanegiAppNode(next.app);
|
||||||
|
this.normalizeKhanegiAppNode(next.defaultSnapshot?.app);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeKhanegiAppNode(app: any) {
|
||||||
|
if (!app || typeof app !== 'object') return;
|
||||||
|
|
||||||
|
if (app.startRoute === 'products') app.startRoute = 'buyProduct';
|
||||||
|
|
||||||
|
const header = app.header;
|
||||||
|
if (header && typeof header === 'object' && header.variant !== 'default') {
|
||||||
|
header.variant = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigation = app.navigation;
|
||||||
|
if (!navigation || typeof navigation !== 'object') return;
|
||||||
|
|
||||||
|
if (navigation.type === 'sidebar+bottom') navigation.type = 'both';
|
||||||
|
|
||||||
|
const mapItems = (items: unknown) => {
|
||||||
|
if (!Array.isArray(items)) return items;
|
||||||
|
return items.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object') return item;
|
||||||
|
if (item.route === 'products') return { ...item, route: 'buyProduct' };
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
navigation.sidebar = mapItems(navigation.sidebar);
|
||||||
|
navigation.bottom = mapItems(navigation.bottom);
|
||||||
|
if (navigation.sidebarReferral?.route === 'products') {
|
||||||
|
navigation.sidebarReferral.route = 'buyProduct';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private buildHamkarUiSettings(ui: Record<string, any>): UiSetting[] {
|
private buildHamkarUiSettings(ui: Record<string, any>): UiSetting[] {
|
||||||
const settings = ui?.settings;
|
const settings = ui?.settings;
|
||||||
if (!settings || typeof settings !== 'object') return [];
|
if (!settings || typeof settings !== 'object') return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user