Files
teknav-bak/app/backend/src/routes/admin.ts
T
arsam 0f35fdc492 Import source: app/backend/src/routes/admin
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
2026-09-18 23:19:53 +03:30

1787 lines
78 KiB
TypeScript

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<string, string>)[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<string, string>)[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<typeof articleBodySchema> | Partial<z.infer<typeof articleBodySchema>>) {
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(/<img[^>]+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<ArrayBufferLike> = 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(/<img[^>]+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}<hr><p dir="rtl" style="font-size:12px;color:#667">برای لغو عضویت: <a href="${unsubscribe}">${unsubscribe}</a></p>`,
});
}
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 })) };
},
);
}