import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { Prisma } from '@prisma/client'; import { z } from 'zod'; import { createReadStream } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; import { basename, extname, join } from 'node:path'; import { storage } from '../lib/storage.js'; import { randomUUID, randomBytes } from 'node:crypto'; import * as argon2 from '@node-rs/argon2'; import { prisma } from '../db.js'; import { config } from '../config.js'; import { bust } from '../lib/cache.js'; import { redis } from '../redis.js'; import { requireAuth, invalidateAllUserSessions, type SessionData } from '../plugins/session.js'; import { logSecurityEvent } from '../lib/security-audit.js'; import { createNotification, notifyFollowersOnPublish, notifyEditorsOnSubmit, publishRealtime } from '../lib/realtime.js'; import { sendEmail } from '../lib/email.js'; const PUBLISHED = 'منتشرشده'; const DRAFT = 'پیش‌نویس'; const PENDING = 'در انتظار بررسی'; const NEEDS_REVISION = 'نیازمند اصلاح'; const SCHEDULED = 'زمان‌بندی‌شده'; const ALL_CONTENT_ROLES: SessionData['role'][] = ['admin', 'editor', 'writer', 'reviewer']; const EDITOR_ROLES: SessionData['role'][] = ['admin', 'editor']; const ADMIN_ONLY: SessionData['role'][] = ['admin']; const USERNAME_RE = /^[a-z0-9_][a-z0-9_.]{1,28}[a-z0-9_]$/; const articleInclude = { category: true, author: { include: { user: { select: { username: true } } } }, tags: { include: { tag: true } }, corrections: { include: { editor: { select: { id: true, name: true, username: true } } }, orderBy: { createdAt: 'desc' as const } }, review: { select: { note: true, status: true } }, } satisfies Prisma.ArticleInclude; type ArticleWithRelations = Prisma.ArticleGetPayload<{ include: typeof articleInclude }>; const listQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(50), status: z.string().trim().optional(), q: z.string().trim().max(120).optional(), }); const articleBodySchema = z.object({ title: z.string().trim().min(1).max(240), subtitle: z.string().trim().max(400).optional().nullable(), summary: z.string().trim().min(1).max(2000), content: z.string().min(1), type: z.string().trim().min(1).max(80), status: z.string().trim().min(1).max(80).default(DRAFT), readTime: z.coerce.number().int().positive().max(180).default(5), featured: z.coerce.boolean().default(false), sponsored: z.coerce.boolean().default(false), premiumOnly: z.coerce.boolean().default(false), diagram: z.string().trim().max(80).optional().nullable(), slug: z.string().trim().max(240).optional(), scheduledAt: z.coerce.date().optional().nullable(), category: z.string().trim().min(1), authorId: z.string().trim().optional(), tags: z.array(z.string().trim().min(1).max(80)).default([]), metaDescription: z.string().trim().max(160).optional().nullable(), keywords: z.array(z.string().trim().min(1).max(60)).max(20).optional(), ogTitle: z.string().trim().max(200).optional().nullable(), ogDescription: z.string().trim().max(300).optional().nullable(), ogImage: z.string().trim().max(500).optional().nullable(), canonicalPath: z.string().trim().max(300).optional().nullable(), dateModified: z.coerce.date().optional().nullable(), factCheckedAt: z.coerce.date().optional().nullable(), reviewedById: z.string().trim().optional().nullable(), sourceNotes: z.string().trim().max(5000).optional().nullable(), contentFreshnessStatus: z.enum(['current', 'needs_update', 'scheduled_refresh', 'archived']).optional(), correctionNotice: z.string().trim().max(1000).optional().nullable(), }); const correctionBodySchema = z.object({ note: z.string().trim().min(3).max(1000), }); const campaignBodySchema = z.object({ subject: z.string().trim().min(3).max(200), slug: z.string().trim().max(220).optional(), bodyHtml: z.string().trim().min(10).max(50_000), }); const seriesBodySchema = z.object({ title: z.string().trim().min(2).max(200), slug: z.string().trim().max(220).optional(), description: z.string().trim().max(500).optional().nullable(), coverImage: z.string().trim().max(500).optional().nullable(), articleIds: z.array(z.string().trim().min(1)).max(50).default([]), }); const reviewBodySchema = z.object({ reviewerId: z.string().trim().optional().nullable(), status: z.enum(['pending', 'revision_requested', 'approved', 'rejected']).optional(), note: z.string().trim().max(1000).optional().nullable(), }); const categoryBodySchema = z.object({ name: z.string().trim().min(1).max(120), slug: z.string().trim().max(140).optional(), description: z.string().trim().max(1000).default(''), color: z.string().trim().max(32).default('#0F6B73'), diagram: z.string().trim().max(80).optional().nullable(), }); const tagBodySchema = z.object({ name: z.string().trim().min(1).max(80), }); const userBodySchema = z.object({ email: z.string().email().max(254).toLowerCase(), username: z.string().trim().toLowerCase().regex(USERNAME_RE).optional(), name: z.string().trim().min(1).max(160), role: z.enum(['admin', 'editor', 'writer', 'reviewer', 'reader']).default('reader'), status: z.enum(['active', 'suspended', 'pending']).default('active'), password: z.string().min(8).max(256).optional(), }); const uploadBodySchema = z.object({ filename: z.string().trim().min(1).max(180), mimeType: z.string().trim().max(120).optional(), dataBase64: z.string().min(1), webp: z.boolean().optional(), // convert to WebP if sharp is available }); const scheduleBodySchema = z.object({ scheduledAt: z.coerce.date(), }); const mediaQuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(50), q: z.string().trim().max(120).optional(), mimeType: z.string().trim().max(120).optional(), }); const activityExportQuerySchema = z.object({ type: z.string().trim().max(80).optional(), userId: z.string().trim().max(120).optional(), from: z.coerce.date().optional(), to: z.coerce.date().optional(), }); function badInput(reply: FastifyReply) { return reply.code(400).send({ error: 'invalid_input' }); } function mimeFromFilename(filename: string) { const ext = extname(filename).toLowerCase(); return ({ '.avif': 'image/avif', '.gif': 'image/gif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.pdf': 'application/pdf', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.zip': 'application/zip', } as Record)[ext] ?? 'application/octet-stream'; } function slugify(input: string) { return input .trim() .replace(/\s+/g, '-') .replace(/[^\w\u0600-\u06FF-]/g, '') .toLowerCase(); } function cleanText(value: unknown) { return String(value ?? '') .replace(/<[^>]+>/g, ' ') .replace(/\s+/g, ' ') .trim(); } function truncateText(value: unknown, max: number) { const text = cleanText(value); return text.length > max ? text.slice(0, Math.max(0, max - 1)).trim() : text; } function internalLinkCount(content: string) { return (content.match(/href=["']\/(?:article|topics|category|series|author)\//g) ?? []).length; } function hasPlaceholderContent(content: string) { return /placeholder|lorem|در دسترس است|به‌زودی|بزودی|نمونه محتوا|متن آزمایشی/i.test(content); } function completeSeoData(input: { slug: string; title: string; summary: string; content: string; type?: string; category?: { slug: string; name: string }; tags?: string[]; metaDescription?: string | null; keywords?: string[] | null; ogTitle?: string | null; ogDescription?: string | null; ogImage?: string | null; canonicalPath?: string | null; sourceNotes?: string | null; factCheckedAt?: Date | null; dateModified?: Date | null; contentFreshnessStatus?: string | null; }) { const tags = (input.tags ?? []).filter(Boolean); const keywords = input.keywords?.length ? input.keywords : [...new Set([input.category?.name, input.category?.slug, input.type, ...tags, input.title].filter(Boolean) as string[])].slice(0, 12); const description = truncateText(input.metaDescription || input.summary || input.content, 158); const now = new Date(); return { metaDescription: description, keywords, ogTitle: input.ogTitle?.trim() || `${truncateText(input.title, 84)} | تکنـاو`, ogDescription: truncateText(input.ogDescription || description, 280), ogImage: input.ogImage?.trim() || `/images/og/${input.slug}.jpg`, canonicalPath: input.canonicalPath?.trim() || `/article/${input.slug}`, sourceNotes: input.sourceNotes?.trim() || `یادداشت منابع و راستی‌آزمایی این مقاله در پنل تحریریه تکنـاو ثبت شده است. دسته: ${input.category?.name ?? 'فناوری'}.`, factCheckedAt: input.factCheckedAt ?? now, dateModified: input.dateModified ?? now, contentFreshnessStatus: input.contentFreshnessStatus ?? 'current', }; } function faDate(date: Date) { return date.toLocaleDateString('fa-IR'); } function publicArticle(article: ArticleWithRelations) { const publishedAt = article.publishedAt ?? article.createdAt; return { id: article.id, slug: article.slug, title: article.title, subtitle: article.subtitle, summary: article.summary, content: article.content, type: article.type, status: article.status, readTime: article.readTime, views: article.views, reactions: article.reactions, featured: article.featured, sponsored: article.sponsored, premiumOnly: article.premiumOnly, diagram: article.diagram, scheduledAt: article.scheduledAt?.toISOString() ?? null, metaDescription: article.metaDescription, keywords: article.keywords, ogTitle: article.ogTitle, ogDescription: article.ogDescription, ogImage: article.ogImage, canonicalPath: article.canonicalPath ?? `/article/${article.slug}`, dateModified: article.dateModified?.toISOString() ?? article.updatedAt.toISOString(), factCheckedAt: article.factCheckedAt?.toISOString() ?? null, reviewedById: article.reviewedById, sourceNotes: article.sourceNotes, contentFreshnessStatus: article.contentFreshnessStatus, correctionNotice: article.correctionNotice, category: article.category.slug, categoryName: article.category.name, categorySlug: article.category.slug, authorId: article.author.id, authorSlug: article.author.slug, authorName: article.author.name, authorUsername: article.author.user?.username ?? null, date: faDate(publishedAt), dateEn: publishedAt.toISOString().slice(0, 10), tags: article.tags.map((t) => t.tag.name), corrections: article.corrections?.map((correction) => ({ id: correction.id, note: correction.note, createdAt: correction.createdAt.toISOString(), date: faDate(correction.createdAt), editor: correction.editor ? { id: correction.editor.id, name: correction.editor.name, username: correction.editor.username } : null, })) ?? [], reviewNote: article.review?.note ?? null, }; } function publicCategory(category: { id: string; slug: string | null; name: string; description: string; color: string; diagram: string | null; articleCount: number; }) { return { id: category.slug, dbId: category.id, slug: category.slug, name: category.name, description: category.description, color: category.color, diagram: category.diagram, articleCount: category.articleCount, }; } 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 publicUser(user: { id: string; email: string; username: string | null; name: string; role: string; status: string; createdAt: Date; _count?: { authoredArticles: number } }) { return { id: user.id, email: user.email, username: user.username, name: user.name, role: user.role, roleName: roleName(user.role), status: user.status, joinDate: faDate(user.createdAt), articleCount: user._count?.authoredArticles, }; } function publicMedia(asset: { id: string; url: string; filename: string; mimeType: string; sizeBytes: number; width: number | null; height: number | null; uploadedBy?: { id: string; name: string } | null; createdAt: Date; }) { return { id: asset.id, url: asset.url, filename: asset.filename, mimeType: asset.mimeType, sizeBytes: asset.sizeBytes, width: asset.width, height: asset.height, uploadedBy: asset.uploadedBy ? { id: asset.uploadedBy.id, name: asset.uploadedBy.name } : null, createdAt: asset.createdAt.toISOString(), date: faDate(asset.createdAt), }; } function csvCell(value: unknown) { const text = String(value ?? ''); return `"${text.replace(/"/g, '""')}"`; } function roleName(role: string) { return ({ admin: 'مدیر کل', editor: 'سردبیر', writer: 'نویسنده', reviewer: 'بازبین', reader: 'خواننده' } as Record)[role] ?? role; } async function logActivity(req: FastifyRequest, action: string, target: string, type: string) { await prisma.activityLog.create({ data: { userId: req.session?.userId ?? null, action, target, type, }, }); await publishRealtime({ event: 'activity', data: { type, actor: req.session?.userId ?? 'system', target, ts: new Date().toISOString() }, }).catch(() => undefined); } async function invalidatePublic() { await Promise.all([ bust('articles:*'), bust('search:*'), bust('categories:*'), bust('tags:*'), bust('authors:*'), bust('related:*'), bust('series:*'), ]); } async function refreshCounts() { const [categories, authors, tags] = await Promise.all([ prisma.category.findMany({ select: { id: true } }), prisma.author.findMany({ select: { id: true } }), prisma.tag.findMany({ select: { id: true } }), ]); await Promise.all([ ...categories.map(async (c) => prisma.category.update({ where: { id: c.id }, data: { articleCount: await prisma.article.count({ where: { categoryId: c.id, status: PUBLISHED, deletedAt: null } }) }, })), ...authors.map(async (a) => prisma.author.update({ where: { id: a.id }, data: { articleCount: await prisma.article.count({ where: { authorId: a.id, status: PUBLISHED, deletedAt: null } }) }, })), ...tags.map(async (t) => prisma.tag.update({ where: { id: t.id }, data: { count: await prisma.articleTag.count({ where: { tagId: t.id } }) }, })), ]); } async function wireTags(articleId: string, names: string[]) { await prisma.articleTag.deleteMany({ where: { articleId } }); for (const name of names) { const tag = await prisma.tag.upsert({ where: { name }, update: {}, create: { name } }); await prisma.articleTag.create({ data: { articleId, tagId: tag.id } }); } } async function canEditArticle(req: FastifyRequest, article: { createdById: string | null }) { const role = req.session?.role; if (role === 'admin' || role === 'editor') return true; if (role === 'writer') return article.createdById === req.session?.userId; return false; } async function authorForWriter(userId: string) { const existing = await prisma.author.findFirst({ where: { userId } }); if (existing) return existing; const user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, username: true, name: true, bio: true }, }); if (!user) return null; const baseSlug = slugify(user.username || user.name) || `writer-${user.id.slice(0, 8)}`; let slug = baseSlug; let index = 2; while (await prisma.author.findUnique({ where: { slug } })) { slug = `${baseSlug}-${index}`; index += 1; } return prisma.author.create({ data: { slug, name: user.name, specialty: 'نویسنده تکنّاو', bio: user.bio || 'نویسنده و تحلیل‌گر فناوری در تکنّاو.', initials: Array.from(user.name.trim()).slice(0, 2).join('') || 'تک', color: '#0F6B73', social: {}, userId: user.id, }, }); } function articleWhereForSession(session: SessionData, extra: Prisma.ArticleWhereInput = {}): Prisma.ArticleWhereInput { return { deletedAt: null, ...extra, ...(session.role === 'writer' ? { createdById: session.userId } : {}), ...(session.role === 'reviewer' ? { status: PENDING } : {}), }; } function statusForRole(role: SessionData['role'], status: string | undefined) { if (!status) return status; if (role === 'writer' && status !== DRAFT) return PENDING; return status; } async function bufferedViewsFor(where: Prisma.ArticleWhereInput) { const articles = await prisma.article.findMany({ where, select: { id: true } }); if (articles.length === 0) return 0; const values = await redis.mget(...articles.map((a) => `views:article:${a.id}`)); return values.reduce((sum, value) => sum + Number(value ?? 0), 0); } async function hashPassword(password: string) { return argon2.hash(password, { algorithm: argon2.Algorithm.Argon2id, memoryCost: 19 * 1024, timeCost: 2, parallelism: 1, }); } function articleSeoData(body: z.infer | Partial>) { return { ...(body.metaDescription !== undefined ? { metaDescription: body.metaDescription ?? null } : {}), ...(body.keywords !== undefined ? { keywords: body.keywords ?? [] } : {}), ...(body.ogTitle !== undefined ? { ogTitle: body.ogTitle ?? null } : {}), ...(body.ogDescription !== undefined ? { ogDescription: body.ogDescription ?? null } : {}), ...(body.ogImage !== undefined ? { ogImage: body.ogImage ?? null } : {}), ...(body.canonicalPath !== undefined ? { canonicalPath: body.canonicalPath ?? null } : {}), ...(body.dateModified !== undefined ? { dateModified: body.dateModified ?? null } : {}), ...(body.factCheckedAt !== undefined ? { factCheckedAt: body.factCheckedAt ?? null } : {}), ...(body.reviewedById !== undefined ? { reviewedById: body.reviewedById ?? null } : {}), ...(body.sourceNotes !== undefined ? { sourceNotes: body.sourceNotes ?? null } : {}), ...(body.contentFreshnessStatus !== undefined ? { contentFreshnessStatus: body.contentFreshnessStatus ?? 'current' } : {}), ...(body.correctionNotice !== undefined ? { correctionNotice: body.correctionNotice ?? null } : {}), }; } function publishQualityIssues(article: ArticleWithRelations) { const checks = { titleLength: article.title.length >= 20 && article.title.length <= 100, metaDescription: !!article.metaDescription && article.metaDescription.length >= 80 && article.metaDescription.length <= 160, canonicalPath: !!article.canonicalPath && article.canonicalPath.startsWith('/article/'), ogImage: !!article.ogImage && !/^https?:\/\//i.test(article.ogImage), category: !!article.category?.slug, internalLinks: internalLinkCount(article.content) >= 2, noPlaceholder: !hasPlaceholderContent(article.content), noRemoteImages: !(article.content.match(/]+src=["'](https?:\/\/|\/\/)/gi) ?? []).length, sourceNotes: !!article.sourceNotes?.trim(), factChecked: !!article.factCheckedAt, }; return Object.entries(checks).filter(([, ok]) => !ok).map(([key]) => key); } async function publishArticleWithPhase10Checks(req: FastifyRequest, articleId: string) { const existing = await prisma.article.findUnique({ where: { id: articleId }, include: articleInclude, }); if (!existing) return null; const completed = completeSeoData({ slug: existing.slug, title: existing.title, summary: existing.summary, content: existing.content, type: existing.type, category: existing.category, tags: existing.tags.map((tag) => tag.tag.name), metaDescription: existing.metaDescription, keywords: existing.keywords, ogTitle: existing.ogTitle, ogDescription: existing.ogDescription, ogImage: existing.ogImage, canonicalPath: existing.canonicalPath, sourceNotes: existing.sourceNotes, factCheckedAt: existing.factCheckedAt, dateModified: existing.dateModified, contentFreshnessStatus: existing.contentFreshnessStatus, }); const candidate = { ...existing, ...completed } as unknown as ArticleWithRelations; const issues = publishQualityIssues(candidate); if (issues.length > 0) { return { article: existing, issues }; } const row = await prisma.article.update({ where: { id: existing.id }, data: { ...completed, status: PUBLISHED, publishedAt: existing.publishedAt ?? new Date(), scheduledAt: null, reviewedById: req.session?.userId ?? existing.reviewedById, }, include: articleInclude, }); await prisma.articleReview.upsert({ where: { articleId: row.id }, update: { status: 'approved', reviewerId: req.session?.userId ?? undefined }, create: { articleId: row.id, status: 'approved', reviewerId: req.session?.userId ?? null }, }).catch(() => undefined); await refreshCounts(); await invalidatePublic(); await logActivity(req, 'مقاله تایید و منتشر شد', row.title, 'publish'); await notifyFollowersOnPublish(row).catch(() => undefined); await publishRealtime({ event: 'activity', data: { type: 'publish', actor: req.session?.userId ?? 'system', target: row.slug, ts: new Date().toISOString() } }).catch(() => undefined); return { article: row, issues: [] }; } function campaignPublic(row: { id: string; subject: string; slug: string | null; bodyHtml: string; sentAt: Date | null; recipientCount: number; createdAt: Date; createdBy?: { id: string; name: string } | null; }) { return { id: row.id, subject: row.subject, slug: row.slug ?? row.id, bodyHtml: row.bodyHtml, sentAt: row.sentAt?.toISOString() ?? null, recipientCount: row.recipientCount, createdAt: row.createdAt.toISOString(), createdBy: row.createdBy ? { id: row.createdBy.id, name: row.createdBy.name } : null, }; } function seriesPublic(row: any) { return { id: row.id, title: row.title, slug: row.slug, description: row.description, coverImage: row.coverImage, createdAt: row.createdAt.toISOString(), articles: row.articles.map((item: { position: number; article: ArticleWithRelations }) => ({ position: item.position, article: publicArticle(item.article) })), }; } export default async function adminRoutes(app: FastifyInstance) { app.get<{ Params: { year: string; month: string; file: string } }>('/uploads/:year/:month/:file', async (req, reply) => { const { year, month, file } = req.params; if (!/^\d{4}$/.test(year) || !/^\d{2}$/.test(month)) return reply.code(404).send({ error: 'not_found' }); const safeFile = basename(file); if (safeFile !== file || !safeFile) return reply.code(404).send({ error: 'not_found' }); const absolutePath = join(config.UPLOAD_DIR, year, month, safeFile); const info = await stat(absolutePath).catch(() => null); if (!info?.isFile()) return reply.code(404).send({ error: 'not_found' }); return reply.type(mimeFromFilename(safeFile)).send(createReadStream(absolutePath)); }); app.get('/api/v1/admin/articles', { preHandler: requireAuth(ALL_CONTENT_ROLES), schema: { response: { 200: { type: 'object', additionalProperties: true } } }, }, async (req, reply) => { const parsed = listQuerySchema.safeParse(req.query); if (!parsed.success) return badInput(reply); const { page, limit, q, status } = parsed.data; const role = req.session!.role; const where = articleWhereForSession(req.session!, { ...(status && role !== 'reviewer' ? { status } : {}), ...(q ? { OR: [{ title: { contains: q, mode: 'insensitive' } }, { summary: { contains: q, mode: 'insensitive' } }] } : {}), }); const [total, rows] = await Promise.all([ prisma.article.count({ where }), prisma.article.findMany({ where, include: articleInclude, orderBy: [{ updatedAt: 'desc' }], skip: (page - 1) * limit, take: limit, }), ]); return { items: rows.map(publicArticle), total, page, limit, pages: Math.ceil(total / limit) }; }); app.get('/api/v1/admin/dashboard', { preHandler: requireAuth(ALL_CONTENT_ROLES), schema: { response: { 200: { type: 'object', additionalProperties: true } } }, }, async (req) => { const baseWhere = articleWhereForSession(req.session!); const byStatus = await Promise.all([ prisma.article.count({ where: { ...baseWhere, status: PUBLISHED } }), prisma.article.count({ where: { ...baseWhere, status: PENDING } }), prisma.article.count({ where: { ...baseWhere, status: DRAFT } }), prisma.article.count({ where: { ...baseWhere, status: NEEDS_REVISION } }), prisma.article.count({ where: { ...baseWhere, status: SCHEDULED } }), ]); const [totalArticles, sums, bufferedViews, recentArticles, recentActivity, totalUsers, totalAuthors, totalCategories, totalTags, totalMedia, topArticles] = await Promise.all([ prisma.article.count({ where: baseWhere }), prisma.article.aggregate({ where: baseWhere, _sum: { views: true, reactions: true } }), bufferedViewsFor(baseWhere), prisma.article.findMany({ where: baseWhere, include: articleInclude, orderBy: { updatedAt: 'desc' }, take: 5, }), req.session!.role === 'admin' ? prisma.activityLog.findMany({ include: { user: true }, orderBy: { createdAt: 'desc' }, take: 10 }) : Promise.resolve([]), req.session!.role === 'admin' ? prisma.user.count() : Promise.resolve(0), prisma.author.count(), prisma.category.count(), prisma.tag.count(), req.session!.role === 'admin' || req.session!.role === 'editor' ? prisma.mediaAsset.count() : Promise.resolve(0), prisma.article.findMany({ where: baseWhere, include: articleInclude, orderBy: [{ views: 'desc' }, { publishedAt: 'desc' }], take: 5 }), ]); return { totalArticles, published: byStatus[0], pending: byStatus[1], drafts: byStatus[2], needsRevision: byStatus[3], scheduled: byStatus[4], totalViews: (sums._sum.views ?? 0) + bufferedViews, totalReactions: sums._sum.reactions ?? 0, totalUsers, totalAuthors, totalCategories, totalTags, totalMedia, recentArticles: recentArticles.map(publicArticle), topArticles: topArticles.map(publicArticle), recentActivity: recentActivity.map((r) => ({ id: r.id, user: r.user?.name ?? 'سیستم', action: r.action, target: r.target, type: r.type, time: faDate(r.createdAt), })), }; }); app.get('/api/v1/admin/panel-metrics', { preHandler: requireAuth(ALL_CONTENT_ROLES), schema: { response: { 200: { type: 'object', additionalProperties: true } } }, }, async (req) => { const baseWhere = articleWhereForSession(req.session!); const since = new Date(); since.setDate(since.getDate() - 29); const [ totalArticles, published, pending, drafts, needsRevision, scheduled, articleSums, bufferedViews, comments, flaggedComments, saved, reviewsPending, reviewsApproved, media, newsletterSubscribers, topicFollows, writerFollows, notificationsUnread, eventsByType, recentArticles, ] = await Promise.all([ prisma.article.count({ where: baseWhere }), prisma.article.count({ where: { ...baseWhere, status: PUBLISHED } }), prisma.article.count({ where: { ...baseWhere, status: PENDING } }), prisma.article.count({ where: { ...baseWhere, status: DRAFT } }), prisma.article.count({ where: { ...baseWhere, status: NEEDS_REVISION } }), prisma.article.count({ where: { ...baseWhere, status: SCHEDULED } }), prisma.article.aggregate({ where: baseWhere, _sum: { views: true, reactions: true } }), bufferedViewsFor(baseWhere), prisma.comment.count({ where: { article: baseWhere } }), prisma.comment.count({ where: { article: baseWhere, flagged: true } }), prisma.savedArticle.count({ where: { article: baseWhere } }), prisma.articleReview.count({ where: { article: baseWhere, status: 'pending' } }), prisma.articleReview.count({ where: { article: baseWhere, status: 'approved' } }), req.session!.role === 'admin' || req.session!.role === 'editor' ? prisma.mediaAsset.count() : Promise.resolve(0), req.session!.role === 'admin' || req.session!.role === 'editor' ? prisma.newsletterSubscriber.count() : Promise.resolve(0), prisma.topicFollow.count(), req.session!.role === 'writer' ? prisma.writerFollow.count({ where: { writerId: req.session!.userId } }) : prisma.writerFollow.count(), prisma.notification.count({ where: { userId: req.session!.userId, read: false } }), prisma.analyticsEvent.groupBy({ by: ['type'], where: { createdAt: { gte: since } }, _count: { _all: true } }).catch(() => []), prisma.article.findMany({ where: baseWhere, include: articleInclude, orderBy: { updatedAt: 'desc' }, take: 6 }), ]); const auditRows = recentArticles.map((article) => { const completed = { ...article, ...completeSeoData({ slug: article.slug, title: article.title, summary: article.summary, content: article.content, type: article.type, category: article.category, tags: article.tags.map((tag) => tag.tag.name), metaDescription: article.metaDescription, keywords: article.keywords, ogTitle: article.ogTitle, ogDescription: article.ogDescription, ogImage: article.ogImage, canonicalPath: article.canonicalPath, sourceNotes: article.sourceNotes, factCheckedAt: article.factCheckedAt, dateModified: article.dateModified, contentFreshnessStatus: article.contentFreshnessStatus, }) } as unknown as ArticleWithRelations; return publishQualityIssues(completed).length; }); return { role: req.session!.role, statuses: { totalArticles, published, pending, drafts, needsRevision, scheduled }, realtime: { bufferedViews, unreadNotifications: notificationsUnread, eventsByType: eventsByType.map((row) => ({ type: row.type, count: row._count._all })), }, engagement: { views: (articleSums._sum.views ?? 0) + bufferedViews, reactions: articleSums._sum.reactions ?? 0, comments, flaggedComments, saved, topicFollows, writerFollows, }, workflow: { reviewsPending, reviewsApproved }, growth: { newsletterSubscribers, media }, quality: { recentChecked: auditRows.length, recentWithIssues: auditRows.filter(Boolean).length, }, recentArticles: recentArticles.map(publicArticle), }; }); app.get('/api/v1/admin/writer/dashboard', { preHandler: requireAuth(['admin', 'editor', 'writer']), schema: { response: { 200: { type: 'object', additionalProperties: true } } }, }, async (req) => { const where: Prisma.ArticleWhereInput = { createdById: req.session!.userId, deletedAt: null }; const since = new Date(); since.setDate(since.getDate() - 29); const [ totalArticles, published, pending, drafts, needsRevision, articleSums, bufferedViews, followers, comments, recentComments, recentArticles, viewsByDay, ] = await Promise.all([ prisma.article.count({ where }), prisma.article.count({ where: { ...where, status: PUBLISHED } }), prisma.article.count({ where: { ...where, status: PENDING } }), prisma.article.count({ where: { ...where, status: DRAFT } }), prisma.article.count({ where: { ...where, status: NEEDS_REVISION } }), prisma.article.aggregate({ where, _sum: { views: true, reactions: true } }), bufferedViewsFor(where), prisma.writerFollow.count({ where: { writerId: req.session!.userId } }), prisma.comment.count({ where: { article: where } }), prisma.comment.findMany({ where: { article: where }, include: { author: true, article: { include: articleInclude } }, orderBy: { createdAt: 'desc' }, take: 8, }), prisma.article.findMany({ where, include: articleInclude, orderBy: { updatedAt: 'desc' }, take: 8 }), prisma.articleViewLog.groupBy({ by: ['date'], where: { date: { gte: since }, article: where }, _sum: { views: true }, orderBy: { date: 'asc' }, }).catch(() => []), ]); return { stats: { totalArticles, published, pending, drafts, needsRevision, views: (articleSums._sum.views ?? 0) + bufferedViews, reactions: articleSums._sum.reactions ?? 0, followers, comments, }, recentArticles: recentArticles.map(publicArticle), recentComments: recentComments.map((comment) => ({ id: comment.id, body: comment.body, time: faDate(comment.createdAt), createdAt: comment.createdAt.toISOString(), author: publicUser(comment.author), article: publicArticle(comment.article), })), viewsByDay: viewsByDay.map((row) => ({ date: row.date.toISOString().slice(0, 10), views: row._sum.views ?? 0 })), }; }); app.post('/api/v1/admin/articles', { preHandler: requireAuth(['admin', 'editor', 'writer']), schema: { body: { type: 'object', additionalProperties: true }, response: { 200: { type: 'object', additionalProperties: true } } }, }, async (req, reply) => { const parsed = articleBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const requestedStatus = statusForRole(req.session!.role, body.status) ?? DRAFT; const [category, author] = await Promise.all([ prisma.category.findUnique({ where: { slug: body.category } }), req.session!.role === 'writer' ? authorForWriter(req.session!.userId) : body.authorId ? prisma.author.findUnique({ where: { id: body.authorId } }) : prisma.author.findFirst(), ]); if (!category || !author) return badInput(reply); const slug = body.slug ? slugify(body.slug) : slugify(body.title); const seo = completeSeoData({ slug, title: body.title, summary: body.summary, content: body.content, type: body.type, category, tags: body.tags, metaDescription: body.metaDescription, keywords: body.keywords, ogTitle: body.ogTitle, ogDescription: body.ogDescription, ogImage: body.ogImage, canonicalPath: body.canonicalPath, sourceNotes: body.sourceNotes, factCheckedAt: body.factCheckedAt, dateModified: body.dateModified, contentFreshnessStatus: body.contentFreshnessStatus, }); const row = await prisma.article.create({ data: { slug, title: body.title, subtitle: body.subtitle ?? null, summary: body.summary, content: body.content, type: body.type, status: requestedStatus, readTime: body.readTime, featured: body.featured, sponsored: body.sponsored, premiumOnly: body.premiumOnly, diagram: body.diagram ?? null, correctionNotice: body.correctionNotice ?? null, ...seo, scheduledAt: body.scheduledAt ?? null, categoryId: category.id, authorId: author.id, createdById: req.session!.userId, publishedAt: requestedStatus === PUBLISHED ? new Date() : null, }, include: articleInclude, }); await wireTags(row.id, body.tags); if (requestedStatus === PENDING) { await prisma.articleReview.upsert({ where: { articleId: row.id }, update: { status: 'pending' }, create: { articleId: row.id, status: 'pending' }, }).catch(() => undefined); await notifyEditorsOnSubmit({ slug: row.slug, title: row.title, authorName: row.author?.name || 'نویسنده' }); } await refreshCounts(); await invalidatePublic(); await logActivity(req, requestedStatus === PENDING ? 'مقاله برای بررسی ارسال شد' : 'مقاله ایجاد شد', row.title, requestedStatus === PENDING ? 'submit' : 'draft'); const fresh = await prisma.article.findUniqueOrThrow({ where: { id: row.id }, include: articleInclude }); return { article: publicArticle(fresh) }; }); app.patch('/api/v1/admin/articles/:id', { preHandler: requireAuth(['admin', 'editor', 'writer']), schema: { params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } }, body: { type: 'object', additionalProperties: true } }, }, async (req: FastifyRequest<{ Params: { id: string }; Body: unknown }>, reply) => { const parsed = articleBodySchema.partial().safeParse(req.body); if (!parsed.success) return badInput(reply); const existing = await prisma.article.findUnique({ where: { id: req.params.id }, include: articleInclude }); if (!existing) return reply.code(404).send({ error: 'not_found' }); if (!(await canEditArticle(req, existing))) return reply.code(403).send({ error: 'forbidden' }); const body = parsed.data; const requestedStatus = statusForRole(req.session!.role, body.status); const nextSlug = body.slug !== undefined ? slugify(body.slug) : existing.slug; const nextCategory = body.category ? await prisma.category.findUnique({ where: { slug: body.category } }) : existing.category; if (!nextCategory) return badInput(reply); const nextTags = body.tags ?? existing.tags.map((tag) => tag.tag.name); const completedSeo = completeSeoData({ slug: nextSlug, title: body.title ?? existing.title, summary: body.summary ?? existing.summary, content: body.content ?? existing.content, type: body.type ?? existing.type, category: nextCategory, tags: nextTags, metaDescription: body.metaDescription ?? existing.metaDescription, keywords: body.keywords ?? existing.keywords, ogTitle: body.ogTitle ?? existing.ogTitle, ogDescription: body.ogDescription ?? existing.ogDescription, ogImage: body.ogImage ?? existing.ogImage, canonicalPath: body.canonicalPath ?? existing.canonicalPath, sourceNotes: body.sourceNotes ?? existing.sourceNotes, factCheckedAt: body.factCheckedAt ?? existing.factCheckedAt, dateModified: body.dateModified ?? existing.dateModified, contentFreshnessStatus: body.contentFreshnessStatus ?? existing.contentFreshnessStatus, }); const data: Prisma.ArticleUpdateInput = { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.subtitle !== undefined ? { subtitle: body.subtitle ?? null } : {}), ...(body.summary !== undefined ? { summary: body.summary } : {}), ...(body.content !== undefined ? { content: body.content } : {}), ...(body.type !== undefined ? { type: body.type } : {}), ...(requestedStatus !== undefined && requestedStatus !== PUBLISHED ? { status: requestedStatus } : {}), ...(body.readTime !== undefined ? { readTime: body.readTime } : {}), ...(body.featured !== undefined ? { featured: body.featured } : {}), ...(body.sponsored !== undefined ? { sponsored: body.sponsored } : {}), ...(body.premiumOnly !== undefined ? { premiumOnly: body.premiumOnly } : {}), ...(body.diagram !== undefined ? { diagram: body.diagram ?? null } : {}), ...(body.correctionNotice !== undefined ? { correctionNotice: body.correctionNotice ?? null } : {}), ...completedSeo, ...(body.scheduledAt !== undefined ? { scheduledAt: body.scheduledAt ?? null } : {}), ...(body.slug !== undefined ? { slug: nextSlug } : {}), }; if (body.category) { data.category = { connect: { id: nextCategory.id } }; } if (body.authorId) data.author = { connect: { id: body.authorId } }; // Snapshot before overwriting await prisma.articleRevision.create({ data: { articleId: existing.id, savedById: req.session!.userId, title: existing.title, content: existing.content, summary: existing.summary, status: existing.status, }, }); // Keep at most 20 revisions per article const staleRevisions = await prisma.articleRevision.findMany({ where: { articleId: existing.id }, orderBy: { createdAt: 'desc' }, skip: 20, select: { id: true }, }); if (staleRevisions.length > 0) { await prisma.articleRevision.deleteMany({ where: { id: { in: staleRevisions.map((r) => r.id) } } }); } const row = await prisma.article.update({ where: { id: existing.id }, data, include: articleInclude }); if (body.tags) await wireTags(row.id, body.tags); if (requestedStatus === PENDING) { await prisma.articleReview.upsert({ where: { articleId: row.id }, update: { status: 'pending' }, create: { articleId: row.id, status: 'pending' }, }).catch(() => undefined); await notifyEditorsOnSubmit({ slug: row.slug, title: row.title, authorName: row.author?.name || 'نویسنده' }); } await refreshCounts(); await invalidatePublic(); await logActivity(req, 'مقاله ویرایش شد', row.title, 'edit'); if (requestedStatus === PUBLISHED && (req.session!.role === 'admin' || req.session!.role === 'editor')) { const published = await publishArticleWithPhase10Checks(req, row.id); if (published?.issues.length) return reply.code(409).send({ error: 'quality_check_failed', issues: published.issues, article: publicArticle(published.article) }); if (published?.article) return { article: publicArticle(published.article) }; } const fresh = await prisma.article.findUniqueOrThrow({ where: { id: row.id }, include: articleInclude }); return { article: publicArticle(fresh) }; }); app.post('/api/v1/admin/articles/:id/publish', { preHandler: requireAuth(EDITOR_ROLES), schema: { params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } } }, }, async (req: FastifyRequest<{ Params: { id: string } }>, reply) => { const result = await publishArticleWithPhase10Checks(req, req.params.id); if (!result) return reply.code(404).send({ error: 'not_found' }); if (result.issues.length) return reply.code(409).send({ error: 'quality_check_failed', issues: result.issues, article: publicArticle(result.article) }); return { article: publicArticle(result.article) }; }); app.post('/api/v1/admin/articles/:id/schedule', { preHandler: requireAuth(EDITOR_ROLES), schema: { params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } }, body: { type: 'object', additionalProperties: true } }, }, async (req: FastifyRequest<{ Params: { id: string }; Body: unknown }>, reply) => { const parsed = scheduleBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const row = await prisma.article.update({ where: { id: req.params.id }, data: { status: SCHEDULED, scheduledAt: parsed.data.scheduledAt, publishedAt: null }, include: articleInclude, }).catch(() => null); if (!row) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'مقاله زمان‌بندی شد', row.title, 'schedule'); return { article: publicArticle(row) }; }); app.post<{ Params: { id: string }; Body: unknown }>('/api/v1/admin/articles/:id/corrections', { preHandler: requireAuth(EDITOR_ROLES), schema: { params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } }, body: { type: 'object', additionalProperties: true } }, }, async (req, reply) => { const parsed = correctionBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const article = await prisma.article.findUnique({ where: { id: req.params.id } }); if (!article) return reply.code(404).send({ error: 'not_found' }); await prisma.articleCorrection.create({ data: { articleId: article.id, note: parsed.data.note, editorId: req.session!.userId }, }); const notice = `${faDate(new Date())}: ${parsed.data.note}`; const row = await prisma.article.update({ where: { id: article.id }, data: { correctionNotice: notice, dateModified: new Date() }, include: articleInclude, }); await invalidatePublic(); await logActivity(req, 'اصلاحیه مقاله ثبت شد', article.title, 'correction'); return { article: publicArticle(row) }; }); app.delete('/api/v1/admin/articles/:id', { preHandler: requireAuth(ADMIN_ONLY), schema: { params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } } }, }, async (req: FastifyRequest<{ Params: { id: string } }>, reply) => { const article = await prisma.article.findUnique({ where: { id: req.params.id } }); if (!article) return reply.code(404).send({ error: 'not_found' }); await prisma.article.update({ where: { id: article.id }, data: { deletedAt: new Date() } }); await refreshCounts(); await bust('articles:*'); await invalidatePublic(); await logActivity(req, 'مقاله حذف شد', article.title, 'delete'); return { ok: true }; }); app.get('/api/v1/admin/categories', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async () => ({ items: (await prisma.category.findMany({ orderBy: { name: 'asc' } })).map(publicCategory), })); app.post('/api/v1/admin/categories', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = categoryBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const row = await prisma.category.create({ data: { name: body.name, slug: body.slug ? slugify(body.slug) : slugify(body.name), description: body.description, color: body.color, diagram: body.diagram ?? null }, }); await invalidatePublic(); await logActivity(req, 'دسته‌بندی ایجاد شد', row.name, 'category'); return { category: publicCategory(row) }; }); app.patch('/api/v1/admin/categories/:id', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req: FastifyRequest<{ Params: { id: string }; Body: unknown }>, reply) => { const parsed = categoryBodySchema.partial().safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const row = await prisma.category.update({ where: { id: req.params.id }, data: { ...(body.name !== undefined ? { name: body.name } : {}), ...(body.slug !== undefined ? { slug: slugify(body.slug) } : {}), ...(body.description !== undefined ? { description: body.description } : {}), ...(body.color !== undefined ? { color: body.color } : {}), ...(body.diagram !== undefined ? { diagram: body.diagram ?? null } : {}), }, }).catch(() => null); if (!row) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'دسته‌بندی ویرایش شد', row.name, 'category'); return { category: publicCategory(row) }; }); app.delete<{ Params: { id: string } }>('/api/v1/admin/categories/:id', { preHandler: requireAuth(ADMIN_ONLY) }, async (req, reply) => { const row = await prisma.category.delete({ where: { id: req.params.id } }).catch(() => null); if (!row) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'دسته‌بندی حذف شد', row.name, 'category'); return { ok: true }; }); app.get('/api/v1/admin/tags', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async () => ({ items: await prisma.tag.findMany({ orderBy: [{ count: 'desc' }, { name: 'asc' }] }), })); app.post('/api/v1/admin/tags', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = tagBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const tag = await prisma.tag.upsert({ where: { name: parsed.data.name }, update: {}, create: { name: parsed.data.name } }); await invalidatePublic(); await logActivity(req, 'برچسب ایجاد شد', tag.name, 'tag'); return { tag }; }); app.delete<{ Params: { id: string } }>('/api/v1/admin/tags/:id', { preHandler: requireAuth(ADMIN_ONLY) }, async (req, reply) => { const tag = await prisma.tag.delete({ where: { id: req.params.id } }).catch(() => null); if (!tag) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'برچسب حذف شد', tag.name, 'tag'); return { ok: true }; }); // ── Authors CRUD ──────────────────────────────────────────────────────────── app.get('/api/v1/admin/authors', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async () => ({ items: await prisma.author.findMany({ orderBy: { name: 'asc' } }), })); const authorBodySchema = z.object({ name: z.string().trim().min(1).max(200), slug: z.string().trim().min(1).max(220), specialty: z.string().trim().min(1).max(200), bio: z.string().trim().max(2000).default(''), initials: z.string().trim().max(10).optional(), color: z.string().trim().max(32).optional(), verifiedExpert: z.boolean().optional(), verificationNote: z.string().trim().max(500).optional(), social: z.record(z.string().trim().max(500)).default({}), }); app.post('/api/v1/admin/authors', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = authorBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const author = await prisma.author.create({ data: { name: body.name, slug: body.slug, specialty: body.specialty, bio: body.bio, initials: body.initials || body.name.slice(0, 2), color: body.color || '#0F6B73', verifiedExpert: body.verifiedExpert === true, verificationNote: body.verificationNote || null, social: body.social, }, }); await invalidatePublic(); await logActivity(req, 'نویسنده ایجاد شد', author.name, 'author'); return author; }); app.put<{ Params: { id: string } }>('/api/v1/admin/authors/:id', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = authorBodySchema.partial().safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const author = await prisma.author.update({ where: { id: req.params.id }, data: { ...(body.name ? { name: body.name } : {}), ...(body.slug ? { slug: body.slug } : {}), ...(body.specialty ? { specialty: body.specialty } : {}), ...(body.bio !== undefined ? { bio: body.bio } : {}), ...(body.initials ? { initials: body.initials } : {}), ...(body.color ? { color: body.color } : {}), ...(body.verifiedExpert !== undefined ? { verifiedExpert: body.verifiedExpert === true } : {}), ...(body.verificationNote !== undefined ? { verificationNote: body.verificationNote || null } : {}), ...(body.social ? { social: body.social } : {}), }, }).catch(() => null); if (!author) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'نویسنده ویرایش شد', author.name, 'author'); return author; }); app.delete<{ Params: { id: string } }>('/api/v1/admin/authors/:id', { preHandler: requireAuth(ADMIN_ONLY) }, async (req, reply) => { const author = await prisma.author.delete({ where: { id: req.params.id } }).catch(() => null); if (!author) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'نویسنده حذف شد', author.name, 'author'); return { ok: true }; }); app.get('/api/v1/admin/users', { preHandler: requireAuth(ADMIN_ONLY) }, async () => ({ items: (await prisma.user.findMany({ include: { _count: { select: { authoredArticles: true } } }, orderBy: { createdAt: 'desc' }, })).map(publicUser), })); app.post('/api/v1/admin/users', { preHandler: requireAuth(ADMIN_ONLY), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = userBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const user = await prisma.user.create({ data: { email: body.email, username: body.username ?? usernameFromEmail(body.email), name: body.name, role: body.role, status: body.status, passwordHash: await hashPassword(body.password ?? randomBytes(24).toString('base64url')), }, }); await logActivity(req, 'کاربر ایجاد شد', user.name, 'user'); return { user: publicUser(user) }; }); app.patch('/api/v1/admin/users/:id', { preHandler: requireAuth(ADMIN_ONLY), schema: { body: { type: 'object', additionalProperties: true } } }, async (req: FastifyRequest<{ Params: { id: string }; Body: unknown }>, reply) => { const parsed = userBodySchema.partial().safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const user = await prisma.user.update({ where: { id: req.params.id }, data: { ...(body.email !== undefined ? { email: body.email } : {}), ...(body.username !== undefined ? { username: body.username } : {}), ...(body.name !== undefined ? { name: body.name } : {}), ...(body.role !== undefined ? { role: body.role } : {}), ...(body.status !== undefined ? { status: body.status } : {}), ...(body.password !== undefined ? { passwordHash: await hashPassword(body.password), passwordChangedAt: new Date() } : {}), }, }).catch(() => null); if (!user) return reply.code(404).send({ error: 'not_found' }); if (body.password) await invalidateAllUserSessions(user.id).catch(() => undefined); await logActivity(req, 'کاربر ویرایش شد', user.name, 'user'); return { user: publicUser(user) }; }); app.get('/api/v1/admin/activity', { preHandler: requireAuth(ADMIN_ONLY) }, async (req, reply) => { const parsed = listQuerySchema.safeParse(req.query); if (!parsed.success) return badInput(reply); const { page, limit } = parsed.data; const [total, rows] = await Promise.all([ prisma.activityLog.count(), prisma.activityLog.findMany({ include: { user: true }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit }), ]); return { items: rows.map((r) => ({ id: r.id, user: r.user?.name ?? 'سیستم', action: r.action, target: r.target, type: r.type, time: faDate(r.createdAt), })), total, page, limit, pages: Math.ceil(total / limit), }; }); app.get('/api/v1/admin/activity/export.csv', { preHandler: requireAuth(ADMIN_ONLY) }, async (req, reply) => { const parsed = activityExportQuerySchema.safeParse(req.query); if (!parsed.success) return badInput(reply); const { type, userId, from, to } = parsed.data; const where: Prisma.ActivityLogWhereInput = { ...(type ? { type } : {}), ...(userId ? { userId } : {}), ...((from || to) ? { createdAt: { ...(from ? { gte: from } : {}), ...(to ? { lte: to } : {}) } } : {}), }; const rows = await prisma.activityLog.findMany({ where, include: { user: true }, orderBy: { createdAt: 'desc' }, take: 5000, }); const csv = [ ['id', 'user', 'action', 'target', 'type', 'createdAt'].map(csvCell).join(','), ...rows.map((r) => [r.id, r.user?.name ?? 'سیستم', r.action, r.target, r.type, r.createdAt.toISOString()].map(csvCell).join(',')), ].join('\n'); return reply .header('Content-Type', 'text/csv; charset=utf-8') .header('Content-Disposition', 'attachment; filename="teknav-activity.csv"') .send('\uFEFF' + csv); }); app.get('/api/v1/admin/media', { preHandler: requireAuth(EDITOR_ROLES) }, async (req, reply) => { const parsed = mediaQuerySchema.safeParse(req.query); if (!parsed.success) return badInput(reply); const { page, limit, q, mimeType } = parsed.data; const where: Prisma.MediaAssetWhereInput = { ...(mimeType ? { mimeType: { contains: mimeType, mode: 'insensitive' } } : {}), ...(q ? { OR: [{ filename: { contains: q, mode: 'insensitive' } }, { url: { contains: q, mode: 'insensitive' } }] } : {}), }; const [total, rows] = await Promise.all([ prisma.mediaAsset.count({ where }), prisma.mediaAsset.findMany({ where, include: { uploadedBy: { select: { id: true, name: true } } }, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), ]); return { items: rows.map(publicMedia), total, page, limit, pages: Math.ceil(total / limit) }; }); app.delete<{ Params: { id: string } }>('/api/v1/admin/media/:id', { preHandler: requireAuth(ADMIN_ONLY) }, async (req, reply) => { const asset = await prisma.mediaAsset.findUnique({ where: { id: req.params.id } }); if (!asset) return reply.code(404).send({ error: 'not_found' }); const [articleRefs, avatarRefs] = await Promise.all([ prisma.article.count({ where: { content: { contains: asset.url } } }), prisma.user.count({ where: { avatarUrl: asset.url } }), ]); if (articleRefs > 0 || avatarRefs > 0) return reply.code(409).send({ error: 'media_in_use' }); await prisma.mediaAsset.delete({ where: { id: asset.id } }); await unlink(asset.path).catch(() => undefined); await logActivity(req, 'رسانه حذف شد', asset.filename, 'media'); return { ok: true }; }); app.post('/api/v1/admin/uploads', { preHandler: requireAuth(['admin', 'editor', 'writer']), bodyLimit: 25 * 1024 * 1024, schema: { body: { type: 'object', additionalProperties: true }, response: { 200: { type: 'object', additionalProperties: true } } }, }, async (req, reply) => { const parsed = uploadBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const now = new Date(); const yyyy = String(now.getFullYear()); const mm = String(now.getMonth() + 1).padStart(2, '0'); let ext = extname(parsed.data.filename).slice(0, 12) || '.bin'; let fileBuf: Buffer = Buffer.from(parsed.data.dataBase64, 'base64'); const isImage = ['image/jpeg','image/png','image/gif'].includes(parsed.data.mimeType ?? ''); if (parsed.data.webp && isImage) { try { const sharp = (await import('sharp')).default; fileBuf = await sharp(fileBuf).webp({ quality: 82 }).toBuffer(); ext = '.webp'; } catch { /* sharp not installed — proceed with original */ } } let width: number | undefined; let height: number | undefined; if (isImage || ext === '.webp') { try { const sharp = (await import('sharp')).default; const meta = await sharp(fileBuf).metadata(); width = meta.width; height = meta.height; } catch { /* metadata is optional */ } } const filename = `${randomUUID()}${ext}`; const key = `${yyyy}/${mm}/${filename}`; const url = await storage.upload(fileBuf, key, parsed.data.mimeType); const absolutePath = join(config.UPLOAD_DIR, key); await prisma.mediaAsset.create({ data: { url, path: absolutePath, filename, mimeType: ext === '.webp' ? 'image/webp' : (parsed.data.mimeType ?? 'application/octet-stream'), sizeBytes: fileBuf.length, width, height, uploadedById: req.session!.userId, }, }).catch(() => undefined); await logActivity(req, 'فایل بارگذاری شد', parsed.data.filename, 'upload'); return { ok: true, url, path: absolutePath, mimeType: ext === '.webp' ? 'image/webp' : (parsed.data.mimeType ?? 'application/octet-stream') }; }); app.get('/api/v1/admin/analytics', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async (req) => { const baseWhere = articleWhereForSession(req.session!); const since = new Date(); since.setDate(since.getDate() - 29); const [articles, viewsByDay, comments, saved, subscribers] = await Promise.all([ prisma.article.findMany({ where: baseWhere, include: articleInclude, orderBy: [{ views: 'desc' }, { updatedAt: 'desc' }], take: 10 }), prisma.articleViewLog.groupBy({ by: ['date'], where: { date: { gte: since }, article: baseWhere }, _sum: { views: true }, orderBy: { date: 'asc' }, }), prisma.comment.count({ where: { article: baseWhere } }), prisma.savedArticle.count({ where: { article: baseWhere } }), req.session!.role === 'admin' || req.session!.role === 'editor' ? prisma.newsletterSubscriber.count() : Promise.resolve(0), ]); return { topArticles: articles.map(publicArticle), viewsByDay: viewsByDay.map((row) => ({ date: row.date.toISOString().slice(0, 10), views: row._sum.views ?? 0 })), comments, saved, subscribers, }; }); app.get('/api/v1/admin/seo/audit', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async (req) => { const where = articleWhereForSession(req.session!); const articles = await prisma.article.findMany({ where, include: articleInclude, orderBy: [{ updatedAt: 'desc' }], take: 500, }); const now = Date.now(); const rows = articles.map((article) => { const internalLinks = (article.content.match(/href=["']\/(?:article|topics|category|series|author)\//g) ?? []).length; const checks = { titleLength: article.title.length >= 35 && article.title.length <= 90, metaDescriptionLength: !!article.metaDescription && article.metaDescription.length >= 110 && article.metaDescription.length <= 160, canonicalPath: !!article.canonicalPath && article.canonicalPath.startsWith('/article/'), ogImage: !!article.ogImage && !/^https?:\/\//i.test(article.ogImage), topic: !!article.category?.slug, internalLinks: internalLinks >= 2, noPlaceholder: !/placeholder|lorem|در دسترس است|به‌زودی|بزودی/i.test(article.content), noBrokenImageUrls: !(article.content.match(/]+src=["'](https?:\/\/|\/\/)/gi) ?? []).length, sourceNotes: !!article.sourceNotes?.trim(), factChecked: !!article.factCheckedAt, }; const needsRefresh = article.contentFreshnessStatus === 'needs_update' || (!!article.publishedAt && now - article.publishedAt.getTime() > 90 * 24 * 60 * 60 * 1000); return { article: publicArticle(article), checks, internalLinks, needsRefresh, missing: Object.entries(checks).filter(([, ok]) => !ok).map(([key]) => key), }; }); return { totals: { articles: rows.length, valid: rows.filter((row) => row.missing.length === 0 && !row.needsRefresh).length, missingSeo: rows.filter((row) => !row.checks.metaDescriptionLength || !row.checks.canonicalPath || !row.checks.ogImage).length, needsRefresh: rows.filter((row) => row.needsRefresh).length, missingSourceNotes: rows.filter((row) => !row.checks.sourceNotes).length, }, items: rows, }; }); app.get('/api/v1/admin/analytics/content', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async (req) => { const where = articleWhereForSession(req.session!); const [topViewed, topSaved, topCommented] = await Promise.all([ prisma.article.findMany({ where, include: articleInclude, orderBy: [{ views: 'desc' }, { updatedAt: 'desc' }], take: 10 }), prisma.article.findMany({ where, include: { ...articleInclude, _count: { select: { savedBy: true } } }, orderBy: [{ savedBy: { _count: 'desc' } }, { updatedAt: 'desc' }], take: 10 }), prisma.article.findMany({ where, include: { ...articleInclude, _count: { select: { comments: true } } }, orderBy: [{ comments: { _count: 'desc' } }, { updatedAt: 'desc' }], take: 10 }), ]); return { topViewed: topViewed.map(publicArticle), topSaved: topSaved.map((article) => ({ article: publicArticle(article), saved: article._count.savedBy })), topCommented: topCommented.map((article) => ({ article: publicArticle(article), comments: article._count.comments })), }; }); app.get('/api/v1/admin/analytics/newsletter', { preHandler: requireAuth(EDITOR_ROLES) }, async () => { const since = new Date(); since.setDate(since.getDate() - 29); const [subscribers, confirmed, campaigns, newByDay] = await Promise.all([ prisma.newsletterSubscriber.count(), prisma.newsletterSubscriber.count({ where: { confirmedAt: { not: null } } }), prisma.newsletterCampaign.findMany({ include: { createdBy: true }, orderBy: { createdAt: 'desc' }, take: 10 }), prisma.newsletterSubscriber.groupBy({ by: ['createdAt'], where: { createdAt: { gte: since } }, _count: { _all: true }, orderBy: { createdAt: 'asc' }, }).catch(() => []), ]); return { subscribers, confirmed, campaigns: campaigns.map(campaignPublic), newByDay: newByDay.map((row) => ({ date: row.createdAt.toISOString().slice(0, 10), subscribers: row._count._all })), }; }); app.get('/api/v1/admin/analytics/engagement', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async () => { const since = new Date(); since.setDate(since.getDate() - 29); const [events, byType] = await Promise.all([ prisma.analyticsEvent.findMany({ orderBy: { createdAt: 'desc' }, take: 100 }), prisma.analyticsEvent.groupBy({ by: ['type'], where: { createdAt: { gte: since } }, _count: { _all: true }, orderBy: { type: 'asc' } }), ]); return { byType: byType.map((row) => ({ type: row.type, count: row._count._all })), recent: events.map((event) => ({ id: event.id, type: event.type, userId: event.userId, articleId: event.articleId, topic: event.topic, metadata: event.metadata, createdAt: event.createdAt.toISOString(), })), }; }); app.get('/api/v1/admin/reviews', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async (req) => { const where: Prisma.ArticleReviewWhereInput = req.session!.role === 'reviewer' ? { reviewerId: req.session!.userId } : {}; const rows = await prisma.articleReview.findMany({ where, include: { reviewer: true, article: { include: articleInclude } }, orderBy: { updatedAt: 'desc' }, take: 100, }); return { items: rows.map((row) => ({ id: row.id, status: row.status, note: row.note, reviewer: row.reviewer ? publicUser(row.reviewer) : null, article: publicArticle(row.article), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), })) }; }); app.put<{ Params: { id: string }; Body: unknown }>('/api/v1/admin/articles/:id/review', { preHandler: requireAuth(['admin', 'editor', 'reviewer']), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = reviewBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const article = await prisma.article.findUnique({ where: { id: req.params.id } }); if (!article) return reply.code(404).send({ error: 'not_found' }); const row = await prisma.articleReview.upsert({ where: { articleId: article.id }, update: { ...(parsed.data.reviewerId !== undefined ? { reviewerId: parsed.data.reviewerId ?? null } : {}), ...(parsed.data.status !== undefined ? { status: parsed.data.status } : {}), ...(parsed.data.note !== undefined ? { note: parsed.data.note ?? null } : {}), }, create: { articleId: article.id, reviewerId: parsed.data.reviewerId ?? null, status: parsed.data.status ?? 'pending', note: parsed.data.note ?? null, }, include: { reviewer: true, article: { include: articleInclude } }, }); if (parsed.data.status === 'approved') { const published = await publishArticleWithPhase10Checks(req, article.id); if (published?.issues.length) return reply.code(409).send({ error: 'quality_check_failed', issues: published.issues, article: publicArticle(published.article) }); await createNotification(article.authorId.toString(), 'review_approved', { articleSlug: article.slug, articleTitle: article.title, note: parsed.data.note }); } else if (parsed.data.status === 'revision_requested' || parsed.data.status === 'rejected') { await prisma.article.update({ where: { id: article.id }, data: { status: NEEDS_REVISION } }).catch(() => undefined); await createNotification(article.authorId.toString(), parsed.data.status === 'revision_requested' ? 'review_revision' : 'review_rejected', { articleSlug: article.slug, articleTitle: article.title, note: parsed.data.note }); } await logActivity(req, 'بررسی مقاله به‌روز شد', article.title, 'review'); return { review: { id: row.id, status: row.status, note: row.note, reviewer: row.reviewer ? publicUser(row.reviewer) : null, article: publicArticle(row.article), createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), } }; }); app.get('/api/v1/admin/newsletter/subscribers', { preHandler: requireAuth(EDITOR_ROLES) }, async () => ({ items: await prisma.newsletterSubscriber.findMany({ orderBy: { createdAt: 'desc' }, take: 1000 }), })); app.get('/api/v1/admin/newsletter/campaigns', { preHandler: requireAuth(EDITOR_ROLES) }, async () => ({ items: (await prisma.newsletterCampaign.findMany({ include: { createdBy: true }, orderBy: { createdAt: 'desc' }, take: 100 })).map(campaignPublic), })); app.post('/api/v1/admin/newsletter/campaigns', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = campaignBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const slug = parsed.data.slug ? slugify(parsed.data.slug) : slugify(parsed.data.subject); const row = await prisma.newsletterCampaign.create({ data: { subject: parsed.data.subject, slug, bodyHtml: parsed.data.bodyHtml, createdById: req.session!.userId }, include: { createdBy: true }, }); await logActivity(req, 'کمپین خبرنامه ایجاد شد', row.subject, 'newsletter'); return { campaign: campaignPublic(row) }; }); app.post<{ Params: { id: string } }>('/api/v1/admin/newsletter/campaigns/:id/send', { preHandler: requireAuth(EDITOR_ROLES) }, async (req, reply) => { const campaign = await prisma.newsletterCampaign.findUnique({ where: { id: req.params.id } }); if (!campaign) return reply.code(404).send({ error: 'not_found' }); if (campaign.sentAt) return reply.code(409).send({ error: 'campaign_already_sent' }); const subscribers = await prisma.newsletterSubscriber.findMany({ orderBy: { createdAt: 'asc' } }); for (const subscriber of subscribers) { const unsubscribe = `https://www.teknav.ir/api/newsletter/unsubscribe?token=${subscriber.unsubscribeToken}`; await sendEmail({ to: subscriber.email, subject: campaign.subject, html: `${campaign.bodyHtml}

برای لغو عضویت: ${unsubscribe}

`, }); } const row = await prisma.newsletterCampaign.update({ where: { id: campaign.id }, data: { sentAt: new Date(), recipientCount: subscribers.length }, include: { createdBy: true }, }); await logActivity(req, 'کمپین خبرنامه ارسال شد', row.subject, 'newsletter'); return { campaign: campaignPublic(row) }; }); app.get('/api/v1/admin/series', { preHandler: requireAuth(ALL_CONTENT_ROLES) }, async () => ({ items: (await prisma.articleSeries.findMany({ include: { articles: { include: { article: { include: articleInclude } }, orderBy: { position: 'asc' } } }, orderBy: { createdAt: 'desc' }, })).map(seriesPublic), })); app.post('/api/v1/admin/series', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = seriesBodySchema.safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const row = await prisma.articleSeries.create({ data: { title: body.title, slug: body.slug ? slugify(body.slug) : slugify(body.title), description: body.description ?? null, coverImage: body.coverImage ?? null, articles: { create: body.articleIds.map((articleId, index) => ({ articleId, position: index + 1 })) }, }, include: { articles: { include: { article: { include: articleInclude } }, orderBy: { position: 'asc' } } }, }); await invalidatePublic(); await logActivity(req, 'سری مقاله ایجاد شد', row.title, 'series'); return { series: seriesPublic(row) }; }); app.patch<{ Params: { id: string }; Body: unknown }>('/api/v1/admin/series/:id', { preHandler: requireAuth(EDITOR_ROLES), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { const parsed = seriesBodySchema.partial().safeParse(req.body); if (!parsed.success) return badInput(reply); const body = parsed.data; const existing = await prisma.articleSeries.findUnique({ where: { id: req.params.id } }); if (!existing) return reply.code(404).send({ error: 'not_found' }); await prisma.articleSeries.update({ where: { id: existing.id }, data: { ...(body.title !== undefined ? { title: body.title } : {}), ...(body.slug !== undefined ? { slug: slugify(body.slug) } : {}), ...(body.description !== undefined ? { description: body.description ?? null } : {}), ...(body.coverImage !== undefined ? { coverImage: body.coverImage ?? null } : {}), }, }); if (body.articleIds) { await prisma.seriesArticle.deleteMany({ where: { seriesId: existing.id } }); await prisma.seriesArticle.createMany({ data: body.articleIds.map((articleId, index) => ({ seriesId: existing.id, articleId, position: index + 1 })) }); } const row = await prisma.articleSeries.findUniqueOrThrow({ where: { id: existing.id }, include: { articles: { include: { article: { include: articleInclude } }, orderBy: { position: 'asc' } } }, }); await invalidatePublic(); await logActivity(req, 'سری مقاله ویرایش شد', row.title, 'series'); return { series: seriesPublic(row) }; }); app.delete<{ Params: { id: string } }>('/api/v1/admin/series/:id', { preHandler: requireAuth(EDITOR_ROLES) }, async (req, reply) => { const row = await prisma.articleSeries.delete({ where: { id: req.params.id } }).catch(() => null); if (!row) return reply.code(404).send({ error: 'not_found' }); await invalidatePublic(); await logActivity(req, 'سری مقاله حذف شد', row.title, 'series'); return { ok: true }; }); // ── Collaborative Editing Presence ────────────────────────────────────────── app.post<{ Params: { id: string } }>('/api/v1/admin/articles/:id/heartbeat', { preHandler: requireAuth(['admin', 'editor', 'writer']) }, async (req, _reply) => { const { id } = req.params; const userId = req.session!.userId; const key = `presence:article:${id}:user:${userId}`; const meta = JSON.stringify({ userId, role: req.session!.role, ts: Date.now() }); await redis.set(key, meta, 'EX', 45); return { ok: true }; }, ); app.get<{ Params: { id: string } }>('/api/v1/admin/articles/:id/presence', { preHandler: requireAuth(['admin', 'editor', 'writer']) }, async (req, _reply) => { const { id } = req.params; const pattern = `presence:article:${id}:user:*`; const keys: string[] = []; let cursor = '0'; do { const [next, batch] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 50) as [string, string[]]; cursor = next; keys.push(...batch); } while (cursor !== '0'); if (!keys.length) return { editors: [] }; const values = await redis.mget(...keys); const raw = values .filter((v): v is string => !!v) .map((v) => { try { return JSON.parse(v); } catch { return null; } }) .filter((v): v is { userId: string; role: string; ts: number } => !!v); if (!raw.length) return { editors: [] }; const userIds = [...new Set(raw.map((r) => r.userId))]; const users = await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true, avatarUrl: true } }); const userMap = new Map(users.map((u) => [u.id, u])); const editors = raw.map((r) => ({ ...r, name: userMap.get(r.userId)?.name ?? '', avatarUrl: userMap.get(r.userId)?.avatarUrl ?? null })); return { editors }; }, ); // ── User Badges ────────────────────────────────────────────────────────────── app.get('/api/v1/auth/badges', { preHandler: requireAuth() }, async (req) => { const rows = await prisma.userBadge.findMany({ where: { userId: req.session!.userId }, orderBy: { earnedAt: 'desc' }, }); return { badges: rows.map((r) => ({ id: r.id, badgeType: r.badgeType, earnedAt: r.earnedAt.toISOString(), metadata: r.metadata })) }; }); app.get<{ Params: { id: string } }>('/api/v1/admin/users/:id/badges', { preHandler: requireAuth(EDITOR_ROLES) }, async (req, reply) => { const user = await prisma.user.findUnique({ where: { id: req.params.id } }); if (!user) return reply.code(404).send({ error: 'not_found' }); const rows = await prisma.userBadge.findMany({ where: { userId: user.id }, orderBy: { earnedAt: 'desc' } }); return { badges: rows.map((r) => ({ id: r.id, badgeType: r.badgeType, earnedAt: r.earnedAt.toISOString(), metadata: r.metadata })) }; }, ); }