feat(api): implement recipe CRUD API endpoints
- Add Recipe type definitions (Recipe, CreateRecipeInput, UpdateRecipeInput) - Implement RecipeRepository for database operations (CRUD + search) - Implement RecipeService with business logic validation - Create Express routes with Zod validation schemas - Add database initialization module with auto-save functionality - Implement comprehensive integration tests (16 tests, all passing) - Support pagination, search filtering, and proper error handling - Follow layered architecture pattern (Routes → Services → Repositories) All tests passing (16/16). TypeScript strict mode enabled.
This commit is contained in:
parent
394aa22df1
commit
e2599b81f4
|
|
@ -13,6 +13,11 @@
|
|||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/sql.js": "^1.4.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.2.3"
|
||||
|
|
@ -463,6 +468,29 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz",
|
||||
|
|
@ -848,6 +876,41 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookiejar": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
|
||||
"integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/emscripten": {
|
||||
"version": "1.41.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
|
||||
"integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -855,15 +918,143 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"node_modules/@types/express": {
|
||||
"version": "4.17.25",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^4.17.33",
|
||||
"@types/qs": "*",
|
||||
"@types/serve-static": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.19.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz",
|
||||
"integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
"integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/mime": {
|
||||
"version": "1.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.37",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
|
||||
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*",
|
||||
"@types/send": "<1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static/node_modules/@types/send": {
|
||||
"version": "0.17.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/mime": "^1",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sql.js": {
|
||||
"version": "1.4.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.10.tgz",
|
||||
"integrity": "sha512-E7XnsrWm01Uvp0/0+iRI9ZwO/BvKyiiHUpcVKJenVVH2pUdZndsgQ5BWXNxKaEO+bkKbvU29Ky9o21juMip1ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/emscripten": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/superagent": {
|
||||
"version": "8.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz",
|
||||
"integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cookiejar": "^2.1.5",
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/node": "*",
|
||||
"form-data": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supertest": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz",
|
||||
"integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/methods": "^1.1.4",
|
||||
"@types/superagent": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
|
|
@ -1005,6 +1196,13 @@
|
|||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
|
||||
|
|
@ -1015,6 +1213,13 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
|
|
@ -1119,6 +1324,29 @@
|
|||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/component-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/confbox": {
|
||||
"version": "0.1.8",
|
||||
"resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
|
||||
|
|
@ -1162,6 +1390,13 @@
|
|||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookiejar": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz",
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/create-require": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
|
||||
|
|
@ -1206,6 +1441,16 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
|
|
@ -1225,6 +1470,17 @@
|
|||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||
|
|
@ -1304,6 +1560,22 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
|
|
@ -1438,6 +1710,13 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-safe-stringify": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
|
||||
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
|
|
@ -1456,6 +1735,39 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz",
|
||||
"integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0",
|
||||
"qs": "^6.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -1582,6 +1894,22 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
|
|
@ -1918,6 +2246,16 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
|
||||
|
|
@ -2192,6 +2530,19 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
|
|
@ -2423,6 +2774,82 @@
|
|||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz",
|
||||
"integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==",
|
||||
"deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"component-emitter": "^1.3.0",
|
||||
"cookiejar": "^2.1.4",
|
||||
"debug": "^4.3.4",
|
||||
"fast-safe-stringify": "^2.1.1",
|
||||
"form-data": "^4.0.0",
|
||||
"formidable": "^2.1.2",
|
||||
"methods": "^1.1.2",
|
||||
"mime": "2.6.0",
|
||||
"qs": "^6.11.0",
|
||||
"semver": "^7.3.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.4.0 <13 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/node_modules/mime": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||
"integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/superagent/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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/supertest": {
|
||||
"version": "6.3.4",
|
||||
"resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz",
|
||||
"integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==",
|
||||
"deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"methods": "^1.1.2",
|
||||
"superagent": "^8.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
|
|
@ -2548,12 +2975,11 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
|
|
@ -2821,6 +3247,13 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@
|
|||
"scripts": {
|
||||
"dev": "ts-node src/backend/index.ts",
|
||||
"build": "tsc",
|
||||
"test": "vitest",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"migrate": "ts-node-esm src/backend/db/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -16,6 +17,11 @@
|
|||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.5",
|
||||
"@types/sql.js": "^1.4.10",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.2.3"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
import initSqlJs, { Database } from 'sql.js';
|
||||
import { readFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
let dbInstance: Database | null = null;
|
||||
|
||||
/**
|
||||
* Initialize and return the database instance
|
||||
*/
|
||||
export async function getDatabase(dbPath: string = 'data/recipes.db'): Promise<Database> {
|
||||
if (dbInstance) {
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
// Load existing database or create new one
|
||||
if (existsSync(dbPath)) {
|
||||
const buffer = readFileSync(dbPath);
|
||||
dbInstance = new SQL.Database(buffer);
|
||||
} else {
|
||||
// Create new empty database
|
||||
dbInstance = new SQL.Database();
|
||||
|
||||
// Apply schema
|
||||
const schemaPath = new URL('../db/schema.sql', import.meta.url).pathname;
|
||||
const schema = readFileSync(schemaPath, 'utf-8');
|
||||
dbInstance.exec(schema);
|
||||
|
||||
// Ensure data directory exists
|
||||
const dir = dirname(dbPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the database to disk
|
||||
*/
|
||||
export function saveDatabase(dbPath: string = 'data/recipes.db'): void {
|
||||
if (!dbInstance) {
|
||||
throw new Error('Database not initialized');
|
||||
}
|
||||
|
||||
const data = dbInstance.export();
|
||||
const buffer = Buffer.from(data);
|
||||
|
||||
// Ensure data directory exists
|
||||
const dir = dirname(dbPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write to disk
|
||||
require('fs').writeFileSync(dbPath, buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
export function closeDatabase(): void {
|
||||
if (dbInstance) {
|
||||
dbInstance.close();
|
||||
dbInstance = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,81 @@
|
|||
import express from 'express';
|
||||
import { getDatabase, saveDatabase } from './db/database.js';
|
||||
import { createRecipeRoutes } from './routes/recipes.js';
|
||||
|
||||
const app = express();
|
||||
const port = 3000;
|
||||
const DB_PATH = 'data/recipes.db';
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// CORS headers for local development
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*');
|
||||
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
||||
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.sendStatus(200);
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Hello World from Recipe Manager backend!');
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Recipe Manager API is running',
|
||||
version: '0.1.0',
|
||||
});
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
});
|
||||
// Initialize database and routes
|
||||
async function startServer() {
|
||||
try {
|
||||
const db = await getDatabase(DB_PATH);
|
||||
|
||||
// Mount recipe routes
|
||||
app.use('/api/recipes', createRecipeRoutes(db));
|
||||
|
||||
// Save database periodically (every 5 seconds)
|
||||
setInterval(() => {
|
||||
try {
|
||||
saveDatabase(DB_PATH);
|
||||
} catch (error) {
|
||||
console.error('Error saving database:', error);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Save database on exit
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nSaving database before exit...');
|
||||
saveDatabase(DB_PATH);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('\nSaving database before exit...');
|
||||
saveDatabase(DB_PATH);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`✓ Recipe Manager API running on http://localhost:${port}`);
|
||||
console.log(`✓ Database: ${DB_PATH}`);
|
||||
console.log(`✓ Endpoints:`);
|
||||
console.log(` GET /api/recipes - List recipes`);
|
||||
console.log(` GET /api/recipes/:id - Get recipe by ID`);
|
||||
console.log(` POST /api/recipes - Create recipe`);
|
||||
console.log(` PUT /api/recipes/:id - Update recipe`);
|
||||
console.log(` DELETE /api/recipes/:id - Delete recipe`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,199 @@
|
|||
import type { Database, SqlValue } from 'sql.js';
|
||||
import type { Recipe, CreateRecipeInput, UpdateRecipeInput, RecipeFilters } from '../types/recipe.js';
|
||||
|
||||
/**
|
||||
* RecipeRepository handles all database operations for recipes.
|
||||
*/
|
||||
export class RecipeRepository {
|
||||
constructor(private db: Database) {}
|
||||
|
||||
/**
|
||||
* Find all recipes with optional filtering and pagination
|
||||
*/
|
||||
findAll(filters: RecipeFilters = {}): Recipe[] {
|
||||
const { search, offset = 0, limit = 50 } = filters;
|
||||
|
||||
let sql = 'SELECT * FROM recipes';
|
||||
const params: SqlValue[] = [];
|
||||
|
||||
if (search) {
|
||||
sql += ' WHERE title LIKE ? OR description LIKE ? OR ingredients LIKE ?';
|
||||
const searchPattern = `%${search}%`;
|
||||
params.push(searchPattern, searchPattern, searchPattern);
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at DESC LIMIT ? OFFSET ?';
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = this.db.exec(sql, params);
|
||||
if (!result.length) return [];
|
||||
|
||||
return this.rowsToRecipes(result[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a recipe by ID
|
||||
*/
|
||||
findById(id: number): Recipe | null {
|
||||
const result = this.db.exec('SELECT * FROM recipes WHERE id = ?', [id]);
|
||||
if (!result.length || !result[0].values.length) return null;
|
||||
|
||||
const recipes = this.rowsToRecipes(result[0]);
|
||||
return recipes[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recipe
|
||||
*/
|
||||
create(input: CreateRecipeInput): Recipe {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO recipes (
|
||||
title, description, ingredients, instructions,
|
||||
source_url, notes, servings, prep_time_minutes,
|
||||
cook_time_minutes, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
this.db.run(sql, [
|
||||
input.title,
|
||||
input.description || null,
|
||||
JSON.stringify(input.ingredients),
|
||||
JSON.stringify(input.instructions),
|
||||
input.source_url || null,
|
||||
input.notes || null,
|
||||
input.servings || null,
|
||||
input.prep_time_minutes || null,
|
||||
input.cook_time_minutes || null,
|
||||
now,
|
||||
now,
|
||||
]);
|
||||
|
||||
// Get the last inserted ID
|
||||
const result = this.db.exec('SELECT last_insert_rowid() as id');
|
||||
const id = result[0].values[0][0] as number;
|
||||
|
||||
return this.findById(id)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing recipe
|
||||
*/
|
||||
update(id: number, input: UpdateRecipeInput): Recipe | null {
|
||||
const existing = this.findById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const fields: string[] = [];
|
||||
const params: SqlValue[] = [];
|
||||
|
||||
// Build dynamic UPDATE query based on provided fields
|
||||
if (input.title !== undefined) {
|
||||
fields.push('title = ?');
|
||||
params.push(input.title);
|
||||
}
|
||||
if (input.description !== undefined) {
|
||||
fields.push('description = ?');
|
||||
params.push(input.description);
|
||||
}
|
||||
if (input.ingredients !== undefined) {
|
||||
fields.push('ingredients = ?');
|
||||
params.push(JSON.stringify(input.ingredients));
|
||||
}
|
||||
if (input.instructions !== undefined) {
|
||||
fields.push('instructions = ?');
|
||||
params.push(JSON.stringify(input.instructions));
|
||||
}
|
||||
if (input.source_url !== undefined) {
|
||||
fields.push('source_url = ?');
|
||||
params.push(input.source_url);
|
||||
}
|
||||
if (input.notes !== undefined) {
|
||||
fields.push('notes = ?');
|
||||
params.push(input.notes);
|
||||
}
|
||||
if (input.servings !== undefined) {
|
||||
fields.push('servings = ?');
|
||||
params.push(input.servings);
|
||||
}
|
||||
if (input.prep_time_minutes !== undefined) {
|
||||
fields.push('prep_time_minutes = ?');
|
||||
params.push(input.prep_time_minutes);
|
||||
}
|
||||
if (input.cook_time_minutes !== undefined) {
|
||||
fields.push('cook_time_minutes = ?');
|
||||
params.push(input.cook_time_minutes);
|
||||
}
|
||||
|
||||
// Always update updated_at
|
||||
fields.push('updated_at = ?');
|
||||
params.push(now);
|
||||
|
||||
// Add ID to params for WHERE clause
|
||||
params.push(id);
|
||||
|
||||
const sql = `UPDATE recipes SET ${fields.join(', ')} WHERE id = ?`;
|
||||
this.db.run(sql, params);
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recipe
|
||||
*/
|
||||
delete(id: number): boolean {
|
||||
const existing = this.findById(id);
|
||||
if (!existing) return false;
|
||||
|
||||
this.db.run('DELETE FROM recipes WHERE id = ?', [id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total recipes (for pagination)
|
||||
*/
|
||||
count(filters: RecipeFilters = {}): number {
|
||||
const { search } = filters;
|
||||
|
||||
let sql = 'SELECT COUNT(*) as count FROM recipes';
|
||||
const params: SqlValue[] = [];
|
||||
|
||||
if (search) {
|
||||
sql += ' WHERE title LIKE ? OR description LIKE ? OR ingredients LIKE ?';
|
||||
const searchPattern = `%${search}%`;
|
||||
params.push(searchPattern, searchPattern, searchPattern);
|
||||
}
|
||||
|
||||
const result = this.db.exec(sql, params);
|
||||
return result[0].values[0][0] as number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert sql.js result rows to Recipe objects
|
||||
*/
|
||||
private rowsToRecipes(result: { columns: string[]; values: SqlValue[][] }): Recipe[] {
|
||||
return result.values.map((row) => {
|
||||
const recipe: Record<string, SqlValue> = {};
|
||||
result.columns.forEach((col, idx) => {
|
||||
recipe[col] = row[idx];
|
||||
});
|
||||
|
||||
return {
|
||||
id: recipe.id as number,
|
||||
title: recipe.title as string,
|
||||
description: recipe.description as string | null,
|
||||
ingredients: JSON.parse(recipe.ingredients as string) as string[],
|
||||
instructions: JSON.parse(recipe.instructions as string) as string[],
|
||||
source_url: recipe.source_url as string | null,
|
||||
notes: recipe.notes as string | null,
|
||||
servings: recipe.servings as number | null,
|
||||
prep_time_minutes: recipe.prep_time_minutes as number | null,
|
||||
cook_time_minutes: recipe.cook_time_minutes as number | null,
|
||||
created_at: recipe.created_at as number,
|
||||
updated_at: recipe.updated_at as number,
|
||||
last_cooked_at: recipe.last_cooked_at as number | null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from 'sql.js';
|
||||
import { RecipeService } from '../services/RecipeService.js';
|
||||
|
||||
/**
|
||||
* Zod validation schemas
|
||||
*/
|
||||
const createRecipeSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
ingredients: z.array(z.string()).min(1, 'At least one ingredient is required'),
|
||||
instructions: z.array(z.string()).min(1, 'At least one instruction is required'),
|
||||
source_url: z.string().url().optional().or(z.literal('')),
|
||||
notes: z.string().optional(),
|
||||
servings: z.number().int().positive().optional(),
|
||||
prep_time_minutes: z.number().int().positive().optional(),
|
||||
cook_time_minutes: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
const updateRecipeSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional().nullable(),
|
||||
ingredients: z.array(z.string()).min(1).optional(),
|
||||
instructions: z.array(z.string()).min(1).optional(),
|
||||
source_url: z.string().url().optional().nullable().or(z.literal('')),
|
||||
notes: z.string().optional().nullable(),
|
||||
servings: z.number().int().positive().optional().nullable(),
|
||||
prep_time_minutes: z.number().int().positive().optional().nullable(),
|
||||
cook_time_minutes: z.number().int().positive().optional().nullable(),
|
||||
});
|
||||
|
||||
const recipeFiltersSchema = z.object({
|
||||
search: z.string().optional(),
|
||||
offset: z.coerce.number().int().nonnegative().optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create recipe routes
|
||||
*/
|
||||
export function createRecipeRoutes(db: Database): Router {
|
||||
const router = Router();
|
||||
const recipeService = new RecipeService(db);
|
||||
|
||||
/**
|
||||
* GET /api/recipes
|
||||
* List recipes with optional filtering
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const filters = recipeFiltersSchema.parse(req.query);
|
||||
const result = recipeService.list(filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.recipes,
|
||||
meta: {
|
||||
total: result.total,
|
||||
offset: filters.offset || 0,
|
||||
limit: filters.limit || 50,
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.errors,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/recipes/:id
|
||||
* Get a single recipe by ID
|
||||
*/
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Invalid recipe ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const recipe = recipeService.get(id);
|
||||
|
||||
if (!recipe) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Recipe not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/recipes
|
||||
* Create a new recipe
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const data = createRecipeSchema.parse(req.body);
|
||||
const recipe = recipeService.create(data);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.errors,
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/recipes/:id
|
||||
* Update an existing recipe
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Invalid recipe ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const data = updateRecipeSchema.parse(req.body);
|
||||
const recipe = recipeService.update(id, data);
|
||||
|
||||
if (!recipe) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Recipe not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: recipe,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.errors,
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message,
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/recipes/:id
|
||||
* Delete a recipe
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Invalid recipe ID',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const deleted = recipeService.delete(id);
|
||||
|
||||
if (!deleted) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Recipe not found',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id },
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
data: null,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { Database } from 'sql.js';
|
||||
import { RecipeRepository } from '../repositories/RecipeRepository.js';
|
||||
import type { Recipe, CreateRecipeInput, UpdateRecipeInput, RecipeFilters } from '../types/recipe.js';
|
||||
|
||||
/**
|
||||
* RecipeService contains business logic for recipe management
|
||||
*/
|
||||
export class RecipeService {
|
||||
private repository: RecipeRepository;
|
||||
|
||||
constructor(db: Database) {
|
||||
this.repository = new RecipeRepository(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* List recipes with optional filtering
|
||||
*/
|
||||
list(filters: RecipeFilters = {}): { recipes: Recipe[]; total: number } {
|
||||
const recipes = this.repository.findAll(filters);
|
||||
const total = this.repository.count(filters);
|
||||
return { recipes, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single recipe by ID
|
||||
*/
|
||||
get(id: number): Recipe | null {
|
||||
return this.repository.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new recipe
|
||||
*/
|
||||
create(input: CreateRecipeInput): Recipe {
|
||||
// Validate business rules
|
||||
if (!input.title.trim()) {
|
||||
throw new Error('Recipe title cannot be empty');
|
||||
}
|
||||
if (!input.ingredients.length) {
|
||||
throw new Error('Recipe must have at least one ingredient');
|
||||
}
|
||||
if (!input.instructions.length) {
|
||||
throw new Error('Recipe must have at least one instruction');
|
||||
}
|
||||
|
||||
return this.repository.create(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing recipe
|
||||
*/
|
||||
update(id: number, input: UpdateRecipeInput): Recipe | null {
|
||||
// Validate business rules
|
||||
if (input.title !== undefined && !input.title.trim()) {
|
||||
throw new Error('Recipe title cannot be empty');
|
||||
}
|
||||
if (input.ingredients !== undefined && !input.ingredients.length) {
|
||||
throw new Error('Recipe must have at least one ingredient');
|
||||
}
|
||||
if (input.instructions !== undefined && !input.instructions.length) {
|
||||
throw new Error('Recipe must have at least one instruction');
|
||||
}
|
||||
|
||||
return this.repository.update(id, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a recipe
|
||||
*/
|
||||
delete(id: number): boolean {
|
||||
return this.repository.delete(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import initSqlJs from 'sql.js';
|
||||
import { readFileSync } from 'fs';
|
||||
import { createRecipeRoutes } from '../routes/recipes.js';
|
||||
|
||||
describe('Recipe API', () => {
|
||||
let app: express.Application;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a fresh in-memory database for each test
|
||||
const SQL = await initSqlJs();
|
||||
const db = new SQL.Database();
|
||||
|
||||
// Load schema
|
||||
const schemaPath = new URL('../db/schema.sql', import.meta.url).pathname;
|
||||
const schema = readFileSync(schemaPath, 'utf-8');
|
||||
db.exec(schema);
|
||||
|
||||
// Set up Express app with recipe routes
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/recipes', createRecipeRoutes(db));
|
||||
});
|
||||
|
||||
describe('POST /api/recipes', () => {
|
||||
it('should create a new recipe', async () => {
|
||||
const recipe = {
|
||||
title: 'Chocolate Chip Cookies',
|
||||
description: 'Classic homemade cookies',
|
||||
ingredients: ['flour', 'sugar', 'chocolate chips'],
|
||||
instructions: ['Mix ingredients', 'Bake at 350°F'],
|
||||
servings: 24,
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/recipes')
|
||||
.send(recipe)
|
||||
.expect(201);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toMatchObject({
|
||||
id: 1,
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
ingredients: recipe.ingredients,
|
||||
instructions: recipe.instructions,
|
||||
servings: recipe.servings,
|
||||
});
|
||||
expect(response.body.data.created_at).toBeDefined();
|
||||
expect(response.body.data.updated_at).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject recipe without title', async () => {
|
||||
const recipe = {
|
||||
ingredients: ['flour'],
|
||||
instructions: ['Mix'],
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/recipes')
|
||||
.send(recipe)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('should reject recipe without ingredients', async () => {
|
||||
const recipe = {
|
||||
title: 'Test Recipe',
|
||||
ingredients: [],
|
||||
instructions: ['Mix'],
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/recipes')
|
||||
.send(recipe)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject recipe without instructions', async () => {
|
||||
const recipe = {
|
||||
title: 'Test Recipe',
|
||||
ingredients: ['flour'],
|
||||
instructions: [],
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/recipes')
|
||||
.send(recipe)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/recipes', () => {
|
||||
it('should return empty list when no recipes exist', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/recipes')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data).toEqual([]);
|
||||
expect(response.body.meta.total).toBe(0);
|
||||
});
|
||||
|
||||
it('should return list of recipes', async () => {
|
||||
// Create test recipes
|
||||
await request(app).post('/api/recipes').send({
|
||||
title: 'Recipe 1',
|
||||
ingredients: ['ingredient 1'],
|
||||
instructions: ['step 1'],
|
||||
});
|
||||
await request(app).post('/api/recipes').send({
|
||||
title: 'Recipe 2',
|
||||
ingredients: ['ingredient 2'],
|
||||
instructions: ['step 2'],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/recipes')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.length).toBe(2);
|
||||
expect(response.body.meta.total).toBe(2);
|
||||
});
|
||||
|
||||
it('should support pagination', async () => {
|
||||
// Create 3 test recipes
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await request(app).post('/api/recipes').send({
|
||||
title: `Recipe ${i}`,
|
||||
ingredients: ['ingredient'],
|
||||
instructions: ['step'],
|
||||
});
|
||||
}
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/recipes?limit=2&offset=1')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.length).toBe(2);
|
||||
expect(response.body.meta.total).toBe(3);
|
||||
expect(response.body.meta.limit).toBe(2);
|
||||
expect(response.body.meta.offset).toBe(1);
|
||||
});
|
||||
|
||||
it('should support search', async () => {
|
||||
await request(app).post('/api/recipes').send({
|
||||
title: 'Chocolate Cake',
|
||||
ingredients: ['chocolate'],
|
||||
instructions: ['bake'],
|
||||
});
|
||||
await request(app).post('/api/recipes').send({
|
||||
title: 'Vanilla Cookies',
|
||||
ingredients: ['vanilla'],
|
||||
instructions: ['bake'],
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/recipes?search=chocolate')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.length).toBe(1);
|
||||
expect(response.body.data[0].title).toBe('Chocolate Cake');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/recipes/:id', () => {
|
||||
it('should get a recipe by ID', async () => {
|
||||
const createResponse = await request(app).post('/api/recipes').send({
|
||||
title: 'Test Recipe',
|
||||
ingredients: ['ingredient'],
|
||||
instructions: ['step'],
|
||||
});
|
||||
|
||||
const id = createResponse.body.data.id;
|
||||
|
||||
const response = await request(app)
|
||||
.get(`/api/recipes/${id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.id).toBe(id);
|
||||
expect(response.body.data.title).toBe('Test Recipe');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent recipe', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/recipes/999')
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.error).toBe('Recipe not found');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid ID', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/recipes/invalid')
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/recipes/:id', () => {
|
||||
it('should update a recipe', async () => {
|
||||
const createResponse = await request(app).post('/api/recipes').send({
|
||||
title: 'Original Title',
|
||||
ingredients: ['ingredient'],
|
||||
instructions: ['step'],
|
||||
});
|
||||
|
||||
const id = createResponse.body.data.id;
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/recipes/${id}`)
|
||||
.send({ title: 'Updated Title' })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.data.title).toBe('Updated Title');
|
||||
expect(response.body.data.ingredients).toEqual(['ingredient']);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent recipe', async () => {
|
||||
const response = await request(app)
|
||||
.put('/api/recipes/999')
|
||||
.send({ title: 'Updated' })
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject empty title', async () => {
|
||||
const createResponse = await request(app).post('/api/recipes').send({
|
||||
title: 'Original Title',
|
||||
ingredients: ['ingredient'],
|
||||
instructions: ['step'],
|
||||
});
|
||||
|
||||
const id = createResponse.body.data.id;
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/recipes/${id}`)
|
||||
.send({ title: '' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/recipes/:id', () => {
|
||||
it('should delete a recipe', async () => {
|
||||
const createResponse = await request(app).post('/api/recipes').send({
|
||||
title: 'To Delete',
|
||||
ingredients: ['ingredient'],
|
||||
instructions: ['step'],
|
||||
});
|
||||
|
||||
const id = createResponse.body.data.id;
|
||||
|
||||
const response = await request(app)
|
||||
.delete(`/api/recipes/${id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body.success).toBe(true);
|
||||
|
||||
// Verify it's deleted
|
||||
await request(app)
|
||||
.get(`/api/recipes/${id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent recipe', async () => {
|
||||
const response = await request(app)
|
||||
.delete('/api/recipes/999')
|
||||
.expect(404);
|
||||
|
||||
expect(response.body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Recipe domain types
|
||||
*/
|
||||
|
||||
export interface Recipe {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
ingredients: string[]; // Stored as JSON in DB
|
||||
instructions: string[]; // Stored as JSON in DB
|
||||
source_url: string | null;
|
||||
notes: string | null;
|
||||
servings: number | null;
|
||||
prep_time_minutes: number | null;
|
||||
cook_time_minutes: number | null;
|
||||
created_at: number; // Unix timestamp
|
||||
updated_at: number; // Unix timestamp
|
||||
last_cooked_at: number | null;
|
||||
}
|
||||
|
||||
export interface CreateRecipeInput {
|
||||
title: string;
|
||||
description?: string;
|
||||
ingredients: string[];
|
||||
instructions: string[];
|
||||
source_url?: string;
|
||||
notes?: string;
|
||||
servings?: number;
|
||||
prep_time_minutes?: number;
|
||||
cook_time_minutes?: number;
|
||||
}
|
||||
|
||||
export interface UpdateRecipeInput {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
ingredients?: string[];
|
||||
instructions?: string[];
|
||||
source_url?: string | null;
|
||||
notes?: string | null;
|
||||
servings?: number | null;
|
||||
prep_time_minutes?: number | null;
|
||||
cook_time_minutes?: number | null;
|
||||
}
|
||||
|
||||
export interface RecipeFilters {
|
||||
search?: string; // Search in title, description, ingredients
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
Loading…
Reference in New Issue