From 301e42b1dcef6b5fdb49af8702cd09d1c898e9cb Mon Sep 17 00:00:00 2001 From: clawd Date: Wed, 18 Feb 2026 17:26:24 +0000 Subject: [PATCH] =?UTF-8?q?v2.1.2026=20=E2=80=94=20PostgreSQL,=20Auth,=20H?= =?UTF-8?q?ousehold,=20Shopping=20Smart-Add,=20Docker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - SQLite → PostgreSQL (pg_trgm search, async services) - All services rewritten to async with pg Pool - Data imported (50 recipes, 8 categories) - better-sqlite3 removed Frontend: - ProfilePage complete (edit profile, change password, no more stubs) - HouseholdCard (create, join via code, manage members, leave) - Shopping scope toggle (personal/household) - IngredientPickerModal (smart add with basics filter) - Auth token auto-attached to all API calls (token.ts) - Removed PlaceholderPage Infrastructure: - Docker Compose (backend + frontend + postgres) - Dockerfile for backend (node:22-alpine + tsx) - Dockerfile for frontend (vite build + nginx) - nginx.conf with API proxy + SPA fallback - .env.example for production secrets Spec: - AUTH-V2-SPEC updated: household join flow, manual shopping items --- .env.example | 5 + backend/.dockerignore | 5 + backend/Dockerfile | 18 + backend/data/recipes.db-shm | Bin 32768 -> 32768 bytes backend/data/recipes.db-wal | Bin 4214792 -> 4214792 bytes backend/package-lock.json | 545 +++++------------- backend/package.json | 7 +- backend/src/db/connection.ts | 29 +- backend/src/db/import-sqlite-data.ts | 116 ++++ backend/src/db/migrate.ts | 32 +- backend/src/db/migrations/001_initial.sql | 136 ----- backend/src/db/migrations/001_initial_pg.sql | 181 ++++++ .../src/db/migrations/002_category_icons.sql | 11 - backend/src/db/migrations/003_auth.sql | 43 -- backend/src/db/migrations/004_households.sql | 27 - backend/src/index.ts | 2 +- backend/src/routes/bot.ts | 4 +- backend/src/routes/categories.ts | 4 +- backend/src/routes/notes.ts | 14 +- backend/src/routes/og-scrape.ts | 16 +- backend/src/routes/recipes.ts | 16 +- backend/src/routes/shopping.ts | 34 +- backend/src/routes/tags.ts | 4 +- backend/src/services/auth.service.ts | 143 ++--- backend/src/services/household.service.ts | 186 ++---- backend/src/services/image.service.ts | 17 +- backend/src/services/note.service.ts | 71 +-- backend/src/services/recipe.service.ts | 292 +++++----- backend/src/services/shopping.service.ts | 201 +++---- backend/src/services/tag.service.ts | 23 +- docker-compose.yml | 44 ++ features/AUTH-V2-SPEC.md | 148 ++++- frontend/.dockerignore | 3 + frontend/Dockerfile | 14 + frontend/nginx.conf | 31 + frontend/src/App.tsx | 19 +- frontend/src/api/auth.ts | 52 +- frontend/src/api/client.ts | 22 +- frontend/src/api/households.ts | 49 ++ frontend/src/api/shopping.ts | 31 +- frontend/src/api/token.ts | 10 + .../profile/ChangePasswordModal.tsx | 159 +++++ .../components/profile/EditProfileModal.tsx | 105 ++++ .../src/components/profile/HouseholdCard.tsx | 316 ++++++++++ .../recipe/IngredientPickerModal.tsx | 154 +++++ frontend/src/context/AuthContext.tsx | 27 +- frontend/src/pages/ProfilePage.tsx | 106 ++-- frontend/src/pages/RecipePage.tsx | 57 +- frontend/src/pages/ShoppingPage.tsx | 112 +++- 49 files changed, 2167 insertions(+), 1474 deletions(-) create mode 100644 .env.example create mode 100644 backend/.dockerignore create mode 100644 backend/Dockerfile create mode 100644 backend/src/db/import-sqlite-data.ts delete mode 100644 backend/src/db/migrations/001_initial.sql create mode 100644 backend/src/db/migrations/001_initial_pg.sql delete mode 100644 backend/src/db/migrations/002_category_icons.sql delete mode 100644 backend/src/db/migrations/003_auth.sql delete mode 100644 backend/src/db/migrations/004_households.sql create mode 100644 docker-compose.yml create mode 100644 frontend/.dockerignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/nginx.conf create mode 100644 frontend/src/api/households.ts create mode 100644 frontend/src/api/token.ts create mode 100644 frontend/src/components/profile/ChangePasswordModal.tsx create mode 100644 frontend/src/components/profile/EditProfileModal.tsx create mode 100644 frontend/src/components/profile/HouseholdCard.tsx create mode 100644 frontend/src/components/recipe/IngredientPickerModal.tsx diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..537da94 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Luna Recipes — Production Environment +DB_PASSWORD=change-me-to-something-secure +JWT_SECRET=change-me-random-string-64-chars +JWT_REFRESH_SECRET=change-me-another-random-string +COOKIE_SECRET=change-me-yet-another-random-string diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..a01b17c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +data/ +*.db +*.db-wal +*.db-shm diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..8967dea --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS base +WORKDIR /app + +# Install dependencies +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +# Copy source +COPY src/ src/ +COPY .env* ./ +COPY tsconfig.json ./ + +# Install tsx for running TypeScript +RUN npm install -g tsx + +EXPOSE 6001 + +CMD ["tsx", "src/index.ts"] diff --git a/backend/data/recipes.db-shm b/backend/data/recipes.db-shm index b4e498668e620b762277bb7bf72b35b4a1122cfd..cc2669dd7c1eb91f28570cb6c6b43c3c5c41e21a 100644 GIT binary patch delta 290 zcmZo@U}|V!s+V}A%K!qbK+MR%AP@s28iDNcs}b#j%*CPr_V z(z?m#yoA{O7=swY7^8sF6E|0Qui<3$0W!-s&k1eeVE1PXW(;SHhG{6eHY-IaF#<))HeYgyVTAAk t+~)zMdnQ+TY=TKQOg`r&1QeXHxx#x5Cy-IGc}{2x2TZzd^MQB=1pr0zP%Hod diff --git a/backend/data/recipes.db-wal b/backend/data/recipes.db-wal index eb0711570240464d59e4780fc12028f8c57bbbcb..bc93691c13989120cf916a19c40f95361474b826 100644 GIT binary patch delta 1778 zcmeBJ(8$yQ#0@QsEle%UEi5gpEo?39EgUVJEnF?!Ej%r}EqpEfEdniqEkZ5ATSOku zQ_W!j0@YPJT6u-9T+_R z!W!-u?JHx}ae+dIe>DUDd;Zn@clb|<5I=oGi0FBTm1wOmEZ_beC8@ae<}jS%Vv7 z8)2m`fgEAk=EiChv^P%xMHnmZGY0<2{K9-I_*8hGZI}AQeTCP~hLy`voPmLZ!I*&o z9A#l<#$jHr#*U^QCccr5?jDW?;U3-riNz&}h)`58G`BJ^v@$Z=?sH1$4Wra_&Lz3^ z-8~8*SF`e`G4Ma)Kg_>`zm-32yAcyl2)~>hvm+yJ)2AP~Ez~2lT%zIX?K?alA%-+C z@HcEvxFb}?&(6Zkz{m;CR@o06Divx9Iw|uVgiQ= z3qO(}t)MiOhwv&mC4$4IyFL8=tBXZ@AolKrgw0+4oy3KW+J+Z`k9N-vBO$#6cv%{G zIk`II_Ky;Y}~tOoyZwtA;ZcPafYc zgcyQ1z2u$}{um`EGYz7Y)buj_NZ+Q3s?u&Gq!(+gK&%bKIzX%o#Ckxi55xvQ tYzV|gKx_=eCO~Wo#AZNj4#XBfYzf3xKx_@fHb86(#CF>cKd^Vr2LKBBKG^^O delta 2031 zcmY+E4{#LK9mn6>-TRZf+kHpK-QFE{m&+v)o1|w%NRA_aPQam*KnqfEBE?jjT4mbQ zaHNnT>?MnJk_yQr;V02RZU${dtVAgbHPSRvsr{3j9jb+zc!<=tB2CLsoK8*pdpCuq zyYu<&@4ff^{n_{S?#aQV@Fb$?R2OtnmvobE)-AeK&(UqVU3ciRuINm6>T~p5eeUtR zO9yM`5xk0zoYYPCe(S|Eom;Oa9{x?Mjdc^oItcq0yT-1t%WRUp%hK#EHp*UO$JtBl zd3K0(@Y_hOb2KpM`bU|`IY+=$3U%_wHz^ZfV7%mh$ZRy3^Kr(H+$!`!i>>v+bCr+C zI2UHQJ>w1c@Jw$i@aPTSw6%A5)t-0O9GEiFHTh2Gf(ymMNa(i7EjCo=uv4?!8e?bK z2s_2P|L>mN7K}X+vzW6@RlsneCjgqy;sWK2pu)ZNe%GxA!?PQFk9wkSrVCGEgLH>E zV$7u6*90T?4{V`mj0NX?ki&LQyY1ibH~qrK{!PLTFFbHYa6#ECZXcva3*5$(%lnia zGS|#;X!^J$U~IlpazSK{FXroq0_AJXG zATv<@L+Lv_UH+@u2hUk}PSfK!Tn@B58aJi{zi9DS;l!;1YkQ6Km8V~<{BHp`)Em7n z2n!$;qyd;3x91qcebFmQ?#@5Ac{`h`ANXHM7@>4~6|H3%JkUWU!{SV14UDjmJfe&! zAvvQEr9sh@K_xD0@}L}&8|1i593zg5W6+^F;*cm*E3)0fF@dfC-x|>lo}s7<`d5pq zjQZ!J*UUX%i-+lQ`0`LRzF(9gutt>p@K-@9f(ZA1B}#WgrzjP|w?(M{UgmS95+6!Z z2o!GiN>VT zc6s7`llVN1OnWehq6tz@91c>CA6ohFrvjRK!BYe^+}p*?>8y@>)7+cn=AghML@e={ z;3%BgBZH4!u)K++0GX}rlolh0h z%Ouvq3kk|5q5yqNEin_)R(MP^e!UU}Qk!^G16t=TfSF45Pl`1|7CxHQJL-vp>x;Nv z{E=p$$U>#$V}~{PB&K>nj;ZhAa4B`yhZJV~e_00F`uF7(R zSfmtDzHZxV|I|8d*lr&rd==RMv0Wen?9C6&19>=LfuA+2CW&1lD*UopEstUp%JNFGTH4MZJZ*M! z+ji&=<2ZYeH#gTzZ>$(ye&;@;e5>koTK{fsJh8sHZpMfUm6RIiFS_#0hBn}xvRYfl zZ)~kP{QETEQWADM?sD<1>o7yZ0o87F_N$kz-%n>;h#OH658_39$UMZ41dt$-kA#o{ zB#aayMMyDn8!{ic9r+wmf|Md<$mfv=atBh5+=(ne?m`wKi;xPW5{V*=22" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -1438,40 +1419,6 @@ "node": ">= 18" } }, - "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, "node_modules/bn.js": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", @@ -1490,42 +1437,12 @@ "node": "20 || >=22" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "license": "ISC" - }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1552,30 +1469,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1624,15 +1517,6 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -1681,15 +1565,6 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, - "node_modules/expand-template": { - "version": "2.0.3", - "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)", - "engines": { - "node": ">=6" - } - }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -1856,12 +1731,6 @@ "xtend": "^4.0.0" } }, - "node_modules/file-uri-to-path": { - "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" - }, "node_modules/find-my-way": { "version": "9.4.0", "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz", @@ -1876,12 +1745,6 @@ "node": ">=20" } }, - "node_modules/fs-constants": { - "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" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1910,12 +1773,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/github-from-package": { - "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" - }, "node_modules/glob": { "version": "13.0.5", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz", @@ -1953,38 +1810,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "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" }, - "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" - }, "node_modules/ipaddr.js": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", @@ -2177,18 +2008,6 @@ "node": ">=10.0.0" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -2210,15 +2029,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -2228,12 +2038,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mkdirp-classic": { - "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" - }, "node_modules/mnemonist": { "version": "0.40.3", "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz", @@ -2249,24 +2053,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/napi-build-utils": { - "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" - }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-addon-api": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", @@ -2302,15 +2088,6 @@ "node": ">=14.0.0" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, "node_modules/path-scurry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", @@ -2327,6 +2104,95 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.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.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "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.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "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", @@ -2364,30 +2230,43 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, - "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==", + "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": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" + "xtend": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=0.10.0" } }, "node_modules/process-warning": { @@ -2406,51 +2285,12 @@ ], "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.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)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -2642,51 +2482,6 @@ "@img/sharp-win32-x64": "0.34.5" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -2727,52 +2522,6 @@ "reusify": "^1.0.0" } }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/thread-stream": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz", @@ -2830,18 +2579,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2871,18 +2608,6 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC" - }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index b135364..5186097 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "node dist/index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "test": "echo \"Error: no test specified\" && exit 1", + "db:import": "tsx src/db/import-sqlite-data.ts" }, "keywords": [], "author": "", @@ -18,19 +19,19 @@ "@fastify/jwt": "^10.0.0", "@fastify/multipart": "^9.4.0", "@fastify/static": "^9.0.0", + "@types/pg": "^8.16.0", "@types/sharp": "^0.31.1", "bcrypt": "^6.0.0", - "better-sqlite3": "^12.6.2", "dotenv": "^17.3.1", "fastify": "^5.7.4", "jsonwebtoken": "^9.0.3", + "pg": "^8.18.0", "sharp": "^0.34.5", "ulid": "^3.0.2", "zod": "^4.3.6" }, "devDependencies": { "@types/bcrypt": "^6.0.0", - "@types/better-sqlite3": "^7.6.13", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.2.3", "tsx": "^4.21.0", diff --git a/backend/src/db/connection.ts b/backend/src/db/connection.ts index 2576205..3df94e7 100644 --- a/backend/src/db/connection.ts +++ b/backend/src/db/connection.ts @@ -1,24 +1,17 @@ -import Database from 'better-sqlite3'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import pg from 'pg'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const DB_PATH = path.resolve(__dirname, '../../data/recipes.db'); +const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://werk:werk_dev_secret@localhost:5432/luna_recipes'; -let db: Database.Database | null = null; +export const pool = new pg.Pool({ + connectionString: DATABASE_URL, + max: 20, + idleTimeoutMillis: 30000, +}); -export function getDb(): Database.Database { - if (!db) { - db = new Database(DB_PATH); - db.pragma('journal_mode = WAL'); - db.pragma('foreign_keys = ON'); - } - return db; +export async function query(text: string, params?: any[]) { + return pool.query(text, params); } -export function closeDb(): void { - if (db) { - db.close(); - db = null; - } +export async function closeDb(): Promise { + await pool.end(); } diff --git a/backend/src/db/import-sqlite-data.ts b/backend/src/db/import-sqlite-data.ts new file mode 100644 index 0000000..96c654d --- /dev/null +++ b/backend/src/db/import-sqlite-data.ts @@ -0,0 +1,116 @@ +import 'dotenv/config'; +import fs from 'fs'; +import { pool } from './connection.js'; +import { runMigrations } from './migrate.js'; + +function loadJson(path: string): any[] { + try { + return JSON.parse(fs.readFileSync(path, 'utf-8')); + } catch { + console.log(`Skipping ${path} (not found or invalid)`); + return []; + } +} + +async function main() { + console.log('Running migrations first...'); + await runMigrations(); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // Categories + const categories = loadJson('/tmp/luna_categories.json'); + for (const c of categories) { + await client.query( + 'INSERT INTO categories (id, name, slug, icon, sort_order, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO NOTHING', + [c.id, c.name, c.slug, c.icon, c.sort_order, c.created_at] + ); + } + console.log(`Imported ${categories.length} categories`); + + // Tags + const tags = loadJson('/tmp/luna_tags.json'); + for (const t of tags) { + await client.query( + 'INSERT INTO tags (id, name, slug, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING', + [t.id, t.name, t.slug, t.created_at] + ); + } + console.log(`Imported ${tags.length} tags`); + + // Recipes + const recipes = loadJson('/tmp/luna_recipes.json'); + for (const r of recipes) { + await client.query( + `INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url, image_source, is_favorite, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) ON CONFLICT (id) DO NOTHING`, + [r.id, r.title, r.slug, r.description, r.category_id, r.difficulty, r.prep_time, r.cook_time, r.total_time, r.servings, r.image_url, r.source_url, r.image_source, r.is_favorite || 0, r.created_at, r.updated_at] + ); + } + console.log(`Imported ${recipes.length} recipes`); + + // Recipe Tags + const recipeTags = loadJson('/tmp/luna_recipe_tags.json'); + for (const rt of recipeTags) { + await client.query( + 'INSERT INTO recipe_tags (recipe_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', + [rt.recipe_id, rt.tag_id] + ); + } + console.log(`Imported ${recipeTags.length} recipe_tags`); + + // Ingredients + const ingredients = loadJson('/tmp/luna_ingredients.json'); + for (const i of ingredients) { + await client.query( + 'INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING', + [i.id, i.recipe_id, i.amount, i.unit, i.name, i.group_name, i.sort_order] + ); + } + console.log(`Imported ${ingredients.length} ingredients`); + + // Steps + const steps = loadJson('/tmp/luna_steps.json'); + for (const s of steps) { + await client.query( + 'INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes, image_url) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (id) DO NOTHING', + [s.id, s.recipe_id, s.step_number, s.instruction, s.duration_minutes, s.image_url] + ); + } + console.log(`Imported ${steps.length} steps`); + + // Notes + const notes = loadJson('/tmp/luna_notes.json'); + for (const n of notes) { + await client.query( + 'INSERT INTO notes (id, recipe_id, content, created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (id) DO NOTHING', + [n.id, n.recipe_id, n.content, n.created_at] + ); + } + console.log(`Imported ${notes.length} notes`); + + // Shopping items + const shopping = loadJson('/tmp/luna_shopping.json'); + for (const s of shopping) { + await client.query( + 'INSERT INTO shopping_items (id, name, amount, unit, checked, recipe_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT (id) DO NOTHING', + [s.id, s.name, s.amount, s.unit, Boolean(s.checked), s.recipe_id, s.created_at] + ); + } + console.log(`Imported ${shopping.length} shopping items`); + + await client.query('COMMIT'); + console.log('Import complete!'); + } catch (e) { + await client.query('ROLLBACK'); + console.error('Import failed:', e); + throw e; + } finally { + client.release(); + await pool.end(); + } +} + +main().catch(() => process.exit(1)); diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index e39d3b6..2ef23a2 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -1,25 +1,22 @@ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { getDb } from './connection.js'; +import { pool } from './connection.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const MIGRATIONS_DIR = path.resolve(__dirname, 'migrations'); -export function runMigrations(): void { - const db = getDb(); - - db.exec(` +export async function runMigrations(): Promise { + await pool.query(` CREATE TABLE IF NOT EXISTS _migrations ( - id INTEGER PRIMARY KEY AUTOINCREMENT, + id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE, - applied_at TEXT NOT NULL DEFAULT (datetime('now')) + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); - const applied = new Set( - db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name) - ); + const { rows } = await pool.query('SELECT name FROM _migrations'); + const applied = new Set(rows.map((r: any) => r.name)); const files = fs.readdirSync(MIGRATIONS_DIR) .filter(f => f.endsWith('.sql')) @@ -29,7 +26,18 @@ export function runMigrations(): void { if (applied.has(file)) continue; const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8'); console.log(`Running migration: ${file}`); - db.exec(sql); - db.prepare('INSERT INTO _migrations (name) VALUES (?)').run(file); + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await client.query(sql); + await client.query('INSERT INTO _migrations (name) VALUES ($1)', [file]); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } } } diff --git a/backend/src/db/migrations/001_initial.sql b/backend/src/db/migrations/001_initial.sql deleted file mode 100644 index b057e72..0000000 --- a/backend/src/db/migrations/001_initial.sql +++ /dev/null @@ -1,136 +0,0 @@ --- Categories -CREATE TABLE IF NOT EXISTS categories ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - icon TEXT, - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - -INSERT OR IGNORE INTO categories (id, name, slug, sort_order) VALUES - ('01BACKEN00000000000000000', 'Backen', 'backen', 1), - ('01TORTEN00000000000000000', 'Torten', 'torten', 2), - ('01FRUEHSTUECK000000000000', 'Frühstück', 'fruehstueck', 3), - ('01MITTAG00000000000000000', 'Mittag', 'mittag', 4), - ('01ABEND000000000000000000', 'Abend', 'abend', 5), - ('01SNACKS0000000000000000A', 'Snacks', 'snacks', 6), - ('01DESSERTS000000000000000', 'Desserts', 'desserts', 7); - --- Tags -CREATE TABLE IF NOT EXISTS tags ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL UNIQUE, - slug TEXT NOT NULL UNIQUE, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Recipes -CREATE TABLE IF NOT EXISTS recipes ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - slug TEXT NOT NULL UNIQUE, - description TEXT, - category_id TEXT REFERENCES categories(id) ON DELETE SET NULL, - difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')) DEFAULT 'medium', - prep_time INTEGER, - cook_time INTEGER, - total_time INTEGER, - servings INTEGER DEFAULT 4, - image_url TEXT, - source_url TEXT, - is_favorite INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Recipe Tags (many-to-many) -CREATE TABLE IF NOT EXISTS recipe_tags ( - recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, - PRIMARY KEY (recipe_id, tag_id) -); - --- Ingredients -CREATE TABLE IF NOT EXISTS ingredients ( - id TEXT PRIMARY KEY, - recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - amount REAL, - unit TEXT, - name TEXT NOT NULL, - group_name TEXT, - sort_order INTEGER NOT NULL DEFAULT 0 -); - --- Steps -CREATE TABLE IF NOT EXISTS steps ( - id TEXT PRIMARY KEY, - recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - step_number INTEGER NOT NULL, - instruction TEXT NOT NULL, - duration_minutes INTEGER, - image_url TEXT -); - --- Notes -CREATE TABLE IF NOT EXISTS notes ( - id TEXT PRIMARY KEY, - recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - content TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- Shopping Items -CREATE TABLE IF NOT EXISTS shopping_items ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - amount REAL, - unit TEXT, - checked INTEGER NOT NULL DEFAULT 0, - recipe_id TEXT REFERENCES recipes(id) ON DELETE SET NULL, - created_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- FTS5 for recipes -CREATE VIRTUAL TABLE IF NOT EXISTS recipes_fts USING fts5( - title, description, content=recipes, content_rowid=rowid -); - -CREATE TRIGGER IF NOT EXISTS recipes_ai AFTER INSERT ON recipes BEGIN - INSERT INTO recipes_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description); -END; - -CREATE TRIGGER IF NOT EXISTS recipes_ad AFTER DELETE ON recipes BEGIN - INSERT INTO recipes_fts(recipes_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description); -END; - -CREATE TRIGGER IF NOT EXISTS recipes_au AFTER UPDATE ON recipes BEGIN - INSERT INTO recipes_fts(recipes_fts, rowid, title, description) VALUES ('delete', old.rowid, old.title, old.description); - INSERT INTO recipes_fts(rowid, title, description) VALUES (new.rowid, new.title, new.description); -END; - --- FTS5 for ingredients -CREATE VIRTUAL TABLE IF NOT EXISTS ingredients_fts USING fts5( - name, content=ingredients, content_rowid=rowid -); - -CREATE TRIGGER IF NOT EXISTS ingredients_ai AFTER INSERT ON ingredients BEGIN - INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name); -END; - -CREATE TRIGGER IF NOT EXISTS ingredients_ad AFTER DELETE ON ingredients BEGIN - INSERT INTO ingredients_fts(ingredients_fts, rowid, name) VALUES ('delete', old.rowid, old.name); -END; - -CREATE TRIGGER IF NOT EXISTS ingredients_au AFTER UPDATE ON ingredients BEGIN - INSERT INTO ingredients_fts(ingredients_fts, rowid, name) VALUES ('delete', old.rowid, old.name); - INSERT INTO ingredients_fts(rowid, name) VALUES (new.rowid, new.name); -END; - --- Indexes -CREATE INDEX IF NOT EXISTS idx_recipes_category ON recipes(category_id); -CREATE INDEX IF NOT EXISTS idx_recipes_slug ON recipes(slug); -CREATE INDEX IF NOT EXISTS idx_recipes_favorite ON recipes(is_favorite); -CREATE INDEX IF NOT EXISTS idx_ingredients_recipe ON ingredients(recipe_id); -CREATE INDEX IF NOT EXISTS idx_steps_recipe ON steps(recipe_id); -CREATE INDEX IF NOT EXISTS idx_notes_recipe ON notes(recipe_id); diff --git a/backend/src/db/migrations/001_initial_pg.sql b/backend/src/db/migrations/001_initial_pg.sql new file mode 100644 index 0000000..de332f4 --- /dev/null +++ b/backend/src/db/migrations/001_initial_pg.sql @@ -0,0 +1,181 @@ +-- Extensions +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- Categories +CREATE TABLE IF NOT EXISTS categories ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + icon TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Tags +CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Recipes (mit allen v2 Feldern) +CREATE TABLE IF NOT EXISTS recipes ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + description TEXT, + category_id TEXT REFERENCES categories(id) ON DELETE SET NULL, + difficulty TEXT CHECK(difficulty IN ('easy', 'medium', 'hard')) DEFAULT 'medium', + prep_time INTEGER, + cook_time INTEGER, + total_time INTEGER, + servings INTEGER DEFAULT 4, + image_url TEXT, + source_url TEXT, + image_source TEXT, + is_favorite INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Recipe Tags +CREATE TABLE IF NOT EXISTS recipe_tags ( + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (recipe_id, tag_id) +); + +-- Ingredients +CREATE TABLE IF NOT EXISTS ingredients ( + id TEXT PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + amount DOUBLE PRECISION, + unit TEXT, + name TEXT NOT NULL, + group_name TEXT, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +-- Steps +CREATE TABLE IF NOT EXISTS steps ( + id TEXT PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + step_number INTEGER NOT NULL, + instruction TEXT NOT NULL, + duration_minutes INTEGER, + image_url TEXT +); + +-- Notes (with user_id) +CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + user_id TEXT, + content TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Users +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + display_name TEXT NOT NULL, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Add FK for recipes.created_by and notes.user_id +ALTER TABLE recipes ADD CONSTRAINT fk_recipes_created_by FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL; +ALTER TABLE notes ADD CONSTRAINT fk_notes_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + +-- User Favorites +CREATE TABLE IF NOT EXISTS user_favorites ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, recipe_id) +); + +-- Households +CREATE TABLE IF NOT EXISTS households ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + invite_code TEXT UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Household Members +CREATE TABLE IF NOT EXISTS household_members ( + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'member')), + joined_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (household_id, user_id) +); + +-- Shopping Items (with user_id + household_id + source) +CREATE TABLE IF NOT EXISTS shopping_items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + amount DOUBLE PRECISION, + unit TEXT, + checked BOOLEAN NOT NULL DEFAULT FALSE, + recipe_id TEXT REFERENCES recipes(id) ON DELETE SET NULL, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + household_id TEXT REFERENCES households(id) ON DELETE CASCADE, + source TEXT DEFAULT 'recipe' CHECK (source IN ('recipe', 'manual')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Full-text Search using pg_trgm +CREATE INDEX IF NOT EXISTS idx_recipes_title_trgm ON recipes USING gin (title gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_recipes_description_trgm ON recipes USING gin (description gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_ingredients_name_trgm ON ingredients USING gin (name gin_trgm_ops); + +-- Standard Indexes +CREATE INDEX IF NOT EXISTS idx_recipes_category ON recipes(category_id); +CREATE INDEX IF NOT EXISTS idx_recipes_slug ON recipes(slug); +CREATE INDEX IF NOT EXISTS idx_recipes_favorite ON recipes(is_favorite); +CREATE INDEX IF NOT EXISTS idx_ingredients_recipe ON ingredients(recipe_id); +CREATE INDEX IF NOT EXISTS idx_steps_recipe ON steps(recipe_id); +CREATE INDEX IF NOT EXISTS idx_notes_recipe ON notes(recipe_id); +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id); +CREATE INDEX IF NOT EXISTS idx_user_favorites_recipe_id ON user_favorites(recipe_id); +CREATE INDEX IF NOT EXISTS idx_shopping_items_user_id ON shopping_items(user_id); +CREATE INDEX IF NOT EXISTS idx_shopping_items_household_id ON shopping_items(household_id); +CREATE INDEX IF NOT EXISTS idx_households_invite_code ON households(invite_code); +CREATE INDEX IF NOT EXISTS idx_household_members_user_id ON household_members(user_id); +CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id); +CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by); + +-- Seed Categories +INSERT INTO categories (id, name, slug, icon, sort_order) VALUES + ('01BACKEN00000000000000000', 'Backen', 'backen', '🥐', 1), + ('01TORTEN00000000000000000', 'Torten', 'torten', '🎂', 2), + ('01FRUEHSTUECK000000000000', 'Frühstück', 'fruehstueck', '🥨', 3), + ('01MITTAG00000000000000000', 'Mittag', 'mittag', '🥘', 4), + ('01ABEND000000000000000000', 'Abend', 'abend', '🍝', 5), + ('01SNACKS0000000000000000A', 'Snacks', 'snacks', '🌱', 6), + ('01DESSERTS000000000000000', 'Desserts', 'desserts', '🍮', 7) +ON CONFLICT (id) DO NOTHING; + +-- Updated_at trigger for recipes +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER recipes_updated_at BEFORE UPDATE ON recipes +FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER users_updated_at BEFORE UPDATE ON users +FOR EACH ROW EXECUTE FUNCTION update_updated_at(); diff --git a/backend/src/db/migrations/002_category_icons.sql b/backend/src/db/migrations/002_category_icons.sql deleted file mode 100644 index 5bc6a2b..0000000 --- a/backend/src/db/migrations/002_category_icons.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Add category icons and Vegan category -UPDATE categories SET icon = '🧁' WHERE slug = 'backen'; -UPDATE categories SET icon = '🎂' WHERE slug = 'torten'; -UPDATE categories SET icon = '🥐' WHERE slug = 'fruehstueck'; -UPDATE categories SET icon = '🍝' WHERE slug = 'mittag'; -UPDATE categories SET icon = '🥘' WHERE slug = 'abend'; -UPDATE categories SET icon = '🥨' WHERE slug = 'snacks'; -UPDATE categories SET icon = '🍮' WHERE slug = 'desserts'; - -INSERT OR IGNORE INTO categories (id, name, slug, icon, sort_order) VALUES - ('01VEGAN000000000000000000', 'Vegan', 'vegan', '🌱', 8); diff --git a/backend/src/db/migrations/003_auth.sql b/backend/src/db/migrations/003_auth.sql deleted file mode 100644 index 9dd465b..0000000 --- a/backend/src/db/migrations/003_auth.sql +++ /dev/null @@ -1,43 +0,0 @@ --- Auth v2 Migration: Users and Authentication - --- Users table -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - display_name TEXT NOT NULL, - avatar_url TEXT, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) -); - --- User favorites table (replaces is_favorite column approach) -CREATE TABLE IF NOT EXISTS user_favorites ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - recipe_id TEXT NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (datetime('now')), - UNIQUE(user_id, recipe_id) -); - --- Add user_id to shopping_items (nullable for migration) -ALTER TABLE shopping_items ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL; - --- Add user_id to notes (nullable for migration) -ALTER TABLE notes ADD COLUMN user_id TEXT REFERENCES users(id) ON DELETE SET NULL; - --- Add created_by to recipes (nullable for migration) -ALTER TABLE recipes ADD COLUMN created_by TEXT REFERENCES users(id) ON DELETE SET NULL; - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); -CREATE INDEX IF NOT EXISTS idx_user_favorites_user_id ON user_favorites(user_id); -CREATE INDEX IF NOT EXISTS idx_user_favorites_recipe_id ON user_favorites(recipe_id); -CREATE INDEX IF NOT EXISTS idx_shopping_items_user_id ON shopping_items(user_id); -CREATE INDEX IF NOT EXISTS idx_notes_user_id ON notes(user_id); -CREATE INDEX IF NOT EXISTS idx_recipes_created_by ON recipes(created_by); - --- Trigger for updating users.updated_at -CREATE TRIGGER IF NOT EXISTS users_update_timestamp AFTER UPDATE ON users BEGIN - UPDATE users SET updated_at = datetime('now') WHERE id = NEW.id; -END; \ No newline at end of file diff --git a/backend/src/db/migrations/004_households.sql b/backend/src/db/migrations/004_households.sql deleted file mode 100644 index 6ce3021..0000000 --- a/backend/src/db/migrations/004_households.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Auth v2 Phase 2: Households - --- Households table -CREATE TABLE IF NOT EXISTS households ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - invite_code TEXT UNIQUE NOT NULL, - created_at TEXT DEFAULT (datetime('now')) -); - --- Household members table (many-to-many relationship between users and households) -CREATE TABLE IF NOT EXISTS household_members ( - household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role TEXT NOT NULL CHECK (role IN ('owner', 'member')), - joined_at TEXT DEFAULT (datetime('now')), - PRIMARY KEY (household_id, user_id) -); - --- Add household_id to shopping_items for household shopping lists -ALTER TABLE shopping_items ADD COLUMN household_id TEXT REFERENCES households(id) ON DELETE CASCADE; - --- Indexes for performance -CREATE INDEX IF NOT EXISTS idx_households_invite_code ON households(invite_code); -CREATE INDEX IF NOT EXISTS idx_household_members_household_id ON household_members(household_id); -CREATE INDEX IF NOT EXISTS idx_household_members_user_id ON household_members(user_id); -CREATE INDEX IF NOT EXISTS idx_shopping_items_household_id ON shopping_items(household_id); \ No newline at end of file diff --git a/backend/src/index.ts b/backend/src/index.ts index 5a39f50..51a49ce 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -6,7 +6,7 @@ const PORT = Number(process.env.PORT || 6001); async function main() { console.log('Running migrations...'); - runMigrations(); + await runMigrations(); const app = await buildApp(); await app.listen({ port: PORT, host: '0.0.0.0' }); diff --git a/backend/src/routes/bot.ts b/backend/src/routes/bot.ts index 6ae3f28..51793be 100644 --- a/backend/src/routes/bot.ts +++ b/backend/src/routes/bot.ts @@ -15,7 +15,7 @@ export async function botRoutes(app: FastifyInstance) { app.get('/api/bot/recipes', async (request) => { const query = request.query as any; - return recipeSvc.listRecipes({ + return await recipeSvc.listRecipes({ page: query.page ? Number(query.page) : undefined, limit: query.limit ? Number(query.limit) : undefined, }); @@ -24,7 +24,7 @@ export async function botRoutes(app: FastifyInstance) { app.post('/api/bot/recipes', async (request, reply) => { const body = request.body as recipeSvc.CreateRecipeInput; if (!body.title) return reply.status(400).send({ error: 'title required' }); - const recipe = recipeSvc.createRecipe(body); + const recipe = await recipeSvc.createRecipe(body); return reply.status(201).send(recipe); }); diff --git a/backend/src/routes/categories.ts b/backend/src/routes/categories.ts index 5b8a245..d10585b 100644 --- a/backend/src/routes/categories.ts +++ b/backend/src/routes/categories.ts @@ -3,13 +3,13 @@ import { listCategories, createCategory } from '../services/recipe.service.js'; export async function categoryRoutes(app: FastifyInstance) { app.get('/api/categories', async () => { - return listCategories(); + return await listCategories(); }); app.post('/api/categories', async (request, reply) => { const { name } = request.body as { name: string }; if (!name) return reply.status(400).send({ error: 'name required' }); - const cat = createCategory(name); + const cat = await createCategory(name); return reply.status(201).send(cat); }); } diff --git a/backend/src/routes/notes.ts b/backend/src/routes/notes.ts index a92525f..da3a809 100644 --- a/backend/src/routes/notes.ts +++ b/backend/src/routes/notes.ts @@ -6,18 +6,15 @@ export async function noteRoutes(app: FastifyInstance) { app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => { const { id } = request.params as { id: string }; const userId = request.user?.id; - - return svc.listNotes(id, userId); + return await svc.listNotes(id, userId); }); app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const { content } = request.body as { content: string }; const userId = request.user?.id; - if (!content) return reply.status(400).send({ error: 'content required' }); - - const note = svc.createNote(id, content, userId); + const note = await svc.createNote(id, content, userId); if (!note) return reply.status(404).send({ error: 'Recipe not found' }); return reply.status(201).send(note); }); @@ -26,10 +23,8 @@ export async function noteRoutes(app: FastifyInstance) { const { id } = request.params as { id: string }; const { content } = request.body as { content: string }; const userId = request.user?.id; - if (!content) return reply.status(400).send({ error: 'content required' }); - - const note = svc.updateNote(id, content, userId); + const note = await svc.updateNote(id, content, userId); if (!note) return reply.status(404).send({ error: 'Not found' }); return note; }); @@ -37,8 +32,7 @@ export async function noteRoutes(app: FastifyInstance) { app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const userId = request.user?.id; - - const ok = svc.deleteNote(id, userId); + const ok = await svc.deleteNote(id, userId); if (!ok) return reply.status(404).send({ error: 'Not found' }); return { ok: true }; }); diff --git a/backend/src/routes/og-scrape.ts b/backend/src/routes/og-scrape.ts index 44ddf40..5133e77 100644 --- a/backend/src/routes/og-scrape.ts +++ b/backend/src/routes/og-scrape.ts @@ -1,6 +1,6 @@ import { FastifyInstance } from 'fastify'; import { scrapeOgData } from '../services/og-scraper.service.js'; -import { getDb } from '../db/connection.js'; +import { query } from '../db/connection.js'; import sharp from 'sharp'; import fs from 'fs'; import path from 'path'; @@ -10,7 +10,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DATA_DIR = path.resolve(__dirname, '../../data'); export async function ogScrapeRoutes(app: FastifyInstance) { - // Preview: Just fetch OG data without downloading app.get('/api/og-preview', async (request, reply) => { const { url } = request.query as { url?: string }; if (!url) return reply.status(400).send({ error: 'url parameter required' }); @@ -23,23 +22,19 @@ export async function ogScrapeRoutes(app: FastifyInstance) { } }); - // Download OG image and attach to recipe app.post('/api/recipes/:id/fetch-image', async (request, reply) => { const { id } = request.params as { id: string }; const { url } = request.body as { url: string }; if (!url) return reply.status(400).send({ error: 'url required' }); - const db = getDb(); - const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any; - if (!recipe) return reply.status(404).send({ error: 'Recipe not found' }); + const { rows } = await query('SELECT id FROM recipes WHERE id = $1', [id]); + if (rows.length === 0) return reply.status(404).send({ error: 'Recipe not found' }); try { - // Scrape OG data const ogData = await scrapeOgData(url); if (!ogData.image) return reply.status(404).send({ error: 'No image found at URL' }); - // Download image const imgRes = await fetch(ogData.image, { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: AbortSignal.timeout(15000), @@ -48,7 +43,6 @@ export async function ogScrapeRoutes(app: FastifyInstance) { const buffer = Buffer.from(await imgRes.arrayBuffer()); - // Process with sharp → WebP, max 1200px wide const imgDir = path.join(DATA_DIR, 'images', 'recipes', id); fs.mkdirSync(imgDir, { recursive: true }); const imgPath = path.join(imgDir, 'hero.webp'); @@ -58,10 +52,8 @@ export async function ogScrapeRoutes(app: FastifyInstance) { .webp({ quality: 85 }) .toFile(imgPath); - // Update recipe const imageUrl = `/images/recipes/${id}/hero.webp`; - db.prepare('UPDATE recipes SET image_url = ?, source_url = ?, updated_at = datetime(\'now\') WHERE id = ?') - .run(imageUrl, url, id); + await query('UPDATE recipes SET image_url = $1, source_url = $2, updated_at = NOW() WHERE id = $3', [imageUrl, url, id]); return { ok: true, diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index fc383f3..ee19416 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -4,7 +4,7 @@ import * as svc from '../services/recipe.service.js'; export async function recipeRoutes(app: FastifyInstance) { app.get('/api/recipes/random', async (request, reply) => { - const recipe = svc.getRandomRecipe(); + const recipe = await svc.getRandomRecipe(); if (!recipe) return reply.status(404).send({ error: 'No recipes found' }); return recipe; }); @@ -12,7 +12,7 @@ export async function recipeRoutes(app: FastifyInstance) { app.get('/api/recipes/search', async (request) => { const { q } = request.query as { q?: string }; if (!q) return { data: [], total: 0 }; - const results = svc.searchRecipes(q); + const results = await svc.searchRecipes(q); return { data: results, total: results.length }; }); @@ -20,7 +20,7 @@ export async function recipeRoutes(app: FastifyInstance) { const query = request.query as any; const userId = request.user?.id; - return svc.listRecipes({ + return await svc.listRecipes({ page: query.page ? Number(query.page) : undefined, limit: query.limit ? Number(query.limit) : undefined, category_id: query.category_id, @@ -34,7 +34,7 @@ export async function recipeRoutes(app: FastifyInstance) { app.get('/api/recipes/:slug', async (request, reply) => { const { slug } = request.params as { slug: string }; - const recipe = svc.getRecipeBySlug(slug); + const recipe = await svc.getRecipeBySlug(slug); if (!recipe) return reply.status(404).send({ error: 'Not found' }); return recipe; }); @@ -42,20 +42,20 @@ export async function recipeRoutes(app: FastifyInstance) { app.post('/api/recipes', async (request, reply) => { const body = request.body as svc.CreateRecipeInput; if (!body.title) return reply.status(400).send({ error: 'title required' }); - const recipe = svc.createRecipe(body); + const recipe = await svc.createRecipe(body); return reply.status(201).send(recipe); }); app.put('/api/recipes/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const recipe = svc.updateRecipe(id, request.body as any); + const recipe = await svc.updateRecipe(id, request.body as any); if (!recipe) return reply.status(404).send({ error: 'Not found' }); return recipe; }); app.delete('/api/recipes/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const ok = svc.deleteRecipe(id); + const ok = await svc.deleteRecipe(id); if (!ok) return reply.status(404).send({ error: 'Not found' }); return { ok: true }; }); @@ -64,7 +64,7 @@ export async function recipeRoutes(app: FastifyInstance) { const { id } = request.params as { id: string }; const userId = request.user?.id; - const result = svc.toggleFavorite(id, userId); + const result = await svc.toggleFavorite(id, userId); if (!result) return reply.status(404).send({ error: 'Not found' }); return result; }); diff --git a/backend/src/routes/shopping.ts b/backend/src/routes/shopping.ts index 1f1aab4..d844ba8 100644 --- a/backend/src/routes/shopping.ts +++ b/backend/src/routes/shopping.ts @@ -3,24 +3,18 @@ import { optionalAuthMiddleware } from '../middleware/auth.js'; import * as svc from '../services/shopping.service.js'; export async function shoppingRoutes(app: FastifyInstance) { - // List shopping items with optional authentication app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => { const { scope } = request.query as { scope?: 'personal' | 'household' }; const userId = request.user?.id; - const shoppingScope = scope || 'personal'; - - return svc.listItems(userId, shoppingScope); + return await svc.listItems(userId, scope || 'personal'); }); - // Add items from recipe with optional authentication app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const { scope } = request.query as { scope?: 'personal' | 'household' }; const userId = request.user?.id; - const shoppingScope = scope || 'personal'; - try { - const items = svc.addFromRecipe(id, userId, shoppingScope); + const items = await svc.addFromRecipe(id, userId, scope || 'personal'); if (!items) return reply.status(404).send({ error: 'Recipe not found' }); return reply.status(201).send({ added: items.length }); } catch (error: any) { @@ -31,17 +25,13 @@ export async function shoppingRoutes(app: FastifyInstance) { } }); - // Add single item with optional authentication app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string }; const { scope } = request.query as { scope?: 'personal' | 'household' }; const userId = request.user?.id; - const shoppingScope = scope || 'personal'; - if (!name) return reply.status(400).send({ error: 'name required' }); - try { - const item = svc.addItem(name, amount, unit, userId, shoppingScope); + const item = await svc.addItem(name, amount, unit, userId, scope || 'personal'); return reply.status(201).send(item); } catch (error: any) { if (error.message === 'USER_NOT_IN_HOUSEHOLD') { @@ -51,42 +41,32 @@ export async function shoppingRoutes(app: FastifyInstance) { } }); - // Toggle check status with optional authentication app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const userId = request.user?.id; - - const item = svc.toggleCheck(id, userId); + const item = await svc.toggleCheck(id, userId); if (!item) return reply.status(404).send({ error: 'Not found' }); return item; }); - // Delete all items with optional authentication app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => { const { scope } = request.query as { scope?: 'personal' | 'household' }; const userId = request.user?.id; - const shoppingScope = scope || 'personal'; - - const count = svc.deleteAll(userId, shoppingScope); + const count = await svc.deleteAll(userId, scope || 'personal'); return { ok: true, deleted: count }; }); - // Delete checked items with optional authentication app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => { const { scope } = request.query as { scope?: 'personal' | 'household' }; const userId = request.user?.id; - const shoppingScope = scope || 'personal'; - - const count = svc.deleteChecked(userId, shoppingScope); + const count = await svc.deleteChecked(userId, scope || 'personal'); return { ok: true, deleted: count }; }); - // Delete single item with optional authentication app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => { const { id } = request.params as { id: string }; const userId = request.user?.id; - - const ok = svc.deleteItem(id, userId); + const ok = await svc.deleteItem(id, userId); if (!ok) return reply.status(404).send({ error: 'Not found' }); return { ok: true }; }); diff --git a/backend/src/routes/tags.ts b/backend/src/routes/tags.ts index 1d9ff71..8036010 100644 --- a/backend/src/routes/tags.ts +++ b/backend/src/routes/tags.ts @@ -3,12 +3,12 @@ import * as svc from '../services/tag.service.js'; export async function tagRoutes(app: FastifyInstance) { app.get('/api/tags', async () => { - return svc.listTags(); + return await svc.listTags(); }); app.get('/api/tags/:name/recipes', async (request, reply) => { const { name } = request.params as { name: string }; - const result = svc.getRecipesByTag(name); + const result = await svc.getRecipesByTag(name); if (!result) return reply.status(404).send({ error: 'Tag not found' }); return result; }); diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts index b5a110b..a4aded3 100644 --- a/backend/src/services/auth.service.ts +++ b/backend/src/services/auth.service.ts @@ -1,7 +1,7 @@ import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; import { ulid } from 'ulid'; -import { getDb } from '../db/connection.js'; +import { query } from '../db/connection.js'; const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production'; const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key-change-in-production'; @@ -30,132 +30,77 @@ export interface AuthResult { } class AuthService { - private db = getDb(); - async register(email: string, password: string, displayName: string): Promise { - // Check if user already exists - const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email); - if (existingUser) { - throw new Error('EMAIL_EXISTS'); - } + const { rows: existing } = await query('SELECT id FROM users WHERE email = $1', [email]); + if (existing.length > 0) throw new Error('EMAIL_EXISTS'); - // Hash password const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); - - // Create user const userId = ulid(); - const now = new Date().toISOString(); - - this.db.prepare(` - INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `).run(userId, email, passwordHash, displayName, now, now); - // Get created user - const user = this.db.prepare(` - SELECT id, email, display_name, avatar_url, created_at, updated_at - FROM users WHERE id = ? - `).get(userId) as User; + await query( + 'INSERT INTO users (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)', + [userId, email, passwordHash, displayName] + ); - // Generate tokens + const { rows } = await query( + 'SELECT id, email, display_name, avatar_url, created_at, updated_at FROM users WHERE id = $1', + [userId] + ); + const user = rows[0] as User; const tokens = this.generateTokens(user); - return { user, tokens }; } async login(email: string, password: string): Promise { - // Get user by email - const userWithPassword = this.db.prepare(` - SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at - FROM users WHERE email = ? - `).get(email) as any; + const { rows } = await query( + 'SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at FROM users WHERE email = $1', + [email] + ); + if (rows.length === 0) throw new Error('INVALID_CREDENTIALS'); - if (!userWithPassword) { - throw new Error('INVALID_CREDENTIALS'); - } - - // Check password + const userWithPassword = rows[0]; const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash); - if (!passwordValid) { - throw new Error('INVALID_CREDENTIALS'); - } + if (!passwordValid) throw new Error('INVALID_CREDENTIALS'); - // Remove password from user object const { password_hash, ...user } = userWithPassword; - - // Generate tokens const tokens = this.generateTokens(user); - return { user, tokens }; } async getProfile(userId: string): Promise { - const user = this.db.prepare(` - SELECT id, email, display_name, avatar_url, created_at, updated_at - FROM users WHERE id = ? - `).get(userId) as User | undefined; - - return user || null; + const { rows } = await query( + 'SELECT id, email, display_name, avatar_url, created_at, updated_at FROM users WHERE id = $1', + [userId] + ); + return rows[0] || null; } async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise { const updates: string[] = []; const values: any[] = []; + let idx = 1; - if (data.display_name !== undefined) { - updates.push('display_name = ?'); - values.push(data.display_name); - } + if (data.display_name !== undefined) { updates.push(`display_name = $${idx}`); values.push(data.display_name); idx++; } + if (data.avatar_url !== undefined) { updates.push(`avatar_url = $${idx}`); values.push(data.avatar_url); idx++; } + if (updates.length === 0) throw new Error('NO_UPDATES_PROVIDED'); - if (data.avatar_url !== undefined) { - updates.push('avatar_url = ?'); - values.push(data.avatar_url); - } - - if (updates.length === 0) { - throw new Error('NO_UPDATES_PROVIDED'); - } - - updates.push('updated_at = ?'); - values.push(new Date().toISOString()); values.push(userId); - - this.db.prepare(` - UPDATE users SET ${updates.join(', ')} WHERE id = ? - `).run(...values); + await query(`UPDATE users SET ${updates.join(', ')} WHERE id = $${idx}`, values); const user = await this.getProfile(userId); - if (!user) { - throw new Error('USER_NOT_FOUND'); - } - + if (!user) throw new Error('USER_NOT_FOUND'); return user; } async changePassword(userId: string, currentPassword: string, newPassword: string): Promise { - // Get current password hash - const userWithPassword = this.db.prepare(` - SELECT password_hash FROM users WHERE id = ? - `).get(userId) as any; + const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [userId]); + if (rows.length === 0) throw new Error('USER_NOT_FOUND'); - if (!userWithPassword) { - throw new Error('USER_NOT_FOUND'); - } - - // Verify current password - const currentPasswordValid = await bcrypt.compare(currentPassword, userWithPassword.password_hash); - if (!currentPasswordValid) { - throw new Error('INVALID_CURRENT_PASSWORD'); - } - - // Hash new password - const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); - - // Update password - this.db.prepare(` - UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ? - `).run(newPasswordHash, new Date().toISOString(), userId); + const valid = await bcrypt.compare(currentPassword, rows[0].password_hash); + if (!valid) throw new Error('INVALID_CURRENT_PASSWORD'); + const newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + await query('UPDATE users SET password_hash = $1 WHERE id = $2', [newHash, userId]); return true; } @@ -163,11 +108,7 @@ class AuthService { try { const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any; const user = await this.getProfile(decoded.sub); - - if (!user) { - throw new Error('USER_NOT_FOUND'); - } - + if (!user) throw new Error('USER_NOT_FOUND'); const tokens = this.generateTokens(user); return { user, tokens }; } catch (error) { @@ -184,17 +125,11 @@ class AuthService { } private generateTokens(user: User): AuthTokens { - const payload = { - sub: user.id, - email: user.email, - display_name: user.display_name, - }; - + const payload = { sub: user.id, email: user.email, display_name: user.display_name }; const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN }); const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN }); - return { accessToken, refreshToken }; } } -export const authService = new AuthService(); \ No newline at end of file +export const authService = new AuthService(); diff --git a/backend/src/services/household.service.ts b/backend/src/services/household.service.ts index ce95502..8d06f11 100644 --- a/backend/src/services/household.service.ts +++ b/backend/src/services/household.service.ts @@ -1,5 +1,5 @@ import { ulid } from 'ulid'; -import { getDb } from '../db/connection.js'; +import { query } from '../db/connection.js'; export interface Household { id: string; @@ -22,9 +22,6 @@ export interface HouseholdWithMembers extends Household { } class HouseholdService { - private db = getDb(); - - // Generate a random 8-character invite code private generateInviteCode(): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let result = ''; @@ -34,185 +31,102 @@ class HouseholdService { return result; } - // Ensure invite code is unique - private generateUniqueInviteCode(): string { + private async generateUniqueInviteCode(): Promise { let code: string; let attempts = 0; do { code = this.generateInviteCode(); attempts++; - if (attempts > 100) { - throw new Error('Could not generate unique invite code'); - } - } while (this.db.prepare('SELECT id FROM households WHERE invite_code = ?').get(code)); - - return code; + if (attempts > 100) throw new Error('Could not generate unique invite code'); + const { rows } = await query('SELECT id FROM households WHERE invite_code = $1', [code]); + if (rows.length === 0) return code; + } while (true); } async createHousehold(userId: string, name: string): Promise { - // Check if user is already in a household - const existingMembership = this.db.prepare(` - SELECT household_id FROM household_members WHERE user_id = ? - `).get(userId); - - if (existingMembership) { - throw new Error('USER_ALREADY_IN_HOUSEHOLD'); - } + const { rows: existing } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]); + if (existing.length > 0) throw new Error('USER_ALREADY_IN_HOUSEHOLD'); const householdId = ulid(); - const inviteCode = this.generateUniqueInviteCode(); + const inviteCode = await this.generateUniqueInviteCode(); - // Create household - this.db.prepare(` - INSERT INTO households (id, name, invite_code) - VALUES (?, ?, ?) - `).run(householdId, name, inviteCode); + await query('INSERT INTO households (id, name, invite_code) VALUES ($1, $2, $3)', [householdId, name, inviteCode]); + await query("INSERT INTO household_members (household_id, user_id, role) VALUES ($1, $2, 'owner')", [householdId, userId]); - // Add user as owner - this.db.prepare(` - INSERT INTO household_members (household_id, user_id, role) - VALUES (?, ?, 'owner') - `).run(householdId, userId); - - // Return household with members const household = await this.getMyHousehold(userId); - if (!household) { - throw new Error('Failed to create household'); - } - + if (!household) throw new Error('Failed to create household'); return household; } async joinHousehold(userId: string, inviteCode: string): Promise { - // Check if user is already in a household - const existingMembership = this.db.prepare(` - SELECT household_id FROM household_members WHERE user_id = ? - `).get(userId); + const { rows: existing } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]); + if (existing.length > 0) throw new Error('USER_ALREADY_IN_HOUSEHOLD'); - if (existingMembership) { - throw new Error('USER_ALREADY_IN_HOUSEHOLD'); - } + const { rows: households } = await query('SELECT id FROM households WHERE invite_code = $1', [inviteCode]); + if (households.length === 0) throw new Error('INVALID_INVITE_CODE'); - // Find household by invite code - const household = this.db.prepare(` - SELECT id FROM households WHERE invite_code = ? - `).get(inviteCode) as { id: string } | undefined; + await query("INSERT INTO household_members (household_id, user_id, role) VALUES ($1, $2, 'member')", [households[0].id, userId]); - if (!household) { - throw new Error('INVALID_INVITE_CODE'); - } - - // Add user as member - this.db.prepare(` - INSERT INTO household_members (household_id, user_id, role) - VALUES (?, ?, 'member') - `).run(household.id, userId); - - // Return household with members const result = await this.getMyHousehold(userId); - if (!result) { - throw new Error('Failed to join household'); - } - + if (!result) throw new Error('Failed to join household'); return result; } async getMyHousehold(userId: string): Promise { - // Get user's household - const householdMembership = this.db.prepare(` + const { rows } = await query(` SELECT h.*, hm.role FROM households h JOIN household_members hm ON h.id = hm.household_id - WHERE hm.user_id = ? - `).get(userId) as (Household & { role: 'owner' | 'member' }) | undefined; + WHERE hm.user_id = $1 + `, [userId]); - if (!householdMembership) { - return null; - } + if (rows.length === 0) return null; + const householdRow = rows[0]; - // Get all members of the household - const members = this.db.prepare(` - SELECT - u.id as user_id, - u.email, - u.display_name, - u.avatar_url, - hm.role, - hm.joined_at + const { rows: members } = await query(` + SELECT u.id as user_id, u.email, u.display_name, u.avatar_url, hm.role, hm.joined_at FROM household_members hm JOIN users u ON hm.user_id = u.id - WHERE hm.household_id = ? + WHERE hm.household_id = $1 ORDER BY hm.role DESC, hm.joined_at ASC - `).all(householdMembership.id) as HouseholdMember[]; + `, [householdRow.id]); - const { role, ...household } = householdMembership; - - return { - ...household, - members - }; + const { role, ...household } = householdRow; + return { ...household, members }; } async leaveHousehold(userId: string, householdId: string): Promise { - // Check if user is member of this household - const membership = this.db.prepare(` - SELECT role FROM household_members - WHERE user_id = ? AND household_id = ? - `).get(userId, householdId) as { role: string } | undefined; + const { rows } = await query( + 'SELECT role FROM household_members WHERE user_id = $1 AND household_id = $2', + [userId, householdId] + ); + if (rows.length === 0) throw new Error('NOT_HOUSEHOLD_MEMBER'); - if (!membership) { - throw new Error('NOT_HOUSEHOLD_MEMBER'); - } - - // If user is owner, check if there are other members - if (membership.role === 'owner') { - const memberCount = this.db.prepare(` - SELECT COUNT(*) as count FROM household_members WHERE household_id = ? - `).get(householdId) as { count: number }; - - if (memberCount.count > 1) { - throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS'); - } - - // If owner is the only member, delete the entire household - this.db.prepare('DELETE FROM households WHERE id = ?').run(householdId); + if (rows[0].role === 'owner') { + const { rows: countRows } = await query('SELECT COUNT(*) as count FROM household_members WHERE household_id = $1', [householdId]); + if (parseInt(countRows[0].count) > 1) throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS'); + await query('DELETE FROM households WHERE id = $1', [householdId]); } else { - // Remove member from household - this.db.prepare(` - DELETE FROM household_members - WHERE user_id = ? AND household_id = ? - `).run(userId, householdId); + await query('DELETE FROM household_members WHERE user_id = $1 AND household_id = $2', [userId, householdId]); } } async regenerateInviteCode(userId: string, householdId: string): Promise { - // Check if user is owner of this household - const membership = this.db.prepare(` - SELECT role FROM household_members - WHERE user_id = ? AND household_id = ? - `).get(userId, householdId) as { role: string } | undefined; + const { rows } = await query( + 'SELECT role FROM household_members WHERE user_id = $1 AND household_id = $2', + [userId, householdId] + ); + if (rows.length === 0 || rows[0].role !== 'owner') throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE'); - if (!membership || membership.role !== 'owner') { - throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE'); - } - - const newInviteCode = this.generateUniqueInviteCode(); - - this.db.prepare(` - UPDATE households SET invite_code = ? WHERE id = ? - `).run(newInviteCode, householdId); - - return newInviteCode; + const newCode = await this.generateUniqueInviteCode(); + await query('UPDATE households SET invite_code = $1 WHERE id = $2', [newCode, householdId]); + return newCode; } async getHouseholdByInviteCode(inviteCode: string): Promise { - const household = this.db.prepare(` - SELECT id, name, invite_code, created_at - FROM households WHERE invite_code = ? - `).get(inviteCode) as Household | undefined; - - return household || null; + const { rows } = await query('SELECT id, name, invite_code, created_at FROM households WHERE invite_code = $1', [inviteCode]); + return rows[0] || null; } } -export const householdService = new HouseholdService(); \ No newline at end of file +export const householdService = new HouseholdService(); diff --git a/backend/src/services/image.service.ts b/backend/src/services/image.service.ts index 92e67d1..435ecab 100644 --- a/backend/src/services/image.service.ts +++ b/backend/src/services/image.service.ts @@ -1,4 +1,4 @@ -import { getDb } from '../db/connection.js'; +import { query } from '../db/connection.js'; import sharp from 'sharp'; import path from 'path'; import fs from 'fs'; @@ -12,9 +12,8 @@ async function ensureDir(dir: string) { } export async function saveRecipeImage(recipeId: string, buffer: Buffer) { - const db = getDb(); - const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId); - if (!recipe) return null; + const { rows } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]); + if (rows.length === 0) return null; const dir = path.join(DATA_DIR, 'recipes', recipeId); await ensureDir(dir); @@ -23,14 +22,14 @@ export async function saveRecipeImage(recipeId: string, buffer: Buffer) { await sharp(buffer).resize({ width: 400, withoutEnlargement: true }).webp({ quality: 70 }).toFile(path.join(dir, 'hero_thumb.webp')); const imagePath = `/images/recipes/${recipeId}/hero.webp`; - db.prepare('UPDATE recipes SET image_url = ?, updated_at = datetime(\'now\') WHERE id = ?').run(imagePath, recipeId); + await query('UPDATE recipes SET image_url = $1, updated_at = NOW() WHERE id = $2', [imagePath, recipeId]); return { image_url: imagePath, thumb_url: `/images/recipes/${recipeId}/hero_thumb.webp` }; } export async function saveStepImage(recipeId: string, stepNumber: number, buffer: Buffer) { - const db = getDb(); - const step = db.prepare('SELECT id FROM steps WHERE recipe_id = ? AND step_number = ?').get(recipeId, stepNumber) as any; - if (!step) return null; + const { rows } = await query('SELECT id FROM steps WHERE recipe_id = $1 AND step_number = $2', [recipeId, stepNumber]); + if (rows.length === 0) return null; + const step = rows[0]; const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps'); await ensureDir(dir); @@ -39,7 +38,7 @@ export async function saveStepImage(recipeId: string, stepNumber: number, buffer await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, filename)); const imageUrl = `/images/recipes/${recipeId}/steps/${filename}`; - db.prepare('UPDATE steps SET image_url = ? WHERE id = ?').run(imageUrl, step.id); + await query('UPDATE steps SET image_url = $1 WHERE id = $2', [imageUrl, step.id]); return { image_url: imageUrl }; } diff --git a/backend/src/services/note.service.ts b/backend/src/services/note.service.ts index 092898f..5490ace 100644 --- a/backend/src/services/note.service.ts +++ b/backend/src/services/note.service.ts @@ -1,64 +1,55 @@ -import { getDb } from '../db/connection.js'; +import { query } from '../db/connection.js'; import { ulid } from 'ulid'; -export function listNotes(recipeId: string, userId?: string) { - const db = getDb(); - +export async function listNotes(recipeId: string, userId?: string) { if (!userId) { - // Legacy: return all notes without user filtering - return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id IS NULL ORDER BY created_at DESC').all(recipeId); + const { rows } = await query('SELECT * FROM notes WHERE recipe_id = $1 AND user_id IS NULL ORDER BY created_at DESC', [recipeId]); + return rows; } - - // Return only user's notes - return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id = ? ORDER BY created_at DESC').all(recipeId, userId); + const { rows } = await query('SELECT * FROM notes WHERE recipe_id = $1 AND user_id = $2 ORDER BY created_at DESC', [recipeId, userId]); + return rows; } -export function createNote(recipeId: string, content: string, userId?: string) { - const db = getDb(); - const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId); - if (!recipe) return null; - +export async function createNote(recipeId: string, content: string, userId?: string) { + const { rows: recipe } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]); + if (recipe.length === 0) return null; + const id = ulid(); - db.prepare('INSERT INTO notes (id, recipe_id, content, user_id) VALUES (?, ?, ?, ?)').run(id, recipeId, content, userId || null); - return db.prepare('SELECT * FROM notes WHERE id = ?').get(id); + await query('INSERT INTO notes (id, recipe_id, content, user_id) VALUES ($1, $2, $3, $4)', [id, recipeId, content, userId || null]); + const { rows } = await query('SELECT * FROM notes WHERE id = $1', [id]); + return rows[0]; } -export function updateNote(id: string, content: string, userId?: string) { - const db = getDb(); - - let query: string; +export async function updateNote(id: string, content: string, userId?: string) { + let sql: string; let params: any[]; - + if (!userId) { - // Legacy: update notes without user filtering - query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id IS NULL'; + sql = 'UPDATE notes SET content = $1 WHERE id = $2 AND user_id IS NULL'; params = [content, id]; } else { - // Update only if note belongs to user - query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id = ?'; + sql = 'UPDATE notes SET content = $1 WHERE id = $2 AND user_id = $3'; params = [content, id, userId]; } - - const result = db.prepare(query).run(...params); - if (result.changes === 0) return null; - return db.prepare('SELECT * FROM notes WHERE id = ?').get(id); + + const result = await query(sql, params); + if ((result.rowCount ?? 0) === 0) return null; + const { rows } = await query('SELECT * FROM notes WHERE id = $1', [id]); + return rows[0]; } -export function deleteNote(id: string, userId?: string): boolean { - const db = getDb(); - - let query: string; +export async function deleteNote(id: string, userId?: string): Promise { + let sql: string; let params: any[]; - + if (!userId) { - // Legacy: delete notes without user filtering - query = 'DELETE FROM notes WHERE id = ? AND user_id IS NULL'; + sql = 'DELETE FROM notes WHERE id = $1 AND user_id IS NULL'; params = [id]; } else { - // Delete only if note belongs to user - query = 'DELETE FROM notes WHERE id = ? AND user_id = ?'; + sql = 'DELETE FROM notes WHERE id = $1 AND user_id = $2'; params = [id, userId]; } - - return db.prepare(query).run(...params).changes > 0; + + const result = await query(sql, params); + return (result.rowCount ?? 0) > 0; } diff --git a/backend/src/services/recipe.service.ts b/backend/src/services/recipe.service.ts index 91ec634..d1488ea 100644 --- a/backend/src/services/recipe.service.ts +++ b/backend/src/services/recipe.service.ts @@ -1,19 +1,21 @@ -import { getDb } from '../db/connection.js'; +import { pool, query } from '../db/connection.js'; import { ulid } from 'ulid'; -function syncTags(db: any, recipeId: string, tags: string[]) { - db.prepare('DELETE FROM recipe_tags WHERE recipe_id = ?').run(recipeId); +async function syncTags(client: any, recipeId: string, tags: string[]) { + await client.query('DELETE FROM recipe_tags WHERE recipe_id = $1', [recipeId]); for (const tagName of tags) { const trimmed = tagName.trim(); if (!trimmed) continue; const slug = trimmed.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); - let tag = db.prepare('SELECT id FROM tags WHERE slug = ?').get(slug) as any; - if (!tag) { - const tagId = ulid(); - db.prepare('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)').run(tagId, trimmed, slug); - tag = { id: tagId }; + const { rows } = await client.query('SELECT id FROM tags WHERE slug = $1', [slug]); + let tagId: string; + if (rows.length === 0) { + tagId = ulid(); + await client.query('INSERT INTO tags (id, name, slug) VALUES ($1, $2, $3)', [tagId, trimmed, slug]); + } else { + tagId = rows[0].id; } - db.prepare('INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)').run(recipeId, tag.id); + await client.query('INSERT INTO recipe_tags (recipe_id, tag_id) VALUES ($1, $2) ON CONFLICT DO NOTHING', [recipeId, tagId]); } } @@ -25,15 +27,14 @@ function slugify(text: string): string { .replace(/^-|-$/g, ''); } -function ensureUniqueSlug(baseSlug: string, excludeId?: string): string { - const db = getDb(); +async function ensureUniqueSlug(baseSlug: string, excludeId?: string): Promise { let slug = baseSlug; let i = 1; while (true) { - const existing = excludeId - ? db.prepare('SELECT id FROM recipes WHERE slug = ? AND id != ?').get(slug, excludeId) - : db.prepare('SELECT id FROM recipes WHERE slug = ?').get(slug); - if (!existing) return slug; + const { rows } = excludeId + ? await query('SELECT id FROM recipes WHERE slug = $1 AND id != $2', [slug, excludeId]) + : await query('SELECT id FROM recipes WHERE slug = $1', [slug]); + if (rows.length === 0) return slug; slug = `${baseSlug}-${i++}`; } } @@ -61,225 +62,234 @@ function mapTimeFields(row: any) { return row; } -export function listRecipes(opts: { +export async function listRecipes(opts: { page?: number; limit?: number; category_id?: string; category_slug?: string; favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string; }) { - const db = getDb(); const page = opts.page || 1; const limit = opts.limit || 20; const offset = (page - 1) * limit; const conditions: string[] = []; const params: any[] = []; - - if (opts.category_id) { conditions.push('r.category_id = ?'); params.push(opts.category_id); } - if (opts.category_slug) { conditions.push('c.slug = ?'); params.push(opts.category_slug); } - if (opts.favorite !== undefined && opts.userId) { - conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL')); - } - if (opts.difficulty) { conditions.push('r.difficulty = ?'); params.push(opts.difficulty); } - if (opts.maxTime) { conditions.push('r.total_time <= ?'); params.push(opts.maxTime); } + let paramIdx = 1; let joins = 'LEFT JOIN categories c ON r.category_id = c.id'; if (opts.userId) { - joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?'; - params.unshift(opts.userId); + joins += ` LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = $${paramIdx}`; + params.push(opts.userId); + paramIdx++; } + if (opts.category_id) { conditions.push(`r.category_id = $${paramIdx}`); params.push(opts.category_id); paramIdx++; } + if (opts.category_slug) { conditions.push(`c.slug = $${paramIdx}`); params.push(opts.category_slug); paramIdx++; } + if (opts.favorite !== undefined && opts.userId) { + conditions.push('uf.user_id IS ' + (opts.favorite ? 'NOT NULL' : 'NULL')); + } + if (opts.difficulty) { conditions.push(`r.difficulty = $${paramIdx}`); params.push(opts.difficulty); paramIdx++; } + if (opts.maxTime) { conditions.push(`r.total_time <= $${paramIdx}`); params.push(opts.maxTime); paramIdx++; } + const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : ''; - - // Adjust parameter positions based on whether userId is included - const countParams = opts.userId ? [opts.userId, ...params.slice(1)] : params; - const dataParams = opts.userId ? [opts.userId, ...params.slice(1), limit, offset] : [...params, limit, offset]; - const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`).get(...countParams) as any; - const rows = db.prepare( - `SELECT r.*, c.name as category_name, c.slug as category_slug, - ${opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite'} - FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?` - ).all(...dataParams); + const countResult = await query(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`, params); + const total = parseInt(countResult.rows[0].total); - const data = rows.map(mapTimeFields); - return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) }; + const dataParams = [...params, limit, offset]; + const selectFav = opts.userId ? '(uf.user_id IS NOT NULL) as is_favorite' : 'r.is_favorite'; + const dataResult = await query( + `SELECT r.*, c.name as category_name, c.slug as category_slug, ${selectFav} + FROM recipes r ${joins} ${where} ORDER BY r.created_at DESC LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`, + dataParams + ); + + const data = dataResult.rows.map(mapTimeFields); + return { data, total, page, limit, totalPages: Math.ceil(total / limit) }; } -export function getRecipeBySlug(slug: string) { - const db = getDb(); - const recipe = db.prepare( - 'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = ?' - ).get(slug) as any; - if (!recipe) return null; +export async function getRecipeBySlug(slug: string) { + const { rows } = await query( + 'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = $1', + [slug] + ); + if (rows.length === 0) return null; + const recipe = rows[0]; - recipe.ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ? ORDER BY sort_order').all(recipe.id); - recipe.steps = db.prepare('SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number').all(recipe.id); - recipe.notes = db.prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipe.id); - const tagRows = db.prepare( - 'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = ?' - ).all(recipe.id) as { name: string }[]; - recipe.tags = tagRows.map(t => t.name); + const ingResult = await query('SELECT * FROM ingredients WHERE recipe_id = $1 ORDER BY sort_order', [recipe.id]); + recipe.ingredients = ingResult.rows; + + const stepResult = await query('SELECT * FROM steps WHERE recipe_id = $1 ORDER BY step_number', [recipe.id]); + recipe.steps = stepResult.rows; + + const noteResult = await query('SELECT * FROM notes WHERE recipe_id = $1 ORDER BY created_at DESC', [recipe.id]); + recipe.notes = noteResult.rows; + + const tagResult = await query( + 'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = $1', + [recipe.id] + ); + recipe.tags = tagResult.rows.map((t: any) => t.name); mapTimeFields(recipe); return recipe; } -export function createRecipe(input: CreateRecipeInput) { - const db = getDb(); +export async function createRecipe(input: CreateRecipeInput) { const id = ulid(); - const slug = ensureUniqueSlug(slugify(input.title)); + const slug = await ensureUniqueSlug(slugify(input.title)); const totalTime = (input.prep_time || 0) + (input.cook_time || 0) || null; - const insertRecipe = db.prepare(` - INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); + const client = await pool.connect(); + try { + await client.query('BEGIN'); - const insertIngredient = db.prepare(` - INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); - - const insertStep = db.prepare(` - INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) - VALUES (?, ?, ?, ?, ?) - `); - - const transaction = db.transaction(() => { - insertRecipe.run(id, input.title, slug, input.description || null, input.category_id || null, - input.difficulty || 'medium', input.prep_time || null, input.cook_time || null, totalTime, - input.servings || 4, input.image_url || null, input.source_url || null); + await client.query(` + INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + `, [id, input.title, slug, input.description || null, input.category_id || null, + input.difficulty || 'medium', input.prep_time || null, input.cook_time || null, totalTime, + input.servings || 4, input.image_url || null, input.source_url || null]); if (input.ingredients) { for (let i = 0; i < input.ingredients.length; i++) { const ing = input.ingredients[i]; - insertIngredient.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i); + await client.query( + 'INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i] + ); } } if (input.steps) { for (const step of input.steps) { - insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null); + await client.query( + 'INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES ($1, $2, $3, $4, $5)', + [ulid(), id, step.step_number, step.instruction, step.duration_minutes || null] + ); } } if (input.tags && input.tags.length > 0) { - syncTags(db, id, input.tags); + await syncTags(client, id, input.tags); } - }); - transaction(); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } + return getRecipeBySlug(slug); } -export function updateRecipe(id: string, input: Partial) { - const db = getDb(); - const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id) as any; - if (!existing) return null; +export async function updateRecipe(id: string, input: Partial) { + const { rows } = await query('SELECT * FROM recipes WHERE id = $1', [id]); + if (rows.length === 0) return null; + const existing = rows[0]; - const slug = input.title ? ensureUniqueSlug(slugify(input.title), id) : existing.slug; + const slug = input.title ? await ensureUniqueSlug(slugify(input.title), id) : existing.slug; const totalTime = ((input.prep_time ?? existing.prep_time) || 0) + ((input.cook_time ?? existing.cook_time) || 0) || null; - db.prepare(` - UPDATE recipes SET title=?, slug=?, description=?, category_id=?, difficulty=?, prep_time=?, cook_time=?, total_time=?, servings=?, image_url=?, source_url=?, updated_at=datetime('now') - WHERE id=? - `).run( + await query(` + UPDATE recipes SET title=$1, slug=$2, description=$3, category_id=$4, difficulty=$5, prep_time=$6, cook_time=$7, total_time=$8, servings=$9, image_url=$10, source_url=$11, updated_at=NOW() + WHERE id=$12 + `, [ input.title ?? existing.title, slug, input.description ?? existing.description, input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty, input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime, input.servings ?? existing.servings, input.image_url ?? existing.image_url, input.source_url ?? existing.source_url, id - ); + ]); - // Replace ingredients if provided if (input.ingredients) { - db.prepare('DELETE FROM ingredients WHERE recipe_id = ?').run(id); + await query('DELETE FROM ingredients WHERE recipe_id = $1', [id]); for (let i = 0; i < input.ingredients.length; i++) { const ing = input.ingredients[i]; - db.prepare('INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)') - .run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i); + await query( + 'INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i] + ); } } - // Replace steps if provided if (input.steps) { - db.prepare('DELETE FROM steps WHERE recipe_id = ?').run(id); + await query('DELETE FROM steps WHERE recipe_id = $1', [id]); for (const step of input.steps) { - db.prepare('INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES (?, ?, ?, ?, ?)') - .run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null); + await query( + 'INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES ($1, $2, $3, $4, $5)', + [ulid(), id, step.step_number, step.instruction, step.duration_minutes || null] + ); } } - // Sync tags if provided if (input.tags) { - syncTags(db, id, input.tags); + const client = await pool.connect(); + try { + await syncTags(client, id, input.tags); + } finally { + client.release(); + } } return getRecipeBySlug(slug); } -export function deleteRecipe(id: string): boolean { - const result = getDb().prepare('DELETE FROM recipes WHERE id = ?').run(id); - return result.changes > 0; +export async function deleteRecipe(id: string): Promise { + const result = await query('DELETE FROM recipes WHERE id = $1', [id]); + return (result.rowCount ?? 0) > 0; } -export function toggleFavorite(id: string, userId?: string) { - const db = getDb(); - const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any; - if (!recipe) return null; +export async function toggleFavorite(id: string, userId?: string) { + const { rows } = await query('SELECT id, is_favorite FROM recipes WHERE id = $1', [id]); + if (rows.length === 0) return null; - // If no user authentication, fallback to old is_favorite column if (!userId) { - const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any; - const newVal = recipeWithFavorite.is_favorite ? 0 : 1; - db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id); + const newVal = rows[0].is_favorite ? 0 : 1; + await query('UPDATE recipes SET is_favorite = $1 WHERE id = $2', [newVal, id]); return { id, is_favorite: newVal }; } - // Check if recipe is already favorited by user - const existing = db.prepare( - 'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?' - ).get(userId, id) as any; + const { rows: favRows } = await query( + 'SELECT id FROM user_favorites WHERE user_id = $1 AND recipe_id = $2', [userId, id] + ); - if (existing) { - // Remove from favorites - db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id); + if (favRows.length > 0) { + await query('DELETE FROM user_favorites WHERE user_id = $1 AND recipe_id = $2', [userId, id]); return { id, is_favorite: false }; } else { - // Add to favorites - const favoriteId = ulid(); - db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id); + await query('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES ($1, $2, $3)', [ulid(), userId, id]); return { id, is_favorite: true }; } } -export function getRandomRecipe() { - const db = getDb(); - const recipe = db.prepare('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1').get() as any; - if (!recipe) return null; - return getRecipeBySlug(recipe.slug); +export async function getRandomRecipe() { + const { rows } = await query('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1'); + if (rows.length === 0) return null; + return getRecipeBySlug(rows[0].slug); } -export function searchRecipes(query: string) { - const db = getDb(); - // Add * for prefix matching - const ftsQuery = query.trim().split(/\s+/).map(t => `"${t}"*`).join(' '); - return db.prepare(` - SELECT r.*, c.name as category_name - FROM recipes_fts fts - JOIN recipes r ON r.rowid = fts.rowid +export async function searchRecipes(q: string) { + const likePattern = `%${q}%`; + const { rows } = await query(` + SELECT r.*, c.name as category_name, + similarity(r.title, $1) + similarity(COALESCE(r.description,''), $1) as rank + FROM recipes r LEFT JOIN categories c ON r.category_id = c.id - WHERE recipes_fts MATCH ? - ORDER BY rank - `).all(ftsQuery).map(mapTimeFields); + WHERE r.title ILIKE $2 OR r.description ILIKE $2 + ORDER BY rank DESC + `, [q, likePattern]); + return rows.map(mapTimeFields); } -export function listCategories() { - return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all(); +export async function listCategories() { + const { rows } = await query('SELECT * FROM categories ORDER BY sort_order'); + return rows; } -export function createCategory(name: string) { - const db = getDb(); +export async function createCategory(name: string) { const id = ulid(); const slug = name.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); - const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM categories').get() as any).m || 0; - db.prepare('INSERT INTO categories (id, name, slug, sort_order) VALUES (?, ?, ?, ?)').run(id, name, slug, maxOrder + 1); + const { rows } = await query('SELECT MAX(sort_order) as m FROM categories'); + const maxOrder = rows[0].m || 0; + await query('INSERT INTO categories (id, name, slug, sort_order) VALUES ($1, $2, $3, $4)', [id, name, slug, maxOrder + 1]); return { id, name, slug, sort_order: maxOrder + 1 }; } diff --git a/backend/src/services/shopping.service.ts b/backend/src/services/shopping.service.ts index 2397b6f..dbab4ad 100644 --- a/backend/src/services/shopping.service.ts +++ b/backend/src/services/shopping.service.ts @@ -1,17 +1,14 @@ -import { getDb } from '../db/connection.js'; +import { pool, query } from '../db/connection.js'; import { ulid } from 'ulid'; export type ShoppingScope = 'personal' | 'household'; -export function listItems(userId?: string, scope: ShoppingScope = 'personal') { - const db = getDb(); - - let query: string; +export async function listItems(userId?: string, scope: ShoppingScope = 'personal') { + let sql: string; let params: any[]; if (!userId) { - // Legacy: no user authentication, return all items - query = ` + sql = ` SELECT si.*, r.title as recipe_title FROM shopping_items si LEFT JOIN recipes r ON si.recipe_id = r.id @@ -20,29 +17,27 @@ export function listItems(userId?: string, scope: ShoppingScope = 'personal') { `; params = []; } else if (scope === 'household') { - // Get household shopping list - query = ` + sql = ` SELECT si.*, r.title as recipe_title FROM shopping_items si LEFT JOIN recipes r ON si.recipe_id = r.id LEFT JOIN household_members hm ON si.household_id = hm.household_id - WHERE hm.user_id = ? AND si.household_id IS NOT NULL + WHERE hm.user_id = $1 AND si.household_id IS NOT NULL ORDER BY si.checked, si.created_at DESC `; params = [userId]; } else { - // Get personal shopping list - query = ` + sql = ` SELECT si.*, r.title as recipe_title FROM shopping_items si LEFT JOIN recipes r ON si.recipe_id = r.id - WHERE si.user_id = ? AND si.household_id IS NULL + WHERE si.user_id = $1 AND si.household_id IS NULL ORDER BY si.checked, si.created_at DESC `; params = [userId]; } - const items = db.prepare(query).all(...params) as any[]; + const { rows: items } = await query(sql, params); const grouped: Record = {}; for (const item of items) { @@ -55,183 +50,141 @@ export function listItems(userId?: string, scope: ShoppingScope = 'personal') { return Object.values(grouped); } -export function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') { - const db = getDb(); - const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId); - if (!recipe) return null; +export async function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') { + const { rows: recipeRows } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]); + if (recipeRows.length === 0) return null; + + const { rows: ingredients } = await query('SELECT * FROM ingredients WHERE recipe_id = $1', [recipeId]); - const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[]; - let householdId = null; if (userId && scope === 'household') { - // Get user's household - const membership = db.prepare(` - SELECT household_id FROM household_members WHERE user_id = ? - `).get(userId) as { household_id: string } | undefined; - - if (!membership) { - throw new Error('USER_NOT_IN_HOUSEHOLD'); - } - householdId = membership.household_id; + const { rows } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]); + if (rows.length === 0) throw new Error('USER_NOT_IN_HOUSEHOLD'); + householdId = rows[0].household_id; } - const insert = db.prepare(` - INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id) - VALUES (?, ?, ?, ?, ?, ?, ?) - `); - const added: any[] = []; - const txn = db.transaction(() => { + const client = await pool.connect(); + try { + await client.query('BEGIN'); for (const ing of ingredients) { const id = ulid(); - insert.run(id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId); - added.push({ - id, - name: ing.name, - amount: ing.amount, - unit: ing.unit, - recipe_id: recipeId, - user_id: userId || null, - household_id: householdId, - checked: 0 - }); + await client.query( + 'INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id) VALUES ($1, $2, $3, $4, $5, $6, $7)', + [id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId] + ); + added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, user_id: userId || null, household_id: householdId, checked: false }); } - }); - txn(); + await client.query('COMMIT'); + } catch (e) { + await client.query('ROLLBACK'); + throw e; + } finally { + client.release(); + } return added; } -export function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') { - const db = getDb(); - +export async function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') { let householdId = null; if (userId && scope === 'household') { - // Get user's household - const membership = db.prepare(` - SELECT household_id FROM household_members WHERE user_id = ? - `).get(userId) as { household_id: string } | undefined; - - if (!membership) { - throw new Error('USER_NOT_IN_HOUSEHOLD'); - } - householdId = membership.household_id; + const { rows } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]); + if (rows.length === 0) throw new Error('USER_NOT_IN_HOUSEHOLD'); + householdId = rows[0].household_id; } const id = ulid(); - db.prepare(` - INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id) - VALUES (?, ?, ?, ?, ?, ?) - `).run(id, name, amount ?? null, unit ?? null, userId || null, householdId); - - return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id); + await query( + 'INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id) VALUES ($1, $2, $3, $4, $5, $6)', + [id, name, amount ?? null, unit ?? null, userId || null, householdId] + ); + + const { rows } = await query('SELECT * FROM shopping_items WHERE id = $1', [id]); + return rows[0]; } -export function toggleCheck(id: string, userId?: string) { - const db = getDb(); - - let query: string; +export async function toggleCheck(id: string, userId?: string) { + let sql: string; let params: any[]; if (!userId) { - // Legacy: no user authentication - query = 'SELECT * FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL'; + sql = 'SELECT * FROM shopping_items WHERE id = $1 AND user_id IS NULL AND household_id IS NULL'; params = [id]; } else { - // Check if item belongs to user (personal) or their household - query = ` + sql = ` SELECT si.* FROM shopping_items si LEFT JOIN household_members hm ON si.household_id = hm.household_id - WHERE si.id = ? AND (si.user_id = ? OR hm.user_id = ?) + WHERE si.id = $1 AND (si.user_id = $2 OR hm.user_id = $3) `; params = [id, userId, userId]; } - const item = db.prepare(query).get(...params) as any; - if (!item) return null; - - const newVal = item.checked ? 0 : 1; - db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id); + const { rows } = await query(sql, params); + if (rows.length === 0) return null; + const item = rows[0]; + + const newVal = !item.checked; + await query('UPDATE shopping_items SET checked = $1 WHERE id = $2', [newVal, id]); return { ...item, checked: newVal }; } -export function deleteItem(id: string, userId?: string): boolean { - const db = getDb(); - - let query: string; +export async function deleteItem(id: string, userId?: string): Promise { + let sql: string; let params: any[]; if (!userId) { - // Legacy: no user authentication - query = 'DELETE FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL'; + sql = 'DELETE FROM shopping_items WHERE id = $1 AND user_id IS NULL AND household_id IS NULL'; params = [id]; } else { - // Delete only if item belongs to user (personal) or their household - query = ` - DELETE FROM shopping_items - WHERE id = ? AND id IN ( + sql = ` + DELETE FROM shopping_items + WHERE id = $1 AND id IN ( SELECT si.id FROM shopping_items si LEFT JOIN household_members hm ON si.household_id = hm.household_id - WHERE si.user_id = ? OR hm.user_id = ? + WHERE si.user_id = $2 OR hm.user_id = $3 ) `; params = [id, userId, userId]; } - return db.prepare(query).run(...params).changes > 0; + const result = await query(sql, params); + return (result.rowCount ?? 0) > 0; } -export function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): number { - const db = getDb(); - - let query: string; +export async function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): Promise { + let sql: string; let params: any[]; if (!userId) { - // Legacy: no user authentication - query = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL'; + sql = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL'; params = []; } else if (scope === 'household') { - // Delete all household items - query = ` - DELETE FROM shopping_items - WHERE household_id IN ( - SELECT household_id FROM household_members WHERE user_id = ? - ) - `; + sql = 'DELETE FROM shopping_items WHERE household_id IN (SELECT household_id FROM household_members WHERE user_id = $1)'; params = [userId]; } else { - // Delete all personal items - query = 'DELETE FROM shopping_items WHERE user_id = ? AND household_id IS NULL'; + sql = 'DELETE FROM shopping_items WHERE user_id = $1 AND household_id IS NULL'; params = [userId]; } - return db.prepare(query).run(...params).changes; + const result = await query(sql, params); + return result.rowCount ?? 0; } -export function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): number { - const db = getDb(); - - let query: string; +export async function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): Promise { + let sql: string; let params: any[]; if (!userId) { - // Legacy: no user authentication - query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id IS NULL AND household_id IS NULL'; + sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND user_id IS NULL AND household_id IS NULL'; params = []; } else if (scope === 'household') { - // Delete checked household items - query = ` - DELETE FROM shopping_items - WHERE checked = 1 AND household_id IN ( - SELECT household_id FROM household_members WHERE user_id = ? - ) - `; + sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND household_id IN (SELECT household_id FROM household_members WHERE user_id = $1)'; params = [userId]; } else { - // Delete checked personal items - query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id = ? AND household_id IS NULL'; + sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND user_id = $1 AND household_id IS NULL'; params = [userId]; } - return db.prepare(query).run(...params).changes; + const result = await query(sql, params); + return result.rowCount ?? 0; } diff --git a/backend/src/services/tag.service.ts b/backend/src/services/tag.service.ts index 8aac5dd..a5161a7 100644 --- a/backend/src/services/tag.service.ts +++ b/backend/src/services/tag.service.ts @@ -1,26 +1,27 @@ -import { getDb } from '../db/connection.js'; +import { query } from '../db/connection.js'; -export function listTags() { - return getDb().prepare(` +export async function listTags() { + const { rows } = await query(` SELECT t.*, COUNT(rt.recipe_id) as recipe_count FROM tags t LEFT JOIN recipe_tags rt ON t.id = rt.tag_id GROUP BY t.id ORDER BY t.name - `).all(); + `); + return rows; } -export function getRecipesByTag(tagName: string) { - const db = getDb(); - const tag = db.prepare('SELECT * FROM tags WHERE name = ? OR slug = ?').get(tagName, tagName) as any; - if (!tag) return null; - const recipes = db.prepare(` +export async function getRecipesByTag(tagName: string) { + const { rows: tagRows } = await query('SELECT * FROM tags WHERE name = $1 OR slug = $1', [tagName]); + if (tagRows.length === 0) return null; + const tag = tagRows[0]; + const { rows: recipes } = await query(` SELECT r.*, c.name as category_name FROM recipes r JOIN recipe_tags rt ON r.id = rt.recipe_id LEFT JOIN categories c ON r.category_id = c.id - WHERE rt.tag_id = ? + WHERE rt.tag_id = $1 ORDER BY r.created_at DESC - `).all(tag.id); + `, [tag.id]); return { tag, recipes }; } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d3ff5d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.8' + +services: + db: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: luna_recipes + POSTGRES_USER: luna + POSTGRES_PASSWORD: ${DB_PASSWORD:-luna-recipes-secret-2026} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U luna -d luna_recipes"] + interval: 5s + timeout: 3s + retries: 5 + + backend: + build: ./backend + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://luna:${DB_PASSWORD:-luna-recipes-secret-2026}@db:5432/luna_recipes + JWT_SECRET: ${JWT_SECRET:-luna-jwt-change-in-prod} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-luna-refresh-change-in-prod} + COOKIE_SECRET: ${COOKIE_SECRET:-luna-cookie-change-in-prod} + PORT: "6001" + NODE_ENV: production + ports: + - "127.0.0.1:6001:6001" + + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + - backend + ports: + - "80:80" + +volumes: + pgdata: diff --git a/features/AUTH-V2-SPEC.md b/features/AUTH-V2-SPEC.md index fe9d073..b5342d8 100644 --- a/features/AUTH-V2-SPEC.md +++ b/features/AUTH-V2-SPEC.md @@ -342,13 +342,18 @@ ALTER TABLE recipes ADD COLUMN created_by UUID REFERENCES users(id); ### 3.2 Profile Pages #### /profile -- **Layout:** Tabbed Interface (Profil, Haushalt) -- **Profil Tab:** +- **Layout:** Card-basiert, vertikal gestapelt (kein Tab-Interface) +- **Profil-Karte:** - Avatar (Upload oder URL) - Display Name (inline editierbar) - E-Mail (nicht editierbar, mit "Ändern" Link für v2.1) - "Passwort ändern" Button -- **Mobile:** Große Avatar-Anzeige, Touch-freundliche Edit-Buttons +- **Haushalt-Karte:** (immer sichtbar, direkt unter Profil) + - **Kein Haushalt:** Card mit 🏠 Icon, "Haushalt erstellen" + "Beitreten" Buttons + - **In Haushalt:** Haushalt-Name, Mitglieder-Avatare, "Verwalten" Button → `/profile/household` + - Visuell prominent — nicht versteckt in einem Tab! +- **Quick-Actions:** Logout-Button unten +- **Mobile:** Große Avatar-Anzeige, Touch-freundliche Edit-Buttons, 44px Targets #### /profile/edit - **Modal oder Fullscreen (Mobile):** Profil bearbeiten @@ -475,10 +480,36 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { ### 5.2 Haushalt beitreten -1. User erhält Einladungscode (per Link, QR-Code oder Text) -2. User klickt "Haushalt beitreten" und gibt Code ein -3. Backend validiert Code und fügt User als Member hinzu -4. Einkaufsliste wird automatisch geteilt +#### Flow +1. User erhält Einladungscode (per Link, QR-Code oder manuell) +2. In `/profile/household` → Button "Haushalt beitreten" +3. **Eingabe:** 6-stelliger Code (uppercase, alphanumerisch, z.B. `COOK2A`) +4. Backend validiert Code → fügt User als `member` hinzu +5. Erfolg: Konfetti 🎉 + Toast "Willkommen bei [Haushalt-Name]!" +6. Einkaufsliste zeigt ab sofort Haushalt-Items + +#### Deep Link Support +- URL-Format: `https://luna.supertoll.xyz/join/{invite_code}` +- Wenn eingeloggt → direkt beitreten (mit Bestätigungs-Dialog) +- Wenn nicht eingeloggt → Redirect zu `/login?redirect=/join/{code}` +- Nach Login → automatisch Join-Flow fortsetzen + +#### QR-Code +- Owner kann QR-Code generieren (enthält Deep Link) +- Share-Button: QR als Bild teilen oder Code kopieren +- Library: `qrcode.react` (lightweight) + +#### Fehlerbehandlung +- `INVALID_INVITE_CODE` → "Code ungültig oder abgelaufen" +- `ALREADY_IN_HOUSEHOLD` → "Du bist bereits in einem Haushalt. Zuerst verlassen?" +- `HOUSEHOLD_FULL` → "Haushalt hat maximale Mitgliederzahl erreicht" (Limit: 10) +- Netzwerkfehler → Retry-Button + +#### Haushalt verlassen +- Member können jederzeit verlassen (Bestätigungs-Dialog) +- Owner kann nur verlassen wenn ein anderer Member zum Owner gemacht wird +- Letzter Member → Haushalt wird gelöscht +- Nach Verlassen: eigene Shopping-Items bleiben privat erhalten ### 5.3 Gemeinsame Einkaufsliste @@ -493,6 +524,109 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { - **Filter:** "Alle", "Haushalt", "Privat" Tabs in Shopping Liste - **Mobile:** Swipe-Actions: "Als privat markieren" / "Mit Haushalt teilen" +### 5.4 Smarte Mengenabfrage beim Rezept-Einkauf + +Aktuell werden beim "Zur Einkaufsliste hinzufügen" alle Zutaten 1:1 übernommen. Aber oft hat man schon Mehl, Eier, Zucker etc. daheim. Statt blind alles draufzupacken → User fragen was noch fehlt. + +#### Flow: "Was brauchst du noch?" +1. User klickt "Zutaten zur Einkaufsliste" auf der Rezeptseite +2. **Modal/Sheet öffnet sich** mit allen Zutaten als Checkliste +3. Jede Zutat hat: + - ☑️ Checkbox (Standard: alle an) + - Name + Menge aus Rezept + - Optional: Menge anpassen (z.B. "hab noch 200g, brauch nur 300g") +4. User deaktiviert was schon da ist +5. Button: "X Artikel hinzufügen" (zeigt Anzahl der aktiven) +6. Nur ausgewählte Zutaten landen auf der Liste + +#### Quick-Actions im Modal +- **"Alles"** — alle Checkboxen an (default) +- **"Basics abwählen"** — typische Vorrats-Zutaten automatisch deaktivieren (Salz, Pfeffer, Öl, Wasser) +- **"Nichts"** — alle aus, manuell auswählen + +#### Basics-Liste (konfigurierbar pro User/Haushalt) +Standard-Basics die man meistens daheim hat: +- Salz, Pfeffer, Zucker, Mehl, Öl, Wasser, Butter, Eier +- User kann in Settings eigene Basics definieren +- Passt zu Luna: Mehl ✅ Zucker ✅ Eier ✅ Backpulver ✅ (Butter ❌ muss immer drauf 😄) + +#### Späterer Ausbau (v2+: Vorratskammer/Pantry) +- Automatischer Abgleich mit Vorratskammer +- "Hab ich schon" wird automatisch vorausgewählt +- Fehlmengen werden berechnet (Rezept braucht 500g, hast 200g → 300g auf Liste) + +### 5.5 Manuelle Artikel zur Einkaufsliste hinzufügen + +Aktuell kommen Shopping-Items nur aus Rezepten. User sollen auch eigene Artikel hinzufügen können (z.B. "Klopapier", "Spülmittel", Zutaten ohne Rezept). + +#### API + +##### POST /api/shopping/manual +```json +// Request +{ + "name": "Klopapier", + "amount": "1", + "unit": "Packung", + "private": false +} + +// Response (201 Created) +{ + "success": true, + "item": { + "id": "uuid", + "name": "Klopapier", + "amount": "1", + "unit": "Packung", + "checked": false, + "recipe_id": null, + "user_id": "user-uuid", + "household_id": "household-uuid", + "created_at": "2026-02-18T16:00:00Z" + } +} +``` + +#### DB-Erweiterung +```sql +-- recipe_id wird NULLABLE (ist es vermutlich schon) +-- Manuelle Items haben recipe_id = NULL +-- Optional: source-Feld um Herkunft zu unterscheiden +ALTER TABLE shopping_items ADD COLUMN source TEXT DEFAULT 'recipe' + CHECK (source IN ('recipe', 'manual')); +``` + +#### Frontend UI + +##### Eingabefeld (oben in der Einkaufsliste) +- **Sticky Input-Bar** oben auf der Shopping-Page +- Textfeld mit Placeholder "Artikel hinzufügen..." + ➕ Button +- **Smart Parsing:** "2 Liter Milch" → amount: "2", unit: "Liter", name: "Milch" +- **Autocomplete:** Vorschläge aus bisherigen Items (häufig gekaufte) +- Enter oder ➕ fügt hinzu, Feld wird geleert +- Standard: Haushalt-Item (wenn in Haushalt), sonst privat + +##### Smart Parsing Regeln +``` +"Milch" → name: "Milch", amount: null, unit: null +"2 Milch" → name: "Milch", amount: "2", unit: null +"2 Liter Milch" → name: "Milch", amount: "2", unit: "Liter" +"500g Mehl" → name: "Mehl", amount: "500", unit: "g" +"1 Packung Butter" → name: "Butter", amount: "1", unit: "Packung" +``` + +##### Visuelle Unterscheidung +- Rezept-Items: normaler Style + Rezept-Name als Subtitle +- Manuelle Items: leicht anderer Style (z.B. 📝 Icon oder kursiver Subtitle "Manuell hinzugefügt") +- Haushalt-Items: 🏠 Badge +- Private Items: 🔒 Badge + +##### Quick-Add Vorschläge +- Unter dem Input: Chips mit häufig hinzugefügten Artikeln +- Max 5 Vorschläge, basierend auf History +- Tap = sofort hinzufügen + ### 5.4 Persönliche Favoriten - Favoriten sind immer pro User (`user_favorites` Tabelle) diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..54850f3 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.env* diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..413b267 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,14 @@ +FROM node:22-alpine AS build +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..7284860 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,31 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # API proxy to backend + location /api/ { + proxy_pass http://backend:6001; + 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; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # Gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + gzip_min_length 1000; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ec1e94b..fab0ee4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ import { PublicRoute } from './components/auth/AuthGuard' import { HomePage } from './pages/HomePage' import { RecipePage } from './pages/RecipePage' import { SearchPage } from './pages/SearchPage' -import { PlaceholderPage } from './pages/PlaceholderPage' import { ProfilePage } from './pages/ProfilePage' import { RecipeFormPage } from './pages/RecipeFormPage' import { ShoppingPage } from './pages/ShoppingPage' @@ -18,22 +17,8 @@ export default function App() { {/* Public Auth Routes */} - - - - } - /> - - - - } - /> + } /> + } /> {/* Main App Routes */} }> diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 63d26f6..d3fb409 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,4 +1,8 @@ import { apiFetch } from './client' +import { setAuthToken, getAuthToken } from './token' + +// Re-export for convenience +export { setAuthToken, getAuthToken } export interface User { id: string @@ -35,66 +39,50 @@ export interface ChangePasswordData { new_password: string } -// Auth token storage (in memory, not localStorage) -let authToken: string | null = null - -export function setAuthToken(token: string | null) { - authToken = token -} - -export function getAuthToken() { - return authToken -} - -// Add Authorization header to apiFetch when token exists -const authFetch = (path: string, options?: RequestInit): Promise => { - const headers = { ...options?.headers } as Record - if (authToken) { - headers.Authorization = `Bearer ${authToken}` - } - return apiFetch(path, { ...options, headers }) -} - export function register(data: RegisterData): Promise { return apiFetch('/auth/register', { method: 'POST', - body: JSON.stringify(data) + body: JSON.stringify(data), }) } export function login(data: LoginData): Promise { return apiFetch('/auth/login', { method: 'POST', - body: JSON.stringify(data) + body: JSON.stringify(data), }) } export function logout(): Promise { - return authFetch('/auth/logout', { - method: 'POST' + return apiFetch('/auth/logout', { + method: 'POST', }) } export function getMe(): Promise { - return authFetch('/auth/me') + return apiFetch('/auth/me') } export function updateProfile(data: UpdateProfileData): Promise { - return authFetch('/auth/me', { + return apiFetch('/auth/me', { method: 'PUT', - body: JSON.stringify(data) + body: JSON.stringify(data), }) } export function changePassword(data: ChangePasswordData): Promise { - return authFetch('/auth/me/password', { + return apiFetch('/auth/me/password', { method: 'PUT', - body: JSON.stringify(data) + body: JSON.stringify(data), }) } export function refreshToken(): Promise { - return apiFetch('/auth/refresh', { - method: 'POST' + return fetch('/api/auth/refresh', { + method: 'POST', + credentials: 'include', + }).then((res) => { + if (!res.ok) throw new Error('Refresh failed') + return res.json() }) -} \ No newline at end of file +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index d3c5476..0734acb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,17 +1,29 @@ +import { getAuthToken } from './token' + const BASE_URL = '/api' export async function apiFetch(path: string, options?: RequestInit): Promise { - const method = options?.method?.toUpperCase() || 'GET'; - const headers: Record = { ...options?.headers as Record }; - if (['POST', 'PUT', 'PATCH'].includes(method) && options?.body) { - headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + const method = options?.method?.toUpperCase() || 'GET' + const headers: Record = { ...options?.headers as Record } + + // Auto-attach auth token + const token = getAuthToken() + if (token) { + headers.Authorization = `Bearer ${token}` } + + if (['POST', 'PUT', 'PATCH'].includes(method) && options?.body) { + headers['Content-Type'] = headers['Content-Type'] || 'application/json' + } + const res = await fetch(`${BASE_URL}${path}`, { ...options, headers, + credentials: 'include', }) if (!res.ok) { - throw new Error(`API Error: ${res.status} ${res.statusText}`) + const errorData = await res.json().catch(() => ({})) + throw new Error(errorData.message || `API Error: ${res.status}`) } return res.json() } diff --git a/frontend/src/api/households.ts b/frontend/src/api/households.ts new file mode 100644 index 0000000..86f2d31 --- /dev/null +++ b/frontend/src/api/households.ts @@ -0,0 +1,49 @@ +import { apiFetch } from './client' + +export interface Household { + id: string + name: string + invite_code: string + created_at: string + members: HouseholdMember[] +} + +export interface HouseholdMember { + user_id: string + email: string + display_name: string + avatar_url?: string + role: 'owner' | 'member' + joined_at: string +} + +export function getMyHousehold() { + return apiFetch<{ success: boolean; data: Household }>('/households/mine') +} + +export function createHousehold(name: string) { + return apiFetch<{ success: boolean; data: Household }>('/households', { + method: 'POST', + body: JSON.stringify({ name }), + }) +} + +export function joinHousehold(inviteCode: string) { + return apiFetch<{ success: boolean; data: Household }>('/households/join', { + method: 'POST', + body: JSON.stringify({ inviteCode }), + }) +} + +export function leaveHousehold(id: string) { + return apiFetch<{ success: boolean }>(`/households/${id}/leave`, { + method: 'DELETE', + }) +} + +export function regenerateInviteCode(id: string) { + return apiFetch<{ success: boolean; data: { invite_code: string } }>( + `/households/${id}/invite`, + { method: 'POST' } + ) +} diff --git a/frontend/src/api/shopping.ts b/frontend/src/api/shopping.ts index da24fd9..b920272 100644 --- a/frontend/src/api/shopping.ts +++ b/frontend/src/api/shopping.ts @@ -17,21 +17,32 @@ export interface ShoppingGroup { items: ShoppingItem[] } -export function fetchShopping() { - return apiFetch('/shopping') +function scopeQuery(scope?: string) { + return scope ? `?scope=${scope}` : '' } -export function addFromRecipe(recipeId: string) { - return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}`, { method: 'POST' }) +export function fetchShopping(scope?: string) { + return apiFetch(`/shopping${scopeQuery(scope)}`) } -export function addCustomItem(item: { name: string; amount?: number; unit?: string }) { - return apiFetch('/shopping', { +export function addFromRecipe(recipeId: string, scope?: string) { + return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}${scopeQuery(scope)}`, { method: 'POST' }) +} + +export function addCustomItem(item: { name: string; amount?: number; unit?: string }, scope?: string) { + return apiFetch(`/shopping${scopeQuery(scope)}`, { method: 'POST', body: JSON.stringify(item), }) } +export function addItems(items: { name: string; amount?: number; unit?: string }[], scope?: string) { + return apiFetch<{ added: number }>(`/shopping/batch${scopeQuery(scope)}`, { + method: 'POST', + body: JSON.stringify({ items }), + }) +} + export function toggleCheck(id: string) { return apiFetch(`/shopping/${id}/check`, { method: 'PATCH' }) } @@ -40,10 +51,10 @@ export function deleteItem(id: string) { return apiFetch(`/shopping/${id}`, { method: 'DELETE' }) } -export function deleteAll() { - return apiFetch('/shopping/all', { method: 'DELETE' }) +export function deleteAll(scope?: string) { + return apiFetch(`/shopping/all${scopeQuery(scope)}`, { method: 'DELETE' }) } -export function deleteChecked() { - return apiFetch('/shopping/checked', { method: 'DELETE' }) +export function deleteChecked(scope?: string) { + return apiFetch(`/shopping/checked${scopeQuery(scope)}`, { method: 'DELETE' }) } diff --git a/frontend/src/api/token.ts b/frontend/src/api/token.ts new file mode 100644 index 0000000..76dfbf0 --- /dev/null +++ b/frontend/src/api/token.ts @@ -0,0 +1,10 @@ +// Token storage — separate file to avoid circular imports +let authToken: string | null = null + +export function setAuthToken(token: string | null) { + authToken = token +} + +export function getAuthToken(): string | null { + return authToken +} diff --git a/frontend/src/components/profile/ChangePasswordModal.tsx b/frontend/src/components/profile/ChangePasswordModal.tsx new file mode 100644 index 0000000..53c17b0 --- /dev/null +++ b/frontend/src/components/profile/ChangePasswordModal.tsx @@ -0,0 +1,159 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, Eye, EyeOff } from 'lucide-react' +import { changePassword } from '../../api/auth' +import { showToast } from '../../utils/toast' + +interface Props { + open: boolean + onClose: () => void +} + +export function ChangePasswordModal({ open, onClose }: Props) { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [showCurrent, setShowCurrent] = useState(false) + const [showNew, setShowNew] = useState(false) + const [showConfirm, setShowConfirm] = useState(false) + const [saving, setSaving] = useState(false) + + const errors: string[] = [] + if (newPassword && newPassword.length < 8) errors.push('Mindestens 8 Zeichen') + if (confirmPassword && newPassword !== confirmPassword) errors.push('Passwörter stimmen nicht überein') + + const canSubmit = + currentPassword.trim() && + newPassword.length >= 8 && + newPassword === confirmPassword && + !saving + + const handleSubmit = async () => { + if (!canSubmit) return + setSaving(true) + try { + await changePassword({ + current_password: currentPassword, + new_password: newPassword, + }) + showToast.success('Passwort geändert ✅') + onClose() + } catch (err) { + showToast.error(err instanceof Error ? err.message : 'Fehler beim Ändern') + } finally { + setSaving(false) + } + } + + const PasswordField = ({ + label, + value, + onChange, + show, + onToggle, + placeholder, + }: { + label: string + value: string + onChange: (v: string) => void + show: boolean + onToggle: () => void + placeholder: string + }) => ( +
+ +
+ onChange(e.target.value)} + className="w-full bg-surface border border-sand rounded-xl px-4 py-3 pr-12 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]" + placeholder={placeholder} + /> + +
+
+ ) + + return ( + + {open && ( + + e.stopPropagation()} + > +
+

Passwort ändern

+ +
+ +
+ setShowCurrent(!showCurrent)} + placeholder="••••••••" + /> + setShowNew(!showNew)} + placeholder="Min. 8 Zeichen" + /> + setShowConfirm(!showConfirm)} + placeholder="Nochmal eingeben" + /> + + {errors.length > 0 && ( +
+ {errors.map((e) => ( +

⚠️ {e}

+ ))} +
+ )} + + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/profile/EditProfileModal.tsx b/frontend/src/components/profile/EditProfileModal.tsx new file mode 100644 index 0000000..ffb624f --- /dev/null +++ b/frontend/src/components/profile/EditProfileModal.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X } from 'lucide-react' +import { updateProfile } from '../../api/auth' +import { useAuth } from '../../context/AuthContext' +import { showToast } from '../../utils/toast' + +interface Props { + open: boolean + onClose: () => void +} + +export function EditProfileModal({ open, onClose }: Props) { + const { user, updateUser } = useAuth() + const [displayName, setDisplayName] = useState(user?.display_name || '') + const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '') + const [saving, setSaving] = useState(false) + + const handleSave = async () => { + if (!displayName.trim()) return + setSaving(true) + try { + const updated = await updateProfile({ + display_name: displayName.trim(), + avatar_url: avatarUrl.trim() || undefined, + }) + updateUser(updated) + showToast.success('Profil aktualisiert ✅') + onClose() + } catch (err) { + showToast.error(err instanceof Error ? err.message : 'Fehler beim Speichern') + } finally { + setSaving(false) + } + } + + return ( + + {open && ( + + e.stopPropagation()} + > +
+

Profil bearbeiten

+ +
+ +
+
+ + setDisplayName(e.target.value)} + className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]" + placeholder="Dein Name" + /> +
+ +
+ + setAvatarUrl(e.target.value)} + className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]" + placeholder="https://..." + /> +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/components/profile/HouseholdCard.tsx b/frontend/src/components/profile/HouseholdCard.tsx new file mode 100644 index 0000000..a1cb7b8 --- /dev/null +++ b/frontend/src/components/profile/HouseholdCard.tsx @@ -0,0 +1,316 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { motion, AnimatePresence } from 'framer-motion' +import { Home, Users, Copy, RefreshCw, LogOut, X, Check } from 'lucide-react' +import { + getMyHousehold, + createHousehold, + joinHousehold, + leaveHousehold, + regenerateInviteCode, + type Household, +} from '../../api/households' +import { useAuth } from '../../context/AuthContext' +import { showToast } from '../../utils/toast' + +export function HouseholdCard() { + const { user } = useAuth() + const qc = useQueryClient() + const [showCreate, setShowCreate] = useState(false) + const [showJoin, setShowJoin] = useState(false) + const [showLeaveConfirm, setShowLeaveConfirm] = useState(false) + const [name, setName] = useState('') + const [code, setCode] = useState('') + const [copied, setCopied] = useState(false) + + const { data, isLoading } = useQuery({ + queryKey: ['household'], + queryFn: getMyHousehold, + retry: false, + }) + + const household: Household | null = data?.data ?? null + const myRole = household?.members.find((m) => m.user_id === user?.id)?.role + + const createMut = useMutation({ + mutationFn: (n: string) => createHousehold(n), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['household'] }) + setShowCreate(false) + setName('') + showToast.success('Haushalt erstellt! 🏠') + }, + onError: (err: Error) => showToast.error(err.message), + }) + + const joinMut = useMutation({ + mutationFn: (c: string) => joinHousehold(c), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['household'] }) + setShowJoin(false) + setCode('') + showToast.success('Haushalt beigetreten! 🎉') + }, + onError: (err: Error) => showToast.error(err.message), + }) + + const leaveMut = useMutation({ + mutationFn: () => leaveHousehold(household!.id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['household'] }) + setShowLeaveConfirm(false) + showToast.success('Haushalt verlassen') + }, + onError: (err: Error) => showToast.error(err.message), + }) + + const regenMut = useMutation({ + mutationFn: () => regenerateInviteCode(household!.id), + onSuccess: (res) => { + qc.invalidateQueries({ queryKey: ['household'] }) + showToast.success('Neuer Code generiert') + }, + onError: (err: Error) => showToast.error(err.message), + }) + + const copyCode = async () => { + if (!household?.invite_code) return + try { + await navigator.clipboard.writeText(household.invite_code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + showToast.error('Kopieren fehlgeschlagen') + } + } + + if (isLoading) { + return
+ } + + // Mini modal component + const MiniModal = ({ + open, + onClose, + title, + children, + }: { + open: boolean + onClose: () => void + title: string + children: React.ReactNode + }) => ( + + {open && ( + + e.stopPropagation()} + > +
+

{title}

+ +
+ {children} +
+
+ )} +
+ ) + + // No household + if (!household) { + return ( + <> +
+
+ + Haushalt +
+

+ Teile deine Einkaufsliste mit deiner Familie +

+
+ + +
+
+ + setShowCreate(false)} title="Haushalt erstellen"> + setName(e.target.value)} + placeholder="z.B. Luna & Marc" + className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4" + /> + + + + setShowJoin(false)} title="Mit Code beitreten"> + setCode(e.target.value)} + placeholder="Einladungscode eingeben" + className="w-full bg-surface border border-sand rounded-xl px-4 py-3 text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px] mb-4" + /> + + + + ) + } + + // Has household + return ( + <> +
+
+
+ + Haushalt +
+ + {household.members.length} + +
+ +

{household.name}

+ + {/* Members */} +
+ {household.members.map((m) => ( +
+
+ {m.avatar_url ? ( + {m.display_name} + ) : ( + + {m.display_name.charAt(0).toUpperCase()} + + )} +
+ {m.display_name} + {m.role === 'owner' && ( + + Admin + + )} +
+ ))} +
+ + {/* Invite code (owner only) */} + {myRole === 'owner' && ( +
+

Einladungscode

+
+ + {household.invite_code} + + + +
+
+ )} + + {/* Actions */} + {myRole === 'member' && ( + + )} +
+ + {/* Leave confirm */} + + {showLeaveConfirm && ( + setShowLeaveConfirm(false)} + > + e.stopPropagation()} + > +

Haushalt verlassen?

+

+ Du verlierst Zugriff auf die gemeinsame Einkaufsliste. +

+
+ + +
+
+
+ )} +
+ + ) +} diff --git a/frontend/src/components/recipe/IngredientPickerModal.tsx b/frontend/src/components/recipe/IngredientPickerModal.tsx new file mode 100644 index 0000000..51d6578 --- /dev/null +++ b/frontend/src/components/recipe/IngredientPickerModal.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { X, ShoppingCart } from 'lucide-react' + +const BASICS = ['salz', 'pfeffer', 'zucker', 'mehl', 'öl', 'wasser', 'essig'] + +interface Ingredient { + name: string + amount?: number + unit?: string +} + +interface Props { + open: boolean + onClose: () => void + ingredients: Ingredient[] + onSubmit: (selected: Ingredient[]) => void + loading?: boolean +} + +export function IngredientPickerModal({ open, onClose, ingredients, onSubmit, loading }: Props) { + const [checked, setChecked] = useState>(() => new Set(ingredients.map((_, i) => i))) + + const toggle = (i: number) => { + setChecked((prev) => { + const next = new Set(prev) + if (next.has(i)) next.delete(i) + else next.add(i) + return next + }) + } + + const selectAll = () => setChecked(new Set(ingredients.map((_, i) => i))) + const selectNone = () => setChecked(new Set()) + const deselectBasics = () => { + setChecked((prev) => { + const next = new Set(prev) + ingredients.forEach((ing, i) => { + if (BASICS.some((b) => ing.name.toLowerCase().includes(b))) { + next.delete(i) + } + }) + return next + }) + } + + const handleSubmit = () => { + const selected = ingredients.filter((_, i) => checked.has(i)) + if (selected.length > 0) onSubmit(selected) + } + + return ( + + {open && ( + + e.stopPropagation()} + > + {/* Header */} +
+

Zutaten auswählen

+ +
+ + {/* Quick actions */} +
+ + + +
+ + {/* Ingredient list */} +
+ {ingredients.map((ing, i) => ( + + ))} +
+ + {/* Submit */} +
+ +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 77f769b..4fe98f9 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,12 +1,11 @@ import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' import { - User, + type User, getMe, - setAuthToken, - getAuthToken, refreshToken, logout as apiLogout } from '../api/auth' +import { setAuthToken, getAuthToken } from '../api/token' interface AuthContextType { user: User | null @@ -45,25 +44,15 @@ export function AuthProvider({ children }: AuthProviderProps) { try { // Try to refresh token first (cookie-based) const authResponse = await refreshToken() - if (mounted) { + if (mounted && authResponse?.access_token) { setAuthToken(authResponse.access_token) setUser(authResponse.user) } - } catch (error) { - // If refresh fails, check if we already have a token - const existingToken = getAuthToken() - if (existingToken) { - try { - const userData = await getMe() - if (mounted) { - setUser(userData) - } - } catch (meError) { - // Token is invalid, clear it - if (mounted) { - setAuthToken(null) - } - } + } catch { + // No valid session — that's fine, app works without auth + if (mounted) { + setAuthToken(null) + setUser(null) } } finally { if (mounted) { diff --git a/frontend/src/pages/ProfilePage.tsx b/frontend/src/pages/ProfilePage.tsx index e44d3ed..dfbe92e 100644 --- a/frontend/src/pages/ProfilePage.tsx +++ b/frontend/src/pages/ProfilePage.tsx @@ -1,17 +1,23 @@ +import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { motion } from 'framer-motion' import { Link } from 'react-router' import { fetchRecipes } from '../api/recipes' import { fetchShopping } from '../api/shopping' -import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User } from 'lucide-react' +import { LogOut, Info, Heart, BookOpen, ShoppingCart, Settings, User, ChevronRight } from 'lucide-react' import { useAuth } from '../context/AuthContext' import { EmptyState } from '../components/ui/EmptyState' import { Button } from '../components/ui/Button' +import { EditProfileModal } from '../components/profile/EditProfileModal' +import { ChangePasswordModal } from '../components/profile/ChangePasswordModal' +import { HouseholdCard } from '../components/profile/HouseholdCard' import { showToast } from '../utils/toast' export function ProfilePage() { const { user, isAuthenticated, logout, isLoading } = useAuth() - + const [showEditProfile, setShowEditProfile] = useState(false) + const [showChangePassword, setShowChangePassword] = useState(false) + const { data: allRecipes } = useQuery({ queryKey: ['recipes', {}], queryFn: () => fetchRecipes({}), @@ -24,7 +30,7 @@ export function ProfilePage() { const { data: shoppingGroups } = useQuery({ queryKey: ['shopping'], - queryFn: fetchShopping, + queryFn: () => fetchShopping(), }) const totalRecipes = allRecipes?.total ?? 0 @@ -47,7 +53,6 @@ export function ProfilePage() { } } - // Show login prompt if not authenticated if (!isLoading && !isAuthenticated) { return (
@@ -58,14 +63,10 @@ export function ProfilePage() { />
- + - +
@@ -73,8 +74,8 @@ export function ProfilePage() { } return ( -
- {/* Header */} +
+ {/* Avatar & Name */}
{user?.avatar_url ? ( - {user.display_name} @@ -94,13 +95,40 @@ export function ProfilePage() {

{user?.display_name || 'Benutzer'}

-

- {user?.email || 'Hobbyköchin & Rezeptsammlerin'} -

+

{user?.email}

+
+ + {/* Profil verwalten */} +
+
+
+ + Profil verwalten +
+ + +
+
+ + {/* Haushalt */} +
+
{/* Stats */} -
+
{stats.map((stat) => (
- {/* Profile Actions */} -
-
-
- - Profil verwalten -
- - - - -
-
- {/* App Info */} -
-
-
+
+
+
App-Info
Version - 2.0 + 2.1.2026
Erstellt @@ -165,7 +165,7 @@ export function ProfilePage() {
- {/* Logout Button */} + {/* Logout */}
+ + {/* Modals */} + setShowEditProfile(false)} /> + setShowChangePassword(false)} />
) } diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index a390887..e68c7f6 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -6,7 +6,8 @@ import toast from 'react-hot-toast' import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react' import { Dices } from 'lucide-react' import { fetchRecipe, toggleFavorite, fetchRandomRecipe } from '../api/recipes' -import { addFromRecipe } from '../api/shopping' +import { addFromRecipe, addCustomItem } from '../api/shopping' +import { IngredientPickerModal } from '../components/recipe/IngredientPickerModal' import { createNote, deleteNote } from '../api/notes' import { Badge } from '../components/ui/Badge' import { Skeleton } from '../components/ui/Skeleton' @@ -22,6 +23,7 @@ export function RecipePage() { const [servingScale, setServingScale] = useState(null) const [noteText, setNoteText] = useState('') const [rerolling, setRerolling] = useState(false) + const [showIngredientPicker, setShowIngredientPicker] = useState(false) const handleReroll = useCallback(async () => { setRerolling(true) @@ -47,14 +49,25 @@ export function RecipePage() { onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'), }) - const shoppingMutation = useMutation({ - mutationFn: () => addFromRecipe(recipe!.id), - onSuccess: (data) => { + const [addingToShopping, setAddingToShopping] = useState(false) + + const handleAddToShopping = async (selected: { name: string; amount?: number; unit?: string }[]) => { + setAddingToShopping(true) + try { + let count = 0 + for (const item of selected) { + await addCustomItem({ name: item.name, amount: item.amount, unit: item.unit }) + count++ + } qc.invalidateQueries({ queryKey: ['shopping'] }) - toast.success(`${data.added} Zutaten zur Einkaufsliste hinzugefügt!`) - }, - onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'), - }) + toast.success(`${count} Zutaten zur Einkaufsliste hinzugefügt!`) + setShowIngredientPicker(false) + } catch { + toast.error('Fehler beim Hinzufügen zur Einkaufsliste') + } finally { + setAddingToShopping(false) + } + } const noteMutation = useMutation({ mutationFn: (content: string) => createNote(recipe!.id, content), @@ -225,14 +238,26 @@ export function RecipePage() { {/* Add to shopping list */} {recipe.ingredients && recipe.ingredients.length > 0 && ( - + <> + + setShowIngredientPicker(false)} + ingredients={recipe.ingredients.map((ing) => ({ + name: ing.name, + amount: ing.amount, + unit: ing.unit, + }))} + onSubmit={handleAddToShopping} + loading={addingToShopping} + /> + )} {/* Steps */} diff --git a/frontend/src/pages/ShoppingPage.tsx b/frontend/src/pages/ShoppingPage.tsx index 308b5b6..6fde125 100644 --- a/frontend/src/pages/ShoppingPage.tsx +++ b/frontend/src/pages/ShoppingPage.tsx @@ -1,6 +1,6 @@ import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { Trash2, Plus, ShoppingCart, X } from 'lucide-react' +import { Trash2, Plus, X } from 'lucide-react' import { fetchShopping, addCustomItem, @@ -10,16 +10,29 @@ import { deleteAll, } from '../api/shopping' import type { ShoppingGroup, ShoppingItem } from '../api/shopping' +import { useAuth } from '../context/AuthContext' import { EmptyState } from '../components/ui/EmptyState' export function ShoppingPage() { const qc = useQueryClient() + const { isAuthenticated } = useAuth() const [newItem, setNewItem] = useState('') + const [scope, setScope] = useState<'personal' | 'household'>('personal') const inputRef = useRef(null) + // Check if user has a household + const { data: householdData } = useQuery({ + queryKey: ['household'], + enabled: isAuthenticated, + retry: false, + }) + const hasHousehold = !!householdData?.data + + const activeScope = hasHousehold ? scope : undefined + const { data: groups = [], isLoading, refetch } = useQuery({ - queryKey: ['shopping'], - queryFn: fetchShopping, + queryKey: ['shopping', activeScope], + queryFn: () => fetchShopping(activeScope), }) const checkMutation = useMutation({ @@ -33,19 +46,19 @@ export function ShoppingPage() { }) const deleteCheckedMutation = useMutation({ - mutationFn: deleteChecked, + mutationFn: () => deleteChecked(activeScope), onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }), }) const deleteAllMutation = useMutation({ - mutationFn: deleteAll, + mutationFn: () => deleteAll(activeScope), onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }), }) const [showClearConfirm, setShowClearConfirm] = useState(false) const addMutation = useMutation({ - mutationFn: addCustomItem, + mutationFn: (item: { name: string }) => addCustomItem(item, activeScope), onSuccess: () => { qc.invalidateQueries({ queryKey: ['shopping'] }) setNewItem('') @@ -65,29 +78,23 @@ export function ShoppingPage() { const totalUnchecked = totalItems - totalChecked const recipeCount = groups.filter((g) => g.recipe_id).length - // Sort items: unchecked first, checked last const sortItems = (items: ShoppingItem[]) => { const unchecked = items.filter((i) => !i.checked) const checked = items.filter((i) => i.checked) return [...unchecked, ...checked] } - // Pull-to-refresh via touch const [pulling, setPulling] = useState(false) const touchStartY = useRef(0) const handleTouchStart = (e: React.TouchEvent) => { - if (window.scrollY === 0) { - touchStartY.current = e.touches[0].clientY - } + if (window.scrollY === 0) touchStartY.current = e.touches[0].clientY } const handleTouchEnd = (e: React.TouchEvent) => { if (pulling) { const dy = e.changedTouches[0].clientY - touchStartY.current - if (dy > 80) { - refetch() - } + if (dy > 80) refetch() setPulling(false) } } @@ -144,7 +151,35 @@ export function ShoppingPage() {
- {/* Clear All Confirm Dialog */} + {/* Scope Toggle */} + {hasHousehold && ( +
+
+ + +
+
+ )} + + {/* Clear All Confirm */} {showClearConfirm && (
@@ -171,13 +206,12 @@ export function ShoppingPage() {
)} - {/* Pull indicator */} {pulling && (
↓ Loslassen zum Aktualisieren
)} {/* Quick-Add */} -
+
{ e.preventDefault() @@ -212,7 +246,9 @@ export function ShoppingPage() { {recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · } {totalItems} Artikel · {totalChecked} erledigt - {totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}% + + {totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}% +
(
- {/* Group header */}
{group.recipe_id ? '🍰' : '📝'}

@@ -245,8 +280,6 @@ export function ShoppingPage() { {group.items.filter((i) => !i.checked).length}/{group.items.length}

- - {/* Items */}
    {sortItems(group.items).map((item) => ( - {/* Delete background */} -
    - +
    + {pastThreshold ? '🗑️ Löschen' : '×'}
    {item.checked && ( - + )}
    - + {item.name}
    {amountText && ( - + {amountText} )}