diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e0bd7af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.gitignore +backend/node_modules +backend/data +backend/drizzle +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7eabbae --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . + +ARG VITE_API_BASE_URL=/api +ENV VITE_API_BASE_URL=${VITE_API_BASE_URL} + +RUN npm run build + +FROM nginx:1.27-alpine + +COPY deploy/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/HANDOFF.md b/HANDOFF.md index 2a941c8..ac319a1 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ - 프로젝트명: 10 Minute Planner 웹 UI - 기술 스택: Vue 3 + Vite + TailwindCSS + JavaScript -- 현재 기준 버전: `v0.1.19` +- 현재 기준 버전: `v0.1.20` - Git 원격 저장소: `https://git.sori.studio/zenn/planner.sori.studio.git` ## 기준 디자인 @@ -33,6 +33,10 @@ - 백엔드 인증 라우트: `backend/src/routes/auth.js` - 백엔드 목표 라우트: `backend/src/routes/goals.js` - 백엔드 비밀번호/세션 유틸: `backend/src/lib/password.js` +- Docker Compose 진입점: `docker-compose.yml` +- 프론트 Dockerfile: `Dockerfile` +- 백엔드 Dockerfile: `backend/Dockerfile` +- nginx 프록시 설정: `deploy/nginx/default.conf` - Tailwind 설정은 완료되어 있으며, 이 프로젝트의 스타일링 기준으로 유지한다. - 현재 선택 날짜는 시스템 날짜 기준으로 시작한다. - `COMMENT`, `TASKS`, `MEMO`는 화면에서 바로 편집할 수 있다. @@ -73,7 +77,7 @@ - `1-UP`은 여백이 과하지 않도록 다시 확대했고, `2-UP`은 한 페이지 고정 안정성을 위해 가로 폭과 세로 높이를 조금 더 보수적으로 조정했다. - `1-UP`은 세로 가운데 정렬을 없애고 상단 기준으로 붙여야 여백이 덜 커 보인다. - 현재 `1-UP`은 프레임 자체를 A4 세로에 가깝게 키우고 배율을 크게 올려 빈 여백을 줄이는 방향으로 맞추고 있다. -- 백엔드 초안은 `Fastify + Drizzle + SQLite` 조합이며, 현재는 `/health`, `/api/meta` 정도의 기본 라우트만 있다. +- 백엔드 초안은 `Fastify + Drizzle + PostgreSQL` 조합으로 전환되었다. - 백엔드에는 `/api/auth/signup`, `/api/auth/login`, `/api/auth/me`가 추가되었다. - 백엔드에는 `/api/planner/:entryDate` 단건 조회/저장과 `/api/planner?from=...&to=...` 범위 조회가 추가되었다. - 비밀번호는 Node 내장 `crypto.scrypt` 기반 해시로 저장하고, 세션 토큰은 `auth_sessions` 테이블에 해시 형태로 저장한다. @@ -107,8 +111,8 @@ - 공유를 위해 나중에 이미지 저장 기능도 필요하지만, 실제 출력 품질과 텍스트 선명도는 HTML/CSS 인쇄 레이아웃을 우선 유지하는 편이 좋다. - 원격 저장소 `origin`은 `https://git.sori.studio/zenn/planner.sori.studio.git`로 연결되어 있다. - 앞으로 버전 체크포인트 커밋은 `v0.1.7 - 작업 요약`처럼 버전 뒤에 짧은 작업 설명을 함께 남기는 형식으로 통일한다. -- 이후 배포 단계에서는 `docker-compose.yml`도 함께 작성해야 하며, 포트 번호와 서비스 구성은 추후 사용자와 확정한다. -- `backend/.env.example`에는 기본 `PORT`, `DB_FILE`, `CORS_ORIGIN` 예시가 들어 있다. +- `docker-compose.yml` 초안은 이미 추가되었고, 포트 번호와 실제 외부 공개 범위는 NAS 배포 단계에서 다시 확정하면 된다. +- `backend/.env.example`에는 기본 `PORT`, `DATABASE_URL`, `CORS_ORIGIN` 예시가 들어 있다. ## 다음 권장 작업 @@ -116,8 +120,8 @@ - 목표나 통계 기능보다 먼저, 플래너 본문의 입력과 상호작용을 우선 구현한다. - 통계 화면 구현은 현재 `localStorage` 기반으로 먼저 진행해도 된다. - DB는 기능 탐색 속도를 해치지 않는 선에서, 저장 레이어를 분리할 수 있는 적절한 시점에 붙이는 것이 좋다. -- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + SQLite 또는 PostgreSQL`이다. -- 현재는 SQLite로 시작하되, 확장 시 PostgreSQL로 옮길 수 있게 Drizzle 기반 스키마를 유지한다. +- 현재 기준 추천 백엔드 방향은 `Vue 프론트엔드 + Node.js API + PostgreSQL`이다. +- Docker 배포를 시작하는 시점이므로 SQLite보다 PostgreSQL을 기본 저장소로 유지하는 편이 낫다. - 현재 인증 방식은 Bearer 토큰 기반의 간단한 세션 구조이며, 추후 쿠키/리프레시 토큰 전략으로 확장할 수 있다. - 다음 프론트 단계에서는 `src/lib/plannerStorage.js`를 유지하되, 인증 이후 백엔드 저장소 adapter를 추가해서 `localStorage`와 전환 가능하게 만드는 흐름이 좋다. - 현재 프론트는 인증만 연결된 상태이고, 플래너 저장은 아직 `localStorage` 기준이다. @@ -135,12 +139,21 @@ - TASK LABELS도 별도 버튼 2개 대신 동일한 토글 UI로 단순화했다. ON이면 01~15를 채우고 OFF이면 비운다. - SETTINGS 화면이 추가되어 닉네임, 이메일, 비밀번호 변경을 분리해서 관리할 수 있다. - 백엔드에는 `/api/auth/profile`, `/api/auth/password`, `/api/goals/:goalId` 수정 API가 추가되었다. +- 백엔드에는 `/api/goals/:goalId` 삭제 API도 추가되었다. - 왼쪽 사이드, 플래너 본문 래퍼, 오른쪽 정보 패널 모두 둥근 카드 톤으로 맞춰서 화면 전체의 통일감을 높였다. - 플래너 집중 보기에서는 본문과 오른쪽 패널이 각각 독립 스크롤되도록 바뀌어서 동시에 참조하기 쉽다. - TASK LABELS, D-DAY 토글은 공통 사이즈의 스위치로 통일했고, `translate` 기반 애니메이션으로 부드럽게 움직이게 했다. - 목표 생성 폼은 기본적으로 `표시 시작일 = 오늘`, `표시 종료일 = 목표일` 흐름으로 자동 채워진다. - D-DAY 기간은 서로 겹칠 수 없고, 프론트와 백엔드 모두 중복 기간을 감지하면 저장을 막는다. - 현재 날짜에 적용된 목표가 있는 경우 D-DAY는 기본적으로 보이고, 해당 날짜에서만 토글로 숨길 수 있다. +- 목표 상태 개념은 제거했고, 목표는 기간이 있으면 곧바로 D-DAY 후보가 되는 단순 구조로 정리했다. +- GOALS 화면에서는 수정 중인 카드가 시각적으로 강조되고, 목표 삭제 버튼이 추가되었다. +- 목표 삭제 시 과거 날짜를 포함해 어떤 날짜에서도 해당 목표는 더 이상 표시되지 않는다. +- 백엔드는 SQLite 파일 기반 구조에서 PostgreSQL 연결 구조로 교체되었다. +- `planner_entries.payload`는 문자열이 아니라 PostgreSQL `JSONB`로 저장되도록 바뀌었다. +- `docker-compose.yml` 기준으로 `postgres`, `backend`, `frontend(nginx)` 3개 서비스 초안이 추가되었다. +- 프론트는 nginx에서 `/api`를 백엔드로 프록시하는 구조라서, 배포 시 브라우저가 별도 API 포트를 직접 알 필요가 없다. +- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다. - 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다. - Docker Compose는 프론트엔드와 백엔드를 함께 올리는 기준으로 설계하되, NAS 환경에 맞는 볼륨과 재시작 정책도 함께 고려한다. diff --git a/TODO.md b/TODO.md index 29b4c5d..c52d3ae 100644 --- a/TODO.md +++ b/TODO.md @@ -88,9 +88,10 @@ - [x] A4 가로 기준 2장 출력 모드를 지원한다. - [x] `1-UP` 세로 인쇄 / `2-UP` 가로 인쇄 기준을 분리한다. - [ ] 공유를 위한 이미지 저장 기능을 추가한다. -- [ ] Docker 배포 구조를 정리한다. -- [ ] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. +- [x] Docker 배포 구조를 정리한다. +- [x] UGREEN NAS 기준 `docker-compose.yml` 초안을 작성한다. - [x] 백엔드 기본 스캐폴딩을 추가한다. +- [x] PostgreSQL 전환 초안을 적용한다. ## 메모 @@ -104,10 +105,12 @@ - 실제 인쇄는 HTML/CSS 기반 프린트 레이아웃으로 유지하고, 공유용으로는 별도의 이미지 저장 기능을 추가하는 방향이 적합하다. - 최종 배포는 UGREEN NAS에서 Docker 기반으로 동작할 예정이며, 포트와 실제 서비스 구성은 추후 확정한다. - 백엔드는 빠른 목업이면 PocketBase도 가능하지만, 현재 방향상 커스텀 로직과 확장성을 생각하면 전용 Node.js API + DB 조합을 우선 검토한다. -- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + SQLite` 기준 초안이 추가되었다. +- 현재 백엔드는 `backend/` 폴더에 `Fastify + Drizzle + PostgreSQL` 기준으로 전환 중이다. - 현재 백엔드는 회원가입, 로그인, 현재 사용자 확인용 기본 인증 API까지 포함한다. - 현재 백엔드는 사용자별 플래너 단건 저장/조회와 범위 조회 API까지 포함한다. - 현재 백엔드는 사용자별 목표 목록 조회와 목표 생성 API까지 포함한다. +- 현재는 `docker-compose.yml`로 `postgres + backend + frontend(nginx)` 초안을 올릴 수 있게 정리했다. +- 현재 환경에서는 Docker 데몬이 꺼져 있어 `docker compose build` 실검증은 아직 완료하지 못했다. - 프론트에는 로그인/회원가입 모달과 현재 사용자 상태 표시가 추가되었다. - 로그인 상태일 때는 서버 저장을 우선 사용하는 흐름으로 전환 중이다. - 로그인 전에는 플래너 본문을 사용하지 못하도록 막고, 인증 후 사용 흐름으로 정리했다. diff --git a/backend/.env.example b/backend/.env.example index 364661a..50f31c3 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..be7a50e --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/drizzle.config.js b/backend/drizzle.config.js index 0f0695c..db2f54e 100644 --- a/backend/drizzle.config.js +++ b/backend/drizzle.config.js @@ -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', }, } diff --git a/backend/package-lock.json b/backend/package-lock.json index 271bd7b..e6512b1 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 36bc39f..5b95ee9 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/config.js b/backend/src/config.js index ba56472..38ee907 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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), }) diff --git a/backend/src/db/client.js b/backend/src/db/client.js index 678e329..fb6d699 100644 --- a/backend/src/db/client.js +++ b/backend/src/db/client.js @@ -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 }) diff --git a/backend/src/db/init.js b/backend/src/db/init.js index 24a0774..a63a325 100644 --- a/backend/src/db/init.js +++ b/backend/src/db/init.js @@ -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); + `) } diff --git a/backend/src/db/schema.js b/backend/src/db/schema.js index afce88c..c5cbc44 100644 --- a/backend/src/db/schema.js +++ b/backend/src/db/schema.js @@ -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), + }), +) diff --git a/backend/src/routes/goals.js b/backend/src/routes/goals.js index b04f69f..e00913a 100644 --- a/backend/src/routes/goals.js +++ b/backend/src/routes/goals.js @@ -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: '목표가 삭제되었습니다.', + } + }) } diff --git a/backend/src/routes/planner.js b/backend/src/routes/planner.js index a8171e1..40cc958 100644 --- a/backend/src/routes/planner.js +++ b/backend/src/routes/planner.js @@ -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, } }) diff --git a/backend/src/server.js b/backend/src/server.js index 7f9e7a5..e444b97 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -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가 준비되어 있습니다.', ], })) diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf new file mode 100644 index 0000000..c42805f --- /dev/null +++ b/deploy/nginx/default.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:3001/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /health { + proxy_pass http://backend:3001/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..26a773a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + postgres: + image: postgres:16-alpine + container_name: ten-minute-postgres + environment: + POSTGRES_DB: ten_minute_planner + POSTGRES_USER: planner + POSTGRES_PASSWORD: planner1234 + volumes: + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U planner -d ten_minute_planner"] + interval: 10s + timeout: 5s + retries: 10 + restart: unless-stopped + + backend: + build: + context: ./backend + container_name: ten-minute-backend + environment: + PORT: 3001 + DATABASE_URL: postgresql://planner:planner1234@postgres:5432/ten_minute_planner + CORS_ORIGIN: http://localhost:8080 + SESSION_TTL_DAYS: 30 + depends_on: + postgres: + condition: service_healthy + expose: + - "3001" + restart: unless-stopped + + frontend: + build: + context: . + args: + VITE_API_BASE_URL: /api + container_name: ten-minute-frontend + depends_on: + - backend + ports: + - "8080:80" + restart: unless-stopped + +volumes: + postgres_data: diff --git a/package-lock.json b/package-lock.json index 2b055a7..4f2fc22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ten-minute-planner", - "version": "0.1.19", + "version": "0.1.20", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ten-minute-planner", - "version": "0.1.19", + "version": "0.1.20", "dependencies": { "vue": "^3.5.13" }, diff --git a/package.json b/package.json index 3af0bfb..d84e004 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ten-minute-planner", "private": true, - "version": "0.1.19", + "version": "0.1.20", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index eb304d1..5d557b3 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,7 +16,7 @@ import { updatePassword, updateProfile, } from './lib/authClient' -import { createGoal, fetchGoals, updateGoal } from './lib/goalsApi' +import { createGoal, deleteGoal, fetchGoals, updateGoal } from './lib/goalsApi' import { deletePlannerEntry, fetchPlannerEntries, savePlannerEntry } from './lib/plannerApi' import { createInitialPlannerRecords, @@ -36,7 +36,6 @@ const authToken = ref('') const currentUser = ref(null) const goals = ref([]) const goalQuery = ref('') -const goalStatusFilter = ref('all') const goalBusy = ref(false) const goalMessage = ref('') const editingGoalId = ref(null) @@ -57,7 +56,6 @@ const goalForm = reactive({ targetDate: '', activeFrom: '', activeUntil: '', - status: 'active', }) const profileForm = reactive({ nickname: '', @@ -337,10 +335,6 @@ const isAuthenticated = computed(() => Boolean(authToken.value && currentUser.va const filteredGoals = computed(() => { const query = goalQuery.value.trim().toLowerCase() return goals.value.filter((goal) => { - if (goalStatusFilter.value !== 'all' && goal.status !== goalStatusFilter.value) { - return false - } - if (!query) { return true } @@ -351,7 +345,7 @@ const filteredGoals = computed(() => { const activePlannerGoals = computed(() => goals.value .filter((goal) => { - if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { + if (!goal.activeFrom || !goal.activeUntil) { return false } @@ -713,8 +707,8 @@ function getTodayKey() { return toKey(new Date()) } -function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId = null }) { - if (!activeFrom || !activeUntil || status !== 'active') { +function findOverlappingGoal({ activeFrom, activeUntil, excludeGoalId = null }) { + if (!activeFrom || !activeUntil) { return null } @@ -723,7 +717,7 @@ function findOverlappingGoal({ activeFrom, activeUntil, status, excludeGoalId = return false } - if (goal.status !== 'active' || !goal.activeFrom || !goal.activeUntil) { + if (!goal.activeFrom || !goal.activeUntil) { return false } @@ -742,7 +736,6 @@ function resetGoalForm() { goalForm.targetDate = '' goalForm.activeFrom = getTodayKey() goalForm.activeUntil = '' - goalForm.status = 'active' editingGoalId.value = null } @@ -850,7 +843,6 @@ function logout() { goals.value = [] goalQuery.value = '' goalMessage.value = '' - goalStatusFilter.value = 'all' setSyncFeedback('local', '로그인 후 클라우드 저장을 사용할 수 있습니다.', { visible: false, }) @@ -900,7 +892,6 @@ async function submitGoal() { const overlappedGoal = findOverlappingGoal({ activeFrom: goalForm.activeFrom || null, activeUntil: goalForm.activeUntil || null, - status: goalForm.status, }) if (overlappedGoal) { @@ -917,7 +908,6 @@ async function submitGoal() { targetDate: goalForm.targetDate, activeFrom: goalForm.activeFrom || null, activeUntil: goalForm.activeUntil || null, - status: goalForm.status, }) await loadGoals() @@ -949,7 +939,6 @@ function startGoalEdit(goal) { goalForm.targetDate = goal.targetDate ?? '' goalForm.activeFrom = goal.activeFrom ?? '' goalForm.activeUntil = goal.activeUntil ?? '' - goalForm.status = goal.status ?? 'active' goalMessage.value = '' } @@ -976,7 +965,6 @@ async function saveGoalEdit() { const overlappedGoal = findOverlappingGoal({ activeFrom: goalForm.activeFrom || null, activeUntil: goalForm.activeUntil || null, - status: goalForm.status, excludeGoalId: editingGoalId.value, }) @@ -994,7 +982,6 @@ async function saveGoalEdit() { targetDate: goalForm.targetDate, activeFrom: goalForm.activeFrom || null, activeUntil: goalForm.activeUntil || null, - status: goalForm.status, }) await loadGoals() @@ -1007,6 +994,30 @@ async function saveGoalEdit() { } } +async function removeGoal(goal) { + const confirmed = window.confirm(`"${goal.title}" 목표를 삭제할까요? 삭제하면 과거 날짜에서도 더 이상 표시되지 않습니다.`) + + if (!confirmed) { + return + } + + goalBusy.value = true + goalMessage.value = '' + + try { + const result = await deleteGoal(authToken.value, goal.id) + await loadGoals() + if (editingGoalId.value === goal.id) { + resetGoalForm() + } + goalMessage.value = result.message || '목표가 삭제되었습니다.' + } catch (error) { + goalMessage.value = error.message || '목표를 삭제하지 못했습니다.' + } finally { + goalBusy.value = false + } +} + function updateProfileField({ field, value }) { profileForm[field] = value } @@ -1236,7 +1247,7 @@ onMounted(() => { + + diff --git a/src/components/GoalsDashboard.vue b/src/components/GoalsDashboard.vue index 02cb02b..39bd0b7 100644 --- a/src/components/GoalsDashboard.vue +++ b/src/components/GoalsDashboard.vue @@ -8,10 +8,6 @@ const props = defineProps({ type: String, default: '', }, - status: { - type: String, - default: 'all', - }, form: { type: Object, required: true, @@ -36,12 +32,12 @@ const props = defineProps({ const emit = defineEmits([ 'update:query', - 'update:status', 'update:form-field', 'submit:create', 'start-edit', 'cancel-edit', 'submit:update', + 'delete-goal', ]) function updateField(field, event) { @@ -109,19 +105,6 @@ function isActiveOnSelectedDate(goal) { -
- - -
-

여기서 목표와 표시 기간을 설정해 두면, 플래너 작성 화면에서는 해당 날짜에 보여줄지 여부만 간단히 ON/OFF 할 수 있습니다.

@@ -161,7 +144,7 @@ function isActiveOnSelectedDate(goal) { 목표가 많아져도 플래너 작성 화면이 길어지지 않도록, 전체 관리는 이 화면에서 처리합니다.

-
+
-
@@ -186,17 +159,18 @@ function isActiveOnSelectedDate(goal) {

{{ goal.title }}

- {{ goal.status === 'done' ? '완료' : goal.status === 'archived' ? '보관' : '진행 중' }} + 수정 중
- +
+ + +
diff --git a/src/components/MiniCalendar.vue b/src/components/MiniCalendar.vue index 43e9ff8..182cc31 100644 --- a/src/components/MiniCalendar.vue +++ b/src/components/MiniCalendar.vue @@ -40,7 +40,7 @@ function selectYear(year) {