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