diff --git a/app/backend/prisma/seed.ts b/app/backend/prisma/seed.ts new file mode 100644 index 0000000..669a9eb --- /dev/null +++ b/app/backend/prisma/seed.ts @@ -0,0 +1,468 @@ +// 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(); + });