305 lines
8.2 KiB
JavaScript
305 lines
8.2 KiB
JavaScript
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: '목표가 삭제되었습니다.',
|
|
}
|
|
})
|
|
}
|