Compare commits
193
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c226c2fcdf | ||
|
|
e1684676a7 | ||
|
|
f71943fa79 | ||
|
|
65f10e9ca6 | ||
|
|
9eb62ddd13 | ||
|
|
b6e83bf9ef | ||
|
|
919148fa75 | ||
|
|
7ba8d51668 | ||
|
|
43dabd1885 | ||
|
|
a4eee819cd | ||
|
|
3fd4812a07 | ||
|
|
6f39813bb4 | ||
|
|
18f3a7f276 | ||
|
|
e6a087147a | ||
|
|
ca88710e49 | ||
|
|
644ba6c9c9 | ||
|
|
15123f691c | ||
|
|
e51e625d93 | ||
|
|
7765350d9a | ||
|
|
e2188fbc87 | ||
|
|
46d0f82f54 | ||
|
|
e887562a1c | ||
|
|
29623f33b9 | ||
|
|
3716dfe611 | ||
|
|
29cbbb1d08 | ||
|
|
dff108c7d5 | ||
|
|
e434fe2fbe | ||
|
|
12296c917e | ||
|
|
a140db8e9d | ||
|
|
2c746ceff3 | ||
|
|
fc86181a73 | ||
|
|
f2daab1325 | ||
|
|
7590c95f06 | ||
|
|
94d9428c43 | ||
|
|
4723c78bbb | ||
|
|
68efa91c39 | ||
|
|
699d163c6a | ||
|
|
25b5e0dd93 | ||
|
|
d91caef0c5 | ||
|
|
7381ce356d | ||
|
|
9acfc31e72 | ||
|
|
d8a81a56ae | ||
|
|
241ce36b85 | ||
|
|
b0594c32e7 | ||
|
|
81ac8860f1 | ||
|
|
8105c01501 | ||
|
|
2fa1e4a981 | ||
|
|
0cc6fa3a9d | ||
|
|
0befd4eb2a | ||
|
|
f442c85cb6 | ||
|
|
ab7845347b | ||
|
|
011ab26828 | ||
|
|
a9ce4a6756 | ||
|
|
d550e57cb8 | ||
|
|
b4b44cb1b9 | ||
|
|
58607cdbc9 | ||
|
|
12bf85b7bd | ||
|
|
01c1be8f16 | ||
|
|
cdae9b5f7c | ||
|
|
73efac482f | ||
|
|
62b2466cce | ||
|
|
d69768806c | ||
|
|
eac108c9f4 | ||
|
|
4dedd9b87a | ||
|
|
518b3f84df | ||
|
|
dc04e04598 | ||
|
|
fdd1e06e77 | ||
|
|
f1c9db2e7b | ||
|
|
ccfcfa3287 | ||
|
|
dbca12d5d1 | ||
|
|
4fe29799cd | ||
|
|
3bfdadd301 | ||
|
|
09289882d0 | ||
|
|
e7f0037ac3 | ||
|
|
4969be5809 | ||
|
|
d7ccbfbe35 | ||
|
|
2b3128ce4a | ||
|
|
077a1ca3ed | ||
|
|
bf05b1d7d7 | ||
|
|
110240b682 | ||
|
|
a723412043 | ||
|
|
1e21fe25f3 | ||
|
|
cd2bf63b8a | ||
|
|
de60a001cc | ||
|
|
481fa66630 | ||
|
|
cde676c4f9 | ||
|
|
0a07fade40 | ||
|
|
0e8a999e41 | ||
|
|
2e02f60601 | ||
|
|
0e6e473e84 | ||
|
|
cc3639b19a | ||
|
|
cf9cd495bf | ||
|
|
76ba28a7ce | ||
|
|
1772744c81 | ||
|
|
5465117238 | ||
|
|
6e2343087e | ||
|
|
f6fabc521e | ||
|
|
5390276ecd | ||
|
|
7e9306de01 | ||
|
|
d832559027 | ||
|
|
54ad1d2aa1 | ||
|
|
060e610a75 | ||
|
|
86b28abe51 | ||
|
|
7e32eef0a7 | ||
|
|
86d8189038 | ||
|
|
aa4e240ad1 | ||
|
|
dbcb3611d4 | ||
|
|
a92447aaf0 | ||
|
|
1fa70f4052 | ||
|
|
94c8a0d97a | ||
|
|
393a527c37 | ||
|
|
af489f39b4 | ||
|
|
dfd58d9b6c | ||
|
|
34a36b069e | ||
|
|
aefe590d2c | ||
|
|
2fa546e6b7 | ||
|
|
8636b16659 | ||
|
|
7e17e5ff8d | ||
|
|
f749484731 | ||
|
|
e8b6d906e6 | ||
|
|
ae0be04964 | ||
|
|
ca98821b3e | ||
|
|
447f3d4137 | ||
|
|
721f40599b | ||
|
|
a9c6b9e15b | ||
|
|
38c003ef6f | ||
|
|
3dbddbb8c4 | ||
|
|
f46740c6ab | ||
|
|
85bfef7a6b | ||
|
|
b1a80f85c9 | ||
|
|
3c2ecdff7a | ||
|
|
8b83702bd2 | ||
|
|
19b8be684f | ||
|
|
e713c05da1 | ||
|
|
5950d85876 | ||
|
|
4339e1e53d | ||
|
|
51d279e341 | ||
|
|
fffe8cd202 | ||
|
|
defa438edf | ||
|
|
e990d576cf | ||
|
|
619e39219f | ||
|
|
e8c6665a06 | ||
|
|
a4e6cfc50e | ||
|
|
6749f2025a | ||
|
|
119edbcfb4 | ||
|
|
80d69a5463 | ||
|
|
0579fda553 | ||
|
|
d14550a1cf | ||
|
|
2645cf1cd6 | ||
|
|
e2ebc6a0a1 | ||
|
|
090ce6e13b | ||
|
|
d66bdd04be | ||
|
|
d8572e7fd6 | ||
|
|
33cc7a9191 | ||
|
|
222d7c702f | ||
|
|
537c46127f | ||
|
|
10ebc77ba1 | ||
|
|
ed18dcab10 | ||
|
|
f9477d3fbe | ||
|
|
bb92f010bd | ||
|
|
42bb872c41 | ||
|
|
4d37131301 | ||
|
|
fac3ae9baa | ||
|
|
04b910295c | ||
|
|
5c28981ac5 | ||
|
|
57ce5af0f4 | ||
|
|
c136f28f12 | ||
|
|
c1204a3050 | ||
|
|
9d4363b2a7 | ||
|
|
4b176b7083 | ||
|
|
af06ab1e66 | ||
|
|
aa148354ec | ||
|
|
00c42c7bee | ||
|
|
a10dc5a195 | ||
|
|
04c0b05aae | ||
|
|
d0b730c8ab | ||
|
|
bc8362bdb7 | ||
|
|
ac3178903d | ||
|
|
aec1990ec9 | ||
|
|
5da2ab9005 | ||
|
|
b2d6414fab | ||
|
|
fbafef8f2c | ||
|
|
42c569e491 | ||
|
|
135c7c2255 | ||
|
|
9cce326046 | ||
|
|
d697083f54 | ||
|
|
ae786426fd | ||
|
|
d32d956b0e | ||
|
|
e4f7a35f0f | ||
|
|
8a6768b316 | ||
|
|
3892a8beab | ||
|
|
d39bc55be3 | ||
|
|
e0debf0394 |
@@ -12,6 +12,7 @@ dist/
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.cursor/
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
@@ -32,6 +33,7 @@ yarn-error.log*
|
||||
*.sqlite3
|
||||
|
||||
# Flutter
|
||||
**/android/local.properties
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
|
||||
@@ -22,5 +22,13 @@ JWT_EXPIRATION_TIME=7d
|
||||
# Environnement
|
||||
NODE_ENV=development
|
||||
|
||||
# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU.
|
||||
# Local : laisser vide → ./uploads/photos (relatif au cwd du processus).
|
||||
# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml).
|
||||
# UPLOAD_PHOTOS_DIR=
|
||||
#
|
||||
# Reverse proxy : si Nginx devant l’API, augmenter la taille du corps (ex. client_max_body_size 16m;).
|
||||
# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+.
|
||||
|
||||
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
|
||||
# LOG_API_REQUESTS=true
|
||||
|
||||
+3
-2
@@ -32,8 +32,9 @@ COPY --from=builder /app/dist ./dist
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S nestjs -u 1001
|
||||
|
||||
# Créer le dossier uploads et donner les permissions
|
||||
RUN mkdir -p /app/uploads/photos && chown -R nestjs:nodejs /app/uploads
|
||||
# Dossiers écriture runtime (nestjs non-root) : photos + documents légaux (PDF)
|
||||
RUN mkdir -p /app/uploads/photos /app/documents/legaux && \
|
||||
chown -R nestjs:nodejs /app/uploads /app/documents
|
||||
|
||||
USER nestjs
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "**/*.hbs",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+179
-20
@@ -22,8 +22,11 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"joi": "^18.0.0",
|
||||
"mapped-types": "^0.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -39,8 +42,11 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
@@ -2462,6 +2468,82 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz",
|
||||
@@ -3613,6 +3695,17 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/handlebars": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.1.0.tgz",
|
||||
"integrity": "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==",
|
||||
"deprecated": "This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"handlebars": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@@ -3688,6 +3781,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
|
||||
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mysql": {
|
||||
"version": "2.15.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz",
|
||||
@@ -3706,6 +3809,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||
"integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
@@ -5578,20 +5691,56 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
"node >= 0.8"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"readable-stream": "^2.2.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
@@ -5658,7 +5807,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
@@ -7004,7 +7152,6 @@
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
@@ -7026,7 +7173,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8889,21 +9035,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
@@ -8995,7 +9142,6 @@
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
@@ -9049,6 +9195,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@@ -9706,6 +9861,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@@ -11534,7 +11695,6 @@
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
@@ -12013,7 +12173,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"test:api-dossiers": "node scripts/test-api-dossiers.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.6",
|
||||
@@ -35,6 +36,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"joi": "^18.0.0",
|
||||
"mapped-types": "^0.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
@@ -54,6 +56,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.7",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Commentaire de clôture + fermeture issue Gitea #120.
|
||||
* Usage: node backend/scripts/close-gitea-issue-120.js
|
||||
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
const ISSUE = 120;
|
||||
const REPO = 'jmartin/petitspas';
|
||||
|
||||
const body = `## Fermeture ticket #120 — livré sur \`develop\`
|
||||
|
||||
Branche **\`feature/120-inscription-am-photo\`** mergée dans **\`develop\`** (livraison : inscription AM alignée parents + panneau validation gestionnaire).
|
||||
|
||||
### Inscription AM (alignement parents)
|
||||
- Photo, consentement, lieux de naissance : parcours et API alignés sur le modèle parents (DTO, entité user, migration SQL, écrans inscription AM, \`registration_photo_slot\`, scripts de test Node).
|
||||
|
||||
### Panneau gestionnaire — onglet « À valider »
|
||||
- Bouton **Ouvrir** : visible au **survol** (même principe que les cartes admin), **icône centrée** sur la ligne et **taille doublée** (\`iconSize\` 34).
|
||||
|
||||
### Wizard validation dossier AM
|
||||
- Titres : **Identité et coordonnées** · **Dossier professionnel** (au-dessus des champs à droite, pas de la photo) · **Présentation**.
|
||||
- **Photo** à gauche (ratio identité 35×45) ; **grille droite** \`[2,2,2,2]\` : NIR | date de naissance, ville | pays de naissance, n° agrément | date d'agrément, capacité | places.
|
||||
- **\`AppUser\`** : \`date_naissance\`, \`lieu_naissance_ville\`, \`lieu_naissance_pays\` ; affichage dates **\`dd/MM/yyyy\`** (\`formatIsoDateFr\`).
|
||||
- **\`ValidationDetailSection\`** : titre **optionnel** (wizard AM).
|
||||
|
||||
### Wizard validation famille
|
||||
- Étape 4 : titre **« Présentation »** (plus « Présentation / Motivation »).
|
||||
|
||||
### Nettoyage
|
||||
- Suppression des **\`debugPrint\`** liés aux médias / images / chargement dossier (\`api_config\`, \`auth_network_image\`, \`dossier_unifie\`, \`user_service\`, carte enfant validation).
|
||||
|
||||
---
|
||||
*Issue fermée après merge sur \`develop\` et recette.*`;
|
||||
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function request(method, apiPath, payloadObj) {
|
||||
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: `/api/v1/repos/${REPO}${apiPath}`,
|
||||
method,
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
|
||||
},
|
||||
};
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200 && res.statusCode !== 201) {
|
||||
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(d ? JSON.parse(d) : {});
|
||||
} catch (_) {
|
||||
resolve({});
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
console.log(`POST commentaire issue #${ISSUE}...`);
|
||||
await request('POST', `/issues/${ISSUE}/comments`, { body });
|
||||
console.log('Commentaire publié.');
|
||||
console.log(`PATCH fermeture issue #${ISSUE}...`);
|
||||
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
|
||||
console.log(`Issue #${ISSUE} fermée.`);
|
||||
} catch (e) {
|
||||
console.error(e.message || e);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Crée l'issue Gitea "[Frontend] Inscription Parent – Branchement soumission formulaire à l'API"
|
||||
* Usage: node backend/scripts/create-gitea-issue-parent-api.js
|
||||
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon
|
||||
* docs/27_BRIEFING-FRONTEND.md (voir docs/26_GITEA-API.md)
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) {
|
||||
token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error(
|
||||
'Token non trouvé : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/26_GITEA-API.md)',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Description
|
||||
|
||||
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale puis redirige vers le login.
|
||||
|
||||
**Estimation** : 4h | **Labels** : frontend, p3, auth, cdc
|
||||
|
||||
## Tâches
|
||||
|
||||
- [ ] Créer un service ou méthode (ex. AuthService.registerParent) appelant POST /api/v1/auth/register/parent
|
||||
- [ ] Construire le body (DTO) à partir de UserRegistrationData (parent1, parent2, children, motivationText, CGU) en cohérence avec le backend (#18)
|
||||
- [ ] Dans ParentRegisterStep5Screen, au clic « Soumettre » : appel API puis modale + redirection ou message d'erreur
|
||||
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
|
||||
|
||||
## Référence
|
||||
|
||||
20_WORKFLOW-CREATION-COMPTE.md § Étape 3 – Inscription d'un parent, backend #18`;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: "[Frontend] Inscription Parent – Branchement soumission formulaire à l'API",
|
||||
body,
|
||||
});
|
||||
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const o = JSON.parse(d);
|
||||
if (o.number) {
|
||||
console.log('NUMBER:', o.number);
|
||||
console.log('URL:', o.html_url);
|
||||
} else {
|
||||
console.error('Erreur API:', o.message || d);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Réponse:', d);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Liste toutes les issues Gitea (ouvertes + fermées) pour jmartin/petitspas.
|
||||
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function get(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = { hostname: 'git.ptits-pas.fr', path, method: 'GET', headers: { Authorization: 'token ' + token } };
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const seen = new Map();
|
||||
for (const state of ['open', 'closed']) {
|
||||
for (let page = 1; ; page++) {
|
||||
const raw = await get('/api/v1/repos/jmartin/petitspas/issues?state=' + state + '&limit=50&page=' + page + '&type=issues');
|
||||
if (raw && raw.message && !Array.isArray(raw)) {
|
||||
console.error('API:', raw.message);
|
||||
process.exit(1);
|
||||
}
|
||||
const list = Array.isArray(raw) ? raw : [];
|
||||
for (const i of list) {
|
||||
if (!i.pull_request) seen.set(i.number, { number: i.number, title: i.title, state: i.state });
|
||||
}
|
||||
if (list.length < 50) break;
|
||||
}
|
||||
}
|
||||
const all = [...seen.values()].sort((a, b) => a.number - b.number);
|
||||
console.log(JSON.stringify(all, null, 2));
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
|
||||
*
|
||||
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/test-api-dossiers.js
|
||||
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
|
||||
*
|
||||
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
|
||||
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
|
||||
|
||||
async function request(method, path, body = null, token = null) {
|
||||
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
};
|
||||
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = text;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Base URL:', BASE_URL);
|
||||
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
|
||||
console.log('');
|
||||
|
||||
let token = null;
|
||||
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||
console.log('1. Login...');
|
||||
const loginRes = await request('POST', '/auth/login', {
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
});
|
||||
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||
process.exit(1);
|
||||
}
|
||||
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||
if (!token) {
|
||||
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK, token reçu.');
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
let numeroDossier = NUMERO_DOSSIER;
|
||||
if (!numeroDossier && token) {
|
||||
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
|
||||
const pendingRes = await request('GET', '/parents/pending-families', null, token);
|
||||
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
|
||||
numeroDossier = pendingRes.data[0].numero_dossier || null;
|
||||
console.log(' Premier numero_dossier:', numeroDossier);
|
||||
} else {
|
||||
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (!numeroDossier) {
|
||||
numeroDossier = '2026-000001';
|
||||
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
|
||||
} else {
|
||||
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
|
||||
}
|
||||
|
||||
const dossierRes = await request(
|
||||
'GET',
|
||||
'/dossiers/' + encodeURIComponent(numeroDossier),
|
||||
null,
|
||||
token
|
||||
);
|
||||
|
||||
console.log(' Status:', dossierRes.status);
|
||||
if (dossierRes.status === 200 && dossierRes.data) {
|
||||
const d = dossierRes.data;
|
||||
console.log(' type:', d.type);
|
||||
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
|
||||
if (d.dossier && Array.isArray(d.dossier.enfants)) {
|
||||
console.log(' enfants:', d.dossier.enfants.length);
|
||||
d.dossier.enfants.forEach((e, i) => {
|
||||
console.log(
|
||||
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
|
||||
);
|
||||
});
|
||||
}
|
||||
console.log('');
|
||||
console.log('Réponse brute (dossier):');
|
||||
console.log(JSON.stringify(d.dossier, null, 2));
|
||||
} else {
|
||||
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Fin du test.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Erreur:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test des endpoints "comptes en attente" (ticket #107).
|
||||
*
|
||||
* Prérequis : backend démarré (npm run start:dev dans backend/).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/test-pending-api.js
|
||||
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
|
||||
*
|
||||
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
|
||||
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
|
||||
const TEST_EMAIL = process.env.TEST_EMAIL;
|
||||
const TEST_PASSWORD = process.env.TEST_PASSWORD;
|
||||
|
||||
async function request(method, path, body = null, token = null) {
|
||||
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
};
|
||||
if (token) opts.headers.Authorization = `Bearer ${token}`;
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(url, opts);
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch (_) {
|
||||
data = text;
|
||||
}
|
||||
return { status: res.status, data };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Base URL:', BASE_URL);
|
||||
console.log('');
|
||||
|
||||
let token = null;
|
||||
if (TEST_EMAIL && TEST_PASSWORD) {
|
||||
console.log('1. Login...');
|
||||
const loginRes = await request('POST', '/auth/login', {
|
||||
email: TEST_EMAIL,
|
||||
password: TEST_PASSWORD,
|
||||
});
|
||||
if (loginRes.status !== 200 && loginRes.status !== 201) {
|
||||
console.log(' Échec login:', loginRes.status, loginRes.data);
|
||||
process.exit(1);
|
||||
}
|
||||
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
|
||||
if (!token) {
|
||||
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(' OK, token reçu.');
|
||||
console.log('');
|
||||
} else {
|
||||
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
|
||||
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('2. GET /users/pending?role=assistante_maternelle');
|
||||
const pendingUsersRes = await request(
|
||||
'GET',
|
||||
'/users/pending?role=assistante_maternelle',
|
||||
null,
|
||||
token
|
||||
);
|
||||
console.log(' Status:', pendingUsersRes.status);
|
||||
if (pendingUsersRes.status === 200) {
|
||||
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
|
||||
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
|
||||
list.forEach((u, i) => {
|
||||
console.log(
|
||||
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log('3. GET /parents/pending-families');
|
||||
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
|
||||
console.log(' Status:', pendingFamiliesRes.status);
|
||||
if (pendingFamiliesRes.status === 200) {
|
||||
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
|
||||
console.log(' Nombre de familles en attente:', list.length);
|
||||
list.forEach((f, i) => {
|
||||
console.log(
|
||||
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
|
||||
);
|
||||
});
|
||||
} else {
|
||||
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Fin du test.');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Erreur:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,27 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Test POST /auth/register/am (ticket #90)
|
||||
# Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql
|
||||
# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
|
||||
# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
|
||||
#
|
||||
# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
|
||||
# Usage: ./scripts/test-register-am.sh [BASE_URL]
|
||||
# Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
|
||||
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
|
||||
# Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1
|
||||
|
||||
BASE_URL="${1:-http://localhost:3000/api/v1}"
|
||||
echo "Testing POST $BASE_URL/auth/register/am"
|
||||
echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)"
|
||||
echo "---"
|
||||
|
||||
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "marie.dupont.test@ptits-pas.fr",
|
||||
"prenom": "Marie",
|
||||
"nom": "DUPONT",
|
||||
"email": "smoke.am.curl@ptits-pas.fr",
|
||||
"prenom": "Smoke",
|
||||
"nom": "CURLTEST",
|
||||
"telephone": "0612345678",
|
||||
"adresse": "1 rue Test",
|
||||
"code_postal": "75001",
|
||||
"ville": "Paris",
|
||||
"consentement_photo": true,
|
||||
"nir": "123456789012345",
|
||||
"numero_agrement": "AGR-2024-001",
|
||||
"capacite_accueil": 4,
|
||||
"adresse": "1 rue Smoke",
|
||||
"code_postal": "95870",
|
||||
"ville": "Bezons",
|
||||
"consentement_photo": false,
|
||||
"date_naissance": "1986-12-15",
|
||||
"nir": "186127500100279",
|
||||
"numero_agrement": "AGR-SMOKE-CURL-001",
|
||||
"capacite_accueil": 3,
|
||||
"places_disponibles": 2,
|
||||
"acceptation_cgu": true,
|
||||
"acceptation_privacy": true
|
||||
}'
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
|
||||
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.js
|
||||
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
let token = process.env.GITEA_TOKEN;
|
||||
if (!token) {
|
||||
try {
|
||||
const tokenFile = path.join(repoRoot, '.gitea-token');
|
||||
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
try {
|
||||
const briefing = fs.readFileSync(
|
||||
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
|
||||
'utf8',
|
||||
);
|
||||
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
|
||||
if (m) token = m[1].trim();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!token) {
|
||||
console.error('Token non trouvé');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Besoin
|
||||
|
||||
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
|
||||
|
||||
**Option A – Endpoint unifié**
|
||||
|
||||
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
|
||||
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
|
||||
- **Réponse** avec discriminent :
|
||||
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
|
||||
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
|
||||
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
|
||||
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
|
||||
|
||||
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
|
||||
|
||||
**Labels suggérés** : backend, api, dossiers, gestionnaire
|
||||
|
||||
---
|
||||
|
||||
## Implémentation
|
||||
|
||||
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
|
||||
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
|
||||
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
|
||||
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
|
||||
body,
|
||||
});
|
||||
|
||||
const opts = {
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues/119',
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: 'token ' + token,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
};
|
||||
|
||||
const req = https.request(opts, (res) => {
|
||||
let d = '';
|
||||
res.on('data', (c) => (d += c));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const o = JSON.parse(d);
|
||||
if (o.number || o.id) {
|
||||
console.log('Issue #119 mise à jour.');
|
||||
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
|
||||
} else {
|
||||
console.error('Erreur API:', o.message || d);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Réponse:', d);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
@@ -16,6 +16,8 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
|
||||
import { EnfantsModule } from './routes/enfants/enfants.module';
|
||||
import { AppConfigModule } from './modules/config/config.module';
|
||||
import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
import { RelaisModule } from './routes/relais/relais.module';
|
||||
import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -53,6 +55,8 @@ import { DocumentsLegauxModule } from './modules/documents-legaux';
|
||||
AuthModule,
|
||||
AppConfigModule,
|
||||
DocumentsLegauxModule,
|
||||
RelaisModule,
|
||||
DossiersModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Utilitaire de validation du NIR (numéro de sécurité sociale français).
|
||||
* - Format 15 caractères (chiffres ou 2A/2B pour la Corse).
|
||||
* - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A→19, 2B→20).
|
||||
* - En cas d'incohérence avec les données (sexe, date, lieu) : warning uniquement, pas de rejet.
|
||||
*/
|
||||
|
||||
const NIR_CORSE_2A = '19';
|
||||
const NIR_CORSE_2B = '20';
|
||||
|
||||
/** Regex 15 caractères : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clé */
|
||||
const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i;
|
||||
|
||||
/**
|
||||
* Convertit le NIR en chaîne de 13 chiffres pour le calcul de la clé (2A→19, 2B→20).
|
||||
*/
|
||||
export function nirTo13Digits(nir: string): string {
|
||||
const n = nir.toUpperCase().replace(/\s/g, '');
|
||||
if (n.length !== 15) return '';
|
||||
const dept = n.slice(5, 7);
|
||||
let deptNum: string;
|
||||
if (dept === '2A') deptNum = NIR_CORSE_2A;
|
||||
else if (dept === '2B') deptNum = NIR_CORSE_2B;
|
||||
else deptNum = dept;
|
||||
return n.slice(0, 5) + deptNum + n.slice(7, 13);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie que le format NIR est valide (15 caractères, 2A/2B acceptés).
|
||||
*/
|
||||
export function isNirFormatValid(nir: string): boolean {
|
||||
if (!nir || typeof nir !== 'string') return false;
|
||||
const n = nir.replace(/\s/g, '').toUpperCase();
|
||||
return NIR_FORMAT.test(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcule la clé de contrôle attendue (97 - (NIR13 mod 97)).
|
||||
* Retourne un nombre entre 1 et 97.
|
||||
*/
|
||||
export function computeNirKey(nir13: string): number {
|
||||
const num = parseInt(nir13, 10);
|
||||
if (Number.isNaN(num) || nir13.length !== 13) return -1;
|
||||
return 97 - (num % 97);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie la clé de contrôle du NIR (15 caractères).
|
||||
* Retourne true si le NIR est valide (format + clé).
|
||||
*/
|
||||
export function isNirKeyValid(nir: string): boolean {
|
||||
const n = nir.replace(/\s/g, '').toUpperCase();
|
||||
if (n.length !== 15) return false;
|
||||
const nir13 = nirTo13Digits(n);
|
||||
if (nir13.length !== 13) return false;
|
||||
const expectedKey = computeNirKey(nir13);
|
||||
const actualKey = parseInt(n.slice(13, 15), 10);
|
||||
return expectedKey === actualKey;
|
||||
}
|
||||
|
||||
export interface NirValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide le NIR (format + clé). En cas d'incohérence avec date de naissance ou sexe, ajoute un warning sans invalider.
|
||||
*/
|
||||
export function validateNir(
|
||||
nir: string,
|
||||
options?: { dateNaissance?: string; genre?: 'H' | 'F' },
|
||||
): NirValidationResult {
|
||||
const n = (nir || '').replace(/\s/g, '').toUpperCase();
|
||||
if (n.length === 0) return { valid: false, error: 'Le NIR est requis' };
|
||||
if (!isNirFormatValid(n)) {
|
||||
return { valid: false, error: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)' };
|
||||
}
|
||||
if (!isNirKeyValid(n)) {
|
||||
return { valid: false, error: 'Clé de contrôle du NIR invalide' };
|
||||
}
|
||||
let warning: string | undefined;
|
||||
if (options?.genre) {
|
||||
const sexNir = n[0];
|
||||
const expectedSex = options.genre === 'F' ? '2' : '1';
|
||||
if (sexNir !== expectedSex) {
|
||||
warning = 'Le NIR ne correspond pas au genre indiqué (position 1 du NIR).';
|
||||
}
|
||||
}
|
||||
if (options?.dateNaissance) {
|
||||
try {
|
||||
const d = new Date(options.dateNaissance);
|
||||
if (!Number.isNaN(d.getTime())) {
|
||||
const year2 = d.getFullYear() % 100;
|
||||
const month = d.getMonth() + 1;
|
||||
const nirYear = parseInt(n.slice(1, 3), 10);
|
||||
const nirMonth = parseInt(n.slice(3, 5), 10);
|
||||
if (nirYear !== year2 || nirMonth !== month) {
|
||||
warning = warning
|
||||
? `${warning} Le NIR ne correspond pas à la date de naissance (positions 2-5).`
|
||||
: 'Le NIR ne correspond pas à la date de naissance indiquée (positions 2-5).';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return { valid: true, warning };
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
|
||||
export default new DataSource({
|
||||
type: 'postgres',
|
||||
host: process.env.DATABASE_HOST,
|
||||
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
|
||||
username: process.env.DATABASE_USERNAME,
|
||||
password: process.env.DATABASE_PASSWORD,
|
||||
database: process.env.DATABASE_NAME,
|
||||
entities: ['src/**/*.entity.ts'],
|
||||
migrations: ['src/migrations/*.ts'],
|
||||
});
|
||||
@@ -48,4 +48,7 @@ export class AssistanteMaternelle {
|
||||
@Column( { name: 'place_disponible', type: 'integer', nullable: true })
|
||||
places_available?: number;
|
||||
|
||||
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
|
||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||
numero_dossier?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Parents } from './parents.entity';
|
||||
import { Children } from './children.entity';
|
||||
import { StatutDossierType } from './dossiers.entity';
|
||||
|
||||
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
|
||||
@Entity('dossier_famille')
|
||||
export class DossierFamille {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'numero_dossier', length: 20 })
|
||||
numero_dossier: string;
|
||||
|
||||
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
|
||||
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
|
||||
parent: Parents;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
presentation?: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: StatutDossierType,
|
||||
enumName: 'statut_dossier_type',
|
||||
default: StatutDossierType.ENVOYE,
|
||||
name: 'statut',
|
||||
})
|
||||
statut: StatutDossierType;
|
||||
|
||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||
cree_le: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||
modifie_le: Date;
|
||||
|
||||
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
|
||||
enfants: DossierFamilleEnfant[];
|
||||
}
|
||||
|
||||
@Entity('dossier_famille_enfants')
|
||||
export class DossierFamilleEnfant {
|
||||
@Column({ name: 'id_dossier_famille', primary: true })
|
||||
id_dossier_famille: string;
|
||||
|
||||
@Column({ name: 'id_enfant', primary: true })
|
||||
id_enfant: string;
|
||||
|
||||
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_dossier_famille' })
|
||||
dossier_famille: DossierFamille;
|
||||
|
||||
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_enfant' })
|
||||
enfant: Children;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
Entity, PrimaryColumn, OneToOne, JoinColumn,
|
||||
Entity, PrimaryColumn, Column, OneToOne, JoinColumn,
|
||||
ManyToOne, OneToMany
|
||||
} from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
@@ -21,6 +21,10 @@ export class Parents {
|
||||
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
|
||||
co_parent?: Users;
|
||||
|
||||
/** Numéro de dossier famille (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
|
||||
@Column({ name: 'numero_dossier', length: 20, nullable: true })
|
||||
numero_dossier?: string;
|
||||
|
||||
// Lien vers enfants via la table enfants_parents
|
||||
@OneToMany(() => ParentsChildren, pc => pc.parent)
|
||||
parentChildren: ParentsChildren[];
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
|
||||
@Entity('relais', { schema: 'public' })
|
||||
export class Relais {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'nom' })
|
||||
nom: string;
|
||||
|
||||
@Column({ name: 'adresse' })
|
||||
adresse: string;
|
||||
|
||||
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
|
||||
horaires_ouverture?: any;
|
||||
|
||||
@Column({ name: 'ligne_fixe', nullable: true })
|
||||
ligne_fixe?: string;
|
||||
|
||||
@Column({ default: true, name: 'actif' })
|
||||
actif: boolean;
|
||||
|
||||
@Column({ type: 'text', name: 'notes', nullable: true })
|
||||
notes?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||
cree_le: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
|
||||
modifie_le: Date;
|
||||
|
||||
@OneToMany(() => Users, user => user.relais)
|
||||
gestionnaires: Users[];
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import {
|
||||
Entity, PrimaryGeneratedColumn, Column,
|
||||
CreateDateColumn, UpdateDateColumn,
|
||||
OneToOne, OneToMany
|
||||
OneToOne, OneToMany, ManyToOne, JoinColumn
|
||||
} from 'typeorm';
|
||||
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||
import { Parents } from './parents.entity';
|
||||
import { Message } from './messages.entity';
|
||||
import { Relais } from './relais.entity';
|
||||
|
||||
// Enums alignés avec la BDD PostgreSQL
|
||||
export enum RoleType {
|
||||
@@ -28,6 +29,7 @@ export enum StatutUtilisateurType {
|
||||
EN_ATTENTE = 'en_attente',
|
||||
ACTIF = 'actif',
|
||||
SUSPENDU = 'suspendu',
|
||||
REFUSE = 'refuse',
|
||||
}
|
||||
|
||||
export enum SituationFamilialeType {
|
||||
@@ -80,7 +82,7 @@ export class Users {
|
||||
type: 'enum',
|
||||
enum: StatutUtilisateurType,
|
||||
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
|
||||
default: StatutUtilisateurType.EN_ATTENTE,
|
||||
default: StatutUtilisateurType.ACTIF,
|
||||
name: 'statut'
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
@@ -117,6 +119,20 @@ export class Users {
|
||||
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
|
||||
token_creation_mdp_expire_le?: Date;
|
||||
|
||||
/** Ticket #127 — réinitialisation mot de passe oublié (distinct de token_creation_mdp / inscription) */
|
||||
@Column({ nullable: true, name: 'password_reset_token', length: 255 })
|
||||
password_reset_token?: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true, name: 'password_reset_expires' })
|
||||
password_reset_expires?: Date;
|
||||
|
||||
/** Token pour reprise après refus (lien email), ticket #110 */
|
||||
@Column({ nullable: true, name: 'token_reprise', length: 255 })
|
||||
token_reprise?: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' })
|
||||
token_reprise_expire_le?: Date;
|
||||
|
||||
@Column({ nullable: true, name: 'ville' })
|
||||
ville?: string;
|
||||
|
||||
@@ -129,6 +145,12 @@ export class Users {
|
||||
@Column({ name: 'date_naissance', type: 'date', nullable: true })
|
||||
date_naissance?: Date;
|
||||
|
||||
@Column({ name: 'lieu_naissance_ville', length: 100, nullable: true })
|
||||
lieu_naissance_ville?: string;
|
||||
|
||||
@Column({ name: 'lieu_naissance_pays', length: 100, nullable: true })
|
||||
lieu_naissance_pays?: string;
|
||||
|
||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||
cree_le: Date;
|
||||
|
||||
@@ -147,4 +169,15 @@ export class Users {
|
||||
|
||||
@OneToMany(() => Parents, parent => parent.co_parent)
|
||||
co_parent_in?: Parents[];
|
||||
|
||||
@Column({ nullable: true, name: 'relais_id' })
|
||||
relaisId?: string;
|
||||
|
||||
/** Numéro de dossier (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
|
||||
@Column({ nullable: true, name: 'numero_dossier', length: 20 })
|
||||
numero_dossier?: string;
|
||||
|
||||
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
|
||||
@JoinColumn({ name: 'relais_id' })
|
||||
relais?: Relais;
|
||||
}
|
||||
|
||||
+67
-6
@@ -1,26 +1,87 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
|
||||
import { DocumentBuilder } from '@nestjs/swagger';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
|
||||
import * as path from 'path';
|
||||
import * as express from 'express';
|
||||
|
||||
/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */
|
||||
function setStaticImageCorsHeaders(res: express.Response): void {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
}
|
||||
|
||||
const staticImageServeOptions = {
|
||||
index: false,
|
||||
fallthrough: true,
|
||||
setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res),
|
||||
};
|
||||
|
||||
/** Préflight si le navigateur interroge OPTIONS sur les médias. */
|
||||
function uploadsCorsPreflight(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): void {
|
||||
if (req.method === 'OPTIONS') {
|
||||
setStaticImageCorsHeaders(res);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
res.status(204).end();
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */
|
||||
function resolveUploadsFilesystemRoot(): string {
|
||||
const raw = process.env.UPLOAD_PHOTOS_DIR?.trim();
|
||||
const photosDir = raw
|
||||
? path.isAbsolute(raw)
|
||||
? raw
|
||||
: path.resolve(process.cwd(), raw)
|
||||
: path.join(process.cwd(), 'uploads', 'photos');
|
||||
return path.dirname(photosDir);
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule,
|
||||
{ logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: ['error', 'warn', 'log', 'debug', 'verbose'],
|
||||
});
|
||||
|
||||
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
|
||||
app.useGlobalInterceptors(new LogRequestInterceptor());
|
||||
// Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué.
|
||||
app.useBodyParser('json', { limit: '15mb' });
|
||||
app.useBodyParser('urlencoded', { extended: true, limit: '15mb' });
|
||||
|
||||
// Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
|
||||
// CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes.
|
||||
app.enableCors({
|
||||
origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
|
||||
origin: true, // Reflète l’Origin (dev / prod) — routes API + cookies JWT
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const expressApp = app.getHttpAdapter().getInstance();
|
||||
const uploadsRoot = resolveUploadsFilesystemRoot();
|
||||
|
||||
// Photos : chemins stockés en base type /uploads/photos/...
|
||||
// En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs).
|
||||
expressApp.use('/uploads', uploadsCorsPreflight);
|
||||
expressApp.use('/api/v1/uploads', uploadsCorsPreflight);
|
||||
|
||||
app.useStaticAssets(uploadsRoot, {
|
||||
prefix: '/uploads/',
|
||||
...staticImageServeOptions,
|
||||
});
|
||||
expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions));
|
||||
|
||||
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
|
||||
app.useGlobalInterceptors(new LogRequestInterceptor());
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
|
||||
@@ -101,8 +101,9 @@ export class DocumentsLegauxController {
|
||||
throw new BadRequestException('Aucun fichier fourni');
|
||||
}
|
||||
|
||||
// TODO: Récupérer l'ID utilisateur depuis le guard
|
||||
const userId = '00000000-0000-0000-0000-000000000000'; // Temporaire
|
||||
// TODO: Récupérer l'ID utilisateur depuis le guard quand l'auth sera branchée.
|
||||
// En attendant, on n'associe pas d'utilisateur plutôt que d'envoyer un UUID fictif invalide.
|
||||
const userId: string | null = null;
|
||||
|
||||
const document = await this.documentsService.uploadNouvelleVersion(
|
||||
uploadDto.type,
|
||||
|
||||
@@ -43,7 +43,7 @@ export class DocumentsLegauxService {
|
||||
async uploadNouvelleVersion(
|
||||
type: 'cgu' | 'privacy',
|
||||
file: Express.Multer.File,
|
||||
userId: string,
|
||||
userId?: string | null,
|
||||
): Promise<DocumentLegal> {
|
||||
// Validation du type de fichier
|
||||
if (file.mimetype !== 'application/pdf') {
|
||||
@@ -84,7 +84,7 @@ export class DocumentsLegauxService {
|
||||
fichier_path: filePath,
|
||||
fichier_hash: hash,
|
||||
actif: false, // Pas actif par défaut
|
||||
televersePar: { id: userId } as any,
|
||||
televersePar: userId ? ({ id: userId } as any) : null,
|
||||
televerseLe: new Date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MailService } from './mail.service';
|
||||
import { AppConfigModule } from '../config/config.module';
|
||||
|
||||
@Module({
|
||||
imports: [AppConfigModule],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MailService, ValidationAccountEmailKind, isTransientSmtpError } from './mail.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
|
||||
describe('isTransientSmtpError', () => {
|
||||
it('détecte les erreurs SMTP temporaires (454, coupure auth, timeout)', () => {
|
||||
expect(isTransientSmtpError(new Error('Invalid login: 454 4.7.0 Temporary authentication failure'))).toBe(true);
|
||||
expect(isTransientSmtpError(new Error('Connection lost to authentication server'))).toBe(true);
|
||||
expect(isTransientSmtpError(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }))).toBe(true);
|
||||
expect(isTransientSmtpError(Object.assign(new Error('auth failed'), { responseCode: 454 }))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignore les erreurs permanentes (mauvaise adresse, refus définitif)', () => {
|
||||
expect(isTransientSmtpError(new Error('Invalid login: 535 Authentication failed'))).toBe(false);
|
||||
expect(isTransientSmtpError(new Error('Mailbox unavailable'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MailService (ticket #28)', () => {
|
||||
let service: MailService;
|
||||
let sendEmailSpy: jest.SpyInstance;
|
||||
|
||||
const mockConfigGet = jest.fn((key: string, defaultValue?: unknown) => {
|
||||
const values: Record<string, unknown> = {
|
||||
app_name: 'TestApp',
|
||||
app_url: 'https://app.test/',
|
||||
smtp_host: '127.0.0.1',
|
||||
smtp_port: 1025,
|
||||
smtp_secure: false,
|
||||
smtp_auth_required: false,
|
||||
smtp_user: '',
|
||||
smtp_password: '',
|
||||
email_from_name: 'Test',
|
||||
email_from_address: 'noreply@test',
|
||||
};
|
||||
if (key in values) return values[key];
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MailService,
|
||||
{
|
||||
provide: AppConfigService,
|
||||
useValue: { get: mockConfigGet },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MailService>(MailService);
|
||||
sendEmailSpy = jest.spyOn(service, 'sendEmail').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sendEmailSpy.mockRestore();
|
||||
});
|
||||
|
||||
it.each<[ValidationAccountEmailKind, string]>([
|
||||
['parent', 'Compte parent validé'],
|
||||
['am', 'Inscription validée'],
|
||||
])('sendValidatedAccountPasswordSetupEmail (%s) utilise Handlebars + lien token', async (kind, subjectPart) => {
|
||||
const token = '11111111-2222-3333-4444-555555555555';
|
||||
await service.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: 'user@test.fr',
|
||||
prenom: 'Jean',
|
||||
nom: 'Dupont',
|
||||
token,
|
||||
numeroDossier: '2025-000001',
|
||||
},
|
||||
kind,
|
||||
);
|
||||
|
||||
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
|
||||
const [, subject, html] = sendEmailSpy.mock.calls[0];
|
||||
expect(subject).toContain('TestApp');
|
||||
expect(subject).toContain(subjectPart);
|
||||
expect(html).toContain('Jean');
|
||||
expect(html).toContain('Dupont');
|
||||
expect(html).toContain('2025-000001');
|
||||
expect(html).toContain(`https://app.test/create-password?token=${encodeURIComponent(token)}`);
|
||||
});
|
||||
|
||||
it('sendPasswordResetEmail utilise Handlebars + lien /reset-password', async () => {
|
||||
const token = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';
|
||||
await service.sendPasswordResetEmail({
|
||||
email: 'u@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'Curie',
|
||||
token,
|
||||
});
|
||||
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
|
||||
const [, subject, html] = sendEmailSpy.mock.calls[0];
|
||||
expect(subject).toContain('Réinitialisation');
|
||||
expect(html).toContain('Marie');
|
||||
expect(html).toContain(`https://app.test/reset-password?token=${encodeURIComponent(token)}`);
|
||||
expect(html).not.toContain('create-password');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
|
||||
/** Ticket #28 — quel template d'email envoyer après validation de compte */
|
||||
export type ValidationAccountEmailKind = 'parent' | 'am';
|
||||
|
||||
type MailTransporter = {
|
||||
sendMail: (options: Record<string, unknown>) => Promise<unknown>;
|
||||
};
|
||||
|
||||
/** Erreurs SMTP temporaires (surcharge auth, coupure réseau…) — retentables. */
|
||||
export function isTransientSmtpError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const code = typeof error === 'object' && error !== null && 'code' in error
|
||||
? String((error as { code?: unknown }).code ?? '')
|
||||
: '';
|
||||
const responseCode =
|
||||
typeof error === 'object' && error !== null && 'responseCode' in error
|
||||
? Number((error as { responseCode?: unknown }).responseCode)
|
||||
: undefined;
|
||||
|
||||
if (responseCode === 454 || responseCode === 421 || responseCode === 450 || responseCode === 451) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const transientCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ESOCKET', 'ECONNREFUSED', 'EPIPE']);
|
||||
if (transientCodes.has(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const transientPatterns = [
|
||||
/temporary authentication failure/i,
|
||||
/connection lost to authentication server/i,
|
||||
/connection reset/i,
|
||||
/timeout/i,
|
||||
/try again later/i,
|
||||
];
|
||||
return transientPatterns.some((pattern) => pattern.test(message));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
private readonly smtpMaxAttempts = 3;
|
||||
private readonly smtpRetryBaseDelayMs = 1000;
|
||||
|
||||
/** Cache des templates Handlebars compilés (fichiers .hbs) */
|
||||
private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
|
||||
|
||||
/** Transporteur SMTP réutilisé (évite une auth TCP/SASL par email). */
|
||||
private transporter: MailTransporter | null = null;
|
||||
private transporterConfigKey: string | null = null;
|
||||
|
||||
constructor(private readonly configService: AppConfigService) {}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
private buildSmtpTransportConfig(): SMTPTransport.Options {
|
||||
const smtpHost = this.configService.get<string>('smtp_host');
|
||||
const smtpPort = this.configService.get<number>('smtp_port');
|
||||
const smtpSecure = this.configService.get<boolean>('smtp_secure');
|
||||
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
|
||||
const smtpUser = this.configService.get<string>('smtp_user');
|
||||
const smtpPassword = this.configService.get<string>('smtp_password');
|
||||
|
||||
const transportConfig = {
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
pool: true,
|
||||
maxConnections: 1,
|
||||
maxMessages: 100,
|
||||
...(smtpAuthRequired && smtpUser && smtpPassword
|
||||
? { auth: { user: smtpUser, pass: smtpPassword } }
|
||||
: {}),
|
||||
} as SMTPTransport.Options;
|
||||
|
||||
return transportConfig;
|
||||
}
|
||||
|
||||
private getSmtpConfigKey(config: SMTPTransport.Options): string {
|
||||
const auth = config.auth as { user?: string; pass?: string } | undefined;
|
||||
return JSON.stringify({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure,
|
||||
user: auth?.user ?? '',
|
||||
pass: auth?.pass ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
private resetTransporter(): void {
|
||||
const current = this.transporter as { close?: () => void } | null;
|
||||
current?.close?.();
|
||||
this.transporter = null;
|
||||
this.transporterConfigKey = null;
|
||||
}
|
||||
|
||||
private async getTransporter(): Promise<MailTransporter> {
|
||||
const transportConfig = this.buildSmtpTransportConfig();
|
||||
const configKey = this.getSmtpConfigKey(transportConfig);
|
||||
|
||||
if (this.transporter && this.transporterConfigKey === configKey) {
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
this.resetTransporter();
|
||||
const nodemailer = await import('nodemailer');
|
||||
this.transporter = nodemailer.createTransport(transportConfig) as MailTransporter;
|
||||
this.transporterConfigKey = configKey;
|
||||
return this.transporter;
|
||||
}
|
||||
|
||||
private templateBasename(kind: ValidationAccountEmailKind): string {
|
||||
const map: Record<ValidationAccountEmailKind, string> = {
|
||||
parent: 'account-validated-parent',
|
||||
am: 'account-validated-am',
|
||||
};
|
||||
return map[kind];
|
||||
}
|
||||
|
||||
private getCompiledTemplate(kind: ValidationAccountEmailKind): Handlebars.TemplateDelegate {
|
||||
let compiled = this.compiledTemplates.get(kind);
|
||||
if (!compiled) {
|
||||
const filePath = join(__dirname, 'templates', `${this.templateBasename(kind)}.hbs`);
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
compiled = Handlebars.compile(source);
|
||||
this.compiledTemplates.set(kind, compiled);
|
||||
}
|
||||
return compiled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Email post-validation : lien création MDP (token existant ou régénéré côté appelant).
|
||||
* Ticket #28 — app_name, app_url, expéditeur via ConfigService (sendEmail).
|
||||
*/
|
||||
async sendValidatedAccountPasswordSetupEmail(
|
||||
recipient: {
|
||||
email: string;
|
||||
prenom: string;
|
||||
nom: string;
|
||||
token: string;
|
||||
numeroDossier?: string | null;
|
||||
},
|
||||
kind: ValidationAccountEmailKind,
|
||||
): Promise<void> {
|
||||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
|
||||
|
||||
const createPasswordUrl = `${appUrl}/create-password?token=${encodeURIComponent(recipient.token)}`;
|
||||
|
||||
const data: Record<string, string> = {
|
||||
prenom: recipient.prenom || '',
|
||||
nom: recipient.nom || '',
|
||||
appName,
|
||||
appUrl,
|
||||
createPasswordUrl,
|
||||
numeroDossier: recipient.numeroDossier || '',
|
||||
};
|
||||
|
||||
const html = this.getCompiledTemplate(kind)(data) as string;
|
||||
|
||||
const subjects: Record<ValidationAccountEmailKind, string> = {
|
||||
parent: `${appName} — Compte parent validé : créez votre mot de passe`,
|
||||
am: `${appName} — Inscription validée : créez votre mot de passe`,
|
||||
};
|
||||
|
||||
await this.sendEmail(recipient.email, subjects[kind], html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoi d'un email générique
|
||||
* @param to Destinataire
|
||||
* @param subject Sujet
|
||||
* @param html Contenu HTML
|
||||
* @param text Contenu texte (optionnel)
|
||||
*/
|
||||
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
|
||||
const emailFromName = this.configService.get<string>('email_from_name');
|
||||
const emailFromAddress = this.configService.get<string>('email_from_address');
|
||||
const mailOptions = {
|
||||
from: `"${emailFromName}" <${emailFromAddress}>`,
|
||||
to,
|
||||
subject,
|
||||
text: text || html.replace(/<[^>]*>?/gm, ''),
|
||||
html,
|
||||
};
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 1; attempt <= this.smtpMaxAttempts; attempt++) {
|
||||
try {
|
||||
const transporter = await this.getTransporter();
|
||||
await transporter.sendMail(mailOptions);
|
||||
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const canRetry = attempt < this.smtpMaxAttempts && isTransientSmtpError(error);
|
||||
|
||||
if (canRetry) {
|
||||
const delayMs = this.smtpRetryBaseDelayMs * 2 ** (attempt - 1);
|
||||
this.logger.warn(
|
||||
`SMTP temporairement indisponible pour ${to} (tentative ${attempt}/${this.smtpMaxAttempts}), nouvel essai dans ${delayMs}ms`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
this.resetTransporter();
|
||||
await this.sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
|
||||
this.resetTransporter();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError);
|
||||
this.resetTransporter();
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoi de l'email de bienvenue pour un gestionnaire
|
||||
* @param to Email du gestionnaire
|
||||
* @param prenom Prénom
|
||||
* @param nom Nom
|
||||
* @param token Token de création de mot de passe (si applicable) ou mot de passe temporaire (si applicable)
|
||||
* @note Pour l'instant, on suppose que le gestionnaire doit définir son mot de passe via "Mot de passe oublié" ou un lien d'activation
|
||||
* Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien.
|
||||
* Le ticket #24 parle de "API Création mot de passe" via token.
|
||||
* Pour le ticket #17, on crée le gestionnaire avec un mot de passe (hashé).
|
||||
* Si on suit le ticket #35 (Frontend), on saisit un mot de passe.
|
||||
* Donc on envoie juste un email de confirmation de création de compte.
|
||||
*/
|
||||
async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise<void> {
|
||||
const appName = this.configService.get<string>('app_name', 'P\'titsPas');
|
||||
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||
|
||||
const subject = `Bienvenue sur ${appName}`;
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bienvenue ${prenom} ${nom} !</h2>
|
||||
<p>Votre compte gestionnaire sur <strong>${appName}</strong> a été créé avec succès.</p>
|
||||
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
|
||||
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
|
||||
</div>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accusé de réception inscription (parent ou assistante maternelle) :
|
||||
* demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade.
|
||||
*/
|
||||
async sendRegistrationPendingEmail(
|
||||
to: string,
|
||||
prenom: string,
|
||||
nom: string,
|
||||
numeroDossier: string,
|
||||
): Promise<void> {
|
||||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
|
||||
|
||||
const safe = (s: string) =>
|
||||
(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`;
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||||
<p>Nous avons bien enregistré votre demande de création de compte sur <strong>${safe(appName)}</strong>.</p>
|
||||
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
|
||||
<p>Votre dossier est <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsqu’il aura été traité.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder au site</a>
|
||||
</div>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Email de refus de dossier avec lien reprise (token).
|
||||
* Ticket #110 – Refus sans suppression
|
||||
*/
|
||||
async sendRefusEmail(
|
||||
to: string,
|
||||
prenom: string,
|
||||
nom: string,
|
||||
comment: string | undefined,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
|
||||
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
|
||||
|
||||
const safe = (s: string) =>
|
||||
(s || '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
const subject = `Votre dossier – compléments demandés`;
|
||||
const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.');
|
||||
const html = `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
|
||||
<p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p>
|
||||
<div style="background-color: #F4FBF5; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 20px 0;">
|
||||
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
|
||||
<p style="margin: 0;">${commentText}</p>
|
||||
</div>
|
||||
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="${repriseLink}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;">
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket #127 — lien application `/reset-password?token=` (pas create-password).
|
||||
*/
|
||||
async sendPasswordResetEmail(recipient: {
|
||||
email: string;
|
||||
prenom: string;
|
||||
nom: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const appName = this.configService.get<string>('app_name', "P'titsPas");
|
||||
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
|
||||
const resetPasswordUrl = `${appUrl}/reset-password?token=${encodeURIComponent(recipient.token)}`;
|
||||
|
||||
const filePath = join(__dirname, 'templates', 'password-reset.hbs');
|
||||
const source = readFileSync(filePath, 'utf8');
|
||||
const compiled = Handlebars.compile(source);
|
||||
const html = compiled({
|
||||
prenom: recipient.prenom || '',
|
||||
nom: recipient.nom || '',
|
||||
appName,
|
||||
resetPasswordUrl,
|
||||
}) as string;
|
||||
|
||||
const subject = `${appName} — Réinitialisation de votre mot de passe`;
|
||||
await this.sendEmail(recipient.email, subject, html);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||
<p>Votre demande d'inscription en tant qu'<strong>assistante maternelle</strong> sur <strong>{{appName}}</strong> a été <strong>validée</strong>.</p>
|
||||
{{#if numeroDossier}}
|
||||
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
|
||||
{{/if}}
|
||||
<p>Pour finaliser l'activation de votre compte, veuillez <strong>créer votre mot de passe</strong> en cliquant sur le bouton ci-dessous.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||
<p>Votre compte parent sur <strong>{{appName}}</strong> a été <strong>validé</strong>.</p>
|
||||
{{#if numeroDossier}}
|
||||
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
|
||||
{{/if}}
|
||||
<p>Pour accéder à l'application, veuillez <strong>définir votre mot de passe</strong> en cliquant sur le bouton ci-dessous.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||
<p>Vous avez demandé à <strong>réinitialiser votre mot de passe</strong> sur <strong>{{appName}}</strong>.</p>
|
||||
<p>Cliquez sur le bouton ci-dessous pour en choisir un nouveau. Ce lien est valable une durée limitée.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{{resetPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Réinitialiser mon mot de passe</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{resetPasswordUrl}}}</span></p>
|
||||
<p style="color: #666; font-size: 12px;">Si vous n'êtes pas à l'origine de cette demande, vous pouvez ignorer cet e-mail.</p>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NumeroDossierService } from './numero-dossier.service';
|
||||
|
||||
@Module({
|
||||
providers: [NumeroDossierService],
|
||||
exports: [NumeroDossierService],
|
||||
})
|
||||
export class NumeroDossierModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
const FORMAT_MAX_SEQUENCE = 990000;
|
||||
|
||||
/**
|
||||
* Service de génération du numéro de dossier (ticket #103).
|
||||
* Format AAAA-NNNNNN (année + 6 chiffres), séquence par année.
|
||||
* Si séquence >= 990000, overflowWarning est true (alerte gestionnaire).
|
||||
*/
|
||||
@Injectable()
|
||||
export class NumeroDossierService {
|
||||
/**
|
||||
* Génère le prochain numéro de dossier dans le cadre d'une transaction.
|
||||
* À appeler avec le manager de la transaction pour garantir l'unicité.
|
||||
*/
|
||||
async getNextNumeroDossier(manager: EntityManager): Promise<{
|
||||
numero: string;
|
||||
overflowWarning: boolean;
|
||||
}> {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// Garantir l'existence de la ligne pour l'année
|
||||
await manager.query(
|
||||
`INSERT INTO numero_dossier_sequence (annee, prochain)
|
||||
VALUES ($1, 1)
|
||||
ON CONFLICT (annee) DO NOTHING`,
|
||||
[year],
|
||||
);
|
||||
|
||||
// Prendre le prochain numéro et incrémenter (FOR UPDATE pour concurrence)
|
||||
const selectRows = await manager.query(
|
||||
`SELECT prochain FROM numero_dossier_sequence WHERE annee = $1 FOR UPDATE`,
|
||||
[year],
|
||||
);
|
||||
const currentVal = selectRows?.[0]?.prochain ?? 1;
|
||||
|
||||
await manager.query(
|
||||
`UPDATE numero_dossier_sequence SET prochain = prochain + 1 WHERE annee = $1`,
|
||||
[year],
|
||||
);
|
||||
|
||||
const nextVal = currentVal;
|
||||
const overflowWarning = nextVal >= FORMAT_MAX_SEQUENCE;
|
||||
if (overflowWarning) {
|
||||
// Log pour alerte gestionnaire (ticket #103)
|
||||
console.warn(
|
||||
`[NumeroDossierService] Séquence année ${year} >= ${FORMAT_MAX_SEQUENCE} (valeur ${nextVal}). Prévoir renouvellement ou format.`,
|
||||
);
|
||||
}
|
||||
|
||||
const numero = `${year}-${String(nextVal).padStart(6, '0')}`;
|
||||
return { numero, overflowWarning };
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
const authServiceMock = {
|
||||
verifyCreatePasswordToken: jest.fn(),
|
||||
createPasswordWithToken: jest.fn(),
|
||||
requestPasswordReset: jest.fn(),
|
||||
resetPasswordWithResetToken: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
{ provide: UserService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
@@ -15,4 +29,82 @@ describe('AuthController', () => {
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('forwards token verification to auth service', async () => {
|
||||
authServiceMock.verifyCreatePasswordToken.mockResolvedValue({
|
||||
valid: true,
|
||||
message: 'Token valide',
|
||||
});
|
||||
|
||||
await expect(controller.verifyCreatePasswordToken('tok-123')).resolves.toEqual({
|
||||
valid: true,
|
||||
message: 'Token valide',
|
||||
});
|
||||
expect(authServiceMock.verifyCreatePasswordToken).toHaveBeenCalledWith('tok-123');
|
||||
});
|
||||
|
||||
it('rejects create-password when confirmation mismatches', async () => {
|
||||
await expect(
|
||||
controller.createPassword({
|
||||
token: 'tok-123',
|
||||
password: 'Password1',
|
||||
password_confirmation: 'Password2',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(authServiceMock.createPasswordWithToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls auth service when create-password payload is valid', async () => {
|
||||
authServiceMock.createPasswordWithToken.mockResolvedValue({
|
||||
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.createPassword({
|
||||
token: 'tok-123',
|
||||
password: 'Password1',
|
||||
password_confirmation: 'Password1',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(authServiceMock.createPasswordWithToken).toHaveBeenCalledWith('tok-123', 'Password1');
|
||||
});
|
||||
|
||||
it('forgot-password délègue au service et renvoie le message générique', async () => {
|
||||
authServiceMock.requestPasswordReset.mockResolvedValue({
|
||||
message: 'Si cette adresse est associée…',
|
||||
});
|
||||
await expect(controller.forgotPassword({ email: 'user@test.fr' })).resolves.toEqual({
|
||||
message: 'Si cette adresse est associée…',
|
||||
});
|
||||
expect(authServiceMock.requestPasswordReset).toHaveBeenCalledWith('user@test.fr');
|
||||
});
|
||||
|
||||
it('rejects reset-password when confirmation mismatches', async () => {
|
||||
await expect(
|
||||
controller.resetPassword({
|
||||
token: 'tok',
|
||||
password: 'Password1',
|
||||
password_confirmation: 'Password2',
|
||||
}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
expect(authServiceMock.resetPasswordWithResetToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls reset-password when payload is valid', async () => {
|
||||
authServiceMock.resetPasswordWithResetToken.mockResolvedValue({
|
||||
message: 'Mot de passe mis à jour.',
|
||||
});
|
||||
await expect(
|
||||
controller.resetPassword({
|
||||
token: 'tok-123',
|
||||
password: 'Password1',
|
||||
password_confirmation: 'Password1',
|
||||
}),
|
||||
).resolves.toEqual({ message: 'Mot de passe mis à jour.' });
|
||||
expect(authServiceMock.resetPasswordWithResetToken).toHaveBeenCalledWith('tok-123', 'Password1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
import { Body, Controller, Get, Post, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from './auth.service';
|
||||
import { Public } from 'src/common/decorators/public.decorator';
|
||||
import { RegisterDto } from './dto/register.dto';
|
||||
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
|
||||
import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
|
||||
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreatePasswordDto } from './dto/create-password.dto';
|
||||
import { ForgotPasswordDto } from './dto/forgot-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import type { Request } from 'express';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { ProfileResponseDto } from './dto/profile_response.dto';
|
||||
import { RefreshTokenDto } from './dto/refresh_token.dto';
|
||||
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
|
||||
import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||
|
||||
@ApiTags('Authentification')
|
||||
@Controller('auth')
|
||||
@@ -56,15 +76,45 @@ export class AuthController {
|
||||
@Post('register/am')
|
||||
@ApiOperation({
|
||||
summary: 'Inscription Assistante Maternelle COMPLÈTE',
|
||||
description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
|
||||
description:
|
||||
'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
|
||||
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation', type: RegisterAmResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
|
||||
@ApiResponse({ status: 409, description: 'Email déjà utilisé' })
|
||||
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
|
||||
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> {
|
||||
return this.authService.inscrireAMComplet(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('reprise-dossier')
|
||||
@ApiOperation({ summary: 'Dossier pour reprise (token seul)' })
|
||||
@ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' })
|
||||
@ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto })
|
||||
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
|
||||
async getRepriseDossier(@Query('token') token: string): Promise<RepriseDossierDto> {
|
||||
return this.authService.getRepriseDossier(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Patch('reprise-resoumettre')
|
||||
@ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' })
|
||||
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
|
||||
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
|
||||
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
|
||||
const { token, ...fields } = dto;
|
||||
return this.authService.resoumettreReprise(token, fields);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reprise-identify')
|
||||
@ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' })
|
||||
@ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' })
|
||||
@ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' })
|
||||
async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) {
|
||||
return this.authService.identifyReprise(dto.numero_dossier, dto.email);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
@ApiBearerAuth('refresh_token')
|
||||
@@ -75,6 +125,59 @@ export class AuthController {
|
||||
return this.authService.refreshTokens(dto.refresh_token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('verify-token')
|
||||
@ApiOperation({ summary: 'Vérifier un token de création de mot de passe' })
|
||||
@ApiQuery({ name: 'token', required: true, description: 'Token UUID reçu par email' })
|
||||
@ApiResponse({ status: 200, description: 'Token valide' })
|
||||
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
|
||||
async verifyCreatePasswordToken(@Query('token') token: string) {
|
||||
return this.authService.verifyCreatePasswordToken(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('create-password')
|
||||
@ApiOperation({ summary: 'Créer le mot de passe initial via token email' })
|
||||
@ApiResponse({ status: 201, description: 'Mot de passe créé avec succès' })
|
||||
@ApiResponse({ status: 400, description: 'Confirmation invalide ou mot de passe invalide' })
|
||||
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
|
||||
async createPassword(@Body() dto: CreatePasswordDto) {
|
||||
if (dto.password !== dto.password_confirmation) {
|
||||
throw new BadRequestException('Les mots de passe ne correspondent pas');
|
||||
}
|
||||
return this.authService.createPasswordWithToken(dto.token, dto.password);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('forgot-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Demande de réinitialisation du mot de passe (ticket #127)',
|
||||
description:
|
||||
'Réponse identique que l’e-mail existe ou non (anti-énumération). Si un compte avec mot de passe existe, un e-mail avec lien /reset-password est envoyé.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Message générique' })
|
||||
async forgotPassword(@Body() dto: ForgotPasswordDto) {
|
||||
return this.authService.requestPasswordReset(dto.email ?? '');
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: 'Réinitialiser le mot de passe via token e-mail (ticket #127)',
|
||||
description: 'Distinct de POST /auth/create-password (inscription). Utilise password_reset_token.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Mot de passe mis à jour' })
|
||||
@ApiResponse({ status: 400, description: 'Confirmation ou règles de mot de passe' })
|
||||
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
|
||||
async resetPassword(@Body() dto: ResetPasswordDto) {
|
||||
if (dto.password !== dto.password_confirmation) {
|
||||
throw new BadRequestException('Les mots de passe ne correspondent pas');
|
||||
}
|
||||
return this.authService.resetPasswordWithResetToken(dto.token, dto.password);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(AuthGuard)
|
||||
@ApiBearerAuth('access-token')
|
||||
|
||||
@@ -10,12 +10,16 @@ import { Parents } from 'src/entities/parents.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AppConfigModule } from 'src/modules/config';
|
||||
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||
forwardRef(() => UserModule),
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
NumeroDossierModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -1,12 +1,61 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
import { MailService } from 'src/modules/mail/mail.service';
|
||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
|
||||
describe('AuthService', () => {
|
||||
describe('AuthService (#118 create-password API)', () => {
|
||||
let service: AuthService;
|
||||
const usersRepoMock = {
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
|
||||
const mailServiceMock = {
|
||||
sendPasswordResetEmail: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const appConfigMock = {
|
||||
get: jest.fn().mockReturnValue(7),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const qbChain: any = {};
|
||||
qbChain.where = jest.fn().mockReturnValue(qbChain);
|
||||
qbChain.select = jest.fn().mockReturnValue(qbChain);
|
||||
qbChain.getOne = jest.fn().mockResolvedValue(null);
|
||||
qbChain.update = jest.fn().mockReturnValue(qbChain);
|
||||
qbChain.set = jest.fn().mockReturnValue(qbChain);
|
||||
qbChain.andWhere = jest.fn().mockReturnValue(qbChain);
|
||||
qbChain.execute = jest.fn().mockResolvedValue({ affected: 0 });
|
||||
usersRepoMock.createQueryBuilder.mockReturnValue(qbChain);
|
||||
usersRepoMock.update.mockResolvedValue({ affected: 1, raw: [] });
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [AuthService],
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: UserService, useValue: {} },
|
||||
{ provide: JwtService, useValue: {} },
|
||||
{ provide: ConfigService, useValue: { get: jest.fn() } },
|
||||
{ provide: AppConfigService, useValue: appConfigMock },
|
||||
{ provide: MailService, useValue: mailServiceMock },
|
||||
{ provide: NumeroDossierService, useValue: {} },
|
||||
{ provide: getRepositoryToken(Parents), useValue: {} },
|
||||
{ provide: getRepositoryToken(Users), useValue: usersRepoMock },
|
||||
{ provide: getRepositoryToken(Children), useValue: {} },
|
||||
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
@@ -15,4 +64,76 @@ describe('AuthService', () => {
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns 404-like error when verify token is missing', async () => {
|
||||
await expect(service.verifyCreatePasswordToken('')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('returns valid=true when token exists and is not expired', async () => {
|
||||
usersRepoMock.findOne.mockResolvedValue({
|
||||
id: 'u1',
|
||||
password: null,
|
||||
token_creation_mdp_expire_le: new Date(Date.now() + 60_000),
|
||||
});
|
||||
|
||||
await expect(service.verifyCreatePasswordToken('tok-123')).resolves.toEqual({
|
||||
valid: true,
|
||||
message: 'Token valide',
|
||||
});
|
||||
});
|
||||
|
||||
describe('#127 forgot / reset password', () => {
|
||||
it('requestPasswordReset returns generic message when email unknown', async () => {
|
||||
const r = await service.requestPasswordReset('unknown@test.fr');
|
||||
expect(r.message).toContain('Si cette adresse');
|
||||
expect(mailServiceMock.sendPasswordResetEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requestPasswordReset sends mail when user has password', async () => {
|
||||
usersRepoMock.createQueryBuilder.mockReturnValueOnce({
|
||||
where: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
getOne: jest.fn().mockResolvedValue({
|
||||
id: 'u1',
|
||||
email: 'a@test.fr',
|
||||
prenom: 'A',
|
||||
nom: 'B',
|
||||
password: 'hashed',
|
||||
}),
|
||||
});
|
||||
const r = await service.requestPasswordReset('a@test.fr');
|
||||
expect(r.message).toContain('Si cette adresse');
|
||||
expect(usersRepoMock.update).toHaveBeenCalled();
|
||||
expect(mailServiceMock.sendPasswordResetEmail).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ email: 'a@test.fr', token: expect.any(String) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resetPasswordWithResetToken throws when token unknown', async () => {
|
||||
usersRepoMock.findOne.mockResolvedValue(null);
|
||||
await expect(service.resetPasswordWithResetToken('bad', 'Password1')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('resetPasswordWithResetToken succeeds when update affects row', async () => {
|
||||
usersRepoMock.findOne.mockResolvedValue({
|
||||
id: 'u1',
|
||||
password: 'oldhash',
|
||||
password_reset_expires: new Date(Date.now() + 60_000),
|
||||
});
|
||||
const exec = jest.fn().mockResolvedValue({ affected: 1 });
|
||||
const chain: any = {};
|
||||
chain.update = jest.fn().mockReturnValue(chain);
|
||||
chain.set = jest.fn().mockReturnValue(chain);
|
||||
chain.where = jest.fn().mockReturnValue(chain);
|
||||
chain.andWhere = jest.fn().mockReturnValue(chain);
|
||||
chain.execute = exec;
|
||||
usersRepoMock.createQueryBuilder.mockReturnValueOnce(chain);
|
||||
|
||||
const r = await service.resetPasswordWithResetToken('good-token', 'Password1');
|
||||
expect(r.message.toLowerCase()).toContain('mis à jour');
|
||||
expect(exec).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { QueryFailedError, Repository } from 'typeorm';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
@@ -21,22 +23,35 @@ import { Parents } from 'src/entities/parents.entity';
|
||||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { StatutDossierType } from 'src/entities/dossiers.entity';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
|
||||
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
|
||||
import { AppConfigService } from 'src/modules/config/config.service';
|
||||
import { validateNir } from 'src/common/utils/nir.util';
|
||||
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
|
||||
import { MailService } from 'src/modules/mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UserService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly appConfigService: AppConfigService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly numeroDossierService: NumeroDossierService,
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepo: Repository<Parents>,
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepo: Repository<Users>,
|
||||
@InjectRepository(Children)
|
||||
private readonly childrenRepo: Repository<Children>,
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -93,6 +108,12 @@ export class AuthService {
|
||||
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
|
||||
}
|
||||
|
||||
if (user.statut === StatutUtilisateurType.REFUSE) {
|
||||
throw new UnauthorizedException(
|
||||
'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.',
|
||||
);
|
||||
}
|
||||
|
||||
return this.generateTokens(user.id, user.email, user.role);
|
||||
}
|
||||
|
||||
@@ -116,6 +137,236 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCreatePasswordToken(token: string) {
|
||||
const cleanedToken = token?.trim();
|
||||
if (!cleanedToken) {
|
||||
// #118: côté front, un token absent doit être traité comme lien invalide/expiré.
|
||||
// On renvoie donc la même réponse neutre 404 que pour les autres cas invalides.
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const user = await this.usersRepo.findOne({
|
||||
where: { token_creation_mdp: cleanedToken },
|
||||
select: ['id', 'token_creation_mdp_expire_le', 'password'],
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
if (user.password) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
if (!user.token_creation_mdp_expire_le || user.token_creation_mdp_expire_le <= new Date()) {
|
||||
// Invalidation explicite d'un token expiré pour éviter toute réutilisation ultérieure.
|
||||
await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
token_creation_mdp: () => 'NULL',
|
||||
token_creation_mdp_expire_le: () => 'NULL',
|
||||
})
|
||||
.where('id = :id', { id: user.id })
|
||||
.execute();
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
message: 'Token valide',
|
||||
};
|
||||
}
|
||||
|
||||
async createPasswordWithToken(token: string, plainPassword: string) {
|
||||
const cleanedToken = token?.trim();
|
||||
if (!cleanedToken) {
|
||||
throw new BadRequestException('Token manquant');
|
||||
}
|
||||
|
||||
const existingUser = await this.usersRepo.findOne({
|
||||
where: {
|
||||
token_creation_mdp: cleanedToken,
|
||||
},
|
||||
select: ['id', 'password', 'token_creation_mdp_expire_le'],
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
if (existingUser.password) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
if (
|
||||
!existingUser.token_creation_mdp_expire_le ||
|
||||
existingUser.token_creation_mdp_expire_le <= new Date()
|
||||
) {
|
||||
await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
token_creation_mdp: () => 'NULL',
|
||||
token_creation_mdp_expire_le: () => 'NULL',
|
||||
})
|
||||
.where('id = :id', { id: existingUser.id })
|
||||
.execute();
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const sel = await bcrypt.genSalt(12);
|
||||
const hashedPassword = await bcrypt.hash(plainPassword, sel);
|
||||
|
||||
// Usage unique atomique : une seule requête UPDATE avec garde-fous.
|
||||
const updateResult = await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
token_creation_mdp: () => 'NULL',
|
||||
token_creation_mdp_expire_le: () => 'NULL',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
changement_mdp_obligatoire: false,
|
||||
})
|
||||
.where('token_creation_mdp = :token', { token: cleanedToken })
|
||||
.andWhere('token_creation_mdp_expire_le > NOW()')
|
||||
.andWhere('password IS NULL')
|
||||
.execute();
|
||||
|
||||
if (!updateResult.affected) {
|
||||
this.logger.warn(
|
||||
'[createPasswordWithToken] Token non consommé (course concurrente ou token invalidé)',
|
||||
);
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
this.logger.log('[createPasswordWithToken] Mot de passe initial créé avec succès');
|
||||
|
||||
return {
|
||||
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
|
||||
userId: existingUser.id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */
|
||||
private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE =
|
||||
'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.';
|
||||
|
||||
/**
|
||||
* Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password).
|
||||
* Toujours la même réponse 200 ; n’utilise pas token_creation_mdp (#118 inscription).
|
||||
*/
|
||||
async requestPasswordReset(rawEmail: string): Promise<{ message: string }> {
|
||||
const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE };
|
||||
const email = (rawEmail ?? '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
return generic;
|
||||
}
|
||||
|
||||
const user = await this.usersRepo
|
||||
.createQueryBuilder('u')
|
||||
.where('LOWER(TRIM(u.email)) = :email', { email })
|
||||
.select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password'])
|
||||
.getOne();
|
||||
|
||||
if (!user?.password) {
|
||||
return generic;
|
||||
}
|
||||
|
||||
const expiryDaysRaw = this.appConfigService.get<number>('password_reset_token_expiry_days', 7);
|
||||
const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7;
|
||||
const expires = new Date();
|
||||
expires.setDate(expires.getDate() + expiryDays);
|
||||
|
||||
const token = crypto.randomUUID();
|
||||
|
||||
await this.usersRepo.update(
|
||||
{ id: user.id },
|
||||
{
|
||||
password_reset_token: token,
|
||||
password_reset_expires: expires,
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await this.mailService.sendPasswordResetEmail({
|
||||
email: user.email,
|
||||
prenom: user.prenom ?? '',
|
||||
nom: user.nom ?? '',
|
||||
token,
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)',
|
||||
err,
|
||||
);
|
||||
}
|
||||
|
||||
return generic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp).
|
||||
*/
|
||||
async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> {
|
||||
const cleanedToken = token?.trim();
|
||||
if (!cleanedToken) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const existingUser = await this.usersRepo.findOne({
|
||||
where: { password_reset_token: cleanedToken },
|
||||
select: ['id', 'password', 'password_reset_expires'],
|
||||
});
|
||||
|
||||
if (!existingUser?.password) {
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) {
|
||||
await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
password_reset_token: () => 'NULL',
|
||||
password_reset_expires: () => 'NULL',
|
||||
})
|
||||
.where('id = :id', { id: existingUser.id })
|
||||
.execute();
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
const sel = await bcrypt.genSalt(12);
|
||||
const hashedPassword = await bcrypt.hash(plainPassword, sel);
|
||||
|
||||
const updateResult = await this.usersRepo
|
||||
.createQueryBuilder()
|
||||
.update(Users)
|
||||
.set({
|
||||
password: hashedPassword,
|
||||
password_reset_token: () => 'NULL',
|
||||
password_reset_expires: () => 'NULL',
|
||||
changement_mdp_obligatoire: false,
|
||||
})
|
||||
.where('password_reset_token = :token', { token: cleanedToken })
|
||||
.andWhere('password_reset_expires > NOW()')
|
||||
.execute();
|
||||
|
||||
if (!updateResult.affected) {
|
||||
this.logger.warn(
|
||||
'[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)',
|
||||
);
|
||||
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
|
||||
}
|
||||
|
||||
this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès');
|
||||
|
||||
return {
|
||||
message: 'Mot de passe mis à jour. Vous pouvez vous connecter.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
|
||||
* @deprecated
|
||||
@@ -177,6 +428,11 @@ export class AuthService {
|
||||
}
|
||||
|
||||
if (dto.co_parent_email) {
|
||||
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
|
||||
throw new BadRequestException(
|
||||
'L\'email du parent et du co-parent doivent être différents.',
|
||||
);
|
||||
}
|
||||
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
|
||||
if (coParentExiste) {
|
||||
throw new ConflictException('L\'email du co-parent est déjà utilisé');
|
||||
@@ -192,7 +448,18 @@ export class AuthService {
|
||||
const dateExpiration = new Date();
|
||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||
|
||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
let resultat: {
|
||||
parent1: Users;
|
||||
parent2: Users | null;
|
||||
enfants: Children[];
|
||||
tokenCreationMdp: string;
|
||||
tokenCoParent: string | null;
|
||||
};
|
||||
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const parent1 = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
@@ -205,6 +472,7 @@ export class AuthService {
|
||||
ville: dto.ville,
|
||||
token_creation_mdp: tokenCreationMdp,
|
||||
token_creation_mdp_expire_le: dateExpiration,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
|
||||
const parent1Enregistre = await manager.save(Users, parent1);
|
||||
@@ -229,6 +497,7 @@ export class AuthService {
|
||||
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
|
||||
token_creation_mdp: tokenCoParent,
|
||||
token_creation_mdp_expire_le: dateExpirationCoParent,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
|
||||
parent2Enregistre = await manager.save(Users, parent2);
|
||||
@@ -236,6 +505,7 @@ export class AuthService {
|
||||
|
||||
const entiteParent = manager.create(Parents, {
|
||||
user_id: parent1Enregistre.id,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
entiteParent.user = parent1Enregistre;
|
||||
if (parent2Enregistre) {
|
||||
@@ -247,6 +517,7 @@ export class AuthService {
|
||||
if (parent2Enregistre) {
|
||||
const entiteCoParent = manager.create(Parents, {
|
||||
user_id: parent2Enregistre.id,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
entiteCoParent.user = parent2Enregistre;
|
||||
entiteCoParent.co_parent = parent1Enregistre;
|
||||
@@ -296,6 +567,25 @@ export class AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119)
|
||||
const presentationTrim = dto.presentation_dossier?.trim();
|
||||
const dossierFamilleEnt = manager.create(DossierFamille, {
|
||||
numero_dossier: numeroDossier,
|
||||
presentation: presentationTrim || undefined,
|
||||
statut: StatutDossierType.ENVOYE,
|
||||
parent: entiteParent,
|
||||
});
|
||||
const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt);
|
||||
for (const enfantEnregistre of enfantsEnregistres) {
|
||||
await manager.save(
|
||||
DossierFamilleEnfant,
|
||||
manager.create(DossierFamilleEnfant, {
|
||||
id_dossier_famille: dossierFamilleSaved.id,
|
||||
id_enfant: enfantEnregistre.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
parent1: parent1Enregistre,
|
||||
parent2: parent2Enregistre,
|
||||
@@ -304,6 +594,38 @@ export class AuthService {
|
||||
tokenCoParent,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException(
|
||||
'Un compte avec cet email existe déjà (contrainte unique en base).',
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const numeroDossier = resultat.parent1.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent1.email,
|
||||
resultat.parent1.prenom ?? '',
|
||||
resultat.parent1.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
if (resultat.parent2) {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.parent2.email,
|
||||
resultat.parent2.prenom ?? '',
|
||||
resultat.parent2.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
@@ -311,6 +633,7 @@ export class AuthService {
|
||||
co_parent_id: resultat.parent2?.id,
|
||||
enfants_ids: resultat.enfants.map(e => e.id),
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -325,11 +648,50 @@ export class AuthService {
|
||||
);
|
||||
}
|
||||
|
||||
if (dto.places_disponibles > dto.capacite_accueil) {
|
||||
throw new BadRequestException(
|
||||
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
|
||||
);
|
||||
}
|
||||
|
||||
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
|
||||
const nirValidation = validateNir(nirNormalized, {
|
||||
dateNaissance: dto.date_naissance,
|
||||
});
|
||||
if (!nirValidation.valid) {
|
||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||
}
|
||||
if (nirValidation.warning) {
|
||||
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
|
||||
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
|
||||
}
|
||||
|
||||
const existe = await this.usersService.findByEmailOrNull(dto.email);
|
||||
if (existe) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà');
|
||||
}
|
||||
|
||||
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||
where: { nir: nirNormalized },
|
||||
});
|
||||
if (nirDejaUtilise) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||
);
|
||||
}
|
||||
|
||||
const numeroAgrement = (dto.numero_agrement || '').trim();
|
||||
if (numeroAgrement) {
|
||||
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
|
||||
where: { approval_number: numeroAgrement },
|
||||
});
|
||||
if (agrementDejaUtilise) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const joursExpirationToken = await this.appConfigService.get<number>(
|
||||
'password_reset_token_expiry_days',
|
||||
7,
|
||||
@@ -339,14 +701,21 @@ export class AuthService {
|
||||
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
|
||||
|
||||
let urlPhoto: string | null = null;
|
||||
if (dto.photo_base64 && dto.photo_filename) {
|
||||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
|
||||
const photoB64 = (dto.photo_base64 ?? '').trim();
|
||||
if (photoB64) {
|
||||
const nomFichier =
|
||||
(dto.photo_filename ?? '').trim() || 'photo_am.jpg';
|
||||
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
|
||||
}
|
||||
|
||||
const dateConsentementPhoto =
|
||||
dto.consentement_photo ? new Date() : undefined;
|
||||
|
||||
const resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
let resultat: { user: Users };
|
||||
try {
|
||||
resultat = await this.usersRepo.manager.transaction(async (manager) => {
|
||||
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
|
||||
|
||||
const user = manager.create(Users, {
|
||||
email: dto.email,
|
||||
prenom: dto.prenom,
|
||||
@@ -363,6 +732,9 @@ export class AuthService {
|
||||
consentement_photo: dto.consentement_photo,
|
||||
date_consentement_photo: dateConsentementPhoto,
|
||||
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
|
||||
lieu_naissance_ville: dto.lieu_naissance_ville,
|
||||
lieu_naissance_pays: dto.lieu_naissance_pays,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
const userEnregistre = await manager.save(Users, user);
|
||||
|
||||
@@ -370,23 +742,48 @@ export class AuthService {
|
||||
const am = amRepo.create({
|
||||
user_id: userEnregistre.id,
|
||||
approval_number: dto.numero_agrement,
|
||||
nir: dto.nir,
|
||||
nir: nirNormalized,
|
||||
max_children: dto.capacite_accueil,
|
||||
places_available: dto.places_disponibles,
|
||||
biography: dto.biographie,
|
||||
residence_city: dto.ville ?? undefined,
|
||||
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
|
||||
available: true,
|
||||
numero_dossier: numeroDossier,
|
||||
});
|
||||
await amRepo.save(am);
|
||||
|
||||
return { user: userEnregistre };
|
||||
});
|
||||
} catch (err) {
|
||||
if (this.isPostgresUniqueViolation(err)) {
|
||||
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const numeroDossier = resultat.user.numero_dossier ?? '';
|
||||
|
||||
try {
|
||||
await this.mailService.sendRegistrationPendingEmail(
|
||||
resultat.user.email,
|
||||
resultat.user.prenom ?? '',
|
||||
resultat.user.nom ?? '',
|
||||
numeroDossier,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
|
||||
user_id: resultat.user.id,
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
numero_dossier: numeroDossier,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -402,7 +799,12 @@ export class AuthService {
|
||||
const extension = correspondances[1];
|
||||
const tamponImage = Buffer.from(correspondances[2], 'base64');
|
||||
|
||||
const dossierUpload = '/app/uploads/photos';
|
||||
const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim();
|
||||
const dossierUpload = rawDir
|
||||
? path.isAbsolute(rawDir)
|
||||
? rawDir
|
||||
: path.resolve(process.cwd(), rawDir)
|
||||
: path.join(process.cwd(), 'uploads', 'photos');
|
||||
await fs.mkdir(dossierUpload, { recursive: true });
|
||||
|
||||
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
|
||||
@@ -470,4 +872,56 @@ export class AuthService {
|
||||
async logout(userId: string) {
|
||||
return { success: true, message: 'Deconnexion'}
|
||||
}
|
||||
|
||||
/** GET dossier reprise – token seul. Ticket #111 */
|
||||
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
|
||||
const user = await this.usersService.findByTokenReprise(token);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||||
}
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
prenom: user.prenom,
|
||||
nom: user.nom,
|
||||
telephone: user.telephone,
|
||||
adresse: user.adresse,
|
||||
ville: user.ville,
|
||||
code_postal: user.code_postal,
|
||||
numero_dossier: user.numero_dossier,
|
||||
role: user.role,
|
||||
photo_url: user.photo_url,
|
||||
genre: user.genre,
|
||||
situation_familiale: user.situation_familiale,
|
||||
};
|
||||
}
|
||||
|
||||
/** PUT resoumission reprise. Ticket #111 */
|
||||
async resoumettreReprise(
|
||||
token: string,
|
||||
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
|
||||
): Promise<Users> {
|
||||
return this.usersService.resoumettreReprise(token, dto);
|
||||
}
|
||||
|
||||
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
|
||||
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
|
||||
const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email);
|
||||
if (!user || !user.token_reprise) {
|
||||
throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.');
|
||||
}
|
||||
return {
|
||||
type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle',
|
||||
token: user.token_reprise,
|
||||
};
|
||||
}
|
||||
|
||||
/** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */
|
||||
private isPostgresUniqueViolation(err: unknown): boolean {
|
||||
if (!(err instanceof QueryFailedError)) {
|
||||
return false;
|
||||
}
|
||||
const code = (err.driverError as { code?: string } | undefined)?.code;
|
||||
return code === '23505';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength, Matches } from 'class-validator';
|
||||
|
||||
export class CreatePasswordDto {
|
||||
@ApiProperty({ description: 'Token de création de mot de passe reçu par email' })
|
||||
@IsString()
|
||||
token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
|
||||
minLength: 8,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
|
||||
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
|
||||
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
|
||||
@IsString()
|
||||
password_confirmation: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Ticket #127 — pas de @IsEmail / @MinLength stricts : éviter 400 sur saisies vides ou bizarres ;
|
||||
* le service renvoie toujours la même réponse 200 (anti-énumération).
|
||||
*/
|
||||
export class ForgotPasswordDto {
|
||||
@ApiProperty({
|
||||
example: 'parent@example.com',
|
||||
required: false,
|
||||
description: 'E-mail (trim + lowercase via transform). Absent ou vide → même réponse générique.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(320)
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toLowerCase() : ''))
|
||||
email?: string;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
@@ -76,7 +77,12 @@ export class RegisterAMCompletDto {
|
||||
@IsString()
|
||||
photo_base64?: string;
|
||||
|
||||
@ApiProperty({ example: 'photo_profil.jpg', required: false })
|
||||
@ApiProperty({
|
||||
example: 'photo_profil.jpg',
|
||||
required: false,
|
||||
description:
|
||||
'Nom du fichier photo (optionnel si photo_base64 est fourni ; défaut serveur : photo_am.jpg). L’extension réelle du fichier stocké suit le type MIME du data-URL.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_filename?: string;
|
||||
@@ -91,22 +97,28 @@ export class RegisterAMCompletDto {
|
||||
@IsDateString()
|
||||
date_naissance?: string;
|
||||
|
||||
@ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'La ville de naissance est requise' })
|
||||
@MinLength(2, { message: 'La ville de naissance doit contenir au moins 2 caractères' })
|
||||
@MaxLength(100)
|
||||
lieu_naissance_ville?: string;
|
||||
lieu_naissance_ville: string;
|
||||
|
||||
@ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' })
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le pays de naissance est requis' })
|
||||
@MinLength(2, { message: 'Le pays de naissance doit contenir au moins 2 caractères' })
|
||||
@MaxLength(100)
|
||||
lieu_naissance_pays?: string;
|
||||
lieu_naissance_pays: string;
|
||||
|
||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
|
||||
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le NIR est requis' })
|
||||
@Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
|
||||
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, {
|
||||
message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)',
|
||||
})
|
||||
nir: string;
|
||||
|
||||
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
|
||||
@@ -126,6 +138,17 @@ export class RegisterAMCompletDto {
|
||||
@Max(10, { message: 'La capacité ne peut pas dépasser 10' })
|
||||
capacite_accueil: number;
|
||||
|
||||
@ApiProperty({
|
||||
example: 2,
|
||||
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
|
||||
minimum: 0,
|
||||
maximum: 10,
|
||||
})
|
||||
@IsInt()
|
||||
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
|
||||
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
|
||||
places_disponibles: number;
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3 : PRÉSENTATION (Optionnel)
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /auth/register/am (alignée sur les champs clés de /auth/register/parent). */
|
||||
export class RegisterAmResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@ApiProperty({ enum: StatutUtilisateurType, example: StatutUtilisateurType.EN_ATTENTE })
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({ example: '2026-000015', description: 'Numéro de dossier attribué à l’inscription' })
|
||||
numero_dossier: string;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */
|
||||
export class RepriseDossierDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
prenom?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
nom?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
telephone?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
adresse?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
ville?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
code_postal?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
numero_dossier?: string;
|
||||
|
||||
@ApiProperty({ enum: RoleType })
|
||||
role: RoleType;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Pour AM' })
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
genre?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
situation_familiale?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
/** Body POST /auth/reprise-identify – numéro + email pour obtenir token reprise. Ticket #111 */
|
||||
export class RepriseIdentifyBodyDto {
|
||||
@ApiProperty({ example: '2026-000001' })
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ example: 'parent@example.com' })
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** Réponse POST /auth/reprise-identify */
|
||||
export class RepriseIdentifyResponseDto {
|
||||
@ApiProperty({ enum: ['parent', 'assistante_maternelle'] })
|
||||
type: 'parent' | 'assistante_maternelle';
|
||||
|
||||
@ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' })
|
||||
token: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, MinLength, Matches } from 'class-validator';
|
||||
|
||||
/** Ticket #127 — mêmes règles que [CreatePasswordDto] (inscription #118). */
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ description: 'Token UUID reçu par e-mail (lien reset-password)' })
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'Token manquant' })
|
||||
token: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
|
||||
minLength: 8,
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
|
||||
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
|
||||
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre',
|
||||
})
|
||||
password: string;
|
||||
|
||||
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
|
||||
@IsString()
|
||||
password_confirmation: string;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator';
|
||||
|
||||
/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */
|
||||
export class ResoumettreRepriseDto {
|
||||
@ApiProperty({ description: 'Token reprise (reçu par email)' })
|
||||
@IsUUID()
|
||||
token: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
prenom?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
nom?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
telephone?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Pour AM' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_url?: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
|
||||
@ApiTags('Dossiers')
|
||||
@Controller('dossiers')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class DossiersController {
|
||||
constructor(private readonly dossiersService: DossiersService) {}
|
||||
|
||||
@Get(':numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) – Ticket #119' })
|
||||
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
|
||||
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsModule } from '../parents/parents.module';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
|
||||
@Module({})
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||
ParentsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('jwt.accessSecret'),
|
||||
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [DossiersController],
|
||||
providers: [DossiersService],
|
||||
exports: [DossiersService],
|
||||
})
|
||||
export class DossiersModule {}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
constructor(
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||
private readonly parentsService: ParentsService,
|
||||
) {}
|
||||
|
||||
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
throw new NotFoundException('Numéro de dossier requis.');
|
||||
}
|
||||
|
||||
// 1) Famille : un parent a ce numéro ?
|
||||
const parentWithNum = await this.parentsRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
select: ['user_id'],
|
||||
});
|
||||
if (parentWithNum) {
|
||||
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
|
||||
return { type: 'family', dossier };
|
||||
}
|
||||
|
||||
// 2) AM : une assistante maternelle a ce numéro ?
|
||||
const am = await this.amRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (am?.user) {
|
||||
const dossier: DossierAmCompletDto = {
|
||||
numero_dossier: num,
|
||||
user: this.toDossierAmUserDto(am.user),
|
||||
numero_agrement: am.approval_number,
|
||||
nir: am.nir,
|
||||
biographie: am.biography,
|
||||
disponible: am.available,
|
||||
ville_residence: am.residence_city,
|
||||
date_agrement: am.agreement_date,
|
||||
annees_experience: am.years_experience,
|
||||
specialite: am.specialty,
|
||||
nb_max_enfants: am.max_children,
|
||||
place_disponible: am.places_available,
|
||||
};
|
||||
return { type: 'am', dossier };
|
||||
}
|
||||
|
||||
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||
}
|
||||
|
||||
private toDossierAmUserDto(user: {
|
||||
id: string;
|
||||
email: string;
|
||||
prenom?: string;
|
||||
nom?: string;
|
||||
telephone?: string;
|
||||
adresse?: string;
|
||||
ville?: string;
|
||||
code_postal?: string;
|
||||
profession?: string;
|
||||
date_naissance?: Date;
|
||||
lieu_naissance_ville?: string;
|
||||
lieu_naissance_pays?: string;
|
||||
photo_url?: string;
|
||||
consentement_photo?: boolean;
|
||||
statut: any;
|
||||
}): DossierAmUserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
prenom: user.prenom,
|
||||
nom: user.nom,
|
||||
telephone: user.telephone,
|
||||
adresse: user.adresse,
|
||||
ville: user.ville,
|
||||
code_postal: user.code_postal,
|
||||
profession: user.profession,
|
||||
date_naissance: user.date_naissance,
|
||||
lieu_naissance_ville: user.lieu_naissance_ville,
|
||||
lieu_naissance_pays: user.lieu_naissance_pays,
|
||||
photo_url: user.photo_url,
|
||||
consentement_photo: user.consentement_photo,
|
||||
statut: user.statut,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
|
||||
export class DossierAmUserDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
@ApiProperty({ required: false })
|
||||
prenom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
telephone?: string;
|
||||
@ApiProperty({ required: false })
|
||||
adresse?: string;
|
||||
@ApiProperty({ required: false })
|
||||
ville?: string;
|
||||
@ApiProperty({ required: false })
|
||||
code_postal?: string;
|
||||
@ApiProperty({ required: false })
|
||||
profession?: string;
|
||||
@ApiProperty({ required: false })
|
||||
date_naissance?: Date;
|
||||
@ApiProperty({ required: false, description: 'Ville de naissance' })
|
||||
lieu_naissance_ville?: string;
|
||||
@ApiProperty({ required: false, description: 'Pays de naissance' })
|
||||
lieu_naissance_pays?: string;
|
||||
@ApiProperty({ required: false })
|
||||
photo_url?: string;
|
||||
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
|
||||
consentement_photo?: boolean;
|
||||
@ApiProperty({ enum: StatutUtilisateurType })
|
||||
statut: StatutUtilisateurType;
|
||||
}
|
||||
|
||||
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
|
||||
export class DossierAmCompletDto {
|
||||
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
|
||||
numero_dossier: string;
|
||||
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
|
||||
user: DossierAmUserDto;
|
||||
@ApiProperty({ required: false })
|
||||
numero_agrement?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nir?: string;
|
||||
@ApiProperty({ required: false })
|
||||
biographie?: string;
|
||||
@ApiProperty({ required: false })
|
||||
disponible?: boolean;
|
||||
@ApiProperty({ required: false })
|
||||
ville_residence?: string;
|
||||
@ApiProperty({ required: false })
|
||||
date_agrement?: Date;
|
||||
@ApiProperty({ required: false })
|
||||
annees_experience?: number;
|
||||
@ApiProperty({ required: false })
|
||||
specialite?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nb_max_enfants?: number;
|
||||
@ApiProperty({ required: false })
|
||||
place_disponible?: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
|
||||
import { DossierAmCompletDto } from './dossier-am-complet.dto';
|
||||
|
||||
/** Réponse unifiée GET /dossiers/:numeroDossier – AM ou famille. Ticket #119 */
|
||||
export class DossierUnifieDto {
|
||||
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
|
||||
type: 'family' | 'am';
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
|
||||
})
|
||||
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
|
||||
|
||||
/** Parent dans le dossier famille (infos utilisateur + parent) */
|
||||
export class DossierFamilleParentDto {
|
||||
@ApiProperty()
|
||||
user_id: string;
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
@ApiProperty({ required: false })
|
||||
prenom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
nom?: string;
|
||||
@ApiProperty({ required: false })
|
||||
telephone?: string;
|
||||
@ApiProperty({ required: false })
|
||||
adresse?: string;
|
||||
@ApiProperty({ required: false })
|
||||
ville?: string;
|
||||
@ApiProperty({ required: false })
|
||||
code_postal?: string;
|
||||
@ApiProperty({ enum: StatutUtilisateurType })
|
||||
statut: StatutUtilisateurType;
|
||||
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
|
||||
co_parent_id?: string;
|
||||
}
|
||||
|
||||
/** Enfant dans le dossier famille */
|
||||
export class DossierFamilleEnfantDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
@ApiProperty({ required: false })
|
||||
first_name?: string;
|
||||
@ApiProperty({ required: false })
|
||||
last_name?: string;
|
||||
@ApiProperty({ required: false, enum: GenreType })
|
||||
genre?: GenreType;
|
||||
@ApiProperty({ required: false })
|
||||
birth_date?: Date;
|
||||
@ApiProperty({ required: false })
|
||||
due_date?: Date;
|
||||
@ApiProperty({ enum: StatutEnfantType })
|
||||
status: StatutEnfantType;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)',
|
||||
})
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Consentement affichage photo (colonne consentement_photo)',
|
||||
})
|
||||
consent_photo?: boolean;
|
||||
}
|
||||
|
||||
/** Réponse GET /parents/dossier-famille/:numeroDossier – dossier famille complet. Ticket #119 */
|
||||
export class DossierFamilleCompletDto {
|
||||
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
|
||||
numero_dossier: string;
|
||||
@ApiProperty({ type: [DossierFamilleParentDto] })
|
||||
parents: DossierFamilleParentDto[];
|
||||
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
|
||||
enfants: DossierFamilleEnfantDto[];
|
||||
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
|
||||
texte_motivation?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export class ParentPendingSummaryDto {
|
||||
@ApiProperty({ description: 'UUID utilisateur' })
|
||||
id: string;
|
||||
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
code_postal?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
ville?: string | null;
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['uuid-parent-1', 'uuid-parent-2'],
|
||||
description: 'IDs utilisateur des parents de la famille',
|
||||
})
|
||||
parentIds: string[];
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
example: '2026-000001',
|
||||
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
|
||||
})
|
||||
numero_dossier: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
nullable: true,
|
||||
example: '2026-01-12T10:00:00.000Z',
|
||||
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
|
||||
})
|
||||
date_soumission: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
example: 3,
|
||||
description: 'Nombre d’enfants distincts liés aux parents de la famille (enfants_parents)',
|
||||
})
|
||||
nombre_enfants: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [String],
|
||||
example: ['parent1@example.com', 'parent2@example.com'],
|
||||
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
|
||||
})
|
||||
emails?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: [ParentPendingSummaryDto],
|
||||
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
|
||||
})
|
||||
parents?: ParentPendingSummaryDto[];
|
||||
}
|
||||
@@ -1,24 +1,83 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@Controller('parents')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class ParentsController {
|
||||
constructor(private readonly parentsService: ParentsService) {}
|
||||
constructor(
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
'Liste des familles (libellé, parentIds, numero_dossier, date_soumission, nombre_enfants, emails, parents)',
|
||||
type: [PendingFamilyDto],
|
||||
})
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||
return this.parentsService.getPendingFamilies();
|
||||
}
|
||||
|
||||
@Get('dossier-famille/:numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
|
||||
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
|
||||
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
|
||||
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
|
||||
}
|
||||
|
||||
@Post(':parentId/valider-dossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
|
||||
@ApiParam({ name: 'parentId', description: "UUID d'un des parents (user_id)" })
|
||||
@ApiResponse({ status: 200, description: 'Utilisateurs validés (famille)' })
|
||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
async validerDossierFamille(
|
||||
@Param('parentId') parentId: string,
|
||||
@User() currentUser: Users,
|
||||
@Body('comment') comment?: string,
|
||||
): Promise<Users[]> {
|
||||
const familyIds = await this.parentsService.getFamilyUserIds(parentId);
|
||||
const validated: Users[] = [];
|
||||
for (const userId of familyIds) {
|
||||
const user = await this.userService.findOne(userId);
|
||||
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) continue;
|
||||
const saved = await this.userService.validateUser(userId, currentUser, comment);
|
||||
validated.push(saved);
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get()
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Parents, Users])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||
forwardRef(() => UserModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get('jwt.accessSecret'),
|
||||
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
],
|
||||
controllers: [ParentsController],
|
||||
providers: [ParentsService],
|
||||
exports: [ParentsService,
|
||||
|
||||
@@ -5,11 +5,19 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { DossierFamille } from 'src/entities/dossier_famille.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateParentDto } from '../user/dto/create_parent.dto';
|
||||
import { UpdateParentsDto } from '../user/dto/update_parent.dto';
|
||||
import { PendingFamilyDto } from './dto/pending-family.dto';
|
||||
import {
|
||||
DossierFamilleCompletDto,
|
||||
DossierFamilleParentDto,
|
||||
DossierFamilleEnfantDto,
|
||||
} from './dto/dossier-famille-complet.dto';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@@ -18,6 +26,8 @@ export class ParentsService {
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(DossierFamille)
|
||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||
) {}
|
||||
|
||||
// Création d’un parent
|
||||
@@ -71,4 +81,279 @@ export class ParentsService {
|
||||
await this.parentsRepository.update(id, dto);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des familles en attente (une entrée par famille).
|
||||
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
||||
* Uniquement les parents dont l'utilisateur a statut = en_attente.
|
||||
*/
|
||||
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
|
||||
let raw: {
|
||||
libelle: string;
|
||||
parentIds: unknown;
|
||||
numero_dossier: string | null;
|
||||
date_soumission: Date | string | null;
|
||||
nombre_enfants: string | number | null;
|
||||
emails: unknown;
|
||||
parents: unknown;
|
||||
}[];
|
||||
try {
|
||||
raw = await this.parentsRepository.query(`
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT
|
||||
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
|
||||
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
|
||||
(array_agg(p.numero_dossier))[1] AS numero_dossier,
|
||||
MIN(u.cree_le) AS date_soumission,
|
||||
COALESCE((
|
||||
SELECT COUNT(DISTINCT ep.id_enfant)::int
|
||||
FROM enfants_parents ep
|
||||
WHERE ep.id_parent IN (
|
||||
SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep
|
||||
)
|
||||
), 0) AS nombre_enfants,
|
||||
array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
)
|
||||
ORDER BY u.nom, u.prenom, u.id
|
||||
) AS parents
|
||||
FROM family_rep fr
|
||||
JOIN parents p ON p.id_utilisateur = fr.id
|
||||
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||
WHERE u.role = 'parent' AND u.statut = 'en_attente'
|
||||
GROUP BY fr.rep
|
||||
ORDER BY libelle
|
||||
`);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((r) => ({
|
||||
libelle: r.libelle ?? '',
|
||||
parentIds: this.normalizeParentIds(r.parentIds),
|
||||
numero_dossier: r.numero_dossier ?? null,
|
||||
date_soumission: this.toIsoDateTimeOrNull(r.date_soumission),
|
||||
nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants),
|
||||
emails: this.normalizeEmails(r.emails),
|
||||
parents: this.normalizeParents(r.parents),
|
||||
}));
|
||||
}
|
||||
|
||||
private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
const d = new Date(value);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
||||
}
|
||||
|
||||
private normalizeNombreEnfants(v: string | number | null | undefined): number {
|
||||
if (v == null) return 0;
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
private normalizeEmails(emails: unknown): string[] {
|
||||
if (Array.isArray(emails)) return emails.map(String);
|
||||
if (typeof emails === 'string') {
|
||||
const s = emails.replace(/^\{|\}$/g, '').trim();
|
||||
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
if (Array.isArray(parents)) {
|
||||
return parents.map((p: any) => ({
|
||||
id: String(p?.id ?? ''),
|
||||
email: String(p?.email ?? ''),
|
||||
telephone: p?.telephone != null ? String(p.telephone) : null,
|
||||
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
|
||||
ville: p?.ville != null ? String(p.ville) : null,
|
||||
}));
|
||||
}
|
||||
if (typeof parents === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(parents);
|
||||
return this.normalizeParents(parsed);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
|
||||
private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto {
|
||||
return {
|
||||
id: child.id,
|
||||
first_name: child.first_name,
|
||||
last_name: child.last_name,
|
||||
genre: child.gender,
|
||||
birth_date: child.birth_date,
|
||||
due_date: child.due_date,
|
||||
status: child.status,
|
||||
photo_url: child.photo_url ?? undefined,
|
||||
consent_photo: child.consent_photo,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeParentIds(parentIds: unknown): string[] {
|
||||
if (Array.isArray(parentIds)) return parentIds.map(String);
|
||||
if (typeof parentIds === 'string') {
|
||||
const s = parentIds.replace(/^\{|\}$/g, '').trim();
|
||||
return s ? s.split(',').map((x) => x.trim()) : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dossier famille complet par numéro de dossier. Ticket #119.
|
||||
* Rôles : admin, gestionnaire.
|
||||
* @throws NotFoundException si aucun parent avec ce numéro de dossier
|
||||
*/
|
||||
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
throw new NotFoundException('Numéro de dossier requis.');
|
||||
}
|
||||
const firstParent = await this.parentsRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!firstParent || !firstParent.user) {
|
||||
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
|
||||
}
|
||||
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
|
||||
const parents = await this.parentsRepository.find({
|
||||
where: { user_id: In(familyUserIds) },
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
|
||||
});
|
||||
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
|
||||
let texte_motivation: string | undefined;
|
||||
|
||||
// Un dossier = une famille, un seul texte de motivation
|
||||
const dossierFamille = await this.dossierFamilleRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['parent', 'enfants', 'enfants.enfant'],
|
||||
});
|
||||
if (dossierFamille?.presentation) {
|
||||
texte_motivation = dossierFamille.presentation;
|
||||
}
|
||||
|
||||
for (const p of parents) {
|
||||
// Enfants via parentChildren
|
||||
if (p.parentChildren) {
|
||||
for (const pc of p.parentChildren) {
|
||||
if (pc.child && !enfantsMap.has(pc.child.id)) {
|
||||
enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback : anciens dossiers (un texte, on prend le premier)
|
||||
if (texte_motivation == null && p.dossiers?.length) {
|
||||
texte_motivation = p.dossiers[0].presentation ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif)
|
||||
if (dossierFamille?.enfants?.length) {
|
||||
for (const dfe of dossierFamille.enfants) {
|
||||
const c = dfe.enfant;
|
||||
if (c && !enfantsMap.has(c.id)) {
|
||||
enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
|
||||
user_id: p.user_id,
|
||||
email: p.user.email,
|
||||
prenom: p.user.prenom,
|
||||
nom: p.user.nom,
|
||||
telephone: p.user.telephone,
|
||||
adresse: p.user.adresse,
|
||||
ville: p.user.ville,
|
||||
code_postal: p.user.code_postal,
|
||||
statut: p.user.statut,
|
||||
co_parent_id: p.co_parent?.id,
|
||||
}));
|
||||
return {
|
||||
numero_dossier: num,
|
||||
parents: parentsDto,
|
||||
enfants: Array.from(enfantsMap.values()),
|
||||
texte_motivation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
|
||||
* @throws NotFoundException si parentId n'est pas un parent
|
||||
*/
|
||||
async getFamilyUserIds(parentId: string): Promise<string[]> {
|
||||
const raw = await this.parentsRepository.query(
|
||||
`
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
|
||||
),
|
||||
input_rep AS (
|
||||
SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1
|
||||
)
|
||||
SELECT fr.id::text AS id
|
||||
FROM family_rep fr
|
||||
CROSS JOIN input_rep ir
|
||||
WHERE fr.rep = ir.rep
|
||||
`,
|
||||
[parentId],
|
||||
);
|
||||
if (!raw || raw.length === 0) {
|
||||
throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.');
|
||||
}
|
||||
return raw.map((r: { id: string }) => r.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator';
|
||||
|
||||
export class CreateRelaisDto {
|
||||
@ApiProperty({ example: 'Relais Petite Enfance Centre' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
nom: string;
|
||||
|
||||
@ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
adresse: string;
|
||||
|
||||
@ApiProperty({ example: { lundi: '09:00-17:00' }, required: false })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
horaires_ouverture?: any;
|
||||
|
||||
@ApiProperty({ example: '0123456789', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ligne_fixe?: string;
|
||||
|
||||
@ApiProperty({ default: true, required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
actif?: boolean;
|
||||
|
||||
@ApiProperty({ example: 'Notes internes...', required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateRelaisDto } from './create-relais.dto';
|
||||
|
||||
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
|
||||
import { RelaisService } from './relais.service';
|
||||
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||
import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
@ApiTags('Relais')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('relais')
|
||||
export class RelaisController {
|
||||
constructor(private readonly relaisService: RelaisService) {}
|
||||
|
||||
@Post()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Créer un relais' })
|
||||
@ApiResponse({ status: 201, description: 'Le relais a été créé.' })
|
||||
create(@Body() createRelaisDto: CreateRelaisDto) {
|
||||
return this.relaisService.create(createRelaisDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||
findAll() {
|
||||
return this.relaisService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.relaisService.findOne(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Mettre à jour un relais' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais a été mis à jour.' })
|
||||
update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) {
|
||||
return this.relaisService.update(id, updateRelaisDto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Supprimer un relais' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais a été supprimé.' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.relaisService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RelaisService } from './relais.service';
|
||||
import { RelaisController } from './relais.controller';
|
||||
import { Relais } from 'src/entities/relais.entity';
|
||||
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Relais]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [RelaisController],
|
||||
providers: [RelaisService],
|
||||
exports: [RelaisService],
|
||||
})
|
||||
export class RelaisModule {}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Relais } from 'src/entities/relais.entity';
|
||||
import { CreateRelaisDto } from './dto/create-relais.dto';
|
||||
import { UpdateRelaisDto } from './dto/update-relais.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RelaisService {
|
||||
constructor(
|
||||
@InjectRepository(Relais)
|
||||
private readonly relaisRepository: Repository<Relais>,
|
||||
) {}
|
||||
|
||||
create(createRelaisDto: CreateRelaisDto) {
|
||||
const relais = this.relaisRepository.create(createRelaisDto);
|
||||
return this.relaisRepository.save(relais);
|
||||
}
|
||||
|
||||
findAll() {
|
||||
return this.relaisRepository.find({ order: { nom: 'ASC' } });
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const relais = await this.relaisRepository.findOne({ where: { id } });
|
||||
if (!relais) {
|
||||
throw new NotFoundException(`Relais #${id} not found`);
|
||||
}
|
||||
return relais;
|
||||
}
|
||||
|
||||
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
|
||||
const relais = await this.findOne(id);
|
||||
Object.assign(relais, updateRelaisDto);
|
||||
return this.relaisRepository.save(relais);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
const relais = await this.findOne(id);
|
||||
return this.relaisRepository.remove(relais);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, Matches } from 'class-validator';
|
||||
|
||||
/** Format AAAA-NNNNNN (année + 6 chiffres) */
|
||||
const NUMERO_DOSSIER_REGEX = /^\d{4}-\d{6}$/;
|
||||
|
||||
export class AffecterNumeroDossierDto {
|
||||
@ApiProperty({ example: '2026-000004', description: 'Numéro de dossier (AAAA-NNNNNN)' })
|
||||
@IsNotEmpty({ message: 'Le numéro de dossier est requis' })
|
||||
@Matches(NUMERO_DOSSIER_REGEX, {
|
||||
message: 'Le numéro de dossier doit être au format AAAA-NNNNNN (ex: 2026-000001)',
|
||||
})
|
||||
numero_dossier: string;
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import { PickType } from "@nestjs/swagger";
|
||||
import { CreateUserDto } from "./create_user.dto";
|
||||
|
||||
export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
||||
export class CreateAdminDto extends PickType(CreateUserDto, [
|
||||
'nom',
|
||||
'prenom',
|
||||
'email',
|
||||
'password',
|
||||
'telephone'
|
||||
] as const) {}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { OmitType } from "@nestjs/swagger";
|
||||
import { ApiProperty, OmitType } from "@nestjs/swagger";
|
||||
import { CreateUserDto } from "./create_user.dto";
|
||||
import { IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {}
|
||||
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) {
|
||||
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
relaisId?: string;
|
||||
}
|
||||
|
||||
@@ -36,10 +36,10 @@ export class CreateUserDto {
|
||||
@MaxLength(100)
|
||||
nom: string;
|
||||
|
||||
@ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
|
||||
@ApiProperty({ enum: GenreType, required: false })
|
||||
@IsOptional()
|
||||
@IsEnum(GenreType)
|
||||
genre?: GenreType = GenreType.AUTRE;
|
||||
genre?: GenreType;
|
||||
|
||||
@ApiProperty({ enum: RoleType })
|
||||
@IsEnum(RoleType)
|
||||
@@ -86,7 +86,7 @@ export class CreateUserDto {
|
||||
@ApiProperty({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
consentement_photo?: boolean = false;
|
||||
consentement_photo?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@@ -96,7 +96,7 @@ export class CreateUserDto {
|
||||
@ApiProperty({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
changement_mdp_obligatoire?: boolean = false;
|
||||
changement_mdp_obligatoire?: boolean;
|
||||
|
||||
@ApiProperty({ example: true })
|
||||
@IsBoolean()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
import { CreateGestionnaireDto } from "./create_gestionnaire.dto";
|
||||
|
||||
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
|
||||
export class UpdateGestionnaireDto extends PartialType(CreateGestionnaireDto) {}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { GestionnairesController } from './gestionnaires.controller';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AuthModule } from 'src/routes/auth/auth.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users]),
|
||||
AuthModule,
|
||||
MailModule,
|
||||
],
|
||||
controllers: [GestionnairesController],
|
||||
providers: [GestionnairesService],
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
|
||||
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { MailService } from 'src/modules/mail/mail.service';
|
||||
|
||||
@Injectable()
|
||||
export class GestionnairesService {
|
||||
constructor(
|
||||
@InjectRepository(Users)
|
||||
private readonly gestionnaireRepository: Repository<Users>,
|
||||
private readonly mailService: MailService,
|
||||
) { }
|
||||
|
||||
// Création d’un gestionnaire
|
||||
@@ -30,30 +32,51 @@ export class GestionnairesService {
|
||||
password: hashedPassword,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
genre: dto.genre,
|
||||
statut: dto.statut,
|
||||
// genre: dto.genre, // Retiré
|
||||
// statut: dto.statut, // Retiré
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
telephone: dto.telephone,
|
||||
adresse: dto.adresse,
|
||||
photo_url: dto.photo_url,
|
||||
consentement_photo: dto.consentement_photo ?? false,
|
||||
date_consentement_photo: dto.date_consentement_photo
|
||||
? new Date(dto.date_consentement_photo)
|
||||
: undefined,
|
||||
changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
|
||||
// adresse: dto.adresse, // Retiré
|
||||
// photo_url: dto.photo_url, // Retiré
|
||||
// consentement_photo: dto.consentement_photo ?? false, // Retiré
|
||||
// date_consentement_photo: dto.date_consentement_photo // Retiré
|
||||
// ? new Date(dto.date_consentement_photo)
|
||||
// : undefined,
|
||||
changement_mdp_obligatoire: true,
|
||||
role: RoleType.GESTIONNAIRE,
|
||||
relaisId: dto.relaisId,
|
||||
});
|
||||
return this.gestionnaireRepository.save(entity);
|
||||
|
||||
const savedUser = await this.gestionnaireRepository.save(entity);
|
||||
|
||||
// Envoi de l'email de bienvenue
|
||||
try {
|
||||
await this.mailService.sendGestionnaireWelcomeEmail(
|
||||
savedUser.email,
|
||||
savedUser.prenom || '',
|
||||
savedUser.nom || '',
|
||||
);
|
||||
} catch (error) {
|
||||
// On ne bloque pas la création si l'envoi d'email échoue, mais on log l'erreur
|
||||
console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error);
|
||||
}
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
// Liste des gestionnaires
|
||||
async findAll(): Promise<Users[]> {
|
||||
return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
|
||||
return this.gestionnaireRepository.find({
|
||||
where: { role: RoleType.GESTIONNAIRE },
|
||||
relations: ['relais'],
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer un gestionnaire par ID
|
||||
async findOne(id: string): Promise<Users> {
|
||||
const gestionnaire = await this.gestionnaireRepository.findOne({
|
||||
where: { id, role: RoleType.GESTIONNAIRE },
|
||||
relations: ['relais'],
|
||||
});
|
||||
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
|
||||
return gestionnaire;
|
||||
@@ -68,13 +91,7 @@ export class GestionnairesService {
|
||||
gestionnaire.password = await bcrypt.hash(dto.password, salt);
|
||||
}
|
||||
|
||||
if (dto.date_consentement_photo !== undefined) {
|
||||
gestionnaire.date_consentement_photo = dto.date_consentement_photo
|
||||
? new Date(dto.date_consentement_photo)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const { password, date_consentement_photo, ...rest } = dto;
|
||||
const { password, ...rest } = dto;
|
||||
Object.entries(rest).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
(gestionnaire as any)[key] = value;
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { UserService } from './user.service';
|
||||
import { CreateUserDto } from './dto/create_user.dto';
|
||||
import { CreateAdminDto } from './dto/create_admin.dto';
|
||||
import { UpdateUserDto } from './dto/update_user.dto';
|
||||
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
|
||||
|
||||
@ApiTags('Utilisateurs')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard)
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) { }
|
||||
|
||||
// Création d'un administrateur (réservée aux super admins)
|
||||
@Post('admin')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
||||
createAdmin(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@User() currentUser: Users
|
||||
) {
|
||||
return this.userService.createAdmin(dto, currentUser);
|
||||
}
|
||||
|
||||
// Création d'un utilisateur (réservée aux super admins)
|
||||
@Post()
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@@ -26,6 +40,26 @@ export class UserController {
|
||||
return this.userService.createUser(dto, currentUser);
|
||||
}
|
||||
|
||||
// Lister les utilisateurs en attente de validation
|
||||
@Get('pending')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Lister les utilisateurs en attente de validation' })
|
||||
findPendingUsers(
|
||||
@Query('role') role?: RoleType
|
||||
) {
|
||||
return this.userService.findPendingUsers(role);
|
||||
}
|
||||
|
||||
// Lister les comptes refusés (à corriger / reprise)
|
||||
@Get('reprise')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Lister les comptes refusés (reprise)' })
|
||||
findRefusedUsers(
|
||||
@Query('role') role?: RoleType
|
||||
) {
|
||||
return this.userService.findRefusedUsers(role);
|
||||
}
|
||||
|
||||
// Lister tous les utilisateurs (super_admin uniquement)
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@@ -43,9 +77,9 @@ export class UserController {
|
||||
return this.userService.findOne(id);
|
||||
}
|
||||
|
||||
// Modifier un utilisateur (réservé super_admin)
|
||||
// Modifier un utilisateur (réservé super_admin et admin)
|
||||
@Patch(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Mettre à jour un utilisateur' })
|
||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||
updateUser(
|
||||
@@ -56,6 +90,23 @@ export class UserController {
|
||||
return this.userService.updateUser(id, dto, currentUser);
|
||||
}
|
||||
|
||||
@Patch(':id/numero-dossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Affecter un numéro de dossier à un utilisateur',
|
||||
description: 'Permet de rapprocher deux dossiers ou d’attribuer un numéro existant à un parent/AM. Réservé aux gestionnaires et administrateurs.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur (parent ou AM)" })
|
||||
@ApiResponse({ status: 200, description: 'Numéro de dossier affecté' })
|
||||
@ApiResponse({ status: 400, description: 'Format invalide, rôle non éligible, ou dossier déjà associé à 2 parents' })
|
||||
@ApiResponse({ status: 404, description: 'Utilisateur introuvable' })
|
||||
affecterNumeroDossier(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AffecterNumeroDossierDto,
|
||||
) {
|
||||
return this.userService.affecterNumeroDossier(id, dto.numero_dossier);
|
||||
}
|
||||
|
||||
@Patch(':id/valider')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Valider un compte utilisateur' })
|
||||
@@ -71,6 +122,18 @@ export class UserController {
|
||||
return this.userService.validateUser(id, currentUser, comment);
|
||||
}
|
||||
|
||||
@Patch(':id/refuser')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Refuser un compte (à corriger)' })
|
||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||
refuse(
|
||||
@Param('id') id: string,
|
||||
@User() currentUser: Users,
|
||||
@Body('comment') comment?: string,
|
||||
) {
|
||||
return this.userService.refuseUser(id, currentUser, comment);
|
||||
}
|
||||
|
||||
@Patch(':id/suspendre')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Suspendre un compte utilisateur' })
|
||||
|
||||
@@ -10,6 +10,8 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
||||
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(
|
||||
@@ -22,6 +24,8 @@ import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||
ParentsModule,
|
||||
AssistantesMaternellesModule,
|
||||
GestionnairesModule,
|
||||
MailModule,
|
||||
AppConfigModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
|
||||
import { In, Repository } from "typeorm";
|
||||
import { In, MoreThan, Repository } from "typeorm";
|
||||
import { CreateUserDto } from "./dto/create_user.dto";
|
||||
import { CreateAdminDto } from "./dto/create_admin.dto";
|
||||
import { UpdateUserDto } from "./dto/update_user.dto";
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||
import { Parents } from "src/entities/parents.entity";
|
||||
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
||||
import { MailService, ValidationAccountEmailKind } from "src/modules/mail/mail.service";
|
||||
import { AppConfigService } from "src/modules/config/config.service";
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@@ -22,7 +28,11 @@ export class UserService {
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>
|
||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||
|
||||
private readonly mailService: MailService,
|
||||
|
||||
private readonly appConfigService: AppConfigService,
|
||||
) { }
|
||||
|
||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||
@@ -106,6 +116,48 @@ export class UserService {
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
|
||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
|
||||
}
|
||||
|
||||
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||
if (exist) throw new BadRequestException('Email déjà utilisé');
|
||||
|
||||
const salt = await bcrypt.genSalt();
|
||||
const hashedPassword = await bcrypt.hash(dto.password, salt);
|
||||
|
||||
const entity = this.usersRepository.create({
|
||||
email: dto.email,
|
||||
password: hashedPassword,
|
||||
prenom: dto.prenom,
|
||||
nom: dto.nom,
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
telephone: dto.telephone,
|
||||
changement_mdp_obligatoire: true,
|
||||
});
|
||||
|
||||
return this.usersRepository.save(entity);
|
||||
}
|
||||
|
||||
async findPendingUsers(role?: RoleType): Promise<Users[]> {
|
||||
const where: any = { statut: StatutUtilisateurType.EN_ATTENTE };
|
||||
if (role) {
|
||||
where.role = role;
|
||||
}
|
||||
return this.usersRepository.find({ where });
|
||||
}
|
||||
|
||||
/** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */
|
||||
async findRefusedUsers(role?: RoleType): Promise<Users[]> {
|
||||
const where: any = { statut: StatutUtilisateurType.REFUSE };
|
||||
if (role) {
|
||||
where.role = role;
|
||||
}
|
||||
return this.usersRepository.find({ where });
|
||||
}
|
||||
|
||||
async findAll(): Promise<Users[]> {
|
||||
return this.usersRepository.find();
|
||||
}
|
||||
@@ -129,11 +181,26 @@ export class UserService {
|
||||
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
|
||||
const user = await this.findOne(id);
|
||||
|
||||
// Le super administrateur conserve une identité figée.
|
||||
if (
|
||||
user.role === RoleType.SUPER_ADMIN &&
|
||||
(dto.nom !== undefined || dto.prenom !== undefined)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
|
||||
);
|
||||
}
|
||||
|
||||
// Interdire changement de rôle si pas super admin
|
||||
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins');
|
||||
}
|
||||
|
||||
// Un admin ne peut pas modifier un super admin
|
||||
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
|
||||
}
|
||||
|
||||
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
|
||||
if (
|
||||
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
|
||||
@@ -165,7 +232,7 @@ export class UserService {
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
// Valider un compte utilisateur
|
||||
// Valider un compte utilisateur (en_attente ou refuse -> actif)
|
||||
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
||||
@@ -173,7 +240,11 @@ export class UserService {
|
||||
|
||||
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
|
||||
|
||||
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) {
|
||||
throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.');
|
||||
}
|
||||
|
||||
user.statut = StatutUtilisateurType.ACTIF;
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
if (user.role === RoleType.PARENT) {
|
||||
@@ -197,6 +268,45 @@ export class UserService {
|
||||
comment,
|
||||
});
|
||||
await this.validationRepository.save(validation);
|
||||
|
||||
// Ticket #28 — email création MDP (parents / AM), Handlebars + lien app_url
|
||||
const roleCibleMdp =
|
||||
savedUser.role === RoleType.PARENT || savedUser.role === RoleType.ASSISTANTE_MATERNELLE;
|
||||
if (roleCibleMdp && !savedUser.password) {
|
||||
try {
|
||||
const joursExpiration = await this.appConfigService.get<number>(
|
||||
'password_reset_token_expiry_days',
|
||||
7,
|
||||
);
|
||||
if (!savedUser.token_creation_mdp) {
|
||||
savedUser.token_creation_mdp = crypto.randomUUID();
|
||||
}
|
||||
const exp = new Date();
|
||||
exp.setDate(exp.getDate() + joursExpiration);
|
||||
savedUser.token_creation_mdp_expire_le = exp;
|
||||
await this.usersRepository.save(savedUser);
|
||||
|
||||
const kind: ValidationAccountEmailKind =
|
||||
savedUser.role === RoleType.ASSISTANTE_MATERNELLE ? 'am' : 'parent';
|
||||
|
||||
await this.mailService.sendValidatedAccountPasswordSetupEmail(
|
||||
{
|
||||
email: savedUser.email,
|
||||
prenom: savedUser.prenom ?? '',
|
||||
nom: savedUser.nom ?? '',
|
||||
token: savedUser.token_creation_mdp,
|
||||
numeroDossier: savedUser.numero_dossier,
|
||||
},
|
||||
kind,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Envoi email validation compte (#28) échoué pour ${savedUser.email}`,
|
||||
err as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
@@ -221,10 +331,204 @@ export class UserService {
|
||||
await this.validationRepository.save(suspend);
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/** Refuser un dossier (en_attente -> refuse) ; tracé validations, token reprise partagé, emails. Ticket #110 */
|
||||
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
|
||||
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
|
||||
}
|
||||
const user = await this.usersRepository.findOne({ where: { id: user_id } });
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
|
||||
throw new BadRequestException('Seul un dossier en attente peut être refusé.');
|
||||
}
|
||||
|
||||
const tokenReprise = crypto.randomUUID();
|
||||
const expireLe = new Date();
|
||||
expireLe.setDate(expireLe.getDate() + 7);
|
||||
|
||||
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
|
||||
? await this.usersRepository.find({
|
||||
where: {
|
||||
role: RoleType.PARENT,
|
||||
numero_dossier: user.numero_dossier,
|
||||
},
|
||||
})
|
||||
: [user];
|
||||
|
||||
const usersARefuser = usersDossier.length ? usersDossier : [user];
|
||||
const utilisateurInvalide = usersARefuser.find(
|
||||
(u) => u.statut !== StatutUtilisateurType.EN_ATTENTE,
|
||||
);
|
||||
if (utilisateurInvalide) {
|
||||
throw new BadRequestException(
|
||||
'Tous les comptes du dossier doivent être en attente pour refuser le dossier.',
|
||||
);
|
||||
}
|
||||
|
||||
const savedUsers = await this.usersRepository.manager.transaction(async (manager) => {
|
||||
const updatedUsers: Users[] = [];
|
||||
|
||||
for (const userARefuser of usersARefuser) {
|
||||
userARefuser.statut = StatutUtilisateurType.REFUSE;
|
||||
userARefuser.token_reprise = tokenReprise;
|
||||
userARefuser.token_reprise_expire_le = expireLe;
|
||||
|
||||
const savedUser = await manager.save(Users, userARefuser);
|
||||
updatedUsers.push(savedUser);
|
||||
|
||||
const validation = manager.create(Validation, {
|
||||
user: savedUser,
|
||||
type: 'refus_compte',
|
||||
status: StatutValidationType.REFUSE,
|
||||
validated_by: currentUser,
|
||||
comment,
|
||||
});
|
||||
await manager.save(Validation, validation);
|
||||
}
|
||||
|
||||
return updatedUsers;
|
||||
});
|
||||
|
||||
const emailFailures: string[] = [];
|
||||
for (const savedUser of savedUsers) {
|
||||
try {
|
||||
await this.mailService.sendRefusEmail(
|
||||
savedUser.email,
|
||||
savedUser.prenom ?? '',
|
||||
savedUser.nom ?? '',
|
||||
comment,
|
||||
tokenReprise,
|
||||
);
|
||||
} catch (err) {
|
||||
emailFailures.push(savedUser.email);
|
||||
this.logger.error(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (emailFailures.length > 0) {
|
||||
throw new ServiceUnavailableException(
|
||||
`Le dossier a bien été refusé en base, mais l'envoi d'email a échoué pour : ${emailFailures.join(', ')}. Réessayez ou contactez le support.`,
|
||||
);
|
||||
}
|
||||
|
||||
return savedUsers.find((savedUser) => savedUser.id === user.id) ?? savedUsers[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM).
|
||||
* Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes).
|
||||
* Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents).
|
||||
*/
|
||||
async affecterNumeroDossier(userId: string, numeroDossier: string): Promise<Users> {
|
||||
const user = await this.usersRepository.findOne({ where: { id: userId } });
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
|
||||
if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) {
|
||||
throw new BadRequestException(
|
||||
'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle',
|
||||
);
|
||||
}
|
||||
|
||||
if (user.role === RoleType.PARENT) {
|
||||
const uneAMALe = await this.assistantesRepository.count({
|
||||
where: { numero_dossier: numeroDossier },
|
||||
});
|
||||
if (uneAMALe > 0) {
|
||||
throw new BadRequestException(
|
||||
'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.',
|
||||
);
|
||||
}
|
||||
const parentsAvecCeNumero = await this.parentsRepository.count({
|
||||
where: { numero_dossier: numeroDossier },
|
||||
});
|
||||
const userADejaCeNumero = user.numero_dossier === numeroDossier;
|
||||
if (!userADejaCeNumero && parentsAvecCeNumero >= 2) {
|
||||
throw new BadRequestException(
|
||||
'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||
const unParentLA = await this.parentsRepository.count({
|
||||
where: { numero_dossier: numeroDossier },
|
||||
});
|
||||
if (unParentLA > 0) {
|
||||
throw new BadRequestException(
|
||||
'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
user.numero_dossier = numeroDossier;
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
|
||||
if (user.role === RoleType.PARENT) {
|
||||
await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
|
||||
} else {
|
||||
await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
|
||||
}
|
||||
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/** Trouve un user par token reprise valide (non expiré). Ticket #111 */
|
||||
async findByTokenReprise(token: string): Promise<Users | null> {
|
||||
return this.usersRepository.findOne({
|
||||
where: {
|
||||
token_reprise: token,
|
||||
statut: StatutUtilisateurType.REFUSE,
|
||||
token_reprise_expire_le: MoreThan(new Date()),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */
|
||||
async resoumettreReprise(
|
||||
token: string,
|
||||
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
|
||||
): Promise<Users> {
|
||||
const user = await this.findByTokenReprise(token);
|
||||
if (!user) {
|
||||
throw new NotFoundException('Token reprise invalide ou expiré.');
|
||||
}
|
||||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||
if (dto.telephone !== undefined) user.telephone = dto.telephone;
|
||||
if (dto.adresse !== undefined) user.adresse = dto.adresse;
|
||||
if (dto.ville !== undefined) user.ville = dto.ville;
|
||||
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
|
||||
if (dto.photo_url !== undefined) user.photo_url = dto.photo_url;
|
||||
user.statut = StatutUtilisateurType.EN_ATTENTE;
|
||||
user.token_reprise = undefined;
|
||||
user.token_reprise_expire_le = undefined;
|
||||
return this.usersRepository.save(user);
|
||||
}
|
||||
|
||||
/** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */
|
||||
async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise<Users | null> {
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: {
|
||||
email: email.trim().toLowerCase(),
|
||||
numero_dossier: numero_dossier.trim(),
|
||||
statut: StatutUtilisateurType.REFUSE,
|
||||
token_reprise_expire_le: MoreThan(new Date()),
|
||||
},
|
||||
});
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
async remove(id: string, currentUser: Users): Promise<void> {
|
||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins');
|
||||
}
|
||||
const user = await this.findOne(id);
|
||||
if (user.role === RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException(
|
||||
'Le super administrateur ne peut pas être supprimé',
|
||||
);
|
||||
}
|
||||
const result = await this.usersRepository.delete(id);
|
||||
if (result.affected === 0) {
|
||||
throw new NotFoundException('Utilisateur introuvable');
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
const pass = '!Bezons2014';
|
||||
|
||||
bcrypt.hash(pass, 10).then(hash => {
|
||||
console.log('New Hash:', hash);
|
||||
}).catch(err => console.error(err));
|
||||
+96
-6
@@ -8,10 +8,10 @@ DO $$ BEGIN
|
||||
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
|
||||
CREATE TYPE genre_type AS ENUM ('H', 'F');
|
||||
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN
|
||||
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu');
|
||||
CREATE TYPE statut_utilisateur_type AS ENUM ('en_attente','actif','suspendu','refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN
|
||||
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre','actif','scolarise');
|
||||
@@ -55,11 +55,16 @@ CREATE TABLE utilisateurs (
|
||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
||||
adresse TEXT,
|
||||
date_naissance DATE,
|
||||
lieu_naissance_ville VARCHAR(100),
|
||||
lieu_naissance_pays VARCHAR(100),
|
||||
photo_url TEXT, -- Obligatoire pour AM, non utilisé pour parents
|
||||
consentement_photo BOOLEAN DEFAULT false,
|
||||
date_consentement_photo TIMESTAMPTZ,
|
||||
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation
|
||||
token_creation_mdp_expire_le TIMESTAMPTZ, -- Expiration 7 jours
|
||||
-- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp)
|
||||
password_reset_token VARCHAR(255) NULL,
|
||||
password_reset_expires TIMESTAMPTZ NULL,
|
||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||
@@ -74,13 +79,17 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
|
||||
ON utilisateurs(token_creation_mdp)
|
||||
WHERE token_creation_mdp IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_utilisateurs_password_reset_token
|
||||
ON utilisateurs(password_reset_token)
|
||||
WHERE password_reset_token IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : assistantes_maternelles
|
||||
-- ==========================================================
|
||||
CREATE TABLE assistantes_maternelles (
|
||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
numero_agrement VARCHAR(50),
|
||||
nir_chiffre CHAR(15),
|
||||
nir_chiffre CHAR(15) NOT NULL,
|
||||
nb_max_enfants INT,
|
||||
biographie TEXT,
|
||||
disponible BOOLEAN DEFAULT true,
|
||||
@@ -125,6 +134,33 @@ CREATE TABLE enfants_parents (
|
||||
PRIMARY KEY (id_parent, id_enfant)
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
|
||||
-- ==========================================================
|
||||
CREATE TABLE dossier_famille (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
numero_dossier VARCHAR(20) NOT NULL,
|
||||
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
|
||||
presentation TEXT,
|
||||
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
|
||||
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dossier_famille_numero ON dossier_famille(numero_dossier);
|
||||
CREATE INDEX idx_dossier_famille_id_parent ON dossier_famille(id_parent);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille_enfants
|
||||
-- ==========================================================
|
||||
CREATE TABLE dossier_famille_enfants (
|
||||
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id_dossier_famille, id_enfant)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossiers
|
||||
-- ==========================================================
|
||||
@@ -240,11 +276,17 @@ CREATE TABLE notifications (
|
||||
-- ==========================================================
|
||||
-- Table : validations
|
||||
-- ==========================================================
|
||||
-- Historique des décisions (validation / refus / suspension de compte).
|
||||
-- Colonnes commentaire + valide_par : requises par l’API Nest (TypeORM).
|
||||
-- FK en ON DELETE SET NULL : conserver la ligne si l’utilisateur référencé
|
||||
-- est supprimé (voir database/docs/FK_POLICIES.md).
|
||||
CREATE TABLE validations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_utilisateur UUID REFERENCES utilisateurs(id),
|
||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||
type VARCHAR(50),
|
||||
statut statut_validation_type DEFAULT 'en_attente',
|
||||
commentaire TEXT,
|
||||
valide_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
@@ -331,13 +373,61 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate
|
||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||
|
||||
-- ==========================================================
|
||||
-- Modification Table : utilisateurs (ajout colonnes documents)
|
||||
-- Table : relais
|
||||
-- ==========================================================
|
||||
CREATE TABLE relais (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
nom VARCHAR(255) NOT NULL,
|
||||
adresse TEXT NOT NULL,
|
||||
horaires_ouverture JSONB,
|
||||
ligne_fixe VARCHAR(20),
|
||||
actif BOOLEAN DEFAULT true,
|
||||
notes TEXT,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Modification Table : utilisateurs (ajout colonnes documents et relais)
|
||||
-- ==========================================================
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ;
|
||||
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
|
||||
-- ==========================================================
|
||||
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
||||
annee INT PRIMARY KEY,
|
||||
prochain INT NOT NULL DEFAULT 1
|
||||
);
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Ticket #110 : Token reprise après refus (lien email)
|
||||
-- ==========================================================
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription)
|
||||
-- ==========================================================
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL;
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL;
|
||||
|
||||
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
|
||||
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Seed : Documents légaux génériques v1
|
||||
|
||||
@@ -47,6 +47,7 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
| **uploads(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans l’auteur |
|
||||
| **notifications(id_utilisateur)** → `utilisateurs(id)` | **CASCADE** | Notifications propres à l’utilisateur |
|
||||
| **validations(id_utilisateur)** → `utilisateurs(id)` | **SET NULL** | Garder l’historique de décision |
|
||||
| **validations(valide_par)** → `utilisateurs(id)` | **SET NULL** | Décideur supprimé : la ligne reste |
|
||||
|
||||
> **ON UPDATE** : **NO ACTION** partout (les UUID ne changent pas).
|
||||
|
||||
@@ -84,7 +85,8 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
- `signalements_bugs.id_utilisateur` → **SET NULL**
|
||||
- `uploads.id_utilisateur` → **SET NULL**
|
||||
- `notifications.id_utilisateur` → **CASCADE**
|
||||
- `validations.id_utilisateur` → **SET NULL**
|
||||
- `validations.id_utilisateur` → **SET NULL**
|
||||
- `validations.valide_par` → **SET NULL**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Un dossier = une famille, N enfants. Ticket #119 évolution.
|
||||
-- Table: un enregistrement par famille (lien via numero_dossier / id_parent).
|
||||
CREATE TABLE IF NOT EXISTS dossier_famille (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
numero_dossier VARCHAR(20) NOT NULL,
|
||||
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
|
||||
presentation TEXT,
|
||||
type_contrat VARCHAR(50),
|
||||
repas BOOLEAN NOT NULL DEFAULT false,
|
||||
budget NUMERIC(10,2),
|
||||
planning_souhaite JSONB,
|
||||
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
|
||||
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dossier_famille_numero ON dossier_famille(numero_dossier);
|
||||
CREATE INDEX IF NOT EXISTS idx_dossier_famille_id_parent ON dossier_famille(id_parent);
|
||||
|
||||
-- Enfants concernés par ce dossier famille (N par dossier).
|
||||
CREATE TABLE IF NOT EXISTS dossier_famille_enfants (
|
||||
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id_dossier_famille, id_enfant)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Dossier famille = inscription uniquement, pas les données de dossier de garde (repas, type_contrat, budget, etc.)
|
||||
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS repas;
|
||||
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS type_contrat;
|
||||
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS budget;
|
||||
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS planning_souhaite;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Lieu de naissance (inscription AM, affichage dossier / validation). Nullable pour l'historique.
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL,
|
||||
ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration : rendre nir_chiffre NOT NULL (ticket #102)
|
||||
-- À exécuter sur les bases existantes avant déploiement du schéma avec nir_chiffre NOT NULL.
|
||||
-- Les lignes sans NIR reçoivent un NIR de test valide (format + clé) pour satisfaire la contrainte.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Renseigner un NIR de test valide pour toute ligne où nir_chiffre est NULL
|
||||
UPDATE assistantes_maternelles
|
||||
SET nir_chiffre = '275119900100102'
|
||||
WHERE nir_chiffre IS NULL;
|
||||
|
||||
-- Appliquer la contrainte NOT NULL
|
||||
ALTER TABLE assistantes_maternelles
|
||||
ALTER COLUMN nir_chiffre SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Migration #103 : Numéro de dossier (format AAAA-NNNNNN, séquence par année)
|
||||
-- Colonnes sur utilisateurs, assistantes_maternelles, parents.
|
||||
-- Table de séquence par année pour génération unique.
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Table de séquence : une ligne par année, prochain = prochain numéro à attribuer (1..999999)
|
||||
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
|
||||
annee INT PRIMARY KEY,
|
||||
prochain INT NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
-- Colonne sur utilisateurs (AM et parents : numéro attribué à la soumission)
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
|
||||
-- Colonne sur assistantes_maternelles (redondant avec users pour accès direct)
|
||||
ALTER TABLE assistantes_maternelles
|
||||
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
|
||||
-- Colonne sur parents (un numéro par famille, même valeur sur les deux lignes si co-parent)
|
||||
ALTER TABLE parents
|
||||
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
|
||||
|
||||
-- Index pour recherche par numéro
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier
|
||||
ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier
|
||||
ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier
|
||||
ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Backfill #103 : attribuer un numero_dossier aux entrées existantes (NULL)
|
||||
-- Famille = lien co_parent OU partage d'au moins un enfant (même dossier).
|
||||
-- Ordre : par année, AM puis familles (une entrée par famille), séquence 000001, 000002...
|
||||
-- À exécuter après 2026_numero_dossier.sql
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
yr INT;
|
||||
seq INT;
|
||||
num TEXT;
|
||||
r RECORD;
|
||||
family_user_ids UUID[];
|
||||
BEGIN
|
||||
-- Réinitialiser pour rejouer le backfill (cohérence AM + familles)
|
||||
UPDATE parents SET numero_dossier = NULL;
|
||||
UPDATE utilisateurs SET numero_dossier = NULL
|
||||
WHERE role IN ('parent', 'assistante_maternelle');
|
||||
UPDATE assistantes_maternelles SET numero_dossier = NULL;
|
||||
|
||||
FOR yr IN
|
||||
SELECT DISTINCT EXTRACT(YEAR FROM u.cree_le)::INT
|
||||
FROM utilisateurs u
|
||||
WHERE (
|
||||
(u.role = 'assistante_maternelle' AND u.numero_dossier IS NULL)
|
||||
OR EXISTS (SELECT 1 FROM parents p WHERE p.id_utilisateur = u.id AND p.numero_dossier IS NULL)
|
||||
)
|
||||
ORDER BY 1
|
||||
LOOP
|
||||
seq := 0;
|
||||
|
||||
-- 1) AM : par ordre de création
|
||||
FOR r IN
|
||||
SELECT u.id
|
||||
FROM utilisateurs u
|
||||
WHERE u.role = 'assistante_maternelle'
|
||||
AND u.numero_dossier IS NULL
|
||||
AND EXTRACT(YEAR FROM u.cree_le) = yr
|
||||
ORDER BY u.cree_le
|
||||
LOOP
|
||||
seq := seq + 1;
|
||||
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
|
||||
UPDATE utilisateurs SET numero_dossier = num WHERE id = r.id;
|
||||
UPDATE assistantes_maternelles SET numero_dossier = num WHERE id_utilisateur = r.id;
|
||||
END LOOP;
|
||||
|
||||
-- 2) Familles : une entrée par "dossier" (co_parent OU enfants partagés)
|
||||
-- family_rep = min(id) de la composante connexe (lien co_parent + partage d'enfants)
|
||||
FOR r IN
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
|
||||
),
|
||||
fam_ordered AS (
|
||||
SELECT fr.rep AS family_rep, MIN(u.cree_le) AS cree_le
|
||||
FROM family_rep fr
|
||||
JOIN parents p ON p.id_utilisateur = fr.id
|
||||
JOIN utilisateurs u ON u.id = p.id_utilisateur
|
||||
WHERE p.numero_dossier IS NULL
|
||||
AND EXTRACT(YEAR FROM u.cree_le) = yr
|
||||
GROUP BY fr.rep
|
||||
ORDER BY MIN(u.cree_le)
|
||||
)
|
||||
SELECT fo.family_rep
|
||||
FROM fam_ordered fo
|
||||
LOOP
|
||||
seq := seq + 1;
|
||||
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
|
||||
|
||||
WITH RECURSIVE
|
||||
links AS (
|
||||
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
|
||||
UNION ALL
|
||||
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
UNION ALL
|
||||
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
|
||||
FROM enfants_parents ep1
|
||||
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
|
||||
),
|
||||
rec AS (
|
||||
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
|
||||
UNION
|
||||
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
|
||||
),
|
||||
family_rep AS (
|
||||
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
|
||||
)
|
||||
SELECT array_agg(DISTINCT fr.id) INTO family_user_ids
|
||||
FROM family_rep fr
|
||||
WHERE fr.rep = r.family_rep;
|
||||
|
||||
UPDATE utilisateurs SET numero_dossier = num WHERE id = ANY(family_user_ids);
|
||||
UPDATE parents SET numero_dossier = num WHERE id_utilisateur = ANY(family_user_ids);
|
||||
END LOOP;
|
||||
|
||||
INSERT INTO numero_dossier_sequence (annee, prochain)
|
||||
VALUES (yr, seq + 1)
|
||||
ON CONFLICT (annee) DO UPDATE
|
||||
SET prochain = GREATEST(numero_dossier_sequence.prochain, seq + 1);
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migration #105 : Statut utilisateur « refusé » (à corriger)
|
||||
-- Ajout de la valeur 'refuse' à l'enum statut_utilisateur_type.
|
||||
|
||||
ALTER TYPE statut_utilisateur_type ADD VALUE IF NOT EXISTS 'refuse';
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Migration #110 : Token reprise après refus (lien email)
|
||||
-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé.
|
||||
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL,
|
||||
ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise
|
||||
ON utilisateurs(token_reprise)
|
||||
WHERE token_reprise IS NOT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Alignement schéma validations ↔ entité TypeORM (ticket validation IHM / #28)
|
||||
-- À exécuter sur les bases déjà créées sans ces colonnes.
|
||||
|
||||
ALTER TABLE validations
|
||||
ADD COLUMN IF NOT EXISTS commentaire TEXT;
|
||||
|
||||
ALTER TABLE validations
|
||||
ADD COLUMN IF NOT EXISTS valide_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Alignement FK validations ↔ BDD.sql / FK_POLICIES (ON DELETE SET NULL).
|
||||
-- À exécuter sur les bases créées avant la mise à jour du 2026-04-18.
|
||||
-- Noms de contraintes par défaut PostgreSQL : {table}_{colonne}_fkey.
|
||||
|
||||
ALTER TABLE validations DROP CONSTRAINT IF EXISTS validations_id_utilisateur_fkey;
|
||||
ALTER TABLE validations
|
||||
ADD CONSTRAINT validations_id_utilisateur_fkey
|
||||
FOREIGN KEY (id_utilisateur) REFERENCES utilisateurs(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE validations DROP CONSTRAINT IF EXISTS validations_valide_par_fkey;
|
||||
ALTER TABLE validations
|
||||
ADD CONSTRAINT validations_valide_par_fkey
|
||||
FOREIGN KEY (valide_par) REFERENCES utilisateurs(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,10 @@
|
||||
# Correctifs SQL (hors init `BDD.sql`)
|
||||
|
||||
Scripts à appliquer **manuellement** sur les bases déjà créées (ex. `psql` ou client SQL), dans l’ordre des dates si plusieurs fichiers.
|
||||
|
||||
| Fichier | Objet |
|
||||
|---------|--------|
|
||||
| `2026-04-17-validations-commentaire-valide-par.sql` | Ajoute `commentaire` et `valide_par` sur `validations` (requis par l’API / TypeORM). |
|
||||
| `2026-04-18-validations-fk-on-delete-set-null.sql` | Passe les FK `id_utilisateur` et `valide_par` en **ON DELETE SET NULL** (comme `BDD.sql` à jour). |
|
||||
|
||||
Les nouvelles installs via `BDD.sql` à jour incluent déjà ces colonnes et ces politiques de suppression.
|
||||
@@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent)
|
||||
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- assistantes_maternelles
|
||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
|
||||
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille')
|
||||
-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102)
|
||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence)
|
||||
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille')
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
@@ -212,7 +212,7 @@ INSERT INTO validations (id, id_utilisateur, statut, commentaire, cree_le)
|
||||
VALUES (
|
||||
'v0000000-0000-0000-0000-000000000001',
|
||||
'66666666-6666-6666-6666-666666666666',
|
||||
'accepte',
|
||||
'valide',
|
||||
'Dossier AM vérifié par gestionnaire.',
|
||||
NOW()
|
||||
)
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
|
||||
-- Aligné sur utilisateurs-test-complet.json
|
||||
-- Mot de passe universel : password (bcrypt)
|
||||
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
|
||||
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
|
||||
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
|
||||
-- Même jeu : docs/test-data/README.md, utilisateurs-test.csv/.html, tests/scripts/register-am-*-test.mjs
|
||||
-- À exécuter après BDD.sql (init DB)
|
||||
-- ============================================================
|
||||
|
||||
@@ -36,10 +40,12 @@ VALUES
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ========== ASSISTANTES MATERNELLES ==========
|
||||
-- Marie Dubois (a0000003) : née en Corse à Ajaccio – NIR 2A pour test exception Corse (1980-06-08, F).
|
||||
-- Fatima El Mansouri (a0000004) : née à l'étranger – NIR 99 pour test (1975-11-12, F).
|
||||
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
|
||||
VALUES
|
||||
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
||||
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
||||
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
|
||||
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119900100102', 3, 'Assistante maternelle expérimentée. Née à l''étranger. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS ==========
|
||||
|
||||
@@ -57,6 +57,12 @@ services:
|
||||
NODE_ENV: ${NODE_ENV}
|
||||
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
|
||||
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
|
||||
# Photos inscription (base64) — chemin absolu dans le conteneur (voir volume backend_uploads)
|
||||
UPLOAD_PHOTOS_DIR: ${UPLOAD_PHOTOS_DIR:-/app/uploads/photos}
|
||||
volumes:
|
||||
- backend_uploads:/app/uploads/photos
|
||||
# PDF CGU / privacy (upload admin) — persistance entre redémarrages
|
||||
- backend_documents_legaux:/app/documents/legaux
|
||||
depends_on:
|
||||
- database
|
||||
labels:
|
||||
@@ -66,6 +72,12 @@ services:
|
||||
- "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver"
|
||||
- "traefik.http.routers.ptitspas-api.priority=20"
|
||||
- "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000"
|
||||
# Fichiers statiques photos (URL relatives /uploads/...) — priorité > front (10)
|
||||
- "traefik.http.routers.ptitspas-uploads.rule=Host(\"app.ptits-pas.fr\") && PathPrefix(\"/uploads\")"
|
||||
- "traefik.http.routers.ptitspas-uploads.entrypoints=websecure"
|
||||
- "traefik.http.routers.ptitspas-uploads.tls.certresolver=leresolver"
|
||||
- "traefik.http.routers.ptitspas-uploads.priority=18"
|
||||
- "traefik.http.routers.ptitspas-uploads.service=ptitspas-api"
|
||||
networks:
|
||||
- ptitspas_network
|
||||
- proxy_network
|
||||
@@ -94,6 +106,8 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
backend_uploads:
|
||||
backend_documents_legaux:
|
||||
|
||||
networks:
|
||||
ptitspas_network:
|
||||
|
||||
+25
-1
@@ -19,15 +19,39 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
||||
### Développement
|
||||
- [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles
|
||||
- [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST
|
||||
- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration
|
||||
- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires
|
||||
- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code
|
||||
|
||||
### Workflows Fonctionnels
|
||||
- [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs
|
||||
- [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique
|
||||
- [**22 - Documents Légaux**](./22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
|
||||
- [**22 - Documents Légaux**](./juridique/22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
|
||||
|
||||
### Juridique (sources & technique)
|
||||
- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown,
|
||||
export PDF, lien vers la doc technique n°22
|
||||
|
||||
### Projet & suivi (Gitea / tickets)
|
||||
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés
|
||||
- [**24 - Décisions Projet**](./24_DECISIONS-PROJET.md) - Décisions architecturales et fonctionnelles
|
||||
- [**25 - Backlog Phase 2**](./25_PHASE-2-BACKLOG.md) - Fonctionnalités techniques reportées
|
||||
- [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
|
||||
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
|
||||
|
||||
### Archive & convention de nommage
|
||||
- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés
|
||||
(temporaires, obsolètes) ; règles de rangement et suppression
|
||||
- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26**
|
||||
|
||||
### Exceptions de nommage (racine `docs/`)
|
||||
Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
|
||||
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
|
||||
possible :
|
||||
- `CHARTE_GRAPHIQUE.md`
|
||||
- `EVOLUTIONS_CDC.md`
|
||||
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
|
||||
- `SuperNounou_SSS-001.md`
|
||||
|
||||
### Administration (À créer)
|
||||
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
|
||||
|
||||
@@ -1528,7 +1528,7 @@ sequenceDiagram
|
||||
|
||||
### Architecture Frontend
|
||||
|
||||
**Framework** : Flutter 3.19.0
|
||||
**Framework** : Flutter 3.29.3
|
||||
**Langage** : Dart 3.x
|
||||
**State Management** : Provider / Riverpod
|
||||
**HTTP Client** : Dio / http
|
||||
|
||||
+5
-695
@@ -1,698 +1,8 @@
|
||||
# 📄 Documentation Technique - Gestion Documents Légaux (CGU/Privacy)
|
||||
|
||||
**Version** : 1.0
|
||||
**Date** : 25 Novembre 2025
|
||||
**Auteur** : Équipe PtitsPas
|
||||
**Référence** : RGPD & Conformité juridique
|
||||
|
||||
---
|
||||
|
||||
## 📖 Table des matières
|
||||
|
||||
1. [Vue d'ensemble](#vue-densemble)
|
||||
2. [Architecture](#architecture)
|
||||
3. [Tables BDD](#tables-bdd)
|
||||
4. [Service Documents Légaux](#service-documents-légaux)
|
||||
5. [Workflow Upload & Activation](#workflow-upload--activation)
|
||||
6. [Workflow Acceptation Utilisateur](#workflow-acceptation-utilisateur)
|
||||
7. [APIs](#apis)
|
||||
8. [Interface Admin](#interface-admin)
|
||||
9. [Conformité RGPD](#conformité-rgpd)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Vue d'ensemble
|
||||
|
||||
### Problématique
|
||||
|
||||
Chaque collectivité déployant P'titsPas on-premise doit pouvoir :
|
||||
1. ✅ **Personnaliser** les CGU et la Politique de confidentialité
|
||||
2. ✅ **Versionner** les documents (traçabilité juridique)
|
||||
3. ✅ **Tracer** qui a accepté quelle version (RGPD)
|
||||
4. ✅ **Prouver** l'acceptation (IP, User-Agent, horodatage)
|
||||
5. ✅ **Empêcher** le retour en arrière (sécurité juridique)
|
||||
|
||||
### Solution
|
||||
|
||||
- **Documents génériques v1** fournis par défaut (rédigés avec juriste)
|
||||
- **Upload de nouvelles versions** par l'admin (PDF uniquement)
|
||||
- **Versioning automatique** (incrémentation sans retour arrière)
|
||||
- **Activation manuelle** (prévisualisation avant mise en prod)
|
||||
- **Traçabilité complète** (hash SHA-256, IP, User-Agent)
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Flux de données
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Workflow Documents │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. UPLOAD (Admin) │
|
||||
│ Admin ──▶ API ──▶ File System ──▶ BDD │
|
||||
│ /documents/legaux/ │
|
||||
│ cgu_v4_<timestamp>.pdf │
|
||||
│ │
|
||||
│ 2. ACTIVATION (Admin) │
|
||||
│ Admin ──▶ API ──▶ BDD (actif=true) │
|
||||
│ │
|
||||
│ 3. ACCEPTATION (Utilisateur) │
|
||||
│ User ──▶ Frontend ──▶ API ──▶ BDD │
|
||||
│ (inscription) (trace IP/UA) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Composants
|
||||
|
||||
1. **Table `documents_legaux`** : Stockage versions + métadonnées
|
||||
2. **Table `acceptations_documents`** : Traçabilité acceptations
|
||||
3. **Service `DocumentsLegauxService`** : Upload, versioning, activation
|
||||
4. **API REST** : CRUD documents
|
||||
5. **Interface Admin** : Upload + activation
|
||||
6. **Interface Inscription** : Affichage + acceptation
|
||||
|
||||
---
|
||||
|
||||
## 📊 Tables BDD
|
||||
|
||||
### Table 1 : `documents_legaux`
|
||||
|
||||
```sql
|
||||
-- Table pour gérer les versions des documents légaux
|
||||
CREATE TABLE documents_legaux (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
||||
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
|
||||
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
|
||||
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
|
||||
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
|
||||
actif BOOLEAN DEFAULT false, -- Version actuellement active
|
||||
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
|
||||
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
|
||||
active_le TIMESTAMPTZ, -- Date d'activation
|
||||
UNIQUE(type, version) -- Pas de doublon version
|
||||
);
|
||||
|
||||
-- Index pour performance
|
||||
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
|
||||
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
|
||||
```
|
||||
|
||||
**Contraintes** :
|
||||
- ✅ Un seul document `actif=true` par type à la fois
|
||||
- ✅ Versioning auto-incrémenté (pas de gaps)
|
||||
- ✅ Hash SHA-256 pour vérifier l'intégrité du fichier
|
||||
|
||||
---
|
||||
|
||||
### Table 2 : `acceptations_documents`
|
||||
|
||||
```sql
|
||||
-- Table de traçabilité des acceptations (RGPD)
|
||||
CREATE TABLE acceptations_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_document UUID REFERENCES documents_legaux(id),
|
||||
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
|
||||
version_document INTEGER NOT NULL, -- Version acceptée
|
||||
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
|
||||
ip_address INET, -- IP de l'utilisateur (RGPD)
|
||||
user_agent TEXT -- Navigateur (preuve)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||
```
|
||||
|
||||
**Données capturées** :
|
||||
- ✅ **Qui** : `id_utilisateur`
|
||||
- ✅ **Quoi** : `type_document`, `version_document`
|
||||
- ✅ **Quand** : `accepte_le`
|
||||
- ✅ **Où** : `ip_address`
|
||||
- ✅ **Comment** : `user_agent`
|
||||
|
||||
---
|
||||
|
||||
### Modification table `utilisateurs`
|
||||
|
||||
```sql
|
||||
-- Ajouter colonnes pour référence rapide (optionnel)
|
||||
ALTER TABLE utilisateurs
|
||||
ADD COLUMN cgu_version_acceptee INTEGER,
|
||||
ADD COLUMN cgu_acceptee_le TIMESTAMPTZ,
|
||||
ADD COLUMN privacy_version_acceptee INTEGER,
|
||||
ADD COLUMN privacy_acceptee_le TIMESTAMPTZ;
|
||||
```
|
||||
|
||||
**Note** : Ces colonnes sont **redondantes** avec `acceptations_documents`, mais permettent un accès rapide sans JOIN.
|
||||
|
||||
---
|
||||
|
||||
### Seed initial
|
||||
|
||||
```sql
|
||||
-- Documents génériques v1 (fournis par défaut)
|
||||
INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_hash, actif, televerse_le, active_le) VALUES
|
||||
('cgu', 1, 'cgu_v1_default.pdf', '/documents/legaux/cgu_v1_default.pdf', 'a3f8b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2', true, now(), now()),
|
||||
('privacy', 1, 'privacy_v1_default.pdf', '/documents/legaux/privacy_v1_default.pdf', 'b4f9c3d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4', true, now(), now());
|
||||
```
|
||||
|
||||
**Fichiers à fournir** :
|
||||
- `/documents/legaux/cgu_v1_default.pdf` (rédigé avec juriste)
|
||||
- `/documents/legaux/privacy_v1_default.pdf` (conforme RGPD)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Service Documents Légaux
|
||||
|
||||
### Responsabilités
|
||||
|
||||
1. **Récupérer documents actifs** : `getDocumentsActifs()`
|
||||
2. **Uploader nouvelle version** : `uploadNouvelleVersion(type, file, userId)`
|
||||
3. **Activer une version** : `activerVersion(documentId)`
|
||||
4. **Lister versions** : `listerVersions(type)`
|
||||
5. **Télécharger document** : `telechargerDocument(documentId)`
|
||||
|
||||
### Implémentation (TypeScript)
|
||||
|
||||
```typescript
|
||||
// backend/src/documents-legaux/documents-legaux.service.ts
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DocumentLegal } from './entities/document-legal.entity';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
@Injectable()
|
||||
export class DocumentsLegauxService {
|
||||
private readonly UPLOAD_DIR = '/app/documents/legaux';
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DocumentLegal)
|
||||
private docRepo: Repository<DocumentLegal>,
|
||||
) {}
|
||||
|
||||
// Récupérer les documents actifs
|
||||
async getDocumentsActifs(): Promise<{ cgu: DocumentLegal; privacy: DocumentLegal }> {
|
||||
const cgu = await this.docRepo.findOne({
|
||||
where: { type: 'cgu', actif: true },
|
||||
});
|
||||
|
||||
const privacy = await this.docRepo.findOne({
|
||||
where: { type: 'privacy', actif: true },
|
||||
});
|
||||
|
||||
if (!cgu || !privacy) {
|
||||
throw new Error('Documents légaux manquants');
|
||||
}
|
||||
|
||||
return { cgu, privacy };
|
||||
}
|
||||
|
||||
// Uploader une nouvelle version
|
||||
async uploadNouvelleVersion(
|
||||
type: 'cgu' | 'privacy',
|
||||
file: Express.Multer.File,
|
||||
userId: string,
|
||||
): Promise<DocumentLegal> {
|
||||
// 1. Calculer la prochaine version
|
||||
const lastDoc = await this.docRepo.findOne({
|
||||
where: { type },
|
||||
order: { version: 'DESC' },
|
||||
});
|
||||
const nouvelleVersion = (lastDoc?.version || 0) + 1;
|
||||
|
||||
// 2. Calculer le hash du fichier
|
||||
const fileBuffer = file.buffer;
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
// 3. Générer le nom de fichier unique
|
||||
const timestamp = Date.now();
|
||||
const fileName = `${type}_v${nouvelleVersion}_${timestamp}.pdf`;
|
||||
const filePath = path.join(this.UPLOAD_DIR, fileName);
|
||||
|
||||
// 4. Sauvegarder le fichier
|
||||
await fs.mkdir(this.UPLOAD_DIR, { recursive: true });
|
||||
await fs.writeFile(filePath, fileBuffer);
|
||||
|
||||
// 5. Créer l'entrée en BDD
|
||||
const document = this.docRepo.create({
|
||||
type,
|
||||
version: nouvelleVersion,
|
||||
fichier_nom: file.originalname,
|
||||
fichier_path: filePath,
|
||||
fichier_hash: hash,
|
||||
actif: false, // Pas actif par défaut
|
||||
televerse_par: userId,
|
||||
televerse_le: new Date(),
|
||||
});
|
||||
|
||||
return await this.docRepo.save(document);
|
||||
}
|
||||
|
||||
// Activer une version
|
||||
async activerVersion(documentId: string): Promise<void> {
|
||||
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document non trouvé');
|
||||
}
|
||||
|
||||
// Transaction : désactiver l'ancienne version, activer la nouvelle
|
||||
await this.docRepo.manager.transaction(async (manager) => {
|
||||
// Désactiver toutes les versions de ce type
|
||||
await manager.update(
|
||||
DocumentLegal,
|
||||
{ type: document.type, actif: true },
|
||||
{ actif: false },
|
||||
);
|
||||
|
||||
// Activer la nouvelle version
|
||||
await manager.update(
|
||||
DocumentLegal,
|
||||
{ id: documentId },
|
||||
{ actif: true, active_le: new Date() },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Lister toutes les versions (pour l'admin)
|
||||
async listerVersions(type: 'cgu' | 'privacy'): Promise<DocumentLegal[]> {
|
||||
return await this.docRepo.find({
|
||||
where: { type },
|
||||
order: { version: 'DESC' },
|
||||
relations: ['televerse_par'],
|
||||
});
|
||||
}
|
||||
|
||||
// Télécharger un document (stream)
|
||||
async telechargerDocument(documentId: string): Promise<{ stream: Buffer; filename: string }> {
|
||||
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document non trouvé');
|
||||
}
|
||||
|
||||
const fileBuffer = await fs.readFile(document.fichier_path);
|
||||
|
||||
return {
|
||||
stream: fileBuffer,
|
||||
filename: document.fichier_nom,
|
||||
};
|
||||
}
|
||||
|
||||
// Vérifier l'intégrité d'un document
|
||||
async verifierIntegrite(documentId: string): Promise<boolean> {
|
||||
const document = await this.docRepo.findOne({ where: { id: documentId } });
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document non trouvé');
|
||||
}
|
||||
|
||||
const fileBuffer = await fs.readFile(document.fichier_path);
|
||||
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
|
||||
return hash === document.fichier_hash;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Workflow Upload & Activation
|
||||
|
||||
### Diagramme de séquence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Admin
|
||||
participant API as Backend API
|
||||
participant FS as File System
|
||||
participant DB as PostgreSQL
|
||||
|
||||
A->>API: POST /api/v1/documents-legaux<br/>{type: 'cgu', file: PDF}
|
||||
|
||||
API->>API: Validation fichier<br/>(PDF, max 10MB)
|
||||
API->>API: Calcul hash SHA-256
|
||||
|
||||
API->>DB: SELECT MAX(version)<br/>WHERE type='cgu'
|
||||
DB-->>API: version = 3
|
||||
|
||||
API->>API: Nouvelle version = 4
|
||||
|
||||
API->>FS: Enregistrer fichier<br/>/documents/legaux/cgu_v4_<timestamp>.pdf
|
||||
FS-->>API: ✅ Fichier sauvegardé
|
||||
|
||||
API->>DB: INSERT INTO documents_legaux<br/>(type, version=4, actif=false)
|
||||
DB-->>API: ✅ Document créé
|
||||
|
||||
API-->>A: 201 Created<br/>{id, version: 4, actif: false}
|
||||
|
||||
A->>A: Prévisualisation PDF
|
||||
A->>API: PATCH /api/v1/documents-legaux/{id}/activer
|
||||
|
||||
API->>DB: BEGIN TRANSACTION
|
||||
API->>DB: UPDATE documents_legaux<br/>SET actif=false WHERE type='cgu'
|
||||
API->>DB: UPDATE documents_legaux<br/>SET actif=true, active_le=now()<br/>WHERE id={id}
|
||||
API->>DB: COMMIT
|
||||
|
||||
API-->>A: ✅ CGU v4 activées
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📥 Workflow Acceptation Utilisateur
|
||||
|
||||
### Diagramme de séquence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as Utilisateur
|
||||
participant App as Frontend
|
||||
participant API as Backend
|
||||
participant DB as PostgreSQL
|
||||
|
||||
U->>App: Inscription (étape CGU)
|
||||
|
||||
App->>API: GET /api/v1/documents-legaux/actifs
|
||||
API->>DB: SELECT * FROM documents_legaux<br/>WHERE actif=true
|
||||
DB-->>API: {cgu: v4, privacy: v2}
|
||||
API-->>App: {cgu: {version: 4, url: '...'}, privacy: {...}}
|
||||
|
||||
App->>App: Afficher liens PDF<br/>"CGU v4" et "Privacy v2"
|
||||
|
||||
U->>U: Lit les documents
|
||||
U->>U: Coche "J'accepte"
|
||||
|
||||
App->>API: POST /api/v1/auth/register<br/>{..., cgu_version: 4, privacy_version: 2, ip, user_agent}
|
||||
|
||||
API->>DB: BEGIN TRANSACTION
|
||||
|
||||
API->>DB: INSERT INTO utilisateurs<br/>(..., cgu_version_acceptee=4, privacy_version_acceptee=2)
|
||||
DB-->>API: id_utilisateur
|
||||
|
||||
API->>DB: INSERT INTO acceptations_documents<br/>(id_utilisateur, type='cgu', version=4, ip, user_agent)
|
||||
API->>DB: INSERT INTO acceptations_documents<br/>(id_utilisateur, type='privacy', version=2, ip, user_agent)
|
||||
|
||||
API->>DB: COMMIT
|
||||
|
||||
API-->>App: ✅ Inscription réussie
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 APIs
|
||||
|
||||
### API 1 : Récupérer documents actifs (Public)
|
||||
|
||||
```http
|
||||
GET /api/v1/documents-legaux/actifs
|
||||
```
|
||||
|
||||
**Réponse 200** :
|
||||
```json
|
||||
{
|
||||
"cgu": {
|
||||
"id": "uuid-cgu-v4",
|
||||
"type": "cgu",
|
||||
"version": 4,
|
||||
"url": "/api/v1/documents-legaux/uuid-cgu-v4/download",
|
||||
"active_le": "2025-11-20T14:30:00Z"
|
||||
},
|
||||
"privacy": {
|
||||
"id": "uuid-privacy-v2",
|
||||
"type": "privacy",
|
||||
"version": 2,
|
||||
"url": "/api/v1/documents-legaux/uuid-privacy-v2/download",
|
||||
"active_le": "2025-10-15T09:15:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### API 2 : Lister versions (Admin)
|
||||
|
||||
```http
|
||||
GET /api/v1/documents-legaux/:type/versions
|
||||
Authorization: Bearer <super_admin_token>
|
||||
```
|
||||
|
||||
**Paramètres** :
|
||||
- `type` : `cgu` | `privacy`
|
||||
|
||||
**Réponse 200** :
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "uuid-cgu-v4",
|
||||
"version": 4,
|
||||
"fichier_nom": "CGU_Mairie_Bezons_2025.pdf",
|
||||
"actif": true,
|
||||
"televerse_par": {
|
||||
"id": "uuid-admin",
|
||||
"prenom": "Lucas",
|
||||
"nom": "MOREAU"
|
||||
},
|
||||
"televerse_le": "2025-11-20T14:00:00Z",
|
||||
"active_le": "2025-11-20T14:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "uuid-cgu-v3",
|
||||
"version": 3,
|
||||
"fichier_nom": "CGU_v3.pdf",
|
||||
"actif": false,
|
||||
"televerse_par": {
|
||||
"id": "uuid-admin",
|
||||
"prenom": "Admin",
|
||||
"nom": "Système"
|
||||
},
|
||||
"televerse_le": "2025-10-15T09:00:00Z",
|
||||
"active_le": "2025-10-15T09:15:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### API 3 : Upload nouvelle version (Admin)
|
||||
|
||||
```http
|
||||
POST /api/v1/documents-legaux
|
||||
Authorization: Bearer <super_admin_token>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**Body** :
|
||||
```
|
||||
type: cgu
|
||||
file: <fichier PDF>
|
||||
```
|
||||
|
||||
**Réponse 201** :
|
||||
```json
|
||||
{
|
||||
"id": "uuid-nouveau-doc",
|
||||
"type": "cgu",
|
||||
"version": 5,
|
||||
"fichier_nom": "CGU_Mairie_Bezons_2025_v2.pdf",
|
||||
"actif": false,
|
||||
"televerse_le": "2025-11-25T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Erreurs** :
|
||||
- `400 Bad Request` : Fichier non PDF ou trop volumineux (>10MB)
|
||||
- `401 Unauthorized` : Token manquant ou invalide
|
||||
- `403 Forbidden` : Rôle insuffisant (pas super_admin)
|
||||
|
||||
---
|
||||
|
||||
### API 4 : Activer une version (Admin)
|
||||
|
||||
```http
|
||||
PATCH /api/v1/documents-legaux/:id/activer
|
||||
Authorization: Bearer <super_admin_token>
|
||||
```
|
||||
|
||||
**Réponse 200** :
|
||||
```json
|
||||
{
|
||||
"message": "Document activé avec succès",
|
||||
"documentId": "uuid-nouveau-doc",
|
||||
"type": "cgu",
|
||||
"version": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### API 5 : Télécharger document (Public)
|
||||
|
||||
```http
|
||||
GET /api/v1/documents-legaux/:id/download
|
||||
```
|
||||
|
||||
**Réponse 200** :
|
||||
```
|
||||
Content-Type: application/pdf
|
||||
Content-Disposition: attachment; filename="CGU_v5.pdf"
|
||||
|
||||
<binary PDF data>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### API 6 : Historique acceptations utilisateur (Admin)
|
||||
|
||||
```http
|
||||
GET /api/v1/users/:userId/acceptations
|
||||
Authorization: Bearer <super_admin_token>
|
||||
```
|
||||
|
||||
**Réponse 200** :
|
||||
```json
|
||||
[
|
||||
{
|
||||
"type_document": "cgu",
|
||||
"version_document": 4,
|
||||
"accepte_le": "2025-11-20T15:30:00Z",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
},
|
||||
{
|
||||
"type_document": "privacy",
|
||||
"version_document": 2,
|
||||
"accepte_le": "2025-11-20T15:30:00Z",
|
||||
"ip_address": "192.168.1.100",
|
||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 Interface Admin
|
||||
|
||||
### Écran Gestion Documents Légaux
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 📄 Gestion des Documents Légaux │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ [ CGU ] [ Politique de confidentialité ] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 📋 Conditions Générales d'Utilisation (CGU) │
|
||||
│ │
|
||||
│ Version active : v4 │
|
||||
│ Activée le : 20/11/2025 14:30 │
|
||||
│ Téléversée par : Lucas MOREAU │
|
||||
│ │
|
||||
│ [ 📥 Télécharger ] [ 👁️ Prévisualiser ] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 📤 Uploader une nouvelle version │
|
||||
│ │
|
||||
│ ⚠️ Attention : L'upload d'une nouvelle version │
|
||||
│ créera la version v5. Vous pourrez la prévisualiser│
|
||||
│ avant de l'activer. │
|
||||
│ │
|
||||
│ [ Choisir un fichier PDF ] (max 10MB) │
|
||||
│ │
|
||||
│ [ 📤 Uploader ] │
|
||||
│ │
|
||||
│ ───────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ 📜 Historique des versions │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ ✅ v4 (Active) │ │
|
||||
│ │ Activée le : 20/11/2025 14:30 │ │
|
||||
│ │ Par : Lucas MOREAU │ │
|
||||
│ │ Hash : a3f8b2c4...e0f1a2 ✓ │ │
|
||||
│ │ [ 📥 Télécharger ] [ 👁️ Voir ] │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────┐ │
|
||||
│ │ v3 (Inactive) │ │
|
||||
│ │ Activée le : 15/10/2025 09:15 │ │
|
||||
│ │ Par : Admin Système │ │
|
||||
│ │ Hash : b4f9c3d6...f2a3b4 ✓ │ │
|
||||
│ │ [ 📥 Télécharger ] [ 👁️ Voir ] [🔄 Réactiver]│ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Conformité RGPD
|
||||
|
||||
### Données capturées
|
||||
|
||||
| Donnée | Justification RGPD | Durée conservation |
|
||||
|--------|-------------------|-------------------|
|
||||
| `id_utilisateur` | Traçabilité acceptation | Durée du compte |
|
||||
| `version_document` | Preuve version acceptée | Durée du compte |
|
||||
| `accepte_le` | Horodatage légal | Durée du compte |
|
||||
| `ip_address` | Preuve origine acceptation | 1 an (recommandé) |
|
||||
| `user_agent` | Preuve navigateur/appareil | 1 an (recommandé) |
|
||||
|
||||
### Droits utilisateur
|
||||
|
||||
#### Droit d'accès (Article 15)
|
||||
L'utilisateur peut demander :
|
||||
- Quelles versions il a acceptées
|
||||
- Quand il les a acceptées
|
||||
- Depuis quelle IP
|
||||
|
||||
**API** : `GET /api/v1/users/me/acceptations`
|
||||
|
||||
#### Droit à l'oubli (Article 17)
|
||||
Lors de la suppression du compte :
|
||||
- Suppression des données personnelles
|
||||
- Conservation des acceptations anonymisées (obligation légale)
|
||||
|
||||
**Implémentation** :
|
||||
```sql
|
||||
-- Anonymisation (pas suppression totale)
|
||||
UPDATE acceptations_documents
|
||||
SET ip_address = NULL,
|
||||
user_agent = NULL
|
||||
WHERE id_utilisateur = '<uuid>';
|
||||
|
||||
-- Puis suppression utilisateur
|
||||
DELETE FROM utilisateurs WHERE id = '<uuid>';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Références
|
||||
|
||||
### Documentation interne
|
||||
- [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md)
|
||||
- [10_DATABASE.md](./10_DATABASE.md)
|
||||
- [11_API.md](./11_API.md)
|
||||
- [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md)
|
||||
|
||||
### Documentation externe
|
||||
- [RGPD - Article 7 (Consentement)](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre2#Article7)
|
||||
- [RGPD - Article 15 (Droit d'accès)](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre3#Article15)
|
||||
- [RGPD - Article 17 (Droit à l'oubli)](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre3#Article17)
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 25 Novembre 2025
|
||||
**Version** : 1.0
|
||||
**Statut** : ✅ Document validé
|
||||
# Fichier déplacé
|
||||
|
||||
La documentation **Documents légaux** a été déplacée vers :
|
||||
|
||||
**[juridique/22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)**
|
||||
|
||||
Voir aussi le dossier **[juridique/](./juridique/)** pour les sources **CGU**
|
||||
et **CGC** en Markdown.
|
||||
|
||||
+312
-65
@@ -1,15 +1,15 @@
|
||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||
|
||||
**Version** : 1.4
|
||||
**Date** : 9 Février 2026
|
||||
**Version** : 1.9
|
||||
**Date** : 23 Avril 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
**Estimation totale** : ~184h
|
||||
**Estimation totale** : ~220h
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Liste des tickets Gitea
|
||||
|
||||
**Les numéros de section dans ce document = numéros d’issues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
|
||||
**Les numéros de section dans ce document = numéros d’issues Gitea** (ticket #14 = issue Gitea #14, etc.). Le **numéro d’une nouvelle issue** n’est pas choisi dans ce fichier : il est **attribué par Gitea** (champ `number` dans la réponse JSON du `POST` création d’issue, ou affiché dans l’UI après création). Procédure : [**26_GITEA-API.md**](./26_GITEA-API.md) (§ 2 authentification, § 3.2 issues). Source dépôt : `jmartin/petitspas` (état au 25 mars 2026 ; #106, #107, #109, #119 fermés via API).
|
||||
|
||||
| Gitea # | Titre (dépôt) | Statut |
|
||||
|--------|----------------|--------|
|
||||
@@ -25,13 +25,89 @@
|
||||
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
|
||||
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
|
||||
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
|
||||
| 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
|
||||
| 15 | [Frontend] Écran Paramètres (accès permanent) / Intégration panneau | Ouvert |
|
||||
| 16 | [Doc] Documentation configuration on-premise | Ouvert |
|
||||
| 17–88 | (voir sections ci‑dessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
|
||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
|
||||
| 17 | [Backend] API Création gestionnaire | ✅ Terminé |
|
||||
| 18 | [Backend] API Inscription Parent - REFONTE (Workflow complet 6 étapes) | ✅ Terminé |
|
||||
| 19 | [Backend] API Inscription Parent (étape 2 - Parent 2) | ✅ Terminé |
|
||||
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
|
||||
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
|
||||
| 24 | [Backend] API Création mot de passe | Ouvert |
|
||||
| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 28 | [Backend] Templates Email - Validation | Ouvert |
|
||||
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
|
||||
| 32 | [Backend] Service Documents Légaux | Ouvert |
|
||||
| 33 | [Backend] API Documents Légaux | Ouvert |
|
||||
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
|
||||
| 35 | [Frontend] Écran Création Gestionnaire | Ouvert |
|
||||
| 36 | [Frontend] Inscription Parent - Étape 1 (Parent 1) | ✅ Terminé |
|
||||
| 37 | [Frontend] Inscription Parent - Étape 2 (Parent 2) | Ouvert |
|
||||
| 38 | [Frontend] Inscription Parent - Étape 3 (Enfants) | ✅ Terminé |
|
||||
| 39 | [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) | ✅ Terminé |
|
||||
| 40 | [Frontend] Inscription AM - Panneau 1 (Identité) | ✅ Terminé |
|
||||
| 41 | [Frontend] Inscription AM - Panneau 2 (Infos pro) | ✅ Terminé |
|
||||
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
|
||||
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
|
||||
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
|
||||
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
|
||||
| 47 | [Frontend] Écran Changement MDP Obligatoire | ✅ Fermé |
|
||||
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
|
||||
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
|
||||
| 50 | [Frontend] Affichage dynamique CGU lors inscription | Ouvert |
|
||||
| 51 | [Frontend] Écran Logs Admin (optionnel v1.1) | Ouvert |
|
||||
| 52 | [Tests] Tests unitaires Backend | Ouvert |
|
||||
| 53 | [Tests] Tests intégration Backend | Ouvert |
|
||||
| 54 | [Tests] Tests E2E Frontend | Ouvert |
|
||||
| 55 | [Doc] Documentation API OpenAPI/Swagger | Ouvert |
|
||||
| 56 | [Backend] Service Upload & Stockage fichiers | Ouvert |
|
||||
| 58 | [Backend] Service Logging (Winston) | Ouvert |
|
||||
| 59 | [Infra] Volume Docker pour uploads | Ouvert |
|
||||
| 60 | [Infra] Volume Docker pour documents légaux | Ouvert |
|
||||
| 61 | [Doc] Guide installation & configuration | Ouvert |
|
||||
| 62 | [Doc] Amendement CDC v1.4 - Suppression SMS | Ouvert |
|
||||
| 63 | [Doc] Rédaction CGU/Privacy génériques v1 | Ouvert |
|
||||
| 78 | [Frontend] Refonte Infrastructure Formulaires Multi-modes | ✅ Terminé |
|
||||
| 79 | [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) | ✅ Terminé |
|
||||
| 81 | [Frontend] Corrections suite refactoring widgets | ✅ Terminé |
|
||||
| 83 | [Frontend] Adapter RegisterChoiceScreen pour mobile | ✅ Terminé |
|
||||
| 86 / 88 | Doublons fermés (voir #12, #14, #15) | ✅ Fermé |
|
||||
| 89 | Log des appels API en mode debug | Ouvert |
|
||||
| 91 | [Frontend] Inscription AM – Branchement soumission formulaire à l'API | Ouvert |
|
||||
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
|
||||
| 93 | [Frontend] Panneau Admin - Homogénéisation des onglets | ✅ Fermé |
|
||||
| 94 | [Backend] Relais - Modèle, API CRUD et liaison gestionnaire | ✅ Terminé |
|
||||
| 95 | [Frontend] Admin - Gestion des Relais et rattachement gestionnaire | ✅ Fermé |
|
||||
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
|
||||
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
|
||||
| 101 | [Frontend] Inscription Parent – Branchement soumission formulaire à l'API | Ouvert |
|
||||
| 103 | Numéro de dossier – backend | Ouvert |
|
||||
| 104 | Numéro de dossier – frontend | Ouvert |
|
||||
| 105 | Statut « refusé » | Ouvert |
|
||||
| 106 | Liste familles en attente | ✅ Fermé |
|
||||
| 107 | Onglet « À valider » + listes | ✅ Fermé |
|
||||
| 108 | Validation dossier famille | Ouvert |
|
||||
| 109 | Modale de validation | ✅ Fermé |
|
||||
| 110 | Refus sans suppression | Ouvert |
|
||||
| 111 | Reprise après refus – backend | Ouvert |
|
||||
| 112 | Reprise après refus – frontend | Ouvert |
|
||||
| 113 | Doublons à l'inscription | Ouvert |
|
||||
| 114 | Doublons – alerte gestionnaire | Ouvert |
|
||||
| 115 | Rattachement parent – backend | Ouvert |
|
||||
| 116 | Rattachement parent – frontend | Ouvert |
|
||||
| 117 | Évolution du cahier des charges | Ouvert |
|
||||
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
|
||||
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents (suite #91) | ✅ Fermé |
|
||||
| 127 | [Full-stack] Mot de passe oublié — flux complet (demande, e-mail, réinitialisation) | ✅ Fermé · **milestone 0.1.0** + label **v0.1.0** (Gitea) |
|
||||
|
||||
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
|
||||
|
||||
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).*
|
||||
|
||||
---
|
||||
|
||||
## 📊 Vue d'ensemble
|
||||
@@ -43,11 +119,11 @@
|
||||
| **P0** | 7 tickets | ~5h | Amendements BDD (BLOQUANT) |
|
||||
| **P1** | 7 tickets | ~22h | Configuration système (BLOQUANT) |
|
||||
| **P2** | 18 tickets | ~50h | Backend métier |
|
||||
| **P3** | 22 tickets | ~71h | Frontend |
|
||||
| **P3** | 23 tickets | ~83h | Frontend |
|
||||
| **P4** | 4 tickets | ~24h | Tests & Documentation |
|
||||
| **CRITIQUES** | 6 tickets | ~13h | Upload, Logs, Infra, CDC |
|
||||
| **JURIDIQUE** | 1 ticket | ~8h | Rédaction CGU/Privacy |
|
||||
| **TOTAL** | **65 tickets** | **~184h** | |
|
||||
| **TOTAL** | **66 tickets** | **~220h** | |
|
||||
|
||||
---
|
||||
|
||||
@@ -168,7 +244,7 @@ Créer les tables pour gérer les versions des documents légaux (CGU/Privacy) e
|
||||
- [ ] Créer migration Prisma
|
||||
- [ ] Tester migration
|
||||
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md)
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -316,21 +392,22 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
|
||||
|
||||
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
|
||||
|
||||
### Ticket #17 : [Backend] API Création gestionnaire
|
||||
### Ticket #17 : [Backend] API Création gestionnaire ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `auth`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
|
||||
|
||||
**Description** :
|
||||
Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Endpoint `POST /api/v1/gestionnaires`
|
||||
- [ ] Validation DTO
|
||||
- [ ] Hash bcrypt
|
||||
- [ ] Flag `changement_mdp_obligatoire = TRUE`
|
||||
- [ ] Guards (super_admin only)
|
||||
- [ ] Email de notification (utiliser MailService avec config dynamique)
|
||||
- [ ] Tests unitaires
|
||||
- [x] Endpoint `POST /api/v1/gestionnaires`
|
||||
- [x] Validation DTO
|
||||
- [x] Hash bcrypt
|
||||
- [x] Flag `changement_mdp_obligatoire = TRUE`
|
||||
- [x] Guards (super_admin only)
|
||||
- [x] Email de notification (utiliser MailService avec config dynamique)
|
||||
- [x] Tests unitaires
|
||||
|
||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
|
||||
|
||||
@@ -443,22 +520,24 @@ Finalisation de l'inscription parent (présentation, CGU, récapitulatif - inté
|
||||
|
||||
---
|
||||
|
||||
### Ticket #24 : [Backend] API Création mot de passe
|
||||
### Ticket #24 : [Backend] API Création mot de passe ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
**Statut** : ✅ TERMINÉ (livré sur `develop` + `master`, févr. 2026 — aligné ticket Gitea **#123** durcissement token)
|
||||
|
||||
**Description** :
|
||||
Créer les endpoints pour permettre aux utilisateurs de créer leur mot de passe via le lien reçu par email.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Endpoint `GET /api/v1/auth/verify-token?token=<uuid>`
|
||||
- [ ] Endpoint `POST /api/v1/auth/create-password`
|
||||
- [ ] Validation token (existe, non expiré, non utilisé)
|
||||
- [ ] Hash bcrypt + suppression token
|
||||
- [ ] Activation compte (`statut = actif`)
|
||||
- [ ] Tests unitaires
|
||||
- [x] Endpoint `GET /api/v1/auth/verify-token?token=<uuid>`
|
||||
- [x] Endpoint `POST /api/v1/auth/create-password` (body : `token`, `password`, `password_confirmation`)
|
||||
- [x] Validation token (existe, non expiré, non utilisé) + invalidation explicite si expiré
|
||||
- [x] Hash bcrypt + suppression token (usage unique atomique côté SQL)
|
||||
- [x] Activation compte (`statut = actif`, `changement_mdp_obligatoire = false`)
|
||||
- [ ] Tests unitaires / e2e (hors périmètre immédiat ; suite Jest actuelle à corriger séparément)
|
||||
|
||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-7--création-du-mot-de-passe)
|
||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-7--création-du-mot-de-passe)
|
||||
**Suite** : alignement **frontend** → ticket **#43** (appels explicites ci-dessous).
|
||||
|
||||
---
|
||||
|
||||
@@ -513,22 +592,23 @@ Installer et configurer Nodemailer pour l'envoi d'emails.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #28 : [Backend] Templates Email - Validation
|
||||
### Ticket #28 : [Backend] Templates Email - Validation ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `email`
|
||||
**Labels** : `backend`, `p2`, `email`
|
||||
**Statut** : ✅ TERMINÉ
|
||||
|
||||
**Description** :
|
||||
Créer les templates d'emails pour la validation des comptes (avec lien création MDP).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Template Parent 1 (création MDP)
|
||||
- [ ] URL app : Lire depuis `ConfigService.get('app_url')`
|
||||
- [ ] Nom app : Lire depuis `ConfigService.get('app_name')`
|
||||
- [ ] Expéditeur : Lire depuis ConfigService
|
||||
- [ ] Template Parent 2 (co-parent création MDP)
|
||||
- [ ] Template AM (création MDP)
|
||||
- [ ] Intégration Handlebars
|
||||
- [ ] Tests d'envoi
|
||||
- [x] Template **Parent** (création MDP, parent principal ou co-parent) — `account-validated-parent.hbs`
|
||||
- [x] URL app : Lire depuis `ConfigService.get('app_url')` (lien `/create-password?token=…`)
|
||||
- [x] Nom app : Lire depuis `ConfigService.get('app_name')`
|
||||
- [x] Expéditeur : via `sendEmail` (email_from_name / email_from_address)
|
||||
- [x] Template AM (création MDP) — `account-validated-am.hbs`
|
||||
- [x] Intégration Handlebars + copie `.hbs` dans `dist` (`nest-cli.json` assets)
|
||||
- [x] Envoi déclenché dans `UserService.validateUser` (parent / AM sans mot de passe) + renouvellement expiration token
|
||||
- [x] Tests unitaires (`mail.service.spec.ts`, mock `sendEmail`)
|
||||
|
||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-6--réception-de-la-notification)
|
||||
|
||||
@@ -567,17 +647,18 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
|
||||
|
||||
---
|
||||
|
||||
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
|
||||
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ✅
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
**Labels** : `backend`, `p2`, `auth`, `security`
|
||||
**Statut** : ✅ TERMINÉ
|
||||
|
||||
**Description** :
|
||||
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Endpoint `POST /api/v1/auth/change-password-required`
|
||||
- [ ] Vérification flag `changement_mdp_obligatoire`
|
||||
- [ ] Mise à jour flag après changement
|
||||
- [x] Endpoint `POST /api/v1/auth/change-password-required`
|
||||
- [x] Vérification flag `changement_mdp_obligatoire`
|
||||
- [x] Mise à jour flag après changement
|
||||
- [ ] Tests unitaires
|
||||
|
||||
---
|
||||
@@ -600,7 +681,7 @@ Créer le service de gestion des documents légaux (CGU/Privacy) avec versioning
|
||||
- [ ] Méthode `listerVersions()`
|
||||
- [ ] Tests unitaires
|
||||
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#service-documents-légaux)
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#service-documents-légaux)
|
||||
|
||||
---
|
||||
|
||||
@@ -620,7 +701,7 @@ Créer les endpoints REST pour gérer les documents légaux.
|
||||
- [ ] Guards + validation
|
||||
- [ ] Tests unitaires
|
||||
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#apis)
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#apis)
|
||||
|
||||
---
|
||||
|
||||
@@ -637,7 +718,40 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
|
||||
- [ ] API `GET /api/v1/users/:id/acceptations` (admin)
|
||||
- [ ] Tests unitaires
|
||||
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#workflow-acceptation-utilisateur)
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#workflow-acceptation-utilisateur)
|
||||
|
||||
---
|
||||
|
||||
### Ticket #94 : [Backend] Relais - Modèle, API CRUD et liaison gestionnaire ✅
|
||||
**Estimation** : 4h
|
||||
**Labels** : `backend`, `p2`, `admin`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-21)
|
||||
|
||||
**Description** :
|
||||
Le back-office admin doit gérer des Relais avec des données réelles en base, et permettre une liaison simple avec les gestionnaires.
|
||||
|
||||
**Tâches** :
|
||||
- [x] Créer le modèle `Relais` (nom, adresse, horaires, téléphone, actif, notes)
|
||||
- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`)
|
||||
- [x] Ajouter la liaison : un gestionnaire peut être rattaché à un relais principal (`relais_id` dans `users` ?)
|
||||
- [x] Validations (champs requis, format horaires)
|
||||
|
||||
---
|
||||
|
||||
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `auth`, `admin`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||
|
||||
**Description** :
|
||||
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Introduire un DTO dédié `CreateAdministrateurDto`
|
||||
- [ ] Champs autorisés : nom, prenom, email, password, telephone
|
||||
- [ ] Champs exclus : adresse, ville, photo, etc.
|
||||
- [ ] Rôle forcé à `ADMINISTRATEUR`
|
||||
- [ ] Validation stricte
|
||||
|
||||
---
|
||||
|
||||
@@ -788,17 +902,50 @@ Créer les étapes finales de l'inscription AM (présentation, CGU, récapitulat
|
||||
**Description** :
|
||||
Créer l'écran de création de mot de passe (lien reçu par email).
|
||||
|
||||
**Dépendance backend** : ticket **#24** / **#123** — routes disponibles sous préfixe global `api/v1` (voir `backend/src/main.ts`).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Page `/create-password?token=<uuid>`
|
||||
- [ ] Vérification token (appel API)
|
||||
- [ ] Page `/create-password?token=<uuid>` (lecture du `token` en query)
|
||||
- [ ] Au chargement (ou avant affichage formulaire) : `GET /api/v1/auth/verify-token?token=<uuid>` — si 404, message neutre type « lien invalide ou expiré »
|
||||
- [ ] Formulaire MDP + confirmation
|
||||
- [ ] Validation côté client (8 car, 1 maj, 1 chiffre)
|
||||
- [ ] Appel API création MDP
|
||||
- [ ] Redirection vers login
|
||||
- [ ] Validation côté client (alignée backend : min 8 car., 1 maj., 1 chiffre — même règle que `change-password-required`)
|
||||
- [ ] Soumission : `POST /api/v1/auth/create-password` avec JSON `{ "token", "password", "password_confirmation" }` ; gérer 400 (ex. confirmation) / 404 (token)
|
||||
- [ ] Succès → redirection vers login + message de succès
|
||||
|
||||
---
|
||||
|
||||
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure
|
||||
### Ticket #127 : [Full-stack] Mot de passe oublié — flux complet (demande, e-mail, réinitialisation)
|
||||
**Issue Gitea** : https://git.ptits-pas.fr/jmartin/petitspas/issues/127 (créée le 2026-04-23 via API, voir [26_GITEA-API.md](./26_GITEA-API.md)). **Version** : **milestone Gitea `0.1.0`** (périmètre release) ; en complément, label **`v0.1.0`** pour le filtrage. Roadmap : [04_ROADMAP-GENERALE.md](./04_ROADMAP-GENERALE.md) § versions incrémentales.
|
||||
|
||||
**Estimation** : 12h
|
||||
**Labels** : `full-stack`, `p2`, `p3`, `auth`, `security`, `cdc`
|
||||
|
||||
**Description** :
|
||||
Couvrir **tout** le parcours « Mot de passe oublié » : distinct du flux **création** de mot de passe post-inscription (#24 / #43 / `GET verify-token` + `POST create-password`). Aujourd’hui, le lien sur l’écran de connexion (`frontend/lib/screens/auth/login_screen.dart`) envoie vers `/create-password`, ce qui est incorrect pour un utilisateur déjà enregistré qui a oublié son mot de passe.
|
||||
|
||||
**Tâches — Backend** :
|
||||
- [ ] Analyse : réutiliser `password_reset_token` / `password_reset_expires` (et config type `password_reset_token_expiry_days`) **ou** introduire des champs / flux séparés si le même couple token/expiry sert encore à l’inscription — documenter le choix
|
||||
- [ ] `POST` (ex.) `/api/v1/auth/forgot-password` : body `{ "email" }` ; toujours réponse **neutre** (ex. 204 ou message identique que l’e-mail existe ou non) pour limiter l’énumération
|
||||
- [ ] Génération token, persistance, expiration ; invalidation après usage ou rotation
|
||||
- [ ] Envoi e-mail via MailService (template Handlebars dédié « réinitialisation »)
|
||||
- [ ] `POST` (ex.) `/api/v1/auth/reset-password` : `{ "token", "password", "password_confirmation" }` — règles de complexité alignées sur `create-password` / changement MDP
|
||||
- [ ] Rate limiting / throttling par IP et/ou par e-mail
|
||||
- [ ] Tests unitaires / intégration ; entrées OpenAPI
|
||||
|
||||
**Tâches — Frontend** :
|
||||
- [ ] Écran ou bottom sheet « Saisir votre e-mail » + appel forgot-password + message générique de confirmation
|
||||
- [ ] Route dédiée « nouveau mot de passe » (ex. `/reset-password?token=`) **distincte** de `/create-password` ; vérification token si endpoint dédié
|
||||
- [ ] Corriger le `onPressed` du lien « Mot de passe oublié ? » sur le login : ne plus naviguer vers `/create-password` pour ce cas
|
||||
- [ ] Gestion erreurs (token expiré / invalide) avec libellés neutres
|
||||
|
||||
**Tâches — transverses** :
|
||||
- [ ] Mise à jour doc CDC / évolutions si besoin ; E2E ou scénario de test manuel référencé
|
||||
|
||||
**Synchronisation doc** : pour toute **nouvelle** issue à l’avenir, le numéro vient de la réponse Gitea (§ 3.2 [26_GITEA-API.md](./26_GITEA-API.md)) ou du script `bash ./scripts/create-gitea-issue.sh "Titre" "Corps"`.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure ✅
|
||||
**Estimation** : 2h
|
||||
**Labels** : `frontend`, `p3`, `gestionnaire`
|
||||
|
||||
@@ -806,9 +953,10 @@ Créer l'écran de création de mot de passe (lien reçu par email).
|
||||
Créer la structure du dashboard gestionnaire avec 2 onglets.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Layout avec 2 onglets (Parents / AM)
|
||||
- [ ] Navigation entre onglets
|
||||
- [ ] État vide ("Aucune demande")
|
||||
- [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire »
|
||||
- [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué
|
||||
- [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard`
|
||||
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
|
||||
|
||||
---
|
||||
|
||||
@@ -890,13 +1038,14 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
|
||||
- [ ] Activation version
|
||||
- [ ] Historique versions
|
||||
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#interface-admin)
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#interface-admin)
|
||||
|
||||
---
|
||||
|
||||
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
|
||||
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API ✅
|
||||
**Estimation** : 8h
|
||||
**Labels** : `frontend`, `p3`, `admin`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
|
||||
|
||||
**Description** :
|
||||
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
|
||||
@@ -1018,6 +1167,89 @@ Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et coh
|
||||
|
||||
---
|
||||
|
||||
### Ticket #91 : [Frontend] Inscription AM – Branchement soumission formulaire à l'API
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||
|
||||
**Description** :
|
||||
Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscription.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Construire le body (DTO) à partir de `AmRegistrationData`
|
||||
- [ ] Appel HTTP `POST /api/v1/auth/register/am`
|
||||
- [ ] Gestion réponse (201 : succès + redirection ; 4xx : erreur)
|
||||
- [ ] Conversion photo en base64 si nécessaire
|
||||
|
||||
---
|
||||
|
||||
### Ticket #101 : [Frontend] Inscription Parent – Branchement soumission formulaire à l'API
|
||||
**Estimation** : 4h
|
||||
**Labels** : `frontend`, `p3`, `auth`, `cdc`
|
||||
|
||||
**Description** :
|
||||
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale de confirmation puis redirige vers le login. Ce ticket vise à envoyer les données collectées (Parent 1, Parent 2 optionnel, enfants, présentation, CGU) à l'API.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Créer un service ou méthode (ex. `AuthService.registerParent` ou `UserService`) appelant `POST /api/v1/auth/register/parent`
|
||||
- [ ] Construire le body (DTO) à partir de `UserRegistrationData` (parent1, parent2, children, motivationText, CGU acceptée, etc.) en cohérence avec le contrat backend (voir ticket #18 refonte)
|
||||
- [ ] Dans `ParentRegisterStep5Screen`, au clic « Soumettre » : appel API puis en cas de succès afficher la modale et redirection vers `/login` ; en cas d'erreur afficher le message (SnackBar/dialog)
|
||||
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
|
||||
- [ ] Optionnel : réinitialiser ou conserver `UserRegistrationData` après succès (selon UX)
|
||||
|
||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-3--inscription-dun-parent), backend #18 (refonte API inscription parent).
|
||||
|
||||
**Création** : issue Gitea #101 créée. Pour recréer ou script : `node backend/scripts/create-gitea-issue-parent-api.js` (token dans `.gitea-token` ou voir [26_GITEA-API.md](./26_GITEA-API.md)).
|
||||
|
||||
---
|
||||
|
||||
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets ✅
|
||||
**Estimation** : 4h
|
||||
**Labels** : `frontend`, `p3`, `admin`, `ux`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||
|
||||
**Description** :
|
||||
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action)
|
||||
- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme)
|
||||
- [ ] Standardiser les états (Loading, Erreur, Vide)
|
||||
- [ ] Factoriser les composants partagés
|
||||
|
||||
---
|
||||
|
||||
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ✅
|
||||
**Estimation** : 5h
|
||||
**Labels** : `frontend`, `p3`, `admin`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||
|
||||
**Description** :
|
||||
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Section Relais avec 2 sous-onglets : Paramètres techniques / Paramètres territoriaux
|
||||
- [ ] Liste, Création, Édition, Activation/Désactivation des relais
|
||||
- [ ] Champs UI : nom, adresse, horaires, téléphone, statut, notes
|
||||
- [ ] Onglet Gestionnaires : Ajout contrôle de rattachement au relais principal
|
||||
|
||||
---
|
||||
|
||||
### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais) ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `frontend`, `p3`, `admin`
|
||||
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
|
||||
|
||||
**Description** :
|
||||
Permettre la création d'un administrateur via une modale simple depuis le dashboard admin.
|
||||
|
||||
**Tâches** :
|
||||
- [x] Bouton "Créer administrateur" dans l'onglet Administrateurs
|
||||
- [x] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone)
|
||||
- [x] Appel API `POST /users` (ou endpoint dédié si #97 implémenté)
|
||||
- [x] Gestion succès/erreur et rafraîchissement liste
|
||||
|
||||
---
|
||||
|
||||
## 🔵 PRIORITÉ 4 : Tests & Documentation
|
||||
|
||||
### Ticket #52 : [Tests] Tests unitaires Backend
|
||||
@@ -1133,6 +1365,20 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
|
||||
|
||||
---
|
||||
|
||||
### Ticket #89 : Log des appels API en mode debug
|
||||
**Estimation** : 2h
|
||||
**Labels** : `backend`, `monitoring`
|
||||
|
||||
**Description** :
|
||||
Ajouter des logs détaillés pour les appels API en mode debug pour faciliter le diagnostic.
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Middleware ou Intercepteur pour logger les requêtes entrantes (méthode, URL, body)
|
||||
- [ ] Logger les réponses (status, temps d'exécution)
|
||||
- [ ] Activable via variable d'environnement `DEBUG=true` ou niveau de log
|
||||
|
||||
---
|
||||
|
||||
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
|
||||
**Estimation** : 4h
|
||||
**Labels** : `frontend`, `p3`, `monitoring`, `admin`
|
||||
@@ -1235,28 +1481,29 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
||||
|
||||
## 📊 Résumé final
|
||||
|
||||
**Total** : 65 tickets
|
||||
**Estimation** : ~184h de développement
|
||||
**Total** : 72 tickets
|
||||
**Estimation** : ~208h de développement
|
||||
|
||||
### Par priorité
|
||||
- **P0 (Bloquant BDD)** : 7 tickets (~5h)
|
||||
- **P1 (Bloquant Config)** : 7 tickets (~22h)
|
||||
- **P2 (Backend)** : 18 tickets (~50h)
|
||||
- **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
|
||||
- **P2 (Backend)** : 19 tickets (~54h)
|
||||
- **P3 (Frontend)** : 25 tickets (~83h)
|
||||
- **P4 (Tests/Doc)** : 4 tickets (~24h)
|
||||
- **Critiques** : 6 tickets (~13h)
|
||||
- **Juridique** : 1 ticket (~8h)
|
||||
|
||||
### Par domaine
|
||||
- **BDD** : 7 tickets
|
||||
- **Backend** : 23 tickets
|
||||
- **Frontend** : 22 tickets ← +1 mobile RegisterChoice
|
||||
- **Backend** : 24 tickets
|
||||
- **Frontend** : 25 tickets
|
||||
- **Tests** : 3 tickets
|
||||
- **Documentation** : 5 tickets
|
||||
- **Infra** : 2 tickets
|
||||
- **Juridique** : 1 ticket
|
||||
|
||||
### Modifications par rapport à la version initiale
|
||||
- ✅ **v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminé.
|
||||
- ✅ **v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86→#12, #87→#14, #88→#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
|
||||
- ✅ **Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
|
||||
- ❌ **Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
|
||||
@@ -1270,7 +1517,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 9 Février 2026
|
||||
**Version** : 1.4
|
||||
**Statut** : ✅ Aligné avec le dépôt Gitea
|
||||
**Dernière mise à jour** : 25 Février 2026
|
||||
**Version** : 1.6
|
||||
**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ ptitspas-app/
|
||||
- Activation manuelle après prévisualisation
|
||||
- Documents génériques v1 fournis par défaut
|
||||
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md)
|
||||
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -413,7 +413,18 @@ docs/
|
||||
├── 11_API.md
|
||||
├── 20_WORKFLOW-CREATION-COMPTE.md
|
||||
├── 21_CONFIGURATION-SYSTEME.md
|
||||
├── 22_DOCUMENTS-LEGAUX.md
|
||||
├── 22_DOCUMENTS-LEGAUX.md # pointeur → juridique/
|
||||
├── 27_BRIEFING-FRONTEND.md
|
||||
├── PROCEDURE-API-GITEA.md # pointeur → 26_GITEA-API.md
|
||||
├── juridique/
|
||||
│ ├── README.md
|
||||
│ ├── cgu.md
|
||||
│ ├── cgc.md
|
||||
│ └── 22_DOCUMENTS-LEGAUX.md
|
||||
├── archive/
|
||||
│ ├── README.md
|
||||
│ ├── temporaires/
|
||||
│ └── obsolete/
|
||||
├── 23_LISTE-TICKETS.md
|
||||
├── 24_DECISIONS-PROJET.md (ce document)
|
||||
├── 90_AUDIT.md
|
||||
|
||||
+21
-1
@@ -255,4 +255,24 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
||||
#### X.1.3 Impact sur l'application
|
||||
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
|
||||
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
|
||||
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
|
||||
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
|
||||
|
||||
## 8. Évolution future - Gouvernance intra-RPE
|
||||
|
||||
### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais
|
||||
|
||||
#### 8.1.1 Situation actuelle
|
||||
- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais.
|
||||
- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil.
|
||||
|
||||
#### 8.1.2 Évolution à prévoir
|
||||
- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif).
|
||||
- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale).
|
||||
- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation).
|
||||
- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions.
|
||||
|
||||
#### 8.1.3 Impact attendu
|
||||
- Évolution du modèle de données vers un RBAC intra-RPE.
|
||||
- Adaptation des écrans d'administration pour gérer les rôles locaux.
|
||||
- Renforcement des contrôles d'accès backend et des règles métier.
|
||||
- Clarification des workflows décisionnels dans l'application.
|
||||
+5
-173
@@ -1,176 +1,8 @@
|
||||
# Procédure – Utilisation de l’API Gitea
|
||||
# Fichier déplacé / fusionné
|
||||
|
||||
## 1. Contexte
|
||||
La procédure **API Gitea** est désormais documentée sous :
|
||||
|
||||
- **Instance** : https://git.ptits-pas.fr
|
||||
- **API de base** : `https://git.ptits-pas.fr/api/v1`
|
||||
- **Projet P'titsPas** : dépôt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`)
|
||||
**[26_GITEA-API.md](./26_GITEA-API.md)**
|
||||
|
||||
## 2. Authentification
|
||||
|
||||
### 2.1 Token
|
||||
|
||||
Le token est défini dans l’environnement (ex. `~/.bashrc`) :
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="<votre_token>"
|
||||
```
|
||||
|
||||
Pour l’utiliser dans les commandes :
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # ou : . ~/.bashrc
|
||||
# Puis utiliser $GITEA_TOKEN dans les curl
|
||||
```
|
||||
|
||||
### 2.2 En-tête HTTP
|
||||
|
||||
Toutes les requêtes API doivent envoyer le token :
|
||||
|
||||
```bash
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas"
|
||||
```
|
||||
|
||||
## 3. Endpoints utiles
|
||||
|
||||
### 3.1 Dépôt (repository)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Infos dépôt | GET | `/repos/{owner}/{repo}` |
|
||||
| Liste dépôts | GET | `/repos/search?q=petitspas` |
|
||||
|
||||
Exemple – infos du dépôt :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq .
|
||||
```
|
||||
|
||||
### 3.2 Issues (tickets)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|------------------|---------|-----|
|
||||
| Liste des issues | GET | `/repos/{owner}/{repo}/issues` |
|
||||
| Détail d’une issue | GET | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Créer une issue | POST | `/repos/{owner}/{repo}/issues` |
|
||||
| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Fermer une issue | PATCH | (même URL, `state: "closed"`) |
|
||||
|
||||
**Paramètres GET utiles pour la liste :**
|
||||
|
||||
- `state` : `open` ou `closed`
|
||||
- `labels` : filtre par label (ex. `frontend`)
|
||||
- `page`, `limit` : pagination
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Toutes les issues ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq .
|
||||
|
||||
# Issues ouvertes avec label "frontend"
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \
|
||||
jq '.[] | select(.labels[].name == "frontend") | {number, title, state}'
|
||||
|
||||
# Détail de l’issue #47
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
|
||||
|
||||
# Fermer l’issue #31
|
||||
curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31"
|
||||
|
||||
# Créer une issue
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
||||
```
|
||||
|
||||
### 3.3 Pull requests
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` |
|
||||
| Détail d’une PR | GET | `/repos/{owner}/{repo}/pulls/{index}` |
|
||||
| Créer une PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||
| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` |
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Liste des PR ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
||||
|
||||
# Créer une PR (head = branche source, base = branche cible)
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||
```
|
||||
|
||||
### 3.4 Branches
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des branches | GET | `/repos/{owner}/{repo}/branches` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name'
|
||||
```
|
||||
|
||||
### 3.5 Webhooks
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` |
|
||||
| Créer webhook | POST | `/repos/{owner}/{repo}/hooks` |
|
||||
|
||||
### 3.6 Labels
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des labels | GET | `/repos/{owner}/{repo}/labels` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}'
|
||||
```
|
||||
|
||||
## 4. Résumé des URLs pour P'titsPas
|
||||
|
||||
Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` :
|
||||
|
||||
| Ressource | URL |
|
||||
|------------------|-----|
|
||||
| Dépôt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` |
|
||||
| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` |
|
||||
| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` |
|
||||
| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` |
|
||||
| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` |
|
||||
| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` |
|
||||
|
||||
## 5. Documentation officielle
|
||||
|
||||
- Swagger / OpenAPI : https://docs.gitea.com/api
|
||||
- Référence selon la version de Gitea installée (ex. 1.21, 1.25).
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
- **401 Unauthorized** : vérifier le token et l’en-tête `Authorization: token <TOKEN>`.
|
||||
- **404** : vérifier owner/repo et l’URL (sensible à la casse).
|
||||
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
|
||||
L’ancienne copie `PROCEDURE-API-GITEA.md` est archivée dans
|
||||
`docs/archive/obsolete/` (doublon).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SuperNounou – SSS-001
|
||||
## Spécification technique & opérationnelle unifiée
|
||||
_Version 0.2 – 24 avril 2025_
|
||||
_Version 0.3 – 27 janvier 2026_
|
||||
|
||||
---
|
||||
|
||||
@@ -62,6 +62,13 @@ Collection Postman, scripts cURL, guide « Appeler l’API ».
|
||||
### B.4 Intégrations futures
|
||||
SSO LDAP/SAML, webhook `contract.validated`, export statistiques CSV.
|
||||
|
||||
### B.5 Contrat de gestion des comptes d'administration
|
||||
- Création d'un administrateur avec un contrat minimal stable : `nom`, `prenom`, `email`, `password`, `telephone`.
|
||||
- Le rôle n'est jamais fourni par le frontend pour ce flux ; le backend impose `ADMINISTRATEUR`.
|
||||
- Les champs hors périmètre (adresse complète, photo, métadonnées métier non nécessaires) ne sont pas requis.
|
||||
- Les protections d'autorisation restent actives : un `SUPER_ADMIN` n'est pas supprimable et son identité (`nom`, `prenom`) est non modifiable.
|
||||
- Côté interface d'administration, les actions d'édition sont conditionnées aux droits ; les entrées non éditables restent consultables en lecture seule.
|
||||
|
||||
---
|
||||
|
||||
# C – Déploiement, CI/CD et Observabilité *(nouveau)*
|
||||
@@ -106,3 +113,4 @@ AES-256, JWT, KMS, OpenAPI, RPO, RTO, rate-limit, HMAC, Compose, CI/CD…
|
||||
|---------|------------|------------------|---------------------------------|
|
||||
| 0.1-draft | 2025-04-24 | Équipe projet | Création du SSS unifié |
|
||||
| 0.2 | 2025-04-24 | ChatGPT & Julien | Ajout déploiement / CI/CD / logs |
|
||||
| 0.3 | 2026-01-27 | Équipe projet | Contrat admin harmonisé et règles d'autorisation |
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Archive documentation · P'titsPas
|
||||
|
||||
Ce dossier regroupe les fichiers **sans préfixe numérique** à la racine de
|
||||
`docs/` qui ne sont plus des **références actives**, ou qui sont des
|
||||
**brouillons / temporaires**.
|
||||
|
||||
## Règle de nommage (racine `docs/`)
|
||||
|
||||
- Les documents **normatifs** à la racine portent un préfixe **`NN_`**
|
||||
(deux chiffres), ex. `23_LISTE-TICKETS.md`.
|
||||
- **Exceptions** (héritage ou outillage) listées dans
|
||||
[**00_INDEX.md**](../00_INDEX.md#exceptions-de-nommage) : charte, CDC
|
||||
historique, évolutions — **cible** : les renommer progressivement en `NN_`
|
||||
et mettre à jour `.cursorrules` / liens.
|
||||
|
||||
## Sous-dossiers ici
|
||||
|
||||
| Dossier | Usage |
|
||||
|---------|--------|
|
||||
| [**temporaires/**](./temporaires/) | Notes jetables, exports de travail.
|
||||
**Supprimables** quand la tâche associée est close. |
|
||||
| [**obsolete/**](./obsolete/) | Ancienne doc **remplacée** ou **doublon**
|
||||
(conservée un temps pour historique). **Supprimer** après bascule confirmée
|
||||
si plus aucune référence. |
|
||||
|
||||
## Hors `docs/` racine
|
||||
|
||||
Les dossiers thématiques (**`juridique/`**, **`test-data/`**, etc.) peuvent
|
||||
contenir des fichiers sans `NN_` : la règle `NN_` s’applique surtout aux
|
||||
fichiers **directement** sous `docs/`.
|
||||
@@ -1,4 +1,9 @@
|
||||
# Architecture Technique - P'titsPas
|
||||
|
||||
> **Archivé** (`docs/archive/obsolete/`) — non maintenu à la racine `docs/`.
|
||||
> Voir [`02_ARCHITECTURE.md`](../../02_ARCHITECTURE.md) pour la référence
|
||||
> courante.
|
||||
|
||||
## Guide d'Infrastructure et de Déploiement
|
||||
|
||||
---
|
||||
@@ -0,0 +1,180 @@
|
||||
# Procédure – Utilisation de l’API Gitea
|
||||
|
||||
> **Archivé** (`docs/archive/obsolete/`) — **doublon** de
|
||||
> [`26_GITEA-API.md`](../../26_GITEA-API.md). Ne pas modifier ici ; éditer le
|
||||
> fichier numéroté.
|
||||
|
||||
## 1. Contexte
|
||||
|
||||
- **Instance** : https://git.ptits-pas.fr
|
||||
- **API de base** : `https://git.ptits-pas.fr/api/v1`
|
||||
- **Projet P'titsPas** : dépôt `jmartin/petitspas` (owner = `jmartin`, repo = `petitspas`)
|
||||
|
||||
## 2. Authentification
|
||||
|
||||
### 2.1 Token
|
||||
|
||||
Le token est défini dans l’environnement (ex. `~/.bashrc`) :
|
||||
|
||||
```bash
|
||||
export GITEA_TOKEN="<votre_token>"
|
||||
```
|
||||
|
||||
Pour l’utiliser dans les commandes :
|
||||
|
||||
```bash
|
||||
source ~/.bashrc # ou : . ~/.bashrc
|
||||
# Puis utiliser $GITEA_TOKEN dans les curl
|
||||
```
|
||||
|
||||
### 2.2 En-tête HTTP
|
||||
|
||||
Toutes les requêtes API doivent envoyer le token :
|
||||
|
||||
```bash
|
||||
-H "Authorization: token $GITEA_TOKEN"
|
||||
```
|
||||
|
||||
Exemple :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas"
|
||||
```
|
||||
|
||||
## 3. Endpoints utiles
|
||||
|
||||
### 3.1 Dépôt (repository)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Infos dépôt | GET | `/repos/{owner}/{repo}` |
|
||||
| Liste dépôts | GET | `/repos/search?q=petitspas` |
|
||||
|
||||
Exemple – infos du dépôt :
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas" | jq .
|
||||
```
|
||||
|
||||
### 3.2 Issues (tickets)
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|------------------|---------|-----|
|
||||
| Liste des issues | GET | `/repos/{owner}/{repo}/issues` |
|
||||
| Détail d’une issue | GET | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Créer une issue | POST | `/repos/{owner}/{repo}/issues` |
|
||||
| Modifier une issue | PATCH | `/repos/{owner}/{repo}/issues/{index}` |
|
||||
| Fermer une issue | PATCH | (même URL, `state: "closed"`) |
|
||||
|
||||
**Paramètres GET utiles pour la liste :**
|
||||
|
||||
- `state` : `open` ou `closed`
|
||||
- `labels` : filtre par label (ex. `frontend`)
|
||||
- `page`, `limit` : pagination
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Toutes les issues ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | jq .
|
||||
|
||||
# Issues ouvertes avec label "frontend"
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues?state=open" | \
|
||||
jq '.[] | select(.labels[].name == "frontend") | {number, title, state}'
|
||||
|
||||
# Détail de l’issue #47
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/47" | jq .
|
||||
|
||||
# Fermer l’issue #31
|
||||
curl -s -X PATCH -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/31"
|
||||
|
||||
# Créer une issue
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"Titre du ticket","body":"Description","labels":[1]}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues"
|
||||
```
|
||||
|
||||
### 3.3 Pull requests
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des PR | GET | `/repos/{owner}/{repo}/pulls` |
|
||||
| Détail d’une PR | GET | `/repos/{owner}/{repo}/pulls/{index}` |
|
||||
| Créer une PR | POST | `/repos/{owner}/{repo}/pulls` |
|
||||
| Fusionner une PR | POST | `/repos/{owner}/{repo}/pulls/{index}/merge` |
|
||||
|
||||
Exemples :
|
||||
|
||||
```bash
|
||||
# Liste des PR ouvertes
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls?state=open" | jq .
|
||||
|
||||
# Créer une PR (head = branche source, base = branche cible)
|
||||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"head":"develop","base":"master","title":"Titre de la PR"}' \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls"
|
||||
```
|
||||
|
||||
### 3.4 Branches
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des branches | GET | `/repos/{owner}/{repo}/branches` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches" | jq '.[].name'
|
||||
```
|
||||
|
||||
### 3.5 Webhooks
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste webhooks | GET | `/repos/{owner}/{repo}/hooks` |
|
||||
| Créer webhook | POST | `/repos/{owner}/{repo}/hooks` |
|
||||
|
||||
### 3.6 Labels
|
||||
|
||||
| Action | Méthode | URL |
|
||||
|---------------|---------|-----|
|
||||
| Liste des labels | GET | `/repos/{owner}/{repo}/labels` |
|
||||
|
||||
```bash
|
||||
curl -s -H "Authorization: token $GITEA_TOKEN" \
|
||||
"https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels" | jq '.[] | {id, name}'
|
||||
```
|
||||
|
||||
## 4. Résumé des URLs pour P'titsPas
|
||||
|
||||
Remplacer `{owner}` par `jmartin` et `{repo}` par `petitspas` :
|
||||
|
||||
| Ressource | URL |
|
||||
|------------------|-----|
|
||||
| Dépôt | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas` |
|
||||
| Issues | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues` |
|
||||
| Issue #n | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/issues/{n}` |
|
||||
| Pull requests | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/pulls` |
|
||||
| Branches | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/branches` |
|
||||
| Labels | `https://git.ptits-pas.fr/api/v1/repos/jmartin/petitspas/labels` |
|
||||
|
||||
## 5. Documentation officielle
|
||||
|
||||
- Swagger / OpenAPI : https://docs.gitea.com/api
|
||||
- Référence selon la version de Gitea installée (ex. 1.21, 1.25).
|
||||
|
||||
## 6. Dépannage
|
||||
|
||||
- **401 Unauthorized** : vérifier le token et l’en-tête `Authorization: token <TOKEN>`.
|
||||
- **404** : vérifier owner/repo et l’URL (sensible à la casse).
|
||||
- **422 / body invalide** : pour POST/PATCH, envoyer `Content-Type: application/json` et un JSON valide.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Obsolete
|
||||
|
||||
Ancienne documentation **déplacée** depuis `docs/` :
|
||||
|
||||
| Fichier | Motif |
|
||||
|---------|--------|
|
||||
| `PROCEDURE-API-GITEA.md` | Doublon fonctionnel de
|
||||
[**26_GITEA-API.md**](../../26_GITEA-API.md). |
|
||||
| `ARCHITECTURE_TECHNIQUE.md` | Non référencé ; la vue d’ensemble est dans
|
||||
[**02_ARCHITECTURE.md**](../../02_ARCHITECTURE.md). |
|
||||
| `STATUS-APPLICATION.md` | Instantané daté ; non tenu comme doc vivante. |
|
||||
|
||||
Après vérification qu’aucun lien externe ne pointe encore vers ces chemins, on
|
||||
peut **supprimer** ce sous-dossier ou ne garder que des pointeurs minimalistes.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user