From b66e47c3b30b49528ea09038be8d9aafe49b66ee Mon Sep 17 00:00:00 2001 From: Mahdi Date: Mon, 3 Aug 2026 21:13:46 +0330 Subject: [PATCH] Add Khanegi default appearance snapshot seed and strip SA meta from get_user. Mirrors Hamkar so each khanegi tenant can restore its factory ui-config without exposing defaultSnapshot publicly. Co-authored-by: Cursor --- package.json | 3 +- prisma/lib/appearance-builders.ts | 10 +- prisma/seed-khanegi-default-snapshots.ts | 206 +++++++++++++++++++++++ src/get-user/get-user.service.ts | 9 +- 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 prisma/seed-khanegi-default-snapshots.ts diff --git a/package.json b/package.json index 5268825..d97a329 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "prisma:seed": "ts-node --transpile-only prisma/seed.ts", "prisma:import-users": "ts-node --transpile-only prisma/import-users.ts", "prisma:backfill-appearance": "ts-node --transpile-only prisma/backfill-appearance-from-registry.ts", - "prisma:seed-hamkar-defaults": "ts-node --transpile-only prisma/seed-hamkar-default-snapshots.ts" + "prisma:seed-hamkar-defaults": "ts-node --transpile-only prisma/seed-hamkar-default-snapshots.ts", + "prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts" }, "prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" diff --git a/prisma/lib/appearance-builders.ts b/prisma/lib/appearance-builders.ts index 8664009..2c4b9e3 100644 --- a/prisma/lib/appearance-builders.ts +++ b/prisma/lib/appearance-builders.ts @@ -148,7 +148,7 @@ export function buildKhanegiUiFromColors(input: { onAccent: '#ffffff', } - return { + const base = { schemaId: 'ui-config', version: '1.0.0', app: { @@ -198,4 +198,12 @@ export function buildKhanegiUiFromColors(input: { pages: {}, _backfill: 'colors-from-live-registry', } + + return { + ...base, + defaultSnapshot: { + app: JSON.parse(JSON.stringify(base.app)), + pages: JSON.parse(JSON.stringify(base.pages || {})), + }, + } } diff --git a/prisma/seed-khanegi-default-snapshots.ts b/prisma/seed-khanegi-default-snapshots.ts new file mode 100644 index 0000000..a4acfeb --- /dev/null +++ b/prisma/seed-khanegi-default-snapshots.ts @@ -0,0 +1,206 @@ +/** + * Seed Khanegi defaultSnapshot from current appUiConfig (or color-built UI). + * Optionally restore current appearance to that snapshot (--restore). + * + * Usage: + * npx ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts + * npx ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts --restore + * npx ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts --restore --domain=example.ir + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { PrismaClient, ProductType } from '@prisma/client'; +import { buildKhanegiUiFromColors } from './lib/appearance-builders'; + +const prisma = new PrismaClient(); + +type SourceUser = { + id: number; + domain?: string | null; + domains?: string | string[] | null; + api_origin?: string | null; + color?: string | null; + secondary_color?: string | null; + background_color?: string | null; + app_ui_config?: unknown; + deleted_at?: string | null; +}; + +function parseArgs() { + const restore = process.argv.includes('--restore'); + const domainArg = process.argv.find((a) => a.startsWith('--domain=')); + const domain = domainArg ? domainArg.slice('--domain='.length).trim().toLowerCase() : null; + return { restore, domain }; +} + +function parseDomains(raw: SourceUser['domains']): string[] { + if (!raw) return []; + try { + const value = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!Array.isArray(value)) return []; + return value + .map((item) => String(item).trim().toLowerCase()) + .filter(Boolean) + .map((item) => item.replace(/^https?:\/\//, '').split('/')[0]); + } catch { + return []; + } +} + +function primaryDomain(row: SourceUser): string | null { + const fromField = row.domain?.trim().toLowerCase(); + if (fromField) return fromField.replace(/^https?:\/\//, '').split('/')[0]; + const fromOrigin = row.api_origin?.trim().toLowerCase(); + if (fromOrigin) return fromOrigin.replace(/^https?:\/\//, '').split('/')[0]; + return parseDomains(row.domains)[0] || null; +} + +function cloneSnap(ui: any) { + return { + app: JSON.parse(JSON.stringify(ui.app)), + pages: JSON.parse(JSON.stringify(ui.pages || {})), + }; +} + +function stripMeta(ui: any) { + if (!ui || typeof ui !== 'object') return ui; + const { defaultSnapshot: _s, _backfill, ...rest } = ui; + return rest; +} + +async function main() { + const { restore, domain: onlyDomain } = parseArgs(); + const file = path.join(__dirname, 'data', 'users.json'); + const byDomain = new Map(); + + if (fs.existsSync(file)) { + const rows = JSON.parse(fs.readFileSync(file, 'utf8')) as SourceUser[]; + for (const row of rows) { + if (row.deleted_at) continue; + const d = primaryDomain(row); + if (d && !byDomain.has(d)) byDomain.set(d, row); + for (const extra of parseDomains(row.domains)) { + if (!byDomain.has(extra)) byDomain.set(extra, row); + } + } + } + + const tenants = await prisma.tenant.findMany({ + where: { product: ProductType.khanegi }, + }); + + const filtered = onlyDomain + ? tenants.filter((tenant) => { + const keys = [ + tenant.domain?.toLowerCase(), + ...((Array.isArray(tenant.domains) ? tenant.domains : []) as string[]).map((d) => + String(d).toLowerCase(), + ), + ]; + return keys.includes(onlyDomain); + }) + : tenants; + + let updated = 0; + let restored = 0; + let skipped = 0; + + for (const tenant of filtered) { + const domainKeys = [ + tenant.domain?.toLowerCase(), + ...((Array.isArray(tenant.domains) ? tenant.domains : []) as string[]).map((d) => + String(d).toLowerCase(), + ), + ].filter(Boolean) as string[]; + + const source = domainKeys.map((d) => byDomain.get(d)).find(Boolean) || null; + + const existing = + tenant.appUiConfig && typeof tenant.appUiConfig === 'object' + ? (tenant.appUiConfig as Record) + : null; + + const fromRegistry = + source?.app_ui_config && + typeof source.app_ui_config === 'object' && + ((source.app_ui_config as any).schemaId === 'ui-config' || (source.app_ui_config as any).app) + ? (source.app_ui_config as Record) + : null; + + const built = + existing && (existing.schemaId === 'ui-config' || existing.app) + ? existing + : buildKhanegiUiFromColors({ + primary: source?.color || tenant.primaryColor, + secondary: source?.secondary_color || tenant.secondaryColor, + background: source?.background_color || tenant.backgroundColor, + existing: source?.app_ui_config, + }); + + // Factory look: never overwrite existing snapshot; else registry JSON, else current UI + const keepSnapshot = + existing?.defaultSnapshot && typeof existing.defaultSnapshot === 'object' + ? existing.defaultSnapshot + : fromRegistry + ? cloneSnap(fromRegistry) + : cloneSnap(built); + + const nextUi = restore + ? { + ...stripMeta(built), + schemaId: built.schemaId || 'ui-config', + version: built.version || '1.0.0', + app: JSON.parse(JSON.stringify(keepSnapshot.app)), + pages: JSON.parse(JSON.stringify(keepSnapshot.pages || {})), + defaultSnapshot: keepSnapshot, + } + : { + ...stripMeta(built), + defaultSnapshot: keepSnapshot, + }; + + const unchanged = + JSON.stringify(existing) === JSON.stringify(nextUi) && !restore; + if (unchanged) { + skipped += 1; + continue; + } + + const colors = nextUi.app?.theme?.colors || {}; + await prisma.tenant.update({ + where: { id: tenant.id }, + data: { + appUiConfig: nextUi as any, + ...(restore + ? { + primaryColor: colors.primary ?? tenant.primaryColor, + secondaryColor: colors.secondary ?? tenant.secondaryColor, + backgroundColor: colors.background ?? tenant.backgroundColor, + } + : {}), + }, + }); + + updated += 1; + if (restore) restored += 1; + console.log(`${restore ? 'restored' : 'seeded'}: ${tenant.domain}`); + } + + console.log({ + tenants: filtered.length, + updated, + restored, + skipped, + restore, + domain: onlyDomain, + }); +} + +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 ca97e56..64cf15b 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -75,7 +75,8 @@ export class GetUserService { product: tenant.product, theme: themeMode, mode: themeMode, - app_ui_config: tenant.product === 'khanegi' ? ui : undefined, + app_ui_config: + tenant.product === 'khanegi' ? this.stripKhanegiSaMeta(ui) : undefined, /** Laravel-compatible overlay; Hamkar merges only when present */ ui_settings: uiSettings.length ? uiSettings : undefined, }; @@ -86,6 +87,12 @@ export class GetUserService { }; } + private stripKhanegiSaMeta(ui: Record | null) { + if (!ui || typeof ui !== 'object') return ui; + const { defaultSnapshot: _snap, _backfill, ...rest } = ui; + return rest; + } + private buildHamkarUiSettings(ui: Record): UiSetting[] { const settings = ui?.settings; if (!settings || typeof settings !== 'object') return [];