v2.1.2026 — PostgreSQL, Auth, Household, Shopping Smart-Add, Docker
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
This commit is contained in:
5
.env.example
Normal file
5
.env.example
Normal file
@@ -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
|
||||||
5
backend/.dockerignore
Normal file
5
backend/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
@@ -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"]
|
||||||
Binary file not shown.
Binary file not shown.
545
backend/package-lock.json
generated
545
backend/package-lock.json
generated
@@ -14,19 +14,19 @@
|
|||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"ulid": "^3.0.2",
|
"ulid": "^3.0.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
@@ -1266,16 +1266,6 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/better-sqlite3": {
|
|
||||||
"version": "7.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz",
|
|
||||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/jsonwebtoken": {
|
"node_modules/@types/jsonwebtoken": {
|
||||||
"version": "9.0.10",
|
"version": "9.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
@@ -1303,6 +1293,17 @@
|
|||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pg": {
|
||||||
|
"version": "8.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
|
||||||
|
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"pg-protocol": "*",
|
||||||
|
"pg-types": "^2.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/sharp": {
|
"node_modules/@types/sharp": {
|
||||||
"version": "0.31.1",
|
"version": "0.31.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/sharp/-/sharp-0.31.1.tgz",
|
||||||
@@ -1404,26 +1405,6 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=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": {
|
"node_modules/bcrypt": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
@@ -1438,40 +1419,6 @@
|
|||||||
"node": ">= 18"
|
"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": {
|
"node_modules/bn.js": {
|
||||||
"version": "4.12.2",
|
"version": "4.12.2",
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||||
@@ -1490,42 +1437,12 @@
|
|||||||
"node": "20 || >=22"
|
"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": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
|
||||||
@@ -1552,30 +1469,6 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -1624,15 +1517,6 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"version": "0.27.3",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||||
@@ -1681,15 +1565,6 @@
|
|||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fast-decode-uri-component": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/find-my-way": {
|
||||||
"version": "9.4.0",
|
"version": "9.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.4.0.tgz",
|
||||||
@@ -1876,12 +1745,6 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -1910,12 +1773,6 @@
|
|||||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
"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": {
|
"node_modules/glob": {
|
||||||
"version": "13.0.5",
|
"version": "13.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz",
|
||||||
@@ -1953,38 +1810,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
|
||||||
@@ -2177,18 +2008,6 @@
|
|||||||
"node": ">=10.0.0"
|
"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": {
|
"node_modules/minimalistic-assert": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
@@ -2210,15 +2029,6 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@@ -2228,12 +2038,6 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/mnemonist": {
|
||||||
"version": "0.40.3",
|
"version": "0.40.3",
|
||||||
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
|
||||||
@@ -2249,24 +2053,6 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.5.0",
|
"version": "8.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||||
@@ -2302,15 +2088,6 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/path-scurry": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
|
||||||
@@ -2327,6 +2104,95 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"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": {
|
"node_modules/pino": {
|
||||||
"version": "10.3.1",
|
"version": "10.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
@@ -2364,30 +2230,43 @@
|
|||||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/postgres-array": {
|
||||||
"version": "7.1.3",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
"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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"detect-libc": "^2.0.0",
|
"xtend": "^4.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"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process-warning": {
|
"node_modules/process-warning": {
|
||||||
@@ -2406,51 +2285,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/quick-format-unescaped": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/real-require": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
@@ -2642,51 +2482,6 @@
|
|||||||
"@img/sharp-win32-x64": "0.34.5"
|
"@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": {
|
"node_modules/sonic-boom": {
|
||||||
"version": "4.2.1",
|
"version": "4.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
@@ -2727,52 +2522,6 @@
|
|||||||
"reusify": "^1.0.0"
|
"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": {
|
"node_modules/thread-stream": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz",
|
||||||
@@ -2830,18 +2579,6 @@
|
|||||||
"fsevents": "~2.3.3"
|
"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": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
@@ -2871,18 +2608,6 @@
|
|||||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -18,19 +19,19 @@
|
|||||||
"@fastify/jwt": "^10.0.0",
|
"@fastify/jwt": "^10.0.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/sharp": "^0.31.1",
|
"@types/sharp": "^0.31.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-sqlite3": "^12.6.2",
|
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"pg": "^8.18.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"ulid": "^3.0.2",
|
"ulid": "^3.0.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/better-sqlite3": "^7.6.13",
|
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import Database from 'better-sqlite3';
|
import pg from 'pg';
|
||||||
import path from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://werk:werk_dev_secret@localhost:5432/luna_recipes';
|
||||||
const DB_PATH = path.resolve(__dirname, '../../data/recipes.db');
|
|
||||||
|
|
||||||
let db: Database.Database | null = null;
|
export const pool = new pg.Pool({
|
||||||
|
connectionString: DATABASE_URL,
|
||||||
|
max: 20,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
export function getDb(): Database.Database {
|
export async function query(text: string, params?: any[]) {
|
||||||
if (!db) {
|
return pool.query(text, params);
|
||||||
db = new Database(DB_PATH);
|
|
||||||
db.pragma('journal_mode = WAL');
|
|
||||||
db.pragma('foreign_keys = ON');
|
|
||||||
}
|
|
||||||
return db;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeDb(): void {
|
export async function closeDb(): Promise<void> {
|
||||||
if (db) {
|
await pool.end();
|
||||||
db.close();
|
|
||||||
db = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
116
backend/src/db/import-sqlite-data.ts
Normal file
116
backend/src/db/import-sqlite-data.ts
Normal file
@@ -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));
|
||||||
@@ -1,25 +1,22 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { getDb } from './connection.js';
|
import { pool } from './connection.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const MIGRATIONS_DIR = path.resolve(__dirname, 'migrations');
|
const MIGRATIONS_DIR = path.resolve(__dirname, 'migrations');
|
||||||
|
|
||||||
export function runMigrations(): void {
|
export async function runMigrations(): Promise<void> {
|
||||||
const db = getDb();
|
await pool.query(`
|
||||||
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS _migrations (
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id SERIAL PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const applied = new Set(
|
const { rows } = await pool.query('SELECT name FROM _migrations');
|
||||||
db.prepare('SELECT name FROM _migrations').all().map((r: any) => r.name)
|
const applied = new Set(rows.map((r: any) => r.name));
|
||||||
);
|
|
||||||
|
|
||||||
const files = fs.readdirSync(MIGRATIONS_DIR)
|
const files = fs.readdirSync(MIGRATIONS_DIR)
|
||||||
.filter(f => f.endsWith('.sql'))
|
.filter(f => f.endsWith('.sql'))
|
||||||
@@ -29,7 +26,18 @@ export function runMigrations(): void {
|
|||||||
if (applied.has(file)) continue;
|
if (applied.has(file)) continue;
|
||||||
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
|
const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), 'utf-8');
|
||||||
console.log(`Running migration: ${file}`);
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
181
backend/src/db/migrations/001_initial_pg.sql
Normal file
181
backend/src/db/migrations/001_initial_pg.sql
Normal file
@@ -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();
|
||||||
@@ -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);
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
@@ -6,7 +6,7 @@ const PORT = Number(process.env.PORT || 6001);
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log('Running migrations...');
|
console.log('Running migrations...');
|
||||||
runMigrations();
|
await runMigrations();
|
||||||
|
|
||||||
const app = await buildApp();
|
const app = await buildApp();
|
||||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function botRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get('/api/bot/recipes', async (request) => {
|
app.get('/api/bot/recipes', async (request) => {
|
||||||
const query = request.query as any;
|
const query = request.query as any;
|
||||||
return recipeSvc.listRecipes({
|
return await recipeSvc.listRecipes({
|
||||||
page: query.page ? Number(query.page) : undefined,
|
page: query.page ? Number(query.page) : undefined,
|
||||||
limit: query.limit ? Number(query.limit) : 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) => {
|
app.post('/api/bot/recipes', async (request, reply) => {
|
||||||
const body = request.body as recipeSvc.CreateRecipeInput;
|
const body = request.body as recipeSvc.CreateRecipeInput;
|
||||||
if (!body.title) return reply.status(400).send({ error: 'title required' });
|
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);
|
return reply.status(201).send(recipe);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import { listCategories, createCategory } from '../services/recipe.service.js';
|
|||||||
|
|
||||||
export async function categoryRoutes(app: FastifyInstance) {
|
export async function categoryRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/categories', async () => {
|
app.get('/api/categories', async () => {
|
||||||
return listCategories();
|
return await listCategories();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/categories', async (request, reply) => {
|
app.post('/api/categories', async (request, reply) => {
|
||||||
const { name } = request.body as { name: string };
|
const { name } = request.body as { name: string };
|
||||||
if (!name) return reply.status(400).send({ error: 'name required' });
|
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);
|
return reply.status(201).send(cat);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,15 @@ export async function noteRoutes(app: FastifyInstance) {
|
|||||||
app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
return await svc.listNotes(id, userId);
|
||||||
return svc.listNotes(id, userId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { content } = request.body as { content: string };
|
const { content } = request.body as { content: string };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
|
||||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||||
|
const note = await svc.createNote(id, content, userId);
|
||||||
const note = svc.createNote(id, content, userId);
|
|
||||||
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
|
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
|
||||||
return reply.status(201).send(note);
|
return reply.status(201).send(note);
|
||||||
});
|
});
|
||||||
@@ -26,10 +23,8 @@ export async function noteRoutes(app: FastifyInstance) {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { content } = request.body as { content: string };
|
const { content } = request.body as { content: string };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
|
||||||
if (!content) return reply.status(400).send({ error: 'content required' });
|
if (!content) return reply.status(400).send({ error: 'content required' });
|
||||||
|
const note = await svc.updateNote(id, content, userId);
|
||||||
const note = svc.updateNote(id, content, userId);
|
|
||||||
if (!note) return reply.status(404).send({ error: 'Not found' });
|
if (!note) return reply.status(404).send({ error: 'Not found' });
|
||||||
return note;
|
return note;
|
||||||
});
|
});
|
||||||
@@ -37,8 +32,7 @@ export async function noteRoutes(app: FastifyInstance) {
|
|||||||
app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
const ok = await svc.deleteNote(id, userId);
|
||||||
const ok = svc.deleteNote(id, userId);
|
|
||||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { scrapeOgData } from '../services/og-scraper.service.js';
|
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 sharp from 'sharp';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -10,7 +10,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|||||||
const DATA_DIR = path.resolve(__dirname, '../../data');
|
const DATA_DIR = path.resolve(__dirname, '../../data');
|
||||||
|
|
||||||
export async function ogScrapeRoutes(app: FastifyInstance) {
|
export async function ogScrapeRoutes(app: FastifyInstance) {
|
||||||
// Preview: Just fetch OG data without downloading
|
|
||||||
app.get('/api/og-preview', async (request, reply) => {
|
app.get('/api/og-preview', async (request, reply) => {
|
||||||
const { url } = request.query as { url?: string };
|
const { url } = request.query as { url?: string };
|
||||||
if (!url) return reply.status(400).send({ error: 'url parameter required' });
|
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) => {
|
app.post('/api/recipes/:id/fetch-image', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { url } = request.body as { url: string };
|
const { url } = request.body as { url: string };
|
||||||
|
|
||||||
if (!url) return reply.status(400).send({ error: 'url required' });
|
if (!url) return reply.status(400).send({ error: 'url required' });
|
||||||
|
|
||||||
const db = getDb();
|
const { rows } = await query('SELECT id FROM recipes WHERE id = $1', [id]);
|
||||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
|
if (rows.length === 0) return reply.status(404).send({ error: 'Recipe not found' });
|
||||||
if (!recipe) return reply.status(404).send({ error: 'Recipe not found' });
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Scrape OG data
|
|
||||||
const ogData = await scrapeOgData(url);
|
const ogData = await scrapeOgData(url);
|
||||||
if (!ogData.image) return reply.status(404).send({ error: 'No image found at URL' });
|
if (!ogData.image) return reply.status(404).send({ error: 'No image found at URL' });
|
||||||
|
|
||||||
// Download image
|
|
||||||
const imgRes = await fetch(ogData.image, {
|
const imgRes = await fetch(ogData.image, {
|
||||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
@@ -48,7 +43,6 @@ export async function ogScrapeRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
||||||
|
|
||||||
// Process with sharp → WebP, max 1200px wide
|
|
||||||
const imgDir = path.join(DATA_DIR, 'images', 'recipes', id);
|
const imgDir = path.join(DATA_DIR, 'images', 'recipes', id);
|
||||||
fs.mkdirSync(imgDir, { recursive: true });
|
fs.mkdirSync(imgDir, { recursive: true });
|
||||||
const imgPath = path.join(imgDir, 'hero.webp');
|
const imgPath = path.join(imgDir, 'hero.webp');
|
||||||
@@ -58,10 +52,8 @@ export async function ogScrapeRoutes(app: FastifyInstance) {
|
|||||||
.webp({ quality: 85 })
|
.webp({ quality: 85 })
|
||||||
.toFile(imgPath);
|
.toFile(imgPath);
|
||||||
|
|
||||||
// Update recipe
|
|
||||||
const imageUrl = `/images/recipes/${id}/hero.webp`;
|
const imageUrl = `/images/recipes/${id}/hero.webp`;
|
||||||
db.prepare('UPDATE recipes SET image_url = ?, source_url = ?, updated_at = datetime(\'now\') WHERE id = ?')
|
await query('UPDATE recipes SET image_url = $1, source_url = $2, updated_at = NOW() WHERE id = $3', [imageUrl, url, id]);
|
||||||
.run(imageUrl, url, id);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as svc from '../services/recipe.service.js';
|
|||||||
|
|
||||||
export async function recipeRoutes(app: FastifyInstance) {
|
export async function recipeRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/recipes/random', async (request, reply) => {
|
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' });
|
if (!recipe) return reply.status(404).send({ error: 'No recipes found' });
|
||||||
return recipe;
|
return recipe;
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
app.get('/api/recipes/search', async (request) => {
|
app.get('/api/recipes/search', async (request) => {
|
||||||
const { q } = request.query as { q?: string };
|
const { q } = request.query as { q?: string };
|
||||||
if (!q) return { data: [], total: 0 };
|
if (!q) return { data: [], total: 0 };
|
||||||
const results = svc.searchRecipes(q);
|
const results = await svc.searchRecipes(q);
|
||||||
return { data: results, total: results.length };
|
return { data: results, total: results.length };
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
const query = request.query as any;
|
const query = request.query as any;
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
|
||||||
return svc.listRecipes({
|
return await svc.listRecipes({
|
||||||
page: query.page ? Number(query.page) : undefined,
|
page: query.page ? Number(query.page) : undefined,
|
||||||
limit: query.limit ? Number(query.limit) : undefined,
|
limit: query.limit ? Number(query.limit) : undefined,
|
||||||
category_id: query.category_id,
|
category_id: query.category_id,
|
||||||
@@ -34,7 +34,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
app.get('/api/recipes/:slug', async (request, reply) => {
|
app.get('/api/recipes/:slug', async (request, reply) => {
|
||||||
const { slug } = request.params as { slug: string };
|
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' });
|
if (!recipe) return reply.status(404).send({ error: 'Not found' });
|
||||||
return recipe;
|
return recipe;
|
||||||
});
|
});
|
||||||
@@ -42,20 +42,20 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
app.post('/api/recipes', async (request, reply) => {
|
app.post('/api/recipes', async (request, reply) => {
|
||||||
const body = request.body as svc.CreateRecipeInput;
|
const body = request.body as svc.CreateRecipeInput;
|
||||||
if (!body.title) return reply.status(400).send({ error: 'title required' });
|
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);
|
return reply.status(201).send(recipe);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/recipes/:id', async (request, reply) => {
|
app.put('/api/recipes/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
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' });
|
if (!recipe) return reply.status(404).send({ error: 'Not found' });
|
||||||
return recipe;
|
return recipe;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/recipes/:id', async (request, reply) => {
|
app.delete('/api/recipes/:id', async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
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' });
|
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ export async function recipeRoutes(app: FastifyInstance) {
|
|||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const userId = request.user?.id;
|
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' });
|
if (!result) return reply.status(404).send({ error: 'Not found' });
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,24 +3,18 @@ import { optionalAuthMiddleware } from '../middleware/auth.js';
|
|||||||
import * as svc from '../services/shopping.service.js';
|
import * as svc from '../services/shopping.service.js';
|
||||||
|
|
||||||
export async function shoppingRoutes(app: FastifyInstance) {
|
export async function shoppingRoutes(app: FastifyInstance) {
|
||||||
// List shopping items with optional authentication
|
|
||||||
app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
const shoppingScope = scope || 'personal';
|
return await svc.listItems(userId, scope || 'personal');
|
||||||
|
|
||||||
return svc.listItems(userId, shoppingScope);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add items from recipe with optional authentication
|
|
||||||
app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
const shoppingScope = scope || 'personal';
|
|
||||||
|
|
||||||
try {
|
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' });
|
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
|
||||||
return reply.status(201).send({ added: items.length });
|
return reply.status(201).send({ added: items.length });
|
||||||
} catch (error: any) {
|
} 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) => {
|
app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||||
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
|
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
|
||||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
const shoppingScope = scope || 'personal';
|
|
||||||
|
|
||||||
if (!name) return reply.status(400).send({ error: 'name required' });
|
if (!name) return reply.status(400).send({ error: 'name required' });
|
||||||
|
|
||||||
try {
|
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);
|
return reply.status(201).send(item);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
|
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) => {
|
app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
const item = await svc.toggleCheck(id, userId);
|
||||||
const item = svc.toggleCheck(id, userId);
|
|
||||||
if (!item) return reply.status(404).send({ error: 'Not found' });
|
if (!item) return reply.status(404).send({ error: 'Not found' });
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete all items with optional authentication
|
|
||||||
app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
const shoppingScope = scope || 'personal';
|
const count = await svc.deleteAll(userId, scope || 'personal');
|
||||||
|
|
||||||
const count = svc.deleteAll(userId, shoppingScope);
|
|
||||||
return { ok: true, deleted: count };
|
return { ok: true, deleted: count };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete checked items with optional authentication
|
|
||||||
app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => {
|
||||||
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
const { scope } = request.query as { scope?: 'personal' | 'household' };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
const shoppingScope = scope || 'personal';
|
const count = await svc.deleteChecked(userId, scope || 'personal');
|
||||||
|
|
||||||
const count = svc.deleteChecked(userId, shoppingScope);
|
|
||||||
return { ok: true, deleted: count };
|
return { ok: true, deleted: count };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete single item with optional authentication
|
|
||||||
app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string };
|
const { id } = request.params as { id: string };
|
||||||
const userId = request.user?.id;
|
const userId = request.user?.id;
|
||||||
|
const ok = await svc.deleteItem(id, userId);
|
||||||
const ok = svc.deleteItem(id, userId);
|
|
||||||
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
if (!ok) return reply.status(404).send({ error: 'Not found' });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import * as svc from '../services/tag.service.js';
|
|||||||
|
|
||||||
export async function tagRoutes(app: FastifyInstance) {
|
export async function tagRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/tags', async () => {
|
app.get('/api/tags', async () => {
|
||||||
return svc.listTags();
|
return await svc.listTags();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/tags/:name/recipes', async (request, reply) => {
|
app.get('/api/tags/:name/recipes', async (request, reply) => {
|
||||||
const { name } = request.params as { name: string };
|
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' });
|
if (!result) return reply.status(404).send({ error: 'Tag not found' });
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { ulid } from 'ulid';
|
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_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';
|
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 {
|
class AuthService {
|
||||||
private db = getDb();
|
|
||||||
|
|
||||||
async register(email: string, password: string, displayName: string): Promise<AuthResult> {
|
async register(email: string, password: string, displayName: string): Promise<AuthResult> {
|
||||||
// Check if user already exists
|
const { rows: existing } = await query('SELECT id FROM users WHERE email = $1', [email]);
|
||||||
const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email);
|
if (existing.length > 0) throw new Error('EMAIL_EXISTS');
|
||||||
if (existingUser) {
|
|
||||||
throw new Error('EMAIL_EXISTS');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
// Create user
|
|
||||||
const userId = ulid();
|
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
|
await query(
|
||||||
const user = this.db.prepare(`
|
'INSERT INTO users (id, email, password_hash, display_name) VALUES ($1, $2, $3, $4)',
|
||||||
SELECT id, email, display_name, avatar_url, created_at, updated_at
|
[userId, email, passwordHash, displayName]
|
||||||
FROM users WHERE id = ?
|
);
|
||||||
`).get(userId) as User;
|
|
||||||
|
|
||||||
// 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);
|
const tokens = this.generateTokens(user);
|
||||||
|
|
||||||
return { user, tokens };
|
return { user, tokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(email: string, password: string): Promise<AuthResult> {
|
async login(email: string, password: string): Promise<AuthResult> {
|
||||||
// Get user by email
|
const { rows } = await query(
|
||||||
const userWithPassword = this.db.prepare(`
|
'SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at FROM users WHERE email = $1',
|
||||||
SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at
|
[email]
|
||||||
FROM users WHERE email = ?
|
);
|
||||||
`).get(email) as any;
|
if (rows.length === 0) throw new Error('INVALID_CREDENTIALS');
|
||||||
|
|
||||||
if (!userWithPassword) {
|
const userWithPassword = rows[0];
|
||||||
throw new Error('INVALID_CREDENTIALS');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check password
|
|
||||||
const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash);
|
const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash);
|
||||||
if (!passwordValid) {
|
if (!passwordValid) throw new Error('INVALID_CREDENTIALS');
|
||||||
throw new Error('INVALID_CREDENTIALS');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove password from user object
|
|
||||||
const { password_hash, ...user } = userWithPassword;
|
const { password_hash, ...user } = userWithPassword;
|
||||||
|
|
||||||
// Generate tokens
|
|
||||||
const tokens = this.generateTokens(user);
|
const tokens = this.generateTokens(user);
|
||||||
|
|
||||||
return { user, tokens };
|
return { user, tokens };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getProfile(userId: string): Promise<User | null> {
|
async getProfile(userId: string): Promise<User | null> {
|
||||||
const user = this.db.prepare(`
|
const { rows } = await query(
|
||||||
SELECT id, email, display_name, avatar_url, created_at, updated_at
|
'SELECT id, email, display_name, avatar_url, created_at, updated_at FROM users WHERE id = $1',
|
||||||
FROM users WHERE id = ?
|
[userId]
|
||||||
`).get(userId) as User | undefined;
|
);
|
||||||
|
return rows[0] || null;
|
||||||
return user || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise<User> {
|
async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise<User> {
|
||||||
const updates: string[] = [];
|
const updates: string[] = [];
|
||||||
const values: any[] = [];
|
const values: any[] = [];
|
||||||
|
let idx = 1;
|
||||||
|
|
||||||
if (data.display_name !== undefined) {
|
if (data.display_name !== undefined) { updates.push(`display_name = $${idx}`); values.push(data.display_name); idx++; }
|
||||||
updates.push('display_name = ?');
|
if (data.avatar_url !== undefined) { updates.push(`avatar_url = $${idx}`); values.push(data.avatar_url); idx++; }
|
||||||
values.push(data.display_name);
|
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);
|
values.push(userId);
|
||||||
|
await query(`UPDATE users SET ${updates.join(', ')} WHERE id = $${idx}`, values);
|
||||||
this.db.prepare(`
|
|
||||||
UPDATE users SET ${updates.join(', ')} WHERE id = ?
|
|
||||||
`).run(...values);
|
|
||||||
|
|
||||||
const user = await this.getProfile(userId);
|
const user = await this.getProfile(userId);
|
||||||
if (!user) {
|
if (!user) throw new Error('USER_NOT_FOUND');
|
||||||
throw new Error('USER_NOT_FOUND');
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
|
||||||
// Get current password hash
|
const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [userId]);
|
||||||
const userWithPassword = this.db.prepare(`
|
if (rows.length === 0) throw new Error('USER_NOT_FOUND');
|
||||||
SELECT password_hash FROM users WHERE id = ?
|
|
||||||
`).get(userId) as any;
|
|
||||||
|
|
||||||
if (!userWithPassword) {
|
const valid = await bcrypt.compare(currentPassword, rows[0].password_hash);
|
||||||
throw new Error('USER_NOT_FOUND');
|
if (!valid) throw new Error('INVALID_CURRENT_PASSWORD');
|
||||||
}
|
|
||||||
|
|
||||||
// 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 newHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||||
|
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [newHash, userId]);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,11 +108,7 @@ class AuthService {
|
|||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any;
|
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any;
|
||||||
const user = await this.getProfile(decoded.sub);
|
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);
|
const tokens = this.generateTokens(user);
|
||||||
return { user, tokens };
|
return { user, tokens };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -184,17 +125,11 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private generateTokens(user: User): AuthTokens {
|
private generateTokens(user: User): AuthTokens {
|
||||||
const payload = {
|
const payload = { sub: user.id, email: user.email, display_name: user.display_name };
|
||||||
sub: user.id,
|
|
||||||
email: user.email,
|
|
||||||
display_name: user.display_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN });
|
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 });
|
const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authService = new AuthService();
|
export const authService = new AuthService();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ulid } from 'ulid';
|
import { ulid } from 'ulid';
|
||||||
import { getDb } from '../db/connection.js';
|
import { query } from '../db/connection.js';
|
||||||
|
|
||||||
export interface Household {
|
export interface Household {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -22,9 +22,6 @@ export interface HouseholdWithMembers extends Household {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class HouseholdService {
|
class HouseholdService {
|
||||||
private db = getDb();
|
|
||||||
|
|
||||||
// Generate a random 8-character invite code
|
|
||||||
private generateInviteCode(): string {
|
private generateInviteCode(): string {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
let result = '';
|
let result = '';
|
||||||
@@ -34,185 +31,102 @@ class HouseholdService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure invite code is unique
|
private async generateUniqueInviteCode(): Promise<string> {
|
||||||
private generateUniqueInviteCode(): string {
|
|
||||||
let code: string;
|
let code: string;
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
do {
|
do {
|
||||||
code = this.generateInviteCode();
|
code = this.generateInviteCode();
|
||||||
attempts++;
|
attempts++;
|
||||||
if (attempts > 100) {
|
if (attempts > 100) throw new Error('Could not generate unique invite code');
|
||||||
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 (this.db.prepare('SELECT id FROM households WHERE invite_code = ?').get(code));
|
} while (true);
|
||||||
|
|
||||||
return code;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createHousehold(userId: string, name: string): Promise<HouseholdWithMembers> {
|
async createHousehold(userId: string, name: string): Promise<HouseholdWithMembers> {
|
||||||
// Check if user is already in a household
|
const { rows: existing } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
|
||||||
const existingMembership = this.db.prepare(`
|
if (existing.length > 0) throw new Error('USER_ALREADY_IN_HOUSEHOLD');
|
||||||
SELECT household_id FROM household_members WHERE user_id = ?
|
|
||||||
`).get(userId);
|
|
||||||
|
|
||||||
if (existingMembership) {
|
|
||||||
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
|
|
||||||
}
|
|
||||||
|
|
||||||
const householdId = ulid();
|
const householdId = ulid();
|
||||||
const inviteCode = this.generateUniqueInviteCode();
|
const inviteCode = await this.generateUniqueInviteCode();
|
||||||
|
|
||||||
// Create household
|
await query('INSERT INTO households (id, name, invite_code) VALUES ($1, $2, $3)', [householdId, name, inviteCode]);
|
||||||
this.db.prepare(`
|
await query("INSERT INTO household_members (household_id, user_id, role) VALUES ($1, $2, 'owner')", [householdId, userId]);
|
||||||
INSERT INTO households (id, name, invite_code)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
`).run(householdId, name, inviteCode);
|
|
||||||
|
|
||||||
// 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);
|
const household = await this.getMyHousehold(userId);
|
||||||
if (!household) {
|
if (!household) throw new Error('Failed to create household');
|
||||||
throw new Error('Failed to create household');
|
|
||||||
}
|
|
||||||
|
|
||||||
return household;
|
return household;
|
||||||
}
|
}
|
||||||
|
|
||||||
async joinHousehold(userId: string, inviteCode: string): Promise<HouseholdWithMembers> {
|
async joinHousehold(userId: string, inviteCode: string): Promise<HouseholdWithMembers> {
|
||||||
// Check if user is already in a household
|
const { rows: existing } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
|
||||||
const existingMembership = this.db.prepare(`
|
if (existing.length > 0) throw new Error('USER_ALREADY_IN_HOUSEHOLD');
|
||||||
SELECT household_id FROM household_members WHERE user_id = ?
|
|
||||||
`).get(userId);
|
|
||||||
|
|
||||||
if (existingMembership) {
|
const { rows: households } = await query('SELECT id FROM households WHERE invite_code = $1', [inviteCode]);
|
||||||
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
|
if (households.length === 0) throw new Error('INVALID_INVITE_CODE');
|
||||||
}
|
|
||||||
|
|
||||||
// Find household by invite code
|
await query("INSERT INTO household_members (household_id, user_id, role) VALUES ($1, $2, 'member')", [households[0].id, userId]);
|
||||||
const household = this.db.prepare(`
|
|
||||||
SELECT id FROM households WHERE invite_code = ?
|
|
||||||
`).get(inviteCode) as { id: string } | undefined;
|
|
||||||
|
|
||||||
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);
|
const result = await this.getMyHousehold(userId);
|
||||||
if (!result) {
|
if (!result) throw new Error('Failed to join household');
|
||||||
throw new Error('Failed to join household');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMyHousehold(userId: string): Promise<HouseholdWithMembers | null> {
|
async getMyHousehold(userId: string): Promise<HouseholdWithMembers | null> {
|
||||||
// Get user's household
|
const { rows } = await query(`
|
||||||
const householdMembership = this.db.prepare(`
|
|
||||||
SELECT h.*, hm.role
|
SELECT h.*, hm.role
|
||||||
FROM households h
|
FROM households h
|
||||||
JOIN household_members hm ON h.id = hm.household_id
|
JOIN household_members hm ON h.id = hm.household_id
|
||||||
WHERE hm.user_id = ?
|
WHERE hm.user_id = $1
|
||||||
`).get(userId) as (Household & { role: 'owner' | 'member' }) | undefined;
|
`, [userId]);
|
||||||
|
|
||||||
if (!householdMembership) {
|
if (rows.length === 0) return null;
|
||||||
return null;
|
const householdRow = rows[0];
|
||||||
}
|
|
||||||
|
|
||||||
// Get all members of the household
|
const { rows: members } = await query(`
|
||||||
const members = this.db.prepare(`
|
SELECT u.id as user_id, u.email, u.display_name, u.avatar_url, hm.role, hm.joined_at
|
||||||
SELECT
|
|
||||||
u.id as user_id,
|
|
||||||
u.email,
|
|
||||||
u.display_name,
|
|
||||||
u.avatar_url,
|
|
||||||
hm.role,
|
|
||||||
hm.joined_at
|
|
||||||
FROM household_members hm
|
FROM household_members hm
|
||||||
JOIN users u ON hm.user_id = u.id
|
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
|
ORDER BY hm.role DESC, hm.joined_at ASC
|
||||||
`).all(householdMembership.id) as HouseholdMember[];
|
`, [householdRow.id]);
|
||||||
|
|
||||||
const { role, ...household } = householdMembership;
|
const { role, ...household } = householdRow;
|
||||||
|
return { ...household, members };
|
||||||
return {
|
|
||||||
...household,
|
|
||||||
members
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async leaveHousehold(userId: string, householdId: string): Promise<void> {
|
async leaveHousehold(userId: string, householdId: string): Promise<void> {
|
||||||
// Check if user is member of this household
|
const { rows } = await query(
|
||||||
const membership = this.db.prepare(`
|
'SELECT role FROM household_members WHERE user_id = $1 AND household_id = $2',
|
||||||
SELECT role FROM household_members
|
[userId, householdId]
|
||||||
WHERE user_id = ? AND household_id = ?
|
);
|
||||||
`).get(userId, householdId) as { role: string } | undefined;
|
if (rows.length === 0) throw new Error('NOT_HOUSEHOLD_MEMBER');
|
||||||
|
|
||||||
if (!membership) {
|
if (rows[0].role === 'owner') {
|
||||||
throw new Error('NOT_HOUSEHOLD_MEMBER');
|
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]);
|
||||||
// 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);
|
|
||||||
} else {
|
} else {
|
||||||
// Remove member from household
|
await query('DELETE FROM household_members WHERE user_id = $1 AND household_id = $2', [userId, householdId]);
|
||||||
this.db.prepare(`
|
|
||||||
DELETE FROM household_members
|
|
||||||
WHERE user_id = ? AND household_id = ?
|
|
||||||
`).run(userId, householdId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async regenerateInviteCode(userId: string, householdId: string): Promise<string> {
|
async regenerateInviteCode(userId: string, householdId: string): Promise<string> {
|
||||||
// Check if user is owner of this household
|
const { rows } = await query(
|
||||||
const membership = this.db.prepare(`
|
'SELECT role FROM household_members WHERE user_id = $1 AND household_id = $2',
|
||||||
SELECT role FROM household_members
|
[userId, householdId]
|
||||||
WHERE user_id = ? AND household_id = ?
|
);
|
||||||
`).get(userId, householdId) as { role: string } | undefined;
|
if (rows.length === 0 || rows[0].role !== 'owner') throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE');
|
||||||
|
|
||||||
if (!membership || membership.role !== 'owner') {
|
const newCode = await this.generateUniqueInviteCode();
|
||||||
throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE');
|
await query('UPDATE households SET invite_code = $1 WHERE id = $2', [newCode, householdId]);
|
||||||
}
|
return newCode;
|
||||||
|
|
||||||
const newInviteCode = this.generateUniqueInviteCode();
|
|
||||||
|
|
||||||
this.db.prepare(`
|
|
||||||
UPDATE households SET invite_code = ? WHERE id = ?
|
|
||||||
`).run(newInviteCode, householdId);
|
|
||||||
|
|
||||||
return newInviteCode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHouseholdByInviteCode(inviteCode: string): Promise<Household | null> {
|
async getHouseholdByInviteCode(inviteCode: string): Promise<Household | null> {
|
||||||
const household = this.db.prepare(`
|
const { rows } = await query('SELECT id, name, invite_code, created_at FROM households WHERE invite_code = $1', [inviteCode]);
|
||||||
SELECT id, name, invite_code, created_at
|
return rows[0] || null;
|
||||||
FROM households WHERE invite_code = ?
|
|
||||||
`).get(inviteCode) as Household | undefined;
|
|
||||||
|
|
||||||
return household || null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const householdService = new HouseholdService();
|
export const householdService = new HouseholdService();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getDb } from '../db/connection.js';
|
import { query } from '../db/connection.js';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -12,9 +12,8 @@ async function ensureDir(dir: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveRecipeImage(recipeId: string, buffer: Buffer) {
|
export async function saveRecipeImage(recipeId: string, buffer: Buffer) {
|
||||||
const db = getDb();
|
const { rows } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]);
|
||||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
if (rows.length === 0) return null;
|
||||||
if (!recipe) return null;
|
|
||||||
|
|
||||||
const dir = path.join(DATA_DIR, 'recipes', recipeId);
|
const dir = path.join(DATA_DIR, 'recipes', recipeId);
|
||||||
await ensureDir(dir);
|
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'));
|
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`;
|
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` };
|
return { image_url: imagePath, thumb_url: `/images/recipes/${recipeId}/hero_thumb.webp` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveStepImage(recipeId: string, stepNumber: number, buffer: Buffer) {
|
export async function saveStepImage(recipeId: string, stepNumber: number, buffer: Buffer) {
|
||||||
const db = getDb();
|
const { rows } = await query('SELECT id FROM steps WHERE recipe_id = $1 AND step_number = $2', [recipeId, stepNumber]);
|
||||||
const step = db.prepare('SELECT id FROM steps WHERE recipe_id = ? AND step_number = ?').get(recipeId, stepNumber) as any;
|
if (rows.length === 0) return null;
|
||||||
if (!step) return null;
|
const step = rows[0];
|
||||||
|
|
||||||
const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps');
|
const dir = path.join(DATA_DIR, 'recipes', recipeId, 'steps');
|
||||||
await ensureDir(dir);
|
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));
|
await sharp(buffer).resize({ width: 1200, withoutEnlargement: true }).webp({ quality: 80 }).toFile(path.join(dir, filename));
|
||||||
|
|
||||||
const imageUrl = `/images/recipes/${recipeId}/steps/${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 };
|
return { image_url: imageUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +1,55 @@
|
|||||||
import { getDb } from '../db/connection.js';
|
import { query } from '../db/connection.js';
|
||||||
import { ulid } from 'ulid';
|
import { ulid } from 'ulid';
|
||||||
|
|
||||||
export function listNotes(recipeId: string, userId?: string) {
|
export async function listNotes(recipeId: string, userId?: string) {
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: return all notes without user filtering
|
const { rows } = await query('SELECT * FROM notes WHERE recipe_id = $1 AND user_id IS NULL ORDER BY created_at DESC', [recipeId]);
|
||||||
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id IS NULL ORDER BY created_at DESC').all(recipeId);
|
return rows;
|
||||||
}
|
}
|
||||||
|
const { rows } = await query('SELECT * FROM notes WHERE recipe_id = $1 AND user_id = $2 ORDER BY created_at DESC', [recipeId, userId]);
|
||||||
// Return only user's notes
|
return rows;
|
||||||
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id = ? ORDER BY created_at DESC').all(recipeId, userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNote(recipeId: string, content: string, userId?: string) {
|
export async function createNote(recipeId: string, content: string, userId?: string) {
|
||||||
const db = getDb();
|
const { rows: recipe } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]);
|
||||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
if (recipe.length === 0) return null;
|
||||||
if (!recipe) return null;
|
|
||||||
|
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
db.prepare('INSERT INTO notes (id, recipe_id, content, user_id) VALUES (?, ?, ?, ?)').run(id, recipeId, content, userId || null);
|
await query('INSERT INTO notes (id, recipe_id, content, user_id) VALUES ($1, $2, $3, $4)', [id, recipeId, content, userId || null]);
|
||||||
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
|
const { rows } = await query('SELECT * FROM notes WHERE id = $1', [id]);
|
||||||
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateNote(id: string, content: string, userId?: string) {
|
export async function updateNote(id: string, content: string, userId?: string) {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: update notes without user filtering
|
sql = 'UPDATE notes SET content = $1 WHERE id = $2 AND user_id IS NULL';
|
||||||
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id IS NULL';
|
|
||||||
params = [content, id];
|
params = [content, id];
|
||||||
} else {
|
} else {
|
||||||
// Update only if note belongs to user
|
sql = 'UPDATE notes SET content = $1 WHERE id = $2 AND user_id = $3';
|
||||||
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id = ?';
|
|
||||||
params = [content, id, userId];
|
params = [content, id, userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = db.prepare(query).run(...params);
|
const result = await query(sql, params);
|
||||||
if (result.changes === 0) return null;
|
if ((result.rowCount ?? 0) === 0) return null;
|
||||||
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
|
const { rows } = await query('SELECT * FROM notes WHERE id = $1', [id]);
|
||||||
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteNote(id: string, userId?: string): boolean {
|
export async function deleteNote(id: string, userId?: string): Promise<boolean> {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: delete notes without user filtering
|
sql = 'DELETE FROM notes WHERE id = $1 AND user_id IS NULL';
|
||||||
query = 'DELETE FROM notes WHERE id = ? AND user_id IS NULL';
|
|
||||||
params = [id];
|
params = [id];
|
||||||
} else {
|
} else {
|
||||||
// Delete only if note belongs to user
|
sql = 'DELETE FROM notes WHERE id = $1 AND user_id = $2';
|
||||||
query = 'DELETE FROM notes WHERE id = ? AND user_id = ?';
|
|
||||||
params = [id, userId];
|
params = [id, userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.prepare(query).run(...params).changes > 0;
|
const result = await query(sql, params);
|
||||||
|
return (result.rowCount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,21 @@
|
|||||||
import { getDb } from '../db/connection.js';
|
import { pool, query } from '../db/connection.js';
|
||||||
import { ulid } from 'ulid';
|
import { ulid } from 'ulid';
|
||||||
|
|
||||||
function syncTags(db: any, recipeId: string, tags: string[]) {
|
async function syncTags(client: any, recipeId: string, tags: string[]) {
|
||||||
db.prepare('DELETE FROM recipe_tags WHERE recipe_id = ?').run(recipeId);
|
await client.query('DELETE FROM recipe_tags WHERE recipe_id = $1', [recipeId]);
|
||||||
for (const tagName of tags) {
|
for (const tagName of tags) {
|
||||||
const trimmed = tagName.trim();
|
const trimmed = tagName.trim();
|
||||||
if (!trimmed) continue;
|
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,'');
|
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;
|
const { rows } = await client.query('SELECT id FROM tags WHERE slug = $1', [slug]);
|
||||||
if (!tag) {
|
let tagId: string;
|
||||||
const tagId = ulid();
|
if (rows.length === 0) {
|
||||||
db.prepare('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)').run(tagId, trimmed, slug);
|
tagId = ulid();
|
||||||
tag = { id: tagId };
|
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, '');
|
.replace(/^-|-$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureUniqueSlug(baseSlug: string, excludeId?: string): string {
|
async function ensureUniqueSlug(baseSlug: string, excludeId?: string): Promise<string> {
|
||||||
const db = getDb();
|
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let i = 1;
|
let i = 1;
|
||||||
while (true) {
|
while (true) {
|
||||||
const existing = excludeId
|
const { rows } = excludeId
|
||||||
? db.prepare('SELECT id FROM recipes WHERE slug = ? AND id != ?').get(slug, excludeId)
|
? await query('SELECT id FROM recipes WHERE slug = $1 AND id != $2', [slug, excludeId])
|
||||||
: db.prepare('SELECT id FROM recipes WHERE slug = ?').get(slug);
|
: await query('SELECT id FROM recipes WHERE slug = $1', [slug]);
|
||||||
if (!existing) return slug;
|
if (rows.length === 0) return slug;
|
||||||
slug = `${baseSlug}-${i++}`;
|
slug = `${baseSlug}-${i++}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -61,225 +62,234 @@ function mapTimeFields(row: any) {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listRecipes(opts: {
|
export async function listRecipes(opts: {
|
||||||
page?: number; limit?: number; category_id?: string; category_slug?: string;
|
page?: number; limit?: number; category_id?: string; category_slug?: string;
|
||||||
favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string;
|
favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string;
|
||||||
}) {
|
}) {
|
||||||
const db = getDb();
|
|
||||||
const page = opts.page || 1;
|
const page = opts.page || 1;
|
||||||
const limit = opts.limit || 20;
|
const limit = opts.limit || 20;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
const params: any[] = [];
|
const params: any[] = [];
|
||||||
|
let paramIdx = 1;
|
||||||
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 joins = 'LEFT JOIN categories c ON r.category_id = c.id';
|
let joins = 'LEFT JOIN categories c ON r.category_id = c.id';
|
||||||
if (opts.userId) {
|
if (opts.userId) {
|
||||||
joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?';
|
joins += ` LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = $${paramIdx}`;
|
||||||
params.unshift(opts.userId);
|
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 ') : '';
|
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 countResult = await query(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`, params);
|
||||||
const rows = db.prepare(
|
const total = parseInt(countResult.rows[0].total);
|
||||||
`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 data = rows.map(mapTimeFields);
|
const dataParams = [...params, limit, offset];
|
||||||
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
|
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) {
|
export async function getRecipeBySlug(slug: string) {
|
||||||
const db = getDb();
|
const { rows } = await query(
|
||||||
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 = $1',
|
||||||
'SELECT r.*, c.name as category_name FROM recipes r LEFT JOIN categories c ON r.category_id = c.id WHERE r.slug = ?'
|
[slug]
|
||||||
).get(slug) as any;
|
);
|
||||||
if (!recipe) return null;
|
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);
|
const ingResult = await query('SELECT * FROM ingredients WHERE recipe_id = $1 ORDER BY sort_order', [recipe.id]);
|
||||||
recipe.steps = db.prepare('SELECT * FROM steps WHERE recipe_id = ? ORDER BY step_number').all(recipe.id);
|
recipe.ingredients = ingResult.rows;
|
||||||
recipe.notes = db.prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipe.id);
|
|
||||||
const tagRows = db.prepare(
|
const stepResult = await query('SELECT * FROM steps WHERE recipe_id = $1 ORDER BY step_number', [recipe.id]);
|
||||||
'SELECT t.name FROM tags t JOIN recipe_tags rt ON t.id = rt.tag_id WHERE rt.recipe_id = ?'
|
recipe.steps = stepResult.rows;
|
||||||
).all(recipe.id) as { name: string }[];
|
|
||||||
recipe.tags = tagRows.map(t => t.name);
|
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);
|
mapTimeFields(recipe);
|
||||||
return recipe;
|
return recipe;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRecipe(input: CreateRecipeInput) {
|
export async function createRecipe(input: CreateRecipeInput) {
|
||||||
const db = getDb();
|
|
||||||
const id = ulid();
|
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 totalTime = (input.prep_time || 0) + (input.cook_time || 0) || null;
|
||||||
|
|
||||||
const insertRecipe = db.prepare(`
|
const client = await pool.connect();
|
||||||
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
|
try {
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
await client.query('BEGIN');
|
||||||
`);
|
|
||||||
|
|
||||||
const insertIngredient = db.prepare(`
|
await client.query(`
|
||||||
INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order)
|
INSERT INTO recipes (id, title, slug, description, category_id, difficulty, prep_time, cook_time, total_time, servings, image_url, source_url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
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,
|
||||||
const insertStep = db.prepare(`
|
input.servings || 4, input.image_url || null, input.source_url || null]);
|
||||||
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);
|
|
||||||
|
|
||||||
if (input.ingredients) {
|
if (input.ingredients) {
|
||||||
for (let i = 0; i < input.ingredients.length; i++) {
|
for (let i = 0; i < input.ingredients.length; i++) {
|
||||||
const ing = input.ingredients[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) {
|
if (input.steps) {
|
||||||
for (const step of 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) {
|
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);
|
return getRecipeBySlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
|
export async function updateRecipe(id: string, input: Partial<CreateRecipeInput>) {
|
||||||
const db = getDb();
|
const { rows } = await query('SELECT * FROM recipes WHERE id = $1', [id]);
|
||||||
const existing = db.prepare('SELECT * FROM recipes WHERE id = ?').get(id) as any;
|
if (rows.length === 0) return null;
|
||||||
if (!existing) 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;
|
const totalTime = ((input.prep_time ?? existing.prep_time) || 0) + ((input.cook_time ?? existing.cook_time) || 0) || null;
|
||||||
|
|
||||||
db.prepare(`
|
await query(`
|
||||||
UPDATE recipes SET title=?, slug=?, description=?, category_id=?, difficulty=?, prep_time=?, cook_time=?, total_time=?, servings=?, image_url=?, source_url=?, updated_at=datetime('now')
|
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=?
|
WHERE id=$12
|
||||||
`).run(
|
`, [
|
||||||
input.title ?? existing.title, slug, input.description ?? existing.description,
|
input.title ?? existing.title, slug, input.description ?? existing.description,
|
||||||
input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty,
|
input.category_id ?? existing.category_id, input.difficulty ?? existing.difficulty,
|
||||||
input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime,
|
input.prep_time ?? existing.prep_time, input.cook_time ?? existing.cook_time, totalTime,
|
||||||
input.servings ?? existing.servings, input.image_url ?? existing.image_url,
|
input.servings ?? existing.servings, input.image_url ?? existing.image_url,
|
||||||
input.source_url ?? existing.source_url, id
|
input.source_url ?? existing.source_url, id
|
||||||
);
|
]);
|
||||||
|
|
||||||
// Replace ingredients if provided
|
|
||||||
if (input.ingredients) {
|
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++) {
|
for (let i = 0; i < input.ingredients.length; i++) {
|
||||||
const ing = input.ingredients[i];
|
const ing = input.ingredients[i];
|
||||||
db.prepare('INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
await query(
|
||||||
.run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i);
|
'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) {
|
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) {
|
for (const step of input.steps) {
|
||||||
db.prepare('INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES (?, ?, ?, ?, ?)')
|
await query(
|
||||||
.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null);
|
'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) {
|
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);
|
return getRecipeBySlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteRecipe(id: string): boolean {
|
export async function deleteRecipe(id: string): Promise<boolean> {
|
||||||
const result = getDb().prepare('DELETE FROM recipes WHERE id = ?').run(id);
|
const result = await query('DELETE FROM recipes WHERE id = $1', [id]);
|
||||||
return result.changes > 0;
|
return (result.rowCount ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleFavorite(id: string, userId?: string) {
|
export async function toggleFavorite(id: string, userId?: string) {
|
||||||
const db = getDb();
|
const { rows } = await query('SELECT id, is_favorite FROM recipes WHERE id = $1', [id]);
|
||||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
|
if (rows.length === 0) return null;
|
||||||
if (!recipe) return null;
|
|
||||||
|
|
||||||
// If no user authentication, fallback to old is_favorite column
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
|
const newVal = rows[0].is_favorite ? 0 : 1;
|
||||||
const newVal = recipeWithFavorite.is_favorite ? 0 : 1;
|
await query('UPDATE recipes SET is_favorite = $1 WHERE id = $2', [newVal, id]);
|
||||||
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
|
|
||||||
return { id, is_favorite: newVal };
|
return { id, is_favorite: newVal };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if recipe is already favorited by user
|
const { rows: favRows } = await query(
|
||||||
const existing = db.prepare(
|
'SELECT id FROM user_favorites WHERE user_id = $1 AND recipe_id = $2', [userId, id]
|
||||||
'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?'
|
);
|
||||||
).get(userId, id) as any;
|
|
||||||
|
|
||||||
if (existing) {
|
if (favRows.length > 0) {
|
||||||
// Remove from favorites
|
await query('DELETE FROM user_favorites WHERE user_id = $1 AND recipe_id = $2', [userId, id]);
|
||||||
db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id);
|
|
||||||
return { id, is_favorite: false };
|
return { id, is_favorite: false };
|
||||||
} else {
|
} else {
|
||||||
// Add to favorites
|
await query('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES ($1, $2, $3)', [ulid(), userId, id]);
|
||||||
const favoriteId = ulid();
|
|
||||||
db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id);
|
|
||||||
return { id, is_favorite: true };
|
return { id, is_favorite: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRandomRecipe() {
|
export async function getRandomRecipe() {
|
||||||
const db = getDb();
|
const { rows } = await query('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1');
|
||||||
const recipe = db.prepare('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1').get() as any;
|
if (rows.length === 0) return null;
|
||||||
if (!recipe) return null;
|
return getRecipeBySlug(rows[0].slug);
|
||||||
return getRecipeBySlug(recipe.slug);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchRecipes(query: string) {
|
export async function searchRecipes(q: string) {
|
||||||
const db = getDb();
|
const likePattern = `%${q}%`;
|
||||||
// Add * for prefix matching
|
const { rows } = await query(`
|
||||||
const ftsQuery = query.trim().split(/\s+/).map(t => `"${t}"*`).join(' ');
|
SELECT r.*, c.name as category_name,
|
||||||
return db.prepare(`
|
similarity(r.title, $1) + similarity(COALESCE(r.description,''), $1) as rank
|
||||||
SELECT r.*, c.name as category_name
|
FROM recipes r
|
||||||
FROM recipes_fts fts
|
|
||||||
JOIN recipes r ON r.rowid = fts.rowid
|
|
||||||
LEFT JOIN categories c ON r.category_id = c.id
|
LEFT JOIN categories c ON r.category_id = c.id
|
||||||
WHERE recipes_fts MATCH ?
|
WHERE r.title ILIKE $2 OR r.description ILIKE $2
|
||||||
ORDER BY rank
|
ORDER BY rank DESC
|
||||||
`).all(ftsQuery).map(mapTimeFields);
|
`, [q, likePattern]);
|
||||||
|
return rows.map(mapTimeFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCategories() {
|
export async function listCategories() {
|
||||||
return getDb().prepare('SELECT * FROM categories ORDER BY sort_order').all();
|
const { rows } = await query('SELECT * FROM categories ORDER BY sort_order');
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createCategory(name: string) {
|
export async function createCategory(name: string) {
|
||||||
const db = getDb();
|
|
||||||
const id = ulid();
|
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 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;
|
const { rows } = await query('SELECT MAX(sort_order) as m FROM categories');
|
||||||
db.prepare('INSERT INTO categories (id, name, slug, sort_order) VALUES (?, ?, ?, ?)').run(id, name, slug, maxOrder + 1);
|
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 };
|
return { id, name, slug, sort_order: maxOrder + 1 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import { getDb } from '../db/connection.js';
|
import { pool, query } from '../db/connection.js';
|
||||||
import { ulid } from 'ulid';
|
import { ulid } from 'ulid';
|
||||||
|
|
||||||
export type ShoppingScope = 'personal' | 'household';
|
export type ShoppingScope = 'personal' | 'household';
|
||||||
|
|
||||||
export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
|
export async function listItems(userId?: string, scope: ShoppingScope = 'personal') {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: no user authentication, return all items
|
sql = `
|
||||||
query = `
|
|
||||||
SELECT si.*, r.title as recipe_title
|
SELECT si.*, r.title as recipe_title
|
||||||
FROM shopping_items si
|
FROM shopping_items si
|
||||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||||
@@ -20,29 +17,27 @@ export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
|
|||||||
`;
|
`;
|
||||||
params = [];
|
params = [];
|
||||||
} else if (scope === 'household') {
|
} else if (scope === 'household') {
|
||||||
// Get household shopping list
|
sql = `
|
||||||
query = `
|
|
||||||
SELECT si.*, r.title as recipe_title
|
SELECT si.*, r.title as recipe_title
|
||||||
FROM shopping_items si
|
FROM shopping_items si
|
||||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
LEFT JOIN recipes r ON si.recipe_id = r.id
|
||||||
LEFT JOIN household_members hm ON si.household_id = hm.household_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
|
ORDER BY si.checked, si.created_at DESC
|
||||||
`;
|
`;
|
||||||
params = [userId];
|
params = [userId];
|
||||||
} else {
|
} else {
|
||||||
// Get personal shopping list
|
sql = `
|
||||||
query = `
|
|
||||||
SELECT si.*, r.title as recipe_title
|
SELECT si.*, r.title as recipe_title
|
||||||
FROM shopping_items si
|
FROM shopping_items si
|
||||||
LEFT JOIN recipes r ON si.recipe_id = r.id
|
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
|
ORDER BY si.checked, si.created_at DESC
|
||||||
`;
|
`;
|
||||||
params = [userId];
|
params = [userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = db.prepare(query).all(...params) as any[];
|
const { rows: items } = await query(sql, params);
|
||||||
|
|
||||||
const grouped: Record<string, any> = {};
|
const grouped: Record<string, any> = {};
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
@@ -55,183 +50,141 @@ export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
|
|||||||
return Object.values(grouped);
|
return Object.values(grouped);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') {
|
export async function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') {
|
||||||
const db = getDb();
|
const { rows: recipeRows } = await query('SELECT id FROM recipes WHERE id = $1', [recipeId]);
|
||||||
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
|
if (recipeRows.length === 0) return null;
|
||||||
if (!recipe) 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;
|
let householdId = null;
|
||||||
if (userId && scope === 'household') {
|
if (userId && scope === 'household') {
|
||||||
// Get user's household
|
const { rows } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
|
||||||
const membership = db.prepare(`
|
if (rows.length === 0) throw new Error('USER_NOT_IN_HOUSEHOLD');
|
||||||
SELECT household_id FROM household_members WHERE user_id = ?
|
householdId = rows[0].household_id;
|
||||||
`).get(userId) as { household_id: string } | undefined;
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error('USER_NOT_IN_HOUSEHOLD');
|
|
||||||
}
|
|
||||||
householdId = membership.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 added: any[] = [];
|
||||||
const txn = db.transaction(() => {
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
for (const ing of ingredients) {
|
for (const ing of ingredients) {
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
insert.run(id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId);
|
await client.query(
|
||||||
added.push({
|
'INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
id,
|
[id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId]
|
||||||
name: ing.name,
|
);
|
||||||
amount: ing.amount,
|
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, user_id: userId || null, household_id: householdId, checked: false });
|
||||||
unit: ing.unit,
|
|
||||||
recipe_id: recipeId,
|
|
||||||
user_id: userId || null,
|
|
||||||
household_id: householdId,
|
|
||||||
checked: 0
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
await client.query('COMMIT');
|
||||||
txn();
|
} catch (e) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') {
|
export async function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') {
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
let householdId = null;
|
let householdId = null;
|
||||||
if (userId && scope === 'household') {
|
if (userId && scope === 'household') {
|
||||||
// Get user's household
|
const { rows } = await query('SELECT household_id FROM household_members WHERE user_id = $1', [userId]);
|
||||||
const membership = db.prepare(`
|
if (rows.length === 0) throw new Error('USER_NOT_IN_HOUSEHOLD');
|
||||||
SELECT household_id FROM household_members WHERE user_id = ?
|
householdId = rows[0].household_id;
|
||||||
`).get(userId) as { household_id: string } | undefined;
|
|
||||||
|
|
||||||
if (!membership) {
|
|
||||||
throw new Error('USER_NOT_IN_HOUSEHOLD');
|
|
||||||
}
|
|
||||||
householdId = membership.household_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
db.prepare(`
|
await query(
|
||||||
INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id)
|
'INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id) VALUES ($1, $2, $3, $4, $5, $6)',
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
[id, name, amount ?? null, unit ?? null, userId || null, householdId]
|
||||||
`).run(id, name, amount ?? null, unit ?? null, userId || null, householdId);
|
);
|
||||||
|
|
||||||
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
|
const { rows } = await query('SELECT * FROM shopping_items WHERE id = $1', [id]);
|
||||||
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleCheck(id: string, userId?: string) {
|
export async function toggleCheck(id: string, userId?: string) {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: no user authentication
|
sql = 'SELECT * FROM shopping_items WHERE id = $1 AND user_id IS NULL AND household_id IS NULL';
|
||||||
query = 'SELECT * FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
|
|
||||||
params = [id];
|
params = [id];
|
||||||
} else {
|
} else {
|
||||||
// Check if item belongs to user (personal) or their household
|
sql = `
|
||||||
query = `
|
|
||||||
SELECT si.* FROM shopping_items si
|
SELECT si.* FROM shopping_items si
|
||||||
LEFT JOIN household_members hm ON si.household_id = hm.household_id
|
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];
|
params = [id, userId, userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = db.prepare(query).get(...params) as any;
|
const { rows } = await query(sql, params);
|
||||||
if (!item) return null;
|
if (rows.length === 0) return null;
|
||||||
|
const item = rows[0];
|
||||||
const newVal = item.checked ? 0 : 1;
|
|
||||||
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
|
const newVal = !item.checked;
|
||||||
|
await query('UPDATE shopping_items SET checked = $1 WHERE id = $2', [newVal, id]);
|
||||||
return { ...item, checked: newVal };
|
return { ...item, checked: newVal };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteItem(id: string, userId?: string): boolean {
|
export async function deleteItem(id: string, userId?: string): Promise<boolean> {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: no user authentication
|
sql = 'DELETE FROM shopping_items WHERE id = $1 AND user_id IS NULL AND household_id IS NULL';
|
||||||
query = 'DELETE FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
|
|
||||||
params = [id];
|
params = [id];
|
||||||
} else {
|
} else {
|
||||||
// Delete only if item belongs to user (personal) or their household
|
sql = `
|
||||||
query = `
|
DELETE FROM shopping_items
|
||||||
DELETE FROM shopping_items
|
WHERE id = $1 AND id IN (
|
||||||
WHERE id = ? AND id IN (
|
|
||||||
SELECT si.id FROM shopping_items si
|
SELECT si.id FROM shopping_items si
|
||||||
LEFT JOIN household_members hm ON si.household_id = hm.household_id
|
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];
|
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 {
|
export async function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): Promise<number> {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: no user authentication
|
sql = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL';
|
||||||
query = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL';
|
|
||||||
params = [];
|
params = [];
|
||||||
} else if (scope === 'household') {
|
} else if (scope === 'household') {
|
||||||
// Delete all household items
|
sql = 'DELETE FROM shopping_items WHERE household_id IN (SELECT household_id FROM household_members WHERE user_id = $1)';
|
||||||
query = `
|
|
||||||
DELETE FROM shopping_items
|
|
||||||
WHERE household_id IN (
|
|
||||||
SELECT household_id FROM household_members WHERE user_id = ?
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
params = [userId];
|
params = [userId];
|
||||||
} else {
|
} else {
|
||||||
// Delete all personal items
|
sql = 'DELETE FROM shopping_items WHERE user_id = $1 AND household_id IS NULL';
|
||||||
query = 'DELETE FROM shopping_items WHERE user_id = ? AND household_id IS NULL';
|
|
||||||
params = [userId];
|
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 {
|
export async function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): Promise<number> {
|
||||||
const db = getDb();
|
let sql: string;
|
||||||
|
|
||||||
let query: string;
|
|
||||||
let params: any[];
|
let params: any[];
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
// Legacy: no user authentication
|
sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND user_id IS NULL AND household_id IS NULL';
|
||||||
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id IS NULL AND household_id IS NULL';
|
|
||||||
params = [];
|
params = [];
|
||||||
} else if (scope === 'household') {
|
} else if (scope === 'household') {
|
||||||
// Delete checked household items
|
sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND household_id IN (SELECT household_id FROM household_members WHERE user_id = $1)';
|
||||||
query = `
|
|
||||||
DELETE FROM shopping_items
|
|
||||||
WHERE checked = 1 AND household_id IN (
|
|
||||||
SELECT household_id FROM household_members WHERE user_id = ?
|
|
||||||
)
|
|
||||||
`;
|
|
||||||
params = [userId];
|
params = [userId];
|
||||||
} else {
|
} else {
|
||||||
// Delete checked personal items
|
sql = 'DELETE FROM shopping_items WHERE checked = TRUE AND user_id = $1 AND household_id IS NULL';
|
||||||
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id = ? AND household_id IS NULL';
|
|
||||||
params = [userId];
|
params = [userId];
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.prepare(query).run(...params).changes;
|
const result = await query(sql, params);
|
||||||
|
return result.rowCount ?? 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import { getDb } from '../db/connection.js';
|
import { query } from '../db/connection.js';
|
||||||
|
|
||||||
export function listTags() {
|
export async function listTags() {
|
||||||
return getDb().prepare(`
|
const { rows } = await query(`
|
||||||
SELECT t.*, COUNT(rt.recipe_id) as recipe_count
|
SELECT t.*, COUNT(rt.recipe_id) as recipe_count
|
||||||
FROM tags t
|
FROM tags t
|
||||||
LEFT JOIN recipe_tags rt ON t.id = rt.tag_id
|
LEFT JOIN recipe_tags rt ON t.id = rt.tag_id
|
||||||
GROUP BY t.id
|
GROUP BY t.id
|
||||||
ORDER BY t.name
|
ORDER BY t.name
|
||||||
`).all();
|
`);
|
||||||
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRecipesByTag(tagName: string) {
|
export async function getRecipesByTag(tagName: string) {
|
||||||
const db = getDb();
|
const { rows: tagRows } = await query('SELECT * FROM tags WHERE name = $1 OR slug = $1', [tagName]);
|
||||||
const tag = db.prepare('SELECT * FROM tags WHERE name = ? OR slug = ?').get(tagName, tagName) as any;
|
if (tagRows.length === 0) return null;
|
||||||
if (!tag) return null;
|
const tag = tagRows[0];
|
||||||
const recipes = db.prepare(`
|
const { rows: recipes } = await query(`
|
||||||
SELECT r.*, c.name as category_name
|
SELECT r.*, c.name as category_name
|
||||||
FROM recipes r
|
FROM recipes r
|
||||||
JOIN recipe_tags rt ON r.id = rt.recipe_id
|
JOIN recipe_tags rt ON r.id = rt.recipe_id
|
||||||
LEFT JOIN categories c ON r.category_id = c.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
|
ORDER BY r.created_at DESC
|
||||||
`).all(tag.id);
|
`, [tag.id]);
|
||||||
return { tag, recipes };
|
return { tag, recipes };
|
||||||
}
|
}
|
||||||
|
|||||||
44
docker-compose.yml
Normal file
44
docker-compose.yml
Normal file
@@ -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:
|
||||||
@@ -342,13 +342,18 @@ ALTER TABLE recipes ADD COLUMN created_by UUID REFERENCES users(id);
|
|||||||
### 3.2 Profile Pages
|
### 3.2 Profile Pages
|
||||||
|
|
||||||
#### /profile
|
#### /profile
|
||||||
- **Layout:** Tabbed Interface (Profil, Haushalt)
|
- **Layout:** Card-basiert, vertikal gestapelt (kein Tab-Interface)
|
||||||
- **Profil Tab:**
|
- **Profil-Karte:**
|
||||||
- Avatar (Upload oder URL)
|
- Avatar (Upload oder URL)
|
||||||
- Display Name (inline editierbar)
|
- Display Name (inline editierbar)
|
||||||
- E-Mail (nicht editierbar, mit "Ändern" Link für v2.1)
|
- E-Mail (nicht editierbar, mit "Ändern" Link für v2.1)
|
||||||
- "Passwort ändern" Button
|
- "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
|
#### /profile/edit
|
||||||
- **Modal oder Fullscreen (Mobile):** Profil bearbeiten
|
- **Modal oder Fullscreen (Mobile):** Profil bearbeiten
|
||||||
@@ -475,10 +480,36 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
### 5.2 Haushalt beitreten
|
### 5.2 Haushalt beitreten
|
||||||
|
|
||||||
1. User erhält Einladungscode (per Link, QR-Code oder Text)
|
#### Flow
|
||||||
2. User klickt "Haushalt beitreten" und gibt Code ein
|
1. User erhält Einladungscode (per Link, QR-Code oder manuell)
|
||||||
3. Backend validiert Code und fügt User als Member hinzu
|
2. In `/profile/household` → Button "Haushalt beitreten"
|
||||||
4. Einkaufsliste wird automatisch geteilt
|
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
|
### 5.3 Gemeinsame Einkaufsliste
|
||||||
|
|
||||||
@@ -493,6 +524,109 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
|||||||
- **Filter:** "Alle", "Haushalt", "Privat" Tabs in Shopping Liste
|
- **Filter:** "Alle", "Haushalt", "Privat" Tabs in Shopping Liste
|
||||||
- **Mobile:** Swipe-Actions: "Als privat markieren" / "Mit Haushalt teilen"
|
- **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
|
### 5.4 Persönliche Favoriten
|
||||||
|
|
||||||
- Favoriten sind immer pro User (`user_favorites` Tabelle)
|
- Favoriten sind immer pro User (`user_favorites` Tabelle)
|
||||||
|
|||||||
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env*
|
||||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -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
|
||||||
31
frontend/nginx.conf
Normal file
31
frontend/nginx.conf
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { PublicRoute } from './components/auth/AuthGuard'
|
|||||||
import { HomePage } from './pages/HomePage'
|
import { HomePage } from './pages/HomePage'
|
||||||
import { RecipePage } from './pages/RecipePage'
|
import { RecipePage } from './pages/RecipePage'
|
||||||
import { SearchPage } from './pages/SearchPage'
|
import { SearchPage } from './pages/SearchPage'
|
||||||
import { PlaceholderPage } from './pages/PlaceholderPage'
|
|
||||||
import { ProfilePage } from './pages/ProfilePage'
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
import { RecipeFormPage } from './pages/RecipeFormPage'
|
import { RecipeFormPage } from './pages/RecipeFormPage'
|
||||||
import { ShoppingPage } from './pages/ShoppingPage'
|
import { ShoppingPage } from './pages/ShoppingPage'
|
||||||
@@ -18,22 +17,8 @@ export default function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Public Auth Routes */}
|
{/* Public Auth Routes */}
|
||||||
<Route
|
<Route path="/login" element={<LoginPage />} />
|
||||||
path="/login"
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
element={
|
|
||||||
<PublicRoute>
|
|
||||||
<LoginPage />
|
|
||||||
</PublicRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/register"
|
|
||||||
element={
|
|
||||||
<PublicRoute>
|
|
||||||
<RegisterPage />
|
|
||||||
</PublicRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main App Routes */}
|
{/* Main App Routes */}
|
||||||
<Route element={<AppShell />}>
|
<Route element={<AppShell />}>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { apiFetch } from './client'
|
import { apiFetch } from './client'
|
||||||
|
import { setAuthToken, getAuthToken } from './token'
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export { setAuthToken, getAuthToken }
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@@ -35,66 +39,50 @@ export interface ChangePasswordData {
|
|||||||
new_password: string
|
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 = <T>(path: string, options?: RequestInit): Promise<T> => {
|
|
||||||
const headers = { ...options?.headers } as Record<string, string>
|
|
||||||
if (authToken) {
|
|
||||||
headers.Authorization = `Bearer ${authToken}`
|
|
||||||
}
|
|
||||||
return apiFetch(path, { ...options, headers })
|
|
||||||
}
|
|
||||||
|
|
||||||
export function register(data: RegisterData): Promise<AuthResponse> {
|
export function register(data: RegisterData): Promise<AuthResponse> {
|
||||||
return apiFetch<AuthResponse>('/auth/register', {
|
return apiFetch<AuthResponse>('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login(data: LoginData): Promise<AuthResponse> {
|
export function login(data: LoginData): Promise<AuthResponse> {
|
||||||
return apiFetch<AuthResponse>('/auth/login', {
|
return apiFetch<AuthResponse>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): Promise<void> {
|
export function logout(): Promise<void> {
|
||||||
return authFetch<void>('/auth/logout', {
|
return apiFetch<void>('/auth/logout', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMe(): Promise<User> {
|
export function getMe(): Promise<User> {
|
||||||
return authFetch<User>('/auth/me')
|
return apiFetch<User>('/auth/me')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateProfile(data: UpdateProfileData): Promise<User> {
|
export function updateProfile(data: UpdateProfileData): Promise<User> {
|
||||||
return authFetch<User>('/auth/me', {
|
return apiFetch<User>('/auth/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function changePassword(data: ChangePasswordData): Promise<void> {
|
export function changePassword(data: ChangePasswordData): Promise<void> {
|
||||||
return authFetch<void>('/auth/me/password', {
|
return apiFetch<void>('/auth/me/password', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data)
|
body: JSON.stringify(data),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshToken(): Promise<AuthResponse> {
|
export function refreshToken(): Promise<AuthResponse> {
|
||||||
return apiFetch<AuthResponse>('/auth/refresh', {
|
return fetch('/api/auth/refresh', {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
}).then((res) => {
|
||||||
|
if (!res.ok) throw new Error('Refresh failed')
|
||||||
|
return res.json()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,29 @@
|
|||||||
|
import { getAuthToken } from './token'
|
||||||
|
|
||||||
const BASE_URL = '/api'
|
const BASE_URL = '/api'
|
||||||
|
|
||||||
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
export async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
const method = options?.method?.toUpperCase() || 'GET';
|
const method = options?.method?.toUpperCase() || 'GET'
|
||||||
const headers: Record<string, string> = { ...options?.headers as Record<string, string> };
|
const headers: Record<string, string> = { ...options?.headers as Record<string, string> }
|
||||||
if (['POST', 'PUT', 'PATCH'].includes(method) && options?.body) {
|
|
||||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
// 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}`, {
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers,
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
if (!res.ok) {
|
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()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|||||||
49
frontend/src/api/households.ts
Normal file
49
frontend/src/api/households.ts
Normal file
@@ -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' }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,21 +17,32 @@ export interface ShoppingGroup {
|
|||||||
items: ShoppingItem[]
|
items: ShoppingItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchShopping() {
|
function scopeQuery(scope?: string) {
|
||||||
return apiFetch<ShoppingGroup[]>('/shopping')
|
return scope ? `?scope=${scope}` : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addFromRecipe(recipeId: string) {
|
export function fetchShopping(scope?: string) {
|
||||||
return apiFetch<{ added: number }>(`/shopping/from-recipe/${recipeId}`, { method: 'POST' })
|
return apiFetch<ShoppingGroup[]>(`/shopping${scopeQuery(scope)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addCustomItem(item: { name: string; amount?: number; unit?: string }) {
|
export function addFromRecipe(recipeId: string, scope?: string) {
|
||||||
return apiFetch<ShoppingItem>('/shopping', {
|
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<ShoppingItem>(`/shopping${scopeQuery(scope)}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(item),
|
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) {
|
export function toggleCheck(id: string) {
|
||||||
return apiFetch<ShoppingItem>(`/shopping/${id}/check`, { method: 'PATCH' })
|
return apiFetch<ShoppingItem>(`/shopping/${id}/check`, { method: 'PATCH' })
|
||||||
}
|
}
|
||||||
@@ -40,10 +51,10 @@ export function deleteItem(id: string) {
|
|||||||
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
|
return apiFetch<void>(`/shopping/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAll() {
|
export function deleteAll(scope?: string) {
|
||||||
return apiFetch<void>('/shopping/all', { method: 'DELETE' })
|
return apiFetch<void>(`/shopping/all${scopeQuery(scope)}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteChecked() {
|
export function deleteChecked(scope?: string) {
|
||||||
return apiFetch<void>('/shopping/checked', { method: 'DELETE' })
|
return apiFetch<void>(`/shopping/checked${scopeQuery(scope)}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|||||||
10
frontend/src/api/token.ts
Normal file
10
frontend/src/api/token.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
159
frontend/src/components/profile/ChangePasswordModal.tsx
Normal file
159
frontend/src/components/profile/ChangePasswordModal.tsx
Normal file
@@ -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
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-espresso mb-1">{label}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={show ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
|
||||||
|
>
|
||||||
|
{show ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-cream w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="font-display text-xl text-espresso">Passwort ändern</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PasswordField
|
||||||
|
label="Aktuelles Passwort"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={setCurrentPassword}
|
||||||
|
show={showCurrent}
|
||||||
|
onToggle={() => setShowCurrent(!showCurrent)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
label="Neues Passwort"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={setNewPassword}
|
||||||
|
show={showNew}
|
||||||
|
onToggle={() => setShowNew(!showNew)}
|
||||||
|
placeholder="Min. 8 Zeichen"
|
||||||
|
/>
|
||||||
|
<PasswordField
|
||||||
|
label="Passwort bestätigen"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={setConfirmPassword}
|
||||||
|
show={showConfirm}
|
||||||
|
onToggle={() => setShowConfirm(!showConfirm)}
|
||||||
|
placeholder="Nochmal eingeben"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.length > 0 && (
|
||||||
|
<div className="text-sm text-berry-red space-y-1">
|
||||||
|
{errors.map((e) => (
|
||||||
|
<p key={e}>⚠️ {e}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50 transition-opacity"
|
||||||
|
>
|
||||||
|
{saving ? 'Wird geändert...' : 'Passwort ändern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
frontend/src/components/profile/EditProfileModal.tsx
Normal file
105
frontend/src/components/profile/EditProfileModal.tsx
Normal file
@@ -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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-cream w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="font-display text-xl text-espresso">Profil bearbeiten</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-espresso mb-1">
|
||||||
|
Anzeigename *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-espresso mb-1">
|
||||||
|
Avatar URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={avatarUrl}
|
||||||
|
onChange={(e) => 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://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !displayName.trim()}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50 transition-opacity"
|
||||||
|
>
|
||||||
|
{saving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
316
frontend/src/components/profile/HouseholdCard.tsx
Normal file
316
frontend/src/components/profile/HouseholdCard.tsx
Normal file
@@ -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 <div className="bg-surface rounded-2xl p-4 shadow-sm animate-pulse h-24" />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mini modal component
|
||||||
|
const MiniModal = ({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-cream w-full sm:max-w-sm sm:rounded-2xl rounded-t-2xl p-6"
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="font-display text-lg text-espresso">{title}</h3>
|
||||||
|
<button onClick={onClose} className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
|
||||||
|
// No household
|
||||||
|
if (!household) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-surface rounded-2xl p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
|
||||||
|
<Home size={16} className="text-primary" />
|
||||||
|
Haushalt
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-warm-grey mb-4">
|
||||||
|
Teile deine Einkaufsliste mit deiner Familie
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] transition-opacity hover:opacity-90"
|
||||||
|
>
|
||||||
|
Haushalt erstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowJoin(true)}
|
||||||
|
className="w-full border border-sand text-espresso rounded-xl py-3 font-medium min-h-[44px] transition-colors hover:bg-sand/30"
|
||||||
|
>
|
||||||
|
Mit Code beitreten
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MiniModal open={showCreate} onClose={() => setShowCreate(false)} title="Haushalt erstellen">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => name.trim() && createMut.mutate(name.trim())}
|
||||||
|
disabled={!name.trim() || createMut.isPending}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{createMut.isPending ? 'Erstellen...' : 'Erstellen'}
|
||||||
|
</button>
|
||||||
|
</MiniModal>
|
||||||
|
|
||||||
|
<MiniModal open={showJoin} onClose={() => setShowJoin(false)} title="Mit Code beitreten">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => code.trim() && joinMut.mutate(code.trim())}
|
||||||
|
disabled={!code.trim() || joinMut.isPending}
|
||||||
|
className="w-full bg-primary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{joinMut.isPending ? 'Beitreten...' : 'Beitreten'}
|
||||||
|
</button>
|
||||||
|
</MiniModal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has household
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="bg-surface rounded-2xl p-4 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2 text-espresso font-medium text-sm">
|
||||||
|
<Home size={16} className="text-primary" />
|
||||||
|
Haushalt
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-warm-grey">
|
||||||
|
<Users size={14} /> {household.members.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="font-display text-lg text-espresso mb-3">{household.name}</h3>
|
||||||
|
|
||||||
|
{/* Members */}
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{household.members.map((m) => (
|
||||||
|
<div key={m.user_id} className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary-light flex items-center justify-center text-sm">
|
||||||
|
{m.avatar_url ? (
|
||||||
|
<img src={m.avatar_url} alt={m.display_name} className="w-full h-full rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-primary font-medium">
|
||||||
|
{m.display_name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-espresso flex-1">{m.display_name}</span>
|
||||||
|
{m.role === 'owner' && (
|
||||||
|
<span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite code (owner only) */}
|
||||||
|
{myRole === 'owner' && (
|
||||||
|
<div className="bg-sand/30 rounded-xl p-3 mb-3">
|
||||||
|
<p className="text-xs text-warm-grey mb-1">Einladungscode</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="flex-1 text-sm font-mono text-espresso bg-surface rounded-lg px-3 py-2">
|
||||||
|
{household.invite_code}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
onClick={copyCode}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={18} className="text-sage" /> : <Copy size={18} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => regenMut.mutate()}
|
||||||
|
disabled={regenMut.isPending}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey hover:text-primary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={regenMut.isPending ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{myRole === 'member' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLeaveConfirm(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 border border-berry-red/30 text-berry-red rounded-xl py-3 font-medium min-h-[44px] transition-colors hover:bg-berry-red/5"
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
Haushalt verlassen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leave confirm */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showLeaveConfirm && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setShowLeaveConfirm(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl"
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="font-display text-lg text-espresso mb-2">Haushalt verlassen?</h3>
|
||||||
|
<p className="text-warm-grey text-sm mb-5">
|
||||||
|
Du verlierst Zugriff auf die gemeinsame Einkaufsliste.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLeaveConfirm(false)}
|
||||||
|
className="flex-1 py-3 rounded-xl border border-sand text-espresso font-medium min-h-[44px]"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => leaveMut.mutate()}
|
||||||
|
disabled={leaveMut.isPending}
|
||||||
|
className="flex-1 py-3 rounded-xl bg-berry-red text-white font-medium min-h-[44px] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{leaveMut.isPending ? 'Wird verlassen...' : 'Verlassen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
frontend/src/components/recipe/IngredientPickerModal.tsx
Normal file
154
frontend/src/components/recipe/IngredientPickerModal.tsx
Normal file
@@ -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<Set<number>>(() => 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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/40 backdrop-blur-sm"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
className="bg-cream w-full sm:max-w-md sm:rounded-2xl rounded-t-2xl max-h-[85vh] flex flex-col"
|
||||||
|
initial={{ y: '100%' }}
|
||||||
|
animate={{ y: 0 }}
|
||||||
|
exit={{ y: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-sand">
|
||||||
|
<h2 className="font-display text-lg text-espresso">Zutaten auswählen</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-w-[44px] min-h-[44px] flex items-center justify-center text-warm-grey"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="flex gap-2 px-4 py-3 border-b border-sand/50">
|
||||||
|
<button
|
||||||
|
onClick={selectAll}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-sand/50 text-xs font-medium text-espresso hover:bg-sand transition-colors"
|
||||||
|
>
|
||||||
|
Alles
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deselectBasics}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-sand/50 text-xs font-medium text-espresso hover:bg-sand transition-colors"
|
||||||
|
>
|
||||||
|
Basics ab
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={selectNone}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-sand/50 text-xs font-medium text-espresso hover:bg-sand transition-colors"
|
||||||
|
>
|
||||||
|
Nichts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredient list */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-1">
|
||||||
|
{ingredients.map((ing, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => toggle(i)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-sand/30 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-5 h-5 rounded-md border-2 flex-shrink-0 flex items-center justify-center transition-colors"
|
||||||
|
style={{
|
||||||
|
borderColor: checked.has(i) ? '#C4737E' : '#E8E0D8',
|
||||||
|
backgroundColor: checked.has(i) ? '#C4737E' : 'transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{checked.has(i) && (
|
||||||
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm flex-1 text-left ${checked.has(i) ? 'text-espresso' : 'text-warm-grey line-through'}`}>
|
||||||
|
{ing.name}
|
||||||
|
</span>
|
||||||
|
{(ing.amount || ing.unit) && (
|
||||||
|
<span className="text-xs text-warm-grey">
|
||||||
|
{[ing.amount, ing.unit].filter(Boolean).join(' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<div className="p-4 border-t border-sand">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={checked.size === 0 || loading}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-secondary text-white rounded-xl py-3 font-medium min-h-[44px] disabled:opacity-50 transition-opacity"
|
||||||
|
>
|
||||||
|
<ShoppingCart size={18} />
|
||||||
|
{loading
|
||||||
|
? 'Wird hinzugefügt...'
|
||||||
|
: `${checked.size} Artikel hinzufügen`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
User,
|
type User,
|
||||||
getMe,
|
getMe,
|
||||||
setAuthToken,
|
|
||||||
getAuthToken,
|
|
||||||
refreshToken,
|
refreshToken,
|
||||||
logout as apiLogout
|
logout as apiLogout
|
||||||
} from '../api/auth'
|
} from '../api/auth'
|
||||||
|
import { setAuthToken, getAuthToken } from '../api/token'
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null
|
user: User | null
|
||||||
@@ -45,25 +44,15 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
|||||||
try {
|
try {
|
||||||
// Try to refresh token first (cookie-based)
|
// Try to refresh token first (cookie-based)
|
||||||
const authResponse = await refreshToken()
|
const authResponse = await refreshToken()
|
||||||
if (mounted) {
|
if (mounted && authResponse?.access_token) {
|
||||||
setAuthToken(authResponse.access_token)
|
setAuthToken(authResponse.access_token)
|
||||||
setUser(authResponse.user)
|
setUser(authResponse.user)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
// If refresh fails, check if we already have a token
|
// No valid session — that's fine, app works without auth
|
||||||
const existingToken = getAuthToken()
|
if (mounted) {
|
||||||
if (existingToken) {
|
setAuthToken(null)
|
||||||
try {
|
setUser(null)
|
||||||
const userData = await getMe()
|
|
||||||
if (mounted) {
|
|
||||||
setUser(userData)
|
|
||||||
}
|
|
||||||
} catch (meError) {
|
|
||||||
// Token is invalid, clear it
|
|
||||||
if (mounted) {
|
|
||||||
setAuthToken(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Link } from 'react-router'
|
import { Link } from 'react-router'
|
||||||
import { fetchRecipes } from '../api/recipes'
|
import { fetchRecipes } from '../api/recipes'
|
||||||
import { fetchShopping } from '../api/shopping'
|
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 { useAuth } from '../context/AuthContext'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
import { Button } from '../components/ui/Button'
|
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'
|
import { showToast } from '../utils/toast'
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { user, isAuthenticated, logout, isLoading } = useAuth()
|
const { user, isAuthenticated, logout, isLoading } = useAuth()
|
||||||
|
const [showEditProfile, setShowEditProfile] = useState(false)
|
||||||
|
const [showChangePassword, setShowChangePassword] = useState(false)
|
||||||
|
|
||||||
const { data: allRecipes } = useQuery({
|
const { data: allRecipes } = useQuery({
|
||||||
queryKey: ['recipes', {}],
|
queryKey: ['recipes', {}],
|
||||||
queryFn: () => fetchRecipes({}),
|
queryFn: () => fetchRecipes({}),
|
||||||
@@ -24,7 +30,7 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
const { data: shoppingGroups } = useQuery({
|
const { data: shoppingGroups } = useQuery({
|
||||||
queryKey: ['shopping'],
|
queryKey: ['shopping'],
|
||||||
queryFn: fetchShopping,
|
queryFn: () => fetchShopping(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const totalRecipes = allRecipes?.total ?? 0
|
const totalRecipes = allRecipes?.total ?? 0
|
||||||
@@ -47,7 +53,6 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login prompt if not authenticated
|
|
||||||
if (!isLoading && !isAuthenticated) {
|
if (!isLoading && !isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@@ -58,14 +63,10 @@ export function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
<div className="absolute bottom-32 left-1/2 transform -translate-x-1/2 space-y-3 w-full max-w-sm px-4">
|
<div className="absolute bottom-32 left-1/2 transform -translate-x-1/2 space-y-3 w-full max-w-sm px-4">
|
||||||
<Link to="/login">
|
<Link to="/login">
|
||||||
<Button className="w-full min-h-[44px]">
|
<Button className="w-full min-h-[44px]">Anmelden</Button>
|
||||||
Anmelden
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/register">
|
<Link to="/register">
|
||||||
<Button variant="ghost" className="w-full min-h-[44px]">
|
<Button variant="ghost" className="w-full min-h-[44px]">Registrieren</Button>
|
||||||
Registrieren
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,8 +74,8 @@ export function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen pb-24">
|
||||||
{/* Header */}
|
{/* Avatar & Name */}
|
||||||
<div className="px-4 py-6 text-center">
|
<div className="px-4 py-6 text-center">
|
||||||
<motion.div
|
<motion.div
|
||||||
className="w-20 h-20 rounded-full bg-primary-light flex items-center justify-center text-3xl mx-auto mb-3"
|
className="w-20 h-20 rounded-full bg-primary-light flex items-center justify-center text-3xl mx-auto mb-3"
|
||||||
@@ -82,8 +83,8 @@ export function ProfilePage() {
|
|||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
>
|
>
|
||||||
{user?.avatar_url ? (
|
{user?.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={user.avatar_url}
|
src={user.avatar_url}
|
||||||
alt={user.display_name}
|
alt={user.display_name}
|
||||||
className="w-full h-full rounded-full object-cover"
|
className="w-full h-full rounded-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -94,13 +95,40 @@ export function ProfilePage() {
|
|||||||
<h1 className="font-display text-2xl text-espresso">
|
<h1 className="font-display text-2xl text-espresso">
|
||||||
{user?.display_name || 'Benutzer'}
|
{user?.display_name || 'Benutzer'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-warm-grey mt-1">
|
<p className="text-sm text-warm-grey mt-1">{user?.email}</p>
|
||||||
{user?.email || 'Hobbyköchin & Rezeptsammlerin'}
|
</div>
|
||||||
</p>
|
|
||||||
|
{/* Profil verwalten */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-1">
|
||||||
|
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
|
||||||
|
<Settings size={16} className="text-primary" />
|
||||||
|
Profil verwalten
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEditProfile(true)}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-xl hover:bg-sand/30 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-espresso">Profil bearbeiten</span>
|
||||||
|
<ChevronRight size={16} className="text-warm-grey" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChangePassword(true)}
|
||||||
|
className="w-full flex items-center justify-between p-3 rounded-xl hover:bg-sand/30 transition-colors min-h-[44px]"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-espresso">Passwort ändern</span>
|
||||||
|
<ChevronRight size={16} className="text-warm-grey" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Haushalt */}
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<HouseholdCard />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="px-4 pb-6">
|
<div className="px-4 pb-4">
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
{stats.map((stat) => (
|
{stats.map((stat) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -117,45 +145,17 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Profile Actions */}
|
|
||||||
<div className="px-4 pb-6">
|
|
||||||
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
|
|
||||||
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
|
|
||||||
<Settings size={16} className="text-primary" />
|
|
||||||
Profil verwalten
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
title="Kommt bald"
|
|
||||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
|
|
||||||
>
|
|
||||||
<span className="text-sm">Profil bearbeiten</span>
|
|
||||||
<span className="text-xs">Kommt bald</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
title="Kommt bald"
|
|
||||||
className="w-full flex items-center justify-between p-3 rounded-xl bg-sand/30 text-warm-grey cursor-not-allowed opacity-60 transition-colors min-h-[44px]"
|
|
||||||
>
|
|
||||||
<span className="text-sm">Passwort ändern</span>
|
|
||||||
<span className="text-xs">Kommt bald</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* App Info */}
|
{/* App Info */}
|
||||||
<div className="px-4 pb-6">
|
<div className="px-4 pb-4">
|
||||||
<div className="bg-surface rounded-2xl p-4 shadow-sm space-y-3">
|
<div className="bg-surface rounded-2xl p-4 shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-espresso font-medium text-sm">
|
<div className="flex items-center gap-2 text-espresso font-medium text-sm mb-3">
|
||||||
<Info size={16} className="text-primary" />
|
<Info size={16} className="text-primary" />
|
||||||
App-Info
|
App-Info
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-warm-grey space-y-1">
|
<div className="text-sm text-warm-grey space-y-1">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Version</span>
|
<span>Version</span>
|
||||||
<span className="text-espresso">2.0</span>
|
<span className="text-espresso">2.1.2026</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Erstellt</span>
|
<span>Erstellt</span>
|
||||||
@@ -165,7 +165,7 @@ export function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logout Button */}
|
{/* Logout */}
|
||||||
<div className="px-4 pb-8">
|
<div className="px-4 pb-8">
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
@@ -175,6 +175,10 @@ export function ProfilePage() {
|
|||||||
Abmelden
|
Abmelden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
|
<EditProfileModal open={showEditProfile} onClose={() => setShowEditProfile(false)} />
|
||||||
|
<ChangePasswordModal open={showChangePassword} onClose={() => setShowChangePassword(false)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react'
|
||||||
import { Dices } from 'lucide-react'
|
import { Dices } from 'lucide-react'
|
||||||
import { fetchRecipe, toggleFavorite, fetchRandomRecipe } from '../api/recipes'
|
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 { createNote, deleteNote } from '../api/notes'
|
||||||
import { Badge } from '../components/ui/Badge'
|
import { Badge } from '../components/ui/Badge'
|
||||||
import { Skeleton } from '../components/ui/Skeleton'
|
import { Skeleton } from '../components/ui/Skeleton'
|
||||||
@@ -22,6 +23,7 @@ export function RecipePage() {
|
|||||||
const [servingScale, setServingScale] = useState<number | null>(null)
|
const [servingScale, setServingScale] = useState<number | null>(null)
|
||||||
const [noteText, setNoteText] = useState('')
|
const [noteText, setNoteText] = useState('')
|
||||||
const [rerolling, setRerolling] = useState(false)
|
const [rerolling, setRerolling] = useState(false)
|
||||||
|
const [showIngredientPicker, setShowIngredientPicker] = useState(false)
|
||||||
|
|
||||||
const handleReroll = useCallback(async () => {
|
const handleReroll = useCallback(async () => {
|
||||||
setRerolling(true)
|
setRerolling(true)
|
||||||
@@ -47,14 +49,25 @@ export function RecipePage() {
|
|||||||
onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'),
|
onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const shoppingMutation = useMutation({
|
const [addingToShopping, setAddingToShopping] = useState(false)
|
||||||
mutationFn: () => addFromRecipe(recipe!.id),
|
|
||||||
onSuccess: (data) => {
|
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'] })
|
qc.invalidateQueries({ queryKey: ['shopping'] })
|
||||||
toast.success(`${data.added} Zutaten zur Einkaufsliste hinzugefügt!`)
|
toast.success(`${count} Zutaten zur Einkaufsliste hinzugefügt!`)
|
||||||
},
|
setShowIngredientPicker(false)
|
||||||
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
|
} catch {
|
||||||
})
|
toast.error('Fehler beim Hinzufügen zur Einkaufsliste')
|
||||||
|
} finally {
|
||||||
|
setAddingToShopping(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const noteMutation = useMutation({
|
const noteMutation = useMutation({
|
||||||
mutationFn: (content: string) => createNote(recipe!.id, content),
|
mutationFn: (content: string) => createNote(recipe!.id, content),
|
||||||
@@ -225,14 +238,26 @@ export function RecipePage() {
|
|||||||
|
|
||||||
{/* Add to shopping list */}
|
{/* Add to shopping list */}
|
||||||
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
{recipe.ingredients && recipe.ingredients.length > 0 && (
|
||||||
<button
|
<>
|
||||||
onClick={() => shoppingMutation.mutate()}
|
<button
|
||||||
disabled={shoppingMutation.isPending}
|
onClick={() => setShowIngredientPicker(true)}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-secondary text-white px-4 py-3 rounded-xl font-medium transition-colors hover:bg-secondary/90 disabled:opacity-50 min-h-[44px]"
|
className="w-full flex items-center justify-center gap-2 bg-secondary text-white px-4 py-3 rounded-xl font-medium transition-colors hover:bg-secondary/90 min-h-[44px]"
|
||||||
>
|
>
|
||||||
<ShoppingCart size={18} />
|
<ShoppingCart size={18} />
|
||||||
{shoppingMutation.isPending ? 'Wird hinzugefügt...' : '🛒 Zur Einkaufsliste'}
|
🛒 Zur Einkaufsliste
|
||||||
</button>
|
</button>
|
||||||
|
<IngredientPickerModal
|
||||||
|
open={showIngredientPicker}
|
||||||
|
onClose={() => setShowIngredientPicker(false)}
|
||||||
|
ingredients={recipe.ingredients.map((ing) => ({
|
||||||
|
name: ing.name,
|
||||||
|
amount: ing.amount,
|
||||||
|
unit: ing.unit,
|
||||||
|
}))}
|
||||||
|
onSubmit={handleAddToShopping}
|
||||||
|
loading={addingToShopping}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Steps */}
|
{/* Steps */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Trash2, Plus, ShoppingCart, X } from 'lucide-react'
|
import { Trash2, Plus, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
fetchShopping,
|
fetchShopping,
|
||||||
addCustomItem,
|
addCustomItem,
|
||||||
@@ -10,16 +10,29 @@ import {
|
|||||||
deleteAll,
|
deleteAll,
|
||||||
} from '../api/shopping'
|
} from '../api/shopping'
|
||||||
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
|
import type { ShoppingGroup, ShoppingItem } from '../api/shopping'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { EmptyState } from '../components/ui/EmptyState'
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
|
||||||
export function ShoppingPage() {
|
export function ShoppingPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
const [newItem, setNewItem] = useState('')
|
const [newItem, setNewItem] = useState('')
|
||||||
|
const [scope, setScope] = useState<'personal' | 'household'>('personal')
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const inputRef = useRef<HTMLInputElement>(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({
|
const { data: groups = [], isLoading, refetch } = useQuery({
|
||||||
queryKey: ['shopping'],
|
queryKey: ['shopping', activeScope],
|
||||||
queryFn: fetchShopping,
|
queryFn: () => fetchShopping(activeScope),
|
||||||
})
|
})
|
||||||
|
|
||||||
const checkMutation = useMutation({
|
const checkMutation = useMutation({
|
||||||
@@ -33,19 +46,19 @@ export function ShoppingPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const deleteCheckedMutation = useMutation({
|
const deleteCheckedMutation = useMutation({
|
||||||
mutationFn: deleteChecked,
|
mutationFn: () => deleteChecked(activeScope),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const deleteAllMutation = useMutation({
|
const deleteAllMutation = useMutation({
|
||||||
mutationFn: deleteAll,
|
mutationFn: () => deleteAll(activeScope),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['shopping'] }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||||
|
|
||||||
const addMutation = useMutation({
|
const addMutation = useMutation({
|
||||||
mutationFn: addCustomItem,
|
mutationFn: (item: { name: string }) => addCustomItem(item, activeScope),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['shopping'] })
|
qc.invalidateQueries({ queryKey: ['shopping'] })
|
||||||
setNewItem('')
|
setNewItem('')
|
||||||
@@ -65,29 +78,23 @@ export function ShoppingPage() {
|
|||||||
const totalUnchecked = totalItems - totalChecked
|
const totalUnchecked = totalItems - totalChecked
|
||||||
const recipeCount = groups.filter((g) => g.recipe_id).length
|
const recipeCount = groups.filter((g) => g.recipe_id).length
|
||||||
|
|
||||||
// Sort items: unchecked first, checked last
|
|
||||||
const sortItems = (items: ShoppingItem[]) => {
|
const sortItems = (items: ShoppingItem[]) => {
|
||||||
const unchecked = items.filter((i) => !i.checked)
|
const unchecked = items.filter((i) => !i.checked)
|
||||||
const checked = items.filter((i) => i.checked)
|
const checked = items.filter((i) => i.checked)
|
||||||
return [...unchecked, ...checked]
|
return [...unchecked, ...checked]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pull-to-refresh via touch
|
|
||||||
const [pulling, setPulling] = useState(false)
|
const [pulling, setPulling] = useState(false)
|
||||||
const touchStartY = useRef(0)
|
const touchStartY = useRef(0)
|
||||||
|
|
||||||
const handleTouchStart = (e: React.TouchEvent) => {
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
if (window.scrollY === 0) {
|
if (window.scrollY === 0) touchStartY.current = e.touches[0].clientY
|
||||||
touchStartY.current = e.touches[0].clientY
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
if (pulling) {
|
if (pulling) {
|
||||||
const dy = e.changedTouches[0].clientY - touchStartY.current
|
const dy = e.changedTouches[0].clientY - touchStartY.current
|
||||||
if (dy > 80) {
|
if (dy > 80) refetch()
|
||||||
refetch()
|
|
||||||
}
|
|
||||||
setPulling(false)
|
setPulling(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +151,35 @@ export function ShoppingPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clear All Confirm Dialog */}
|
{/* Scope Toggle */}
|
||||||
|
{hasHousehold && (
|
||||||
|
<div className="sticky top-[53px] z-35 bg-cream/95 backdrop-blur-sm px-4 py-2">
|
||||||
|
<div className="flex bg-sand/50 rounded-xl p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setScope('personal')}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
||||||
|
scope === 'personal'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-warm-grey hover:text-espresso'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Persönlich
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setScope('household')}
|
||||||
|
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors min-h-[36px] ${
|
||||||
|
scope === 'household'
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-warm-grey hover:text-espresso'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🏠 Haushalt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear All Confirm */}
|
||||||
{showClearConfirm && (
|
{showClearConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm px-6">
|
||||||
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl">
|
<div className="bg-surface rounded-2xl p-6 max-w-sm w-full shadow-xl">
|
||||||
@@ -171,13 +206,12 @@ export function ShoppingPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pull indicator */}
|
|
||||||
{pulling && (
|
{pulling && (
|
||||||
<div className="text-center text-warm-grey text-sm py-2">↓ Loslassen zum Aktualisieren</div>
|
<div className="text-center text-warm-grey text-sm py-2">↓ Loslassen zum Aktualisieren</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Quick-Add */}
|
{/* Quick-Add */}
|
||||||
<div className="sticky top-[53px] z-30 bg-cream/95 backdrop-blur-sm px-4 py-3">
|
<div className={`sticky ${hasHousehold ? 'top-[105px]' : 'top-[53px]'} z-30 bg-cream/95 backdrop-blur-sm px-4 py-3`}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -212,7 +246,9 @@ export function ShoppingPage() {
|
|||||||
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}
|
{recipeCount > 0 && <>{recipeCount} Rezept{recipeCount !== 1 ? 'e' : ''} · </>}
|
||||||
{totalItems} Artikel · {totalChecked} erledigt
|
{totalItems} Artikel · {totalChecked} erledigt
|
||||||
</span>
|
</span>
|
||||||
<span className="text-warm-grey text-xs">{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%</span>
|
<span className="text-warm-grey text-xs">
|
||||||
|
{totalItems > 0 ? Math.round((totalChecked / totalItems) * 100) : 0}%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 h-1.5 bg-sand rounded-full overflow-hidden">
|
<div className="mt-2 h-1.5 bg-sand rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
@@ -235,7 +271,6 @@ export function ShoppingPage() {
|
|||||||
) : (
|
) : (
|
||||||
groups.map((group) => (
|
groups.map((group) => (
|
||||||
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
|
<div key={group.recipe_title} className="bg-surface rounded-2xl shadow-sm overflow-hidden">
|
||||||
{/* Group header */}
|
|
||||||
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
|
<div className="px-4 py-3 border-b border-sand/50 flex items-center gap-2">
|
||||||
<span className="text-base">{group.recipe_id ? '🍰' : '📝'}</span>
|
<span className="text-base">{group.recipe_id ? '🍰' : '📝'}</span>
|
||||||
<h3 className="font-semibold text-sm text-espresso truncate">
|
<h3 className="font-semibold text-sm text-espresso truncate">
|
||||||
@@ -245,8 +280,6 @@ export function ShoppingPage() {
|
|||||||
{group.items.filter((i) => !i.checked).length}/{group.items.length}
|
{group.items.filter((i) => !i.checked).length}/{group.items.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items */}
|
|
||||||
<ul>
|
<ul>
|
||||||
{sortItems(group.items).map((item) => (
|
{sortItems(group.items).map((item) => (
|
||||||
<ShoppingItemRow
|
<ShoppingItemRow
|
||||||
@@ -309,15 +342,24 @@ function ShoppingItemRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="relative overflow-hidden">
|
<li className="relative overflow-hidden">
|
||||||
{/* Delete background */}
|
<div
|
||||||
<div className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'}`}>
|
className={`absolute inset-y-0 right-0 w-40 flex items-center justify-center transition-colors ${
|
||||||
<span className={`text-white font-medium transition-all ${pastThreshold ? 'text-sm scale-110' : 'text-xs'}`}>
|
pastThreshold ? 'bg-berry-red' : 'bg-berry-red/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`text-white font-medium transition-all ${
|
||||||
|
pastThreshold ? 'text-sm scale-110' : 'text-xs'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{pastThreshold ? '🗑️ Löschen' : '×'}
|
{pastThreshold ? '🗑️ Löschen' : '×'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${swiping.current ? '' : 'transition-transform duration-200'}`}
|
className={`relative bg-surface flex items-center gap-3 px-4 min-h-[52px] ${
|
||||||
|
swiping.current ? '' : 'transition-transform duration-200'
|
||||||
|
}`}
|
||||||
style={{ transform: `translateX(${swipeX}px)` }}
|
style={{ transform: `translateX(${swipeX}px)` }}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
onTouchMove={handleTouchMove}
|
onTouchMove={handleTouchMove}
|
||||||
@@ -333,19 +375,33 @@ function ShoppingItemRow({
|
|||||||
>
|
>
|
||||||
{item.checked && (
|
{item.checked && (
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
<path d="M2.5 7L5.5 10L11.5 4" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
<path
|
||||||
|
d="M2.5 7L5.5 10L11.5 4"
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
|
<div className={`flex-1 min-w-0 ${item.checked ? 'opacity-50' : ''}`}>
|
||||||
<span className={`text-base sm:text-lg text-espresso ${item.checked ? 'line-through text-warm-grey' : ''}`}>
|
<span
|
||||||
|
className={`text-base sm:text-lg text-espresso ${
|
||||||
|
item.checked ? 'line-through text-warm-grey' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{amountText && (
|
{amountText && (
|
||||||
<span className={`text-sm flex-shrink-0 ${item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'}`}>
|
<span
|
||||||
|
className={`text-sm flex-shrink-0 ${
|
||||||
|
item.checked ? 'text-warm-grey/50 line-through' : 'text-warm-grey'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{amountText}
|
{amountText}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user