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:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user