Import source: app/backend/src/routes/history

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:12 +03:30
parent 8bc82aeeb7
commit 7b903072b5
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { prisma } from '../db.js';
import { requireAuth } from '../plugins/session.js';
import { computeStreak, currentStreakIfAlive } from '../lib/persian.js';
const bodySchema = z.object({
articleId: z.string().trim().min(1),
progress: z.number().min(0).max(1).optional(),
});
const listSchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(50).default(20),
});
function badInput(reply: FastifyReply) {
return reply.code(400).send({ error: 'invalid_input' });
}
export default async function historyRoutes(app: FastifyInstance) {
app.post('/api/v1/auth/history', { preHandler: requireAuth() }, async (req, reply) => {
const parsed = bodySchema.safeParse(req.body);
if (!parsed.success) return badInput(reply);
const userId = req.session!.userId;
const { articleId, progress = 0 } = parsed.data;
const article = await prisma.article.findUnique({ where: { id: articleId }, select: { id: true } });
if (!article) return reply.code(404).send({ error: 'not_found' });
await prisma.readHistory.upsert({
where: { userId_articleId: { userId, articleId } },
update: { readAt: new Date(), progress: Math.max(progress, 0) },
create: { userId, articleId, progress },
});
// Streak logic
const user = await prisma.user.findUnique({ where: { id: userId }, select: { streakCount: true, lastReadAt: true } });
if (user) {
const now = new Date();
const newStreak = computeStreak(user.streakCount, user.lastReadAt, now);
if (newStreak !== user.streakCount || !user.lastReadAt ||
new Date(user.lastReadAt).toDateString() !== now.toDateString()) {
await prisma.user.update({
where: { id: userId },
data: { streakCount: newStreak, lastReadAt: now },
});
}
}
return { ok: true };
});
app.get('/api/v1/auth/streaks', { preHandler: requireAuth() }, async (req) => {
const userId = req.session!.userId;
const user = await prisma.user.findUnique({ where: { id: userId }, select: { streakCount: true, lastReadAt: true } });
let currentStreak = currentStreakIfAlive(user?.streakCount ?? 0, user?.lastReadAt ?? null, new Date());
const unfinished = await prisma.readHistory.findMany({
where: { userId, progress: { lt: 0.9, gt: 0.05 } },
include: { article: { select: { id: true, slug: true, title: true, summary: true } } },
orderBy: { readAt: 'desc' },
take: 5,
});
return {
streak: currentStreak,
lastReadAt: user?.lastReadAt,
continueReading: unfinished.map(h => ({
id: h.article.id,
slug: h.article.slug,
title: h.article.title,
progress: h.progress,
})),
};
});
app.get('/api/v1/auth/history', { preHandler: requireAuth() }, async (req, reply) => {
const parsed = listSchema.safeParse(req.query);
if (!parsed.success) return badInput(reply);
const { page, limit } = parsed.data;
const userId = req.session!.userId;
const [total, rows] = await Promise.all([
prisma.readHistory.count({ where: { userId } }),
prisma.readHistory.findMany({
where: { userId },
include: { article: { include: { category: true, author: true } } },
orderBy: { readAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
]);
return {
items: rows.map((row) => ({
id: row.id,
readAt: row.readAt.toISOString(),
time: row.readAt.toLocaleDateString('fa-IR'),
article: {
id: row.article.id,
slug: row.article.slug,
title: row.article.title,
summary: row.article.summary,
categoryName: row.article.category.name,
authorName: row.article.author.name,
readTime: row.article.readTime,
},
})),
total,
page,
limit,
pages: Math.ceil(total / limit),
};
});
app.delete('/api/v1/auth/history', { preHandler: requireAuth() }, async (req) => {
await prisma.readHistory.deleteMany({ where: { userId: req.session!.userId } });
return { ok: true };
});
}