import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { hasErrorLogViewAccessToken } from '../services/error-log-service.js'; import { BoardPostAutomationLockedError, boardPostPayloadSchema, createBoardPost, deleteBoardPost, ensureBoardPostsTable, getBoardPost, listBoardPosts, receiveBoardPostAutomation, updateBoardPost, } from '../services/board-service.js'; export async function registerBoardRoutes(app: FastifyInstance) { function isLoopbackAddress(value: string | null | undefined) { const normalizedValue = String(value ?? '').trim(); return normalizedValue === '127.0.0.1' || normalizedValue === '::1' || normalizedValue === '::ffff:127.0.0.1'; } function hasBoardAutomationAccess(request: { headers: Record; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }) { if (hasErrorLogViewAccessToken(request.headers['x-access-token'] as string | string[] | undefined)) { return true; } return isLoopbackAddress(request.ip) || isLoopbackAddress(request.raw?.socket?.remoteAddress) || isLoopbackAddress(request.socket?.remoteAddress); } function requireBoardAutomationAccess( request: { headers: Record; ip?: string; socket?: { remoteAddress?: string | null }; raw?: { socket?: { remoteAddress?: string | null } } }, reply: Parameters[1] extends (request: any, reply: infer T) => any ? T : any, ) { if (hasBoardAutomationAccess(request)) { return true; } void reply.code(403).send({ message: '권한 토큰이 등록된 사용자만 자동화 접수를 할 수 있습니다.', }); return false; } async function respondWithBoardSetup() { await ensureBoardPostsTable(); return { ok: true, table: 'board_posts', }; } async function respondWithBoardPosts() { const items = await listBoardPosts(); return { ok: true, items, }; } app.get('/api/board/setup', async () => respondWithBoardSetup()); app.post('/api/board/setup', async () => { return respondWithBoardSetup(); }); app.get('/api/board/posts', async () => respondWithBoardPosts()); app.get('/api/board/items', async () => respondWithBoardPosts()); app.get('/api/board/posts/:id', async (request, reply) => { const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); const item = await getBoardPost(id); if (!item) { return reply.code(404).send({ message: '게시글을 찾을 수 없습니다.', }); } return { ok: true, item, }; }); app.post('/api/board/posts', async (request) => { const item = await createBoardPost(boardPostPayloadSchema.parse(request.body ?? {})); return { ok: true, item, }; }); app.post('/api/board/posts/:id/actions/automation-receive', async (request, reply) => { if (!requireBoardAutomationAccess(request, reply)) { return; } const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); const result = await receiveBoardPostAutomation(id); if (!result) { return reply.code(404).send({ message: '자동화 접수할 게시글을 찾을 수 없습니다.', }); } return { ok: true, item: result.item, planItemIds: result.planItemIds, alreadyReceived: result.alreadyReceived, }; }); app.patch('/api/board/posts/:id', async (request, reply) => { const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); let item; try { item = await updateBoardPost(id, boardPostPayloadSchema.parse(request.body ?? {})); } catch (error) { if (error instanceof BoardPostAutomationLockedError) { return reply.code(409).send({ message: error.message, }); } throw error; } if (!item) { return reply.code(404).send({ message: '수정할 게시글을 찾을 수 없습니다.', }); } return { ok: true, item, }; }); app.delete('/api/board/posts/:id', async (request, reply) => { const id = z.coerce.number().int().positive().parse((request.params as { id: string }).id); let deleted; try { deleted = await deleteBoardPost(id); } catch (error) { if (error instanceof BoardPostAutomationLockedError) { return reply.code(409).send({ message: error.message, }); } throw error; } if (!deleted) { return reply.code(404).send({ message: '삭제할 게시글을 찾을 수 없습니다.', }); } return { ok: true, id, }; }); }