v0.1.20 - PostgreSQL 전환 및 Docker Compose 초안 추가
This commit is contained in:
@@ -10,7 +10,7 @@ config({ path: path.join(__dirname, '..', '.env') })
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3001),
|
||||
DB_FILE: z.string().default('./data/planner.sqlite'),
|
||||
DATABASE_URL: z.string().min(1).default('postgresql://planner:planner1234@localhost:5432/ten_minute_planner'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
SESSION_TTL_DAYS: z.coerce.number().default(30),
|
||||
})
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import Database from 'better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
||||
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||
import pg from 'pg'
|
||||
import { env } from '../config.js'
|
||||
import * as schema from './schema.js'
|
||||
|
||||
function ensureDatabaseDirectory(dbFile) {
|
||||
const absoluteDbPath = path.resolve(dbFile)
|
||||
fs.mkdirSync(path.dirname(absoluteDbPath), { recursive: true })
|
||||
return absoluteDbPath
|
||||
}
|
||||
const { Pool } = pg
|
||||
|
||||
const sqlite = new Database(ensureDatabaseDirectory(env.DB_FILE))
|
||||
export const pool = new Pool({
|
||||
connectionString: env.DATABASE_URL,
|
||||
})
|
||||
|
||||
export const db = drizzle(sqlite, { schema })
|
||||
export { sqlite }
|
||||
export const db = drizzle(pool, { schema })
|
||||
|
||||
@@ -1,63 +1,55 @@
|
||||
import { sqlite } from './client.js'
|
||||
import { pool } from './client.js'
|
||||
|
||||
function ensureColumn(tableName, columnName, definition) {
|
||||
const columns = sqlite.prepare(`PRAGMA table_info(${tableName})`).all()
|
||||
const hasColumn = columns.some((column) => column.name === columnName)
|
||||
|
||||
if (!hasColumn) {
|
||||
sqlite.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureDatabaseSchema() {
|
||||
sqlite.exec(`
|
||||
export async function ensureDatabaseSchema() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
id SERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(60) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(255) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS auth_sessions_user_id_idx
|
||||
ON auth_sessions (user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS planner_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
entry_date TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entry_date VARCHAR(10) NOT NULL,
|
||||
payload JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS planner_entries_user_date_unique
|
||||
ON planner_entries (user_id, entry_date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
target_date TEXT NOT NULL,
|
||||
active_from TEXT,
|
||||
active_until TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
color TEXT NOT NULL DEFAULT '#1c1917',
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
completed_at INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`)
|
||||
CREATE INDEX IF NOT EXISTS planner_entries_user_id_idx
|
||||
ON planner_entries (user_id);
|
||||
|
||||
ensureColumn('goals', 'active_from', 'TEXT')
|
||||
ensureColumn('goals', 'active_until', 'TEXT')
|
||||
CREATE TABLE IF NOT EXISTS goals (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(120) NOT NULL,
|
||||
target_date VARCHAR(10) NOT NULL,
|
||||
active_from VARCHAR(10),
|
||||
active_until VARCHAR(10),
|
||||
color VARCHAR(32) NOT NULL DEFAULT '#1c1917',
|
||||
created_at TIMESTAMPTZ NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS goals_user_id_idx
|
||||
ON goals (user_id);
|
||||
`)
|
||||
}
|
||||
|
||||
@@ -1,47 +1,67 @@
|
||||
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
import {
|
||||
integer,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
serial,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core'
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
nickname: text('nickname').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
export const users = pgTable('users', {
|
||||
id: serial('id').primaryKey(),
|
||||
email: varchar('email', { length: 255 }).notNull().unique(),
|
||||
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
|
||||
nickname: varchar('nickname', { length: 60 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
||||
})
|
||||
|
||||
export const authSessions = sqliteTable('auth_sessions', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
tokenHash: text('token_hash').notNull().unique(),
|
||||
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
})
|
||||
|
||||
export const plannerEntries = sqliteTable(
|
||||
'planner_entries',
|
||||
export const authSessions = pgTable(
|
||||
'auth_sessions',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
id: serial('id').primaryKey(),
|
||||
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(),
|
||||
tokenHash: varchar('token_hash', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate),
|
||||
userIndex: index('auth_sessions_user_id_idx').on(table.userId),
|
||||
}),
|
||||
)
|
||||
|
||||
export const goals = sqliteTable('goals', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
title: text('title').notNull(),
|
||||
targetDate: text('target_date').notNull(),
|
||||
activeFrom: text('active_from'),
|
||||
activeUntil: text('active_until'),
|
||||
status: text('status').notNull().default('active'),
|
||||
color: text('color').notNull().default('#1c1917'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' }).notNull(),
|
||||
completedAt: integer('completed_at', { mode: 'timestamp_ms' }),
|
||||
})
|
||||
export const plannerEntries = pgTable(
|
||||
'planner_entries',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
entryDate: varchar('entry_date', { length: 10 }).notNull(),
|
||||
payload: jsonb('payload').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userDateUnique: uniqueIndex('planner_entries_user_date_unique').on(table.userId, table.entryDate),
|
||||
userIndex: index('planner_entries_user_id_idx').on(table.userId),
|
||||
}),
|
||||
)
|
||||
|
||||
export const goals = pgTable(
|
||||
'goals',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
title: varchar('title', { length: 120 }).notNull(),
|
||||
targetDate: varchar('target_date', { length: 10 }).notNull(),
|
||||
activeFrom: varchar('active_from', { length: 10 }),
|
||||
activeUntil: varchar('active_until', { length: 10 }),
|
||||
color: varchar('color', { length: 32 }).notNull().default('#1c1917'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIndex: index('goals_user_id_idx').on(table.userId),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ const goalSchema = z.object({
|
||||
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(),
|
||||
status: z.enum(['active', 'done', 'archived']).optional(),
|
||||
color: z.string().trim().min(4).max(32).optional(),
|
||||
})
|
||||
|
||||
@@ -18,13 +17,11 @@ const goalUpdateSchema = z.object({
|
||||
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(),
|
||||
status: z.enum(['active', 'done', 'archived']).optional(),
|
||||
color: z.string().trim().min(4).max(32).optional(),
|
||||
})
|
||||
|
||||
const goalQuerySchema = z.object({
|
||||
query: z.string().trim().optional(),
|
||||
status: z.enum(['active', 'done', 'archived', 'all']).optional(),
|
||||
})
|
||||
|
||||
async function requireAuthenticatedUser(request, reply) {
|
||||
@@ -48,10 +45,9 @@ async function validateGoalSchedule({
|
||||
userId,
|
||||
activeFrom,
|
||||
activeUntil,
|
||||
status,
|
||||
excludeGoalId = null,
|
||||
}) {
|
||||
if (!activeFrom || !activeUntil || status !== 'active') {
|
||||
if (!activeFrom || !activeUntil) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -65,7 +61,7 @@ async function validateGoalSchedule({
|
||||
return false
|
||||
}
|
||||
|
||||
if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) {
|
||||
if (!goal.activeFrom || !goal.activeUntil) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -92,10 +88,6 @@ export async function registerGoalRoutes(app) {
|
||||
|
||||
const filters = [eq(goals.userId, user.id)]
|
||||
|
||||
if (query.data.status && query.data.status !== 'all') {
|
||||
filters.push(eq(goals.status, query.data.status))
|
||||
}
|
||||
|
||||
if (query.data.query) {
|
||||
filters.push(like(goals.title, `%${query.data.query}%`))
|
||||
}
|
||||
@@ -141,7 +133,6 @@ export async function registerGoalRoutes(app) {
|
||||
userId: user.id,
|
||||
activeFrom: payload.data.activeFrom ?? null,
|
||||
activeUntil: payload.data.activeUntil ?? null,
|
||||
status: payload.data.status ?? 'active',
|
||||
})
|
||||
|
||||
if (overlappedGoal) {
|
||||
@@ -161,10 +152,8 @@ export async function registerGoalRoutes(app) {
|
||||
activeFrom: payload.data.activeFrom ?? null,
|
||||
activeUntil: payload.data.activeUntil ?? null,
|
||||
color: payload.data.color ?? '#1c1917',
|
||||
status: payload.data.status ?? 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
completedAt: payload.data.status === 'done' ? now : null,
|
||||
})
|
||||
.returning()
|
||||
|
||||
@@ -214,8 +203,6 @@ export async function registerGoalRoutes(app) {
|
||||
|
||||
const nextActiveFrom = payload.data.activeFrom !== undefined ? payload.data.activeFrom : existingGoal.activeFrom
|
||||
const nextActiveUntil = payload.data.activeUntil !== undefined ? payload.data.activeUntil : existingGoal.activeUntil
|
||||
const nextStatus = payload.data.status ?? existingGoal.status
|
||||
|
||||
if ((nextActiveFrom && !nextActiveUntil) || (!nextActiveFrom && nextActiveUntil)) {
|
||||
return reply.code(400).send({
|
||||
message: '표시 시작일과 종료일은 함께 입력해 주세요.',
|
||||
@@ -232,7 +219,6 @@ export async function registerGoalRoutes(app) {
|
||||
userId: user.id,
|
||||
activeFrom: nextActiveFrom,
|
||||
activeUntil: nextActiveUntil,
|
||||
status: nextStatus,
|
||||
excludeGoalId: existingGoal.id,
|
||||
})
|
||||
|
||||
@@ -262,22 +248,10 @@ export async function registerGoalRoutes(app) {
|
||||
nextValues.activeUntil = payload.data.activeUntil
|
||||
}
|
||||
|
||||
if (payload.data.status !== undefined) {
|
||||
nextValues.status = payload.data.status
|
||||
}
|
||||
|
||||
if (payload.data.color !== undefined) {
|
||||
nextValues.color = payload.data.color
|
||||
}
|
||||
|
||||
if (payload.data.status === 'done' && !existingGoal.completedAt) {
|
||||
nextValues.completedAt = new Date()
|
||||
}
|
||||
|
||||
if (payload.data.status && payload.data.status !== 'done') {
|
||||
nextValues.completedAt = null
|
||||
}
|
||||
|
||||
const [goal] = await db
|
||||
.update(goals)
|
||||
.set(nextValues)
|
||||
@@ -289,4 +263,42 @@ export async function registerGoalRoutes(app) {
|
||||
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: '목표가 삭제되었습니다.',
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -67,10 +67,7 @@ export async function registerPlannerRoutes(app) {
|
||||
.orderBy(asc(plannerEntries.entryDate))
|
||||
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
payload: JSON.parse(entry.payload),
|
||||
})),
|
||||
entries,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -101,12 +98,7 @@ export async function registerPlannerRoutes(app) {
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
entry: entry
|
||||
? {
|
||||
...entry,
|
||||
payload: JSON.parse(entry.payload),
|
||||
}
|
||||
: null,
|
||||
entry: entry ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,14 +133,14 @@ export async function registerPlannerRoutes(app) {
|
||||
.values({
|
||||
userId: user.id,
|
||||
entryDate: dateResult.data,
|
||||
payload: JSON.stringify(payloadResult.data.payload),
|
||||
payload: payloadResult.data.payload,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [plannerEntries.userId, plannerEntries.entryDate],
|
||||
set: {
|
||||
payload: JSON.stringify(payloadResult.data.payload),
|
||||
payload: payloadResult.data.payload,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
@@ -156,10 +148,7 @@ export async function registerPlannerRoutes(app) {
|
||||
|
||||
return {
|
||||
message: '플래너가 저장되었습니다.',
|
||||
entry: {
|
||||
...entry,
|
||||
payload: JSON.parse(entry.payload),
|
||||
},
|
||||
entry,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import { env } from './config.js'
|
||||
import { sqlite } from './db/client.js'
|
||||
import { pool } from './db/client.js'
|
||||
import { ensureDatabaseSchema } from './db/init.js'
|
||||
import { registerAuthRoutes } from './routes/auth.js'
|
||||
import { registerGoalRoutes } from './routes/goals.js'
|
||||
@@ -11,7 +11,7 @@ const app = Fastify({
|
||||
logger: true,
|
||||
})
|
||||
|
||||
ensureDatabaseSchema()
|
||||
await ensureDatabaseSchema()
|
||||
|
||||
await app.register(cors, {
|
||||
origin: env.CORS_ORIGIN,
|
||||
@@ -23,13 +23,14 @@ await registerGoalRoutes(app)
|
||||
await registerPlannerRoutes(app)
|
||||
|
||||
app.get('/health', async () => {
|
||||
const version = sqlite.prepare('select sqlite_version() as version').get()
|
||||
const versionResult = await pool.query('select version() as version')
|
||||
const version = versionResult.rows[0]
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'ten-minute-planner-backend',
|
||||
database: {
|
||||
client: 'sqlite',
|
||||
client: 'postgresql',
|
||||
version: version?.version ?? 'unknown',
|
||||
},
|
||||
}
|
||||
@@ -37,11 +38,11 @@ app.get('/health', async () => {
|
||||
|
||||
app.get('/api/meta', async () => ({
|
||||
auth: 'active',
|
||||
storage: 'sqlite',
|
||||
storage: 'postgresql',
|
||||
orm: 'drizzle',
|
||||
notes: [
|
||||
'회원가입, 로그인, 현재 사용자 확인 API가 준비되어 있습니다.',
|
||||
'사용자별 목표 목록과 생성 API가 준비되어 있습니다.',
|
||||
'사용자별 목표 목록, 수정, 삭제 API가 준비되어 있습니다.',
|
||||
'사용자별 플래너 저장 및 조회 API가 준비되어 있습니다.',
|
||||
],
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user