diff --git a/.env.template b/.env.template index 92e99255a..b20ce862b 100644 --- a/.env.template +++ b/.env.template @@ -58,4 +58,6 @@ SENTRY_ENVIRONMENT=local CAMPAIGN_CREATION_WEBHOOK=https://webhook.site/11111111-1111-1111-1111-11111111111 -STATUS_CHANGE_WEBHOOK=https://webhook.site/11111111-1111-1111-1111-11111111111 \ No newline at end of file +STATUS_CHANGE_WEBHOOK=https://webhook.site/11111111-1111-1111-1111-11111111111 + +AI_REVIEWER_PROFILE_ID=11111 \ No newline at end of file diff --git a/deployment/after-install.sh b/deployment/after-install.sh index 879cf0082..e2e3f5a07 100644 --- a/deployment/after-install.sh +++ b/deployment/after-install.sh @@ -97,6 +97,7 @@ services: TESTER_LEADER_CPV2_EMAIL: '${TESTER_LEADER_CPV2_EMAIL}' TASK_MEDIA_BUCKET: '${TASK_MEDIA_BUCKET}' OPTIMIZED_TASK_MEDIA_BUCKET: '${OPTIMIZED_TASK_MEDIA_BUCKET}' + AI_REVIEWER_PROFILE_ID: '${AI_REVIEWER_PROFILE_ID}' volumes: - /var/docker/keys:/app/keys logging: diff --git a/package-lock.json b/package-lock.json index f31d9f1d1..5b9a210f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.11", + "@appquality/tryber-database": "^0.46.20", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", @@ -108,9 +108,9 @@ } }, "node_modules/@appquality/tryber-database": { - "version": "0.46.11", - "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.11.tgz", - "integrity": "sha512-mec9oRm+ojlVoVqQqHZfVRbJIPSmZs163oWYsRXH+Ab7XESeWqlAySjg4hpVl0WZXr5wnH+AgJ2vt4d2LFhzpw==", + "version": "0.46.20", + "resolved": "https://registry.npmjs.org/@appquality/tryber-database/-/tryber-database-0.46.20.tgz", + "integrity": "sha512-1lW6F6wqNni1AZTMPA1WnaozikUXB3mnGHP2XkXwOZ2YP1nsl6BCFoFqHgiPakd2bVzZHyFCQnwv6VNXg1Q+qQ==", "license": "ISC", "dependencies": { "better-sqlite3": "^12.5.0", @@ -468,7 +468,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1004,6 +1003,7 @@ "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", @@ -1028,6 +1028,7 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } @@ -1082,6 +1083,7 @@ "deprecated": "Use @eslint/config-array instead", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", @@ -1097,6 +1099,7 @@ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=12.22" }, @@ -1111,7 +1114,8 @@ "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", "deprecated": "Use @eslint/object-schema instead", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -2841,7 +2845,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -3385,7 +3388,6 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3584,7 +3586,6 @@ "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "5.62.0", "@typescript-eslint/types": "5.62.0", @@ -3745,7 +3746,8 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/abbrev": { "version": "3.0.1", @@ -3776,7 +3778,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3790,6 +3791,7 @@ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -4447,7 +4449,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-0.25.0.tgz", "integrity": "sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.14.7" } @@ -4834,7 +4835,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5658,7 +5658,8 @@ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/deepmerge": { "version": "4.3.1", @@ -5810,6 +5811,7 @@ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "esutils": "^2.0.2" }, @@ -6044,6 +6046,7 @@ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6142,6 +6145,7 @@ "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" @@ -6159,6 +6163,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -6178,6 +6183,7 @@ "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", @@ -6221,6 +6227,7 @@ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "estraverse": "^5.1.0" }, @@ -6234,6 +6241,7 @@ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=4.0" } @@ -6277,6 +6285,7 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6671,7 +6680,8 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -6724,6 +6734,7 @@ "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flat-cache": "^3.0.4" }, @@ -6807,6 +6818,7 @@ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -6824,6 +6836,7 @@ "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", @@ -6838,7 +6851,8 @@ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/floating-point-regex": { "version": "0.1.0", @@ -7194,6 +7208,7 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -7235,6 +7250,7 @@ "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "type-fest": "^0.20.2" }, @@ -7596,6 +7612,7 @@ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -7899,6 +7916,7 @@ "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -8111,7 +8129,6 @@ "integrity": "sha512-N4GT5on8UkZgH0O5LUavMRV1EDEhNTL0KEfRmDIeZHSV7p2XgLoY9t9VDUgL6o+yfdgYHVxuz81G8oB9VG5uyA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^28.1.3", "@jest/types": "^28.1.3", @@ -9209,7 +9226,8 @@ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", @@ -9229,7 +9247,8 @@ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/json5": { "version": "2.2.3", @@ -9309,6 +9328,7 @@ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "json-buffer": "3.0.1" } @@ -9446,6 +9466,7 @@ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -9485,6 +9506,7 @@ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-locate": "^5.0.0" }, @@ -10763,6 +10785,7 @@ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", @@ -10797,6 +10820,7 @@ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "p-limit": "^3.0.2" }, @@ -10855,6 +10879,7 @@ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -11194,6 +11219,7 @@ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8.0" } @@ -11617,6 +11643,7 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=4" } @@ -12816,7 +12843,8 @@ "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/through2": { "version": "2.0.5", @@ -12899,7 +12927,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13091,7 +13118,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -13697,6 +13723,7 @@ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "prelude-ls": "^1.2.1" }, @@ -13720,6 +13747,7 @@ "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -13746,7 +13774,6 @@ "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14111,6 +14138,7 @@ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } diff --git a/package.json b/package.json index 8618d0745..b381c482c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "author": "", "license": "ISC", "dependencies": { - "@appquality/tryber-database": "^0.46.11", + "@appquality/tryber-database": "^0.46.20", "@appquality/wp-auth": "^1.0.7", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/hash-node": "^3.374.0", diff --git a/src/config.ts b/src/config.ts index bfdec4397..ef6ffe440 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,6 +41,7 @@ const config: { name: string; email: string; }; + aiReviewer: number; } = { port: process.env.PORT || "3000", apiRoot: false, @@ -77,6 +78,7 @@ const config: { name: process.env.TESTER_LEADER_CPV2_NAME || "Unknown", email: process.env.TESTER_LEADER_CPV2_EMAIL || "Unknown", }, + aiReviewer: Number(process.env.AI_REVIEWER_PROFILE_ID) || 0, }; if (process.env.SSL_CHAIN && process.env.SSL_PRIVATE) { diff --git a/src/features/s3/presignUrl/index.ts b/src/features/s3/presignUrl/index.ts index dd0df6577..107332838 100644 --- a/src/features/s3/presignUrl/index.ts +++ b/src/features/s3/presignUrl/index.ts @@ -4,8 +4,11 @@ import { parseUrl } from "@aws-sdk/url-parser"; import { Hash } from "@aws-sdk/hash-node"; import { formatUrl } from "@aws-sdk/util-format-url"; -export const getPresignedUrl = async (url: string): Promise => { - const expirationSeconds = 1200; // 20 minutes +// default expiration is 20 minutes (1200 seconds) +export const getPresignedUrl = async ( + url: string, + expirationSeconds: number = 1200 +): Promise => { const s3ObjectUrl = parseUrl(url); const presigner = new S3RequestPresigner({ credentials: { diff --git a/src/reference/openapi.yml b/src/reference/openapi.yml index e5dc569a7..2ffa50f79 100644 --- a/src/reference/openapi.yml +++ b/src/reference/openapi.yml @@ -2985,11 +2985,86 @@ paths: size: 0 start: 0 total: 0 + reviewer-types: + value: + items: + - created: string + duplication: father + id: 1 + internalId: string + isFavourite: true + severity: + id: 0 + name: string + status: + id: 0 + name: string + tags: + - id: 0 + name: string + tester: + id: 0 + title: string + type: + id: 0 + name: string + updated: string + - created: string + duplication: father + id: 2 + internalId: string + isFavourite: true + reviewerType: ai + severity: + id: 0 + name: string + status: + id: 0 + name: string + tags: + - id: 0 + name: string + tester: + id: 0 + title: string + type: + id: 0 + name: string + updated: string + - created: string + duplication: father + id: 3 + internalId: string + isFavourite: true + reviewerType: human + severity: + id: 0 + name: string + status: + id: 0 + name: string + tags: + - id: 0 + name: string + tester: + id: 0 + title: string + type: + id: 0 + name: string + updated: string + limit: 0 + size: 0 + start: 0 + total: 3 schema: allOf: - - properties: + - type: object + properties: items: + type: array items: + type: object properties: created: type: string @@ -3005,68 +3080,70 @@ paths: type: string isFavourite: type: boolean + reviewerType: + type: string + enum: + - ai + - human severity: + type: object + required: + - id + - name properties: id: type: integer name: type: string + status: + type: object required: - id - name - type: object - status: properties: id: type: integer name: type: string - required: - - id - - name - type: object tags: + type: array items: $ref: '#/components/schemas/BugTag' - type: array tester: + type: object + required: + - id properties: id: type: integer - required: - - id - type: object title: type: string type: + type: object + required: + - id + - name properties: id: type: integer name: type: string - required: - - id - - name - type: object updated: type: string required: + - created + - duplication - id - - title - internalId - - status - - type + - isFavourite - severity + - status - tester - - duplication - - isFavourite - - created + - title + - type - updated - type: object - type: array required: - items - type: object - $ref: '#/components/schemas/PaginationData' description: OK '403': @@ -3981,6 +4058,82 @@ paths: required: true schema: type: string + '/campaigns/{campaign}/finance/attachments': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + post: + summary: Your POST endpoint + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + attachments: + type: array + items: + type: object + required: + - url + - name + - mime_type + properties: + url: + type: string + name: + type: string + mime_type: + type: string + failed: + type: array + items: + type: object + required: + - name + - path + properties: + name: + type: string + path: + type: string + '403': + $ref: '#/components/responses/NotAuthorized' + '500': + description: Internal Server Error + operationId: post-campaigns-campaign-finance-attachments + x-stoplight: + id: 0ucra88nj3skb + security: + - JWT: [] + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + attachment: + x-stoplight: + id: loo9qp15yg7er + oneOf: + - type: string + x-stoplight: + id: 1y0da4vjut50n + format: binary + - type: array + x-stoplight: + id: 567eldf6sv5mh + items: + x-stoplight: + id: ykrxebcilk657 + type: string + format: binary '/campaigns/{campaign}/forms': get: description: '' @@ -6671,13 +6824,7 @@ paths: summary: Get Human Resources for a campaign tags: [] parameters: - - schema: - type: string - name: campaign - in: path - required: true - $ref: '#/components/parameters/campaign' - description: Updates tokens_usage in campaign and updates the link between cp_id and agreementId put: summary: Your PUT endpoint tags: [] @@ -12841,6 +12988,7 @@ paths: - id: 0 name: Name rate: 0 + description: '' '403': $ref: '#/components/responses/Authentication' '404': @@ -13173,12 +13321,6 @@ paths: schema: type: object properties: {} - '': - content: - application/json: - schema: - type: object - properties: {} security: - JWT: [] requestBody: @@ -13199,6 +13341,571 @@ paths: - jotformId - testerQuestionId description: '' + '/campaigns/{campaign}/finance/supplier': + get: + description: Get all finance suppliers + operationId: get-campaigns-campaign-finance-supplier + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + x-stoplight: + id: hhr19m5dfxjtt + items: + x-stoplight: + id: ihcfe7bywmrjs + type: object + required: + - name + - id + properties: + name: + type: string + x-stoplight: + id: 08yzm5eu8tl1h + created_at: + type: string + x-stoplight: + id: w0fmflgc090ff + created_by: + type: integer + x-stoplight: + id: gcs2p8mvl32gc + id: + type: number + x-stoplight: + id: ek7zfipegepg5 + examples: + Example 2: + value: + items: + - id: 1 + name: Respondent + created_at: '2026-01-01' + created_by: 10 + '400': + description: Bad Request + '403': + description: Forbidden + '500': + description: Internal Server Error + security: + - JWT: [] + summary: Get finance supplier + x-stoplight: + id: tlziygldrfder + parameters: + - schema: + type: string + name: campaign + in: path + required: true + post: + summary: POST a new supplier + operationId: post-campaigns-campaign-finance-supplier + tags: + - Campaign + responses: + '201': + description: Created + content: + application/json: + schema: + type: object + required: + - supplier_id + properties: + supplier_id: + type: number + x-stoplight: + id: rsrwvlerw0w2j + '400': + description: Bad Request + '403': + description: Forbidden + '500': + description: Internal Server Error + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + examples: + Example 1: + value: + name: Respondent + x-stoplight: + id: j17dlfvjwluu7 + '/campaigns/{campaign}/finance/type': + parameters: + - schema: + type: string + name: campaign + in: path + required: true + get: + summary: GET types + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + x-stoplight: + id: d7f0e5l5qrx6o + items: + x-stoplight: + id: r1p9rcymebjpu + type: object + required: + - name + - id + properties: + name: + type: string + x-stoplight: + id: y0keeoutvijjh + id: + type: number + x-stoplight: + id: r546xns1ecc4j + examples: + Example 1: + value: + items: + - name: Recruiting + id: 1 + - name: Survey + id: 2 + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: get-campaigns-campaign-finance-type + x-stoplight: + id: 02e8ns5xdhecm + security: + - JWT: [] + '/campaigns/{campaign}/finance/otherCosts': + parameters: + - description: A campaign id + in: path + name: campaign + required: true + schema: + type: string + get: + summary: Your GET endpoint + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + required: + - items + properties: + items: + type: array + x-stoplight: + id: 7k2qykvqlzwfe + items: + x-stoplight: + id: srl106ylr6cpm + type: object + required: + - cost_id + - type + - supplier + - description + - attachments + - cost + properties: + cost_id: + type: number + x-stoplight: + id: 5tq4npphsv3wx + type: + type: object + x-stoplight: + id: itchthltx575n + required: + - name + - id + properties: + name: + type: string + x-stoplight: + id: mkzkmmdtonn2u + id: + type: number + x-stoplight: + id: ng1a3g0fc695g + supplier: + type: object + x-stoplight: + id: pewi2gjqq2j1h + required: + - name + - id + properties: + name: + type: string + x-stoplight: + id: eel1e2x9tsc4x + id: + type: number + x-stoplight: + id: ppawzunmvk3qx + description: + type: string + x-stoplight: + id: jlosijekgy2c6 + attachments: + type: array + x-stoplight: + id: jj9p1k4imekme + items: + x-stoplight: + id: w31uej26rl532 + type: object + required: + - id + - url + - mimetype + - presigned_url + properties: + id: + type: number + x-stoplight: + id: bz8ydut5na834 + url: + type: string + x-stoplight: + id: nctzb7saomq7c + mimetype: + type: string + x-stoplight: + id: 6tqj2cg96280v + presigned_url: + type: string + x-stoplight: + id: 8zt5euhrtitz3 + cost: + type: number + x-stoplight: + id: 4zut3xli3ht5f + examples: + Example 1: + value: + items: + - type: + name: string + id: 0 + supplier: + name: string + id: 0 + description: string + attachments: + - id: 0 + url: string + mimetype: string + cost_id: 0 + cost: 10.2 + '403': + description: Forbidden + '404': + description: Not Found + '500': + description: Internal Server Error + operationId: get-campaigns-campaign-finance-otherCosts + x-stoplight: + id: 9hp8r67rwl59d + security: + - JWT: [] + post: + summary: Your POST endpoint + tags: [] + responses: + '201': + description: Created + '400': + description: Bad Request + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: post-campaigns-campaign-finance-otherCosts + x-stoplight: + id: aujq76gdkus39 + description: Create a new campaign cost + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type_id + - supplier_id + - cost + - attachments + properties: + description: + type: string + type_id: + type: integer + supplier_id: + type: integer + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + examples: + Example 1: + value: + - description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + security: + - JWT: [] + delete: + summary: Your DELETE endpoint + tags: [] + responses: + '200': + description: OK + '400': + description: Bad Request + '403': + $ref: '#/components/responses/NotAuthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: delete-campaigns-campaign-finance-otherCosts + x-stoplight: + id: p9q4g69c20okp + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: object + required: + - cost_id + properties: + cost_id: + type: integer + x-stoplight: + id: b27nhd7f5ugfs + examples: + Example 1: + value: + cost_id: 80 + patch: + summary: Your PATCH endpoint + tags: [] + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type + - cost_id + - supplier + - cost + - attachments + properties: + description: + type: string + type: + type: string + x-stoplight: + id: q54ltj77jcyf0 + cost_id: + type: integer + supplier: + type: string + x-stoplight: + id: 5aunsjh1dxfq1 + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + examples: + Example 1: + value: + - description: description + type: Type 1 + cost_id: 10 + supplier: Supplier + cost: 104 + attachments: + - url: 'https://example.com/' + mime_type: image/jpg + '400': + description: Bad Request + '403': + description: Forbidden + '404': + $ref: '#/components/responses/NotFound' + '500': + description: Internal Server Error + operationId: patch-campaigns-campaign-finance-otherCosts + x-stoplight: + id: mwhcb91voxivy + security: + - JWT: [] + requestBody: + content: + application/json: + schema: + type: array + items: + type: object + x-examples: + Example 1: + description: Riparazione hardware ufficio + type_id: 3 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg + required: + - description + - type_id + - supplier_id + - cost + - attachments + - cost_id + properties: + description: + type: string + type_id: + type: integer + supplier_id: + type: integer + cost: + type: number + attachments: + type: array + items: + type: object + required: + - url + - mime_type + properties: + url: + type: string + mime_type: + type: string + cost_id: + type: integer + x-stoplight: + id: drnv0dayw8k18 + examples: + Example 1: + value: + - description: Riparazione hardware ufficio + type_id: 3 + cost_id: 2 + supplier_id: 105 + cost: 250.5 + attachments: + - url: 'https://esempio.com/documenti/fattura.pdf' + mime_type: application/pdf + - url: 'https://esempio.com/immagini/danno.jpg' + mime_type: image/jpeg servers: - url: 'https://api.app-quality.com' tags: diff --git a/src/routes/campaigns/campaignId/bugs/_get/index.spec.ts b/src/routes/campaigns/campaignId/bugs/_get/index.spec.ts index bda0d7367..f80a9869c 100644 --- a/src/routes/campaigns/campaignId/bugs/_get/index.spec.ts +++ b/src/routes/campaigns/campaignId/bugs/_get/index.spec.ts @@ -5,6 +5,15 @@ import request from "supertest"; const yesterday = new Date(new Date().getTime() - 86400000).toISOString(); const tomorrow = new Date(new Date().getTime() + 86400000).toISOString(); +// mock config to set aiReviewer to 100 +jest.mock("@src/config", () => ({ + __esModule: true, + default: { + ...jest.requireActual("@src/config").default, + aiReviewer: 100, + }, +})); + beforeAll(async () => { await tryber.tables.WpAppqEvdProfile.do().insert({ id: 1, @@ -70,7 +79,7 @@ beforeAll(async () => { status_id: 1, wp_user_id: 1, profile_id: 1, - reviewer: 1, + reviewer: 100, last_editor_id: 1, severity_id: 1, bug_replicability_id: 1, @@ -227,4 +236,16 @@ describe("GET /campaigns/campaignId/bugs", () => { expect(response.body.items[1]).toHaveProperty("tester", { id: 1 }); expect(response.body.items[2]).toHaveProperty("tester", { id: 1 }); }); + // Should return a bug list with reviewerType foreach bug + it("Should return a bug list with reviewerType foreach bug", async () => { + const response = await request(app) + .get("/campaigns/1/bugs") + .set("Authorization", "Bearer admin"); + expect(response.body).toHaveProperty("items"); + + expect(response.body.items).toHaveLength(3); + expect(response.body.items[0]).toHaveProperty("reviewerType", "ai"); + expect(response.body.items[1]).toHaveProperty("reviewerType", "human"); + expect(response.body.items[2]).toHaveProperty("reviewerType", "human"); + }); }); diff --git a/src/routes/campaigns/campaignId/bugs/_get/index.ts b/src/routes/campaigns/campaignId/bugs/_get/index.ts index c8e0926f1..10b8e62cc 100644 --- a/src/routes/campaigns/campaignId/bugs/_get/index.ts +++ b/src/routes/campaigns/campaignId/bugs/_get/index.ts @@ -3,12 +3,16 @@ import OpenapiError from "@src/features/OpenapiError"; import { tryber } from "@src/features/database"; import CampaignRoute from "@src/features/routes/CampaignRoute"; +import config from "@src/config"; interface Tag { id: number; name: string; } +type ReviewerType = + StoplightOperations["get-campaigns-cid-bugs"]["responses"]["200"]["content"]["application/json"]["items"][number]["reviewerType"]; + export default class BugsRoute extends CampaignRoute<{ response: StoplightOperations["get-campaigns-cid-bugs"]["responses"]["200"]["content"]["application/json"]; parameters: StoplightOperations["get-campaigns-cid-bugs"]["parameters"]["path"]; @@ -133,6 +137,7 @@ export default class BugsRoute extends CampaignRoute<{ "is_duplicated", "duplicated_of_id", "is_favorite", + "reviewer", tryber.raw("CAST(created AS CHAR) as created"), tryber.raw("CAST(updated AS CHAR) as updated"), tryber.ref("message").as("title"), @@ -197,6 +202,7 @@ export default class BugsRoute extends CampaignRoute<{ id: bug.bug_type_id, name: bug.type, }, + reviewerType: bug.reviewer === config.aiReviewer ? "ai" : "human", })); } @@ -277,6 +283,7 @@ export default class BugsRoute extends CampaignRoute<{ .toISOString() .slice(0, 19) .replace("T", " "), + reviewerType: bug.reviewerType as ReviewerType, }; }); } diff --git a/src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts new file mode 100644 index 000000000..0d3a10b36 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/attachment/_post/index.spec.ts @@ -0,0 +1,98 @@ +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import upload from "@src/features/upload"; +import request from "supertest"; + +jest.mock("@src/features/upload"); + +const profile = { + id: 1, + wp_user_id: 1, + email: "tester@example.com", + employment_id: 1, + education_id: 1, +}; +const wpUser = { + ID: 1, + user_login: "tester", + user_email: "tester@example.com", + user_pass: "pass", +}; +const campaign = { + id: 1, + title: "Test Campaign", + customer_title: "Test Campaign", + start_date: "2020-01-01", + end_date: "2020-01-01", + pm_id: 1, + page_manual_id: 0, + page_preview_id: 0, + platform_id: 1, + customer_id: 1, + project_id: 1, +}; + +const campaign2 = { + ...campaign, + id: 2, +}; + +describe("Route POST /campaigns/{campaignId}/finance/attachments", () => { + beforeAll(async () => { + (upload as jest.Mock).mockImplementation( + ({ key, bucket }: { bucket: string; key: string }) => { + return `https://s3.amazonaws.com/${bucket}/${key}`; + } + ); + await tryber.tables.WpUsers.do().insert(wpUser); + await tryber.tables.WpAppqEvdProfile.do().insert(profile); + await tryber.tables.WpAppqEvdCampaign.do().insert([campaign, campaign2]); + }); + + afterAll(async () => { + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + }); + + it("Should answer 403 if not logged in", async () => { + const response = await request(app).post( + "/campaigns/1/finance/attachments" + ); + expect(response.status).toBe(403); + }); + + it("Should answer 200 and mark as failed if try to send file as .bat, .sh and .exe", async () => { + const mockFileBuffer = Buffer.from("some data"); + + const response = await request(app) + .post("/campaigns/1/finance/attachments") + .attach("media", mockFileBuffer, "void.bat") + .attach("media", mockFileBuffer, "image.png") + .attach("media", mockFileBuffer, "void.sh") + .attach("media", mockFileBuffer, "void.exe") + .set("authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("failed", [ + { path: "INVALID_FILE_EXTENSION", name: "void.bat" }, + { path: "INVALID_FILE_EXTENSION", name: "void.sh" }, + { path: "INVALID_FILE_EXTENSION", name: "void.exe" }, + ]); + }); + + it("Should answer 200 and mark as failed if try to send an oversized file", async () => { + process.env.MAX_FILE_SIZE = "100"; + const mockFileBuffer = Buffer.alloc(101); + + const response = await request(app) + .post("/campaigns/1/finance/attachments") + .attach("media", mockFileBuffer, "oversized.png") + .set("authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty("failed", [ + { path: "FILE_TOO_BIG", name: "oversized.png" }, + ]); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/attachment/_post/index.ts b/src/routes/campaigns/campaignId/finance/attachment/_post/index.ts new file mode 100644 index 000000000..08586a79a --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/attachment/_post/index.ts @@ -0,0 +1,132 @@ +/** OPENAPI-CLASS: post-campaigns-campaign-finance-attachments */ +import upload from "@src/features/upload"; +import path from "path"; +import busboyMapper from "@src/features/busboyMapper"; +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import OpenapiError from "@src/features/OpenapiError"; +import debugMessage from "@src/features/debugMessage"; + +interface InvalidMedia { + name: string; + path: string; +} + +interface UploadSuccess { + files: { name: string; mime_type: string; path: string }[]; + failed: InvalidMedia[]; +} + +interface UploadError { + element: string; + id: number; + message: string; +} + +export default class SingleCampaignRoute extends CampaignRoute<{ + response: StoplightOperations["post-campaigns-campaign-finance-attachments"]["responses"]["200"]["content"]["application/json"]; + body: StoplightOperations["post-campaigns-campaign-finance-attachments"]["requestBody"]["content"]["multipart/form-data"]; + parameters: StoplightOperations["post-campaigns-campaign-finance-attachments"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + return true; + } + + protected async prepare(): Promise { + try { + const result = await this.uploadAttachmentFiles(); + + if ("message" in result) { + throw new OpenapiError(result.message); + } + + return this.setSuccess(200, { + attachments: result.files.map((file) => ({ + url: file.path, + name: file.name, + mime_type: file.mime_type, + })), + failed: result.failed, + }); + } catch (err) { + debugMessage(err); + this.setError( + (err as OpenapiError).status_code || 500, + err as OpenapiError + ); + } + } + + protected getKey({ + filename, + extension, + }: { + filename: string; + extension: string; + }): string { + return `${ + process.env.FINANCE_ATTACHMENTS_FOLDER || "finance-attachments" + }/CP${this.cp_id}/${filename}_${new Date().getTime()}${extension}`; + } + + protected isAcceptableFile(file: { name: string }): boolean { + return ![".bat", ".sh", ".exe"].includes( + path.extname(file.name).toLowerCase() + ); + } + + private async uploadAttachmentFiles(): Promise { + try { + const { valid, invalid } = await busboyMapper( + this.configuration.request, + (file) => { + if (!this.isAcceptableFile(file)) { + return "INVALID_FILE_EXTENSION"; + } + return false; + } + ); + + return { + files: await this.uploadFiles(valid), + failed: invalid.map((fail) => ({ + name: fail.name, + path: fail.errorCode, + })), + }; + } catch (err) { + return { + element: "media-upload", + id: 0, + message: err instanceof Error ? err.message : "Unknown error", + }; + } + } + + private async uploadFiles( + files: Media[] + ): Promise<{ name: string; mime_type: string; path: string }[]> { + let uploadedFiles = []; + for (const media of files) { + const uploadedPath = await upload({ + bucket: process.env.MEDIA_BUCKET || "", + key: this.getKey({ + filename: path.basename(media.name, path.extname(media.name)), + extension: path.extname(media.name), + }), + file: media, + }); + + uploadedFiles.push({ + name: media.name, + mime_type: media.mimeType, + path: uploadedPath.toString(), + }); + } + return uploadedFiles; + } +} diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts new file mode 100644 index 000000000..848c12edd --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.spec.ts @@ -0,0 +1,806 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +jest.mock("@src/features/deleteFromS3"); + +describe("DELETE /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + describe("Authentication and Authorization", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 403 if user is not authenticated", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user does not have access to campaign", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return 200 if logged in as olp with access to campaign", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + }); + }); + + describe("Input Validation", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 400 if cost_id is missing", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({}) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is null", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: null }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is not a number", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: "invalid" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is zero", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 0 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if cost_id is negative", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: -1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "cost_id must be a positive number", + }) + ); + }); + }); + + describe("Not Found ", () => { + it("Should return 404 if cost does not exist", async () => { + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 999 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if cost belongs to another campaign", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 2, + description: "Cost for another campaign", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 10 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Cost not found for this campaign", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should delete cost from database", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete cost and all its attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://example.com/attachment3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should only delete specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const cost1 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(cost1).toHaveLength(0); + + const cost2 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .select(); + expect(cost2).toHaveLength(1); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + }) + ); + }); + + it("Should only delete attachments of the deleted cost", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/delete1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/delete2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://example.com/keep.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachmentsCost1 = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachmentsCost1).toHaveLength(0); + + const attachmentsCost2 = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 2 }) + .select(); + expect(attachmentsCost2).toHaveLength(1); + expect(attachmentsCost2[0]).toEqual( + expect.objectContaining({ + id: 3, + url: "https://example.com/keep.png", + }) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/keep.png", + mimetype: "image/png", + }), + ]), + }) + ); + }); + + it("Should delete cost without attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost without attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete correctly only one cost item", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const deleteResponse = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(deleteResponse.status).toBe(200); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + }) + ); + }); + }); + + describe("Success - olp permissions", () => { + it("Should delete cost with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete cost and attachments ", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .select(); + expect(costs).toHaveLength(0); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(0); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(0); + }); + + it("Should delete correctly only one cost item", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const deleteResponse = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(deleteResponse.status).toBe(200); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 2, + description: "Cost to keep", + }) + ); + }); + }); + + describe("S3 Deletion", () => { + it("Should not call deleteFromS3 if cost has no attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost without attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(0); + }); + + it("Should call deleteFromS3 once for cost with one attachment", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with one attachment", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(1); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + }); + + it("Should call deleteFromS3 three times for cost with three attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Cost with multiple attachments", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(3); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + }); + }); + + it("Should only delete S3 files for the specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to delete", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 2, + url: "https://s3.eu-west-1.amazonaws.com/bucket/keep.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .delete("/campaigns/1/finance/otherCosts") + .send({ cost_id: 1 }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toBeCalledTimes(2); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/delete2.jpg", + }); + expect(deleteFromS3).not.toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/keep.png", + }); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts new file mode 100644 index 000000000..754e6eca3 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_delete/index.ts @@ -0,0 +1,83 @@ +/** OPENAPI-CLASS: delete-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +export default class OtherCostsDeleteRoute extends CampaignRoute<{ + response: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["responses"]["200"]; + parameters: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["delete-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + if (body.cost_id <= 0) { + this.setError(400, new OpenapiError("cost_id must be a positive number")); + return false; + } + + const costExists = await this.costExistsInCampaign(body.cost_id); + if (!costExists) { + this.setError(404, new OpenapiError("Cost not found for this campaign")); + return false; + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + await this.deleteOtherCost(body.cost_id); + + return this.setSuccess(200, {}); + } catch (e) { + console.error("Error deleting other cost: ", e); + return this.setError(500, new OpenapiError("Error deleting other cost")); + } + } + + private async costExistsInCampaign(costId: number): Promise { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId, campaign_id: this.cp_id }) + .first(); + return cost !== undefined; + } + + private async deleteOtherCost(costId: number): Promise { + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("url", "id") + .where({ cost_id: costId }); + + if (attachments.length > 0) { + for (const attachment of attachments) { + try { + await deleteFromS3({ url: attachment.url }); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ id: attachment.id }) + .delete(); + } catch (e) { + console.error( + `Error deleting attachment from S3: ${attachment.url}`, + e + ); + throw new Error("Error deleting attachment from S3"); + } + } + } + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId }) + .delete(); + } +} diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts new file mode 100644 index 000000000..586d26162 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.spec.ts @@ -0,0 +1,367 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import { getPresignedUrl } from "@src/features/s3/presignUrl"; + +jest.mock("@src/features/s3/presignUrl", () => { + return { + getPresignedUrl: jest + .fn() + .mockImplementation((url: string) => Promise.resolve(url)), + }; +}); + +describe("GET /campaigns/campaignId/finance/otherCosts", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert({ ID: 1 }); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost 1 description", + cost: 100.5, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost 2 description", + cost: 200.75, + type_id: 2, + supplier_id: 2, + }, + { + id: 3, + campaign_id: 2, + description: "Cost for other campaign", + cost: 150.0, + type_id: 1, + supplier_id: 1, + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + url: "https://example.com/attachment1.pdf", + mime_type: "application/pdf", + cost_id: 1, + }, + { + id: 2, + url: "https://example.com/attachment2.jpg", + mime_type: "image/jpeg", + cost_id: 1, + }, + { + id: 3, + url: "https://example.com/attachment3.png", + mime_type: "image/png", + cost_id: 2, + }, + ]); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + it("Should return 403 if logged out", async () => { + const response = await request(app).get("/campaigns/1/finance/otherCosts"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return finance other costs - admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + cost: 100.5, + type: { + name: "Type 1", + id: 1, + }, + supplier: { + name: "Supplier 1", + id: 1, + }, + description: "Cost 1 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + url: "https://example.com/attachment1.pdf", + mimetype: "application/pdf", + presigned_url: expect.any(String), + }), + expect.objectContaining({ + id: 2, + url: "https://example.com/attachment2.jpg", + mimetype: "image/jpeg", + presigned_url: expect.any(String), + }), + ]), + }), + expect.objectContaining({ + cost_id: 2, + cost: 200.75, + type: { + name: "Type 2", + id: 2, + }, + supplier: { + name: "Supplier 2", + id: 2, + }, + description: "Cost 2 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/attachment3.png", + mimetype: "image/png", + presigned_url: expect.any(String), + }), + ]), + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return other costs - olp permissions", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + cost: 100.5, + type: { + name: "Type 1", + id: 1, + }, + supplier: { + name: "Supplier 1", + id: 1, + }, + description: "Cost 1 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + url: "https://example.com/attachment1.pdf", + mimetype: "application/pdf", + presigned_url: expect.any(String), + }), + expect.objectContaining({ + id: 2, + url: "https://example.com/attachment2.jpg", + mimetype: "image/jpeg", + presigned_url: expect.any(String), + }), + ]), + }), + expect.objectContaining({ + cost_id: 2, + cost: 200.75, + type: { + name: "Type 2", + id: 2, + }, + supplier: { + name: "Supplier 2", + id: 2, + }, + description: "Cost 2 description", + attachments: expect.arrayContaining([ + expect.objectContaining({ + id: 3, + url: "https://example.com/attachment3.png", + mimetype: "image/png", + presigned_url: expect.any(String), + }), + ]), + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return empty items array if no costs exist for campaign", async () => { + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 99, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Campaign with no costs", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + + const response = await request(app) + .get("/campaigns/99/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + items: [], + }); + }); + + it("Should not include costs from other campaigns", async () => { + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(response.body.items).toHaveLength(2); + expect( + response.body.items.find((item: any) => item.cost_id === 3) + ).toBeUndefined(); + }); + + it("Should return cost with empty attachments array if cost has no attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 1, + description: "Cost without attachments", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const costWithoutAttachments = response.body.items.find( + (item: any) => item.cost_id === 10 + ); + expect(costWithoutAttachments).toBeDefined(); + expect(costWithoutAttachments.cost).toBe(50); + expect(costWithoutAttachments.attachments).toEqual([]); + }); + + it("Should call getPresignedUrl for each attachment", async () => { + await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + + expect(getPresignedUrl).toHaveBeenCalledTimes(3); + expect(getPresignedUrl).toHaveBeenCalledWith( + "https://example.com/attachment1.pdf", + 10800 + ); + expect(getPresignedUrl).toHaveBeenCalledWith( + "https://example.com/attachment2.jpg", + 10800 + ); + expect(getPresignedUrl).toHaveBeenCalledWith( + "https://example.com/attachment3.png", + 10800 + ); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts new file mode 100644 index 000000000..2fddca8d8 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_get/index.ts @@ -0,0 +1,127 @@ +/** OPENAPI-CLASS: get-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; +import { getPresignedUrl } from "@src/features/s3/presignUrl"; + +type OtherCost = { + cost_id: number; + cost: number; + type: { + name: string; + id: number; + }; + supplier: { + name: string; + id: number; + }; + description: string; + attachments: { + id: number; + url: string; + mimetype: string; + presigned_url: string; + }[]; +}; + +export default class OtherCostsRoute extends CampaignRoute<{ + response: StoplightOperations["get-campaigns-campaign-finance-otherCosts"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["get-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + return true; + } + + protected async prepare(): Promise { + const costs = await this.getOtherCosts(); + + return this.setSuccess(200, { items: costs }); + } + + private async getOtherCosts(): Promise { + const costs = await tryber.tables.WpAppqCampaignOtherCosts.do() + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_other_costs") + .as("cost_id"), + "description", + "type_id", + "supplier_id", + "cost" + ) + .where("campaign_id", this.cp_id); + + if (!costs.length) return []; + + const typeIds = [...new Set(costs.map((c) => c.type_id))]; + const supplierIds = [...new Set(costs.map((c) => c.supplier_id))]; + const costIds = costs.map((c) => c.cost_id); + + const types = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .select("id", "name") + .whereIn("id", typeIds); + + const suppliers = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .select("id", "name") + .whereIn("id", supplierIds); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("id", "url", "mime_type", "cost_id") + .whereIn("cost_id", costIds); + + return Promise.all( + costs.map(async (cost) => { + const type = types.find((t) => t.id === cost.type_id); + const supplier = suppliers.find((s) => s.id === cost.supplier_id); + const costAttachments = attachments.filter( + (a) => a.cost_id === cost.cost_id + ); + + const resolvedAttachments = await Promise.all( + costAttachments.map(async (a) => { + let presignedUrl = a.url; + try { + presignedUrl = await getPresignedUrl(a.url, 10800); + } catch (error) { + console.error( + `Failed to generate presigned URL for ${a.url}:`, + error + ); + } + return { + id: a.id, + url: a.url, + mimetype: a.mime_type, + presigned_url: presignedUrl, + }; + }) + ); + + return { + cost_id: cost.cost_id, + cost: cost.cost, + type: { + name: type?.name || "", + id: type?.id || 0, + }, + supplier: { + name: supplier?.name || "", + id: supplier?.id || 0, + }, + description: cost.description, + attachments: resolvedAttachments, + }; + }) + ); + } +} diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts new file mode 100644 index 000000000..7df3e00a0 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.spec.ts @@ -0,0 +1,914 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +jest.mock("@src/features/deleteFromS3"); + +describe("PATCH /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + (deleteFromS3 as jest.Mock).mockResolvedValue(undefined); + + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + { + id: 3, + name: "Type 3", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + { + id: 105, + name: "Supplier 105", + created_by: 1, + created_on: "2024-01-03 12:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + jest.clearAllMocks(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + const validPayload = [ + { + description: "Riparazione hardware ufficio", + type_id: 3, + cost_id: 1, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]; + + describe("Authentication and Authorization", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 403 if user is not logged in", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user is not admin and does not have olp permissions", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if user has olp permissions for different campaign", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + + it("Should allow access with admin permissions", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should allow access with olp permissions for the campaign", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + }); + }); + + describe("Input Validation", () => { + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + }); + + it("Should return 400 if body is not an array", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload[0]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if array is empty", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Body must be a non-empty array of cost items" + ); + }); + + it("Should return 400 if cost_id is missing", async () => { + const { cost_id, ...item } = validPayload[0]; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([item]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is null", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost_id: null }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost_id is zero", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost_id: 0 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if cost_id is negative", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost_id: -1 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: cost_id must be a positive number", + }) + ); + }); + + it("Should return 400 if description is missing", async () => { + const { description, ...item } = validPayload[0]; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([item]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if type_id is missing", async () => { + const { type_id, ...item } = validPayload[0]; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([item]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if supplier_id is missing", async () => { + const { supplier_id, ...item } = validPayload[0]; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([item]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if cost is missing", async () => { + const { cost, ...item } = validPayload[0]; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([item]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if attachments is missing", async () => { + const { attachments, ...item } = validPayload[0]; + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([item]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if attachments is an empty array", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], attachments: [] }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Item 1: At least one attachment is required" + ); + }); + + it("Should return 400 if attachments array item is missing url", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + attachments: [{ mime_type: "application/pdf" }], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if attachments array item is missing mime_type", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + attachments: [{ url: "https://example.com/file.pdf" }], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + }); + + describe("Resource Validation", () => { + it("Should return 404 if cost_id does not exist", async () => { + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost_id: 999 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if cost belongs to another campaign", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 10, + campaign_id: 2, + description: "Cost for another campaign", + cost: 50.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost_id: 10 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Cost not found for this campaign", + }) + ); + }); + + it("Should return 404 if type_id does not exist", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], type_id: 999 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Type not found", + }) + ); + }); + + it("Should return 404 if supplier_id does not exist", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], supplier_id: 999 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(404); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Supplier not found", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should update cost in database", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + expect(response.body[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + + expect(updatedCost).toEqual( + expect.objectContaining({ + id: 1, + campaign_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + type_id: 3, + supplier_id: 105, + }) + ); + }); + + it("Should update cost and replace attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://old.com/old1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://old.com/old2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + + expect(attachments).toHaveLength(2); + expect(attachments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + cost_id: 1, + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }), + expect.objectContaining({ + cost_id: 1, + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }), + ]) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + cost: 250.5, + type: { + name: "Type 3", + id: 3, + }, + supplier: { + name: "Supplier 105", + id: 105, + }, + }) + ); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + }); + + it("Should delete old attachments from S3 when updating", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toHaveBeenCalledTimes(1); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + }); + }); + + it("Should delete multiple old attachments from S3 when updating", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + mime_type: "image/jpeg", + }, + { + id: 3, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + mime_type: "image/png", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(deleteFromS3).toHaveBeenCalledTimes(3); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file1.pdf", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file2.jpg", + }); + expect(deleteFromS3).toHaveBeenCalledWith({ + url: "https://s3.eu-west-1.amazonaws.com/bucket/file3.png", + }); + }); + + it("Should update cost without old attachments", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(2); + expect(deleteFromS3).not.toHaveBeenCalled(); + }); + + it("Should update cost with empty attachments array", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/old-file.pdf", + mime_type: "application/pdf", + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], attachments: [] }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.message).toBe( + "Item 1: At least one attachment is required" + ); + }); + + it("Should only update specified cost, not others", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "Cost to update", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Cost to keep", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const untouchedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .first(); + expect(untouchedCost).toEqual( + expect.objectContaining({ + description: "Cost to keep", + cost: 200.0, + }) + ); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(2); + }); + + it("Should update multiple costs in single request", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert([ + { + id: 1, + campaign_id: 1, + description: "First cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }, + { + id: 2, + campaign_id: 1, + description: "Second cost", + cost: 200.0, + type_id: 2, + supplier_id: 2, + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send([ + validPayload[0], + { + ...validPayload[0], + cost_id: 2, + description: "Updated second cost", + cost: 300.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(2); + + const cost1 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(cost1).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + + const cost2 = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 2 }) + .first(); + expect(cost2).toEqual( + expect.objectContaining({ + description: "Updated second cost", + cost: 300.0, + }) + ); + }); + }); + + describe("Success - olp permissions", () => { + it("Should update cost with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const updatedCost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: 1 }) + .first(); + expect(updatedCost).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + cost: 250.5, + }) + ); + }); + + it("Should update cost and replace attachments with olp permissions", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert([ + { + id: 1, + cost_id: 1, + url: "https://old.com/old1.pdf", + mime_type: "application/pdf", + }, + { + id: 2, + cost_id: 1, + url: "https://old.com/old2.jpg", + mime_type: "image/jpeg", + }, + ]); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body).toHaveLength(1); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where({ cost_id: 1 }) + .select(); + expect(attachments).toHaveLength(2); + expect(deleteFromS3).toHaveBeenCalledTimes(2); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + cost_id: 1, + description: "Riparazione hardware ufficio", + }) + ); + }); + }); + + describe("Error Handling", () => { + it("Should return 500 if S3 deletion fails", async () => { + await tryber.tables.WpAppqCampaignOtherCosts.do().insert({ + id: 1, + campaign_id: 1, + description: "Original cost", + cost: 100.0, + type_id: 1, + supplier_id: 1, + }); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert({ + id: 1, + cost_id: 1, + url: "https://s3.eu-west-1.amazonaws.com/bucket/file.pdf", + mime_type: "application/pdf", + }); + + (deleteFromS3 as jest.Mock).mockRejectedValueOnce( + new Error("S3 deletion failed") + ); + + const response = await request(app) + .patch("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(500); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Error updating other costs", + }) + ); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts new file mode 100644 index 000000000..76a9e3246 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_patch/index.ts @@ -0,0 +1,229 @@ +/** OPENAPI-CLASS: patch-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; +import deleteFromS3 from "@src/features/deleteFromS3"; + +type OtherCostItem = { + cost_id: number; + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { url: string; mime_type: string }[]; +}; + +export default class OtherCostsPatchRoute extends CampaignRoute<{ + response: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["patch-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + if (!Array.isArray(body) || body.length === 0) { + this.setError( + 400, + new OpenapiError("Body must be a non-empty array of cost items") + ); + return false; + } + + for (const item of body) { + const i = body.indexOf(item); + if (item.attachments.length === 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: At least one attachment is required`) + ); + return false; + } + + if (item.cost_id <= 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: cost_id must be a positive number`) + ); + return false; + } + + if (!(await this.costExistsInCampaign(item.cost_id))) { + this.setError( + 404, + new OpenapiError(`Item ${i + 1}: Cost not found for this campaign`) + ); + return false; + } + + if (!(await this.typeExists(item.type_id))) { + this.setError(404, new OpenapiError(`Item ${i + 1}: Type not found`)); + return false; + } + + if (!(await this.supplierExists(item.supplier_id))) { + this.setError( + 404, + new OpenapiError(`Item ${i + 1}: Supplier not found`) + ); + return false; + } + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + const updatedCosts = []; + + for (const item of body) { + await this.updateOtherCost(item); + const updatedCost = await this.getUpdatedCost(item.cost_id); + updatedCosts.push(updatedCost); + } + + return this.setSuccess(200, updatedCosts); + } catch (e) { + console.error("Error updating other costs: ", e); + return this.setError(500, new OpenapiError("Error updating other costs")); + } + } + + private async costExistsInCampaign(costId: number): Promise { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: costId, campaign_id: this.cp_id }) + .first(); + return cost !== undefined; + } + + private async typeExists(typeId: number): Promise { + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .where({ id: typeId }) + .first(); + return type !== undefined; + } + + private async supplierExists(supplierId: number): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ id: supplierId }) + .first(); + return supplier !== undefined; + } + + private async updateOtherCost(item: OtherCostItem): Promise { + await this.updateAttachments(item); + + await tryber.tables.WpAppqCampaignOtherCosts.do() + .where({ id: item.cost_id }) + .update({ + description: item.description, + cost: item.cost, + type_id: item.type_id, + supplier_id: item.supplier_id, + }); + } + + private async updateAttachments(item: OtherCostItem): Promise { + const { cost_id, attachments } = item; + const existingAttachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("id", "url", "mime_type") + .where({ cost_id: cost_id }); + + const existingUrls = existingAttachments.map((a) => a.url); + const newUrls = attachments.map((a) => a.url); + + const attachmentsToDelete = existingAttachments.filter( + (existing) => !newUrls.includes(existing.url) + ); + + if (attachmentsToDelete.length > 0) { + for (const attachment of attachmentsToDelete) { + try { + await deleteFromS3({ url: attachment.url }); + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .where("id", attachment.id) + .delete(); + } catch (e) { + console.error( + `Error deleting attachment from S3: ${attachment.url}`, + e + ); + throw new Error("Error deleting attachment from S3"); + } + } + } + + const attachmentsToAdd = attachments.filter( + (newAttachment) => !existingUrls.includes(newAttachment.url) + ); + + if (attachmentsToAdd.length > 0) { + const attachmentsData = attachmentsToAdd.map((attachment) => ({ + cost_id: cost_id, + url: attachment.url, + mime_type: attachment.mime_type, + })); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( + attachmentsData + ); + } + } + + private async getUpdatedCost(costId: number) { + const cost = await tryber.tables.WpAppqCampaignOtherCosts.do() + .select( + tryber + .ref("id") + .withSchema("wp_appq_campaign_other_costs") + .as("cost_id"), + "description", + "type_id", + "supplier_id", + "cost" + ) + .where({ id: costId }) + .first(); + + if (!cost) { + throw new Error("Cost not found after update"); + } + + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .select("id", "name") + .where({ id: cost.type_id }) + .first(); + + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .select("id", "name") + .where({ id: cost.supplier_id }) + .first(); + + const attachments = + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do() + .select("id", "url", "mime_type") + .where({ cost_id: costId }); + + return { + description: cost.description, + type: type?.name || "", + cost_id: cost.cost_id, + supplier: supplier?.name || "", + cost: cost.cost, + attachments: attachments.map((a) => ({ + url: a.url, + mime_type: a.mime_type, + })), + }; + } +} diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts new file mode 100644 index 000000000..2b0294d73 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.spec.ts @@ -0,0 +1,574 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("POST /campaigns/campaignId/finance/otherCosts", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "Jane", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert([ + { + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + { + id: 2, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "Another campaign", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + { + id: 3, + name: "Type 3", + }, + ]); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 1, + created_on: "2024-01-02 11:00:00", + }, + { + id: 105, + name: "Supplier 105", + created_by: 1, + created_on: "2024-01-03 12:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().delete(); + await tryber.tables.WpAppqCampaignOtherCosts.do().delete(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + const validPayload = [ + { + description: "Riparazione hardware ufficio", + type_id: 3, + supplier_id: 105, + cost: 250.5, + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]; + + describe("Not enough permissions", () => { + it("Should return 403 if logged out", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + }); + + describe("Validation errors", () => { + it("Should return 400 if body is not an array", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload[0]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body.err).toBeDefined(); + }); + + it("Should return 400 if body is an empty array", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Body must be a non-empty array of cost items", + }) + ); + }); + + it("Should return 400 if description is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], description: "" }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Description should not be empty", + }) + ); + }); + + it("Should return 400 if description is only whitespace", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], description: " " }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Description should not be empty", + }) + ); + }); + + it("Should return 400 if cost is 0", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost: 0 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Cost must be greater than 0", + }) + ); + }); + + it("Should return 400 if cost is negative", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost: -10 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Cost must be greater than 0", + }) + ); + }); + + it("Should return 400 if type_id does not exist", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], type_id: 999 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Type not found", + }) + ); + }); + + it("Should return 400 if supplier_id does not exist", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], supplier_id: 999 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Supplier not found", + }) + ); + }); + + it("Should return 400 if attachments array is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], attachments: [] }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: At least one attachment is required", + }) + ); + }); + + it("Should return 400 if attachment url is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "", + mime_type: "application/pdf", + }, + ], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Attachment URL is required", + }) + ); + }); + + it("Should return 400 if attachment mime_type is empty", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "", + }, + ], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 1: Attachment mime_type is required", + }) + ); + }); + + it("Should return 400 for second item with invalid data", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([validPayload[0], { ...validPayload[0], description: "" }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Item 2: Description should not be empty", + }) + ); + }); + }); + + describe("Success - admin permissions", () => { + it("Should return 201 if logged in as admin", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + }); + + it("Should create other cost in database", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + type: { name: "Type 3", id: 3 }, + supplier: { name: "Supplier 105", id: 105 }, + }) + ); + }); + + it("Should create attachments in database", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + expect(getResponse.body.items[0].attachments).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + url: "https://esempio.com/documenti/fattura.pdf", + mimetype: "application/pdf", + }), + expect.objectContaining({ + url: "https://esempio.com/immagini/danno.jpg", + mimetype: "image/jpeg", + }), + ]) + ); + }); + + it("Should create cost with single attachment", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura.pdf", + mime_type: "application/pdf", + }, + ], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(1); + }); + + it("Should create cost with multiple attachments", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + attachments: [ + { + url: "https://esempio.com/documenti/fattura1.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/documenti/fattura2.pdf", + mime_type: "application/pdf", + }, + { + url: "https://esempio.com/immagini/danno.jpg", + mime_type: "image/jpeg", + }, + ], + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(3); + }); + + it("Should create multiple costs independently", async () => { + const response1 = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response1.status).toBe(201); + + const response2 = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + { + ...validPayload[0], + description: "Second cost", + cost: 100.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response2.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(2); + }); + + it("Should create multiple costs in single request", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([ + validPayload[0], + { + ...validPayload[0], + description: "Second cost", + cost: 100.0, + }, + { + ...validPayload[0], + description: "Third cost", + cost: 150.0, + }, + ]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(3); + }); + + it("Should accept decimal cost values", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send([{ ...validPayload[0], cost: 123.456 }]) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0]).toBeDefined(); + }); + }); + + describe("Success - olp permissions", () => { + it("Should return 201 if logged in as olp with access to campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + }); + + it("Should create other cost in database with olp permissions", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items).toHaveLength(1); + expect(getResponse.body.items[0]).toEqual( + expect.objectContaining({ + description: "Riparazione hardware ufficio", + }) + ); + }); + + it("Should create attachments with olp permissions", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + + const getResponse = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(getResponse.status).toBe(200); + expect(getResponse.body.items[0].attachments).toHaveLength(2); + }); + + it("Should return 403 if olp does not have access to campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[2]}'); + expect(response.status).toBe(403); + }); + }); + + describe("Campaign isolation", () => { + it("Should create cost only for specified campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/otherCosts") + .send(validPayload) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + + const getResponse1 = await request(app) + .get("/campaigns/1/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse1.status).toBe(200); + expect(getResponse1.body.items).toHaveLength(1); + + const getResponse2 = await request(app) + .get("/campaigns/2/finance/otherCosts") + .set("Authorization", "Bearer admin"); + expect(getResponse2.status).toBe(200); + expect(getResponse2.body.items).toHaveLength(0); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts new file mode 100644 index 000000000..d65478f94 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/otherCosts/_post/index.ts @@ -0,0 +1,161 @@ +/** OPENAPI-CLASS: post-campaigns-campaign-finance-otherCosts */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +type OtherCostItem = { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { url: string; mime_type: string }[]; +}; + +export default class OtherCostsPostRoute extends CampaignRoute<{ + response: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["responses"]["201"]; + parameters: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["parameters"]["path"]; + body: StoplightOperations["post-campaigns-campaign-finance-otherCosts"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + return false; + } + + const body = this.getBody(); + + if (!Array.isArray(body) || body.length === 0) { + this.setError( + 400, + new OpenapiError("Body must be a non-empty array of cost items") + ); + return false; + } + + for (const item of body) { + const i = body.indexOf(item); + + if (!item.description || item.description.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Description should not be empty`) + ); + return false; + } + + if (item.cost <= 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Cost must be greater than 0`) + ); + return false; + } + + if (!(await this.typeExists(item.type_id))) { + this.setError(400, new OpenapiError(`Item ${i + 1}: Type not found`)); + return false; + } + + if (!(await this.supplierExists(item.supplier_id))) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Supplier not found`) + ); + return false; + } + + if (!item.attachments || item.attachments.length === 0) { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: At least one attachment is required`) + ); + return false; + } + + for (const attachment of item.attachments) { + if (!attachment.url || attachment.url.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Attachment URL is required`) + ); + return false; + } + if (!attachment.mime_type || attachment.mime_type.trim() === "") { + this.setError( + 400, + new OpenapiError(`Item ${i + 1}: Attachment mime_type is required`) + ); + return false; + } + } + } + + return true; + } + + protected async prepare(): Promise { + try { + const body = this.getBody(); + + for (const item of body) { + const costId = await this.createOtherCost(item); + await this.createAttachments(costId, item.attachments); + } + + return this.setSuccess(201, {}); + } catch (e) { + console.error("Error creating other costs: ", e); + return this.setError(500, new OpenapiError("Error creating other costs")); + } + } + + private async createOtherCost(item: OtherCostItem): Promise { + const result = await tryber.tables.WpAppqCampaignOtherCosts.do() + .insert({ + campaign_id: this.cp_id, + description: item.description, + cost: item.cost, + type_id: item.type_id, + supplier_id: item.supplier_id, + }) + .returning("id"); + + const id = result[0]?.id ?? result[0]; + + if (!id) throw new Error("Error creating other cost"); + + return id; + } + + private async createAttachments( + costId: number, + attachments: { url: string; mime_type: string }[] + ): Promise { + const attachmentsData = attachments.map((attachment) => ({ + cost_id: costId, + url: attachment.url, + mime_type: attachment.mime_type, + })); + + await tryber.tables.WpAppqCampaignOtherCostsAttachment.do().insert( + attachmentsData + ); + } + + private async typeExists(typeId: number): Promise { + const type = await tryber.tables.WpAppqCampaignOtherCostsType.do() + .where({ id: typeId }) + .first(); + return type !== undefined; + } + + private async supplierExists(supplierId: number): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ id: supplierId }) + .first(); + return supplier !== undefined; + } +} diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts new file mode 100644 index 000000000..ab4f40ee6 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.spec.ts @@ -0,0 +1,140 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("GET /campaigns/campaignId/finance/supplier", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "John", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + { + id: 2, + name: "Supplier 2", + created_by: 2, + created_on: "2024-01-02 11:00:00", + }, + ]); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + it("Should return 403 if logged out", async () => { + const response = await request(app).get("/campaigns/1/finance/supplier"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return finance suppliers - admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", "Bearer admin"); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }), + expect.objectContaining({ + id: 2, + name: "Supplier 2", + created_by: 2, + created_on: "2024-01-02 11:00:00", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return suppliers - olp permissions", async () => { + const response = await request(app) + .get("/campaigns/1/finance/supplier") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }), + expect.objectContaining({ + id: 2, + name: "Supplier 2", + created_by: 2, + created_on: "2024-01-02 11:00:00", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts new file mode 100644 index 000000000..da33fa92a --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_get/index.ts @@ -0,0 +1,43 @@ +/** OPENAPI-CLASS: get-campaigns-campaign-finance-supplier */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +type Supplier = { + id: number; + name: string; + created_on?: string; + created_by?: number; +}; + +export default class SupplierRoute extends CampaignRoute<{ + response: StoplightOperations["get-campaigns-campaign-finance-supplier"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["get-campaigns-campaign-finance-supplier"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + return true; + } + + protected async prepare(): Promise { + const suppliers = await this.getSuppliers(); + + return this.setSuccess(200, { items: suppliers }); + } + + private async getSuppliers(): Promise { + return await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().select( + "id", + "name", + "created_on", + "created_by" + ); + } +} diff --git a/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts b/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts new file mode 100644 index 000000000..649f9ffca --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_post/index.spec.ts @@ -0,0 +1,146 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("POST /campaigns/campaignId/finance/supplier", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + { + id: 2, + name: "John", + surname: "Doe", + wp_user_id: 2, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert([{ ID: 1 }, { ID: 2 }]); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + }); + + beforeEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().insert([ + { + id: 1, + name: "Supplier 1", + created_by: 1, + created_on: "2024-01-01 10:00:00", + }, + ]); + }); + + afterEach(async () => { + await tryber.tables.WpAppqCampaignOtherCostsSupplier.do().delete(); + }); + + afterAll(async () => { + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + describe("Not enough permissions", () => { + it("Should return 403 if logged out", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + }); + + describe("Enough permissions - admin", () => { + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + }); + + it("Should add new finance supplier", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(201); + expect(response.body).toEqual({ supplier_id: expect.any(Number) }); + }); + it("Should not add existing supplier", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "Supplier 1" }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + }); + + it("Should not add supplier with empty name", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: " " }) + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(400); + expect(response.body).toEqual( + expect.objectContaining({ + message: "Supplier name should not be empty", + }) + ); + }); + }); + + describe("Enough permissions - olp", () => { + it("Should add supplier ", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "New Supplier" }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(201); + expect(response.body).toEqual({ supplier_id: expect.any(Number) }); + }); + + it("Should not add existing supplier", async () => { + const response = await request(app) + .post("/campaigns/1/finance/supplier") + .send({ name: "Supplier 1" }) + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(400); + }); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts b/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts new file mode 100644 index 000000000..a6fa15ef7 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/supplier/_post/index.ts @@ -0,0 +1,67 @@ +/** OPENAPI-CLASS: post-campaigns-campaign-finance-supplier */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class SupplierRoute extends CampaignRoute<{ + response: StoplightOperations["post-campaigns-campaign-finance-supplier"]["responses"]["201"]["content"]["application/json"]; + parameters: StoplightOperations["post-campaigns-campaign-finance-supplier"]["parameters"]["path"]; + body: StoplightOperations["post-campaigns-campaign-finance-supplier"]["requestBody"]["content"]["application/json"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + + if (this.getBody().name.trim() === "") { + this.setError(400, new OpenapiError("Supplier name should not be empty")); + return false; + } + + return true; + } + + protected async prepare(): Promise { + if (await this.checkSupplierExists(this.getBody().name)) { + return this.setError(400, new OpenapiError("Supplier already exists")); + } + + try { + const supplierId = await this.createNewSupplier(this.getBody().name); + return this.setSuccess(201, { supplier_id: supplierId }); + } catch (e) { + console.error("Error creating new supplier: ", e); + return this.setError( + 500, + new OpenapiError("Error creating new supplier") + ); + } + } + + private async createNewSupplier(name: string): Promise { + const result = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .insert({ + name, + created_by: this.getTesterId(), + }) + .returning("id"); + + const id = result[0]?.id ?? result[0]; + + if (!id) throw new Error("Error creating supplier"); + + return id; + } + + private async checkSupplierExists(name: string): Promise { + const supplier = await tryber.tables.WpAppqCampaignOtherCostsSupplier.do() + .where({ name }) + .first(); + return supplier !== undefined; + } +} diff --git a/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts b/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts new file mode 100644 index 000000000..300f8d481 --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/type/_get/index.spec.ts @@ -0,0 +1,119 @@ +import request from "supertest"; +import app from "@src/app"; +import { tryber } from "@src/features/database"; + +describe("GET /campaigns/campaignId/finance/type", () => { + beforeAll(async () => { + await tryber.tables.WpAppqEvdProfile.do().insert([ + { + id: 1, + name: "John", + surname: "Doe", + wp_user_id: 1, + email: "", + employment_id: 1, + education_id: 1, + }, + ]); + await tryber.tables.WpUsers.do().insert({ ID: 1 }); + await tryber.tables.WpAppqEvdCampaign.do().insert({ + id: 1, + platform_id: 1, + start_date: "2020-01-01", + end_date: "2020-01-01", + title: "This is the title", + page_preview_id: 1, + page_manual_id: 1, + customer_id: 1, + pm_id: 1, + project_id: 1, + customer_title: "", + }); + await tryber.tables.WpAppqCampaignOtherCostsType.do().insert([ + { + id: 1, + name: "Type 1", + }, + { + id: 2, + name: "Type 2", + }, + ]); + }); + + afterAll(async () => { + await tryber.tables.WpAppqCampaignOtherCostsType.do().delete(); + await tryber.tables.WpAppqEvdCampaign.do().delete(); + await tryber.tables.WpUsers.do().delete(); + await tryber.tables.WpAppqEvdProfile.do().delete(); + }); + + it("Should return 403 if logged out", async () => { + const response = await request(app).get("/campaigns/1/finance/type"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if logged in as not admin user", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", "Bearer tester"); + expect(response.status).toBe(403); + }); + + it("Should return 403 if no access to the campaign", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", 'Bearer tester"'); + expect(response.status).toBe(403); + }); + + it("Should return 200 if logged in as admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", "Bearer admin"); + expect(response.status).toBe(200); + }); + + it("Should return finance types - admin", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", "Bearer admin"); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: "Type 1", + }), + expect.objectContaining({ + id: 2, + name: "Type 2", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); + + it("Should return types - olp permissions", async () => { + const response = await request(app) + .get("/campaigns/1/finance/type") + .set("Authorization", 'Bearer tester olp {"appq_campaign":[1]}'); + expect(response.status).toBe(200); + expect(response.body).toEqual( + expect.objectContaining({ + items: expect.arrayContaining([ + expect.objectContaining({ + id: 1, + name: "Type 1", + }), + expect.objectContaining({ + id: 2, + name: "Type 2", + }), + ]), + }) + ); + expect(response.body.items).toHaveLength(2); + }); +}); diff --git a/src/routes/campaigns/campaignId/finance/type/_get/index.ts b/src/routes/campaigns/campaignId/finance/type/_get/index.ts new file mode 100644 index 000000000..3b8f5e66d --- /dev/null +++ b/src/routes/campaigns/campaignId/finance/type/_get/index.ts @@ -0,0 +1,34 @@ +/** OPENAPI-CLASS: get-campaigns-campaign-finance-type */ + +import CampaignRoute from "@src/features/routes/CampaignRoute"; +import { tryber } from "@src/features/database"; +import OpenapiError from "@src/features/OpenapiError"; + +export default class TypeRoute extends CampaignRoute<{ + response: StoplightOperations["get-campaigns-campaign-finance-type"]["responses"]["200"]["content"]["application/json"]; + parameters: StoplightOperations["get-campaigns-campaign-finance-type"]["parameters"]["path"]; +}> { + protected async filter(): Promise { + if (!(await super.filter())) return false; + + if (!this.hasAccessToCampaign(this.cp_id)) { + this.setError(403, new OpenapiError("Access denied")); + + return false; + } + return true; + } + + protected async prepare(): Promise { + const types = await this.getTypes(); + + return this.setSuccess(200, { items: types }); + } + + private async getTypes() { + return await tryber.tables.WpAppqCampaignOtherCostsType.do().select( + "name", + "id" + ); + } +} diff --git a/src/routes/media/_post/index.ts b/src/routes/media/_post/index.ts index 215fc882b..bf63ea416 100644 --- a/src/routes/media/_post/index.ts +++ b/src/routes/media/_post/index.ts @@ -2,7 +2,6 @@ import debugMessage from "@src/features/debugMessage"; import upload from "@src/features/upload"; import { Context } from "openapi-backend"; import path from "path"; -import fs from "fs"; import busboyMapper from "@src/features/busboyMapper"; /** OPENAPI-ROUTE: post-media */ diff --git a/src/schema.ts b/src/schema.ts index 05530f136..1b0da0544 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -146,6 +146,14 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/attachments": { + post: operations["post-campaigns-campaign-finance-attachments"]; + parameters: { + path: { + campaign: string; + }; + }; + }; "/campaigns/{campaign}/forms": { get: operations["get-campaigns-campaign-forms"]; parameters: { @@ -796,6 +804,37 @@ export interface paths { }; }; }; + "/campaigns/{campaign}/finance/supplier": { + /** Get all finance suppliers */ + get: operations["get-campaigns-campaign-finance-supplier"]; + post: operations["post-campaigns-campaign-finance-supplier"]; + parameters: { + path: { + campaign: string; + }; + }; + }; + "/campaigns/{campaign}/finance/type": { + get: operations["get-campaigns-campaign-finance-type"]; + parameters: { + path: { + campaign: string; + }; + }; + }; + "/campaigns/{campaign}/finance/otherCosts": { + get: operations["get-campaigns-campaign-finance-otherCosts"]; + /** Create a new campaign cost */ + post: operations["post-campaigns-campaign-finance-otherCosts"]; + delete: operations["delete-campaigns-campaign-finance-otherCosts"]; + patch: operations["patch-campaigns-campaign-finance-otherCosts"]; + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + }; } export interface components { @@ -809,6 +848,7 @@ export interface components { }; Agreement: { expirationDate: string; + /** @default false */ isTokenBased?: boolean; note?: string; startDate: string; @@ -888,6 +928,7 @@ export interface components { applied?: boolean; /** @description If bugform is deactivated is a boolean else contains URLs to bugforms for each languages */ bugform_link?: boolean | components["schemas"]["TranslatablePage"]; + /** @default 0 */ csm_effort?: number; customerCanViewReviewing?: boolean; customer_title?: string; @@ -910,7 +951,9 @@ export interface components { public?: boolean; status?: boolean; titleRule?: boolean; + /** @default 0 */ tokens?: number; + /** @default 0 */ ux_effort?: number; visibility?: { freeSpots?: number; @@ -1867,6 +1910,8 @@ export interface operations { id: number; internalId: string; isFavourite: boolean; + /** @enum {string} */ + reviewerType?: "ai" | "human"; severity: { id: number; name: string; @@ -2113,6 +2158,41 @@ export interface operations { 404: components["responses"]["NotFound"]; }; }; + "post-campaigns-campaign-finance-attachments": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + attachments?: { + url: string; + name: string; + mime_type: string; + }[]; + failed?: { + name: string; + path: string; + }[]; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "multipart/form-data": { + attachment?: string | string[]; + }; + }; + }; + }; "get-campaigns-campaign-forms": { parameters: { path: { @@ -2984,15 +3064,20 @@ export interface operations { }; } & { autoApply?: number; + /** @default 0 */ autoApprove?: number; bugLanguage?: components["schemas"]["BugLang"]; hasBugForm?: number; hasBugParade?: number; /** @enum {string} */ pageVersion?: "v1" | "v2"; + /** @default 0 */ skipPagesAndTasks?: number; } & { - /** @enum {undefined} */ + /** + * @default 0 + * @enum {undefined} + */ notify_everyone?: 0 | 1; /** @example 1 */ ux_notify?: number; @@ -3013,6 +3098,7 @@ export interface operations { content: { "application/json": { autoApply: number; + /** @default 0 */ autoApprove: number; browsers?: { id: number; @@ -3035,6 +3121,7 @@ export interface operations { name: string; }[]; deviceRequirements?: string; + /** @default false */ hasPlan?: boolean; /** Format: date-time */ endDate: string; @@ -3121,6 +3208,7 @@ export interface operations { content: { "application/json": components["schemas"]["DossierCreationData"] & { autoApply?: number; + /** @default 0 */ autoApprove?: number; bugLanguage?: components["schemas"]["BugLang"] | boolean; hasBugParade?: number; @@ -5443,21 +5531,247 @@ export interface operations { "application/json": { [key: string]: unknown }; }; }; - "": { + }; + requestBody: { + content: { + "application/json": { + jotformId: string; + testerQuestionId: string; + }; + }; + }; + }; + /** Get all finance suppliers */ + "get-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { content: { - "application/json": { [key: string]: unknown }; + "application/json": { + items: { + name: string; + created_at?: string; + created_by?: number; + id: number; + }[]; + }; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ + 500: unknown; + }; + }; + "post-campaigns-campaign-finance-supplier": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** Created */ + 201: { + content: { + "application/json": { + supplier_id: number; + }; }; }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + /** Internal Server Error */ + 500: unknown; }; requestBody: { content: { "application/json": { - jotformId: string; - testerQuestionId: string; + name: string; }; }; }; }; + "get-campaigns-campaign-finance-type": { + parameters: { + path: { + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + name: string; + id: number; + }[]; + }; + }; + }; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + }; + "get-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + items: { + cost_id: number; + type: { + name: string; + id: number; + }; + supplier: { + name: string; + id: number; + }; + description: string; + attachments: { + id: number; + url: string; + mimetype: string; + presigned_url: string; + }[]; + cost: number; + }[]; + }; + }; + }; + /** Forbidden */ + 403: unknown; + /** Not Found */ + 404: unknown; + /** Internal Server Error */ + 500: unknown; + }; + }; + /** Create a new campaign cost */ + "post-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** Created */ + 201: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }[]; + }; + }; + }; + "delete-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: unknown; + /** Bad Request */ + 400: unknown; + 403: components["responses"]["NotAuthorized"]; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + cost_id: number; + }; + }; + }; + }; + "patch-campaigns-campaign-finance-otherCosts": { + parameters: { + path: { + /** A campaign id */ + campaign: string; + }; + }; + responses: { + /** OK */ + 200: { + content: { + "application/json": { + description: string; + type: string; + cost_id: number; + supplier: string; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + }[]; + }; + }; + /** Bad Request */ + 400: unknown; + /** Forbidden */ + 403: unknown; + 404: components["responses"]["NotFound"]; + /** Internal Server Error */ + 500: unknown; + }; + requestBody: { + content: { + "application/json": { + description: string; + type_id: number; + supplier_id: number; + cost: number; + attachments: { + url: string; + mime_type: string; + }[]; + cost_id: number; + }[]; + }; + }; + }; } export interface external {}