Creating a control panel infrastructure
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,325 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Prisma, PrismaClient, ProductType } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
type SourceUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
name_fa: string;
|
||||
number?: string | null;
|
||||
avatar?: string | null;
|
||||
domains?: string | string[] | null;
|
||||
node_server?: string | null;
|
||||
node_ws?: string | null;
|
||||
color?: string | null;
|
||||
secondary_color?: string | null;
|
||||
background_color?: string | null;
|
||||
domain?: string | null;
|
||||
version?: string | null;
|
||||
app_ui_config?: unknown;
|
||||
deleted_at?: string | null;
|
||||
mode?: string | null;
|
||||
};
|
||||
|
||||
/** Manual product corrections from SuperAdmin review */
|
||||
const FORCE_KHANEGI: Array<{ domain?: string; name?: string; externalId?: number }> = [
|
||||
{ domain: 'abshodehtabatabaei.ir' },
|
||||
{ domain: 'boursemes.com' },
|
||||
{ domain: 'abaeigold.ir' },
|
||||
{ domain: 'niazitala.ir' },
|
||||
{ domain: 'garoosigholaktala.ir' },
|
||||
{ domain: 'kh.kashkolgold.ir' },
|
||||
{ domain: 'goldsun.zaarbox.ir' },
|
||||
{ domain: 'goldaliii.com' },
|
||||
];
|
||||
|
||||
/** Canonical public domains after migrations */
|
||||
const DOMAIN_OVERRIDES: Record<string, { domain: string; appUrl: string; nodeServer: string }> = {
|
||||
'24gold.app': {
|
||||
domain: '24gold.ir',
|
||||
appUrl: 'https://24gold.ir',
|
||||
nodeServer: 'https://24gold.ir/server',
|
||||
},
|
||||
'liman.gold': {
|
||||
domain: 'limangold.ir',
|
||||
appUrl: 'https://limangold.ir',
|
||||
nodeServer: 'https://limangold.ir/server',
|
||||
},
|
||||
'hamkar2.vahid.gold': {
|
||||
domain: 'vahid.gold',
|
||||
appUrl: 'https://vahid.gold',
|
||||
nodeServer: 'https://vahid.gold/server',
|
||||
},
|
||||
};
|
||||
|
||||
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 isIp(host: string) {
|
||||
return /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
|
||||
}
|
||||
|
||||
function hostFromUrl(url?: string | null) {
|
||||
if (!url) return '';
|
||||
return url
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.split('/')[0]
|
||||
.split(':')[0];
|
||||
}
|
||||
|
||||
function classifyProduct(row: SourceUser, domains: string[]): {
|
||||
product: ProductType;
|
||||
confident: boolean;
|
||||
reason: string;
|
||||
} {
|
||||
const forced = FORCE_KHANEGI.find((item) => {
|
||||
if (item.externalId && item.externalId === row.id) return true;
|
||||
if (item.domain && domains.includes(item.domain)) return true;
|
||||
return false;
|
||||
});
|
||||
if (forced) {
|
||||
return {
|
||||
product: 'khanegi',
|
||||
confident: true,
|
||||
reason: `manual override (${forced.domain || forced.externalId})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (domains.some((d) => d.startsWith('app.'))) {
|
||||
return { product: 'khanegi', confident: true, reason: 'domain starts with app.' };
|
||||
}
|
||||
if (domains.some((d) => d.startsWith('khanegi.'))) {
|
||||
return { product: 'khanegi', confident: true, reason: 'domain starts with khanegi.' };
|
||||
}
|
||||
if (domains.some((d) => d.startsWith('hamkar.'))) {
|
||||
return { product: 'hamkar', confident: true, reason: 'domain starts with hamkar.' };
|
||||
}
|
||||
|
||||
const nodeHost = hostFromUrl(row.node_server);
|
||||
if (nodeHost.startsWith('app.')) {
|
||||
return {
|
||||
product: 'khanegi',
|
||||
confident: true,
|
||||
reason: 'node_server host starts with app.',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback guess — flagged for manual review
|
||||
if (row.app_ui_config) {
|
||||
return {
|
||||
product: 'khanegi',
|
||||
confident: false,
|
||||
reason: 'has app_ui_config but no app./hamkar. domain',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
product: 'hamkar',
|
||||
confident: false,
|
||||
reason: 'no app./hamkar. domain — defaulted to hamkar',
|
||||
};
|
||||
}
|
||||
|
||||
function pickDomain(product: ProductType, domains: string[], row: SourceUser) {
|
||||
for (const domain of domains) {
|
||||
if (DOMAIN_OVERRIDES[domain]) return DOMAIN_OVERRIDES[domain].domain;
|
||||
}
|
||||
|
||||
const preferredPrefix = product === 'khanegi' ? 'app.' : 'hamkar.';
|
||||
const preferred = domains.find((d) => d.startsWith(preferredPrefix));
|
||||
if (preferred) return preferred;
|
||||
|
||||
const nonIp = domains.find((d) => !isIp(d));
|
||||
if (nonIp) return nonIp;
|
||||
|
||||
const fromNode = hostFromUrl(row.node_server);
|
||||
if (fromNode && !isIp(fromNode)) return fromNode;
|
||||
|
||||
if (row.domain) return String(row.domain).toLowerCase();
|
||||
return domains[0] || `${slugify(row.name)}.local`;
|
||||
}
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9._-]/g, '')
|
||||
.replace(/\.+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'tenant';
|
||||
}
|
||||
|
||||
function avatarUrl(raw?: string | null) {
|
||||
if (!raw) return null;
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
return null;
|
||||
}
|
||||
|
||||
function asUiConfig(value: unknown): Prisma.InputJsonValue | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
return JSON.parse(value) as Prisma.InputJsonValue;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
if (typeof value === 'object') return value as Prisma.InputJsonValue;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function uniqueSlug(base: string, product: ProductType, externalId: number) {
|
||||
const candidates = [
|
||||
base,
|
||||
`${base}-${product}`,
|
||||
`${base}-${product}-${externalId}`,
|
||||
`${base}-${externalId}`,
|
||||
];
|
||||
for (const slug of candidates) {
|
||||
const existing = await prisma.tenant.findUnique({ where: { slug } });
|
||||
if (!existing) return slug;
|
||||
// allow overwrite of previously imported same external id via notes marker
|
||||
if (existing.notes?.includes(`externalId:${externalId}`)) return slug;
|
||||
}
|
||||
return `${base}-${product}-${externalId}-${Date.now()}`;
|
||||
}
|
||||
|
||||
async function uniqueDomain(preferred: string, externalId: number) {
|
||||
const candidates = [
|
||||
preferred,
|
||||
`${productSafeHost(preferred)}`,
|
||||
`id${externalId}.${preferred}`,
|
||||
`ext-${externalId}.${preferred}`,
|
||||
];
|
||||
for (const domain of candidates) {
|
||||
const existing = await prisma.tenant.findUnique({ where: { domain } });
|
||||
if (!existing) return domain;
|
||||
if (existing.notes?.includes(`externalId:${externalId}`)) return domain;
|
||||
}
|
||||
return `ext-${externalId}-${Date.now()}.local`;
|
||||
}
|
||||
|
||||
function productSafeHost(host: string) {
|
||||
return host;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const file = path.join(__dirname, 'data', 'users.json');
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`File not found: ${file}`);
|
||||
}
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(file, 'utf8')) as SourceUser[];
|
||||
const summary = {
|
||||
total: rows.length,
|
||||
imported: 0,
|
||||
skippedDeleted: 0,
|
||||
hamkar: 0,
|
||||
khanegi: 0,
|
||||
uncertain: 0,
|
||||
withUiConfig: 0,
|
||||
};
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.deleted_at) {
|
||||
summary.skippedDeleted += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const domains = parseDomains(row.domains);
|
||||
const { product, confident, reason } = classifyProduct(row, domains);
|
||||
const baseSlug = slugify(row.name);
|
||||
const slug = await uniqueSlug(baseSlug, product, row.id);
|
||||
const preferredDomain = pickDomain(product, domains, row);
|
||||
const domain = await uniqueDomain(preferredDomain, row.id);
|
||||
const ui = asUiConfig(row.app_ui_config);
|
||||
const domainOverride =
|
||||
DOMAIN_OVERRIDES[domain] ||
|
||||
domains.map((d) => DOMAIN_OVERRIDES[d]).find(Boolean);
|
||||
|
||||
const notes = [
|
||||
`externalId:${row.id}`,
|
||||
`import:users.json`,
|
||||
`classify:${reason}`,
|
||||
confident ? 'classifyConfidence:high' : 'classifyConfidence:low',
|
||||
row.number ? `phone:${row.number}` : null,
|
||||
row.version ? `version:${row.version}` : null,
|
||||
row.mode ? `mode:${row.mode}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
|
||||
const data = {
|
||||
product,
|
||||
slug,
|
||||
nameFa: (row.name_fa || row.name).replace(/\s+/g, ' ').trim(),
|
||||
nameEn: row.name,
|
||||
domain: domainOverride?.domain || domain,
|
||||
domains: domains as unknown as Prisma.InputJsonValue,
|
||||
avatarUrl: avatarUrl(row.avatar),
|
||||
primaryColor: row.color || (product === 'hamkar' ? '#20c997' : '#ffb84d'),
|
||||
secondaryColor: row.secondary_color || null,
|
||||
backgroundColor: row.background_color || '#ffffff',
|
||||
nodeServer:
|
||||
domainOverride?.nodeServer || row.node_server || `https://${domain}/server`,
|
||||
nodeWs: row.node_ws || null,
|
||||
appUrl:
|
||||
domainOverride?.appUrl || domains.find((d) => !isIp(d)) || domain,
|
||||
adminUrl: null as string | null,
|
||||
appUiConfig: ui,
|
||||
isActive: true,
|
||||
notes,
|
||||
};
|
||||
|
||||
const existingByNotes = await prisma.tenant.findFirst({
|
||||
where: { notes: { contains: `externalId:${row.id}` } },
|
||||
});
|
||||
|
||||
if (existingByNotes) {
|
||||
await prisma.tenant.update({
|
||||
where: { id: existingByNotes.id },
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
// Prefer update by slug if this is a known seed like bakhtargold
|
||||
const bySlug = await prisma.tenant.findUnique({ where: { slug } });
|
||||
if (bySlug) {
|
||||
await prisma.tenant.update({ where: { id: bySlug.id }, data });
|
||||
} else {
|
||||
await prisma.tenant.create({ data });
|
||||
}
|
||||
}
|
||||
|
||||
summary.imported += 1;
|
||||
summary[product] += 1;
|
||||
if (!confident) summary.uncertain += 1;
|
||||
if (ui) summary.withUiConfig += 1;
|
||||
}
|
||||
|
||||
console.log('Import finished:', summary);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tenants" ADD COLUMN "sms_settings_cache" JSONB;
|
||||
ALTER TABLE "tenants" ADD COLUMN "sms_synced_at" DATETIME;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "sms_accounts" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"tenant_id" TEXT NOT NULL,
|
||||
"balance_rials" INTEGER NOT NULL DEFAULT 0,
|
||||
"price_per_part_rials" INTEGER NOT NULL DEFAULT 2500,
|
||||
"is_blocked" BOOLEAN NOT NULL DEFAULT false,
|
||||
"alert_critical_rials" INTEGER NOT NULL DEFAULT 10000,
|
||||
"alert_warning_rials" INTEGER NOT NULL DEFAULT 100000,
|
||||
"gateway_provider" TEXT,
|
||||
"melipayamak_user" TEXT,
|
||||
"melipayamak_password" TEXT,
|
||||
"melipayamak_from" TEXT,
|
||||
"panel_username" TEXT,
|
||||
"panel_password" TEXT,
|
||||
"panel_from" TEXT,
|
||||
"extra_settings" JSONB,
|
||||
"today_messages" INTEGER NOT NULL DEFAULT 0,
|
||||
"today_parts" INTEGER NOT NULL DEFAULT 0,
|
||||
"today_cost_rials" INTEGER NOT NULL DEFAULT 0,
|
||||
"month_messages" INTEGER NOT NULL DEFAULT 0,
|
||||
"month_parts" INTEGER NOT NULL DEFAULT 0,
|
||||
"month_cost_rials" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" DATETIME NOT NULL,
|
||||
CONSTRAINT "sms_accounts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "sms_ledgers" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"sms_account_id" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"amount_rials" INTEGER NOT NULL,
|
||||
"parts" INTEGER,
|
||||
"note" TEXT,
|
||||
"created_by_id" TEXT,
|
||||
"created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "sms_ledgers_sms_account_id_fkey" FOREIGN KEY ("sms_account_id") REFERENCES "sms_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "sms_accounts_tenant_id_key" ON "sms_accounts"("tenant_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sms_ledgers_sms_account_id_idx" ON "sms_ledgers"("sms_account_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "sms_ledgers_created_at_idx" ON "sms_ledgers"("created_at");
|
||||
@@ -18,6 +18,12 @@ enum UserRole {
|
||||
viewer
|
||||
}
|
||||
|
||||
enum SmsLedgerType {
|
||||
charge
|
||||
debit
|
||||
adjust
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
@@ -49,6 +55,9 @@ model Tenant {
|
||||
appUrl String? @map("app_url")
|
||||
adminUrl String? @map("admin_url")
|
||||
remoteApiToken String? @map("remote_api_token")
|
||||
/// Legacy cache from older sync attempts — SuperAdmin SMS account is now source of truth
|
||||
smsSettingsCache Json? @map("sms_settings_cache")
|
||||
smsSyncedAt DateTime? @map("sms_synced_at")
|
||||
/// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now.
|
||||
appUiConfig Json? @map("app_ui_config")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
@@ -56,12 +65,65 @@ model Tenant {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
auditLogs AuditLog[]
|
||||
smsAccount SmsAccount?
|
||||
|
||||
@@index([product])
|
||||
@@index([isActive])
|
||||
@@map("tenants")
|
||||
}
|
||||
|
||||
/// SuperAdmin-owned SMS wallet + gateway config per sold version.
|
||||
/// Does NOT replace Hamkar/Khanegi internal SMS panels yet.
|
||||
model SmsAccount {
|
||||
id String @id @default(cuid())
|
||||
tenantId String @unique @map("tenant_id")
|
||||
balanceRials Int @default(0) @map("balance_rials")
|
||||
pricePerPartRials Int @default(2500) @map("price_per_part_rials")
|
||||
isBlocked Boolean @default(false) @map("is_blocked")
|
||||
/// below this => red/critical
|
||||
alertCriticalRials Int @default(10000) @map("alert_critical_rials")
|
||||
/// at/below this (and above critical) => yellow; above => green
|
||||
alertWarningRials Int @default(100000) @map("alert_warning_rials")
|
||||
gatewayProvider String? @map("gateway_provider")
|
||||
melipayamakUser String? @map("melipayamak_user")
|
||||
melipayamakPassword String? @map("melipayamak_password")
|
||||
melipayamakFrom String? @map("melipayamak_from")
|
||||
panelUsername String? @map("panel_username")
|
||||
panelPassword String? @map("panel_password")
|
||||
panelFrom String? @map("panel_from")
|
||||
extraSettings Json? @map("extra_settings")
|
||||
todayMessages Int @default(0) @map("today_messages")
|
||||
todayParts Int @default(0) @map("today_parts")
|
||||
todayCostRials Int @default(0) @map("today_cost_rials")
|
||||
monthMessages Int @default(0) @map("month_messages")
|
||||
monthParts Int @default(0) @map("month_parts")
|
||||
monthCostRials Int @default(0) @map("month_cost_rials")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||
ledgers SmsLedger[]
|
||||
|
||||
@@map("sms_accounts")
|
||||
}
|
||||
|
||||
model SmsLedger {
|
||||
id String @id @default(cuid())
|
||||
smsAccountId String @map("sms_account_id")
|
||||
type SmsLedgerType
|
||||
amountRials Int @map("amount_rials")
|
||||
parts Int?
|
||||
note String?
|
||||
createdById String? @map("created_by_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([smsAccountId])
|
||||
@@index([createdAt])
|
||||
@@map("sms_ledgers")
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
action String
|
||||
|
||||
+55
-17
@@ -22,30 +22,68 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const sample = await prisma.tenant.upsert({
|
||||
where: { slug: 'demo-hamkar' },
|
||||
update: {},
|
||||
// Extracted from Hamkar SettingsSeeder (bakhtargold) — real project defaults found in code
|
||||
const bakhtarSmsValues = {
|
||||
name_fa: 'باختر گلد',
|
||||
name_en: 'bakhtargold',
|
||||
app_url: 'bakhtargold.com',
|
||||
admin_url: 'https://bakhtargold.com/admin',
|
||||
sms_long: 'true',
|
||||
sms_panel: '',
|
||||
melipayamak_user: '',
|
||||
melipayamak_password: '',
|
||||
melipayamak_from: '',
|
||||
sms_panel_username: '',
|
||||
sms_panel_password: '',
|
||||
sms_panel_from: '',
|
||||
send_sms_on_customer_order: '',
|
||||
sms_charge_card: '',
|
||||
sms_charge_iban: '',
|
||||
sms_charge_account_holder: '',
|
||||
sms_charge_instruction: '',
|
||||
};
|
||||
|
||||
const bakhtar = await prisma.tenant.upsert({
|
||||
where: { slug: 'bakhtargold' },
|
||||
update: {
|
||||
nameFa: bakhtarSmsValues.name_fa,
|
||||
nameEn: bakhtarSmsValues.name_en,
|
||||
domain: 'bakhtargold.com',
|
||||
appUrl: bakhtarSmsValues.app_url,
|
||||
adminUrl: bakhtarSmsValues.admin_url,
|
||||
smsSettingsCache: {
|
||||
values: bakhtarSmsValues,
|
||||
wallet: null,
|
||||
source: 'hamkar/database/seeders/SettingsSeeder.php',
|
||||
syncedAt: new Date().toISOString(),
|
||||
note: 'استخراج از سیدر کد؛ برای داده زنده DB باید همگامسازی MySQL انجام شود',
|
||||
},
|
||||
smsSyncedAt: new Date(),
|
||||
},
|
||||
create: {
|
||||
product: 'hamkar',
|
||||
slug: 'demo-hamkar',
|
||||
nameFa: 'نسخه دمو همکار',
|
||||
nameEn: 'Demo Hamkar',
|
||||
domain: 'demo-hamkar.local',
|
||||
avatarUrl: null,
|
||||
primaryColor: '#1d4ed8',
|
||||
secondaryColor: '#0f172a',
|
||||
backgroundColor: '#ffffff',
|
||||
nodeServer: 'https://demo-hamkar.local/server',
|
||||
nodeWs: 'wss://demo-hamkar.local/prc/ws',
|
||||
appUrl: 'https://demo-hamkar.local',
|
||||
adminUrl: 'https://demo-hamkar.local/admin',
|
||||
slug: 'bakhtargold',
|
||||
nameFa: bakhtarSmsValues.name_fa,
|
||||
nameEn: bakhtarSmsValues.name_en,
|
||||
domain: 'bakhtargold.com',
|
||||
primaryColor: '#20c997',
|
||||
nodeServer: 'https://bakhtargold.com/server',
|
||||
appUrl: bakhtarSmsValues.app_url,
|
||||
adminUrl: bakhtarSmsValues.admin_url,
|
||||
isActive: true,
|
||||
notes: 'نمونه اولیه برای تست اسکفولد',
|
||||
notes: 'نسخه استخراجشده از کد/سیدر همکار',
|
||||
smsSettingsCache: {
|
||||
values: bakhtarSmsValues,
|
||||
wallet: null,
|
||||
source: 'hamkar/database/seeders/SettingsSeeder.php',
|
||||
syncedAt: new Date().toISOString(),
|
||||
},
|
||||
smsSyncedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Seeded admin:', { id: user.id, email: user.email });
|
||||
console.log('Seeded tenant:', { id: sample.id, slug: sample.slug });
|
||||
console.log('Seeded tenant:', { id: bakhtar.id, slug: bakhtar.slug });
|
||||
console.log('Login with:', email, '/', password);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user