From 43749ad8b1699cc93387ee68b137d8b1b9f2f76f Mon Sep 17 00:00:00 2001 From: arsam Date: Fri, 18 Sep 2026 23:20:14 +0330 Subject: [PATCH] Import source: app/backend/src/routes/lists Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality. --- app/backend/src/routes/lists.ts | 156 ++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 app/backend/src/routes/lists.ts diff --git a/app/backend/src/routes/lists.ts b/app/backend/src/routes/lists.ts new file mode 100644 index 0000000..1a60814 --- /dev/null +++ b/app/backend/src/routes/lists.ts @@ -0,0 +1,156 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../db.js'; +import { requireAuth } from '../plugins/session.js'; + +const listBodySchema = z.object({ + name: z.string().trim().min(2).max(80), +}); + +const itemBodySchema = z.object({ + articleId: z.string().trim().min(1), +}); + +const reorderBodySchema = z.object({ + itemIds: z.array(z.string().trim().min(1)).max(200), +}); + +function faDate(date: Date) { + return date.toLocaleDateString('fa-IR'); +} + +function publicArticle(article: any) { + const publishedAt = article.publishedAt ?? article.createdAt; + return { + id: article.id, + slug: article.slug, + title: article.title, + summary: article.summary, + type: article.type, + status: article.status, + readTime: article.readTime, + views: article.views, + category: article.category?.slug, + categoryName: article.category?.name, + 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: any) => t.tag.name) ?? [], + }; +} + +function publicList(row: any) { + return { + id: row.id, + name: row.name, + isDefault: row.isDefault, + createdAt: row.createdAt.toISOString(), + items: (row.items ?? []).map((item: any) => ({ + id: item.id, + position: item.position, + addedAt: item.addedAt.toISOString(), + article: publicArticle(item.article), + })), + }; +} + +async function loadList(id: string, userId: string) { + return prisma.readingList.findFirst({ + where: { id, userId }, + include: { + items: { + include: { + article: { + include: { + category: true, + author: { include: { user: { select: { username: true } } } }, + tags: { include: { tag: true } }, + }, + }, + }, + orderBy: [{ position: 'asc' }, { addedAt: 'asc' }], + }, + }, + }); +} + +export default async function listRoutes(app: FastifyInstance) { + app.get('/api/v1/auth/lists', { preHandler: requireAuth() }, async (req) => { + const rows = await prisma.readingList.findMany({ + where: { userId: req.session!.userId }, + include: { + items: { + include: { + article: { + include: { + category: true, + author: { include: { user: { select: { username: true } } } }, + tags: { include: { tag: true } }, + }, + }, + }, + orderBy: [{ position: 'asc' }, { addedAt: 'asc' }], + }, + }, + orderBy: [{ isDefault: 'desc' }, { createdAt: 'asc' }], + }); + return { items: rows.map(publicList) }; + }); + + app.post('/api/v1/auth/lists', { preHandler: requireAuth(), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { + const parsed = listBodySchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + const row = await prisma.readingList.create({ + data: { userId: req.session!.userId, name: parsed.data.name }, + }).catch(() => null); + if (!row) return reply.code(409).send({ error: 'list_exists' }); + return { list: publicList({ ...row, items: [] }) }; + }); + + app.delete<{ Params: { id: string } }>('/api/v1/auth/lists/:id', { preHandler: requireAuth() }, async (req, reply) => { + const row = await prisma.readingList.findFirst({ where: { id: req.params.id, userId: req.session!.userId } }); + if (!row) return reply.code(404).send({ error: 'not_found' }); + if (row.isDefault) return reply.code(409).send({ error: 'default_list_locked' }); + await prisma.readingList.delete({ where: { id: row.id } }); + return { ok: true }; + }); + + app.post<{ Params: { id: string }; Body: unknown }>('/api/v1/auth/lists/:id/items', { preHandler: requireAuth(), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { + const parsed = itemBodySchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + const list = await prisma.readingList.findFirst({ where: { id: req.params.id, userId: req.session!.userId } }); + if (!list) return reply.code(404).send({ error: 'not_found' }); + const article = await prisma.article.findUnique({ where: { id: parsed.data.articleId } }); + if (!article) return reply.code(404).send({ error: 'article_not_found' }); + 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 }, + }); + const fresh = await loadList(list.id, req.session!.userId); + return { list: publicList(fresh) }; + }); + + app.delete<{ Params: { id: string; itemId: string } }>('/api/v1/auth/lists/:id/items/:itemId', { preHandler: requireAuth() }, async (req, reply) => { + const list = await prisma.readingList.findFirst({ where: { id: req.params.id, userId: req.session!.userId } }); + if (!list) return reply.code(404).send({ error: 'not_found' }); + await prisma.readingListItem.deleteMany({ where: { id: req.params.itemId, listId: list.id } }); + return { ok: true }; + }); + + app.put<{ Params: { id: string }; Body: unknown }>('/api/v1/auth/lists/:id/reorder', { preHandler: requireAuth(), schema: { body: { type: 'object', additionalProperties: true } } }, async (req, reply) => { + const parsed = reorderBodySchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + const list = await prisma.readingList.findFirst({ where: { id: req.params.id, userId: req.session!.userId } }); + if (!list) return reply.code(404).send({ error: 'not_found' }); + await prisma.$transaction(parsed.data.itemIds.map((id, index) => ( + prisma.readingListItem.updateMany({ where: { id, listId: list.id }, data: { position: index + 1 } }) + ))); + const fresh = await loadList(list.id, req.session!.userId); + return { list: publicList(fresh) }; + }); +}