diff --git a/backend/package-lock.json b/backend/package-lock.json
index 9f9159c..8830bf4 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -22,10 +22,60 @@
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
}
},
+ "node_modules/@apidevtools/json-schema-ref-parser": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz",
+ "integrity": "sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15",
+ "js-yaml": "^4.1.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/philsturgeon"
+ }
+ },
+ "node_modules/@apidevtools/openapi-schemas": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz",
+ "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@apidevtools/swagger-methods": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz",
+ "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==",
+ "license": "MIT"
+ },
+ "node_modules/@apidevtools/swagger-parser": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz",
+ "integrity": "sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng==",
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/json-schema-ref-parser": "14.0.1",
+ "@apidevtools/openapi-schemas": "^2.1.0",
+ "@apidevtools/swagger-methods": "^3.0.2",
+ "ajv": "^8.17.1",
+ "ajv-draft-04": "^1.0.0",
+ "call-me-maybe": "^1.0.2"
+ },
+ "peerDependencies": {
+ "openapi-types": ">=7"
+ }
+ },
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
@@ -501,6 +551,15 @@
"url": "https://opencollective.com/libvips"
}
},
+ "node_modules/@isaacs/cliui": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+ "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@noble/hashes": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
@@ -569,6 +628,13 @@
"@otplib/core": "13.4.1"
}
},
+ "node_modules/@scarf/scarf": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz",
+ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0"
+ },
"node_modules/@scure/base": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz",
@@ -578,6 +644,12 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "license": "MIT"
+ },
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -600,6 +672,36 @@
"node": ">=0.8"
}
},
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-draft-04": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz",
+ "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "ajv": "^8.5.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -630,12 +732,27 @@
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -752,6 +869,18 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
+ "node_modules/brace-expansion": {
+ "version": "5.0.7",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
+ "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
@@ -837,6 +966,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/call-me-maybe": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz",
+ "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==",
+ "license": "MIT"
+ },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
@@ -903,6 +1038,15 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
+ "node_modules/commander": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz",
+ "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
@@ -989,6 +1133,20 @@
"node": ">=0.8"
}
},
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
@@ -1065,6 +1223,18 @@
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
+ "node_modules/doctrine": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+ "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "esutils": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@ -1166,6 +1336,15 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -1245,6 +1424,28 @@
"express": ">= 4.11"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
@@ -1282,6 +1483,22 @@
"node": ">=8"
}
},
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1376,6 +1593,30 @@
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
"license": "MIT"
},
+ "node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1509,6 +1750,55 @@
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
+ "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^9.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+ "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@ -1606,12 +1896,27 @@
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
"license": "MIT"
},
+ "node_modules/lodash.mergewith": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz",
+ "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==",
+ "license": "MIT"
+ },
"node_modules/lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
"license": "MIT"
},
+ "node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -1693,6 +1998,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1702,6 +2022,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -1860,6 +2189,13 @@
"wrappy": "1"
}
},
+ "node_modules/openapi-types": {
+ "version": "12.1.3",
+ "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz",
+ "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==",
+ "license": "MIT",
+ "peer": true
+ },
"node_modules/otplib": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/otplib/-/otplib-13.4.1.tgz",
@@ -1910,6 +2246,12 @@
"node": ">=6"
}
},
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "license": "BlueOak-1.0.0"
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1928,6 +2270,31 @@
"node": ">=8"
}
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -2100,6 +2467,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@@ -2245,6 +2621,27 @@
"@img/sharp-win32-x64": "0.34.5"
}
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
@@ -2317,6 +2714,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
@@ -2441,6 +2850,50 @@
"node": ">=0.10.0"
}
},
+ "node_modules/swagger-jsdoc": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.3.0.tgz",
+ "integrity": "sha512-I+iQjVGV3t28pOkQUJv2MncthvOtkEactOn8R76SvSYhxgtIn7FoqfDHwQaN+GBnQdXQLrhgDXseKitmJcHMsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@apidevtools/swagger-parser": "^12.1.0",
+ "commander": "6.2.0",
+ "doctrine": "3.0.0",
+ "glob": "11.1.0",
+ "lodash.mergewith": "^4.6.2",
+ "yaml": "2.0.0-1"
+ },
+ "bin": {
+ "swagger-jsdoc": "bin/swagger-jsdoc.js"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/swagger-ui-dist": {
+ "version": "5.32.8",
+ "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz",
+ "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@scarf/scarf": "=1.4.0"
+ }
+ },
+ "node_modules/swagger-ui-express": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz",
+ "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==",
+ "license": "MIT",
+ "dependencies": {
+ "swagger-ui-dist": ">=5.0.0"
+ },
+ "engines": {
+ "node": ">= v0.10.32"
+ },
+ "peerDependencies": {
+ "express": ">=4.0.0 || >=5.0.0-beta"
+ }
+ },
"node_modules/tar-fs": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
@@ -2563,6 +3016,21 @@
"node": ">= 0.8"
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
@@ -2643,6 +3111,15 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.0.0-1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz",
+ "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
diff --git a/backend/package.json b/backend/package.json
index 6f2a280..b9e7064 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -25,6 +25,8 @@
"otplib": "^13.4.1",
"qrcode": "^1.5.4",
"sharp": "^0.34.5",
+ "swagger-jsdoc": "^6.2.8",
+ "swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5",
"zod": "^3.23.8"
},
diff --git a/backend/src/db/index.js b/backend/src/db/index.js
index 7912cdc..0919672 100644
--- a/backend/src/db/index.js
+++ b/backend/src/db/index.js
@@ -2254,4 +2254,29 @@ db.exec('CREATE INDEX IF NOT EXISTS idx_objectifs_investisseur ON objectifs(inve
}
}
+// ── Migration : table api_keys ───────────────────────────────────────────
+// Clés API générées par l'utilisateur pour un accès programmatique/MCP.
+// Chaque clé est scopée à un investisseur précis (usage single-user
+// multi-investisseur — pas de notion multi-tenant ici). Seul le hash SHA-256
+// est stocké ; la valeur en clair n'est montrée qu'une fois à la création.
+// `key_prefix` (8 premiers caractères) permet d'identifier une clé dans
+// l'UI sans jamais réafficher la valeur complète.
+db.exec(`
+ CREATE TABLE IF NOT EXISTS api_keys (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ investisseur_id INTEGER NOT NULL REFERENCES investisseurs(id) ON DELETE CASCADE,
+ nom TEXT NOT NULL,
+ key_prefix TEXT NOT NULL,
+ key_hash TEXT NOT NULL UNIQUE,
+ scopes TEXT NOT NULL DEFAULT 'read',
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ last_used_at TEXT,
+ revoked_at TEXT
+ )
+`);
+db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id)');
+db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_inv ON api_keys(investisseur_id)');
+db.exec('CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash)');
+
export default db;
diff --git a/backend/src/middleware/apiKey.js b/backend/src/middleware/apiKey.js
new file mode 100644
index 0000000..b102bfd
--- /dev/null
+++ b/backend/src/middleware/apiKey.js
@@ -0,0 +1,33 @@
+import crypto from 'node:crypto';
+import db from '../db/index.js';
+
+/**
+ * Authentification par clé API (X-API-Key), distincte du JWT utilisé par le
+ * frontend (requireAuth). Une clé API est toujours scopée à un seul
+ * investisseur — pas de notion de "scope=all" ici, contrairement aux routes
+ * internes. Réservé aux routes /api/v1 (lecture seule, Phase 1).
+ */
+export function requireApiKey(req, res, next) {
+ const key = req.header('X-API-Key');
+ if (!key) {
+ return res.status(401).json({ error: 'Missing X-API-Key header' });
+ }
+
+ const hash = crypto.createHash('sha256').update(key).digest('hex');
+ const row = db.prepare(`
+ SELECT k.id, k.investisseur_id, k.scopes, k.revoked_at
+ FROM api_keys k
+ WHERE k.key_hash = ?
+ `).get(hash);
+
+ if (!row || row.revoked_at) {
+ return res.status(401).json({ error: 'Invalid or revoked API key' });
+ }
+
+ db.prepare(`UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?`).run(row.id);
+
+ req.apiKeyId = row.id;
+ req.investisseurId = row.investisseur_id;
+ req.apiScopes = (row.scopes || 'read').split(',').map(s => s.trim());
+ next();
+}
diff --git a/backend/src/routes/apiKeys.js b/backend/src/routes/apiKeys.js
new file mode 100644
index 0000000..cd2fa58
--- /dev/null
+++ b/backend/src/routes/apiKeys.js
@@ -0,0 +1,114 @@
+import { Router } from 'express';
+import crypto from 'node:crypto';
+import db from '../db/index.js';
+import { HttpError } from '../middleware/errorHandler.js';
+
+const router = Router();
+
+const KEY_PREFIX_LEN = 12; // ex: "clk_live_ab3" — assez pour identifier sans exposer le secret
+
+function generateKey() {
+ const secret = crypto.randomBytes(24).toString('hex'); // 48 caractères hex
+ const full = `clk_live_${secret}`;
+ const hash = crypto.createHash('sha256').update(full).digest('hex');
+ return { full, hash, prefix: full.slice(0, KEY_PREFIX_LEN) };
+}
+
+/* ── GET /api/api-keys ── liste des clés de l'utilisateur connecté ──────── */
+router.get('/', (req, res) => {
+ const rows = db.prepare(`
+ SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
+ i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
+ FROM api_keys k
+ JOIN investisseurs i ON i.id = k.investisseur_id
+ WHERE k.user_id = ?
+ ORDER BY k.revoked_at IS NOT NULL, k.created_at DESC
+ `).all(req.user.id);
+ res.json(rows);
+});
+
+/* ── POST /api/api-keys ── créer une nouvelle clé (nom + investisseur) ──── */
+router.post('/', (req, res, next) => {
+ try {
+ const nom = (req.body?.nom || '').trim();
+ const investisseur_id = Number(req.body?.investisseur_id);
+
+ if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
+ if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
+ if (!Number.isInteger(investisseur_id)) throw new HttpError(400, 'investisseur_id est requis');
+
+ const inv = db.prepare('SELECT id FROM investisseurs WHERE id = ? AND user_id = ?')
+ .get(investisseur_id, req.user.id);
+ if (!inv) throw new HttpError(404, 'Investisseur introuvable');
+
+ const { full, hash, prefix } = generateKey();
+
+ const info = db.prepare(`
+ INSERT INTO api_keys (user_id, investisseur_id, nom, key_prefix, key_hash, scopes)
+ VALUES (?, ?, ?, ?, ?, 'read')
+ `).run(req.user.id, investisseur_id, nom, prefix, hash);
+
+ const saved = db.prepare(`
+ SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
+ i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
+ FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
+ WHERE k.id = ?
+ `).get(info.lastInsertRowid);
+
+ // La valeur en clair n'est renvoyée qu'ici, une seule fois.
+ res.status(201).json({ ...saved, key: full });
+ } catch (e) { next(e); }
+});
+
+/* ── PATCH /api/api-keys/:id ── renommer une clé ─────────────────────────── */
+router.patch('/:id', (req, res, next) => {
+ try {
+ const nom = (req.body?.nom || '').trim();
+ if (!nom) throw new HttpError(400, 'Le nom de la clé est requis');
+ if (nom.length > 100) throw new HttpError(400, 'Le nom de la clé est trop long (100 caractères max)');
+
+ const existing = db.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?')
+ .get(req.params.id, req.user.id);
+ if (!existing) throw new HttpError(404, 'Clé API introuvable');
+
+ db.prepare('UPDATE api_keys SET nom = ? WHERE id = ?').run(nom, req.params.id);
+
+ const saved = db.prepare(`
+ SELECT k.id, k.nom, k.key_prefix, k.scopes, k.investisseur_id,
+ i.nom AS investisseur_nom, k.created_at, k.last_used_at, k.revoked_at
+ FROM api_keys k JOIN investisseurs i ON i.id = k.investisseur_id
+ WHERE k.id = ?
+ `).get(req.params.id);
+ res.json(saved);
+ } catch (e) { next(e); }
+});
+
+/* ── DELETE /api/api-keys/:id ── révoque la clé (soft-delete) ───────────── */
+router.delete('/:id', (req, res, next) => {
+ try {
+ const existing = db.prepare('SELECT id, revoked_at FROM api_keys WHERE id = ? AND user_id = ?')
+ .get(req.params.id, req.user.id);
+ if (!existing) throw new HttpError(404, 'Clé API introuvable');
+ if (existing.revoked_at) return res.json({ revoked: true });
+
+ db.prepare(`UPDATE api_keys SET revoked_at = datetime('now') WHERE id = ?`).run(req.params.id);
+ res.json({ revoked: true });
+ } catch (e) { next(e); }
+});
+
+/* ── DELETE /api/api-keys/:id/purge ── suppression définitive de la ligne ──
+ Distinct de la révocation ci-dessus : ici la clé disparaît complètement
+ (active ou déjà révoquée). Le frontend affiche un avertissement avant
+ d'appeler cette route si la clé est encore active. ─────────────────── */
+router.delete('/:id/purge', (req, res, next) => {
+ try {
+ const existing = db.prepare('SELECT id FROM api_keys WHERE id = ? AND user_id = ?')
+ .get(req.params.id, req.user.id);
+ if (!existing) throw new HttpError(404, 'Clé API introuvable');
+
+ db.prepare('DELETE FROM api_keys WHERE id = ?').run(req.params.id);
+ res.json({ deleted: true });
+ } catch (e) { next(e); }
+});
+
+export default router;
diff --git a/backend/src/routes/v1/dashboard.js b/backend/src/routes/v1/dashboard.js
new file mode 100644
index 0000000..b67d7ab
--- /dev/null
+++ b/backend/src/routes/v1/dashboard.js
@@ -0,0 +1,49 @@
+import { Router } from 'express';
+import db from '../../db/index.js';
+
+const router = Router();
+
+/**
+ * @openapi
+ * /dashboard:
+ * get:
+ * summary: Synthèse du portefeuille (KPIs)
+ * tags: [Dashboard]
+ * security: [{ ApiKeyAuth: [] }]
+ * responses:
+ * 200: { description: Synthèse KPI }
+ */
+router.get('/', (req, res) => {
+ const invId = req.investisseurId;
+
+ const investissements = db.prepare(`
+ SELECT
+ COUNT(*) AS nb_investissements,
+ COALESCE(SUM(montant_investi), 0) AS total_investi,
+ COALESCE(SUM(CASE WHEN statut='en_cours' THEN montant_investi END), 0) AS encours,
+ COALESCE(SUM(CASE WHEN statut='rembourse' THEN montant_investi END), 0) AS rembourse
+ FROM investissements WHERE investisseur_id = ?
+ `).get(invId);
+
+ const interets = db.prepare(`
+ SELECT
+ COALESCE(SUM(r.interets_bruts), 0) AS interets_bruts,
+ COALESCE(SUM(r.interets_nets), 0) AS interets_nets,
+ COALESCE(SUM(r.capital), 0) AS capital_recu,
+ COALESCE(SUM(r.net_recu), 0) AS net_recu_total
+ FROM remboursements r
+ JOIN investissements i ON i.id = r.investissement_id
+ WHERE i.investisseur_id = ?
+ `).get(invId);
+
+ const cash = db.prepare(`
+ SELECT
+ COALESCE(SUM(CASE WHEN type='depot' THEN montant END), 0) AS total_depots,
+ COALESCE(SUM(CASE WHEN type='retrait' THEN montant END), 0) AS total_retraits
+ FROM depots_retraits WHERE investisseur_id = ?
+ `).get(invId);
+
+ res.json({ investissements, interets, cash });
+});
+
+export default router;
diff --git a/backend/src/routes/v1/depotsRetraits.js b/backend/src/routes/v1/depotsRetraits.js
new file mode 100644
index 0000000..49098cf
--- /dev/null
+++ b/backend/src/routes/v1/depotsRetraits.js
@@ -0,0 +1,28 @@
+import { Router } from 'express';
+import db from '../../db/index.js';
+
+const router = Router();
+
+/**
+ * @openapi
+ * /depots-retraits:
+ * get:
+ * summary: Liste des mouvements de cash (dépôts / retraits)
+ * tags: [Dépôts / Retraits]
+ * security: [{ ApiKeyAuth: [] }]
+ * responses:
+ * 200: { description: Liste des mouvements }
+ */
+router.get('/', (req, res) => {
+ const rows = db.prepare(`
+ SELECT dr.id, dr.date_operation, p.nom AS plateforme_nom, dr.type,
+ dr.montant, dr.libelle
+ FROM depots_retraits dr
+ JOIN plateformes p ON p.id = dr.plateforme_id
+ WHERE dr.investisseur_id = ?
+ ORDER BY dr.date_operation DESC
+ `).all(req.investisseurId);
+ res.json(rows);
+});
+
+export default router;
diff --git a/backend/src/routes/v1/index.js b/backend/src/routes/v1/index.js
new file mode 100644
index 0000000..5e219c8
--- /dev/null
+++ b/backend/src/routes/v1/index.js
@@ -0,0 +1,16 @@
+import { Router } from 'express';
+import investisseurRouter from './investisseur.js';
+import investissementsRouter from './investissements.js';
+import remboursementsRouter from './remboursements.js';
+import depotsRetraitsRouter from './depotsRetraits.js';
+import dashboardRouter from './dashboard.js';
+
+const router = Router();
+
+router.use('/investisseur', investisseurRouter);
+router.use('/investissements', investissementsRouter);
+router.use('/remboursements', remboursementsRouter);
+router.use('/depots-retraits', depotsRetraitsRouter);
+router.use('/dashboard', dashboardRouter);
+
+export default router;
diff --git a/backend/src/routes/v1/investissements.js b/backend/src/routes/v1/investissements.js
new file mode 100644
index 0000000..c8bd28d
--- /dev/null
+++ b/backend/src/routes/v1/investissements.js
@@ -0,0 +1,82 @@
+import { Router } from 'express';
+import db from '../../db/index.js';
+import { HttpError } from '../../middleware/errorHandler.js';
+
+const router = Router();
+
+const LIST_COLUMNS = `
+ i.id, i.nom_projet, i.emetteur, p.nom AS plateforme_nom,
+ i.date_souscription, i.date_premiere_echeance, i.date_cible,
+ i.montant_investi, i.taux_interet, i.duree_mois,
+ i.type_remb, i.freq_interets, i.statut, i.reference
+`;
+
+/**
+ * @openapi
+ * /investissements:
+ * get:
+ * summary: Liste des investissements de l'investisseur
+ * tags: [Investissements]
+ * security: [{ ApiKeyAuth: [] }]
+ * parameters:
+ * - in: query
+ * name: statut
+ * schema: { type: string, enum: [en_cours, rembourse, en_retard, procedure, cloture] }
+ * responses:
+ * 200: { description: Liste des investissements }
+ */
+router.get('/', (req, res) => {
+ const { statut } = req.query;
+ const conds = ['i.investisseur_id = ?'];
+ const args = [req.investisseurId];
+ if (statut) { conds.push('i.statut = ?'); args.push(statut); }
+
+ const rows = db.prepare(`
+ SELECT ${LIST_COLUMNS}
+ FROM investissements i
+ JOIN plateformes p ON p.id = i.plateforme_id
+ WHERE ${conds.join(' AND ')}
+ ORDER BY i.date_souscription DESC
+ `).all(...args);
+ res.json(rows);
+});
+
+/**
+ * @openapi
+ * /investissements/{id}:
+ * get:
+ * summary: Détail d'un investissement, avec ses remboursements réels
+ * tags: [Investissements]
+ * security: [{ ApiKeyAuth: [] }]
+ * parameters:
+ * - in: path
+ * name: id
+ * required: true
+ * schema: { type: integer }
+ * responses:
+ * 200: { description: Détail de l'investissement }
+ * 404: { description: Investissement introuvable }
+ */
+router.get('/:id', (req, res, next) => {
+ try {
+ const inv = db.prepare(`
+ SELECT ${LIST_COLUMNS}, i.notes
+ FROM investissements i
+ JOIN plateformes p ON p.id = i.plateforme_id
+ WHERE i.id = ? AND i.investisseur_id = ?
+ `).get(req.params.id, req.investisseurId);
+ if (!inv) throw new HttpError(404, 'Investissement introuvable');
+
+ const remboursements = db.prepare(`
+ SELECT id, date_remb, capital, interets_bruts, prelev_sociaux,
+ prelev_forfaitaire, cashback, interets_nets, net_recu, statut
+ FROM remboursements
+ WHERE investissement_id = ?
+ ORDER BY date_remb
+ `).all(inv.id);
+
+ res.json({ ...inv, remboursements });
+ } catch (e) { next(e); }
+});
+
+export default router;
diff --git a/backend/src/routes/v1/investisseur.js b/backend/src/routes/v1/investisseur.js
new file mode 100644
index 0000000..af01930
--- /dev/null
+++ b/backend/src/routes/v1/investisseur.js
@@ -0,0 +1,27 @@
+import { Router } from 'express';
+import db from '../../db/index.js';
+
+const router = Router();
+
+/**
+ * @openapi
+ * /investisseur:
+ * get:
+ * summary: Profil de l'investisseur lié à la clé API
+ * tags: [Investisseur]
+ * security: [{ ApiKeyAuth: [] }]
+ * responses:
+ * 200:
+ * description: Profil investisseur
+ * 401:
+ * description: Clé API invalide ou manquante
+ */
+router.get('/', (req, res) => {
+ const inv = db.prepare(`
+ SELECT id, nom, prenom, type, type_fiscal, notes, created_at
+ FROM investisseurs WHERE id = ?
+ `).get(req.investisseurId);
+ res.json(inv);
+});
+
+export default router;
diff --git a/backend/src/routes/v1/remboursements.js b/backend/src/routes/v1/remboursements.js
new file mode 100644
index 0000000..a71bf01
--- /dev/null
+++ b/backend/src/routes/v1/remboursements.js
@@ -0,0 +1,43 @@
+import { Router } from 'express';
+import db from '../../db/index.js';
+
+const router = Router();
+
+/**
+ * @openapi
+ * /remboursements:
+ * get:
+ * summary: Liste des remboursements réels perçus
+ * tags: [Remboursements]
+ * security: [{ ApiKeyAuth: [] }]
+ * parameters:
+ * - in: query
+ * name: date_debut
+ * schema: { type: string, format: date }
+ * - in: query
+ * name: date_fin
+ * schema: { type: string, format: date }
+ * responses:
+ * 200: { description: Liste des remboursements }
+ */
+router.get('/', (req, res) => {
+ const { date_debut, date_fin } = req.query;
+ const conds = ['i.investisseur_id = ?'];
+ const args = [req.investisseurId];
+ if (date_debut) { conds.push('r.date_remb >= ?'); args.push(date_debut); }
+ if (date_fin) { conds.push('r.date_remb <= ?'); args.push(date_fin); }
+
+ const rows = db.prepare(`
+ SELECT r.id, r.date_remb, i.nom_projet, p.nom AS plateforme_nom,
+ r.capital, r.interets_bruts, r.prelev_sociaux, r.prelev_forfaitaire,
+ r.cashback, r.interets_nets, r.net_recu, r.statut
+ FROM remboursements r
+ JOIN investissements i ON i.id = r.investissement_id
+ JOIN plateformes p ON p.id = i.plateforme_id
+ WHERE ${conds.join(' AND ')}
+ ORDER BY r.date_remb DESC
+ `).all(...args);
+ res.json(rows);
+});
+
+export default router;
diff --git a/backend/src/server.js b/backend/src/server.js
index 8da7776..f0cc2d8 100644
--- a/backend/src/server.js
+++ b/backend/src/server.js
@@ -52,6 +52,10 @@ import secteursInvRouter from './routes/secteurs-inv.js';
import associationsInvRouter from './routes/associations-inv.js';
import notificationsRouter from './routes/notifications.js';
import ticketsRouter from './routes/tickets.js';
+import apiKeysRouter from './routes/apiKeys.js';
+import v1Router from './routes/v1/index.js';
+import { requireApiKey } from './middleware/apiKey.js';
+import { swaggerSpec, swaggerUi } from './swagger.js';
import db from './db/index.js';
import { getSmtpConfig } from './utils/mailer.js';
@@ -104,6 +108,15 @@ app.get('/api/app-info', (_, res) => {
app.use('/api/auth', authLimiter, authRouter);
+// ── API publique v1 (lecture seule, authentification par clé API) ────────
+// Montée ici, avant les routes internes ci-dessous : la ligne
+// `app.use('/api', requireAuth, associationsInvRouter)` plus bas capte tout
+// préfixe /api/* avec le JWT interne, ce qui intercepterait /api/v1/* et
+// /api/docs si elles étaient montées après.
+app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
+app.get('/api/openapi.json', (_, res) => res.json(swaggerSpec));
+app.use('/api/v1', requireApiKey, v1Router);
+
// All routes below require authentication
app.use('/api/investisseurs', requireAuth, investisseursRouter);
app.use('/api/plateformes', requireAuth, plateformesRouter);
@@ -142,6 +155,7 @@ app.use('/api/secteurs-inv', requireAuth, secteursInvRouter);
app.use('/api', requireAuth, associationsInvRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/tickets', requireAuth, ticketsRouter);
+app.use('/api/api-keys', requireAuth, apiKeysRouter);
app.use(errorHandler);
diff --git a/backend/src/swagger.js b/backend/src/swagger.js
new file mode 100644
index 0000000..c5c4674
--- /dev/null
+++ b/backend/src/swagger.js
@@ -0,0 +1,46 @@
+import swaggerJsdoc from 'swagger-jsdoc';
+import swaggerUi from 'swagger-ui-express';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// swagger-jsdoc résout ses motifs `apis` avec `glob`, qui n'interprète pas
+// les antislashs Windows comme séparateurs — on force donc des slashs.
+const toGlobPath = (p) => p.split(path.sep).join('/');
+
+/**
+ * Documentation OpenAPI de l'API publique v1 (lecture seule).
+ * Générée depuis les annotations JSDoc `@openapi` dans backend/src/routes/v1/*.js.
+ * Servie sur /api/docs (public, pas d'authentification pour consulter la doc —
+ * seules les requêtes réelles vers /api/v1/... nécessitent une clé API).
+ */
+const swaggerSpec = swaggerJsdoc({
+ definition: {
+ openapi: '3.0.3',
+ info: {
+ title: 'Crowdlending Tracker API',
+ version: 'v1',
+ description:
+ "API publique en lecture seule du portefeuille de crowdlending. " +
+ "Authentification par clé API (header `X-API-Key`), générée depuis Mon compte → Clés API. " +
+ "Chaque clé est scopée à un seul investisseur.",
+ },
+ servers: [{ url: '/api/v1' }],
+ components: {
+ securitySchemes: {
+ ApiKeyAuth: {
+ type: 'apiKey',
+ in: 'header',
+ name: 'X-API-Key',
+ },
+ },
+ },
+ security: [{ ApiKeyAuth: [] }],
+ },
+ apis: [toGlobPath(path.join(__dirname, 'routes/v1/*.js'))],
+});
+
+const nbPaths = Object.keys(swaggerSpec.paths || {}).length;
+console.log(`[Swagger] ${nbPaths} route(s) documentée(s) sur /api/docs`);
+
+export { swaggerSpec, swaggerUi };
diff --git a/frontend/src/pages/MonCompte.jsx b/frontend/src/pages/MonCompte.jsx
index 83be1f2..c3d2ccf 100644
--- a/frontend/src/pages/MonCompte.jsx
+++ b/frontend/src/pages/MonCompte.jsx
@@ -4,7 +4,10 @@ import PasswordInput from '../components/PasswordInput.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext.jsx';
import { useUi } from '../context/UiContext.jsx';
+import { useInvestisseur } from '../context/InvestisseurContext.jsx';
+import Modal from '../components/Modal.jsx';
import { api } from '../api.js';
+import { memberLabel } from '../utils/format.js';
/* ── Icônes nav ─────────────────────────────────────────────── */
function IconUser() {
@@ -13,6 +16,12 @@ function IconUser() {
function IconLock() {
return ;
}
+function IconKey() {
+ return ;
+}
+function IconTrash() {
+ return ;
+}
/* ── Dropdown custom style Finary ────────────────────────────── */
const LANGUES = [
@@ -782,6 +791,302 @@ function DeleteAccountSection() {
);
}
+/* ── Clés API ─────────────────────────────────────────────────
+ Permet de générer des clés pour un accès programmatique (API publique
+ en lecture seule, serveur MCP local...). Chaque clé est nommée par
+ l'utilisateur et rattachée à un investisseur précis. La valeur en clair
+ n'est affichée qu'une seule fois, juste après la création. ─────────── */
+function NewApiKeyModal({ open, onClose, onCreated, investisseurs }) {
+ const [nom, setNom] = useState('');
+ const [investisseurId, setInvestisseurId] = useState(investisseurs[0]?.id || '');
+ const [busy, setBusy] = useState(false);
+ const [err, setErr] = useState(null);
+
+ useEffect(() => {
+ if (open) {
+ setNom('');
+ setInvestisseurId(investisseurs[0]?.id || '');
+ setErr(null);
+ }
+ }, [open, investisseurs]);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ if (!nom.trim()) return setErr('Le nom de la clé est requis');
+ if (!investisseurId) return setErr('Sélectionnez un investisseur');
+ setBusy(true);
+ setErr(null);
+ try {
+ const created = await api.post('/api-keys', { nom: nom.trim(), investisseur_id: Number(investisseurId) });
+ onCreated(created);
+ } catch (e) { setErr(e.message); }
+ finally { setBusy(false); }
+ };
+
+ return (
+
+
+
+ );
+}
+
+/** URL absolue de la doc Swagger, déduite de VITE_API_URL comme le reste de l'app
+ * (ex: pages/settings/PlateformesSection.jsx). En dev via proxy Vite, VITE_API_URL
+ * n'est pas défini et l'URL reste relative — ça fonctionne aussi bien. */
+const DOCS_URL = (import.meta.env.VITE_API_URL || '/api').replace(/\/api$/, '') + '/api/docs';
+
+function RevealApiKeyModal({ apiKey, onClose }) {
+ const [copied, setCopied] = useState(false);
+ const [copiedCurl, setCopiedCurl] = useState(false);
+ if (!apiKey) return null;
+
+ const curlCmd = `curl -H "X-API-Key: ${apiKey.key}" /api/v1/dashboard`;
+
+ const copy = async () => {
+ try {
+ await navigator.clipboard.writeText(apiKey.key);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch { /* clipboard indisponible, l'utilisateur peut sélectionner manuellement */ }
+ };
+
+ const copyCurl = async () => {
+ try {
+ await navigator.clipboard.writeText(curlCmd);
+ setCopiedCurl(true);
+ setTimeout(() => setCopiedCurl(false), 2000);
+ } catch { /* clipboard indisponible */ }
+ };
+
+ return (
+
+
+ Copiez cette clé maintenant : elle ne sera plus jamais affichée en entier.
+
+
+ {apiKey.key}
+
+
+
+
+
Tester votre clé
+
+ Le plus simple : ouvrez la documentation interactive (Swagger),
+ cliquez sur « Authorize » et collez la clé ci-dessus, puis testez un endpoint (ex. GET /dashboard).
+
+
+ Ou en ligne de commande (remplacez <URL_DE_VOTRE_API> par l'adresse de votre backend,
+ ex. http://localhost:4000 en dev) :
+
+
+ {curlCmd}
+
+
+
+
+
+
+
+
+ );
+}
+
+function fmtKeyDate(iso) {
+ if (!iso) return '—';
+ return new Date(iso.replace(' ', 'T') + 'Z').toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
+}
+
+/** Modale d'avertissement avant suppression définitive — affichée uniquement
+ * quand la clé est encore active (une clé révoquée ne sert déjà plus à rien,
+ * pas besoin d'avertissement dans ce cas). */
+function DeleteApiKeyModal({ apiKey, onCancel, onConfirm, busy }) {
+ if (!apiKey) return null;
+ return (
+
+
+ La clé « {apiKey.nom} » est encore active. La supprimer coupera immédiatement l'accès
+ à toute application qui l'utilise (API, serveur MCP...), et cette action est irréversible.
+
+
+ Si vous voulez juste bloquer l'accès sans supprimer la clé, préférez « Révoquer ».
+
+ Utilisées pour un accès programmatique en lecture seule (API publique, serveur MCP local...). Vous pouvez créer plusieurs clés, une par usage.
+ {' '}Consultez la documentation de l'API (Swagger) pour la liste des endpoints disponibles.
+