Import source: app/backend/src/routes/public
Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
@@ -0,0 +1,802 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../db.js';
|
||||
import { redis } from '../redis.js';
|
||||
import { cached, bust } from '../lib/cache.js';
|
||||
import { requireAuth } from '../plugins/session.js';
|
||||
import { verifyCaptcha } from '../lib/captcha.js';
|
||||
import { publishRealtime } from '../lib/realtime.js';
|
||||
|
||||
const PUBLISHED = 'منتشرشده';
|
||||
const ARTICLE_CACHE_TTL = 30;
|
||||
const LOOKUP_CACHE_TTL = 300;
|
||||
const SEARCH_CACHE_TTL = 30;
|
||||
const MAX_LIMIT = 50;
|
||||
const LEGACY_ARTICLE_SLUGS: Record<string, string> = {
|
||||
'ai-agents-2026': 'agentic-ai-production',
|
||||
'quantum-computing-2024': 'quantum-computing',
|
||||
};
|
||||
|
||||
const articleQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(MAX_LIMIT).default(12),
|
||||
category: z.string().trim().optional(),
|
||||
author: z.string().trim().optional(),
|
||||
tag: z.string().trim().optional(),
|
||||
type: z.string().trim().optional(),
|
||||
q: z.string().trim().max(120).optional(),
|
||||
sort: z.enum(['latest', 'popular', 'readtime', 'personalized']).default('latest'),
|
||||
featured: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().trim().min(1).max(120),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(MAX_LIMIT).default(12),
|
||||
});
|
||||
|
||||
const newsletterSchema = z.object({
|
||||
email: z.string().email().max(254).toLowerCase(),
|
||||
captchaId: z.string().trim().min(1).max(64),
|
||||
userSolution: z.string().trim().min(1).max(20),
|
||||
source: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
const analyticsEventSchema = z.object({
|
||||
type: z.enum(['share_clicked']),
|
||||
articleId: z.string().trim().optional(),
|
||||
slug: z.string().trim().max(240).optional(),
|
||||
topic: z.string().trim().max(120).optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
const reactionSchema = z.object({
|
||||
type: z.string().trim().min(1).max(32),
|
||||
});
|
||||
|
||||
const listResponseSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
items: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
page: { type: 'number' },
|
||||
limit: { type: 'number' },
|
||||
total: { type: 'number' },
|
||||
pages: { type: 'number' },
|
||||
},
|
||||
};
|
||||
|
||||
const simpleListResponseSchema = {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
items: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
||||
},
|
||||
};
|
||||
|
||||
type ArticleWithRelations = Prisma.ArticleGetPayload<{
|
||||
include: {
|
||||
category: true;
|
||||
author: { include: { user: { select: { username: true } } } };
|
||||
tags: { include: { tag: true } };
|
||||
corrections: { include: { editor: { select: { id: true; name: true; username: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
type AuthorWithArticles = Prisma.AuthorGetPayload<{
|
||||
include: {
|
||||
articles: {
|
||||
where: { status: string; publishedAt: { not: null } };
|
||||
orderBy: { publishedAt: 'desc' };
|
||||
take: 1;
|
||||
};
|
||||
_count: { select: { articles: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
function normalizeFa(input: string) {
|
||||
return input
|
||||
.replace(/[\u200c\u0640]/g, '')
|
||||
.replace(/[\u064b-\u0652]/g, '')
|
||||
.replace(/[يى]/g, 'ی')
|
||||
.replace(/ك/g, 'ک')
|
||||
.replace(/[أإآٱ]/g, 'ا')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function articleOrder(sort: z.infer<typeof articleQuerySchema>['sort']) {
|
||||
if (sort === 'popular') return [{ views: 'desc' as const }, { publishedAt: 'desc' as const }];
|
||||
if (sort === 'readtime') return [{ readTime: 'desc' as const }, { publishedAt: 'desc' as const }];
|
||||
return [{ publishedAt: 'desc' as const }, { createdAt: 'desc' as const }];
|
||||
}
|
||||
|
||||
function publicArticle(article: ArticleWithRelations, options: { premium?: boolean } = {}) {
|
||||
const publishedAt = article.publishedAt ?? article.createdAt;
|
||||
const locked = article.premiumOnly && !options.premium;
|
||||
return {
|
||||
id: article.id,
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
subtitle: article.subtitle,
|
||||
summary: article.summary,
|
||||
content: locked ? `<p>${article.summary}</p><div class="premium-lock">این مقاله برای اعضای پریمیوم تکناو است.</div>` : 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,
|
||||
premiumLocked: locked,
|
||||
diagram: article.diagram,
|
||||
metaDescription: article.metaDescription,
|
||||
keywords: article.keywords,
|
||||
ogTitle: article.ogTitle,
|
||||
ogDescription: article.ogDescription,
|
||||
ogImage: article.ogImage,
|
||||
canonicalPath: article.canonicalPath ?? `/article/${article.slug}`,
|
||||
correctionNotice: article.correctionNotice,
|
||||
dateModified: article.dateModified?.toISOString().slice(0, 10) ?? article.updatedAt.toISOString().slice(0, 10),
|
||||
category: article.category.slug,
|
||||
categoryName: article.category.name,
|
||||
categorySlug: article.category.slug,
|
||||
categoryColor: article.category.color,
|
||||
authorId: article.author.id,
|
||||
authorSlug: article.author.slug,
|
||||
authorName: article.author.name,
|
||||
authorUsername: article.author.user?.username ?? null,
|
||||
date: publishedAt.toLocaleDateString('fa-IR'),
|
||||
dateEn: publishedAt.toISOString().slice(0, 10),
|
||||
tags: article.tags.map((t) => t.tag.name),
|
||||
author: publicAuthor(article.author),
|
||||
categoryInfo: publicCategory(article.category),
|
||||
factCheckedAt: article.factCheckedAt?.toISOString() ?? null,
|
||||
reviewedById: article.reviewedById,
|
||||
sourceNotes: article.sourceNotes,
|
||||
contentFreshnessStatus: article.contentFreshnessStatus,
|
||||
corrections: article.corrections?.map((correction) => ({
|
||||
id: correction.id,
|
||||
note: correction.note,
|
||||
createdAt: correction.createdAt.toISOString(),
|
||||
date: correction.createdAt.toLocaleDateString('fa-IR'),
|
||||
editor: correction.editor ? { id: correction.editor.id, name: correction.editor.name, username: correction.editor.username } : 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 publicAuthor(author: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
specialty: string;
|
||||
bio: string;
|
||||
initials: string;
|
||||
color: string;
|
||||
social: Prisma.JsonValue;
|
||||
articleCount: number;
|
||||
verifiedExpert?: boolean;
|
||||
verificationNote?: string | null;
|
||||
user?: { username: string | null } | null;
|
||||
}) {
|
||||
return {
|
||||
id: author.id,
|
||||
slug: author.slug,
|
||||
name: author.name,
|
||||
specialty: author.specialty,
|
||||
bio: author.bio,
|
||||
initials: author.initials,
|
||||
color: author.color,
|
||||
social: author.social,
|
||||
articleCount: author.articleCount,
|
||||
verifiedExpert: !!author.verifiedExpert,
|
||||
verificationNote: author.verificationNote ?? null,
|
||||
username: author.user?.username ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function publicNewsletterCampaign(row: {
|
||||
id: string;
|
||||
subject: string;
|
||||
slug: string | null;
|
||||
bodyHtml: string;
|
||||
sentAt: Date | null;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
subject: row.subject,
|
||||
slug: row.slug ?? row.id,
|
||||
bodyHtml: row.bodyHtml,
|
||||
sentAt: row.sentAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function articleInclude() {
|
||||
return {
|
||||
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 } },
|
||||
} satisfies Prisma.ArticleInclude;
|
||||
}
|
||||
|
||||
function badInput(reply: FastifyReply) {
|
||||
return reply.code(400).send({ error: 'invalid_input' });
|
||||
}
|
||||
|
||||
async function flushViewCounters() {
|
||||
let cursor = '0';
|
||||
do {
|
||||
const [next, keys] = await redis.scan(cursor, 'MATCH', 'views:article:*', 'COUNT', 100);
|
||||
cursor = next;
|
||||
for (const key of keys) {
|
||||
const articleId = key.slice('views:article:'.length);
|
||||
const raw = await redis.getset(key, '0');
|
||||
const amount = Number(raw ?? 0);
|
||||
if (amount > 0) {
|
||||
const updated = await prisma.article.update({
|
||||
where: { id: articleId },
|
||||
data: { views: { increment: amount } },
|
||||
select: { id: true, slug: true, views: true },
|
||||
}).catch(() => undefined);
|
||||
if (updated) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
await prisma.articleViewLog.upsert({
|
||||
where: { articleId_date: { articleId, date: today } },
|
||||
update: { views: { increment: amount } },
|
||||
create: { articleId, date: today, views: amount },
|
||||
}).catch(() => undefined);
|
||||
await publishRealtime({ event: 'view_update', data: { articleId: updated.id, slug: updated.slug, views: updated.views } }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
await redis.del(key);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
}
|
||||
|
||||
export default async function publicRoutes(app: FastifyInstance) {
|
||||
const include = await articleInclude();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
flushViewCounters().catch((err) => app.log.warn({ err }, 'failed to flush article view counters'));
|
||||
}, 60_000);
|
||||
app.addHook('onClose', async () => {
|
||||
clearInterval(interval);
|
||||
await flushViewCounters();
|
||||
});
|
||||
|
||||
app.get('/api/v1/articles', {
|
||||
schema: {
|
||||
querystring: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
page: { type: 'number' },
|
||||
limit: { type: 'number' },
|
||||
category: { type: 'string' },
|
||||
author: { type: 'string' },
|
||||
tag: { type: 'string' },
|
||||
type: { type: 'string' },
|
||||
q: { type: 'string' },
|
||||
sort: { type: 'string', enum: ['latest', 'popular', 'readtime', 'personalized'] },
|
||||
featured: { type: 'boolean' },
|
||||
},
|
||||
},
|
||||
response: { 200: listResponseSchema },
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const parsed = articleQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return badInput(reply);
|
||||
const query = parsed.data;
|
||||
const userId = req.session?.userId;
|
||||
const cacheKey = `articles:${normalizeFa(JSON.stringify(query))}:${userId ?? 'anon'}`;
|
||||
|
||||
return cached(cacheKey, ARTICLE_CACHE_TTL, async () => {
|
||||
let where: Prisma.ArticleWhereInput = {
|
||||
status: PUBLISHED,
|
||||
publishedAt: { not: null },
|
||||
deletedAt: null,
|
||||
...(query.featured !== undefined ? { featured: query.featured } : {}),
|
||||
...(query.type ? { type: query.type } : {}),
|
||||
...(query.category ? { category: { slug: query.category } } : {}),
|
||||
...(query.author ? { author: { slug: query.author } } : {}),
|
||||
...(query.tag ? { tags: { some: { tag: { name: { equals: query.tag, mode: 'insensitive' } } } } } : {}),
|
||||
};
|
||||
|
||||
if (query.q) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.q, mode: 'insensitive' } },
|
||||
{ summary: { contains: query.q, mode: 'insensitive' } },
|
||||
{ subtitle: { contains: query.q, mode: 'insensitive' } },
|
||||
{ author: { name: { contains: query.q, mode: 'insensitive' } } },
|
||||
{ category: { name: { contains: query.q, mode: 'insensitive' } } },
|
||||
{ tags: { some: { tag: { name: { contains: query.q, mode: 'insensitive' } } } } },
|
||||
];
|
||||
}
|
||||
|
||||
// Personalization logic
|
||||
if (query.sort === 'personalized' && userId) {
|
||||
const [followedTopics, followedWriters, readHistory] = await Promise.all([
|
||||
prisma.topicFollow.findMany({ where: { userId }, select: { topic: true } }),
|
||||
prisma.writerFollow.findMany({ where: { followerId: userId }, select: { writerId: true } }),
|
||||
prisma.readHistory.findMany({ where: { userId }, select: { articleId: true } }),
|
||||
]);
|
||||
|
||||
const topicSlugs = followedTopics.map(t => t.topic);
|
||||
const writerIds = followedWriters.map(w => w.writerId);
|
||||
const readIds = readHistory.map(h => h.articleId);
|
||||
|
||||
where = {
|
||||
...where,
|
||||
id: { notIn: readIds },
|
||||
OR: [
|
||||
...(topicSlugs.length ? [{ category: { slug: { in: topicSlugs } } }] : []),
|
||||
...(writerIds.length ? [{ author: { user: { id: { in: writerIds } } } }] : []),
|
||||
...(topicSlugs.length === 0 && writerIds.length === 0 ? [{ featured: true }] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
prisma.article.count({ where }),
|
||||
prisma.article.findMany({
|
||||
where,
|
||||
include,
|
||||
orderBy: query.sort === 'personalized' ? [{ publishedAt: 'desc' }] : articleOrder(query.sort),
|
||||
skip: (query.page - 1) * query.limit,
|
||||
take: query.limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
let items = rows.map((article) => publicArticle(article));
|
||||
if (query.sort === 'personalized' && items.length < query.limit) {
|
||||
const readIds = userId ? (await prisma.readHistory.findMany({ where: { userId }, select: { articleId: true } })).map(h => h.articleId) : [];
|
||||
const fill = await prisma.article.findMany({
|
||||
where: {
|
||||
status: PUBLISHED,
|
||||
publishedAt: { not: null },
|
||||
deletedAt: null,
|
||||
id: { notIn: [...items.map(i => i.id), ...readIds] }
|
||||
},
|
||||
include,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: query.limit - items.length,
|
||||
});
|
||||
items = [...items, ...fill.map((article) => publicArticle(article))];
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total: query.sort === 'personalized' ? items.length : total,
|
||||
pages: Math.ceil(total / query.limit),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/v1/articles/:slug', {
|
||||
schema: {
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: {
|
||||
200: { type: 'object', additionalProperties: true },
|
||||
404: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
}, async (req: FastifyRequest<{ Params: { slug: string } }>, reply) => {
|
||||
const canonicalSlug = LEGACY_ARTICLE_SLUGS[req.params.slug] ?? req.params.slug;
|
||||
const article = await prisma.article.findFirst({
|
||||
where: { slug: canonicalSlug, status: PUBLISHED, publishedAt: { not: null }, deletedAt: null },
|
||||
include,
|
||||
});
|
||||
if (!article) return reply.code(404).send({ error: 'not_found' });
|
||||
|
||||
await redis.incr(`views:article:${article.id}`);
|
||||
await prisma.analyticsEvent.create({
|
||||
data: {
|
||||
type: 'article_viewed',
|
||||
userId: req.session?.userId ?? null,
|
||||
articleId: article.id,
|
||||
topic: article.category.slug,
|
||||
metadata: { slug: article.slug },
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
|
||||
const [related, saved, reactions, viewer, seriesLink] = await Promise.all([
|
||||
prisma.article.findMany({
|
||||
where: {
|
||||
status: PUBLISHED,
|
||||
publishedAt: { not: null },
|
||||
deletedAt: null,
|
||||
categoryId: article.categoryId,
|
||||
id: { not: article.id },
|
||||
},
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }],
|
||||
take: 3,
|
||||
}),
|
||||
req.session
|
||||
? prisma.savedArticle.findUnique({ where: { userId_articleId: { userId: req.session.userId, articleId: article.id } } })
|
||||
: Promise.resolve(null),
|
||||
req.session
|
||||
? prisma.reaction.findMany({ where: { userId: req.session.userId, articleId: article.id }, select: { type: true } })
|
||||
: Promise.resolve([]),
|
||||
req.session
|
||||
? prisma.user.findUnique({ where: { id: req.session.userId }, select: { membershipTier: true, membershipExpiresAt: true } })
|
||||
: Promise.resolve(null),
|
||||
prisma.seriesArticle.findFirst({
|
||||
where: { articleId: article.id },
|
||||
include: {
|
||||
series: {
|
||||
include: {
|
||||
articles: {
|
||||
include: { article: { select: { id: true, slug: true, title: true } } },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const premium = viewer?.membershipTier === 'premium' && (!viewer.membershipExpiresAt || viewer.membershipExpiresAt > new Date());
|
||||
const series = seriesLink ? {
|
||||
id: seriesLink.series.id,
|
||||
title: seriesLink.series.title,
|
||||
slug: seriesLink.series.slug,
|
||||
position: seriesLink.position,
|
||||
articles: seriesLink.series.articles.map((sa) => ({
|
||||
position: sa.position,
|
||||
slug: sa.article.slug,
|
||||
title: sa.article.title,
|
||||
})),
|
||||
} : null;
|
||||
|
||||
return {
|
||||
article: { ...publicArticle(article, { premium }), series },
|
||||
related: related.map((item) => publicArticle(item)),
|
||||
saved: !!saved,
|
||||
reactions: Object.fromEntries(reactions.map((r) => [r.type, true])),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/v1/articles/:slug/related', async (req: FastifyRequest<{ Params: { slug: string } }>, reply) => {
|
||||
const canonicalSlug = LEGACY_ARTICLE_SLUGS[req.params.slug] ?? req.params.slug;
|
||||
const article = await prisma.article.findFirst({
|
||||
where: { slug: canonicalSlug, status: PUBLISHED, publishedAt: { not: null }, deletedAt: null },
|
||||
include: { tags: true },
|
||||
});
|
||||
if (!article) return reply.code(404).send({ error: 'not_found' });
|
||||
const cacheKey = `related:${article.id}`;
|
||||
const cachedRelated = await redis.get(cacheKey).catch(() => null);
|
||||
if (cachedRelated) return JSON.parse(cachedRelated);
|
||||
const tagIds = article.tags.map((t) => t.tagId);
|
||||
const candidates = tagIds.length
|
||||
? await prisma.article.findMany({
|
||||
where: {
|
||||
id: { not: article.id },
|
||||
status: PUBLISHED,
|
||||
publishedAt: { not: null },
|
||||
deletedAt: null,
|
||||
tags: { some: { tagId: { in: tagIds } } },
|
||||
},
|
||||
include,
|
||||
take: 12,
|
||||
})
|
||||
: [];
|
||||
const scored = candidates
|
||||
.map((row) => ({ row, overlap: row.tags.filter((t) => tagIds.includes(t.tagId)).length }))
|
||||
.sort((a, b) => b.overlap - a.overlap || b.row.views - a.row.views)
|
||||
.slice(0, 3)
|
||||
.map(({ row }) => publicArticle(row));
|
||||
const payload = { items: scored };
|
||||
await redis.set(cacheKey, JSON.stringify(payload), 'EX', 60 * 60).catch(() => undefined);
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.get('/api/v1/categories', {
|
||||
schema: { response: { 200: simpleListResponseSchema } },
|
||||
}, async () => cached('categories:all', LOOKUP_CACHE_TTL, async () => ({
|
||||
items: (await prisma.category.findMany({ orderBy: { name: 'asc' } })).map(publicCategory),
|
||||
})));
|
||||
|
||||
app.get('/api/v1/categories/:slug', {
|
||||
schema: {
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 200: { type: 'object', additionalProperties: true }, 404: { type: 'object', properties: { error: { type: 'string' } } } },
|
||||
},
|
||||
}, async (req: FastifyRequest<{ Params: { slug: string } }>, reply) => {
|
||||
const category = await prisma.category.findUnique({ where: { slug: req.params.slug } });
|
||||
if (!category) return reply.code(404).send({ error: 'not_found' });
|
||||
return { category: publicCategory(category) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/tags', {
|
||||
schema: { response: { 200: simpleListResponseSchema } },
|
||||
}, async () => cached('tags:all', LOOKUP_CACHE_TTL, async () => ({
|
||||
items: await prisma.tag.findMany({ orderBy: [{ count: 'desc' }, { name: 'asc' }] }),
|
||||
})));
|
||||
|
||||
app.get('/api/v1/authors', {
|
||||
schema: { response: { 200: simpleListResponseSchema } },
|
||||
}, async () => cached('authors:all', LOOKUP_CACHE_TTL, async () => {
|
||||
const authors = await prisma.author.findMany({
|
||||
include: {
|
||||
articles: {
|
||||
where: { status: PUBLISHED, publishedAt: { not: null }, deletedAt: null },
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
_count: { select: { articles: true } },
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { items: authors.map((a: AuthorWithArticles) => ({ ...publicAuthor(a), liveArticleCount: a._count?.articles ?? a.articleCount, latestArticle: a.articles[0]?.title ?? null })) };
|
||||
}));
|
||||
|
||||
app.get('/api/v1/authors/:slug', {
|
||||
schema: {
|
||||
params: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' } } },
|
||||
response: { 200: { type: 'object', additionalProperties: true }, 404: { type: 'object', properties: { error: { type: 'string' } } } },
|
||||
},
|
||||
}, async (req: FastifyRequest<{ Params: { slug: string } }>, reply) => {
|
||||
const author = await prisma.author.findUnique({
|
||||
where: { slug: req.params.slug },
|
||||
include: { _count: { select: { articles: true } } },
|
||||
});
|
||||
if (!author) return reply.code(404).send({ error: 'not_found' });
|
||||
return { author: { ...publicAuthor(author), liveArticleCount: author._count?.articles ?? author.articleCount } };
|
||||
});
|
||||
|
||||
app.get('/api/v1/series', async () => {
|
||||
const rows = await prisma.articleSeries.findMany({
|
||||
include: { articles: { include: { article: { include } }, orderBy: { position: 'asc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return {
|
||||
items: rows.map((series) => ({
|
||||
id: series.id,
|
||||
title: series.title,
|
||||
slug: series.slug,
|
||||
description: series.description,
|
||||
coverImage: series.coverImage,
|
||||
articleCount: series.articles.length,
|
||||
articles: series.articles.map((item) => ({ position: item.position, article: publicArticle(item.article) })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/v1/series/:slug', async (req: FastifyRequest<{ Params: { slug: string } }>, reply) => {
|
||||
const series = await prisma.articleSeries.findUnique({
|
||||
where: { slug: req.params.slug },
|
||||
include: { articles: { include: { article: { include } }, orderBy: { position: 'asc' } } },
|
||||
});
|
||||
if (!series) return reply.code(404).send({ error: 'not_found' });
|
||||
return {
|
||||
series: {
|
||||
id: series.id,
|
||||
title: series.title,
|
||||
slug: series.slug,
|
||||
description: series.description,
|
||||
coverImage: series.coverImage,
|
||||
articles: series.articles.map((item) => ({ position: item.position, article: publicArticle(item.article) })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.get('/api/v1/newsletter/archive', async () => {
|
||||
const rows = await prisma.newsletterCampaign.findMany({
|
||||
where: { sentAt: { not: null } },
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return { items: rows.map(publicNewsletterCampaign) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/newsletter/archive/:slug', async (req: FastifyRequest<{ Params: { slug: string } }>, reply) => {
|
||||
const row = await prisma.newsletterCampaign.findFirst({
|
||||
where: { OR: [{ slug: req.params.slug }, { id: req.params.slug }], sentAt: { not: null } },
|
||||
});
|
||||
if (!row) return reply.code(404).send({ error: 'not_found' });
|
||||
return { campaign: publicNewsletterCampaign(row) };
|
||||
});
|
||||
|
||||
app.get('/api/v1/search', {
|
||||
schema: {
|
||||
querystring: { type: 'object', required: ['q'], properties: { q: { type: 'string' }, page: { type: 'number' }, limit: { type: 'number' } } },
|
||||
response: { 200: listResponseSchema },
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const parsed = searchQuerySchema.safeParse(req.query);
|
||||
if (!parsed.success) return badInput(reply);
|
||||
const { q, page, limit } = parsed.data;
|
||||
const normalized = normalizeFa(q);
|
||||
return cached(`search:${normalized}:${page}:${limit}`, SEARCH_CACHE_TTL, async () => {
|
||||
const offset = (page - 1) * limit;
|
||||
const ids = await prisma.$queryRaw<{ id: string }[]>`
|
||||
SELECT id
|
||||
FROM "Article"
|
||||
WHERE status = ${PUBLISHED}
|
||||
AND "publishedAt" IS NOT NULL
|
||||
AND "deletedAt" IS NULL
|
||||
AND search_tsv @@ plainto_tsquery('simple', teknav_norm_fa(${q}))
|
||||
ORDER BY ts_rank(search_tsv, plainto_tsquery('simple', teknav_norm_fa(${q}))) DESC,
|
||||
"publishedAt" DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
const count = await prisma.$queryRaw<{ count: bigint }[]>`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM "Article"
|
||||
WHERE status = ${PUBLISHED}
|
||||
AND "publishedAt" IS NOT NULL
|
||||
AND "deletedAt" IS NULL
|
||||
AND search_tsv @@ plainto_tsquery('simple', teknav_norm_fa(${q}))
|
||||
`;
|
||||
const order = new Map(ids.map((row, index) => [row.id, index]));
|
||||
const rows = ids.length
|
||||
? await prisma.article.findMany({ where: { id: { in: ids.map((i) => i.id) } }, include })
|
||||
: [];
|
||||
rows.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
const total = Number(count[0]?.count ?? 0);
|
||||
return { items: rows.map((article) => publicArticle(article)), page, limit, total, pages: Math.ceil(total / limit) };
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/v1/newsletter', {
|
||||
schema: {
|
||||
body: { type: 'object', required: ['email', 'captchaId', 'userSolution'], properties: { email: { type: 'string' }, captchaId: { type: 'string' }, userSolution: { type: 'string' } } },
|
||||
response: {
|
||||
200: { type: 'object', properties: { ok: { type: 'boolean' }, alreadySubscribed: { type: 'boolean' } } },
|
||||
400: { type: 'object', properties: { error: { type: 'string' } } },
|
||||
429: { type: 'object', properties: { error: { type: 'string' }, retryAfter: { type: 'number' } } },
|
||||
},
|
||||
},
|
||||
}, async (req, reply) => {
|
||||
const parsed = newsletterSchema.safeParse(req.body);
|
||||
if (!parsed.success) return badInput(reply);
|
||||
|
||||
// Verify captcha first
|
||||
const captchaValid = await verifyCaptcha(parsed.data.captchaId, parsed.data.userSolution);
|
||||
if (!captchaValid) {
|
||||
return reply.code(400).send({ error: 'invalid_captcha' });
|
||||
}
|
||||
|
||||
const key = `newsletter:${req.ip}`;
|
||||
const hits = await redis.incr(key);
|
||||
if (hits === 1) await redis.expire(key, 60 * 60);
|
||||
if (hits > 3) {
|
||||
const ttl = await redis.ttl(key);
|
||||
reply.header('Retry-After', String(Math.max(ttl, 1)));
|
||||
return reply.code(429).send({ error: 'too_many_requests', retryAfter: ttl });
|
||||
}
|
||||
const existing = await prisma.newsletterSubscriber.findUnique({ where: { email: parsed.data.email } });
|
||||
if (existing) return { ok: true, alreadySubscribed: true };
|
||||
await prisma.newsletterSubscriber.create({ data: { email: parsed.data.email, source: parsed.data.source ?? 'site' } });
|
||||
await prisma.analyticsEvent.create({
|
||||
data: { type: 'newsletter_subscribed', topic: parsed.data.source ?? null, metadata: { source: parsed.data.source ?? 'site' } },
|
||||
}).catch(() => undefined);
|
||||
return { ok: true, alreadySubscribed: false };
|
||||
});
|
||||
|
||||
app.post('/api/v1/analytics/events', {
|
||||
schema: { body: { type: 'object', additionalProperties: true } },
|
||||
}, async (req, reply) => {
|
||||
const parsed = analyticsEventSchema.safeParse(req.body);
|
||||
if (!parsed.success) return badInput(reply);
|
||||
const article = parsed.data.articleId
|
||||
? await prisma.article.findUnique({ where: { id: parsed.data.articleId }, select: { id: true, slug: true, category: { select: { slug: true } } } })
|
||||
: parsed.data.slug
|
||||
? await prisma.article.findUnique({ where: { slug: parsed.data.slug }, select: { id: true, slug: true, category: { select: { slug: true } } } })
|
||||
: null;
|
||||
await prisma.analyticsEvent.create({
|
||||
data: {
|
||||
type: parsed.data.type,
|
||||
userId: req.session?.userId ?? null,
|
||||
articleId: article?.id ?? null,
|
||||
topic: parsed.data.topic ?? article?.category.slug ?? null,
|
||||
metadata: { ...(parsed.data.metadata ?? {}), slug: article?.slug ?? parsed.data.slug ?? null },
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/v1/newsletter/unsubscribe', async (req, reply) => {
|
||||
const token = String((req.query as any)?.token ?? '');
|
||||
if (!token) return reply.code(400).type('text/plain; charset=utf-8').send('invalid token');
|
||||
await prisma.newsletterSubscriber.delete({ where: { unsubscribeToken: token } }).catch(() => null);
|
||||
return reply.type('text/plain; charset=utf-8').send('اشتراک خبرنامه حذف شد.');
|
||||
});
|
||||
|
||||
app.post('/api/v1/articles/:id/reactions', {
|
||||
preHandler: requireAuth(),
|
||||
schema: {
|
||||
params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
|
||||
body: { type: 'object', required: ['type'], properties: { type: { type: 'string' } } },
|
||||
response: { 200: { type: 'object', additionalProperties: true } },
|
||||
},
|
||||
}, async (req: FastifyRequest<{ Params: { id: string }; Body: unknown }>, reply) => {
|
||||
const parsed = reactionSchema.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 key = { userId_articleId_type: { userId: req.session!.userId, articleId: article.id, type: parsed.data.type } };
|
||||
const existing = await prisma.reaction.findUnique({ where: key });
|
||||
if (existing) {
|
||||
await prisma.reaction.delete({ where: key });
|
||||
} else {
|
||||
await prisma.reaction.create({ data: { userId: req.session!.userId, articleId: article.id, type: parsed.data.type } });
|
||||
}
|
||||
const total = await prisma.reaction.count({ where: { articleId: article.id } });
|
||||
await prisma.article.update({ where: { id: article.id }, data: { reactions: total } });
|
||||
const actor = await prisma.user.findUnique({ where: { id: req.session!.userId }, select: { username: true, name: true } });
|
||||
await prisma.activityLog.create({
|
||||
data: { userId: req.session!.userId, action: existing ? 'واکنش حذف شد' : 'واکنش ثبت شد', target: article.title, type: 'reaction' },
|
||||
});
|
||||
await publishRealtime({
|
||||
event: 'activity',
|
||||
data: { type: 'reaction', actor: actor?.username ?? actor?.name ?? 'user', target: article.slug, ts: new Date().toISOString() },
|
||||
});
|
||||
await bust('articles:*');
|
||||
return { ok: true, active: !existing, reactions: total };
|
||||
});
|
||||
|
||||
app.post('/api/v1/articles/:id/save', {
|
||||
preHandler: requireAuth(),
|
||||
schema: {
|
||||
params: { type: 'object', required: ['id'], properties: { id: { type: 'string' } } },
|
||||
response: { 200: { type: 'object', properties: { ok: { type: 'boolean' }, saved: { type: 'boolean' } } } },
|
||||
},
|
||||
}, 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' });
|
||||
const key = { userId_articleId: { userId: req.session!.userId, articleId: article.id } };
|
||||
const existing = await prisma.savedArticle.findUnique({ where: key });
|
||||
if (existing) {
|
||||
await prisma.savedArticle.delete({ where: key });
|
||||
const list = await prisma.readingList.findFirst({ where: { userId: req.session!.userId, isDefault: true } });
|
||||
if (list) await prisma.readingListItem.deleteMany({ where: { listId: list.id, articleId: article.id } });
|
||||
return { ok: true, saved: false };
|
||||
}
|
||||
await prisma.savedArticle.create({ data: { userId: req.session!.userId, articleId: article.id } });
|
||||
await prisma.analyticsEvent.create({
|
||||
data: { type: 'article_saved', userId: req.session!.userId, articleId: article.id, metadata: { slug: article.slug } },
|
||||
}).catch(() => undefined);
|
||||
const list = await prisma.readingList.upsert({
|
||||
where: { userId_name: { userId: req.session!.userId, name: 'بعداً بخوانم' } },
|
||||
update: { isDefault: true },
|
||||
create: { userId: req.session!.userId, name: 'بعداً بخوانم', isDefault: true },
|
||||
});
|
||||
const nextPosition = await prisma.readingListItem.count({ where: { listId: list.id } });
|
||||
await prisma.readingListItem.upsert({
|
||||
where: { listId_articleId: { listId: list.id, articleId: article.id } },
|
||||
update: {},
|
||||
create: { listId: list.id, articleId: article.id, position: nextPosition + 1 },
|
||||
});
|
||||
return { ok: true, saved: true };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user