diff --git a/HANDOFF.md b/HANDOFF.md index c6550f6..484f083 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.9` +- 현재 기준 버전: `v0.1.10` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -66,8 +66,10 @@ - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. - 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. +- 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. - 초기 실행 시 `backend/src/db/init.js`에서 테이블이 없으면 자동 생성하도록 맞춰두었다. +- 플래너 저장은 `planner_entries (user_id, entry_date)` 고유 키 기준으로 upsert 하도록 구성했다. - 현재 샌드박스에서는 포트 바인딩 제한 때문에 백엔드 실제 리슨 확인이 막힐 수 있다. `listen EPERM 0.0.0.0:3001`은 코드 자체보다 실행 환경 제약에 가깝다. ## 확정된 결정사항 @@ -108,6 +110,7 @@ - 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다. - 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다. - 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다. +- 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index e8a91ca..82aaf2a 100644 --- a/TODO.md +++ b/TODO.md @@ -51,6 +51,7 @@ - [ ] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [x] 회원 가입 및 로그인 구조를 고려한 사용자별 데이터 모델을 설계한다. - [ ] 사용자별 문서 저장/조회 흐름을 정리한다. +- [x] 사용자별 문서 저장/조회 흐름을 정리한다. - [ ] 출력용 문서 포맷과 프린트 흐름을 고려한 데이터 구조를 정리한다. ## 추가 반영 메모 @@ -92,4 +93,5 @@ - 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. - 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. - 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. +- 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다. - 구현할 때마다 완료된 항목은 체크하고, 큰 결정사항은 `HANDOFF.md`에도 함께 반영한다. diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index 338b18d..ea6408e 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -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), + }), +) diff --git a/backend/src/lib/authSession.js b/backend/src/lib/authSession.js new file mode 100644 index 0000000..efd5a62 --- /dev/null +++ b/backend/src/lib/authSession.js @@ -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 +} diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index df31bfe..ac2f499 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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) diff --git a/backend/src/routes/planner.js b/backend/src/routes/planner.js new file mode 100644 index 0000000..fd99a00 --- /dev/null +++ b/backend/src/routes/planner.js @@ -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), + }, + } + }) +} diff --git a/backend/src/server.js b/backend/src/server.js index e125325..97cc0ff 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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가 준비되어 있습니다.', ], })) diff --git a/package-lock.json b/package-lock.json index 0054e00..e04415c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.9", + "version": "0.1.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.9", + "version": "0.1.10", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 749fc72..453bee1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.9", + "version": "0.1.10", "type": "module", "scripts": { "dev": "vite",