Files
arsam bea68ad59b Import source: app/backend/prisma/seed
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:19:01 +03:30

469 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
});