v0.1.10 - 플래너 저장 API 추가
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
@@ -17,11 +17,17 @@ export const authSessions = sqliteTable('auth_sessions', {
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
|
||||
export const plannerEntries = sqliteTable('planner_entries', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
entryDate: text('entry_date').notNull(),
|
||||
payload: text('payload').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
export const plannerEntries = sqliteTable(
|
||||
'planner_entries',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
entryDate: text('entry_date').notNull(),
|
||||
payload: text('payload').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate),
|
||||
}),
|
||||
)
|
||||
|
||||
65
backend/src/lib/authSession.js
Normal file
65
backend/src/lib/authSession.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { env } from '../config.js'
|
||||
import { db } from '../db/client.js'
|
||||
import { authSessions, users } from '../db/schema.js'
|
||||
import { createSessionToken, hashSessionToken } from './password.js'
|
||||
|
||||
function getBearerToken(request) {
|
||||
const authorization = request.headers.authorization
|
||||
|
||||
if (!authorization?.startsWith('Bearer ')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return authorization.slice('Bearer '.length).trim()
|
||||
}
|
||||
|
||||
export async function createSession(userId) {
|
||||
const token = createSessionToken()
|
||||
const tokenHash = hashSessionToken(token)
|
||||
const now = Date.now()
|
||||
const expiresAt = now + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
const [session] = await db
|
||||
.insert(authSessions)
|
||||
.values({
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt: new Date(expiresAt),
|
||||
createdAt: new Date(now),
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
token,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAuthenticatedUser(request) {
|
||||
const token = getBearerToken(request)
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token)
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(eq(authSessions.tokenHash, tokenHash))
|
||||
.limit(1)
|
||||
|
||||
if (!session || new Date(session.expiresAt).getTime() <= Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
return user ?? null
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/client.js'
|
||||
import { authSessions, users } from '../db/schema.js'
|
||||
import { env } from '../config.js'
|
||||
import {
|
||||
createSessionToken,
|
||||
hashPassword,
|
||||
hashSessionToken,
|
||||
verifyPassword,
|
||||
} from '../lib/password.js'
|
||||
import { users } from '../db/schema.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/password.js'
|
||||
import { createSession, findAuthenticatedUser } from '../lib/authSession.js'
|
||||
|
||||
const signupSchema = z.object({
|
||||
email: z.string().trim().email(),
|
||||
@@ -31,66 +26,6 @@ function sanitizeUser(user) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBearerToken(request) {
|
||||
const authorization = request.headers.authorization
|
||||
|
||||
if (!authorization?.startsWith('Bearer ')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return authorization.slice('Bearer '.length).trim()
|
||||
}
|
||||
|
||||
async function createSession(userId) {
|
||||
const token = createSessionToken()
|
||||
const tokenHash = hashSessionToken(token)
|
||||
const now = Date.now()
|
||||
const expiresAt = now + env.SESSION_TTL_DAYS * 24 * 60 * 60 * 1000
|
||||
|
||||
const [session] = await db
|
||||
.insert(authSessions)
|
||||
.values({
|
||||
userId,
|
||||
tokenHash,
|
||||
expiresAt: new Date(expiresAt),
|
||||
createdAt: new Date(now),
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
token,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
async function findAuthenticatedUser(request) {
|
||||
const token = getBearerToken(request)
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token)
|
||||
|
||||
const [session] = await db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(eq(authSessions.tokenHash, tokenHash))
|
||||
.limit(1)
|
||||
|
||||
if (!session || new Date(session.expiresAt).getTime() <= Date.now()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [user] = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.userId))
|
||||
.limit(1)
|
||||
|
||||
return user ?? null
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app) {
|
||||
app.post('/api/auth/signup', async (request, reply) => {
|
||||
const payload = signupSchema.safeParse(request.body)
|
||||
|
||||
165
backend/src/routes/planner.js
Normal file
165
backend/src/routes/planner.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import { and, asc, eq, gte, lte } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { db } from '../db/client.js'
|
||||
import { plannerEntries } from '../db/schema.js'
|
||||
import { findAuthenticatedUser } from '../lib/authSession.js'
|
||||
|
||||
const dateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
|
||||
const plannerPayloadSchema = z.object({
|
||||
payload: z.record(z.any()),
|
||||
})
|
||||
|
||||
const plannerRangeQuerySchema = z.object({
|
||||
from: dateSchema.optional(),
|
||||
to: dateSchema.optional(),
|
||||
})
|
||||
|
||||
async function requireAuthenticatedUser(request, reply) {
|
||||
const user = await findAuthenticatedUser(request)
|
||||
|
||||
if (!user) {
|
||||
reply.code(401).send({
|
||||
message: '인증이 필요합니다.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
export async function registerPlannerRoutes(app) {
|
||||
app.get('/api/planner', async (request, reply) => {
|
||||
const user = await requireAuthenticatedUser(request, reply)
|
||||
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = plannerRangeQuerySchema.safeParse(request.query ?? {})
|
||||
|
||||
if (!query.success) {
|
||||
return reply.code(400).send({
|
||||
message: '조회 범위가 올바르지 않습니다.',
|
||||
issues: query.error.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
const filters = [eq(plannerEntries.userId, user.id)]
|
||||
|
||||
if (query.data.from) {
|
||||
filters.push(gte(plannerEntries.entryDate, query.data.from))
|
||||
}
|
||||
|
||||
if (query.data.to) {
|
||||
filters.push(lte(plannerEntries.entryDate, query.data.to))
|
||||
}
|
||||
|
||||
const entries = await db
|
||||
.select({
|
||||
entryDate: plannerEntries.entryDate,
|
||||
payload: plannerEntries.payload,
|
||||
createdAt: plannerEntries.createdAt,
|
||||
updatedAt: plannerEntries.updatedAt,
|
||||
})
|
||||
.from(plannerEntries)
|
||||
.where(and(...filters))
|
||||
.orderBy(asc(plannerEntries.entryDate))
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
payload: JSON.parse(entry.payload),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/planner/:entryDate', async (request, reply) => {
|
||||
const user = await requireAuthenticatedUser(request, reply)
|
||||
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
const dateResult = dateSchema.safeParse(request.params.entryDate)
|
||||
|
||||
if (!dateResult.success) {
|
||||
return reply.code(400).send({
|
||||
message: '날짜 형식이 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(plannerEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(plannerEntries.userId, user.id),
|
||||
eq(plannerEntries.entryDate, dateResult.data),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
entry: entry
|
||||
? {
|
||||
...entry,
|
||||
payload: JSON.parse(entry.payload),
|
||||
}
|
||||
: null,
|
||||
}
|
||||
})
|
||||
|
||||
app.put('/api/planner/:entryDate', async (request, reply) => {
|
||||
const user = await requireAuthenticatedUser(request, reply)
|
||||
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
|
||||
const dateResult = dateSchema.safeParse(request.params.entryDate)
|
||||
|
||||
if (!dateResult.success) {
|
||||
return reply.code(400).send({
|
||||
message: '날짜 형식이 올바르지 않습니다.',
|
||||
})
|
||||
}
|
||||
|
||||
const payloadResult = plannerPayloadSchema.safeParse(request.body)
|
||||
|
||||
if (!payloadResult.success) {
|
||||
return reply.code(400).send({
|
||||
message: '플래너 저장 데이터가 올바르지 않습니다.',
|
||||
issues: payloadResult.error.flatten(),
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const [entry] = await db
|
||||
.insert(plannerEntries)
|
||||
.values({
|
||||
userId: user.id,
|
||||
entryDate: dateResult.data,
|
||||
payload: JSON.stringify(payloadResult.data.payload),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [plannerEntries.userId, plannerEntries.entryDate],
|
||||
set: {
|
||||
payload: JSON.stringify(payloadResult.data.payload),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
|
||||
return {
|
||||
message: '플래너가 저장되었습니다.',
|
||||
entry: {
|
||||
...entry,
|
||||
payload: JSON.parse(entry.payload),
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { env } from './config.js'
|
||||
import { sqlite } from './db/client.js'
|
||||
import { ensureDatabaseSchema } from './db/init.js'
|
||||
import { registerAuthRoutes } from './routes/auth.js'
|
||||
import { registerPlannerRoutes } from './routes/planner.js'
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
@@ -17,6 +18,7 @@ await app.register(cors, {
|
||||
})
|
||||
|
||||
await registerAuthRoutes(app)
|
||||
await registerPlannerRoutes(app)
|
||||
|
||||
app.get('/health', async () => {
|
||||
const version = sqlite.prepare('select sqlite_version() as version').get()
|
||||
@@ -37,7 +39,7 @@ app.get('/api/meta', async () => ({
|
||||
orm: 'drizzle',
|
||||
notes: [
|
||||
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
|
||||
'플래너 저장 API는 로컬 저장 레이어 분리 이후 연결 예정',
|
||||
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
|
||||
],
|
||||
}))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user