diff --git a/package.json b/package.json index 2a96ce2..74b067b 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "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-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:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts" }, diff --git a/prisma/lib/appearance-builders.ts b/prisma/lib/appearance-builders.ts index 2c4b9e3..aac5b8e 100644 --- a/prisma/lib/appearance-builders.ts +++ b/prisma/lib/appearance-builders.ts @@ -165,17 +165,18 @@ export function buildKhanegiUiFromColors(input: { borderRadius: { sm: 8, md: 12, lg: 16 }, }, navigation: { - type: 'sidebar+bottom', + // userTheme schema: bottom-navigation | sidebar | both + type: 'both', sidebar: [ { 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: '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 }, ], bottom: [ { 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: '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 }, @@ -189,6 +190,7 @@ export function buildKhanegiUiFromColors(input: { }, header: { visible: true, + variant: 'default', showLogo: true, showNotifications: true, showProfile: true, diff --git a/prisma/repair-khanegi-ui-config.ts b/prisma/repair-khanegi-ui-config.ts new file mode 100644 index 0000000..d610baf --- /dev/null +++ b/prisma/repair-khanegi-ui-config.ts @@ -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) }; + 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(); + }); diff --git a/src/get-user/get-user.service.ts b/src/get-user/get-user.service.ts index 848dabe..51fe587 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -83,7 +83,7 @@ export class GetUserService { ui_variant: isKhanegi ? uiVariant : undefined, app_ui_config: isKhanegi && uiVariant === 'newTheme' - ? this.stripKhanegiSaMeta(ui) + ? this.stripKhanegiSaMeta(this.normalizeKhanegiUiConfig(ui)) : undefined, /** Laravel-compatible overlay; Hamkar merges only when present */ ui_settings: uiSettings.length ? uiSettings : undefined, @@ -168,6 +168,49 @@ export class GetUserService { return rest; } + /** + * Backfillهای قدیمی sidebar+bottom / route=products / بدون header.variant + * را با اسکیمای userTheme هم‌تراز می‌کند تا Zod رد نکند و fallback نشود. + */ + private normalizeKhanegiUiConfig(ui: Record | null) { + if (!ui || typeof ui !== 'object') return ui; + const next = JSON.parse(JSON.stringify(ui)) as Record; + 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): UiSetting[] { const settings = ui?.settings; if (!settings || typeof settings !== 'object') return [];