import { and, asc, desc, eq, like } from 'drizzle-orm' import { z } from 'zod' import { db } from '../db/client.js' import { goals } from '../db/schema.js' import { findAuthenticatedUser } from '../lib/authSession.js' const goalSchema = z.object({ title: z.string().trim().min(1).max(80), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), color: z.string().trim().min(4).max(32).optional(), }) const goalUpdateSchema = z.object({ title: z.string().trim().min(1).max(80).optional(), targetDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), activeFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), activeUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().nullable(), color: z.string().trim().min(4).max(32).optional(), }) const goalQuerySchema = z.object({ query: z.string().trim().optional(), }) async function requireAuthenticatedUser(request, reply) { const user = await findAuthenticatedUser(request) if (!user) { reply.code(401).send({ message: '인증이 필요합니다.', }) return null } return user } function hasGoalRangeOverlap(leftStart, leftEnd, rightStart, rightEnd) { return leftStart <= rightEnd && leftEnd >= rightStart } async function validateGoalSchedule({ userId, activeFrom, activeUntil, excludeGoalId = null, }) { if (!activeFrom || !activeUntil) { return null } const existingGoals = await db .select() .from(goals) .where(eq(goals.userId, userId)) return existingGoals.find((goal) => { if (excludeGoalId && goal.id === excludeGoalId) { return false } if (!goal.activeFrom || !goal.activeUntil) { return false } return hasGoalRangeOverlap(activeFrom, activeUntil, goal.activeFrom, goal.activeUntil) }) ?? null } export async function registerGoalRoutes(app) { app.get('/api/goals', async (request, reply) => { const user = await requireAuthenticatedUser(request, reply) if (!user) { return } const query = goalQuerySchema.safeParse(request.query ?? {}) if (!query.success) { return reply.code(400).send({ message: '목표 조회 조건이 올바르지 않습니다.', issues: query.error.flatten(), }) } const filters = [eq(goals.userId, user.id)] if (query.data.query) { filters.push(like(goals.title, `%${query.data.query}%`)) } const items = await db .select() .from(goals) .where(and(...filters)) .orderBy(desc(goals.updatedAt), asc(goals.targetDate), asc(goals.id)) return { goals: items } }) app.post('/api/goals', async (request, reply) => { const user = await requireAuthenticatedUser(request, reply) if (!user) { return } const payload = goalSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '목표 입력값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } if ((payload.data.activeFrom && !payload.data.activeUntil) || (!payload.data.activeFrom && payload.data.activeUntil)) { return reply.code(400).send({ message: '표시 시작일과 종료일은 함께 입력해 주세요.', }) } if (payload.data.activeFrom && payload.data.activeUntil && payload.data.activeFrom > payload.data.activeUntil) { return reply.code(400).send({ message: '표시 종료일은 시작일보다 빠를 수 없습니다.', }) } const overlappedGoal = await validateGoalSchedule({ userId: user.id, activeFrom: payload.data.activeFrom ?? null, activeUntil: payload.data.activeUntil ?? null, }) if (overlappedGoal) { return reply.code(409).send({ message: `표시 기간이 "${overlappedGoal.title}" 목표와 겹칩니다. D-DAY 기간은 하나만 설정할 수 있습니다.`, }) } const now = new Date() const [goal] = await db .insert(goals) .values({ userId: user.id, title: payload.data.title, targetDate: payload.data.targetDate, activeFrom: payload.data.activeFrom ?? null, activeUntil: payload.data.activeUntil ?? null, color: payload.data.color ?? '#1c1917', createdAt: now, updatedAt: now, }) .returning() return reply.code(201).send({ message: '목표가 추가되었습니다.', goal, }) }) app.patch('/api/goals/:goalId', async (request, reply) => { const user = await requireAuthenticatedUser(request, reply) if (!user) { return } const params = z.object({ goalId: z.coerce.number().int().positive(), }).safeParse(request.params) if (!params.success) { return reply.code(400).send({ message: '목표 식별자가 올바르지 않습니다.', }) } const payload = goalUpdateSchema.safeParse(request.body) if (!payload.success) { return reply.code(400).send({ message: '목표 수정값이 올바르지 않습니다.', issues: payload.error.flatten(), }) } const [existingGoal] = await db .select() .from(goals) .where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id))) .limit(1) if (!existingGoal) { return reply.code(404).send({ message: '목표를 찾을 수 없습니다.', }) } const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) { return reply.code(400).send({ message: '표시 시작일과 종료일은 함께 입력해 주세요.', }) } if (nextActiveFrom && nextActiveUntil && nextActiveFrom > nextActiveUntil) { return reply.code(400).send({ message: '표시 종료일은 시작일보다 빠를 수 없습니다.', }) } const overlappedGoal = await validateGoalSchedule({ userId: user.id, activeFrom: nextActiveFrom, activeUntil: nextActiveUntil, excludeGoalId: existingGoal.id, }) if (overlappedGoal) { return reply.code(409).send({ message: `표시 기간이 "${overlappedGoal.title}" 목표와 겹칩니다. D-DAY 기간은 하나만 설정할 수 있습니다.`, }) } const nextValues = { updatedAt: new Date(), } if (payload.data.title !== undefined) { nextValues.title = payload.data.title } if (payload.data.targetDate !== undefined) { nextValues.targetDate = payload.data.targetDate } if (payload.data.activeFrom !== undefined) { nextValues.activeFrom = payload.data.activeFrom } if (payload.data.activeUntil !== undefined) { nextValues.activeUntil = payload.data.activeUntil } if (payload.data.color !== undefined) { nextValues.color = payload.data.color } const [goal] = await db .update(goals) .set(nextValues) .where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id))) .returning() return { message: '목표가 수정되었습니다.', goal, } }) app.delete('/api/goals/:goalId', async (request, reply) => { const user = await requireAuthenticatedUser(request, reply) if (!user) { return } const params = z.object({ goalId: z.coerce.number().int().positive(), }).safeParse(request.params) if (!params.success) { return reply.code(400).send({ message: '목표 식별자가 올바르지 않습니다.', }) } const [existingGoal] = await db .select() .from(goals) .where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id))) .limit(1) if (!existingGoal) { return reply.code(404).send({ message: '목표를 찾을 수 없습니다.', }) } await db .delete(goals) .where(and(eq(goals.id, params.data.goalId), eq(goals.userId, user.id))) return { message: '목표가 삭제되었습니다.', } }) }