v0.1.20 - PostgreSQL 전환 및 Docker Compose 초안 추가

This commit is contained in:
2026-04-22 10:48:24 +09:00
parent 9b788406ea
commit 8ff4c979fa
24 changed files with 614 additions and 248 deletions

View File

@@ -1,3 +1,3 @@
PORT=3001
DB_FILE=./data/planner.sqlite
DATABASE_URL=postgresql://planner:planner1234@localhost:5432/ten_minute_planner
CORS_ORIGIN=http://localhost:5173

14
backend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY src ./src
COPY .env.example ./.env.example
COPY drizzle.config.js ./drizzle.config.js
EXPOSE 3001
CMD ["node", "src/server.js"]

View File

@@ -10,8 +10,8 @@ config({ path: path.join(__dirname, '.env') })
export default {
schema: './src/db/schema.js',
out: './drizzle',
dialect: 'sqlite',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DB_FILE ?? './data/planner.sqlite',
url: process.env.DATABASE_URL ?? 'postgresql://planner:planner1234@localhost:5432/ten_minute_planner',
},
}

View File

@@ -9,10 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^10.0.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.39.1",
"fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2"
},
"devDependencies": {
@@ -1083,7 +1083,9 @@
"url": "https://feross.org/support"
}
],
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/better-sqlite3": {
"version": "11.10.0",
@@ -1091,6 +1093,8 @@
"integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.1"
@@ -1101,6 +1105,8 @@
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
@@ -1110,6 +1116,8 @@
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
@@ -1135,6 +1143,8 @@
}
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
@@ -1151,7 +1161,9 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
"license": "ISC",
"optional": true,
"peer": true
},
"node_modules/cookie": {
"version": "1.1.1",
@@ -1189,6 +1201,8 @@
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"mimic-response": "^3.1.0"
},
@@ -1204,6 +1218,8 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=4.0.0"
}
@@ -1222,6 +1238,8 @@
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
@@ -1377,6 +1395,8 @@
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"once": "^1.4.0"
}
@@ -1451,6 +1471,8 @@
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"license": "(MIT OR WTFPL)",
"optional": true,
"peer": true,
"engines": {
"node": ">=6"
}
@@ -1578,7 +1600,9 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/find-my-way": {
"version": "9.5.0",
@@ -1598,7 +1622,9 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/gel": {
"version": "2.2.0",
@@ -1638,7 +1664,9 @@
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/ieee754": {
"version": "1.2.1",
@@ -1658,19 +1686,25 @@
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"optional": true,
"peer": true
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
"license": "ISC",
"optional": true,
"peer": true
},
"node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
"license": "ISC",
"optional": true,
"peer": true
},
"node_modules/ipaddr.js": {
"version": "2.3.0",
@@ -1758,6 +1792,8 @@
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=10"
},
@@ -1770,6 +1806,8 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"optional": true,
"peer": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -1778,7 +1816,9 @@
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/mnemonist": {
"version": "0.40.0",
@@ -1800,13 +1840,17 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/node-abi": {
"version": "3.89.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz",
"integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"semver": "^7.3.5"
},
@@ -1834,10 +1878,101 @@
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
"pg-protocol": "^1.13.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.3.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
"integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
"integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
"integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
"integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
@@ -1875,12 +2010,53 @@
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prebuild-install": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
@@ -1923,6 +2099,8 @@
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
@@ -1939,6 +2117,8 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"peer": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
@@ -1954,6 +2134,8 @@
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
@@ -2034,7 +2216,9 @@
"url": "https://feross.org/support"
}
],
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/safe-regex2": {
"version": "5.1.1",
@@ -2132,7 +2316,9 @@
"url": "https://feross.org/support"
}
],
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/simple-get": {
"version": "4.0.1",
@@ -2153,6 +2339,8 @@
}
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
@@ -2203,6 +2391,8 @@
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
@@ -2212,6 +2402,8 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2221,6 +2413,8 @@
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
@@ -2233,6 +2427,8 @@
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
@@ -2270,6 +2466,8 @@
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"safe-buffer": "^5.0.1"
},
@@ -2281,7 +2479,9 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/which": {
"version": "4.0.0",
@@ -2303,7 +2503,18 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
"license": "ISC",
"optional": true,
"peer": true
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zod": {
"version": "3.25.76",

View File

@@ -11,10 +11,10 @@
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"better-sqlite3": "^11.8.1",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.39.1",
"fastify": "^5.2.1",
"pg": "^8.13.3",
"zod": "^3.24.2"
},
"devDependencies": {

View File

@@ -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),
})

View File

@@ -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 })

View File

@@ -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);
`)
}

View File

@@ -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),
}),
)

View File

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

View File

@@ -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,
}
})

View File

@@ -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가 준비되어 있습니다.',
],
}))