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:
arsam
2026-09-18 23:20:31 +03:30
parent 6b07367a09
commit 7626977ab2
+802
View File
@@ -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 };
});
}