diff --git a/backend/package-lock.json b/backend/package-lock.json index 0fe6bb5..1c477bc 100755 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,8 @@ "cookie-parser": "^1.4.6", "express": "^4.19.2", "multer": "^1.4.5-lts.1", + "node-cron": "^3.0.3", + "node-pg-migrate": "^7.6.1", "nodemailer": "^6.9.14", "pg": "^8.12.0" }, @@ -22,6 +24,7 @@ "@types/jest": "^29.5.12", "@types/multer": "^1.4.11", "@types/node": "^20.14.15", + "@types/node-cron": "^3.0.11", "@types/nodemailer": "^6.4.15", "@types/pg": "^8.11.6", "@types/supertest": "^6.0.2", @@ -1020,6 +1023,14 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "engines": { + "node": ">=18" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1638,12 +1649,18 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, "node_modules/@types/nodemailer": { "version": "6.4.24", "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.24.tgz", @@ -1658,7 +1675,7 @@ "version": "8.21.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.21.0.tgz", "integrity": "sha512-AYdtudzabjLZgVgRZmAnU8bAnVUXzuJX2IYHeSIiIHm68olD+LgQYCGWdtcNYnP0uq9c4S4NibVG3Ni7VbKW7Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -1794,7 +1811,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -1804,7 +1820,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2263,7 +2278,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -2296,7 +2310,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2309,7 +2322,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -2452,7 +2464,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2601,7 +2612,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/encodeurl": { @@ -2715,7 +2725,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2925,6 +2934,32 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -3023,7 +3058,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -3335,7 +3369,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3384,7 +3417,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3496,6 +3528,20 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", @@ -4371,6 +4417,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -4431,6 +4485,17 @@ "dev": true, "license": "MIT" }, + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", + "dependencies": { + "uuid": "8.3.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -4438,6 +4503,88 @@ "dev": true, "license": "MIT" }, + "node_modules/node-pg-migrate": { + "version": "7.9.1", + "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-7.9.1.tgz", + "integrity": "sha512-6z4OSN27ye8aYdX9ZU7NN2PTI5pOp34hTr+22Ej12djIYECq++gT7LPLZVOQXEeVCBOZQLqf87kC3Y36G434OQ==", + "dependencies": { + "glob": "~11.0.0", + "yargs": "~17.7.0" + }, + "bin": { + "node-pg-migrate": "bin/node-pg-migrate.js", + "node-pg-migrate-cjs": "bin/node-pg-migrate.js", + "node-pg-migrate-esm": "bin/node-pg-migrate.mjs" + }, + "engines": { + "node": ">=18.19.0" + }, + "peerDependencies": { + "@types/pg": ">=6.0.0 <9.0.0", + "pg": ">=4.3.0 <9.0.0" + }, + "peerDependenciesMeta": { + "@types/pg": { + "optional": true + } + } + }, + "node_modules/node-pg-migrate/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/node-pg-migrate/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/node-pg-migrate/node_modules/glob": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", + "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.0.3", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/node-pg-migrate/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -4594,6 +4741,11 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -4646,7 +4798,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4659,6 +4810,29 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-to-regexp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", @@ -4986,7 +5160,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5138,7 +5311,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5151,7 +5323,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5353,7 +5524,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -5368,7 +5538,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -5736,7 +5905,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -5794,6 +5963,15 @@ "node": ">= 0.4.0" } }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5832,7 +6010,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5855,7 +6032,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -5903,7 +6079,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -5920,7 +6095,6 @@ "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -5939,7 +6113,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/backend/src/routes/cartCheckout.ts b/backend/src/routes/cartCheckout.ts index a276116..09d96dc 100644 --- a/backend/src/routes/cartCheckout.ts +++ b/backend/src/routes/cartCheckout.ts @@ -22,9 +22,21 @@ async function getAccessToken(): Promise { return data.access_token; } +interface CartItem { + id: number; + name: string; + price_cents: number; +} + +interface LockedCart { + cartId: number; + items: CartItem[]; + totalCents: number; +} + // Locks the customer's cart, verifies every item is still reserved to them, // and returns { cartId, items: [{id, name, price_cents}], totalCents }. -async function loadLockedCart(client: any, customerId: number) { +async function loadLockedCart(client: any, customerId: number): Promise { const { rows: cartRows } = await client.query(`SELECT id FROM carts WHERE customer_id = $1`, [customerId]); if (!cartRows.length) return null; const cartId = cartRows[0].id; @@ -37,10 +49,49 @@ async function loadLockedCart(client: any, customerId: number) { [cartId] ); if (!items.length) return { cartId, items: [], totalCents: 0 }; - const totalCents = items.reduce((sum: number, it: any) => sum + it.price_cents, 0); + const totalCents = items.reduce((sum: number, it: CartItem) => sum + it.price_cents, 0); return { cartId, items, totalCents }; } +type OpenedCheckout = + | { ok: true; checkoutId: number; cart: LockedCart } + | { ok: false; error: string }; + +// Both checkout flows open the same way: confirm the shipping address belongs +// to the caller, lock the cart, and record a pending checkout with its line +// items. The caller owns the transaction — on `ok: false` it should roll back +// and return the error as a 400. +async function openCheckout( + client: any, + customerId: number, + shippingAddressId: number, + processor: string, + processorOrderId: string | null +): Promise { + const { rows: addrRows } = await client.query( + `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, + [shippingAddressId, customerId] + ); + if (!addrRows.length) return { ok: false, error: 'invalid shipping address' }; + + const cart = await loadLockedCart(client, customerId); + if (!cart || !cart.items.length) return { ok: false, error: 'cart is empty' }; + + const { rows: checkoutRows } = await client.query( + `INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status) + VALUES ($1, $2, $3, $4, $5, 'pending') RETURNING id`, + [customerId, shippingAddressId, processor, processorOrderId, cart.totalCents] + ); + const checkoutId = checkoutRows[0].id; + for (const it of cart.items) { + await client.query( + `INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`, + [checkoutId, it.id, it.price_cents] + ); + } + return { ok: true, checkoutId, cart }; +} + router.post('/paypal/create', requireCustomer, async (req: Request, res: Response) => { const { shippingAddressId } = req.body; if (!shippingAddressId) return res.status(400).json({ error: 'shippingAddressId is required' }); @@ -48,27 +99,9 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons const client = await pool.connect(); try { await client.query('BEGIN'); - const { rows: addrRows } = await client.query( - `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, - [shippingAddressId, req.customerId] - ); - if (!addrRows.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'invalid shipping address' }); } - - const cart = await loadLockedCart(client, req.customerId as number); - if (!cart || !cart.items.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'cart is empty' }); } - - const { rows: checkoutRows } = await client.query( - `INSERT INTO checkouts (customer_id, shipping_address_id, processor, amount_cents, status) - VALUES ($1, $2, 'paypal', $3, 'pending') RETURNING id`, - [req.customerId, shippingAddressId, cart.totalCents] - ); - const checkoutId = checkoutRows[0].id; - for (const it of cart.items) { - await client.query( - `INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`, - [checkoutId, it.id, it.price_cents] - ); - } + const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'paypal', null); + if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); } + const { checkoutId, cart } = opened; const token = await getAccessToken(); const currency = process.env.SITE_CURRENCY || 'USD'; @@ -84,7 +117,7 @@ router.post('/paypal/create', requireCustomer, async (req: Request, res: Respons value: (cart.totalCents / 100).toFixed(2), breakdown: { item_total: { currency_code: currency, value: (cart.totalCents / 100).toFixed(2) } } }, - items: cart.items.map((it: any) => ({ + items: cart.items.map((it: CartItem) => ({ name: it.name.slice(0, 127), quantity: '1', unit_amount: { currency_code: currency, value: (it.price_cents / 100).toFixed(2) } @@ -168,26 +201,10 @@ router.post('/demo/purchase', requireCustomer, async (req: Request, res: Respons const client = await pool.connect(); try { await client.query('BEGIN'); - const { rows: addrRows } = await client.query( - `SELECT id FROM shipping_addresses WHERE id = $1 AND customer_id = $2`, - [shippingAddressId, req.customerId] - ); - if (!addrRows.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'invalid shipping address' }); } + const opened = await openCheckout(client, req.customerId as number, shippingAddressId, 'demo', `demo-${Date.now()}`); + if (!opened.ok) { await client.query('ROLLBACK'); return res.status(400).json({ error: opened.error }); } - const cart = await loadLockedCart(client, req.customerId as number); - if (!cart || !cart.items.length) { await client.query('ROLLBACK'); return res.status(400).json({ error: 'cart is empty' }); } - - const { rows: checkoutRows } = await client.query( - `INSERT INTO checkouts (customer_id, shipping_address_id, processor, processor_order_id, amount_cents, status) - VALUES ($1, $2, 'demo', $3, $4, 'pending') RETURNING id`, - [req.customerId, shippingAddressId, `demo-${Date.now()}`, cart.totalCents] - ); - const checkoutId = checkoutRows[0].id; - for (const it of cart.items) { - await client.query(`INSERT INTO checkout_items (checkout_id, item_id, price_cents) VALUES ($1, $2, $3)`, [checkoutId, it.id, it.price_cents]); - } - - await completeCheckout(client, checkoutId, 'demo', null, { demo: true }); + await completeCheckout(client, opened.checkoutId, 'demo', null, { demo: true }); await client.query('COMMIT'); res.json({ status: 'completed' }); } catch (err) { diff --git a/backend/src/routes/demo.ts b/backend/src/routes/demo.ts deleted file mode 100755 index c4b93ea..0000000 --- a/backend/src/routes/demo.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Router, Request, Response } from 'express'; -import { pool } from '../db'; - -const router = Router(); - -router.post('/:id/purchase', async (req: Request, res: Response) => { - if (process.env.DEMO_MODE === 'false') { - return res.status(403).json({ error: 'demo mode disabled' }); - } - const itemId = req.params.id; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); - const item = rows[0]; - if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } - if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); } - - const { rows: updated } = await client.query( - `UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 RETURNING *`, - [itemId] - ); - await client.query( - `INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event) - VALUES ($1, $2, 'demo', $3, $4, 'completed', $5)`, - [itemId, req.customerId || null, `demo-${Date.now()}`, item.price_cents, JSON.stringify({ demo: true })] - ); - await client.query('COMMIT'); - res.json({ status: 'sold', item: updated[0] }); - } catch (err) { - await client.query('ROLLBACK'); - console.error(err); - res.status(500).json({ error: 'internal error' }); - } finally { - client.release(); - } -}); - -export default router; diff --git a/backend/src/routes/paypal.ts b/backend/src/routes/paypal.ts deleted file mode 100755 index d9c7852..0000000 --- a/backend/src/routes/paypal.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { Router, Request, Response } from 'express'; -import { pool } from '../db'; - -const router = Router(); -const webhookRouter = Router(); - -const PAYPAL_BASE = - process.env.PAYPAL_ENV === 'live' - ? 'https://api-m.paypal.com' - : 'https://api-m.sandbox.paypal.com'; - -const RESERVATION_MINUTES = parseInt(process.env.RESERVATION_MINUTES || '15', 10); - -async function getAccessToken(): Promise { - const auth = Buffer.from( - `${process.env.PAYPAL_CLIENT_ID}:${process.env.PAYPAL_CLIENT_SECRET}` - ).toString('base64'); - const resp = await fetch(`${PAYPAL_BASE}/v1/oauth2/token`, { - method: 'POST', - headers: { - Authorization: `Basic ${auth}`, - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: 'grant_type=client_credentials' - }); - const data = await resp.json(); - if (!resp.ok) throw new Error('paypal auth failed: ' + JSON.stringify(data)); - return data.access_token; -} - -router.post('/:id/create', async (req: Request, res: Response) => { - const itemId = req.params.id; - const client = await pool.connect(); - try { - await client.query('BEGIN'); - const { rows } = await client.query(`SELECT * FROM items WHERE id = $1 FOR UPDATE`, [itemId]); - const item = rows[0]; - if (!item) { await client.query('ROLLBACK'); return res.status(404).json({ error: 'not found' }); } - if (item.status === 'sold') { await client.query('ROLLBACK'); return res.status(409).json({ error: 'already sold' }); } - if (item.status === 'reserved' && new Date(item.reserved_until) > new Date()) { - await client.query('ROLLBACK'); - return res.status(409).json({ error: 'currently reserved by another checkout' }); - } - - const token = await getAccessToken(); - const amount = (item.price_cents / 100).toFixed(2); - const orderResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - intent: 'CAPTURE', - purchase_units: [{ - custom_id: String(item.id), - description: item.name, - amount: { currency_code: process.env.SITE_CURRENCY || 'USD', value: amount } - }] - }) - }); - const order = await orderResp.json(); - if (!orderResp.ok) { await client.query('ROLLBACK'); return res.status(502).json({ error: 'paypal order create failed', detail: order }); } - - const reservedUntil = new Date(Date.now() + RESERVATION_MINUTES * 60 * 1000); - await client.query( - `UPDATE items SET status = 'reserved', reserved_until = $1, paypal_order_id = $2 WHERE id = $3`, - [reservedUntil, order.id, item.id] - ); - await client.query('COMMIT'); - res.json({ orderID: order.id }); - } catch (err) { - await client.query('ROLLBACK'); - console.error(err); - res.status(500).json({ error: 'internal error' }); - } finally { - client.release(); - } -}); - -router.post('/:id/capture', async (req: Request, res: Response) => { - const itemId = req.params.id; - const { orderID } = req.body; - const client = await pool.connect(); - try { - const token = await getAccessToken(); - const captureResp = await fetch(`${PAYPAL_BASE}/v2/checkout/orders/${orderID}/capture`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } - }); - const capture = await captureResp.json(); - if (!captureResp.ok || capture.status !== 'COMPLETED') { - return res.status(502).json({ error: 'capture failed', detail: capture }); - } - - await client.query('BEGIN'); - const { rows } = await client.query( - `UPDATE items SET status = 'sold', sold_at = now() - WHERE id = $1 AND paypal_order_id = $2 AND status != 'sold' - RETURNING *`, - [itemId, orderID] - ); - const capturedAmount = capture.purchase_units?.[0]?.payments?.captures?.[0]?.amount?.value; - await client.query( - `INSERT INTO orders (item_id, customer_id, processor, processor_order_id, amount_cents, status, raw_event) - VALUES ($1, $2, 'paypal', $3, $4, 'completed', $5)`, - [itemId, req.customerId || null, orderID, Math.round(parseFloat(capturedAmount || '0') * 100), capture] - ); - await client.query('COMMIT'); - res.json({ status: 'sold', item: rows[0] || null }); - } catch (err) { - await client.query('ROLLBACK'); - console.error(err); - res.status(500).json({ error: 'internal error' }); - } finally { - client.release(); - } -}); - -webhookRouter.post('/', async (req: Request, res: Response) => { - try { - const token = await getAccessToken(); - const verifyResp = await fetch(`${PAYPAL_BASE}/v1/notifications/verify-webhook-signature`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - auth_algo: req.headers['paypal-auth-algo'], - cert_url: req.headers['paypal-cert-url'], - transmission_id: req.headers['paypal-transmission-id'], - transmission_sig: req.headers['paypal-transmission-sig'], - transmission_time: req.headers['paypal-transmission-time'], - webhook_id: process.env.PAYPAL_WEBHOOK_ID, - webhook_event: req.body - }) - }); - const verification = await verifyResp.json(); - if (verification.verification_status !== 'SUCCESS') { - console.warn('paypal webhook signature invalid'); - return res.status(400).end(); - } - - const event = req.body; - if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { - const itemId = event.resource?.custom_id; - if (itemId) { - await pool.query( - `UPDATE items SET status = 'sold', sold_at = now() WHERE id = $1 AND status != 'sold'`, - [itemId] - ); - } - } - res.status(200).end(); - } catch (err) { - console.error('webhook error', err); - res.status(500).end(); - } -}); - -export { router, webhookRouter };