// Seed Postgres with the legacy frontend's hardcoded content. // Source of truth is the existing browser module `teknav-data.js`; we import it // directly so this stays in lockstep until the admin UI replaces it as the // content origin (Phase 5). // // Idempotent: uses `upsert` keyed on natural identifiers (slug / email / name). import { PrismaClient } from '@prisma/client'; import * as argon2 from '@node-rs/argon2'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; // @ts-expect-error - plain JS module, no types import { TeknavData } from '../../teknav-data.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const credentials = JSON.parse( await readFile(resolve(__dirname, 'credentials.json'), 'utf-8') ) as { staff: Array<{ email: string; password: string; role: string; name: string; username?: string; resetPasswordOnSeed?: boolean }> }; const prisma = new PrismaClient(); const authorIdMap = new Map(); const categoryIdMap = new Map(); const tagIdMap = new Map(); const userIdByEmail = new Map(); // Legacy frontend stores roles as plain strings; the schema uses an enum. const ROLE_MAP: Record = { admin: 'admin', editor: 'editor', writer: 'writer', reviewer: 'reviewer', }; const STATUS_MAP: Record = { active: 'active', inactive: 'suspended', }; async function hash(password: string) { return argon2.hash(password, { algorithm: argon2.Algorithm.Argon2id, memoryCost: 19 * 1024, timeCost: 2, parallelism: 1, }); } function usernameFromEmail(email: string) { return email.split('@')[0] .toLowerCase() .replace(/[^a-z0-9_.]/g, '_') .replace(/^[^a-z0-9_]+/, '') .replace(/[^a-z0-9_]+$/, '') .slice(0, 30); } function usernameFromText(text: string) { return text .toLowerCase() .replace(/^@/, '') .replace(/@.*$/, '') .replace(/[^a-z0-9_.]/g, '_') .replace(/^[^a-z0-9_]+/, '') .replace(/[^a-z0-9_]+$/, '') .slice(0, 30); } async function uniqueUsername(base: string, excludeUserId?: string) { const cleanBase = base || 'user'; for (let i = 0; i < 50; i += 1) { const suffix = i === 0 ? '' : `_${i}`; const candidate = `${cleanBase.slice(0, 30 - suffix.length)}${suffix}`; const taken = await prisma.user.findUnique({ where: { username: candidate } }); if (!taken || taken.id === excludeUserId) return candidate; } return `${cleanBase.slice(0, 23)}_${Date.now().toString(36)}`.slice(0, 30); } async function seedCategories() { for (const c of TeknavData.categories) { const row = await prisma.category.upsert({ where: { slug: c.slug }, update: { name: c.name, description: c.description, color: c.color, diagram: c.diagram ?? null, }, create: { slug: c.slug, name: c.name, description: c.description, color: c.color, diagram: c.diagram ?? null, }, }); categoryIdMap.set(c.id, row.id); } console.log(` ✓ categories: ${TeknavData.categories.length}`); } async function seedTags() { for (const t of TeknavData.tags) { const row = await prisma.tag.upsert({ where: { name: t.name }, update: { count: t.count ?? 0 }, create: { name: t.name, count: t.count ?? 0 }, }); tagIdMap.set(String(t.id), row.id); } console.log(` ✓ tags: ${TeknavData.tags.length}`); } async function seedAuthors() { const authorSlugAliases: Array<{ from: string; to: string }> = [ { from: 'arsam-sabbagh', to: 'sabbagh' }, { from: 'ahmadreza-mahjoub', to: 'mahjoob' }, ]; for (const alias of authorSlugAliases) { const oldAuthor = await prisma.author.findUnique({ where: { slug: alias.from }, select: { id: true } }); const newAuthor = await prisma.author.findUnique({ where: { slug: alias.to }, select: { id: true } }); if (oldAuthor && !newAuthor) { await prisma.author.update({ where: { id: oldAuthor.id }, data: { slug: alias.to } }); } } for (const a of TeknavData.authors) { const row = await prisma.author.upsert({ where: { slug: a.slug }, update: { name: a.name, specialty: a.specialty, bio: a.bio ?? '', initials: a.initials ?? '', color: a.color ?? '#0F6B73', social: a.social ?? {}, articleCount: a.articleCount ?? 0, }, create: { slug: a.slug, name: a.name, specialty: a.specialty, bio: a.bio ?? '', initials: a.initials ?? '', color: a.color ?? '#0F6B73', social: a.social ?? {}, articleCount: a.articleCount ?? 0, }, }); authorIdMap.set(a.id, row.id); } // Extra authors (none currently) const extraAuthors: Array<{ slug: string; name: string; specialty: string; bio: string; initials: string; color: string; verifiedExpert: boolean; social: object; articleCount: number; userUsername: string | null; }> = []; for (const ea of extraAuthors) { const linkedUser = ea.userUsername ? await prisma.user.findUnique({ where: { username: ea.userUsername }, select: { id: true } }) : null; await prisma.author.upsert({ where: { slug: ea.slug }, update: { name: ea.name, specialty: ea.specialty, bio: ea.bio, initials: ea.initials, color: ea.color, verifiedExpert: ea.verifiedExpert, social: ea.social, articleCount: ea.articleCount, ...(linkedUser ? { userId: linkedUser.id } : {}), }, create: { slug: ea.slug, name: ea.name, specialty: ea.specialty, bio: ea.bio, initials: ea.initials, color: ea.color, verifiedExpert: ea.verifiedExpert, social: ea.social, articleCount: ea.articleCount, userId: linkedUser?.id ?? null, }, }); } console.log(` ✓ authors: ${TeknavData.authors.length + extraAuthors.length}`); } async function linkAuthorsToUsers() { const authors = await prisma.author.findMany({ select: { id: true, name: true, userId: true } }); for (const author of authors) { const linkedUser = await prisma.user.findFirst({ where: { name: author.name }, select: { id: true }, }); if (linkedUser && author.userId !== linkedUser.id) { await prisma.author.update({ where: { id: author.id }, data: { userId: linkedUser.id }, }); } } } async function seedStaffAccounts() { // Initial staff accounts come from credentials.json. Plaintext passwords are // hashed (Argon2id) before insert. Rotate via the admin panel before go-live. for (const a of credentials.staff) { const passwordHash = await hash(a.password); const existing = await prisma.user.findUnique({ where: { email: a.email }, select: { id: true } }); const username = await uniqueUsername(a.username ?? usernameFromEmail(a.email), existing?.id); const row = await prisma.user.upsert({ where: { email: a.email }, update: { name: a.name, role: ROLE_MAP[a.role] ?? 'reader', username, ...(a.resetPasswordOnSeed ? { passwordHash } : {}), // Passwords are not overwritten on re-seed unless resetPasswordOnSeed // is explicit. This lets new staff accounts replace old placeholder // seed passwords while preserving manually rotated production accounts. }, create: { email: a.email, passwordHash, name: a.name, role: ROLE_MAP[a.role] ?? 'reader', username, status: 'active', }, }); userIdByEmail.set(a.email, row.id); } console.log(` ✓ staff accounts: ${credentials.staff.length}`); } async function seedDirectoryUsers() { // The legacy frontend's `users` list is a separate directory used by admin UI; // seed each as an active reader/writer with a placeholder password the user // must reset before logging in. Skip if a same-email account already exists. for (const u of TeknavData.users) { const existing = await prisma.user.findUnique({ where: { email: u.email } }); if (existing) { if (!existing.username) { await prisma.user.update({ where: { id: existing.id }, data: { username: await uniqueUsername(u.username ?? usernameFromEmail(u.email), existing.id) } }); } userIdByEmail.set(u.email, existing.id); continue; } const passwordHash = await hash(`reset-me-${u.email}`); const username = await uniqueUsername(u.username ?? usernameFromEmail(u.email)); const row = await prisma.user.create({ data: { email: u.email, passwordHash, name: u.name, username, role: ROLE_MAP[u.role] ?? 'reader', status: STATUS_MAP[u.status] ?? 'active', }, }); userIdByEmail.set(u.email, row.id); } console.log(` ✓ directory users: ${TeknavData.users.length}`); } async function backfillUsernames() { const users = await prisma.user.findMany({ where: { username: null }, select: { id: true, email: true, name: true }, }); for (const user of users) { const base = user.email.includes('@teknav.local') ? usernameFromText(user.name || user.id) : usernameFromEmail(user.email); await prisma.user.update({ where: { id: user.id }, data: { username: await uniqueUsername(base, user.id) }, }); } console.log(` ✓ username backfill: ${users.length}`); } // Convert Persian date `۱۴۰۳/۰۸/۲۸` → null (we already have dateEn = ISO). function persianDigitsToInt(s: string): string { return s.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))); } async function seedArticles() { let created = 0; for (const art of TeknavData.articles) { const categoryId = categoryIdMap.get(art.category); const authorId = authorIdMap.get(art.authorId); if (!categoryId || !authorId) { console.warn(` · skipping ${art.slug}: missing category or author`); continue; } const publishedAt = art.dateEn ? new Date(art.dateEn) : art.date ? new Date(persianDigitsToInt(art.date).replace(/\//g, '-')) : null; const status = ['rust-systems', 'venture-capital-ai'].includes(art.slug) ? 'پیش‌نویس' : 'منتشرشده'; const data = { slug: art.slug, title: art.title, subtitle: art.subtitle ?? null, summary: art.summary ?? '', content: art.content ?? '', type: art.type ?? 'تحلیل عمیق', status, readTime: art.readTime ?? 5, views: art.views ?? 0, reactions: art.reactions ?? 0, featured: !!art.featured, diagram: art.diagram ?? null, metaDescription: art.metaDescription ?? null, keywords: Array.isArray(art.keywords) ? art.keywords : [], ogTitle: art.ogTitle ?? null, ogDescription: art.ogDescription ?? null, ogImage: art.ogImage ?? null, canonicalPath: art.canonicalPath ?? `/article/${art.slug}`, dateModified: art.dateModified ? new Date(art.dateModified) : publishedAt, factCheckedAt: art.factCheckedAt ? new Date(art.factCheckedAt) : publishedAt, sourceNotes: art.sourceNotes ?? null, contentFreshnessStatus: art.contentFreshnessStatus ?? 'current', categoryId, authorId, publishedAt, }; const row = await prisma.article.upsert({ where: { slug: art.slug }, update: data, create: data, }); // Wire tags by name (idempotent: delete + re-link is safest on re-seed). if (Array.isArray(art.tags) && art.tags.length) { await prisma.articleTag.deleteMany({ where: { articleId: row.id } }); for (const tagName of art.tags) { const tag = await prisma.tag.upsert({ where: { name: tagName }, update: {}, create: { name: tagName }, }); await prisma.articleTag.create({ data: { articleId: row.id, tagId: tag.id }, }); } } created++; } console.log(` ✓ articles: ${created}`); } async function seedActivity() { // Idempotent: wipe seed-origin entries (those without a userId) before reinserting. // Real user-driven activity (with userId) is preserved across re-seeds. await prisma.activityLog.deleteMany({ where: { userId: null } }); const entries = TeknavData.activityLog ?? []; if (entries.length) { await prisma.activityLog.createMany({ data: entries.map((a: any) => ({ action: a.action ?? '', target: a.target ?? '', type: a.type ?? 'system', })), }); } console.log(` ✓ activity entries: ${entries.length}`); } async function seedSeries() { const seriesList = [ { slug: 'openclaw-series', title: 'سری OpenClaw: از معماری تا امنیت', description: 'بررسی جامع OpenClaw — از معماری سلف‌هاستد تا امنیت، ژئوپلیتیک، و اقتصاد هاستینگ عامل', articleSlugs: [ 'openclaw-self-hosted-architecture', 'openclaw-docker-vps-guide', 'openclaw-china-geopolitical-split', 'openclaw-prompt-injection-defense', 'microsoft-execution-containers-mxc', 'clawhosters-agent-hosting-economy', ], }, { slug: 'ai-science-series', title: 'سری کشف علمی با هوش مصنوعی', description: 'از ابطال حدس اردوش تا بازنگری در روش‌شناسی علمی — سه مقاله درباره نقش هوش مصنوعی در پیشرفت ریاضیات', articleSlugs: [ 'erdos-unit-distance-conjecture-disproved', 'ai-scientific-discovery-sociology', 'human-ai-symbiosis-mathematical-proof', ], }, ]; for (const s of seriesList) { const series = await prisma.articleSeries.upsert({ where: { slug: s.slug }, update: { title: s.title, description: s.description ?? '' }, create: { slug: s.slug, title: s.title, description: s.description ?? '' }, }); for (let i = 0; i < s.articleSlugs.length; i++) { const article = await prisma.article.findFirst({ where: { slug: s.articleSlugs[i] }, select: { id: true } }); if (!article) { console.warn(` · series: article not found: ${s.articleSlugs[i]}`); continue; } await prisma.seriesArticle.upsert({ where: { seriesId_articleId: { seriesId: series.id, articleId: article.id } }, update: { position: i + 1 }, create: { seriesId: series.id, articleId: article.id, position: i + 1 }, }); } } console.log(` ✓ series: ${seriesList.length}`); } async function refreshAggregates() { // Keep articleCount on Author/Category in sync with reality (the legacy seed // had hand-rolled values; from now on the API maintains these on writes). const authors = await prisma.author.findMany({ include: { _count: { select: { articles: true } } } }); for (const a of authors) { await prisma.author.update({ where: { id: a.id }, data: { articleCount: a._count.articles } }); } const cats = await prisma.category.findMany({ include: { _count: { select: { articles: true } } } }); for (const c of cats) { await prisma.category.update({ where: { id: c.id }, data: { articleCount: c._count.articles } }); } console.log(` ✓ aggregates refreshed`); } async function main() { console.log('seeding teknav…'); await seedCategories(); await seedTags(); await seedAuthors(); await seedStaffAccounts(); await seedDirectoryUsers(); await backfillUsernames(); await linkAuthorsToUsers(); await seedArticles(); await seedSeries(); await seedActivity(); await refreshAggregates(); console.log('done.'); } main() .catch((e) => { console.error(e); process.exit(1); }) .finally(async () => { await prisma.$disconnect(); });