diff --git a/app/backend/src/routes/history.ts b/app/backend/src/routes/history.ts new file mode 100644 index 0000000..92a6914 --- /dev/null +++ b/app/backend/src/routes/history.ts @@ -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 }; + }); +}