diff --git a/app/backend/src/routes/wiki-proposals.ts b/app/backend/src/routes/wiki-proposals.ts new file mode 100644 index 0000000..41588ec --- /dev/null +++ b/app/backend/src/routes/wiki-proposals.ts @@ -0,0 +1,126 @@ +import type { FastifyPluginAsync } from 'fastify'; +import { z } from 'zod'; +import { prisma } from '../db.js'; +import { requireAuth } from '../plugins/session.js'; + +const submitWikiSchema = z.object({ + articleId: z.string(), + title: z.string().min(1).max(200), + summary: z.string().min(1).max(500), + content: z.string().min(1).max(500_000), +}); + +const submitGlossarySchema = z.object({ + term: z.string().min(1).max(100), + definition: z.string().min(1).max(50_000), + sameAs: z.array(z.string().url()).default([]), +}); + +const reviewProposalSchema = z.object({ + status: z.enum(['approved', 'rejected']), + rejectionReason: z.string().optional(), +}); + +export const wikiProposalRoutes: FastifyPluginAsync = async (app) => { + app.addHook('preHandler', requireAuth()); + + // Submit a Wiki Edit Proposal + app.post('/api/v1/wiki/proposals', async (req, reply) => { + const parsed = submitWikiSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input', details: parsed.error.issues }); + + const article = await prisma.article.findUnique({ where: { id: parsed.data.articleId } }); + if (!article) return reply.code(404).send({ error: 'article_not_found' }); + + const proposal = await prisma.wikiProposal.create({ + data: { + articleId: article.id, + title: parsed.data.title, + summary: parsed.data.summary, + content: parsed.data.content, + suggestedById: req.session!.userId, + }, + }); + + return { success: true, proposal }; + }); + + // Submit a Glossary Proposal + app.post('/api/v1/glossary/proposals', async (req, reply) => { + const parsed = submitGlossarySchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input', details: parsed.error.issues }); + + const proposal = await prisma.glossaryProposal.create({ + data: { + term: parsed.data.term, + definition: parsed.data.definition, + sameAs: parsed.data.sameAs, + suggestedById: req.session!.userId, + }, + }); + + return { success: true, proposal }; + }); + + // Admin routes for reviewing proposals + app.register(async (adminApp) => { + adminApp.addHook('preHandler', async (req, reply) => { + if (req.session?.role !== 'admin' && req.session?.role !== 'editor') { + return reply.code(403).send({ error: 'forbidden' }); + } + }); + + adminApp.get('/api/v1/admin/wiki/proposals', async (req) => { + const proposals = await prisma.wikiProposal.findMany({ + where: { status: 'pending' }, + include: { article: { select: { title: true, slug: true } }, suggestedBy: { select: { name: true, username: true } } }, + orderBy: { createdAt: 'desc' }, + }); + return { proposals }; + }); + + adminApp.patch('/api/v1/admin/wiki/proposals/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + const parsed = reviewProposalSchema.safeParse(req.body); + if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' }); + + const proposal = await prisma.wikiProposal.findUnique({ where: { id } }); + if (!proposal) return reply.code(404).send({ error: 'not_found' }); + + const updated = await prisma.wikiProposal.update({ + where: { id }, + data: { + status: parsed.data.status, + rejectionReason: parsed.data.rejectionReason, + reviewedById: req.session!.userId, + }, + }); + + if (parsed.data.status === 'approved') { + // Merge text and record in ArticleRevision + await prisma.articleRevision.create({ + data: { + articleId: proposal.articleId, + savedById: req.session!.userId, + title: proposal.title, + summary: proposal.summary, + content: proposal.content, + status: 'wiki_merge', + }, + }); + + await prisma.article.update({ + where: { id: proposal.articleId }, + data: { + title: proposal.title, + summary: proposal.summary, + content: proposal.content, + dateModified: new Date(), + }, + }); + } + + return { success: true, proposal: updated }; + }); + }); +};