Import source: app/backend/prisma/seed

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:19:01 +03:30
parent 393c6b034a
commit bea68ad59b
+468
View File
@@ -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<string, string>();
const categoryIdMap = new Map<string, string>();
const tagIdMap = new Map<string, string>();
const userIdByEmail = new Map<string, string>();
// Legacy frontend stores roles as plain strings; the schema uses an enum.
const ROLE_MAP: Record<string, 'admin' | 'editor' | 'writer' | 'reviewer' | 'reader'> = {
admin: 'admin',
editor: 'editor',
writer: 'writer',
reviewer: 'reviewer',
};
const STATUS_MAP: Record<string, 'active' | 'suspended' | 'pending'> = {
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();
});