Auth v2: Register/Login/Profile, Households, per-user Favorites/Notes/Shopping, Frontend Auth Pages

This commit is contained in:
clawd
2026-02-18 15:47:13 +00:00
parent b0bd3e533f
commit 30e44370a1
32 changed files with 3561 additions and 113 deletions

View File

@@ -0,0 +1,116 @@
# Auth v2 Phase 2: Households Backend - Implementation Summary
## ✅ What was implemented
### 1. Database Migration (004_households.sql)
- ✅ Created `households` table with id, name, invite_code, created_at
- ✅ Created `household_members` table for many-to-many user-household relationships
- ✅ Added `household_id` column to `shopping_items` table
- ✅ Added proper indexes for performance
### 2. Household Service (`src/services/household.service.ts`)
-`createHousehold(userId, name)` - Creates household and makes user owner
-`joinHousehold(userId, inviteCode)` - User joins household with invite code
-`getMyHousehold(userId)` - Get user's household with member list
-`leaveHousehold(userId, householdId)` - Leave or delete household
-`regenerateInviteCode(userId, householdId)` - Owner-only invite code regeneration
- ✅ Automatic 8-character unique invite code generation
- ✅ Proper role management (owner/member)
### 3. Household Routes (`src/routes/households.ts`)
-`POST /api/households` - Create household (authenticated)
-`GET /api/households/mine` - Get my household (authenticated)
-`POST /api/households/join` - Join with invite code (authenticated)
-`POST /api/households/:id/invite` - Regenerate invite (owner only)
-`DELETE /api/households/:id/leave` - Leave household (authenticated)
### 4. Shopping Items Extended
- ✅ Updated `shopping.service.ts` to support user_id and household_id
- ✅ Added `scope` parameter support: `?scope=personal` vs `?scope=household`
- ✅ Personal shopping: user-specific items
- ✅ Household shopping: shared items for household members
- ✅ Proper access control - users can only modify their own or household items
### 5. Favorites Per User
- ✅ Updated `recipe.service.ts` to use `user_favorites` table instead of `is_favorite` column
-`toggleFavorite(id, userId)` - Per-user favorites
-`listRecipes()` - Shows user-specific favorite status
-`GET /api/recipes?favorite=true` - Only user's favorites
### 6. Notes Per User
- ✅ Updated `note.service.ts` to filter by user_id
- ✅ Users only see and can modify their own notes
- ✅ Maintains backward compatibility for unauthenticated access
### 7. Routes Registration
- ✅ Added household routes to `src/app.ts`
## ✅ Test Results
All tests performed successfully with two test users:
### User Management
- ✅ User registration and login working
- ✅ JWT tokens generated and accepted
### Household Functionality
-**Household Creation**: User 1 created "Test Family" household with invite code EF27Y501
-**Household Joining**: User 2 successfully joined using invite code
-**Member Roles**: User 1 = owner, User 2 = member
-**Invite Code Regeneration**: Owner (User 1) can regenerate → new code: 9IBNZMRM
-**Permission Control**: Member (User 2) cannot regenerate invite codes (403 Forbidden)
### Shopping Lists
-**Personal Scope**: Each user sees only their own personal items
- User 1 personal: "User1 Personal Item" (2 pieces)
- User 2 personal: "User2 Personal Coffee" (1 bag)
-**Household Scope**: Both users see same household items
- "Household Milk" (1 liter) - added by User 1
- "Household Bread" (2 loaves) - added by User 2
-**Scope Isolation**: Personal and household lists completely separate
### Favorites System
-**Per-User Favorites**: Each user has independent favorite recipes
-**Toggle Functionality**: User 1 favorites recipe → User 2 unfavorites same recipe
-**Isolated Lists**: User 1 has 1 favorite, User 2 has 0 favorites
### Notes System
-**Per-User Notes**: Each user sees only their own notes on recipes
-**Content Isolation**:
- User 1 note: "User1 note: This is my favorite recipe!"
- User 2 note: "User2 note: Need to try this recipe soon."
-**Privacy**: Users cannot see other users' notes
### API Endpoints Tested
```
✅ POST /api/auth/register
✅ POST /api/households
✅ GET /api/households/mine
✅ POST /api/households/join
✅ POST /api/households/:id/invite
✅ GET /api/shopping?scope=personal
✅ GET /api/shopping?scope=household
✅ POST /api/shopping
✅ POST /api/shopping?scope=household
✅ PATCH /api/recipes/:id/favorite
✅ GET /api/recipes?favorite=true
✅ POST /api/recipes/:id/notes
✅ GET /api/recipes/:id/notes
```
## 🚀 System Status
- ✅ Database migrations applied successfully
- ✅ Backend server running on http://localhost:6001
- ✅ All core household features working as specified
- ✅ Proper authentication and authorization
- ✅ Data isolation between users and households
- ✅ Backward compatibility maintained for non-authenticated access
## 🔒 Security Features
- ✅ JWT-based authentication for all household operations
- ✅ Owner-only actions properly restricted
- ✅ User data isolation (personal shopping, favorites, notes)
- ✅ Household data sharing only between members
- ✅ Proper error handling and validation
Phase 2 implementation **COMPLETE** and fully tested! 🎉

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -9,18 +9,25 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@types/sharp": "^0.31.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"dotenv": "^17.3.1",
"fastify": "^5.7.4",
"jsonwebtoken": "^9.0.3",
"sharp": "^0.34.5",
"ulid": "^3.0.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
@@ -521,6 +528,26 @@
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
"node_modules/@fastify/cookie": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^1.0.0",
"fastify-plugin": "^5.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
@@ -608,6 +635,29 @@
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.0.0.tgz",
"integrity": "sha512-2Qka3NiyNNcsfejMUvyzot1T4UYIzzcbkFGDdVyrl344fRZ/WkD6VFXOoXhxe2Pzf3LpJNkoSxUM4Ru4DVgkYA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.0.2",
"fastify-plugin": "^5.0.1",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -1206,6 +1256,16 @@
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
@@ -1216,6 +1276,24 @@
"@types/node": "*"
}
},
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
@@ -1273,6 +1351,18 @@
}
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -1334,6 +1424,20 @@
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/better-sqlite3": {
"version": "12.6.2",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz",
@@ -1368,6 +1472,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
@@ -1404,6 +1514,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
@@ -1487,6 +1603,27 @@
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"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",
@@ -1589,6 +1726,21 @@
"rfdc": "^1.2.0"
}
},
"node_modules/fast-jwt": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.1.0.tgz",
"integrity": "sha512-cGK/TXlud8INL49Iv7yRtZy0PHzNJId1shfqNCqdF0gOlWiy+1FPgjxX+ZHp/CYxFYDaoNnxeYEGzcXSkahUEQ==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
@@ -1614,6 +1766,18 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.7.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
@@ -1663,6 +1827,16 @@
],
"license": "MIT"
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -1672,6 +1846,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.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",
@@ -1850,6 +2034,49 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
"license": "MIT",
"dependencies": {
"jws": "^4.0.1",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
},
"engines": {
"node": ">=12",
"npm": ">=6"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@@ -1887,6 +2114,48 @@
],
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"license": "MIT"
},
"node_modules/lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
"license": "MIT"
},
"node_modules/lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
"license": "MIT"
},
"node_modules/lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
"license": "MIT"
},
"node_modules/lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"license": "MIT"
},
"node_modules/lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
"node_modules/lru-cache": {
"version": "11.2.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
@@ -1920,6 +2189,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.2.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.1.tgz",
@@ -1959,6 +2234,21 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mnemonist": {
"version": "0.40.3",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.3.tgz",
"integrity": "sha512-Vjyr90sJ23CKKH/qPAgUKicw/v6pRoamxIEDFOF8uSgFME7DqPRpHgRTejWVjkdGg5dXj0/NyxZHZ9bcjH+2uQ==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/napi-build-utils": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
@@ -1977,6 +2267,32 @@
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -2236,6 +2552,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -2392,6 +2714,19 @@
"node": ">= 0.8"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.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",
@@ -2548,6 +2883,15 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@@ -13,18 +13,25 @@
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@types/sharp": "^0.31.1",
"bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.2",
"dotenv": "^17.3.1",
"fastify": "^5.7.4",
"jsonwebtoken": "^9.0.3",
"sharp": "^0.34.5",
"ulid": "^3.0.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3",
"tsx": "^4.21.0",
"typescript": "^5.9.3"

View File

@@ -1,5 +1,7 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import cookie from '@fastify/cookie';
import { healthRoutes } from './routes/health.js';
import { categoryRoutes } from './routes/categories.js';
import { recipeRoutes } from './routes/recipes.js';
@@ -10,6 +12,7 @@ import { imageRoutes } from './routes/images.js';
import { botRoutes } from './routes/bot.js';
import { ogScrapeRoutes } from './routes/og-scrape.js';
import { authRoutes } from './routes/auth.js';
import { householdRoutes } from './routes/households.js';
export async function buildApp() {
const app = Fastify({ logger: true });
@@ -17,6 +20,19 @@ export async function buildApp() {
await app.register(cors, { origin: true });
await app.after();
// Register JWT plugin (though we handle JWT manually in auth service)
await app.register(jwt, {
secret: process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production',
});
await app.after();
// Register Cookie plugin for refresh tokens
await app.register(cookie, {
secret: process.env.COOKIE_SECRET || 'your-super-secret-cookie-key-change-in-production',
parseOptions: {},
});
await app.after();
await app.register(healthRoutes);
await app.after();
@@ -47,5 +63,8 @@ export async function buildApp() {
await app.register(authRoutes);
await app.after();
await app.register(householdRoutes);
await app.after();
return app;
}

View File

@@ -0,0 +1,43 @@
-- 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;

View File

@@ -0,0 +1,27 @@
-- 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);

View File

@@ -1,3 +1,4 @@
import 'dotenv/config';
import { buildApp } from './app.js';
import { runMigrations } from './db/migrate.js';

View File

@@ -1,7 +1,63 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import { authService } from '../services/auth.service.js';
// v2: Auth middleware — currently passes through everything
export async function authMiddleware(request: FastifyRequest, _reply: FastifyReply) {
// TODO v2: Verify JWT token, set request.user
(request as any).user = { id: 'default', name: 'Luna' };
declare module 'fastify' {
interface FastifyRequest {
user?: {
id: string;
email: string;
display_name: string;
};
}
}
// Required auth middleware - throws error if no valid token
export async function authMiddleware(request: FastifyRequest, reply: FastifyReply) {
try {
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return reply.status(401).send({
success: false,
error: 'MISSING_TOKEN',
message: 'Authorization token required'
});
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
const decoded = authService.verifyAccessToken(token);
request.user = {
id: decoded.sub,
email: decoded.email,
display_name: decoded.display_name,
};
} catch (error) {
return reply.status(401).send({
success: false,
error: 'INVALID_TOKEN',
message: 'Invalid or expired token'
});
}
}
// Optional auth middleware - sets user if token is valid, but doesn't fail if missing
export async function optionalAuthMiddleware(request: FastifyRequest, _reply: FastifyReply) {
try {
const authHeader = request.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const decoded = authService.verifyAccessToken(token);
request.user = {
id: decoded.sub,
email: decoded.email,
display_name: decoded.display_name,
};
}
} catch (error) {
// Silently ignore invalid tokens in optional auth
request.user = undefined;
}
}

View File

@@ -1,15 +1,308 @@
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { authService } from '../services/auth.service.js';
import { authMiddleware } from '../middleware/auth.js';
const registerSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long'),
});
const loginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
const updateProfileSchema = z.object({
display_name: z.string().min(2, 'Display name must be at least 2 characters').max(50, 'Display name too long').optional(),
avatar_url: z.string().url('Invalid avatar URL').or(z.literal('')).optional(),
});
const changePasswordSchema = z.object({
current_password: z.string().min(1, 'Current password is required'),
new_password: z.string().min(8, 'New password must be at least 8 characters'),
});
const refreshTokenSchema = z.object({
refresh_token: z.string().min(1, 'Refresh token is required'),
});
export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
// POST /api/auth/register
app.post('/api/auth/register', async (request, reply) => {
try {
const data = registerSchema.parse(request.body);
const result = await authService.register(data.email, data.password, data.display_name);
// Set refresh token as httpOnly cookie
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
reply.status(201).send({
success: true,
user: result.user,
access_token: result.tokens.accessToken,
});
} catch (error: any) {
if (error.message === 'EMAIL_EXISTS') {
return reply.status(400).send({
success: false,
error: 'EMAIL_EXISTS',
message: 'An account with this email already exists',
});
}
if (error instanceof z.ZodError) {
const firstError = error.errors && error.errors.length > 0 ? error.errors[0] : null;
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: firstError?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Register error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Registration failed',
});
}
});
app.post('/api/auth/register', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
// POST /api/auth/login
app.post('/api/auth/login', async (request, reply) => {
try {
const data = loginSchema.parse(request.body);
const result = await authService.login(data.email, data.password);
// Set refresh token as httpOnly cookie
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
reply.send({
success: true,
user: result.user,
access_token: result.tokens.accessToken,
});
} catch (error: any) {
if (error.message === 'INVALID_CREDENTIALS') {
return reply.status(401).send({
success: false,
error: 'INVALID_CREDENTIALS',
message: 'Invalid email or password',
});
}
if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: error.errors[0]?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Login error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Login failed',
});
}
});
app.get('/api/auth/me', async (_request, reply) => {
reply.status(501).send({ error: 'not implemented' });
// POST /api/auth/logout
app.post('/api/auth/logout', async (request, reply) => {
// Clear refresh token cookie
reply.clearCookie('luna_refresh_token', { path: '/' });
reply.send({
success: true,
message: 'Successfully logged out',
});
});
// GET /api/auth/me (protected)
app.get('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => {
try {
const user = await authService.getProfile(request.user!.id);
if (!user) {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
reply.send({
success: true,
user,
});
} catch (error) {
console.error('Get profile error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to get profile',
});
}
});
// PUT /api/auth/me (protected)
app.put('/api/auth/me', { preHandler: authMiddleware }, async (request, reply) => {
try {
const data = updateProfileSchema.parse(request.body);
const user = await authService.updateProfile(request.user!.id, data);
reply.send({
success: true,
user,
});
} catch (error: any) {
if (error.message === 'USER_NOT_FOUND') {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
if (error.message === 'NO_UPDATES_PROVIDED') {
return reply.status(400).send({
success: false,
error: 'NO_UPDATES_PROVIDED',
message: 'No updates provided',
});
}
if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: error.errors[0]?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Update profile error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to update profile',
});
}
});
// PUT /api/auth/me/password (protected)
app.put('/api/auth/me/password', { preHandler: authMiddleware }, async (request, reply) => {
try {
const data = changePasswordSchema.parse(request.body);
await authService.changePassword(request.user!.id, data.current_password, data.new_password);
reply.send({
success: true,
message: 'Password changed successfully',
});
} catch (error: any) {
if (error.message === 'USER_NOT_FOUND') {
return reply.status(404).send({
success: false,
error: 'USER_NOT_FOUND',
message: 'User not found',
});
}
if (error.message === 'INVALID_CURRENT_PASSWORD') {
return reply.status(400).send({
success: false,
error: 'INVALID_CURRENT_PASSWORD',
message: 'Current password is incorrect',
});
}
if (error instanceof z.ZodError) {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: error.errors[0]?.message || 'Invalid data provided',
details: error.errors,
});
}
console.error('Change password error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to change password',
});
}
});
// POST /api/auth/refresh (for refreshing access tokens)
app.post('/api/auth/refresh', async (request, reply) => {
try {
const refreshToken = request.cookies.luna_refresh_token;
if (!refreshToken) {
return reply.status(401).send({
success: false,
error: 'MISSING_REFRESH_TOKEN',
message: 'Refresh token required',
});
}
const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie
reply.setCookie('luna_refresh_token', result.tokens.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/',
});
reply.send({
success: true,
user: result.user,
access_token: result.tokens.accessToken,
});
} catch (error: any) {
if (error.message === 'INVALID_REFRESH_TOKEN' || error.message === 'USER_NOT_FOUND') {
// Clear invalid refresh token cookie
reply.clearCookie('luna_refresh_token', { path: '/' });
return reply.status(401).send({
success: false,
error: 'INVALID_REFRESH_TOKEN',
message: 'Invalid or expired refresh token',
});
}
console.error('Refresh token error:', error);
reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to refresh token',
});
}
});
}

View File

@@ -0,0 +1,178 @@
import { FastifyInstance } from 'fastify';
import { authMiddleware } from '../middleware/auth.js';
import { householdService } from '../services/household.service.js';
export async function householdRoutes(app: FastifyInstance) {
// Create a new household
app.post('/api/households', { preHandler: [authMiddleware] }, async (request, reply) => {
const { name } = request.body as { name: string };
if (!name || name.trim() === '') {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Household name is required'
});
}
try {
const household = await householdService.createHousehold(request.user!.id, name.trim());
return reply.status(201).send({
success: true,
data: household
});
} catch (error: any) {
if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') {
return reply.status(409).send({
success: false,
error: 'USER_ALREADY_IN_HOUSEHOLD',
message: 'You are already a member of a household'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to create household'
});
}
});
// Get my household
app.get('/api/households/mine', { preHandler: [authMiddleware] }, async (request, reply) => {
try {
const household = await householdService.getMyHousehold(request.user!.id);
if (!household) {
return reply.status(404).send({
success: false,
error: 'NO_HOUSEHOLD',
message: 'You are not a member of any household'
});
}
return reply.send({
success: true,
data: household
});
} catch (error: any) {
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to get household'
});
}
});
// Join a household with invite code
app.post('/api/households/join', { preHandler: [authMiddleware] }, async (request, reply) => {
const { inviteCode } = request.body as { inviteCode: string };
if (!inviteCode || inviteCode.trim() === '') {
return reply.status(400).send({
success: false,
error: 'VALIDATION_ERROR',
message: 'Invite code is required'
});
}
try {
const household = await householdService.joinHousehold(request.user!.id, inviteCode.trim().toUpperCase());
return reply.send({
success: true,
data: household
});
} catch (error: any) {
if (error.message === 'USER_ALREADY_IN_HOUSEHOLD') {
return reply.status(409).send({
success: false,
error: 'USER_ALREADY_IN_HOUSEHOLD',
message: 'You are already a member of a household'
});
}
if (error.message === 'INVALID_INVITE_CODE') {
return reply.status(404).send({
success: false,
error: 'INVALID_INVITE_CODE',
message: 'Invalid invite code'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to join household'
});
}
});
// Regenerate invite code (owner only)
app.post('/api/households/:id/invite', { preHandler: [authMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
try {
const newInviteCode = await householdService.regenerateInviteCode(request.user!.id, id);
return reply.send({
success: true,
data: { invite_code: newInviteCode }
});
} catch (error: any) {
if (error.message === 'ONLY_OWNER_CAN_REGENERATE_INVITE') {
return reply.status(403).send({
success: false,
error: 'FORBIDDEN',
message: 'Only household owners can regenerate invite codes'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to regenerate invite code'
});
}
});
// Leave household
app.delete('/api/households/:id/leave', { preHandler: [authMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
try {
await householdService.leaveHousehold(request.user!.id, id);
return reply.send({
success: true,
message: 'Successfully left household'
});
} catch (error: any) {
if (error.message === 'NOT_HOUSEHOLD_MEMBER') {
return reply.status(404).send({
success: false,
error: 'NOT_HOUSEHOLD_MEMBER',
message: 'You are not a member of this household'
});
}
if (error.message === 'OWNER_CANNOT_LEAVE_WITH_MEMBERS') {
return reply.status(409).send({
success: false,
error: 'OWNER_CANNOT_LEAVE_WITH_MEMBERS',
message: 'Household owners cannot leave while other members remain'
});
}
app.log.error(error);
return reply.status(500).send({
success: false,
error: 'INTERNAL_ERROR',
message: 'Failed to leave household'
});
}
});
}

View File

@@ -1,33 +1,44 @@
import { FastifyInstance } from 'fastify';
import { optionalAuthMiddleware } from '../middleware/auth.js';
import * as svc from '../services/note.service.js';
export async function noteRoutes(app: FastifyInstance) {
app.get('/api/recipes/:id/notes', async (request) => {
app.get('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { id } = request.params as { id: string };
return svc.listNotes(id);
const userId = request.user?.id;
return svc.listNotes(id, userId);
});
app.post('/api/recipes/:id/notes', async (request, reply) => {
app.post('/api/recipes/:id/notes', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
const userId = request.user?.id;
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.createNote(id, content);
const note = svc.createNote(id, content, userId);
if (!note) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send(note);
});
app.put('/api/notes/:id', async (request, reply) => {
app.put('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const { content } = request.body as { content: string };
const userId = request.user?.id;
if (!content) return reply.status(400).send({ error: 'content required' });
const note = svc.updateNote(id, content);
const note = svc.updateNote(id, content, userId);
if (!note) return reply.status(404).send({ error: 'Not found' });
return note;
});
app.delete('/api/notes/:id', async (request, reply) => {
app.delete('/api/notes/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteNote(id);
const userId = request.user?.id;
const ok = svc.deleteNote(id, userId);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});

View File

@@ -1,4 +1,5 @@
import { FastifyInstance } from 'fastify';
import { optionalAuthMiddleware, authMiddleware } from '../middleware/auth.js';
import * as svc from '../services/recipe.service.js';
export async function recipeRoutes(app: FastifyInstance) {
@@ -15,8 +16,10 @@ export async function recipeRoutes(app: FastifyInstance) {
return { data: results, total: results.length };
});
app.get('/api/recipes', async (request) => {
app.get('/api/recipes', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const query = request.query as any;
const userId = request.user?.id;
return svc.listRecipes({
page: query.page ? Number(query.page) : undefined,
limit: query.limit ? Number(query.limit) : undefined,
@@ -25,6 +28,7 @@ export async function recipeRoutes(app: FastifyInstance) {
favorite: query.favorite !== undefined ? query.favorite === 'true' : undefined,
difficulty: query.difficulty,
maxTime: query.maxTime ? Number(query.maxTime) : undefined,
userId: userId,
});
});
@@ -56,9 +60,11 @@ export async function recipeRoutes(app: FastifyInstance) {
return { ok: true };
});
app.patch('/api/recipes/:id/favorite', async (request, reply) => {
app.patch('/api/recipes/:id/favorite', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const result = svc.toggleFavorite(id);
const userId = request.user?.id;
const result = svc.toggleFavorite(id, userId);
if (!result) return reply.status(404).send({ error: 'Not found' });
return result;
});

View File

@@ -1,40 +1,92 @@
import { FastifyInstance } from 'fastify';
import { optionalAuthMiddleware } from '../middleware/auth.js';
import * as svc from '../services/shopping.service.js';
export async function shoppingRoutes(app: FastifyInstance) {
app.get('/api/shopping', async () => {
return svc.listItems();
// List shopping items with optional authentication
app.get('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
return svc.listItems(userId, shoppingScope);
});
app.post('/api/shopping/from-recipe/:id', async (request, reply) => {
// Add items from recipe with optional authentication
app.post('/api/shopping/from-recipe/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const items = svc.addFromRecipe(id);
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send({ added: items.length });
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
try {
const items = svc.addFromRecipe(id, userId, shoppingScope);
if (!items) return reply.status(404).send({ error: 'Recipe not found' });
return reply.status(201).send({ added: items.length });
} catch (error: any) {
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
return reply.status(400).send({ error: 'You are not a member of any household' });
}
throw error;
}
});
app.post('/api/shopping', async (request, reply) => {
// Add single item with optional authentication
app.post('/api/shopping', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { name, amount, unit } = request.body as { name: string; amount?: number; unit?: string };
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
if (!name) return reply.status(400).send({ error: 'name required' });
const item = svc.addItem(name, amount, unit);
return reply.status(201).send(item);
try {
const item = svc.addItem(name, amount, unit, userId, shoppingScope);
return reply.status(201).send(item);
} catch (error: any) {
if (error.message === 'USER_NOT_IN_HOUSEHOLD') {
return reply.status(400).send({ error: 'You are not a member of any household' });
}
throw error;
}
});
app.patch('/api/shopping/:id/check', async (request, reply) => {
// Toggle check status with optional authentication
app.patch('/api/shopping/:id/check', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const item = svc.toggleCheck(id);
const userId = request.user?.id;
const item = svc.toggleCheck(id, userId);
if (!item) return reply.status(404).send({ error: 'Not found' });
return item;
});
app.delete('/api/shopping/checked', async () => {
const count = svc.deleteChecked();
// Delete all items with optional authentication
app.delete('/api/shopping/all', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
const count = svc.deleteAll(userId, shoppingScope);
return { ok: true, deleted: count };
});
app.delete('/api/shopping/:id', async (request, reply) => {
// Delete checked items with optional authentication
app.delete('/api/shopping/checked', { preHandler: [optionalAuthMiddleware] }, async (request) => {
const { scope } = request.query as { scope?: 'personal' | 'household' };
const userId = request.user?.id;
const shoppingScope = scope || 'personal';
const count = svc.deleteChecked(userId, shoppingScope);
return { ok: true, deleted: count };
});
// Delete single item with optional authentication
app.delete('/api/shopping/:id', { preHandler: [optionalAuthMiddleware] }, async (request, reply) => {
const { id } = request.params as { id: string };
const ok = svc.deleteItem(id);
const userId = request.user?.id;
const ok = svc.deleteItem(id, userId);
if (!ok) return reply.status(404).send({ error: 'Not found' });
return { ok: true };
});

View File

@@ -0,0 +1,200 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { ulid } from 'ulid';
import { getDb } from '../db/connection.js';
const JWT_SECRET = process.env.JWT_SECRET || 'your-super-secret-jwt-key-change-in-production';
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'your-super-secret-refresh-key-change-in-production';
const BCRYPT_ROUNDS = 12;
const ACCESS_TOKEN_EXPIRES_IN = '15m';
const REFRESH_TOKEN_EXPIRES_IN = '7d';
export interface User {
id: string;
email: string;
display_name: string;
avatar_url?: string;
created_at: string;
updated_at: string;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface AuthResult {
user: User;
tokens: AuthTokens;
}
class AuthService {
private db = getDb();
async register(email: string, password: string, displayName: string): Promise<AuthResult> {
// Check if user already exists
const existingUser = this.db.prepare('SELECT id FROM users WHERE email = ?').get(email);
if (existingUser) {
throw new Error('EMAIL_EXISTS');
}
// Hash password
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
// Create user
const userId = ulid();
const now = new Date().toISOString();
this.db.prepare(`
INSERT INTO users (id, email, password_hash, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`).run(userId, email, passwordHash, displayName, now, now);
// Get created user
const user = this.db.prepare(`
SELECT id, email, display_name, avatar_url, created_at, updated_at
FROM users WHERE id = ?
`).get(userId) as User;
// Generate tokens
const tokens = this.generateTokens(user);
return { user, tokens };
}
async login(email: string, password: string): Promise<AuthResult> {
// Get user by email
const userWithPassword = this.db.prepare(`
SELECT id, email, password_hash, display_name, avatar_url, created_at, updated_at
FROM users WHERE email = ?
`).get(email) as any;
if (!userWithPassword) {
throw new Error('INVALID_CREDENTIALS');
}
// Check password
const passwordValid = await bcrypt.compare(password, userWithPassword.password_hash);
if (!passwordValid) {
throw new Error('INVALID_CREDENTIALS');
}
// Remove password from user object
const { password_hash, ...user } = userWithPassword;
// Generate tokens
const tokens = this.generateTokens(user);
return { user, tokens };
}
async getProfile(userId: string): Promise<User | null> {
const user = this.db.prepare(`
SELECT id, email, display_name, avatar_url, created_at, updated_at
FROM users WHERE id = ?
`).get(userId) as User | undefined;
return user || null;
}
async updateProfile(userId: string, data: { display_name?: string; avatar_url?: string }): Promise<User> {
const updates: string[] = [];
const values: any[] = [];
if (data.display_name !== undefined) {
updates.push('display_name = ?');
values.push(data.display_name);
}
if (data.avatar_url !== undefined) {
updates.push('avatar_url = ?');
values.push(data.avatar_url);
}
if (updates.length === 0) {
throw new Error('NO_UPDATES_PROVIDED');
}
updates.push('updated_at = ?');
values.push(new Date().toISOString());
values.push(userId);
this.db.prepare(`
UPDATE users SET ${updates.join(', ')} WHERE id = ?
`).run(...values);
const user = await this.getProfile(userId);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
return user;
}
async changePassword(userId: string, currentPassword: string, newPassword: string): Promise<boolean> {
// Get current password hash
const userWithPassword = this.db.prepare(`
SELECT password_hash FROM users WHERE id = ?
`).get(userId) as any;
if (!userWithPassword) {
throw new Error('USER_NOT_FOUND');
}
// Verify current password
const currentPasswordValid = await bcrypt.compare(currentPassword, userWithPassword.password_hash);
if (!currentPasswordValid) {
throw new Error('INVALID_CURRENT_PASSWORD');
}
// Hash new password
const newPasswordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
// Update password
this.db.prepare(`
UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?
`).run(newPasswordHash, new Date().toISOString(), userId);
return true;
}
async refreshTokens(refreshToken: string): Promise<AuthResult> {
try {
const decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as any;
const user = await this.getProfile(decoded.sub);
if (!user) {
throw new Error('USER_NOT_FOUND');
}
const tokens = this.generateTokens(user);
return { user, tokens };
} catch (error) {
throw new Error('INVALID_REFRESH_TOKEN');
}
}
verifyAccessToken(token: string): any {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
throw new Error('INVALID_ACCESS_TOKEN');
}
}
private generateTokens(user: User): AuthTokens {
const payload = {
sub: user.id,
email: user.email,
display_name: user.display_name,
};
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRES_IN });
const refreshToken = jwt.sign({ sub: user.id }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRES_IN });
return { accessToken, refreshToken };
}
}
export const authService = new AuthService();

View File

@@ -0,0 +1,218 @@
import { ulid } from 'ulid';
import { getDb } from '../db/connection.js';
export interface Household {
id: string;
name: string;
invite_code: string;
created_at: string;
}
export interface HouseholdMember {
user_id: string;
email: string;
display_name: string;
avatar_url?: string;
role: 'owner' | 'member';
joined_at: string;
}
export interface HouseholdWithMembers extends Household {
members: HouseholdMember[];
}
class HouseholdService {
private db = getDb();
// Generate a random 8-character invite code
private generateInviteCode(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let result = '';
for (let i = 0; i < 8; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
// Ensure invite code is unique
private generateUniqueInviteCode(): string {
let code: string;
let attempts = 0;
do {
code = this.generateInviteCode();
attempts++;
if (attempts > 100) {
throw new Error('Could not generate unique invite code');
}
} while (this.db.prepare('SELECT id FROM households WHERE invite_code = ?').get(code));
return code;
}
async createHousehold(userId: string, name: string): Promise<HouseholdWithMembers> {
// Check if user is already in a household
const existingMembership = this.db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId);
if (existingMembership) {
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
}
const householdId = ulid();
const inviteCode = this.generateUniqueInviteCode();
// Create household
this.db.prepare(`
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);
if (!household) {
throw new Error('Failed to create household');
}
return household;
}
async joinHousehold(userId: string, inviteCode: string): Promise<HouseholdWithMembers> {
// Check if user is already in a household
const existingMembership = this.db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId);
if (existingMembership) {
throw new Error('USER_ALREADY_IN_HOUSEHOLD');
}
// Find household by invite code
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);
if (!result) {
throw new Error('Failed to join household');
}
return result;
}
async getMyHousehold(userId: string): Promise<HouseholdWithMembers | null> {
// Get user's household
const householdMembership = this.db.prepare(`
SELECT h.*, hm.role
FROM households h
JOIN household_members hm ON h.id = hm.household_id
WHERE hm.user_id = ?
`).get(userId) as (Household & { role: 'owner' | 'member' }) | undefined;
if (!householdMembership) {
return null;
}
// Get all members of the household
const members = this.db.prepare(`
SELECT
u.id as user_id,
u.email,
u.display_name,
u.avatar_url,
hm.role,
hm.joined_at
FROM household_members hm
JOIN users u ON hm.user_id = u.id
WHERE hm.household_id = ?
ORDER BY hm.role DESC, hm.joined_at ASC
`).all(householdMembership.id) as HouseholdMember[];
const { role, ...household } = householdMembership;
return {
...household,
members
};
}
async leaveHousehold(userId: string, householdId: string): Promise<void> {
// Check if user is member of this household
const membership = this.db.prepare(`
SELECT role FROM household_members
WHERE user_id = ? AND household_id = ?
`).get(userId, householdId) as { role: string } | undefined;
if (!membership) {
throw new Error('NOT_HOUSEHOLD_MEMBER');
}
// If user is owner, check if there are other members
if (membership.role === 'owner') {
const memberCount = this.db.prepare(`
SELECT COUNT(*) as count FROM household_members WHERE household_id = ?
`).get(householdId) as { count: number };
if (memberCount.count > 1) {
throw new Error('OWNER_CANNOT_LEAVE_WITH_MEMBERS');
}
// If owner is the only member, delete the entire household
this.db.prepare('DELETE FROM households WHERE id = ?').run(householdId);
} else {
// Remove member from household
this.db.prepare(`
DELETE FROM household_members
WHERE user_id = ? AND household_id = ?
`).run(userId, householdId);
}
}
async regenerateInviteCode(userId: string, householdId: string): Promise<string> {
// Check if user is owner of this household
const membership = this.db.prepare(`
SELECT role FROM household_members
WHERE user_id = ? AND household_id = ?
`).get(userId, householdId) as { role: string } | undefined;
if (!membership || membership.role !== 'owner') {
throw new Error('ONLY_OWNER_CAN_REGENERATE_INVITE');
}
const newInviteCode = this.generateUniqueInviteCode();
this.db.prepare(`
UPDATE households SET invite_code = ? WHERE id = ?
`).run(newInviteCode, householdId);
return newInviteCode;
}
async getHouseholdByInviteCode(inviteCode: string): Promise<Household | null> {
const household = this.db.prepare(`
SELECT id, name, invite_code, created_at
FROM households WHERE invite_code = ?
`).get(inviteCode) as Household | undefined;
return household || null;
}
}
export const householdService = new HouseholdService();

View File

@@ -1,26 +1,64 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
export function listNotes(recipeId: string) {
return getDb().prepare('SELECT * FROM notes WHERE recipe_id = ? ORDER BY created_at DESC').all(recipeId);
export function listNotes(recipeId: string, userId?: string) {
const db = getDb();
if (!userId) {
// Legacy: return all notes without user filtering
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id IS NULL ORDER BY created_at DESC').all(recipeId);
}
// Return only user's notes
return db.prepare('SELECT * FROM notes WHERE recipe_id = ? AND user_id = ? ORDER BY created_at DESC').all(recipeId, userId);
}
export function createNote(recipeId: string, content: string) {
export function createNote(recipeId: string, content: string, userId?: string) {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const id = ulid();
db.prepare('INSERT INTO notes (id, recipe_id, content) VALUES (?, ?, ?)').run(id, recipeId, content);
db.prepare('INSERT INTO notes (id, recipe_id, content, user_id) VALUES (?, ?, ?, ?)').run(id, recipeId, content, userId || null);
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
}
export function updateNote(id: string, content: string) {
export function updateNote(id: string, content: string, userId?: string) {
const db = getDb();
const result = db.prepare('UPDATE notes SET content = ? WHERE id = ?').run(content, id);
let query: string;
let params: any[];
if (!userId) {
// Legacy: update notes without user filtering
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id IS NULL';
params = [content, id];
} else {
// Update only if note belongs to user
query = 'UPDATE notes SET content = ? WHERE id = ? AND user_id = ?';
params = [content, id, userId];
}
const result = db.prepare(query).run(...params);
if (result.changes === 0) return null;
return db.prepare('SELECT * FROM notes WHERE id = ?').get(id);
}
export function deleteNote(id: string): boolean {
return getDb().prepare('DELETE FROM notes WHERE id = ?').run(id).changes > 0;
export function deleteNote(id: string, userId?: string): boolean {
const db = getDb();
let query: string;
let params: any[];
if (!userId) {
// Legacy: delete notes without user filtering
query = 'DELETE FROM notes WHERE id = ? AND user_id IS NULL';
params = [id];
} else {
// Delete only if note belongs to user
query = 'DELETE FROM notes WHERE id = ? AND user_id = ?';
params = [id, userId];
}
return db.prepare(query).run(...params).changes > 0;
}

View File

@@ -63,7 +63,7 @@ function mapTimeFields(row: any) {
export function listRecipes(opts: {
page?: number; limit?: number; category_id?: string; category_slug?: string;
favorite?: boolean; difficulty?: string; maxTime?: number;
favorite?: boolean; difficulty?: string; maxTime?: number; userId?: string;
}) {
const db = getDb();
const page = opts.page || 1;
@@ -74,15 +74,30 @@ export function listRecipes(opts: {
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) { conditions.push('r.is_favorite = ?'); params.push(opts.favorite ? 1 : 0); }
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';
if (opts.userId) {
joins += ' LEFT JOIN user_favorites uf ON r.id = uf.recipe_id AND uf.user_id = ?';
params.unshift(opts.userId);
}
const where = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where}`).get(...params) as any;
// Adjust parameter positions based on whether userId is included
const countParams = opts.userId ? [opts.userId, ...params.slice(1)] : params;
const dataParams = opts.userId ? [opts.userId, ...params.slice(1), limit, offset] : [...params, limit, offset];
const countRow = db.prepare(`SELECT COUNT(*) as total FROM recipes r ${joins} ${where}`).get(...countParams) as any;
const rows = db.prepare(
`SELECT r.*, c.name as category_name, c.slug as category_slug FROM recipes r LEFT JOIN categories c ON r.category_id = c.id ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`
).all(...params, limit, offset);
`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);
return { data, total: countRow.total, page, limit, totalPages: Math.ceil(countRow.total / limit) };
@@ -205,13 +220,34 @@ export function deleteRecipe(id: string): boolean {
return result.changes > 0;
}
export function toggleFavorite(id: string) {
export function toggleFavorite(id: string, userId?: string) {
const db = getDb();
const recipe = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(id) as any;
if (!recipe) return null;
const newVal = recipe.is_favorite ? 0 : 1;
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
return { id, is_favorite: newVal };
// If no user authentication, fallback to old is_favorite column
if (!userId) {
const recipeWithFavorite = db.prepare('SELECT is_favorite FROM recipes WHERE id = ?').get(id) as any;
const newVal = recipeWithFavorite.is_favorite ? 0 : 1;
db.prepare('UPDATE recipes SET is_favorite = ? WHERE id = ?').run(newVal, id);
return { id, is_favorite: newVal };
}
// Check if recipe is already favorited by user
const existing = db.prepare(
'SELECT id FROM user_favorites WHERE user_id = ? AND recipe_id = ?'
).get(userId, id) as any;
if (existing) {
// Remove from favorites
db.prepare('DELETE FROM user_favorites WHERE user_id = ? AND recipe_id = ?').run(userId, id);
return { id, is_favorite: false };
} else {
// Add to favorites
const favoriteId = ulid();
db.prepare('INSERT INTO user_favorites (id, user_id, recipe_id) VALUES (?, ?, ?)').run(favoriteId, userId, id);
return { id, is_favorite: true };
}
}
export function getRandomRecipe() {

View File

@@ -1,14 +1,48 @@
import { getDb } from '../db/connection.js';
import { ulid } from 'ulid';
export function listItems() {
export type ShoppingScope = 'personal' | 'household';
export function listItems(userId?: string, scope: ShoppingScope = 'personal') {
const db = getDb();
const items = db.prepare(`
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
ORDER BY si.checked, si.created_at DESC
`).all() as any[];
let query: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication, return all items
query = `
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
WHERE si.user_id IS NULL AND si.household_id IS NULL
ORDER BY si.checked, si.created_at DESC
`;
params = [];
} else if (scope === 'household') {
// Get household shopping list
query = `
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
LEFT JOIN household_members hm ON si.household_id = hm.household_id
WHERE hm.user_id = ? AND si.household_id IS NOT NULL
ORDER BY si.checked, si.created_at DESC
`;
params = [userId];
} else {
// Get personal shopping list
query = `
SELECT si.*, r.title as recipe_title
FROM shopping_items si
LEFT JOIN recipes r ON si.recipe_id = r.id
WHERE si.user_id = ? AND si.household_id IS NULL
ORDER BY si.checked, si.created_at DESC
`;
params = [userId];
}
const items = db.prepare(query).all(...params) as any[];
const grouped: Record<string, any> = {};
for (const item of items) {
@@ -21,44 +55,183 @@ export function listItems() {
return Object.values(grouped);
}
export function addFromRecipe(recipeId: string) {
export function addFromRecipe(recipeId: string, userId?: string, scope: ShoppingScope = 'personal') {
const db = getDb();
const recipe = db.prepare('SELECT id FROM recipes WHERE id = ?').get(recipeId);
if (!recipe) return null;
const ingredients = db.prepare('SELECT * FROM ingredients WHERE recipe_id = ?').all(recipeId) as any[];
const insert = db.prepare('INSERT INTO shopping_items (id, name, amount, unit, recipe_id) VALUES (?, ?, ?, ?, ?)');
let householdId = null;
if (userId && scope === 'household') {
// Get user's household
const membership = db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId) as { household_id: string } | undefined;
if (!membership) {
throw new Error('USER_NOT_IN_HOUSEHOLD');
}
householdId = membership.household_id;
}
const insert = db.prepare(`
INSERT INTO shopping_items (id, name, amount, unit, recipe_id, user_id, household_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
const added: any[] = [];
const txn = db.transaction(() => {
for (const ing of ingredients) {
const id = ulid();
insert.run(id, ing.name, ing.amount, ing.unit, recipeId);
added.push({ id, name: ing.name, amount: ing.amount, unit: ing.unit, recipe_id: recipeId, checked: 0 });
insert.run(id, ing.name, ing.amount, ing.unit, recipeId, userId || null, householdId);
added.push({
id,
name: ing.name,
amount: ing.amount,
unit: ing.unit,
recipe_id: recipeId,
user_id: userId || null,
household_id: householdId,
checked: 0
});
}
});
txn();
return added;
}
export function addItem(name: string, amount?: number, unit?: string) {
export function addItem(name: string, amount?: number, unit?: string, userId?: string, scope: ShoppingScope = 'personal') {
const db = getDb();
let householdId = null;
if (userId && scope === 'household') {
// Get user's household
const membership = db.prepare(`
SELECT household_id FROM household_members WHERE user_id = ?
`).get(userId) as { household_id: string } | undefined;
if (!membership) {
throw new Error('USER_NOT_IN_HOUSEHOLD');
}
householdId = membership.household_id;
}
const id = ulid();
db.prepare('INSERT INTO shopping_items (id, name, amount, unit) VALUES (?, ?, ?, ?)').run(id, name, amount ?? null, unit ?? null);
db.prepare(`
INSERT INTO shopping_items (id, name, amount, unit, user_id, household_id)
VALUES (?, ?, ?, ?, ?, ?)
`).run(id, name, amount ?? null, unit ?? null, userId || null, householdId);
return db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id);
}
export function toggleCheck(id: string) {
export function toggleCheck(id: string, userId?: string) {
const db = getDb();
const item = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(id) as any;
let query: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'SELECT * FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
params = [id];
} else {
// Check if item belongs to user (personal) or their household
query = `
SELECT si.* FROM shopping_items si
LEFT JOIN household_members hm ON si.household_id = hm.household_id
WHERE si.id = ? AND (si.user_id = ? OR hm.user_id = ?)
`;
params = [id, userId, userId];
}
const item = db.prepare(query).get(...params) as any;
if (!item) return null;
const newVal = item.checked ? 0 : 1;
db.prepare('UPDATE shopping_items SET checked = ? WHERE id = ?').run(newVal, id);
return { ...item, checked: newVal };
}
export function deleteItem(id: string): boolean {
return getDb().prepare('DELETE FROM shopping_items WHERE id = ?').run(id).changes > 0;
export function deleteItem(id: string, userId?: string): boolean {
const db = getDb();
let query: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'DELETE FROM shopping_items WHERE id = ? AND user_id IS NULL AND household_id IS NULL';
params = [id];
} else {
// Delete only if item belongs to user (personal) or their household
query = `
DELETE FROM shopping_items
WHERE id = ? AND id IN (
SELECT si.id FROM shopping_items si
LEFT JOIN household_members hm ON si.household_id = hm.household_id
WHERE si.user_id = ? OR hm.user_id = ?
)
`;
params = [id, userId, userId];
}
return db.prepare(query).run(...params).changes > 0;
}
export function deleteChecked(): number {
return getDb().prepare('DELETE FROM shopping_items WHERE checked = 1').run().changes;
export function deleteAll(userId?: string, scope: ShoppingScope = 'personal'): number {
const db = getDb();
let query: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'DELETE FROM shopping_items WHERE user_id IS NULL AND household_id IS NULL';
params = [];
} else if (scope === 'household') {
// Delete all household items
query = `
DELETE FROM shopping_items
WHERE household_id IN (
SELECT household_id FROM household_members WHERE user_id = ?
)
`;
params = [userId];
} else {
// Delete all personal items
query = 'DELETE FROM shopping_items WHERE user_id = ? AND household_id IS NULL';
params = [userId];
}
return db.prepare(query).run(...params).changes;
}
export function deleteChecked(userId?: string, scope: ShoppingScope = 'personal'): number {
const db = getDb();
let query: string;
let params: any[];
if (!userId) {
// Legacy: no user authentication
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id IS NULL AND household_id IS NULL';
params = [];
} else if (scope === 'household') {
// Delete checked household items
query = `
DELETE FROM shopping_items
WHERE checked = 1 AND household_id IN (
SELECT household_id FROM household_members WHERE user_id = ?
)
`;
params = [userId];
} else {
// Delete checked personal items
query = 'DELETE FROM shopping_items WHERE checked = 1 AND user_id = ? AND household_id IS NULL';
params = [userId];
}
return db.prepare(query).run(...params).changes;
}