Compare commits
135
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
939e7777ac | ||
|
|
b474842e19 | ||
|
|
a312d9c0fa | ||
|
|
b5b32062b3 | ||
|
|
946d8edcd2 | ||
|
|
c8cb82dd24 | ||
|
|
9cd180bf6a | ||
|
|
ca07d2e111 | ||
|
|
67336c64fe | ||
|
|
97afbbcf9a | ||
|
|
e235b30140 | ||
|
|
7f23356273 | ||
|
|
c8c9cfbc4d | ||
|
|
530e896b66 | ||
|
|
0029c5ab86 | ||
|
|
2ce9e9215f | ||
|
|
3fdd913367 | ||
|
|
6708f73b06 | ||
|
|
f596f062a6 | ||
|
|
86701731e3 | ||
|
|
b4abb7d6de | ||
|
|
cb5c1a5518 | ||
|
|
30ca99fb65 | ||
|
|
8ee2ca8ea6 | ||
|
|
e3552667bc | ||
|
|
4ae334b247 | ||
|
|
471a62ddb7 | ||
|
|
59afeb0a8d | ||
|
|
d2172eafdb | ||
|
|
d247867fa0 | ||
|
|
93912d1374 | ||
|
|
925b6d5cd4 | ||
|
|
b0dddd6695 | ||
|
|
ef7512dc1e | ||
|
|
2ececa711b | ||
|
|
7a3d997ff9 | ||
|
|
6b89d7b405 | ||
|
|
f7ac628445 | ||
|
|
26e9738ec7 | ||
|
|
0ec3410457 | ||
|
|
c3acf1970d | ||
|
|
6fe2b89a61 | ||
|
|
d6a9b3fd66 | ||
|
|
134b9781c8 | ||
|
|
b936d27445 | ||
|
|
18c1d1eba7 | ||
|
|
f74b14c203 | ||
|
|
f194b5f9e8 | ||
|
|
5ab8ae3423 | ||
|
|
3277f77846 | ||
|
|
2d80ad0d7e | ||
|
|
09386f8aa6 | ||
|
|
faa50f637c | ||
|
|
267fe63aec | ||
|
|
90b185740c | ||
|
|
b903dbf60b | ||
|
|
291ea26b34 | ||
|
|
59919834b9 | ||
|
|
cf6acbae7c | ||
|
|
7951c38d4d | ||
|
|
77d952a6f7 | ||
|
|
b4b546044d | ||
|
|
6788351070 | ||
|
|
d6dac181d2 | ||
|
|
7b69f27ca5 | ||
|
|
003fe6b762 | ||
|
|
8ed68797aa | ||
|
|
9ad371a342 | ||
|
|
18718670e9 | ||
|
|
fbf22f2540 | ||
|
|
43a2cd213b | ||
|
|
c865d11dc3 | ||
|
|
d03a8e6c8b | ||
|
|
66c7f22280 | ||
|
|
53721ffbb3 | ||
|
|
52e40d0001 | ||
|
|
4985726bc6 | ||
|
|
479a32b4bf | ||
|
|
2fd97ddecb | ||
|
|
ce474797c4 | ||
|
|
ebf794e1ac | ||
|
|
d55f240f56 | ||
|
|
966f4a9c9c | ||
|
|
8494341b56 | ||
|
|
1dddc67933 | ||
|
|
b99745e0fe | ||
|
|
df776d8200 | ||
|
|
c26ed00374 | ||
|
|
9d54d9b19b | ||
|
|
c438009286 | ||
|
|
9b7231f1da | ||
|
|
f300505225 | ||
|
|
25c10c885a | ||
|
|
d70577b1c3 | ||
|
|
671da71752 | ||
|
|
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 | ||
|
|
25b5e0dd93 | ||
|
|
7381ce356d | ||
|
|
9acfc31e72 | ||
|
|
d8a81a56ae |
@@ -33,6 +33,7 @@ yarn-error.log*
|
||||
*.sqlite3
|
||||
|
||||
# Flutter
|
||||
**/android/local.properties
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -36,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",
|
||||
@@ -55,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",
|
||||
@@ -87,6 +89,9 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
|
||||
@@ -33,7 +33,6 @@ model Child {
|
||||
dateOfBirth DateTime
|
||||
photoUrl String?
|
||||
photoConsent Boolean @default(false)
|
||||
isMultiple Boolean @default(false)
|
||||
isUnborn Boolean @default(false)
|
||||
parentId String
|
||||
parent Parent @relation(fields: [parentId], references: [id])
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Commentaire de clôture + fermeture issue Gitea #131.
|
||||
* Usage: node backend/scripts/close-gitea-issue-131.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
const ISSUE = 131;
|
||||
const REPO = 'jmartin/petitspas';
|
||||
|
||||
const body = `## Fermeture ticket #131 — livré sur \`develop\` et \`master\`
|
||||
|
||||
Branche **\`feature/131\`** mergée dans **\`develop\`**, puis squash merge **\`develop\` → \`master\`** (déploiement production).
|
||||
|
||||
### Fiche parent (dashboard admin)
|
||||
- Modale **\`AdminParentEditModal\`** éditable dès l'ouverture
|
||||
- En-tête dynamique : **nom/prénom** + sous-titre **co-parent** (si connu)
|
||||
- Gélule **statut** modifiable
|
||||
- **\`PATCH /api/v1/parents/:id/fiche\`** — identité + statut
|
||||
- **\`GET /api/v1/parents\`** — liste parents (fix décorateur \`@Get()\` manquant)
|
||||
- Réponses API : \`co_parent\` peuplé, secrets user masqués (\`sanitizeUserForApi\`)
|
||||
- Liste enfants en bas de fiche + rattachement/détachement
|
||||
|
||||
### Fiche AM (dashboard admin)
|
||||
- Modale **\`AdminAmEditModal\`** — 3 onglets : Identité | Fiche pro | Enfants accueillis
|
||||
- **\`PATCH /api/v1/assistantes-maternelles/:id/fiche\`** — identité + champs pro (NIR, date/lieu naissance, date agrément, places, disponibilité)
|
||||
- **\`POST/DELETE …/enfants/:enfantId\`** — rattacher / détacher un enfant
|
||||
- Grille capacité **\`AdminAmChildrenCapacityGrid\`** (2×2)
|
||||
- Rattachement enfants **différé jusqu'à Sauvegarder** (pas d'appel API immédiat)
|
||||
|
||||
### Back — placement AM ↔ enfant
|
||||
- Table **\`enfants_assistantes_maternelles\`** (placement temporel, 1 garde active/enfant)
|
||||
- Enum enfant : \`a_naitre\`, \`garde\`, \`sans_garde\`, \`scolarise\` — **plus \`actif\`**
|
||||
- Rattachement → statut enfant \`garde\` ; détachement → \`sans_garde\`
|
||||
- Migration : \`database/migrations/2026_enfants_assistantes_maternelles.sql\`
|
||||
- Schéma canonique : \`database/BDD.sql\`
|
||||
|
||||
### Front — statuts & polish
|
||||
- **\`enfant_status_utils.dart\`** — libellés/couleurs \`garde\`/\`sans_garde\`
|
||||
- Mise à jour cartes enfants, modale détail, filtres dashboard
|
||||
|
||||
### Correctifs recette
|
||||
- \`GET /parents\` 404 → ajout \`@Get()\` sur \`getAll()\`
|
||||
- Sauvegarde AM 400 → alignement DTO \`UpdateAmFicheAdminDto\` sur payload front
|
||||
- Crash NIR → fix \`toISOString\` + pas d'effacement NIR (colonne NOT NULL)
|
||||
|
||||
### Hors périmètre #131 (tickets voisins)
|
||||
- **#137** onglet Enfants global · **#138** fiche enfant complète · **#115/#116** affiliation avancée
|
||||
|
||||
---
|
||||
*Issue fermée après merge sur \`develop\` + \`master\` et déploiement production.*`;
|
||||
|
||||
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,111 @@
|
||||
/**
|
||||
* Crée l'issue Gitea — gestionnaire ne doit pas pouvoir se supprimer.
|
||||
* Usage: node backend/scripts/create-gitea-issue-gestionnaire-self-delete.js
|
||||
*/
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const repoRoot = path.join(__dirname, '../..');
|
||||
const MILESTONE_0_1_0 = 10;
|
||||
|
||||
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*(gitebu_[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);
|
||||
}
|
||||
|
||||
const body = `## Contexte
|
||||
|
||||
Quand un **gestionnaire** connecté ouvre sa propre fiche dans l'onglet **Gestionnaires** (Gestion des utilisateurs), la modale **Modifier un "Gestionnaire"** affiche le bouton **Supprimer**.
|
||||
|
||||
Comportement actuel : le gestionnaire voit et peut tenter de supprimer son propre compte.
|
||||
|
||||
## Comportement attendu
|
||||
|
||||
Comme pour le **super administrateur** (bouton Supprimer masqué sur la fiche super admin) :
|
||||
|
||||
- **Pas de bouton Supprimer** quand l'utilisateur édite **sa propre fiche**
|
||||
- **Modification** des informations (prénom, nom, email, téléphone, relais, mot de passe) **toujours autorisée**
|
||||
|
||||
## Périmètre
|
||||
|
||||
- Frontend : \`AdminUserFormDialog\` (\`gestionnaires_create.dart\`)
|
||||
- Comparer \`initialUser.id\` avec l'utilisateur connecté (\`AuthService.getCurrentUser\`)
|
||||
- Masquer Supprimer si édition de soi-même ; conserver garde existante super admin
|
||||
|
||||
## Critères d'acceptation
|
||||
|
||||
- [ ] Gestionnaire connecté → ouvre sa fiche → **pas** de bouton Supprimer
|
||||
- [ ] Gestionnaire connecté → peut **Modifier** ses informations
|
||||
- [ ] Super admin / admin → peut toujours supprimer **un autre** gestionnaire (si droits API)
|
||||
- [ ] Pas de régression sur fiche super administrateur (Supprimer toujours masqué)
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
- \`frontend/lib/screens/administrateurs/creation/gestionnaires_create.dart\`
|
||||
- \`frontend/lib/widgets/admin/gestionnaire_management_widget.dart\`
|
||||
|
||||
## Milestone
|
||||
|
||||
**0.1.0** — correction UX / sécurité gestion utilisateurs.`;
|
||||
|
||||
const payloadClean = JSON.stringify({
|
||||
title: '[Bug] Gestionnaire peut voir Supprimer sur sa propre fiche',
|
||||
body,
|
||||
milestone: MILESTONE_0_1_0,
|
||||
});
|
||||
|
||||
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(payloadClean),
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
console.log('MILESTONE:', o.milestone?.title ?? '(aucun)');
|
||||
} else {
|
||||
console.error('Réponse:', d);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(d);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payloadClean);
|
||||
req.end();
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Met à jour l'issue Gitea #140 — epic dashboard admin ch.6 famille.
|
||||
* Usage: node backend/scripts/update-gitea-issue-140-ch6-famille.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é : .gitea-token ou GITEA_TOKEN (voir docs/26_GITEA-API.md)');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = `## Rôle de ce ticket
|
||||
|
||||
**#140 est un ticket epic / livraison** : il regroupe la mise en œuvre du **chapitre 6** du doc [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](../docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) (§6.1 et §6.2) sur la branche \`feature/140-dashboard-admin-ch6-famille\`.
|
||||
|
||||
Il **ne remplace pas** les tickets détaillés ci-dessous : il sert de **fil de livraison** (PR, recette, fermeture coordonnée). Chaque sous-ticket garde son périmètre propre ; #140 est clos quand l'ensemble est **fonctionnel et homogène en UI**.
|
||||
|
||||
---
|
||||
|
||||
## Tickets couverts (périmètres embarqués)
|
||||
|
||||
| Ticket | Sujet | Rôle dans #140 |
|
||||
|--------|--------|----------------|
|
||||
| **#115** | Rattachement parent — **backend** | API \`POST/DELETE …/enfants/:enfantId\` |
|
||||
| **#116** | Rattachement parent — **frontend** | UI rattacher / détacher depuis la fiche parent |
|
||||
| **#130** | \`UserService\` — APIs admin | Appels \`getParents\`, \`getParent\`, \`getEnfants\`, \`updateParentFiche\`, etc. |
|
||||
| **#131** | Fiche parent éditable | Modale parent (dashboard) — *hors fiche AM* |
|
||||
| **#137** | Onglet **Enfants** | Liste globale admin + accès fiche enfant |
|
||||
| **#138** | Fiche enfant + liste dans fiche parent | \`AdminChildDetailModal\` + liste enfants en bas de fiche parent |
|
||||
|
||||
> **Note :** fermer #140 peut entraîner la fermeture **partielle ou totale** de ces tickets selon ce qui est réellement livré et recetté dans la PR.
|
||||
|
||||
---
|
||||
|
||||
## Hors scope #140
|
||||
|
||||
- **§6.3** — Création dossier admin sans numéro → **#129**
|
||||
- Fiche **AM** éditable (dashboard) → reste **#131** (partie AM)
|
||||
- Parcours gestionnaire « famille complexe » → **#139**
|
||||
- Qualification responsable légal (combobox sur lien) → ticket à créer
|
||||
|
||||
---
|
||||
|
||||
## Backend (attendu / livré)
|
||||
|
||||
- [x] \`PATCH /parents/:id/fiche\` — édition fiche parent (admin/gestionnaire)
|
||||
- [x] \`POST /parents/:id/enfants/:enfantId\` — rattacher un enfant existant
|
||||
- [x] \`DELETE /parents/:id/enfants/:enfantId\` — détacher (garde-fou : ≥1 responsable / enfant)
|
||||
- [x] \`GET /enfants\` enrichi ; \`PATCH /enfants/:id\` pour gestionnaire
|
||||
|
||||
---
|
||||
|
||||
## Frontend — fait
|
||||
|
||||
- [x] Modale **fiche parent** éditable (shell aligné validation, statut gélule, téléphone formaté, \`IdentityBlock\`)
|
||||
- [x] Onglet **Enfants** — liste globale (\`EnfantManagementWidget\`)
|
||||
- [x] Liste enfants dans fiche parent — cartes (photo, nom, âge ans/mois, statut), cadre blanc, scroll 2,5 lignes
|
||||
- [x] Rattacher / détacher un enfant **existant** (API branchée)
|
||||
- [x] Parsing robuste \`parentChildren\` + URLs médias \`/uploads\` en Flutter web
|
||||
- [x] \`UserService\` — APIs parents / enfants / affiliation
|
||||
|
||||
---
|
||||
|
||||
## Frontend — reste à faire (bloquant clôture)
|
||||
|
||||
### Fiche enfant — #138 (UI)
|
||||
- [ ] Reprendre \`AdminChildDetailModal\` : même **look & feel** que fiche parent / modales validation (largeur ~930 px, grille champs, photo enfant)
|
||||
- [ ] Aligner dates, genre, statut sur les wizards validation famille
|
||||
|
||||
### Modale **rattacher** un enfant — #116 (UI)
|
||||
- [ ] Remplacer le \`SimpleDialog\` actuel par une modale cohérente : liste type \`AdminEnfantUserCard\` (photo, nom, âge), recherche éventuelle
|
||||
|
||||
### Création d'un **nouvel** enfant depuis la fiche parent — doc §6.2
|
||||
- [ ] **Non implémenté** aujourd'hui (seul le rattachement d'un enfant déjà en base existe)
|
||||
- [ ] À trancher : inclus dans #140 si le back expose un \`POST\` admin depuis le contexte parent, sinon ticket dédié / extension #129
|
||||
|
||||
---
|
||||
|
||||
## Recette avant merge
|
||||
|
||||
1. Parent avec 0 / 1 / 3+ enfants — liste, scroll, compteur
|
||||
2. Rattacher puis détacher (message si dernier responsable)
|
||||
3. Clic enfant → fiche enfant (après refonte UI)
|
||||
4. Onglet Enfants — même rendu cartes
|
||||
5. Avatars photos (URLs absolues web)
|
||||
|
||||
---
|
||||
|
||||
## Branche
|
||||
|
||||
\`feature/140-dashboard-admin-ch6-famille\`
|
||||
|
||||
## Références
|
||||
|
||||
- Doc produit : \`docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md\` §6.1, §6.2`;
|
||||
|
||||
const payload = JSON.stringify({ body });
|
||||
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: 'git.ptits-pas.fr',
|
||||
path: '/api/v1/repos/jmartin/petitspas/issues/140',
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `token ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
const json = JSON.parse(data);
|
||||
console.log('Issue #140 mise à jour :', json.html_url);
|
||||
console.log('updated_at:', json.updated_at);
|
||||
} else {
|
||||
console.error('Erreur', res.statusCode, data);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on('error', (err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
req.write(payload);
|
||||
req.end();
|
||||
@@ -18,6 +18,7 @@ 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';
|
||||
import { SuppressionsModule } from './routes/suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -57,6 +58,7 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
|
||||
DocumentsLegauxModule,
|
||||
RelaisModule,
|
||||
DossiersModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { sanitizeUserForApi } from './sanitize-user-for-api';
|
||||
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
|
||||
|
||||
describe('sanitizeUserForApi', () => {
|
||||
const base: Users = {
|
||||
id: 'u1',
|
||||
email: 'a@b.fr',
|
||||
prenom: 'Paul',
|
||||
nom: 'Parent',
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
password: 'hash',
|
||||
token_creation_mdp: 'tok',
|
||||
token_creation_mdp_expire_le: new Date(),
|
||||
password_reset_token: 'rst',
|
||||
password_reset_expires: new Date(),
|
||||
} as Users;
|
||||
|
||||
it('retire password et tokens', () => {
|
||||
const out = sanitizeUserForApi(base)!;
|
||||
expect(out.prenom).toBe('Paul');
|
||||
expect(out.nom).toBe('Parent');
|
||||
expect(out.password).toBeUndefined();
|
||||
expect(out.token_creation_mdp).toBeUndefined();
|
||||
expect(out.password_reset_token).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retourne undefined si user absent', () => {
|
||||
expect(sanitizeUserForApi(null)).toBeUndefined();
|
||||
expect(sanitizeUserForApi(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
|
||||
/** Champs sensibles exclus des réponses API (ticket #131 — user / co_parent). */
|
||||
const SENSITIVE_USER_KEYS: (keyof Users)[] = [
|
||||
'password',
|
||||
'token_creation_mdp',
|
||||
'token_creation_mdp_expire_le',
|
||||
'password_reset_token',
|
||||
'password_reset_expires',
|
||||
];
|
||||
|
||||
/**
|
||||
* Retourne une copie utilisateur sans secrets (hash MDP, tokens).
|
||||
* Utilisé pour `user` et `co_parent` dans les réponses Parents.
|
||||
*/
|
||||
export function sanitizeUserForApi(user?: Users | null): Users | undefined {
|
||||
if (!user) return undefined;
|
||||
const safe = { ...user } as Users;
|
||||
for (const key of SENSITIVE_USER_KEYS) {
|
||||
delete safe[key];
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { AssistanteMaternelle } from './assistantes_maternelles.entity';
|
||||
import { Children } from './children.entity';
|
||||
import { Users } from './users.entity';
|
||||
|
||||
@Entity('enfants_assistantes_maternelles', { schema: 'public' })
|
||||
export class AmChildren {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ name: 'id_am', type: 'uuid' })
|
||||
amId: string;
|
||||
|
||||
@Column({ name: 'id_enfant', type: 'uuid' })
|
||||
enfantId: string;
|
||||
|
||||
@Column({ name: 'date_debut', type: 'date' })
|
||||
date_debut: Date;
|
||||
|
||||
@Column({ name: 'date_fin', type: 'date', nullable: true })
|
||||
date_fin?: Date;
|
||||
|
||||
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
|
||||
cree_le: Date;
|
||||
|
||||
@Column({ name: 'cree_par', type: 'uuid', nullable: true })
|
||||
cree_par?: string;
|
||||
|
||||
@ManyToOne(() => AssistanteMaternelle, (am) => am.amChildren, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_am', referencedColumnName: 'user_id' })
|
||||
am: AssistanteMaternelle;
|
||||
|
||||
@ManyToOne(() => Children, (c) => c.amLinks, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'id_enfant', referencedColumnName: 'id' })
|
||||
child: Children;
|
||||
|
||||
@ManyToOne(() => Users, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'cree_par', referencedColumnName: 'id' })
|
||||
createdBy?: Users;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Entity, PrimaryColumn, Column, OneToOne, JoinColumn } from 'typeorm';
|
||||
import { Entity, PrimaryColumn, Column, OneToOne, OneToMany, JoinColumn } from 'typeorm';
|
||||
import { Users } from './users.entity';
|
||||
import { AmChildren } from './am_children.entity';
|
||||
|
||||
@Entity('assistantes_maternelles')
|
||||
export class AssistanteMaternelle {
|
||||
@@ -51,4 +52,7 @@ export class AssistanteMaternelle {
|
||||
/** 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;
|
||||
|
||||
@OneToMany(() => AmChildren, (ac) => ac.am)
|
||||
amChildren: AmChildren[];
|
||||
}
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Parents } from './parents.entity';
|
||||
import { ParentsChildren } from './parents_children.entity';
|
||||
import { AmChildren } from './am_children.entity';
|
||||
import { Dossier } from './dossiers.entity';
|
||||
|
||||
export enum StatutEnfantType {
|
||||
A_NAITRE = 'a_naitre',
|
||||
ACTIF = 'actif',
|
||||
SCOLARISE = 'scolarise',
|
||||
GARDE = 'garde',
|
||||
SANS_GARDE = 'sans_garde',
|
||||
}
|
||||
|
||||
export enum GenreType {
|
||||
@@ -61,13 +63,13 @@ export class Children {
|
||||
@Column({ type: 'timestamptz', nullable: true, name: 'date_consentement_photo' })
|
||||
consent_photo_at?: Date;
|
||||
|
||||
@Column({ default: false, name: 'est_multiple', type: 'boolean' })
|
||||
is_multiple: boolean;
|
||||
|
||||
// Lien via table de jointure enfants_parents
|
||||
@OneToMany(() => ParentsChildren, pc => pc.child)
|
||||
parentLinks: ParentsChildren[];
|
||||
|
||||
@OneToMany(() => AmChildren, (ac) => ac.child)
|
||||
amLinks: AmChildren[];
|
||||
|
||||
// Relation avec Dossier
|
||||
@OneToMany(() => Dossier, d => d.child)
|
||||
dossiers: Dossier[];
|
||||
|
||||
@@ -119,6 +119,13 @@ 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;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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');
|
||||
});
|
||||
|
||||
it('sendResoumissionPendingEmail — accusé resoumission avec n° dossier', async () => {
|
||||
await service.sendResoumissionPendingEmail(
|
||||
'parent@test.fr',
|
||||
'Claire',
|
||||
'MARTIN',
|
||||
'2026-000024',
|
||||
);
|
||||
expect(sendEmailSpy).toHaveBeenCalledTimes(1);
|
||||
const [to, subject, html] = sendEmailSpy.mock.calls[0];
|
||||
expect(to).toBe('parent@test.fr');
|
||||
expect(subject).toContain('resoumis');
|
||||
expect(subject).toContain('2026-000024');
|
||||
expect(html).toContain('Claire');
|
||||
expect(html).toContain('2026-000024');
|
||||
expect(html).toContain('en attente de validation');
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,179 @@
|
||||
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
|
||||
@@ -15,50 +182,48 @@ export class MailService {
|
||||
* @param text Contenu texte (optionnel)
|
||||
*/
|
||||
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
|
||||
try {
|
||||
// Récupération de la configuration SMTP
|
||||
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 emailFromName = this.configService.get<string>('email_from_name');
|
||||
const emailFromAddress = this.configService.get<string>('email_from_address');
|
||||
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,
|
||||
};
|
||||
|
||||
// Import dynamique de nodemailer
|
||||
const nodemailer = await import('nodemailer');
|
||||
let lastError: unknown;
|
||||
|
||||
// Configuration du transporteur
|
||||
const transportConfig: any = {
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
secure: smtpSecure,
|
||||
};
|
||||
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 (smtpAuthRequired && smtpUser && smtpPassword) {
|
||||
transportConfig.auth = {
|
||||
user: smtpUser,
|
||||
pass: smtpPassword,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport(transportConfig);
|
||||
|
||||
// Envoi de l'email
|
||||
await transporter.sendMail({
|
||||
from: `"${emailFromName}" <${emailFromAddress}>`,
|
||||
to,
|
||||
subject,
|
||||
text: text || html.replace(/<[^>]*>?/gm, ''), // Fallback texte simple
|
||||
html,
|
||||
});
|
||||
|
||||
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError);
|
||||
this.resetTransporter();
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,6 +301,43 @@ export class MailService {
|
||||
await this.sendEmail(to, subject, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accusé de resoumission après refus (reprise #112) : dossier à nouveau en attente de validation.
|
||||
*/
|
||||
async sendResoumissionPendingEmail(
|
||||
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 dossier a été resoumis — ${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 reçu la <strong>resoumission</strong> de votre dossier sur <strong>${safe(appName)}</strong>.</p>
|
||||
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
|
||||
<p>Votre dossier est de nouveau <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
|
||||
@@ -148,21 +350,29 @@ export class MailService {
|
||||
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');
|
||||
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 commentBlock = comment
|
||||
? `<p><strong>Message du gestionnaire :</strong></p><p>${comment.replace(/</g, '<').replace(/>/g, '>')}</p>`
|
||||
: '';
|
||||
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: #333;">Bonjour ${prenom} ${nom},</h2>
|
||||
<p>Votre dossier d'inscription sur <strong>${appName}</strong> n'a pas pu être validé en l'état.</p>
|
||||
${commentBlock}
|
||||
<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: #2196F3; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
|
||||
<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;">
|
||||
@@ -172,4 +382,31 @@ export class MailService {
|
||||
|
||||
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>
|
||||
+51
-2
@@ -1,20 +1,69 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
|
||||
describe('AssistantesMaternellesController', () => {
|
||||
let controller: AssistantesMaternellesController;
|
||||
const authServiceMock = {
|
||||
createAmDossierStaff: jest.fn(),
|
||||
};
|
||||
const amServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AssistantesMaternellesController],
|
||||
providers: [AssistantesMaternellesService],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: AssistantesMaternellesService, useValue: amServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<AssistantesMaternellesController>(AssistantesMaternellesController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createAmDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createAmDossierStaff.mockResolvedValue({
|
||||
message: 'ok',
|
||||
user_id: 'u1',
|
||||
statut: 'actif',
|
||||
numero_dossier: '2026-000001',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'am.staff@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'TEST',
|
||||
telephone: '0689567890',
|
||||
consentement_photo: false,
|
||||
lieu_naissance_ville: 'Paris',
|
||||
lieu_naissance_pays: 'France',
|
||||
nir: '285017512345678',
|
||||
numero_agrement: 'AGR-TEST-001',
|
||||
capacite_accueil: 3,
|
||||
places_disponibles: 2,
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createAmDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,23 +7,60 @@ import {
|
||||
Param,
|
||||
Delete,
|
||||
UseGuards,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||||
import { StaffCreateAmDossierDto } from './dto/staff-create-am-dossier.dto';
|
||||
import { StaffCreateAmDossierResponseDto } from './dto/staff-create-am-dossier-response.dto';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { User } from 'src/common/decorators/user.decorator';
|
||||
import { mapAmForApi, mapAmsForApi } from './assistantes_maternelles.mapper';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { RegisterAMCompletDto } from '../auth/dto/register-am-complet.dto';
|
||||
|
||||
@ApiTags("Assistantes Maternelles")
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('assistantes-maternelles')
|
||||
export class AssistantesMaternellesController {
|
||||
constructor(private readonly assistantesMaternellesService: AssistantesMaternellesService) { }
|
||||
constructor(
|
||||
private readonly assistantesMaternellesService: AssistantesMaternellesService,
|
||||
private readonly authService: AuthService,
|
||||
) { }
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier AM complet (staff) — ticket #156',
|
||||
description:
|
||||
'Crée user + fiche AM avec statut actif, n° dossier, et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/am depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateAmDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateAmDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation métier / NIR' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email / NIR / agrément déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateAmDossierDto,
|
||||
): Promise<StaffCreateAmDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterAMCompletDto;
|
||||
return this.authService.createAmDossierStaff(registerDto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Créer nounou' })
|
||||
@@ -31,28 +68,74 @@ export class AssistantesMaternellesController {
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||
@ApiBody({ type: CreateAssistanteDto })
|
||||
@Post()
|
||||
create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||
return this.assistantesMaternellesService.create(dto);
|
||||
async create(@Body() dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||
const am = await this.assistantesMaternellesService.create(dto);
|
||||
return mapAmForApi(am);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Récupérer la liste des nounous' })
|
||||
@ApiOperation({ summary: 'Récupérer la liste des nounous (inclut amChildren actifs) — ticket #131' })
|
||||
@ApiResponse({ status: 200, description: 'Liste des nounous' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||
getAll(): Promise<AssistanteMaternelle[]> {
|
||||
return this.assistantesMaternellesService.findAll();
|
||||
async getAll(): Promise<AssistanteMaternelle[]> {
|
||||
const ams = await this.assistantesMaternellesService.findAll();
|
||||
return mapAmsForApi(ams);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get(':id')
|
||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||
@ApiOperation({ summary: 'Récupérer une nounou par id' })
|
||||
@ApiOperation({ summary: 'Récupérer une nounou par id (inclut amChildren) — ticket #131' })
|
||||
@ApiResponse({ status: 200, description: 'Détails de la nounou' })
|
||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins et gestionnaires' })
|
||||
getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
|
||||
return this.assistantesMaternellesService.findOne(user_id);
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
async getOne(@Param('id') user_id: string): Promise<AssistanteMaternelle> {
|
||||
const am = await this.assistantesMaternellesService.findOne(user_id);
|
||||
return mapAmForApi(am);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Patch(':id/fiche')
|
||||
@ApiBody({ type: UpdateAmFicheAdminDto })
|
||||
@ApiOperation({ summary: 'Mettre à jour la fiche AM (identité + pro) — ticket #131' })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
|
||||
@ApiResponse({ status: 200, description: 'Fiche AM mise à jour' })
|
||||
async updateFicheAdmin(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAmFicheAdminDto,
|
||||
): Promise<AssistanteMaternelle> {
|
||||
const am = await this.assistantesMaternellesService.updateFicheAdmin(id, dto);
|
||||
return mapAmForApi(am);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: 'Rattacher un enfant à une AM (statut enfant → garde) — ticket #131' })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
|
||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
|
||||
async attachEnfant(
|
||||
@Param('id') id: string,
|
||||
@Param('enfantId') enfantId: string,
|
||||
@User() currentUser: Users,
|
||||
): Promise<AssistanteMaternelle> {
|
||||
const am = await this.assistantesMaternellesService.attachEnfant(id, enfantId, currentUser);
|
||||
return mapAmForApi(am);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Delete(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: "Clôturer le placement d'un enfant chez une AM — ticket #131" })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur de l'AM" })
|
||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||
@ApiResponse({ status: 200, description: 'AM avec enfants mis à jour' })
|
||||
async detachEnfant(
|
||||
@Param('id') id: string,
|
||||
@Param('enfantId') enfantId: string,
|
||||
): Promise<AssistanteMaternelle> {
|
||||
const am = await this.assistantesMaternellesService.detachEnfant(id, enfantId);
|
||||
return mapAmForApi(am);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@@ -63,14 +146,15 @@ export class AssistantesMaternellesController {
|
||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||
return this.assistantesMaternellesService.update(id, dto);
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||
const am = await this.assistantesMaternellesService.update(id, dto);
|
||||
return mapAmForApi(am);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Supprimer une nounou' })
|
||||
@ApiResponse({ status: 200, description: 'Nounou supprimée avec succès' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé : Réservé aux super_admins, gestionnaires et administrateurs' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
@ApiResponse({ status: 404, description: 'Nounou non trouvée' })
|
||||
@ApiParam({ name: 'id', description: "UUID de la nounou" })
|
||||
@Delete(':id')
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
|
||||
|
||||
/**
|
||||
* Sérialisation API fiche AM — ticket #131.
|
||||
* Expose `amChildren` actifs (date_fin null) avec enfant imbriqué, sans secrets user.
|
||||
*/
|
||||
export function mapAmForApi(am: AssistanteMaternelle): AssistanteMaternelle {
|
||||
const activeChildren = (am.amChildren ?? []).filter((link) => !link.date_fin);
|
||||
|
||||
return {
|
||||
...am,
|
||||
user: sanitizeUserForApi(am.user)!,
|
||||
amChildren: activeChildren.map((link) => ({
|
||||
...link,
|
||||
child: link.child,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapAmsForApi(ams: AssistanteMaternelle[]): AssistanteMaternelle[] {
|
||||
return ams.map(mapAmForApi);
|
||||
}
|
||||
|
||||
export function filterActiveAmChildren(links: AmChildren[] | undefined): AmChildren[] {
|
||||
return (links ?? []).filter((link) => !link.date_fin);
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { Module } from '@nestjs/common';
|
||||
import { AssistantesMaternellesService } from './assistantes_maternelles.service';
|
||||
import { AssistantesMaternellesController } from './assistantes_maternelles.controller';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, Users]),
|
||||
imports: [TypeOrmModule.forFeature([AssistanteMaternelle, AmChildren, Children, Users]),
|
||||
AuthModule
|
||||
],
|
||||
controllers: [AssistantesMaternellesController],
|
||||
|
||||
@@ -5,11 +5,17 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||
import { CreateAssistanteDto } from '../user/dto/create_assistante.dto';
|
||||
import { UpdateAssistanteDto } from '../user/dto/update_assistante.dto';
|
||||
import { UpdateAmFicheAdminDto } from './dto/update-am-fiche-admin.dto';
|
||||
import { validateNir } from 'src/common/utils/nir.util';
|
||||
|
||||
const AM_CHILDREN_RELATIONS = ['user', 'amChildren', 'amChildren.child'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class AssistantesMaternellesService {
|
||||
@@ -17,10 +23,13 @@ export class AssistantesMaternellesService {
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly assistantesMaternelleRepository: Repository<AssistanteMaternelle>,
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(AmChildren)
|
||||
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||
@InjectRepository(Children)
|
||||
private readonly childrenRepository: Repository<Children>,
|
||||
) {}
|
||||
|
||||
// Création d’une assistante maternelle
|
||||
async create(dto: CreateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||
const user = await this.usersRepository.findOneBy({ id: dto.user_id });
|
||||
if (!user) throw new NotFoundException('Utilisateur introuvable');
|
||||
@@ -49,30 +58,208 @@ export class AssistantesMaternellesService {
|
||||
return this.assistantesMaternelleRepository.save(entity);
|
||||
}
|
||||
|
||||
// Liste des assistantes maternelles
|
||||
async findAll(): Promise<AssistanteMaternelle[]> {
|
||||
return this.assistantesMaternelleRepository.find({
|
||||
relations: ['user'],
|
||||
relations: [...AM_CHILDREN_RELATIONS],
|
||||
});
|
||||
}
|
||||
|
||||
// Récupérer une assistante maternelle par user_id
|
||||
async findOne(user_id: string): Promise<AssistanteMaternelle> {
|
||||
const assistante = await this.assistantesMaternelleRepository.findOne({
|
||||
where: { user_id },
|
||||
relations: ['user'],
|
||||
relations: [...AM_CHILDREN_RELATIONS],
|
||||
});
|
||||
if (!assistante) throw new NotFoundException('Assistante maternelle introuvable');
|
||||
return assistante;
|
||||
}
|
||||
|
||||
// Mise à jour
|
||||
async update(id: string, dto: UpdateAssistanteDto): Promise<AssistanteMaternelle> {
|
||||
await this.assistantesMaternelleRepository.update(id, dto);
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
// Suppression d’une assistante maternelle
|
||||
/**
|
||||
* Mise à jour fiche AM (identité + champs pro) par admin/gestionnaire. Ticket #131.
|
||||
*/
|
||||
async updateFicheAdmin(amUserId: string, dto: UpdateAmFicheAdminDto): Promise<AssistanteMaternelle> {
|
||||
const am = await this.findOne(amUserId);
|
||||
const user = am.user;
|
||||
|
||||
if (dto.email && dto.email !== user.email) {
|
||||
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw new ConflictException('Cet email est déjà utilisé');
|
||||
}
|
||||
user.email = dto.email;
|
||||
}
|
||||
|
||||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||
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.statut !== undefined) user.statut = dto.statut;
|
||||
if (dto.date_naissance !== undefined) {
|
||||
user.date_naissance = dto.date_naissance ? new Date(dto.date_naissance) : undefined;
|
||||
}
|
||||
if (dto.lieu_naissance_ville !== undefined) {
|
||||
user.lieu_naissance_ville = dto.lieu_naissance_ville || undefined;
|
||||
}
|
||||
if (dto.lieu_naissance_pays !== undefined) {
|
||||
user.lieu_naissance_pays = dto.lieu_naissance_pays || undefined;
|
||||
}
|
||||
|
||||
await this.usersRepository.save(user);
|
||||
|
||||
const amPatch: Partial<AssistanteMaternelle> = {};
|
||||
if (dto.approval_number !== undefined) amPatch.approval_number = dto.approval_number;
|
||||
if (dto.residence_city !== undefined) amPatch.residence_city = dto.residence_city;
|
||||
if (dto.max_children !== undefined) amPatch.max_children = dto.max_children;
|
||||
if (dto.places_available !== undefined) amPatch.places_available = dto.places_available;
|
||||
if (dto.biography !== undefined) amPatch.biography = dto.biography;
|
||||
if (dto.available !== undefined) amPatch.available = dto.available;
|
||||
if (dto.agreement_date !== undefined) {
|
||||
amPatch.agreement_date = dto.agreement_date ? new Date(dto.agreement_date) : undefined;
|
||||
}
|
||||
|
||||
if (dto.nir !== undefined) {
|
||||
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
|
||||
if (nirNormalized) {
|
||||
const dateNaissanceForNir =
|
||||
dto.date_naissance ??
|
||||
(user.date_naissance instanceof Date
|
||||
? user.date_naissance.toISOString().slice(0, 10)
|
||||
: user.date_naissance
|
||||
? String(user.date_naissance).slice(0, 10)
|
||||
: undefined);
|
||||
const nirValidation = validateNir(nirNormalized, {
|
||||
dateNaissance: dateNaissanceForNir,
|
||||
});
|
||||
if (!nirValidation.valid) {
|
||||
throw new BadRequestException(nirValidation.error || 'NIR invalide');
|
||||
}
|
||||
const nirDejaUtilise = await this.assistantesMaternelleRepository.findOne({
|
||||
where: { nir: nirNormalized },
|
||||
});
|
||||
if (nirDejaUtilise && nirDejaUtilise.user_id !== amUserId) {
|
||||
throw new ConflictException(
|
||||
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
|
||||
);
|
||||
}
|
||||
amPatch.nir = nirNormalized;
|
||||
}
|
||||
// NIR vide : ne pas effacer (colonne NOT NULL en BDD) — le front renvoie toujours la clé.
|
||||
}
|
||||
|
||||
if (Object.keys(amPatch).length > 0) {
|
||||
await this.assistantesMaternelleRepository.update(amUserId, amPatch);
|
||||
}
|
||||
|
||||
return this.findOne(amUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant à une AM (placement actif). Ticket #131.
|
||||
* Passe le statut enfant à `garde` (sauf a_naitre / scolarise).
|
||||
*/
|
||||
async attachEnfant(amUserId: string, enfantId: string, createdBy?: Users): Promise<AssistanteMaternelle> {
|
||||
const am = await this.findOne(amUserId);
|
||||
|
||||
const existingForAm = await this.amChildrenRepository.findOne({
|
||||
where: { amId: amUserId, enfantId, date_fin: IsNull() },
|
||||
});
|
||||
if (existingForAm) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à cette assistante maternelle');
|
||||
}
|
||||
|
||||
const child = await this.childrenRepository.findOne({ where: { id: enfantId } });
|
||||
if (!child) {
|
||||
throw new NotFoundException('Enfant introuvable');
|
||||
}
|
||||
|
||||
const activeForChild = await this.amChildrenRepository.findOne({
|
||||
where: { enfantId, date_fin: IsNull() },
|
||||
});
|
||||
if (activeForChild && activeForChild.amId !== amUserId) {
|
||||
throw new ConflictException(
|
||||
'Cet enfant est déjà en garde chez une autre assistante maternelle',
|
||||
);
|
||||
}
|
||||
|
||||
const activeCount = await this.amChildrenRepository.count({
|
||||
where: { amId: amUserId, date_fin: IsNull() },
|
||||
});
|
||||
if (am.max_children != null && activeCount >= am.max_children) {
|
||||
throw new BadRequestException(
|
||||
`Capacité maximale atteinte (${am.max_children} enfant(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.amChildrenRepository.save(
|
||||
this.amChildrenRepository.create({
|
||||
amId: amUserId,
|
||||
enfantId,
|
||||
date_debut: new Date(),
|
||||
cree_par: createdBy?.id,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applyGardeStatusOnAttach(child);
|
||||
|
||||
return this.findOne(amUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clôturer le placement AM ↔ enfant. Ticket #131.
|
||||
* Repasse l'enfant en `sans_garde` s'il n'a plus de placement actif.
|
||||
*/
|
||||
async detachEnfant(amUserId: string, enfantId: string): Promise<AssistanteMaternelle> {
|
||||
await this.findOne(amUserId);
|
||||
|
||||
const link = await this.amChildrenRepository.findOne({
|
||||
where: { amId: amUserId, enfantId, date_fin: IsNull() },
|
||||
relations: ['child'],
|
||||
});
|
||||
if (!link) {
|
||||
throw new NotFoundException('Lien assistante maternelle-enfant introuvable');
|
||||
}
|
||||
|
||||
link.date_fin = new Date();
|
||||
await this.amChildrenRepository.save(link);
|
||||
|
||||
const remaining = await this.amChildrenRepository.count({
|
||||
where: { enfantId, date_fin: IsNull() },
|
||||
});
|
||||
if (remaining === 0 && link.child) {
|
||||
await this.applySansGardeStatusOnDetach(link.child);
|
||||
}
|
||||
|
||||
return this.findOne(amUserId);
|
||||
}
|
||||
|
||||
private async applyGardeStatusOnAttach(child: Children): Promise<void> {
|
||||
if (
|
||||
child.status === StatutEnfantType.A_NAITRE ||
|
||||
child.status === StatutEnfantType.SCOLARISE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
child.status = StatutEnfantType.GARDE;
|
||||
await this.childrenRepository.save(child);
|
||||
}
|
||||
|
||||
private async applySansGardeStatusOnDetach(child: Children): Promise<void> {
|
||||
if (
|
||||
child.status === StatutEnfantType.A_NAITRE ||
|
||||
child.status === StatutEnfantType.SCOLARISE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
child.status = StatutEnfantType.SANS_GARDE;
|
||||
await this.childrenRepository.save(child);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<{ message: string }> {
|
||||
await this.assistantesMaternelleRepository.delete(id);
|
||||
return { message: 'Assistante maternelle supprimée' };
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /assistantes-maternelles/dossier (#156). */
|
||||
export class StaffCreateAmDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
user_id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000042',
|
||||
description: 'Numéro de dossier attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterAMCompletDto } from 'src/routes/auth/dto/register-am-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier AM par staff (#156).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateAmDossierDto extends OmitType(RegisterAMCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Mise à jour fiche AM par admin/gestionnaire (doc 28 §6.1, ticket #131). */
|
||||
export class UpdateAmFicheAdminDto {
|
||||
@ApiPropertyOptional({ example: 'MARTIN' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
nom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Claire' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
prenom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'claire@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '0612345678' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
telephone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '5 place Bellecour' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Lyon' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '69002' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: StatutUtilisateurType })
|
||||
@IsOptional()
|
||||
@IsEnum(StatutUtilisateurType)
|
||||
statut?: StatutUtilisateurType;
|
||||
|
||||
@ApiPropertyOptional({ example: '123456789012345' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(15)
|
||||
nir?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '1985-03-12' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date_naissance?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Lyon' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lieu_naissance_ville?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'France' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lieu_naissance_pays?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'AGR-2024-12345' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
approval_number?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2020-01-15' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
agreement_date?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Lyon' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
residence_city?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 4 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
max_children?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 2 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
places_available?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
biography?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
available?: boolean;
|
||||
}
|
||||
@@ -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,4 +1,17 @@
|
||||
import { Body, Controller, Get, Patch, Post, Query, 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';
|
||||
@@ -8,6 +21,8 @@ import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
|
||||
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
|
||||
import { ChangePasswordRequiredDto } from './dto/change-password.dto';
|
||||
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';
|
||||
@@ -87,8 +102,7 @@ export class AuthController {
|
||||
@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);
|
||||
return this.authService.resoumettreReprise(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@@ -133,6 +147,36 @@ export class AuthController {
|
||||
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')
|
||||
|
||||
@@ -12,11 +12,25 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
|
||||
import { AppConfigModule } from 'src/modules/config';
|
||||
import { NumeroDossierModule } from 'src/modules/numero-dossier/numero-dossier.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
import { ParentsModule } from '../parents/parents.module';
|
||||
import { DossiersModule } from '../dossiers/dossiers.module';
|
||||
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
|
||||
TypeOrmModule.forFeature([
|
||||
Users,
|
||||
Parents,
|
||||
Children,
|
||||
AssistanteMaternelle,
|
||||
DossierFamille,
|
||||
DossierFamilleEnfant,
|
||||
ParentsChildren,
|
||||
]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => ParentsModule),
|
||||
DossiersModule,
|
||||
AppConfigModule,
|
||||
MailModule,
|
||||
NumeroDossierModule,
|
||||
|
||||
@@ -1,12 +1,79 @@
|
||||
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 { ParentsService } from '../parents/parents.service';
|
||||
import { DossiersService } from '../dossiers/dossiers.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, RoleType, StatutUtilisateurType } 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(),
|
||||
manager: { transaction: jest.fn() },
|
||||
};
|
||||
|
||||
const userServiceMock = {
|
||||
findByTokenReprise: jest.fn(),
|
||||
};
|
||||
|
||||
const parentsServiceMock = {
|
||||
getDossierFamilleByNumero: jest.fn(),
|
||||
getFamilyUserIds: jest.fn(),
|
||||
};
|
||||
|
||||
const dossiersServiceMock = {
|
||||
getDossierByNumero: 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: userServiceMock },
|
||||
{ provide: ParentsService, useValue: parentsServiceMock },
|
||||
{ provide: DossiersService, useValue: dossiersServiceMock },
|
||||
{ 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 +82,141 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reprise après refus (#112)', () => {
|
||||
const token = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
it('getRepriseDossier throws when token invalid', async () => {
|
||||
userServiceMock.findByTokenReprise.mockResolvedValue(null);
|
||||
await expect(service.getRepriseDossier(token)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('getRepriseDossier returns famille complète pour parent', async () => {
|
||||
userServiceMock.findByTokenReprise.mockResolvedValue({
|
||||
id: 'p1',
|
||||
email: 'claire@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'MARTIN',
|
||||
role: RoleType.PARENT,
|
||||
numero_dossier: '2026-000021',
|
||||
statut: StatutUtilisateurType.REFUSE,
|
||||
});
|
||||
parentsServiceMock.getDossierFamilleByNumero.mockResolvedValue({
|
||||
numero_dossier: '2026-000021',
|
||||
parents: [{ user_id: 'p1', email: 'claire@test.fr', statut: StatutUtilisateurType.REFUSE }],
|
||||
enfants: [{ id: 'e1', first_name: 'Emma', status: 'sans_garde' }],
|
||||
texte_motivation: 'Motivation test',
|
||||
});
|
||||
|
||||
const result = await service.getRepriseDossier(token);
|
||||
expect(result.parents).toHaveLength(1);
|
||||
expect(result.enfants).toHaveLength(1);
|
||||
expect(result.texte_motivation).toBe('Motivation test');
|
||||
expect(result.numero_dossier).toBe('2026-000021');
|
||||
});
|
||||
|
||||
it('getRepriseDossier returns fiche AM complète', async () => {
|
||||
userServiceMock.findByTokenReprise.mockResolvedValue({
|
||||
id: 'am1',
|
||||
email: 'am@test.fr',
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
numero_dossier: '2026-000003',
|
||||
});
|
||||
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
|
||||
type: 'am',
|
||||
dossier: {
|
||||
numero_dossier: '2026-000003',
|
||||
user: {
|
||||
id: 'am1',
|
||||
email: 'am@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'DUPONT',
|
||||
statut: StatutUtilisateurType.REFUSE,
|
||||
},
|
||||
numero_agrement: 'AGR-1',
|
||||
nir: '123456789012345',
|
||||
biographie: 'Bio',
|
||||
nb_max_enfants: 4,
|
||||
place_disponible: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await service.getRepriseDossier(token);
|
||||
expect(result.numero_agrement).toBe('AGR-1');
|
||||
expect(result.biographie).toBe('Bio');
|
||||
expect(result.nb_max_enfants).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,9 +55,13 @@ export class EnfantInscriptionDto {
|
||||
@IsString()
|
||||
photo_filename?: string;
|
||||
|
||||
@ApiProperty({ example: false, required: false, description: 'Grossesse multiple (jumeaux, triplés, etc.)' })
|
||||
@ApiProperty({
|
||||
example: true,
|
||||
required: false,
|
||||
description: 'Consentement affichage / stockage photo (colonne enfants.consentement_photo)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
grossesse_multiple?: boolean;
|
||||
consent_photo?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
import { EnfantInscriptionDto } from './enfant-inscription.dto';
|
||||
|
||||
/** Enfant existant modifiable lors de la resoumission reprise (#112). */
|
||||
export class EnfantRepriseDto extends EnfantInscriptionDto {
|
||||
@ApiProperty({ description: 'UUID enfant existant (obligatoire en reprise)' })
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
id: 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,7 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import {
|
||||
DossierFamilleEnfantDto,
|
||||
DossierFamilleParentDto,
|
||||
} from '../../parents/dto/dossier-famille-complet.dto';
|
||||
|
||||
/** Réponse GET /auth/reprise-dossier – données dossier pour préremplir le formulaire reprise. Ticket #111 */
|
||||
/** Réponse GET /auth/reprise-dossier – dossier complet pour préremplir le wizard reprise. #111 + #112 */
|
||||
export class RepriseDossierDto {
|
||||
@ApiProperty()
|
||||
id: string;
|
||||
@@ -41,4 +45,47 @@ export class RepriseDossierDto {
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
situation_familiale?: string;
|
||||
|
||||
// --- Parent (dossier famille, aligné #119) ---
|
||||
|
||||
@ApiProperty({ type: [DossierFamilleParentDto], required: false })
|
||||
parents?: DossierFamilleParentDto[];
|
||||
|
||||
@ApiProperty({ type: [DossierFamilleEnfantDto], required: false })
|
||||
enfants?: DossierFamilleEnfantDto[];
|
||||
|
||||
@ApiProperty({ required: false, description: 'Motivation / présentation dossier famille' })
|
||||
texte_motivation?: string;
|
||||
|
||||
// --- AM (aligné DossierAmCompletDto) ---
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
consentement_photo?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
date_naissance?: Date;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
lieu_naissance_ville?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
lieu_naissance_pays?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
numero_agrement?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
nir?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
date_agrement?: Date;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
nb_max_enfants?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
place_disponible?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
biographie?: 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;
|
||||
}
|
||||
@@ -1,12 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength, IsUUID } from 'class-validator';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
IsUUID,
|
||||
IsBoolean,
|
||||
IsArray,
|
||||
ValidateNested,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
IsDateString,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EnfantRepriseDto } from './enfant-reprise.dto';
|
||||
|
||||
/** Body PUT /auth/reprise-resoumettre – token + champs modifiables. Ticket #111 */
|
||||
/** Body PATCH /auth/reprise-resoumettre – token + champs modifiables du wizard. #111 + #112 */
|
||||
export class ResoumettreRepriseDto {
|
||||
@ApiProperty({ description: 'Token reprise (reçu par email)' })
|
||||
@IsUUID()
|
||||
token: string;
|
||||
|
||||
// --- Identité titulaire (parent principal ou AM) ---
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -42,8 +59,147 @@ export class ResoumettreRepriseDto {
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Pour AM' })
|
||||
@ApiProperty({ required: false, description: 'Pour AM (URL photo existante)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Pour AM (nouvelle photo base64)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_base64?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Pour AM (nom fichier photo)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
photo_filename?: string;
|
||||
|
||||
// --- Co-parent (reprise parent) ---
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_email?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_prenom?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_nom?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||
message: 'Le numéro de téléphone du co-parent doit être valide',
|
||||
})
|
||||
co_parent_telephone?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
co_parent_meme_adresse?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_adresse?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_code_postal?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
co_parent_ville?: string;
|
||||
|
||||
// --- Motivation dossier famille ---
|
||||
|
||||
@ApiProperty({ required: false, description: 'Alias texte_motivation' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
texte_motivation?: string;
|
||||
|
||||
@ApiProperty({ required: false, description: 'Alias presentation_dossier (inscription)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
presentation_dossier?: string;
|
||||
|
||||
@ApiProperty({ type: [EnfantRepriseDto], required: false })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => EnfantRepriseDto)
|
||||
enfants?: EnfantRepriseDto[];
|
||||
|
||||
// --- AM ---
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
consentement_photo?: boolean;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date_naissance?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lieu_naissance_ville?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
lieu_naissance_pays?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
numero_agrement?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@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({ required: false })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
date_agrement?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
capacite_accueil?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10)
|
||||
places_disponibles?: number;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
biographie?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('DossiersController', () => {
|
||||
let controller: DossiersController;
|
||||
const dossiersServiceMock = {
|
||||
listDossiers: jest.fn(),
|
||||
getDossierByNumero: jest.fn(),
|
||||
};
|
||||
const suppressionServiceMock = {
|
||||
deleteDossier: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DossiersController],
|
||||
providers: [
|
||||
{ provide: DossiersService, useValue: dossiersServiceMock },
|
||||
{ provide: SuppressionService, useValue: suppressionServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<DossiersController>(DossiersController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('list delegates to dossiersService.listDossiers with q', async () => {
|
||||
dossiersServiceMock.listDossiers.mockResolvedValue([
|
||||
{
|
||||
type: 'famille',
|
||||
numero_dossier: '2026-000043',
|
||||
libelle: 'Claire MARTIN',
|
||||
emails: ['claire@test.fr'],
|
||||
user_ids: ['u1'],
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
a_valider: false,
|
||||
date_reference: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await controller.list('martin');
|
||||
expect(dossiersServiceMock.listDossiers).toHaveBeenCalledWith('martin');
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].numero_dossier).toBe('2026-000043');
|
||||
});
|
||||
|
||||
it('getDossier delegates to getDossierByNumero', async () => {
|
||||
dossiersServiceMock.getDossierByNumero.mockResolvedValue({
|
||||
type: 'family',
|
||||
dossier: { numero_dossier: '2026-000001' },
|
||||
});
|
||||
const res = await controller.getDossier('2026-000001');
|
||||
expect(dossiersServiceMock.getDossierByNumero).toHaveBeenCalledWith('2026-000001');
|
||||
expect(res.type).toBe('family');
|
||||
});
|
||||
|
||||
it('remove delegates to suppressionService.deleteDossier', async () => {
|
||||
const user = { id: 'u1', role: 'gestionnaire' } as never;
|
||||
suppressionServiceMock.deleteDossier.mockResolvedValue({
|
||||
type: 'famille',
|
||||
deleted_user_ids: [],
|
||||
deleted_enfant_ids: [],
|
||||
message: 'ok',
|
||||
});
|
||||
await controller.remove('2026-000001', user);
|
||||
expect(suppressionServiceMock.deleteDossier).toHaveBeenCalledWith(
|
||||
'2026-000001',
|
||||
user,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,58 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
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 { DossiersService } from './dossiers.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
@ApiTags('Dossiers')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('dossiers')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class DossiersController {
|
||||
constructor(private readonly dossiersService: DossiersService) {}
|
||||
constructor(
|
||||
private readonly dossiersService: DossiersService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Liste unifiée des dossiers (familles + AM) — ticket #153',
|
||||
description:
|
||||
'1 entrée = 1 numero_dossier. Types `famille` | `assistante_maternelle`. ' +
|
||||
'Filtre optionnel `q` (n°, nom, email). Tri : à valider d’abord, puis n° décroissant. ' +
|
||||
'`sans_enfant` (#159) pour dossiers famille sans enfant.',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'q',
|
||||
required: false,
|
||||
description: 'Recherche libre : n° dossier, libellé, email…',
|
||||
})
|
||||
@ApiResponse({ status: 200, type: [DossierListItemDto] })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
list(@Query('q') q?: string): Promise<DossierListItemDto[]> {
|
||||
return this.dossiersService.listDossiers(q);
|
||||
}
|
||||
|
||||
@Get(':numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@@ -23,4 +64,21 @@ export class DossiersController {
|
||||
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
return this.dossiersService.getDossierByNumero(numeroDossier);
|
||||
}
|
||||
|
||||
@Delete(':numeroDossier')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Supprimer un dossier (famille ou AM) — #159',
|
||||
description:
|
||||
'Famille : parents + enfants. AM : compte AM + dossier AM (enfants conservés, placements clos).',
|
||||
})
|
||||
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier' })
|
||||
@ApiResponse({ status: 200, description: 'Résultat de suppression' })
|
||||
@ApiResponse({ status: 404, description: 'Dossier introuvable' })
|
||||
remove(
|
||||
@Param('numeroDossier') numeroDossier: string,
|
||||
@User() currentUser: Users,
|
||||
) {
|
||||
return this.suppressionService.deleteDossier(numeroDossier, currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
import { DossiersController } from './dossiers.controller';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
|
||||
@@ -12,6 +13,7 @@ import { DossiersService } from './dossiers.service';
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
|
||||
ParentsModule,
|
||||
SuppressionsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { DossiersService } from './dossiers.service';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('DossiersService.listDossiers', () => {
|
||||
let service: DossiersService;
|
||||
const parentsQb = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
const amQb = {
|
||||
innerJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn(),
|
||||
};
|
||||
const parentsRepo = {
|
||||
createQueryBuilder: jest.fn(() => parentsQb),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const amRepo = {
|
||||
createQueryBuilder: jest.fn(() => amQb),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const parentsService = {
|
||||
getDossierFamilleByNumero: jest.fn(),
|
||||
};
|
||||
const suppressionService = {
|
||||
countEnfantsForNumero: jest.fn().mockResolvedValue(1),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DossiersService,
|
||||
{ provide: getRepositoryToken(Parents), useValue: parentsRepo },
|
||||
{ provide: getRepositoryToken(AssistanteMaternelle), useValue: amRepo },
|
||||
{ provide: ParentsService, useValue: parentsService },
|
||||
{ provide: SuppressionService, useValue: suppressionService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(DossiersService);
|
||||
jest.clearAllMocks();
|
||||
parentsRepo.createQueryBuilder.mockReturnValue(parentsQb);
|
||||
amRepo.createQueryBuilder.mockReturnValue(amQb);
|
||||
suppressionService.countEnfantsForNumero.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('aggregates famille (pivot+co-parent) and AM, sorts a_valider first', async () => {
|
||||
parentsQb.getMany.mockResolvedValue([
|
||||
{
|
||||
user_id: 'p1',
|
||||
numero_dossier: '2026-000010',
|
||||
user: {
|
||||
id: 'p1',
|
||||
email: 'claire@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'Martin',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-01'),
|
||||
},
|
||||
co_parent: {
|
||||
id: 'p2',
|
||||
email: 'thomas@test.fr',
|
||||
prenom: 'Thomas',
|
||||
nom: 'Martin',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-02'),
|
||||
},
|
||||
},
|
||||
{
|
||||
user_id: 'p3',
|
||||
numero_dossier: '2026-000020',
|
||||
user: {
|
||||
id: 'p3',
|
||||
email: 'pending@test.fr',
|
||||
prenom: 'Paul',
|
||||
nom: 'Pending',
|
||||
statut: StatutUtilisateurType.EN_ATTENTE,
|
||||
cree_le: new Date('2026-02-01'),
|
||||
},
|
||||
co_parent: undefined,
|
||||
},
|
||||
]);
|
||||
amQb.getMany.mockResolvedValue([
|
||||
{
|
||||
user_id: 'am1',
|
||||
numero_dossier: '2026-000015',
|
||||
user: {
|
||||
id: 'am1',
|
||||
email: 'am@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'Dupont',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-15'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const list = await service.listDossiers();
|
||||
expect(list).toHaveLength(3);
|
||||
expect(list[0].a_valider).toBe(true);
|
||||
expect(list[0].type).toBe('famille');
|
||||
expect(list[0].numero_dossier).toBe('2026-000020');
|
||||
|
||||
const famille = list.find((i) => i.numero_dossier === '2026-000010')!;
|
||||
expect(famille.type).toBe('famille');
|
||||
expect(famille.user_ids).toEqual(expect.arrayContaining(['p1', 'p2']));
|
||||
expect(famille.emails).toHaveLength(2);
|
||||
expect(famille.libelle).toContain('MARTIN');
|
||||
|
||||
const am = list.find((i) => i.type === 'assistante_maternelle')!;
|
||||
expect(am.numero_dossier).toBe('2026-000015');
|
||||
expect(am.libelle).toContain('Marie');
|
||||
});
|
||||
|
||||
it('filters with q', async () => {
|
||||
parentsQb.getMany.mockResolvedValue([]);
|
||||
amQb.getMany.mockResolvedValue([
|
||||
{
|
||||
user_id: 'am1',
|
||||
numero_dossier: '2026-000015',
|
||||
user: {
|
||||
id: 'am1',
|
||||
email: 'am@test.fr',
|
||||
prenom: 'Marie',
|
||||
nom: 'Dupont',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
cree_le: new Date('2026-01-15'),
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const hit = await service.listDossiers('dupont');
|
||||
expect(hit).toHaveLength(1);
|
||||
const miss = await service.listDossiers('zzz');
|
||||
expect(miss).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,15 @@ 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 { StatutUtilisateurType, Users } from 'src/entities/users.entity';
|
||||
import { ParentsService } from '../parents/parents.service';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
|
||||
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
|
||||
import { DossierListItemDto } from './dto/dossier-list-item.dto';
|
||||
|
||||
/**
|
||||
* Endpoint unifié GET /dossiers/:numeroDossier – AM ou famille. Ticket #119.
|
||||
* Dossiers unifiés — détail (#119) + liste (#153) + sans_enfant (#159).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DossiersService {
|
||||
@@ -18,8 +21,173 @@ export class DossiersService {
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Liste unifiée tous dossiers (familles + AM) ayant un numero_dossier.
|
||||
* Ticket #153 — optionnel `q` filtre n° / nom / prénom / email (côté serveur).
|
||||
*/
|
||||
async listDossiers(q?: string): Promise<DossierListItemDto[]> {
|
||||
const items: DossierListItemDto[] = [
|
||||
...(await this.listFamilleItems()),
|
||||
...(await this.listAmItems()),
|
||||
];
|
||||
|
||||
const needle = (q ?? '').trim().toLowerCase();
|
||||
const filtered = needle
|
||||
? items.filter((item) => this.matchesQuery(item, needle))
|
||||
: items;
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
// À valider d'abord, puis n° dossier décroissant
|
||||
if (a.a_valider !== b.a_valider) return a.a_valider ? -1 : 1;
|
||||
return b.numero_dossier.localeCompare(a.numero_dossier, 'fr');
|
||||
});
|
||||
|
||||
for (const item of filtered) {
|
||||
if (item.type === 'famille') {
|
||||
const n = await this.suppressionService.countEnfantsForNumero(
|
||||
item.numero_dossier,
|
||||
);
|
||||
item.sans_enfant = n === 0;
|
||||
} else {
|
||||
item.sans_enfant = false;
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private async listFamilleItems(): Promise<DossierListItemDto[]> {
|
||||
const parents = await this.parentsRepository
|
||||
.createQueryBuilder('p')
|
||||
.innerJoinAndSelect('p.user', 'u')
|
||||
.leftJoinAndSelect('p.co_parent', 'cp')
|
||||
.where('p.numero_dossier IS NOT NULL')
|
||||
.andWhere("TRIM(p.numero_dossier) <> ''")
|
||||
.getMany();
|
||||
|
||||
const byNum = new Map<string, Parents[]>();
|
||||
for (const p of parents) {
|
||||
const num = (p.numero_dossier ?? '').trim();
|
||||
if (!num) continue;
|
||||
const group = byNum.get(num) ?? [];
|
||||
group.push(p);
|
||||
byNum.set(num, group);
|
||||
}
|
||||
|
||||
const items: DossierListItemDto[] = [];
|
||||
for (const [numero_dossier, group] of byNum) {
|
||||
const usersMap = new Map<string, Users>();
|
||||
for (const p of group) {
|
||||
if (p.user) usersMap.set(p.user.id, p.user);
|
||||
if (p.co_parent) usersMap.set(p.co_parent.id, p.co_parent);
|
||||
}
|
||||
const users = [...usersMap.values()].sort((a, b) => {
|
||||
const an = `${a.nom ?? ''} ${a.prenom ?? ''}`.toLowerCase();
|
||||
const bn = `${b.nom ?? ''} ${b.prenom ?? ''}`.toLowerCase();
|
||||
return an.localeCompare(bn, 'fr') || a.id.localeCompare(b.id);
|
||||
});
|
||||
if (users.length === 0) continue;
|
||||
|
||||
const names = users.map((u) => this.formatPersonName(u)).filter(Boolean);
|
||||
const libelle =
|
||||
names.length === 0
|
||||
? `Dossier ${numero_dossier}`
|
||||
: names.length === 1
|
||||
? names[0]
|
||||
: names.join(' & ');
|
||||
|
||||
const emails = users.map((u) => u.email).filter(Boolean);
|
||||
const user_ids = users.map((u) => u.id);
|
||||
const a_valider = users.some((u) => u.statut === StatutUtilisateurType.EN_ATTENTE);
|
||||
const statut = a_valider
|
||||
? StatutUtilisateurType.EN_ATTENTE
|
||||
: (users[0].statut ?? StatutUtilisateurType.ACTIF);
|
||||
const date_reference = this.minCreeLeIso(users);
|
||||
|
||||
items.push({
|
||||
type: 'famille',
|
||||
numero_dossier,
|
||||
libelle,
|
||||
emails,
|
||||
user_ids,
|
||||
statut,
|
||||
a_valider,
|
||||
date_reference,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private async listAmItems(): Promise<DossierListItemDto[]> {
|
||||
const ams = await this.amRepository
|
||||
.createQueryBuilder('am')
|
||||
.innerJoinAndSelect('am.user', 'u')
|
||||
.where('am.numero_dossier IS NOT NULL')
|
||||
.andWhere("TRIM(am.numero_dossier) <> ''")
|
||||
.getMany();
|
||||
|
||||
const byNum = new Map<string, AssistanteMaternelle>();
|
||||
for (const am of ams) {
|
||||
const num = (am.numero_dossier ?? '').trim();
|
||||
if (!num || !am.user) continue;
|
||||
// Un n° = une AM ; garder le premier
|
||||
if (!byNum.has(num)) byNum.set(num, am);
|
||||
}
|
||||
|
||||
const items: DossierListItemDto[] = [];
|
||||
for (const [numero_dossier, am] of byNum) {
|
||||
const u = am.user!;
|
||||
const libelle = this.formatPersonName(u) || `AM ${numero_dossier}`;
|
||||
const a_valider = u.statut === StatutUtilisateurType.EN_ATTENTE;
|
||||
items.push({
|
||||
type: 'assistante_maternelle',
|
||||
numero_dossier,
|
||||
libelle,
|
||||
emails: u.email ? [u.email] : [],
|
||||
user_ids: [u.id],
|
||||
statut: u.statut ?? StatutUtilisateurType.ACTIF,
|
||||
a_valider,
|
||||
date_reference: this.minCreeLeIso([u]),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private matchesQuery(item: DossierListItemDto, needle: string): boolean {
|
||||
const hay = [
|
||||
item.numero_dossier,
|
||||
item.libelle,
|
||||
...item.emails,
|
||||
item.statut,
|
||||
item.type,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return hay.includes(needle);
|
||||
}
|
||||
|
||||
private formatPersonName(u: Users): string {
|
||||
const prenom = (u.prenom ?? '').trim();
|
||||
const nom = (u.nom ?? '').trim();
|
||||
const nomFmt = nom ? nom.toUpperCase() : '';
|
||||
return [prenom, nomFmt].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
private minCreeLeIso(users: Users[]): string | null {
|
||||
let min: Date | null = null;
|
||||
for (const u of users) {
|
||||
const d = u.cree_le;
|
||||
if (!d) continue;
|
||||
const date = d instanceof Date ? d : new Date(d);
|
||||
if (Number.isNaN(date.getTime())) continue;
|
||||
if (!min || date < min) min = date;
|
||||
}
|
||||
return min ? min.toISOString() : null;
|
||||
}
|
||||
|
||||
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Ligne de liste GET /dossiers (#153). */
|
||||
export class DossierListItemDto {
|
||||
@ApiProperty({
|
||||
enum: ['famille', 'assistante_maternelle'],
|
||||
description: 'Type de dossier',
|
||||
})
|
||||
type: 'famille' | 'assistante_maternelle';
|
||||
|
||||
@ApiProperty({ example: '2026-000043' })
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Claire MARTIN & Thomas MARTIN',
|
||||
description: 'Libellé affiché (noms)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
example: ['claire@example.com', 'thomas@example.com'],
|
||||
})
|
||||
emails: string[];
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'IDs utilisateur liés au dossier (parents du foyer ou AM)',
|
||||
})
|
||||
user_ids: string[];
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
description:
|
||||
'Statut agrégé : en_attente si au moins un user en_attente, sinon statut du premier',
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'True si le dossier est en attente de validation (section haute UI)',
|
||||
})
|
||||
a_valider: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
example: '2026-01-12T10:00:00.000Z',
|
||||
description: 'Date de référence (MIN cree_le des users du dossier)',
|
||||
})
|
||||
date_reference: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'True si dossier famille sans enfant lié (#159). Omis ou false pour AM.',
|
||||
})
|
||||
sans_enfant?: boolean;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
@@ -6,13 +7,25 @@ import {
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
import { GenreType, StatutEnfantType } from 'src/entities/children.entity';
|
||||
|
||||
/** Multipart envoie des strings ("true"/"false") — JSON envoie déjà des booleans. */
|
||||
function toBoolean({ value }: { value: unknown }): boolean | unknown {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'true' || v === '1') return true;
|
||||
if (v === 'false' || v === '0' || v === '') return false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export class CreateEnfantsDto {
|
||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.ACTIF })
|
||||
@ApiProperty({ enum: StatutEnfantType, example: StatutEnfantType.SANS_GARDE })
|
||||
@IsEnum(StatutEnfantType)
|
||||
@IsNotEmpty()
|
||||
status: StatutEnfantType;
|
||||
@@ -52,6 +65,7 @@ export class CreateEnfantsDto {
|
||||
photo_url?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@Transform(toBoolean)
|
||||
@IsBoolean()
|
||||
consent_photo: boolean;
|
||||
|
||||
@@ -60,7 +74,16 @@ export class CreateEnfantsDto {
|
||||
@IsDateString()
|
||||
consent_photo_at?: string;
|
||||
|
||||
@ApiProperty({ default: false })
|
||||
@IsBoolean()
|
||||
is_multiple: boolean;
|
||||
/**
|
||||
* Parent pivot du foyer — obligatoire pour staff (gestionnaire/admin).
|
||||
* Ignoré / interdit en externe pour un PARENT (ticket #132).
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'UUID du parent pivot (staff only). Obligatoire pour GESTIONNAIRE / ADMIN / SUPER_ADMIN.',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
parent_user_id?: string;
|
||||
}
|
||||
|
||||
@@ -29,9 +29,6 @@ export class EnfantResponseDto {
|
||||
@ApiProperty({ example: false })
|
||||
consent_photo: boolean;
|
||||
|
||||
@ApiProperty({ example: false })
|
||||
is_multiple: boolean;
|
||||
|
||||
@ApiProperty({ example: 'UUID-parent' })
|
||||
parent_id: string;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import {
|
||||
Body,
|
||||
CallHandler,
|
||||
Controller,
|
||||
Delete,
|
||||
ExecutionContext,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiTags, ApiConsumes } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { Observable } from 'rxjs';
|
||||
import { EnfantsService } from './enfants.service';
|
||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||
import { UpdateEnfantsDto } from './dto/update_enfants.dto';
|
||||
@@ -23,38 +38,80 @@ import { User } from 'src/common/decorators/user.decorator';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { Roles } from 'src/common/decorators/roles.decorator';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
|
||||
const photoMulterOptions = {
|
||||
storage: diskStorage({
|
||||
destination: './uploads/photos',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = extname(file.originalname);
|
||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||
return cb(new Error('Seules les images sont autorisées'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Multer uniquement si Content-Type multipart (parent ou staff + photo).
|
||||
* JSON sans photo (#132) passe sans interceptor fichier.
|
||||
*/
|
||||
@Injectable()
|
||||
class OptionalEnfantPhotoInterceptor implements NestInterceptor {
|
||||
private readonly multipart = new (FileInterceptor(
|
||||
'photo',
|
||||
photoMulterOptions,
|
||||
))();
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> | Promise<Observable<unknown>> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const ct = String(req.headers['content-type'] ?? '');
|
||||
if (!ct.includes('multipart/form-data')) {
|
||||
return next.handle();
|
||||
}
|
||||
return this.multipart.intercept(context, next);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiBearerAuth('access-token')
|
||||
@ApiTags('Enfants')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('enfants')
|
||||
export class EnfantsController {
|
||||
constructor(private readonly enfantsService: EnfantsService) { }
|
||||
constructor(
|
||||
private readonly enfantsService: EnfantsService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) { }
|
||||
|
||||
@Roles(RoleType.PARENT)
|
||||
@Post()
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('photo', {
|
||||
storage: diskStorage({
|
||||
destination: './uploads/photos',
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = extname(file.originalname);
|
||||
cb(null, `enfant-${uniqueSuffix}${ext}`);
|
||||
},
|
||||
}),
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (!file.mimetype.match(/\/(jpg|jpeg|png|gif)$/)) {
|
||||
return cb(new Error('Seules les images sont autorisées'), false);
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024,
|
||||
},
|
||||
}),
|
||||
@Roles(
|
||||
RoleType.PARENT,
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
)
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un enfant',
|
||||
description:
|
||||
'PARENT : multipart éventuel, rattache au compte connecté. ' +
|
||||
'Staff : parent_user_id obligatoire ; JSON sans photo OK ; avec photo → multipart (champ fichier `photo`, max 5 Mo). Ticket #132.',
|
||||
})
|
||||
@ApiConsumes('application/json', 'multipart/form-data')
|
||||
@ApiBody({
|
||||
description:
|
||||
'Champs métier (+ parent_user_id côté staff). Fichier optionnel `photo` en multipart.',
|
||||
type: CreateEnfantsDto,
|
||||
})
|
||||
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||
create(
|
||||
@Body() dto: CreateEnfantsDto,
|
||||
@UploadedFile() photo: Express.Multer.File,
|
||||
@@ -83,19 +140,54 @@ export class EnfantsController {
|
||||
return this.enfantsService.findOne(id, currentUser);
|
||||
}
|
||||
|
||||
@Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT)
|
||||
@Roles(
|
||||
RoleType.PARENT,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
RoleType.GESTIONNAIRE,
|
||||
)
|
||||
@Patch(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Mettre à jour un enfant',
|
||||
description:
|
||||
'JSON sans photo OK ; avec nouvelle photo → multipart (champ fichier `photo`, max 5 Mo).',
|
||||
})
|
||||
@ApiConsumes('application/json', 'multipart/form-data')
|
||||
@UseInterceptors(OptionalEnfantPhotoInterceptor)
|
||||
update(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Body() dto: UpdateEnfantsDto,
|
||||
@UploadedFile() photo: Express.Multer.File,
|
||||
@User() currentUser: Users,
|
||||
) {
|
||||
return this.enfantsService.update(id, dto, currentUser);
|
||||
return this.enfantsService.update(id, dto, currentUser, photo);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@Roles(
|
||||
RoleType.SUPER_ADMIN,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.GESTIONNAIRE,
|
||||
)
|
||||
@Delete(':id')
|
||||
remove(@Param('id', new ParseUUIDPipe()) id: string) {
|
||||
return this.enfantsService.remove(id);
|
||||
@ApiOperation({
|
||||
summary: 'Supprimer un enfant (#159)',
|
||||
description:
|
||||
'Query `deleteDossier=true` si dernier enfant et suppression du dossier famille souhaitée.',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'deleteDossier',
|
||||
required: false,
|
||||
description: 'Si true et dernier enfant : cascade dossier famille',
|
||||
})
|
||||
remove(
|
||||
@Param('id', new ParseUUIDPipe()) id: string,
|
||||
@Query('deleteDossier') deleteDossier: string | undefined,
|
||||
@User() currentUser: Users,
|
||||
) {
|
||||
const flag =
|
||||
deleteDossier === 'true' ||
|
||||
deleteDossier === '1' ||
|
||||
deleteDossier === 'yes';
|
||||
return this.suppressionService.deleteEnfant(id, flag, currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,16 @@ import { Children } from 'src/entities/children.entity';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||
AuthModule
|
||||
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Children, Parents, ParentsChildren]),
|
||||
AuthModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [EnfantsController],
|
||||
providers: [EnfantsService]
|
||||
providers: [EnfantsService],
|
||||
exports: [EnfantsService],
|
||||
})
|
||||
export class EnfantsModule { }
|
||||
|
||||
@@ -13,6 +13,12 @@ import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { CreateEnfantsDto } from './dto/create_enfants.dto';
|
||||
|
||||
const STAFF_ROLES: RoleType[] = [
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class EnfantsService {
|
||||
constructor(
|
||||
@@ -24,20 +30,35 @@ export class EnfantsService {
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
) { }
|
||||
|
||||
// Création d'un enfant
|
||||
async create(dto: CreateEnfantsDto, currentUser: Users, photoFile?: Express.Multer.File): Promise<Children> {
|
||||
private isStaff(user: Users): boolean {
|
||||
return STAFF_ROLES.includes(user.role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Création d'un enfant.
|
||||
* - PARENT : rattache au parent connecté (multipart photo optionnel).
|
||||
* - Staff : `parent_user_id` obligatoire ; JSON sans photo OK ;
|
||||
* avec photo → multipart (même stockage `/uploads/photos/...`). Ticket #132.
|
||||
*/
|
||||
async create(
|
||||
dto: CreateEnfantsDto,
|
||||
currentUser: Users,
|
||||
photoFile?: Express.Multer.File,
|
||||
): Promise<Children> {
|
||||
const pivotUserId = this.resolvePivotParentUserId(dto, currentUser);
|
||||
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id: currentUser.id },
|
||||
where: { user_id: pivotUserId },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||
|
||||
// Vérif métier simple
|
||||
// Vérif métier simple (aligné comportement historique parent)
|
||||
if (dto.status !== StatutEnfantType.A_NAITRE && !dto.birth_date) {
|
||||
throw new BadRequestException('Un enfant actif doit avoir une date de naissance');
|
||||
throw new BadRequestException('Un enfant né doit avoir une date de naissance');
|
||||
}
|
||||
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance pour ce parent)
|
||||
// Vérif doublon éventuel (ex: même prénom + date de naissance)
|
||||
const exist = await this.childrenRepository.findOne({
|
||||
where: {
|
||||
first_name: dto.first_name,
|
||||
@@ -47,50 +68,107 @@ export class EnfantsService {
|
||||
});
|
||||
if (exist) throw new ConflictException('Cet enfant existe déjà');
|
||||
|
||||
// Gestion de la photo uploadée
|
||||
// Gestion de la photo uploadée (multipart parent ou staff)
|
||||
let photoUrl = dto.photo_url;
|
||||
let consentAt: Date | undefined;
|
||||
if (photoFile) {
|
||||
dto.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||
photoUrl = `/uploads/photos/${photoFile.filename}`;
|
||||
if (dto.consent_photo) {
|
||||
dto.consent_photo_at = new Date().toISOString();
|
||||
consentAt = new Date();
|
||||
}
|
||||
} else if (dto.consent_photo) {
|
||||
consentAt = dto.consent_photo_at
|
||||
? new Date(dto.consent_photo_at)
|
||||
: new Date();
|
||||
}
|
||||
|
||||
// Création
|
||||
const child = this.childrenRepository.create(dto);
|
||||
const child = this.childrenRepository.create({
|
||||
status: dto.status,
|
||||
first_name: dto.first_name,
|
||||
last_name: dto.last_name,
|
||||
gender: dto.gender,
|
||||
birth_date: dto.birth_date ? new Date(dto.birth_date) : undefined,
|
||||
due_date: dto.due_date ? new Date(dto.due_date) : undefined,
|
||||
photo_url: photoUrl,
|
||||
consent_photo: !!dto.consent_photo,
|
||||
consent_photo_at: consentAt,
|
||||
});
|
||||
await this.childrenRepository.save(child);
|
||||
|
||||
// Lien parent-enfant (Parent 1)
|
||||
const parentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(parentLink);
|
||||
// Lien parent-enfant (pivot)
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.user_id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
|
||||
// Rattachement automatique au co-parent s'il existe
|
||||
if (parent.co_parent) {
|
||||
const coParentLink = this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
});
|
||||
await this.parentsChildrenRepository.save(coParentLink);
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: parent.co_parent.id,
|
||||
enfantId: child.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.findOne(child.id, currentUser);
|
||||
}
|
||||
|
||||
// Liste des enfants
|
||||
async findAll(): Promise<Children[]> {
|
||||
return this.childrenRepository.find({
|
||||
relations: ['parentLinks'],
|
||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||
private resolvePivotParentUserId(
|
||||
dto: CreateEnfantsDto,
|
||||
currentUser: Users,
|
||||
): string {
|
||||
if (this.isStaff(currentUser)) {
|
||||
const id = dto.parent_user_id?.trim();
|
||||
if (!id) {
|
||||
throw new BadRequestException(
|
||||
'parent_user_id est obligatoire pour créer un enfant (staff)',
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
if (currentUser.role === RoleType.PARENT) {
|
||||
if (
|
||||
dto.parent_user_id &&
|
||||
dto.parent_user_id.trim() !== currentUser.id
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Un parent ne peut pas créer un enfant pour un autre compte',
|
||||
);
|
||||
}
|
||||
return currentUser.id;
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
/** Flag API #157 — true si aucun lien enfants_parents. */
|
||||
private withSansResponsable(child: Children): Children & { sans_responsable: boolean } {
|
||||
return Object.assign(child, {
|
||||
sans_responsable: !child.parentLinks || child.parentLinks.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Liste des enfants (admin/gestionnaire) — inclut les orphelins (parentLinks: [])
|
||||
async findAll(): Promise<Array<Children & { sans_responsable: boolean }>> {
|
||||
const children = await this.childrenRepository.find({
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
order: { last_name: 'ASC', first_name: 'ASC' },
|
||||
});
|
||||
return children.map((c) => this.withSansResponsable(c));
|
||||
}
|
||||
|
||||
// Récupérer un enfant par id
|
||||
async findOne(id: string, currentUser: Users): Promise<Children> {
|
||||
async findOne(
|
||||
id: string,
|
||||
currentUser: Users,
|
||||
): Promise<Children & { sans_responsable: boolean }> {
|
||||
const child = await this.childrenRepository.findOne({
|
||||
where: { id },
|
||||
relations: ['parentLinks'],
|
||||
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'],
|
||||
});
|
||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||
|
||||
@@ -104,23 +182,42 @@ export class EnfantsService {
|
||||
case RoleType.ADMINISTRATEUR:
|
||||
case RoleType.SUPER_ADMIN:
|
||||
case RoleType.GESTIONNAIRE:
|
||||
// accès complet
|
||||
// accès complet (y compris orphelins)
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ForbiddenException('Accès interdit');
|
||||
}
|
||||
|
||||
return child;
|
||||
return this.withSansResponsable(child);
|
||||
}
|
||||
|
||||
|
||||
// Mise à jour
|
||||
async update(id: string, dto: Partial<CreateEnfantsDto>, currentUser: Users): Promise<Children> {
|
||||
async update(
|
||||
id: string,
|
||||
dto: Partial<CreateEnfantsDto>,
|
||||
currentUser: Users,
|
||||
photoFile?: Express.Multer.File,
|
||||
): Promise<Children> {
|
||||
const child = await this.childrenRepository.findOne({ where: { id } });
|
||||
if (!child) throw new NotFoundException('Enfant introuvable');
|
||||
|
||||
await this.childrenRepository.update(id, dto);
|
||||
const { parent_user_id: _ignored, ...rest } = dto;
|
||||
const patch: Partial<Children> = { ...rest } as Partial<Children>;
|
||||
if (dto.consent_photo !== undefined) {
|
||||
patch.consent_photo = dto.consent_photo;
|
||||
patch.consent_photo_at = dto.consent_photo ? new Date() : null!;
|
||||
}
|
||||
if (photoFile) {
|
||||
patch.photo_url = `/uploads/photos/${photoFile.filename}`;
|
||||
if (dto.consent_photo !== false) {
|
||||
patch.consent_photo = true;
|
||||
patch.consent_photo_at = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
await this.childrenRepository.update(id, patch);
|
||||
return this.findOne(id, currentUser);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,12 @@ export class ParentPendingSummaryDto {
|
||||
@ApiProperty()
|
||||
email: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
nom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
prenom?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
telephone?: string | null;
|
||||
|
||||
@@ -18,7 +24,10 @@ export class ParentPendingSummaryDto {
|
||||
}
|
||||
|
||||
export class PendingFamilyDto {
|
||||
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
|
||||
@ApiProperty({
|
||||
example: 'MARTIN Claire - MARTIN Thomas',
|
||||
description: 'Libellé affiché : NOM Prénom (séparés par « - » si co-parent)',
|
||||
})
|
||||
libelle: string;
|
||||
|
||||
@ApiProperty({
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /parents/:id/co-parent (#135). */
|
||||
export class StaffAddCoParentResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ example: '2026-000043' })
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID du parent pivot' })
|
||||
parent_user_id: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID du co-parent créé' })
|
||||
co_parent_user_id: string;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* Ajout d’un co-parent sur un foyer existant (staff) — ticket #135.
|
||||
* Corps sans préfixe `co_parent_*` (l’URL cible déjà le pivot).
|
||||
*/
|
||||
export class StaffAddCoParentDto {
|
||||
@ApiProperty({ example: 'thomas.martin@ptits-pas.fr' })
|
||||
@IsEmail({}, { message: 'Email invalide' })
|
||||
@IsNotEmpty({ message: "L'email est requis" })
|
||||
email: string;
|
||||
|
||||
@ApiProperty({ example: 'Thomas' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le prénom est requis' })
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
prenom: string;
|
||||
|
||||
@ApiProperty({ example: 'MARTIN' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le nom est requis' })
|
||||
@MinLength(2)
|
||||
@MaxLength(100)
|
||||
nom: string;
|
||||
|
||||
@ApiProperty({ example: '0678456789' })
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'Le téléphone est requis' })
|
||||
@Matches(/^(\+33|0)[1-9](\d{2}){4}$/, {
|
||||
message: 'Le numéro de téléphone doit être valide (ex: 0689567890 ou +33689567890)',
|
||||
})
|
||||
telephone: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: true,
|
||||
description: 'Si true, copie l’adresse du parent pivot',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
meme_adresse?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Réponse 201 POST /parents/dossier (#129). */
|
||||
export class StaffCreateParentDossierResponseDto {
|
||||
@ApiProperty()
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: '2026-000043',
|
||||
description: 'Numéro de dossier famille attribué',
|
||||
})
|
||||
numero_dossier: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid', description: 'UUID user du parent pivot' })
|
||||
parent_user_id: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
nullable: true,
|
||||
description: 'UUID user du co-parent, ou null',
|
||||
})
|
||||
co_parent_user_id: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
enum: StatutUtilisateurType,
|
||||
example: StatutUtilisateurType.ACTIF,
|
||||
})
|
||||
statut: StatutUtilisateurType;
|
||||
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
format: 'uuid',
|
||||
description: 'IDs des enfants créés',
|
||||
})
|
||||
enfant_ids: string[];
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiPropertyOptional, OmitType } from '@nestjs/swagger';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
import { RegisterParentCompletDto } from 'src/routes/auth/dto/register-parent-complet.dto';
|
||||
|
||||
/**
|
||||
* Création dossier parent/famille par staff (#129).
|
||||
* Mêmes champs que l'inscription publique, sans CGU/privacy obligatoires
|
||||
* (acceptées côté serveur pour le compte du gestionnaire).
|
||||
*/
|
||||
export class StaffCreateParentDossierDto extends OmitType(RegisterParentCompletDto, [
|
||||
'acceptation_cgu',
|
||||
'acceptation_privacy',
|
||||
] as const) {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (CGU acceptées serveur). Conservé pour compat éventuelle.',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_cgu?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Ignoré côté staff (privacy acceptée serveur).',
|
||||
default: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
acceptation_privacy?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
/** Mise à jour fiche parent par admin/gestionnaire (doc 28 §6.1, ticket #131). */
|
||||
export class UpdateParentFicheAdminDto {
|
||||
@ApiPropertyOptional({ example: 'Dupont' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
nom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Marie' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
prenom?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'marie.dupont@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '+33612345678' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(20)
|
||||
telephone?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '10 rue de la Paix' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
adresse?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Paris' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(150)
|
||||
ville?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '75001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10)
|
||||
code_postal?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: StatutUtilisateurType })
|
||||
@IsOptional()
|
||||
@IsEnum(StatutUtilisateurType)
|
||||
statut?: StatutUtilisateurType;
|
||||
}
|
||||
@@ -1,18 +1,104 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
import { RolesGuard } from 'src/common/guards/roles.guard';
|
||||
import { StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('ParentsController', () => {
|
||||
let controller: ParentsController;
|
||||
const authServiceMock = {
|
||||
createParentDossierStaff: jest.fn(),
|
||||
addCoParentStaff: jest.fn(),
|
||||
};
|
||||
const parentsServiceMock = {};
|
||||
const userServiceMock = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ParentsController],
|
||||
}).compile();
|
||||
providers: [
|
||||
{ provide: ParentsService, useValue: parentsServiceMock },
|
||||
{ provide: UserService, useValue: userServiceMock },
|
||||
{ provide: AuthService, useValue: authServiceMock },
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(RolesGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<ParentsController>(ParentsController);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
it('createDossier delegates to authService.createParentDossierStaff with CGU accepted', async () => {
|
||||
authServiceMock.createParentDossierStaff.mockResolvedValue({
|
||||
message: 'Dossier famille créé et validé. Un e-mail de création de mot de passe a été envoyé.',
|
||||
parent_user_id: 'p1',
|
||||
co_parent_user_id: 'p2',
|
||||
enfant_ids: ['e1'],
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
numero_dossier: '2026-000043',
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'parent.staff@test.fr',
|
||||
prenom: 'Claire',
|
||||
nom: 'MARTIN',
|
||||
telephone: '0689567890',
|
||||
enfants: [
|
||||
{
|
||||
prenom: 'Emma',
|
||||
nom: 'MARTIN',
|
||||
date_naissance: '2023-02-15',
|
||||
genre: 'F',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await controller.createDossier(body as any);
|
||||
expect(authServiceMock.createParentDossierStaff).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: body.email,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
}),
|
||||
);
|
||||
expect(res.numero_dossier).toBe('2026-000043');
|
||||
expect(res.parent_user_id).toBe('p1');
|
||||
expect(res.co_parent_user_id).toBe('p2');
|
||||
expect(res.enfant_ids).toEqual(['e1']);
|
||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||
});
|
||||
|
||||
it('addCoParent delegates to authService.addCoParentStaff', async () => {
|
||||
authServiceMock.addCoParentStaff.mockResolvedValue({
|
||||
message: 'ok',
|
||||
numero_dossier: '2026-000043',
|
||||
parent_user_id: 'p1',
|
||||
co_parent_user_id: 'p2',
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
});
|
||||
|
||||
const body = {
|
||||
email: 'coparent@test.fr',
|
||||
prenom: 'Thomas',
|
||||
nom: 'MARTIN',
|
||||
telephone: '0678456789',
|
||||
meme_adresse: true,
|
||||
};
|
||||
|
||||
const res = await controller.addCoParent('p1', body as any);
|
||||
expect(authServiceMock.addCoParentStaff).toHaveBeenCalledWith('p1', body);
|
||||
expect(res.co_parent_user_id).toBe('p2');
|
||||
expect(res.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@@ -9,28 +12,79 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthService } from '../auth/auth.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, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
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 { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
import { StaffCreateParentDossierDto } from './dto/staff-create-parent-dossier.dto';
|
||||
import { StaffCreateParentDossierResponseDto } from './dto/staff-create-parent-dossier-response.dto';
|
||||
import { StaffAddCoParentDto } from './dto/staff-add-co-parent.dto';
|
||||
import { StaffAddCoParentResponseDto } from './dto/staff-add-co-parent-response.dto';
|
||||
import { RegisterParentCompletDto } from '../auth/dto/register-parent-complet.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';
|
||||
import { mapParentForApi, mapParentsForApi } from './parents.mapper';
|
||||
|
||||
@ApiTags('Parents')
|
||||
@ApiBearerAuth('access-token')
|
||||
@Controller('parents')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
export class ParentsController {
|
||||
constructor(
|
||||
private readonly parentsService: ParentsService,
|
||||
private readonly userService: UserService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post('dossier')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Créer un dossier famille/parent complet (staff) — ticket #129',
|
||||
description:
|
||||
'Crée parent (+ co-parent optionnel) + enfants + n° dossier avec statut actif, ' +
|
||||
'et envoie l’e-mail de création de mot de passe. ' +
|
||||
'Ne pas utiliser POST /auth/register/parent depuis le dashboard.',
|
||||
})
|
||||
@ApiBody({ type: StaffCreateParentDossierDto })
|
||||
@ApiResponse({ status: 201, type: StaffCreateParentDossierResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Validation DTO / métier' })
|
||||
@ApiResponse({ status: 403, description: 'Rôle non autorisé' })
|
||||
@ApiResponse({ status: 409, description: 'Email pivot et/ou co-parent déjà pris' })
|
||||
async createDossier(
|
||||
@Body() dto: StaffCreateParentDossierDto,
|
||||
): Promise<StaffCreateParentDossierResponseDto> {
|
||||
const registerDto = {
|
||||
...dto,
|
||||
acceptation_cgu: true,
|
||||
acceptation_privacy: true,
|
||||
} as RegisterParentCompletDto;
|
||||
const result = await this.authService.createParentDossierStaff(registerDto);
|
||||
return {
|
||||
message: result.message,
|
||||
numero_dossier: result.numero_dossier,
|
||||
parent_user_id: result.parent_user_id,
|
||||
co_parent_user_id: result.co_parent_user_id ?? null,
|
||||
statut: result.statut,
|
||||
enfant_ids: result.enfant_ids,
|
||||
};
|
||||
}
|
||||
|
||||
@Get('pending-families')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
|
||||
@@ -79,21 +133,25 @@ export class ParentsController {
|
||||
return validated;
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Liste des parents (user, co_parent, parentChildren) — ticket #131' })
|
||||
@ApiResponse({ status: 200, type: [Parents], description: 'Liste des parents' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
getAll(): Promise<Parents[]> {
|
||||
return this.parentsService.findAll();
|
||||
async getAll(): Promise<Parents[]> {
|
||||
const parents = await this.parentsService.findAll();
|
||||
return mapParentsForApi(parents);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Détail parent par user_id (inclut co_parent si id_co_parent renseigné) — ticket #131' })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
|
||||
@ApiResponse({ status: 404, description: 'Parent non trouvé' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
getOne(@Param('id') user_id: string): Promise<Parents> {
|
||||
return this.parentsService.findOne(user_id);
|
||||
async getOne(@Param('id') user_id: string): Promise<Parents> {
|
||||
const parent = await this.parentsService.findOne(user_id);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@@ -101,8 +159,73 @@ export class ParentsController {
|
||||
@ApiBody({ type: CreateParentDto })
|
||||
@ApiResponse({ status: 201, type: Parents, description: 'Parent créé avec succès' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
create(@Body() dto: CreateParentDto): Promise<Parents> {
|
||||
return this.parentsService.create(dto);
|
||||
async create(@Body() dto: CreateParentDto): Promise<Parents> {
|
||||
const parent = await this.parentsService.create(dto);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Patch(':id/fiche')
|
||||
@ApiOperation({ summary: 'Mettre à jour la fiche parent (admin/gestionnaire) — ticket #131' })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||
@ApiBody({ type: UpdateParentFicheAdminDto })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Fiche parent mise à jour' })
|
||||
async updateFicheAdmin(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateParentFicheAdminDto,
|
||||
): Promise<Parents> {
|
||||
const parent = await this.parentsService.updateFicheAdmin(id, dto);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@Post(':id/co-parent')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({
|
||||
summary: 'Ajouter un co-parent à un foyer existant (staff) — ticket #135',
|
||||
description:
|
||||
'Foyer mono-parent uniquement. Crée le co-parent actif, liens foyer + enfants, ' +
|
||||
'e-mail de création de mot de passe. Ne pas utiliser POST /auth/register/parent.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: 'UUID utilisateur du parent pivot' })
|
||||
@ApiBody({ type: StaffAddCoParentDto })
|
||||
@ApiResponse({ status: 201, type: StaffAddCoParentResponseDto })
|
||||
@ApiResponse({ status: 400, description: 'Foyer déjà à 2 parents / validation' })
|
||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||
@ApiResponse({ status: 409, description: 'Email déjà pris' })
|
||||
async addCoParent(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: StaffAddCoParentDto,
|
||||
): Promise<StaffAddCoParentResponseDto> {
|
||||
return this.authService.addCoParentStaff(id, dto);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Post(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: 'Rattacher un enfant à un parent — ticket #115' })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||
async attachEnfant(
|
||||
@Param('id') id: string,
|
||||
@Param('enfantId') enfantId: string,
|
||||
): Promise<Parents> {
|
||||
const parent = await this.parentsService.attachEnfant(id, enfantId);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
|
||||
@Delete(':id/enfants/:enfantId')
|
||||
@ApiOperation({ summary: "Détacher un enfant d'un parent — ticket #115" })
|
||||
@ApiParam({ name: 'id', description: "UUID utilisateur du parent" })
|
||||
@ApiParam({ name: 'enfantId', description: "UUID de l'enfant" })
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent avec enfants mis à jour' })
|
||||
async detachEnfant(
|
||||
@Param('id') id: string,
|
||||
@Param('enfantId') enfantId: string,
|
||||
): Promise<Parents> {
|
||||
const parent = await this.parentsService.detachEnfant(id, enfantId);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
|
||||
@@ -111,7 +234,8 @@ export class ParentsController {
|
||||
@ApiResponse({ status: 200, type: Parents, description: 'Parent mis à jour avec succès' })
|
||||
@ApiResponse({ status: 404, description: 'Parent introuvable' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé !' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
||||
return this.parentsService.update(id, dto);
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateParentsDto): Promise<Parents> {
|
||||
const parent = await this.parentsService.update(id, dto);
|
||||
return mapParentForApi(parent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { mapParentForApi } from './parents.mapper';
|
||||
import { Parents } from '../../entities/parents.entity';
|
||||
import { RoleType, StatutUtilisateurType, Users } from '../../entities/users.entity';
|
||||
|
||||
describe('mapParentForApi', () => {
|
||||
it('expose co_parent avec prenom/nom sans secrets', () => {
|
||||
const coParent = {
|
||||
id: 'cp1',
|
||||
email: 'co@b.fr',
|
||||
prenom: 'Clara',
|
||||
nom: 'Co',
|
||||
role: RoleType.PARENT,
|
||||
statut: StatutUtilisateurType.ACTIF,
|
||||
password: 'secret',
|
||||
} as Users;
|
||||
|
||||
const parent = {
|
||||
user_id: 'u1',
|
||||
numero_dossier: '2026-000042',
|
||||
user: {
|
||||
id: 'u1',
|
||||
email: 'p@b.fr',
|
||||
prenom: 'Paul',
|
||||
nom: 'Parent',
|
||||
role: RoleType.PARENT,
|
||||
password: 'secret',
|
||||
} as Users,
|
||||
co_parent: coParent,
|
||||
parentChildren: [],
|
||||
} as Parents;
|
||||
|
||||
const out = mapParentForApi(parent);
|
||||
expect(out.co_parent?.prenom).toBe('Clara');
|
||||
expect(out.co_parent?.nom).toBe('Co');
|
||||
expect(out.co_parent?.password).toBeUndefined();
|
||||
expect(out.user.password).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { sanitizeUserForApi } from '../../common/utils/sanitize-user-for-api';
|
||||
|
||||
/**
|
||||
* Sérialisation API fiche parent — ticket #131.
|
||||
* Garantit `user`, `co_parent` (si présent) et relations sans champs sensibles.
|
||||
*/
|
||||
export function mapParentForApi(parent: Parents): Parents {
|
||||
return {
|
||||
...parent,
|
||||
user: sanitizeUserForApi(parent.user)!,
|
||||
co_parent: sanitizeUserForApi(parent.co_parent),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapParentsForApi(parents: Parents[]): Parents[] {
|
||||
return parents.map(mapParentForApi);
|
||||
}
|
||||
@@ -4,15 +4,18 @@ 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 { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { ParentsController } from './parents.controller';
|
||||
import { ParentsService } from './parents.service';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant]),
|
||||
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
|
||||
forwardRef(() => UserModule),
|
||||
forwardRef(() => AuthModule),
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
||||
@@ -17,7 +17,9 @@ import {
|
||||
DossierFamilleParentDto,
|
||||
DossierFamilleEnfantDto,
|
||||
} from './dto/dossier-famille-complet.dto';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ParentsService {
|
||||
@@ -28,6 +30,8 @@ export class ParentsService {
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(DossierFamille)
|
||||
private readonly dossierFamilleRepository: Repository<DossierFamille>,
|
||||
@InjectRepository(ParentsChildren)
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
) {}
|
||||
|
||||
// Création d’un parent
|
||||
@@ -62,7 +66,7 @@ export class ParentsService {
|
||||
// Liste des parents
|
||||
async findAll(): Promise<Parents[]> {
|
||||
return this.parentsRepository.find({
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +74,7 @@ export class ParentsService {
|
||||
async findOne(user_id: string): Promise<Parents> {
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id },
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
|
||||
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'],
|
||||
});
|
||||
if (!parent) throw new NotFoundException('Parent introuvable');
|
||||
return parent;
|
||||
@@ -82,6 +86,131 @@ export class ParentsService {
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mise à jour fiche parent (champs user + statut) par admin/gestionnaire. Ticket #131 / doc 28 §6.1.
|
||||
*/
|
||||
async updateFicheAdmin(parentUserId: string, dto: UpdateParentFicheAdminDto): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
const user = parent.user;
|
||||
|
||||
if (dto.email && dto.email !== user.email) {
|
||||
const existing = await this.usersRepository.findOne({ where: { email: dto.email } });
|
||||
if (existing && existing.id !== user.id) {
|
||||
throw new ConflictException('Cet email est déjà utilisé');
|
||||
}
|
||||
user.email = dto.email;
|
||||
}
|
||||
|
||||
if (dto.nom !== undefined) user.nom = dto.nom;
|
||||
if (dto.prenom !== undefined) user.prenom = dto.prenom;
|
||||
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.statut !== undefined) user.statut = dto.statut;
|
||||
|
||||
await this.usersRepository.save(user);
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Membres du foyer (user ids) pour affiliation enfant.
|
||||
* Pivot + co-parent (A→B et B→A) + même numero_dossier. Ticket #158.
|
||||
*/
|
||||
private async resolveFoyerParentUserIds(parent: Parents): Promise<string[]> {
|
||||
const ids = new Set<string>([parent.user_id]);
|
||||
|
||||
if (parent.co_parent?.id) {
|
||||
ids.add(parent.co_parent.id);
|
||||
}
|
||||
|
||||
// Sens inverse : parents qui déclarent ce user comme co-parent
|
||||
const reverseLinks = await this.parentsRepository.find({
|
||||
where: { co_parent: { id: parent.user_id } },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of reverseLinks) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
|
||||
const dossier = parent.numero_dossier?.trim();
|
||||
if (dossier) {
|
||||
const sameDossier = await this.parentsRepository.find({
|
||||
where: { numero_dossier: dossier },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
for (const p of sameDossier) {
|
||||
ids.add(p.user_id);
|
||||
if (p.co_parent?.id) ids.add(p.co_parent.id);
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
/**
|
||||
* Rattacher un enfant au foyer du parent (tous les responsables). Ticket #158.
|
||||
* Un seul POST suffit : liens créés pour pivot + co-parent / même dossier.
|
||||
*/
|
||||
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const child = await this.parentsRepository.manager.findOne(Children, {
|
||||
where: { id: enfantId },
|
||||
});
|
||||
if (!child) {
|
||||
throw new NotFoundException('Enfant introuvable');
|
||||
}
|
||||
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
let created = 0;
|
||||
|
||||
for (const memberId of foyerIds) {
|
||||
const existing = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: memberId, enfantId },
|
||||
});
|
||||
if (existing) continue;
|
||||
|
||||
await this.parentsChildrenRepository.save(
|
||||
this.parentsChildrenRepository.create({
|
||||
parentId: memberId,
|
||||
enfantId,
|
||||
}),
|
||||
);
|
||||
created += 1;
|
||||
}
|
||||
|
||||
if (created === 0) {
|
||||
throw new ConflictException('Cet enfant est déjà rattaché à ce foyer');
|
||||
}
|
||||
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Détacher un enfant du foyer du parent (tous les responsables). Ticket #158.
|
||||
* Si plus aucun lien ensuite → enfant orphelin (#157).
|
||||
*/
|
||||
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
|
||||
const parent = await this.findOne(parentUserId);
|
||||
|
||||
const link = await this.parentsChildrenRepository.findOne({
|
||||
where: { parentId: parentUserId, enfantId },
|
||||
});
|
||||
if (!link) {
|
||||
throw new NotFoundException('Lien parent-enfant introuvable');
|
||||
}
|
||||
|
||||
const foyerIds = await this.resolveFoyerParentUserIds(parent);
|
||||
await this.parentsChildrenRepository.delete({
|
||||
parentId: In(foyerIds),
|
||||
enfantId,
|
||||
});
|
||||
|
||||
return this.findOne(parentUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste des familles en attente (une entrée par famille).
|
||||
* Famille = lien co_parent ou partage d'enfants (même logique que backfill #103).
|
||||
@@ -122,7 +251,15 @@ export class ParentsService {
|
||||
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,
|
||||
string_agg(
|
||||
UPPER(TRIM(u.nom))
|
||||
|| CASE
|
||||
WHEN u.prenom IS NOT NULL AND TRIM(u.prenom) <> ''
|
||||
THEN ' ' || INITCAP(TRIM(u.prenom))
|
||||
ELSE ''
|
||||
END,
|
||||
' - ' ORDER BY u.nom, u.prenom, u.id
|
||||
) 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,
|
||||
@@ -138,6 +275,8 @@ export class ParentsService {
|
||||
json_build_object(
|
||||
'id', u.id::text,
|
||||
'email', u.email,
|
||||
'nom', u.nom,
|
||||
'prenom', u.prenom,
|
||||
'telephone', u.telephone,
|
||||
'code_postal', u.code_postal,
|
||||
'ville', u.ville
|
||||
@@ -188,11 +327,21 @@ export class ParentsService {
|
||||
return [];
|
||||
}
|
||||
|
||||
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
|
||||
private normalizeParents(parents: unknown): {
|
||||
id: string;
|
||||
email: string;
|
||||
nom: string | null;
|
||||
prenom: string | null;
|
||||
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 ?? ''),
|
||||
nom: p?.nom != null ? String(p.nom) : null,
|
||||
prenom: p?.prenom != null ? String(p.prenom) : null,
|
||||
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,
|
||||
|
||||
@@ -24,15 +24,19 @@ export class RelaisController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Lister tous les relais' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({
|
||||
summary: 'Lister tous les relais',
|
||||
description:
|
||||
'Lecture ouverte aux gestionnaires (combobox fiches). CRUD write reste admin-only. Ticket #151.',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Liste des relais.' })
|
||||
findAll() {
|
||||
return this.relaisService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
|
||||
@ApiOperation({ summary: 'Récupérer un relais par ID' })
|
||||
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
|
||||
findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { SuppressionService } from './suppression.service';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
describe('SuppressionService (#159)', () => {
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (cb: (m: unknown) => Promise<unknown>) =>
|
||||
cb({
|
||||
delete: jest.fn(),
|
||||
query: jest.fn(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
const usersRepository = {
|
||||
findOne: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
const parentsRepository = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
query: jest.fn(),
|
||||
};
|
||||
const amRepository = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
const childrenRepository = {
|
||||
findOne: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const parentsChildrenRepository = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
const amChildrenRepository = {
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
let service: SuppressionService;
|
||||
|
||||
const staff = {
|
||||
id: 'staff-1',
|
||||
role: RoleType.GESTIONNAIRE,
|
||||
} as never;
|
||||
|
||||
const admin = {
|
||||
id: 'admin-1',
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
} as never;
|
||||
|
||||
const superAdmin = {
|
||||
id: 'sa-1',
|
||||
role: RoleType.SUPER_ADMIN,
|
||||
} as never;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new SuppressionService(
|
||||
dataSource as never,
|
||||
usersRepository as never,
|
||||
parentsRepository as never,
|
||||
amRepository as never,
|
||||
childrenRepository as never,
|
||||
parentsChildrenRepository as never,
|
||||
amChildrenRepository as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuse self-delete', async () => {
|
||||
usersRepository.findOne.mockResolvedValue({
|
||||
id: 'admin-1',
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
});
|
||||
await expect(service.deleteUser('admin-1', admin)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuse gestionnaire deleting another gestionnaire', async () => {
|
||||
usersRepository.findOne.mockResolvedValue({
|
||||
id: 'g2',
|
||||
role: RoleType.GESTIONNAIRE,
|
||||
});
|
||||
await expect(service.deleteUser('g2', staff)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('dernier admin : refus si pas super_admin', async () => {
|
||||
usersRepository.findOne.mockResolvedValue({
|
||||
id: 'admin-2',
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
});
|
||||
usersRepository.count.mockResolvedValue(1);
|
||||
await expect(service.deleteUser('admin-2', admin)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('dernier admin : OK pour super_admin', async () => {
|
||||
usersRepository.findOne.mockResolvedValue({
|
||||
id: 'admin-2',
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
});
|
||||
usersRepository.count.mockResolvedValue(1);
|
||||
usersRepository.delete.mockResolvedValue({ affected: 1 });
|
||||
const res = await service.deleteUser('admin-2', superAdmin);
|
||||
expect(res.deleted_user_ids).toEqual(['admin-2']);
|
||||
});
|
||||
|
||||
it('delete AM : clos placements, pas d’enfants deleted', async () => {
|
||||
usersRepository.findOne.mockResolvedValue({
|
||||
id: 'am-1',
|
||||
role: RoleType.ASSISTANTE_MATERNELLE,
|
||||
});
|
||||
amRepository.findOne.mockResolvedValue({
|
||||
user_id: 'am-1',
|
||||
numero_dossier: '2026-000015',
|
||||
});
|
||||
amChildrenRepository.find.mockResolvedValue([
|
||||
{
|
||||
amId: 'am-1',
|
||||
enfantId: 'e1',
|
||||
child: { id: 'e1', status: 'garde' },
|
||||
},
|
||||
]);
|
||||
amChildrenRepository.count.mockResolvedValue(0);
|
||||
amChildrenRepository.save.mockImplementation(async (x) => x);
|
||||
childrenRepository.save.mockResolvedValue({});
|
||||
usersRepository.delete.mockResolvedValue({ affected: 1 });
|
||||
|
||||
const res = await service.deleteUser('am-1', staff);
|
||||
expect(res.deleted_enfant_ids).toEqual([]);
|
||||
expect(res.deleted_user_ids).toEqual(['am-1']);
|
||||
expect(res.type).toBe('assistante_maternelle');
|
||||
});
|
||||
|
||||
it('delete dossier famille introuvable', async () => {
|
||||
parentsRepository.findOne.mockResolvedValue(null);
|
||||
amRepository.findOne.mockResolvedValue(null);
|
||||
await expect(
|
||||
service.deleteDossier('2026-999999', staff),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('co-parent : delete user seul', async () => {
|
||||
usersRepository.findOne.mockResolvedValue({
|
||||
id: 'p2',
|
||||
role: RoleType.PARENT,
|
||||
});
|
||||
parentsRepository.findOne.mockResolvedValue({
|
||||
user_id: 'p2',
|
||||
numero_dossier: '2026-000010',
|
||||
co_parent: { id: 'p1' },
|
||||
});
|
||||
parentsRepository.query.mockResolvedValue([
|
||||
{ id: 'p1' },
|
||||
{ id: 'p2' },
|
||||
]);
|
||||
dataSource.transaction.mockImplementation(async (cb) =>
|
||||
cb({
|
||||
delete: jest.fn(),
|
||||
query: jest.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await service.deleteUser('p2', staff);
|
||||
expect(res.deleted_enfant_ids).toEqual([]);
|
||||
expect(res.deleted_user_ids).toEqual(['p2']);
|
||||
expect(res.message).toMatch(/co-parent/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, Repository } from 'typeorm';
|
||||
import { RoleType, Users } from 'src/entities/users.entity';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { Children, StatutEnfantType } from 'src/entities/children.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
|
||||
export type SuppressionResult = {
|
||||
type?: 'famille' | 'assistante_maternelle';
|
||||
numero_dossier?: string;
|
||||
deleted_user_ids: string[];
|
||||
deleted_enfant_ids: string[];
|
||||
dossier_supprime?: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const STAFF_METIER: RoleType[] = [
|
||||
RoleType.GESTIONNAIRE,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.SUPER_ADMIN,
|
||||
];
|
||||
|
||||
/**
|
||||
* Cascades de suppression métier — tickets #154 / #159.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SuppressionService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@InjectRepository(Users)
|
||||
private readonly usersRepository: Repository<Users>,
|
||||
@InjectRepository(Parents)
|
||||
private readonly parentsRepository: Repository<Parents>,
|
||||
@InjectRepository(AssistanteMaternelle)
|
||||
private readonly amRepository: Repository<AssistanteMaternelle>,
|
||||
@InjectRepository(Children)
|
||||
private readonly childrenRepository: Repository<Children>,
|
||||
@InjectRepository(ParentsChildren)
|
||||
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
|
||||
@InjectRepository(AmChildren)
|
||||
private readonly amChildrenRepository: Repository<AmChildren>,
|
||||
) {}
|
||||
|
||||
assertStaffMetier(currentUser: Users): void {
|
||||
if (!STAFF_METIER.includes(currentUser.role)) {
|
||||
throw new ForbiddenException('Accès refusé');
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDossier(
|
||||
numeroDossier: string,
|
||||
currentUser: Users,
|
||||
): Promise<SuppressionResult> {
|
||||
this.assertStaffMetier(currentUser);
|
||||
const num = numeroDossier?.trim();
|
||||
if (!num) {
|
||||
throw new BadRequestException('Numéro de dossier requis.');
|
||||
}
|
||||
|
||||
const parentHit = await this.parentsRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
});
|
||||
if (parentHit) {
|
||||
return this.deleteFamilleByNumero(num);
|
||||
}
|
||||
|
||||
const amHit = await this.amRepository.findOne({
|
||||
where: { numero_dossier: num },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (amHit?.user) {
|
||||
return this.deleteAmUser(amHit.user.id);
|
||||
}
|
||||
|
||||
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
|
||||
}
|
||||
|
||||
async deleteUser(
|
||||
id: string,
|
||||
currentUser: Users,
|
||||
): Promise<SuppressionResult> {
|
||||
const target = await this.usersRepository.findOne({ where: { id } });
|
||||
if (!target) {
|
||||
throw new NotFoundException('Utilisateur introuvable');
|
||||
}
|
||||
|
||||
if (target.id === currentUser.id) {
|
||||
throw new ForbiddenException('Vous ne pouvez pas supprimer votre propre compte.');
|
||||
}
|
||||
if (target.role === RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Le super administrateur ne peut pas être supprimé.');
|
||||
}
|
||||
|
||||
if (target.role === RoleType.PARENT) {
|
||||
this.assertStaffMetier(currentUser);
|
||||
return this.deleteParentUser(target.id);
|
||||
}
|
||||
if (target.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||
this.assertStaffMetier(currentUser);
|
||||
return this.deleteAmUser(target.id);
|
||||
}
|
||||
if (target.role === RoleType.GESTIONNAIRE) {
|
||||
if (
|
||||
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||||
currentUser.role !== RoleType.SUPER_ADMIN
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Seul un administrateur peut supprimer un gestionnaire.',
|
||||
);
|
||||
}
|
||||
await this.usersRepository.delete(target.id);
|
||||
return {
|
||||
deleted_user_ids: [target.id],
|
||||
deleted_enfant_ids: [],
|
||||
message: 'Gestionnaire supprimé.',
|
||||
};
|
||||
}
|
||||
if (target.role === RoleType.ADMINISTRATEUR) {
|
||||
await this.assertCanDeleteAdministrateur(target, currentUser);
|
||||
await this.usersRepository.delete(target.id);
|
||||
return {
|
||||
deleted_user_ids: [target.id],
|
||||
deleted_enfant_ids: [],
|
||||
message: 'Administrateur supprimé.',
|
||||
};
|
||||
}
|
||||
|
||||
throw new BadRequestException('Type d’utilisateur non supprimable via cet endpoint.');
|
||||
}
|
||||
|
||||
async deleteEnfant(
|
||||
enfantId: string,
|
||||
deleteDossier: boolean,
|
||||
currentUser: Users,
|
||||
): Promise<SuppressionResult> {
|
||||
this.assertStaffMetier(currentUser);
|
||||
|
||||
const child = await this.childrenRepository.findOne({
|
||||
where: { id: enfantId },
|
||||
relations: ['parentLinks', 'parentLinks.parent'],
|
||||
});
|
||||
if (!child) {
|
||||
throw new NotFoundException('Enfant introuvable');
|
||||
}
|
||||
|
||||
const parentIds = (child.parentLinks ?? [])
|
||||
.map((l) => l.parentId ?? l.parent?.user_id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
let numero: string | undefined;
|
||||
if (parentIds.length > 0) {
|
||||
const parents = await this.parentsRepository.find({
|
||||
where: { user_id: In(parentIds) },
|
||||
});
|
||||
numero = parents.map((p) => p.numero_dossier?.trim()).find((n) => !!n);
|
||||
}
|
||||
|
||||
if (!numero) {
|
||||
await this.closePlacementsForEnfants([enfantId]);
|
||||
await this.childrenRepository.delete(enfantId);
|
||||
return {
|
||||
deleted_user_ids: [],
|
||||
deleted_enfant_ids: [enfantId],
|
||||
dossier_supprime: false,
|
||||
message: 'Enfant supprimé.',
|
||||
};
|
||||
}
|
||||
|
||||
const siblingIds = await this.listEnfantIdsForNumero(numero);
|
||||
const isLast = siblingIds.length <= 1;
|
||||
|
||||
if (isLast && deleteDossier) {
|
||||
const result = await this.deleteFamilleByNumero(numero);
|
||||
return {
|
||||
...result,
|
||||
dossier_supprime: true,
|
||||
message: 'Dernier enfant et dossier famille supprimés.',
|
||||
};
|
||||
}
|
||||
|
||||
await this.closePlacementsForEnfants([enfantId]);
|
||||
await this.childrenRepository.delete(enfantId);
|
||||
return {
|
||||
deleted_user_ids: [],
|
||||
deleted_enfant_ids: [enfantId],
|
||||
dossier_supprime: false,
|
||||
numero_dossier: numero,
|
||||
type: 'famille',
|
||||
message: isLast
|
||||
? 'Dernier enfant supprimé. Le dossier famille reste sans enfant.'
|
||||
: 'Enfant supprimé du dossier famille.',
|
||||
};
|
||||
}
|
||||
|
||||
/** Compte enfants liés à un numero_dossier famille (pour flag sans_enfant). */
|
||||
async countEnfantsForNumero(numeroDossier: string): Promise<number> {
|
||||
const ids = await this.listEnfantIdsForNumero(numeroDossier);
|
||||
return ids.length;
|
||||
}
|
||||
|
||||
private async assertCanDeleteAdministrateur(
|
||||
target: Users,
|
||||
currentUser: Users,
|
||||
): Promise<void> {
|
||||
if (
|
||||
currentUser.role !== RoleType.ADMINISTRATEUR &&
|
||||
currentUser.role !== RoleType.SUPER_ADMIN
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Seul un administrateur peut supprimer un administrateur.',
|
||||
);
|
||||
}
|
||||
const adminCount = await this.usersRepository.count({
|
||||
where: { role: RoleType.ADMINISTRATEUR },
|
||||
});
|
||||
if (adminCount <= 1) {
|
||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException(
|
||||
'Seul le super administrateur peut supprimer le dernier administrateur.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteParentUser(userId: string): Promise<SuppressionResult> {
|
||||
const parent = await this.parentsRepository.findOne({
|
||||
where: { user_id: userId },
|
||||
relations: ['co_parent'],
|
||||
});
|
||||
if (!parent) {
|
||||
// Compte parent sans fiche — hard delete user
|
||||
await this.usersRepository.delete(userId);
|
||||
return {
|
||||
deleted_user_ids: [userId],
|
||||
deleted_enfant_ids: [],
|
||||
message: 'Parent supprimé.',
|
||||
};
|
||||
}
|
||||
|
||||
const numero = parent.numero_dossier?.trim();
|
||||
const foyerIds = numero
|
||||
? await this.listParentUserIdsForNumero(numero)
|
||||
: [userId];
|
||||
|
||||
const isLast = foyerIds.filter((id) => id !== userId).length === 0;
|
||||
|
||||
if (!isLast) {
|
||||
// Co-parent : retirer liens enfants de ce parent, clear co_parent refs, delete user
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(ParentsChildren, { parentId: userId });
|
||||
await manager.query(
|
||||
`UPDATE parents SET id_co_parent = NULL WHERE id_co_parent = $1 OR id_utilisateur = $1`,
|
||||
[userId],
|
||||
);
|
||||
await manager.delete(Users, { id: userId });
|
||||
});
|
||||
return {
|
||||
deleted_user_ids: [userId],
|
||||
deleted_enfant_ids: [],
|
||||
numero_dossier: numero,
|
||||
type: 'famille',
|
||||
message: 'Parent retiré du dossier (co-parent).',
|
||||
};
|
||||
}
|
||||
|
||||
// Dernier parent : + enfants
|
||||
const enfantIds = numero
|
||||
? await this.listEnfantIdsForNumero(numero)
|
||||
: await this.listEnfantIdsForParent(userId);
|
||||
await this.closePlacementsForEnfants(enfantIds);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (enfantIds.length) {
|
||||
await manager.delete(Children, { id: In(enfantIds) });
|
||||
}
|
||||
await manager.query(
|
||||
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = $1 OR id_co_parent = $1`,
|
||||
[userId],
|
||||
);
|
||||
await manager.delete(Users, { id: userId });
|
||||
});
|
||||
return {
|
||||
deleted_user_ids: [userId],
|
||||
deleted_enfant_ids: enfantIds,
|
||||
numero_dossier: numero,
|
||||
type: 'famille',
|
||||
message: 'Dernier parent et enfants rattachés supprimés.',
|
||||
};
|
||||
}
|
||||
|
||||
private async deleteFamilleByNumero(numero: string): Promise<SuppressionResult> {
|
||||
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||
if (parentIds.length === 0) {
|
||||
throw new NotFoundException('Aucun parent pour ce dossier.');
|
||||
}
|
||||
const enfantIds = await this.listEnfantIdsForNumero(numero);
|
||||
await this.closePlacementsForEnfants(enfantIds);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
if (enfantIds.length) {
|
||||
await manager.delete(Children, { id: In(enfantIds) });
|
||||
}
|
||||
await manager.query(
|
||||
`UPDATE parents SET id_co_parent = NULL WHERE id_utilisateur = ANY($1::uuid[]) OR id_co_parent = ANY($1::uuid[])`,
|
||||
[parentIds],
|
||||
);
|
||||
await manager.delete(Users, { id: In(parentIds) });
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'famille',
|
||||
numero_dossier: numero,
|
||||
deleted_user_ids: parentIds,
|
||||
deleted_enfant_ids: enfantIds,
|
||||
message: 'Dossier famille supprimé.',
|
||||
};
|
||||
}
|
||||
|
||||
private async deleteAmUser(userId: string): Promise<SuppressionResult> {
|
||||
const am = await this.amRepository.findOne({ where: { user_id: userId } });
|
||||
const numero = am?.numero_dossier?.trim();
|
||||
|
||||
const active = await this.amChildrenRepository.find({
|
||||
where: { amId: userId, date_fin: IsNull() },
|
||||
relations: ['child'],
|
||||
});
|
||||
const now = new Date();
|
||||
for (const link of active) {
|
||||
link.date_fin = now;
|
||||
await this.amChildrenRepository.save(link);
|
||||
if (link.child) {
|
||||
await this.applySansGarde(link.child);
|
||||
}
|
||||
}
|
||||
|
||||
await this.usersRepository.delete(userId);
|
||||
return {
|
||||
type: 'assistante_maternelle',
|
||||
numero_dossier: numero,
|
||||
deleted_user_ids: [userId],
|
||||
deleted_enfant_ids: [],
|
||||
message: 'Dossier assistante maternelle supprimé.',
|
||||
};
|
||||
}
|
||||
|
||||
private async applySansGarde(child: Children): Promise<void> {
|
||||
if (
|
||||
child.status === StatutEnfantType.A_NAITRE ||
|
||||
child.status === StatutEnfantType.SCOLARISE
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const remaining = await this.amChildrenRepository.count({
|
||||
where: { enfantId: child.id, date_fin: IsNull() },
|
||||
});
|
||||
if (remaining === 0) {
|
||||
child.status = StatutEnfantType.SANS_GARDE;
|
||||
await this.childrenRepository.save(child);
|
||||
}
|
||||
}
|
||||
|
||||
private async closePlacementsForEnfants(enfantIds: string[]): Promise<void> {
|
||||
if (!enfantIds.length) return;
|
||||
const links = await this.amChildrenRepository.find({
|
||||
where: { enfantId: In(enfantIds), date_fin: IsNull() },
|
||||
relations: ['child'],
|
||||
});
|
||||
const now = new Date();
|
||||
for (const link of links) {
|
||||
link.date_fin = now;
|
||||
await this.amChildrenRepository.save(link);
|
||||
if (link.child) {
|
||||
await this.applySansGarde(link.child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listParentUserIdsForNumero(numero: string): Promise<string[]> {
|
||||
const rows: Array<{ id: string }> = await this.parentsRepository.query(
|
||||
`
|
||||
SELECT DISTINCT x.id::text AS id FROM (
|
||||
SELECT id_utilisateur AS id FROM parents WHERE TRIM(numero_dossier) = $1
|
||||
UNION
|
||||
SELECT id_co_parent AS id FROM parents
|
||||
WHERE TRIM(numero_dossier) = $1 AND id_co_parent IS NOT NULL
|
||||
UNION
|
||||
SELECT p2.id_utilisateur AS id FROM parents p1
|
||||
JOIN parents p2 ON p2.id_utilisateur = p1.id_co_parent
|
||||
WHERE TRIM(p1.numero_dossier) = $1
|
||||
) x WHERE x.id IS NOT NULL
|
||||
`,
|
||||
[numero],
|
||||
);
|
||||
return rows.map((r) => r.id);
|
||||
}
|
||||
|
||||
private async listEnfantIdsForNumero(numero: string): Promise<string[]> {
|
||||
const parentIds = await this.listParentUserIdsForNumero(numero);
|
||||
if (!parentIds.length) return [];
|
||||
return this.listEnfantIdsForParents(parentIds);
|
||||
}
|
||||
|
||||
private async listEnfantIdsForParent(parentId: string): Promise<string[]> {
|
||||
return this.listEnfantIdsForParents([parentId]);
|
||||
}
|
||||
|
||||
private async listEnfantIdsForParents(parentIds: string[]): Promise<string[]> {
|
||||
const links = await this.parentsChildrenRepository.find({
|
||||
where: { parentId: In(parentIds) },
|
||||
});
|
||||
return [...new Set(links.map((l) => l.enfantId))];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Users } from 'src/entities/users.entity';
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
|
||||
import { Children } from 'src/entities/children.entity';
|
||||
import { ParentsChildren } from 'src/entities/parents_children.entity';
|
||||
import { AmChildren } from 'src/entities/am_children.entity';
|
||||
import { SuppressionService } from './suppression.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Users,
|
||||
Parents,
|
||||
AssistanteMaternelle,
|
||||
Children,
|
||||
ParentsChildren,
|
||||
AmChildren,
|
||||
]),
|
||||
],
|
||||
providers: [SuppressionService],
|
||||
exports: [SuppressionService],
|
||||
})
|
||||
export class SuppressionsModule {}
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import 'reflect-metadata';
|
||||
import { GestionnairesController } from './gestionnaires.controller';
|
||||
import { GestionnairesService } from './gestionnaires.service';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
describe('GestionnairesController', () => {
|
||||
let controller: GestionnairesController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [GestionnairesController],
|
||||
providers: [GestionnairesService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<GestionnairesController>(GestionnairesController);
|
||||
describe('GestionnairesController roles (#161)', () => {
|
||||
it('POST /gestionnaires autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.create);
|
||||
expect(roles).toEqual(
|
||||
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||
);
|
||||
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
it('PATCH /gestionnaires/:id autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||
const roles = Reflect.getMetadata('roles', GestionnairesController.prototype.update);
|
||||
expect(roles).toEqual(
|
||||
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||
);
|
||||
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,10 +25,10 @@ import { AuthGuard } from 'src/common/guards/auth.guard';
|
||||
export class GestionnairesController {
|
||||
constructor(private readonly gestionnairesService: GestionnairesService) { }
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiResponse({ status: 201, description: 'Le gestionnaire a été créé avec succès.', type: Users })
|
||||
@ApiResponse({ status: 409, description: 'Conflit. L\'email est déjà utilisé.' })
|
||||
@ApiOperation({ summary: 'Création d\'un gestionnaire' })
|
||||
@ApiOperation({ summary: 'Création d\'un gestionnaire (admin / super admin)' })
|
||||
@ApiBody({ type: CreateGestionnaireDto })
|
||||
@Post()
|
||||
create(@Body() dto: CreateGestionnaireDto): Promise<Users> {
|
||||
@@ -43,7 +43,7 @@ export class GestionnairesController {
|
||||
return this.gestionnairesService.findAll();
|
||||
}
|
||||
|
||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN)
|
||||
@Roles(RoleType.GESTIONNAIRE, RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Récupérer un gestionnaire par ID' })
|
||||
@ApiResponse({ status: 400, description: 'ID invalide' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
@@ -56,8 +56,8 @@ export class GestionnairesController {
|
||||
return this.gestionnairesService.findOne(id);
|
||||
}
|
||||
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Mettre à jour un gestionnaire (admin / super admin)' })
|
||||
@ApiResponse({ status: 200, description: 'Le gestionnaire a été mis à jour avec succès.', type: Users })
|
||||
@ApiResponse({ status: 404, description: 'Gestionnaire non trouvé' })
|
||||
@ApiResponse({ status: 403, description: 'Accès refusé' })
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import 'reflect-metadata';
|
||||
import { UserController } from './user.controller';
|
||||
import { UserService } from './user.service';
|
||||
import { RoleType } from 'src/entities/users.entity';
|
||||
|
||||
describe('UserController', () => {
|
||||
let controller: UserController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UserController>(UserController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
describe('UserController roles (#161)', () => {
|
||||
it('POST /users/admin autorise SUPER_ADMIN et ADMINISTRATEUR', () => {
|
||||
const roles = Reflect.getMetadata('roles', UserController.prototype.createAdmin);
|
||||
expect(roles).toEqual(
|
||||
expect.arrayContaining([RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR]),
|
||||
);
|
||||
expect(roles).not.toContain(RoleType.GESTIONNAIRE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,22 @@ 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';
|
||||
import { SuppressionService } from '../suppressions/suppression.service';
|
||||
|
||||
@ApiTags('Utilisateurs')
|
||||
@ApiBearerAuth('access-token')
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) { }
|
||||
constructor(
|
||||
private readonly userService: UserService,
|
||||
private readonly suppressionService: SuppressionService,
|
||||
) { }
|
||||
|
||||
// Création d'un administrateur (réservée aux super admins)
|
||||
// Création d'un administrateur (admin + super admin) — #161
|
||||
@Post('admin')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
|
||||
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
|
||||
@ApiOperation({ summary: 'Créer un nouvel administrateur (admin / super admin)' })
|
||||
createAdmin(
|
||||
@Body() dto: CreateAdminDto,
|
||||
@User() currentUser: Users
|
||||
@@ -146,12 +150,20 @@ export class UserController {
|
||||
return this.userService.suspendUser(id, currentUser, comment);
|
||||
}
|
||||
|
||||
// Supprimer un utilisateur (super_admin uniquement)
|
||||
// Supprimer un utilisateur — cascades métier #159
|
||||
@Delete(':id')
|
||||
@Roles(RoleType.SUPER_ADMIN)
|
||||
@ApiOperation({ summary: 'Supprimer un utilisateur' })
|
||||
@Roles(
|
||||
RoleType.SUPER_ADMIN,
|
||||
RoleType.ADMINISTRATEUR,
|
||||
RoleType.GESTIONNAIRE,
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: 'Supprimer un utilisateur (cascades métier #159)',
|
||||
description:
|
||||
'Parent / AM / staff selon matrice. Gestionnaire ne peut pas supprimer un autre gestionnaire. Self interdit.',
|
||||
})
|
||||
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
|
||||
remove(@Param('id') id: string, @User() currentUser: Users) {
|
||||
return this.userService.remove(id, currentUser);
|
||||
return this.suppressionService.deleteUser(id, currentUser);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { AssistantesMaternellesModule } from '../assistantes_maternelles/assista
|
||||
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';
|
||||
import { SuppressionsModule } from '../suppressions/suppressions.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(
|
||||
@@ -24,6 +26,8 @@ import { MailModule } from 'src/modules/mail/mail.module';
|
||||
AssistantesMaternellesModule,
|
||||
GestionnairesModule,
|
||||
MailModule,
|
||||
AppConfigModule,
|
||||
SuppressionsModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
|
||||
@@ -1,18 +1,87 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { UserService } from './user.service';
|
||||
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity';
|
||||
|
||||
describe('UserService.createAdmin (#161)', () => {
|
||||
const usersRepository = {
|
||||
findOneBy: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
describe('UserService', () => {
|
||||
let service: UserService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UserService],
|
||||
}).compile();
|
||||
const dto = {
|
||||
email: 'nouveau.admin@ptits-pas.fr',
|
||||
password: 'Password1!',
|
||||
prenom: 'Nina',
|
||||
nom: 'Admin',
|
||||
telephone: '0601020304',
|
||||
};
|
||||
|
||||
service = module.get<UserService>(UserService);
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new UserService(
|
||||
usersRepository as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
it('autorise un administrateur à créer un admin', async () => {
|
||||
usersRepository.findOneBy.mockResolvedValue(null);
|
||||
usersRepository.create.mockImplementation((data) => data);
|
||||
usersRepository.save.mockImplementation(async (entity) => ({
|
||||
id: 'new-admin',
|
||||
...entity,
|
||||
}));
|
||||
|
||||
const result = await service.createAdmin(dto as never, {
|
||||
id: 'admin-1',
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
} as never);
|
||||
|
||||
expect(result.role).toBe(RoleType.ADMINISTRATEUR);
|
||||
expect(result.statut).toBe(StatutUtilisateurType.ACTIF);
|
||||
expect(usersRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('autorise un super_admin à créer un admin', async () => {
|
||||
usersRepository.findOneBy.mockResolvedValue(null);
|
||||
usersRepository.create.mockImplementation((data) => data);
|
||||
usersRepository.save.mockImplementation(async (entity) => ({
|
||||
id: 'new-admin',
|
||||
...entity,
|
||||
}));
|
||||
|
||||
await expect(
|
||||
service.createAdmin(dto as never, {
|
||||
id: 'sa-1',
|
||||
role: RoleType.SUPER_ADMIN,
|
||||
} as never),
|
||||
).resolves.toMatchObject({ role: RoleType.ADMINISTRATEUR });
|
||||
});
|
||||
|
||||
it('refuse un gestionnaire (403 métier)', async () => {
|
||||
await expect(
|
||||
service.createAdmin(dto as never, {
|
||||
id: 'gest-1',
|
||||
role: RoleType.GESTIONNAIRE,
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(usersRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuse un email déjà utilisé', async () => {
|
||||
usersRepository.findOneBy.mockResolvedValue({ id: 'exists' });
|
||||
await expect(
|
||||
service.createAdmin(dto as never, {
|
||||
id: 'admin-1',
|
||||
role: RoleType.ADMINISTRATEUR,
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, Logger, 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, MoreThan, Repository } from "typeorm";
|
||||
@@ -9,7 +9,8 @@ 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 } from "src/modules/mail/mail.service";
|
||||
import { MailService, ValidationAccountEmailKind } from "src/modules/mail/mail.service";
|
||||
import { AppConfigService } from "src/modules/config/config.service";
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
@@ -30,6 +31,8 @@ export class UserService {
|
||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||
|
||||
private readonly mailService: MailService,
|
||||
|
||||
private readonly appConfigService: AppConfigService,
|
||||
) { }
|
||||
|
||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||
@@ -114,8 +117,14 @@ export class UserService {
|
||||
}
|
||||
|
||||
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');
|
||||
// #161 — admin et super_admin peuvent créer un administrateur
|
||||
if (
|
||||
currentUser.role !== RoleType.SUPER_ADMIN &&
|
||||
currentUser.role !== RoleType.ADMINISTRATEUR
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'Seuls les administrateurs et super administrateurs peuvent créer un administrateur',
|
||||
);
|
||||
}
|
||||
|
||||
const exist = await this.usersRepository.findOneBy({ email: dto.email });
|
||||
@@ -265,6 +274,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;
|
||||
}
|
||||
|
||||
@@ -290,7 +338,7 @@ export class UserService {
|
||||
return savedUser;
|
||||
}
|
||||
|
||||
/** Refuser un compte (en_attente -> refuse) ; tracé validations, token reprise, email. Ticket #110 */
|
||||
/** 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');
|
||||
@@ -298,40 +346,79 @@ 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) {
|
||||
throw new BadRequestException('Seul un compte en attente peut être refusé.');
|
||||
throw new BadRequestException('Seul un dossier en attente peut être refusé.');
|
||||
}
|
||||
|
||||
const tokenReprise = crypto.randomUUID();
|
||||
const expireLe = new Date();
|
||||
expireLe.setDate(expireLe.getDate() + 7);
|
||||
|
||||
user.statut = StatutUtilisateurType.REFUSE;
|
||||
user.token_reprise = tokenReprise;
|
||||
user.token_reprise_expire_le = expireLe;
|
||||
const savedUser = await this.usersRepository.save(user);
|
||||
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
|
||||
? await this.usersRepository.find({
|
||||
where: {
|
||||
role: RoleType.PARENT,
|
||||
numero_dossier: user.numero_dossier,
|
||||
},
|
||||
})
|
||||
: [user];
|
||||
|
||||
const validation = this.validationRepository.create({
|
||||
user: savedUser,
|
||||
type: 'refus_compte',
|
||||
status: StatutValidationType.REFUSE,
|
||||
validated_by: currentUser,
|
||||
comment,
|
||||
});
|
||||
await this.validationRepository.save(validation);
|
||||
|
||||
try {
|
||||
await this.mailService.sendRefusEmail(
|
||||
savedUser.email,
|
||||
savedUser.prenom ?? '',
|
||||
savedUser.nom ?? '',
|
||||
comment,
|
||||
tokenReprise,
|
||||
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.',
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Envoi email refus échoué pour ${savedUser.email}`, err);
|
||||
}
|
||||
|
||||
return savedUser;
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -439,6 +526,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
async remove(id: string, currentUser: Users): Promise<void> {
|
||||
// Délégué historiquement ; préférer SuppressionService via controller (#159).
|
||||
if (currentUser.role !== RoleType.SUPER_ADMIN) {
|
||||
throw new ForbiddenException('Accès réservé aux super admins');
|
||||
}
|
||||
|
||||
+123
-96
@@ -1,3 +1,9 @@
|
||||
-- ==========================================================
|
||||
-- P'titsPas — Schéma PostgreSQL (création from scratch)
|
||||
-- Fichier canonique : toute nouvelle BDD doit partir d'ici.
|
||||
-- Migrations dans database/migrations/ : BDD existantes uniquement.
|
||||
-- ==========================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
|
||||
-- ==========================================================
|
||||
@@ -5,40 +11,63 @@ CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||
-- ==========================================================
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role_type') THEN
|
||||
CREATE TYPE role_type AS ENUM ('parent', 'gestionnaire', 'super_admin', 'administrateur', 'assistante_maternelle');
|
||||
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', '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','refuse');
|
||||
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');
|
||||
CREATE TYPE statut_enfant_type AS ENUM ('a_naitre', 'garde', 'sans_garde', 'scolarise');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_dossier_type') THEN
|
||||
CREATE TYPE statut_dossier_type AS ENUM ('envoye','accepte','refuse');
|
||||
CREATE TYPE statut_dossier_type AS ENUM ('envoye', 'accepte', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_contrat_type') THEN
|
||||
CREATE TYPE statut_contrat_type AS ENUM ('brouillon','en_attente_signature','valide','resilie');
|
||||
CREATE TYPE statut_contrat_type AS ENUM (
|
||||
'brouillon', 'en_attente_signature', 'valide', 'resilie'
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_avenant_type') THEN
|
||||
CREATE TYPE statut_avenant_type AS ENUM ('propose','accepte','refuse');
|
||||
CREATE TYPE statut_avenant_type AS ENUM ('propose', 'accepte', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'type_evenement_type') THEN
|
||||
CREATE TYPE type_evenement_type AS ENUM ('absence_enfant','conge_am','conge_parent','arret_maladie_am','evenement_rpe');
|
||||
CREATE TYPE type_evenement_type AS ENUM (
|
||||
'absence_enfant', 'conge_am', 'conge_parent', 'arret_maladie_am', 'evenement_rpe'
|
||||
);
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_evenement_type') THEN
|
||||
CREATE TYPE statut_evenement_type AS ENUM ('propose','valide','refuse');
|
||||
CREATE TYPE statut_evenement_type AS ENUM ('propose', 'valide', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_validation_type') THEN
|
||||
CREATE TYPE statut_validation_type AS ENUM ('en_attente','valide','refuse');
|
||||
CREATE TYPE statut_validation_type AS ENUM ('en_attente', 'valide', 'refuse');
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'situation_familiale_type') THEN
|
||||
CREATE TYPE situation_familiale_type AS ENUM ('celibataire','marie','concubinage','pacse','separe','divorce','veuf','parent_isole');
|
||||
CREATE TYPE situation_familiale_type AS ENUM (
|
||||
'celibataire', 'marie', 'concubinage', 'pacse', 'separe', 'divorce', 'veuf', 'parent_isole'
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : relais (avant utilisateurs — FK relais_id)
|
||||
-- ==========================================================
|
||||
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()
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : utilisateurs
|
||||
-- ==========================================================
|
||||
@@ -46,23 +75,33 @@ CREATE TABLE utilisateurs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'),
|
||||
password TEXT, -- NULL avant création via token
|
||||
password TEXT,
|
||||
prenom VARCHAR(100),
|
||||
nom VARCHAR(100),
|
||||
genre genre_type,
|
||||
role role_type NOT NULL,
|
||||
statut statut_utilisateur_type DEFAULT 'en_attente',
|
||||
telephone VARCHAR(20), -- Unifié (mobile privilégié)
|
||||
telephone VARCHAR(20),
|
||||
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
|
||||
photo_url TEXT,
|
||||
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
|
||||
token_creation_mdp VARCHAR(255),
|
||||
token_creation_mdp_expire_le TIMESTAMPTZ,
|
||||
password_reset_token VARCHAR(255),
|
||||
password_reset_expires TIMESTAMPTZ,
|
||||
token_reprise VARCHAR(255),
|
||||
token_reprise_expire_le TIMESTAMPTZ,
|
||||
changement_mdp_obligatoire BOOLEAN DEFAULT false,
|
||||
numero_dossier VARCHAR(20),
|
||||
cgu_version_acceptee INTEGER,
|
||||
cgu_acceptee_le TIMESTAMPTZ,
|
||||
privacy_version_acceptee INTEGER,
|
||||
privacy_acceptee_le TIMESTAMPTZ,
|
||||
relais_id UUID REFERENCES relais(id) ON DELETE SET NULL,
|
||||
cree_le TIMESTAMPTZ DEFAULT now(),
|
||||
modifie_le TIMESTAMPTZ DEFAULT now(),
|
||||
ville VARCHAR(150),
|
||||
@@ -71,11 +110,22 @@ CREATE TABLE utilisateurs (
|
||||
situation_familiale situation_familiale_type
|
||||
);
|
||||
|
||||
-- Index pour recherche par token
|
||||
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;
|
||||
|
||||
CREATE INDEX idx_utilisateurs_token_reprise
|
||||
ON utilisateurs(token_reprise)
|
||||
WHERE token_reprise IS NOT NULL;
|
||||
|
||||
CREATE INDEX idx_utilisateurs_numero_dossier
|
||||
ON utilisateurs(numero_dossier)
|
||||
WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : assistantes_maternelles
|
||||
-- ==========================================================
|
||||
@@ -90,17 +140,27 @@ CREATE TABLE assistantes_maternelles (
|
||||
date_agrement DATE,
|
||||
annee_experience SMALLINT,
|
||||
specialite VARCHAR(100),
|
||||
place_disponible INT
|
||||
place_disponible INT,
|
||||
numero_dossier VARCHAR(20)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_assistantes_maternelles_numero_dossier
|
||||
ON assistantes_maternelles(numero_dossier)
|
||||
WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : parents
|
||||
-- ==========================================================
|
||||
CREATE TABLE parents (
|
||||
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_co_parent UUID REFERENCES utilisateurs(id)
|
||||
id_co_parent UUID REFERENCES utilisateurs(id),
|
||||
numero_dossier VARCHAR(20)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_parents_numero_dossier
|
||||
ON parents(numero_dossier)
|
||||
WHERE numero_dossier IS NOT NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : enfants
|
||||
-- ==========================================================
|
||||
@@ -109,13 +169,12 @@ CREATE TABLE enfants (
|
||||
statut statut_enfant_type,
|
||||
prenom VARCHAR(100),
|
||||
nom VARCHAR(100),
|
||||
genre genre_type NOT NULL, -- Obligatoire selon CDC
|
||||
genre genre_type NOT NULL,
|
||||
date_naissance DATE,
|
||||
date_prevue_naissance DATE,
|
||||
photo_url TEXT,
|
||||
consentement_photo BOOLEAN DEFAULT false,
|
||||
date_consentement_photo TIMESTAMPTZ,
|
||||
est_multiple BOOLEAN DEFAULT false
|
||||
date_consentement_photo TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
@@ -128,7 +187,27 @@ CREATE TABLE enfants_parents (
|
||||
);
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
|
||||
-- Table : enfants_assistantes_maternelles (placement AM ↔ enfant — #131)
|
||||
-- ==========================================================
|
||||
CREATE TABLE enfants_assistantes_maternelles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
date_fin DATE NULL,
|
||||
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_eam_am ON enfants_assistantes_maternelles(id_am);
|
||||
CREATE INDEX idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
|
||||
|
||||
CREATE UNIQUE INDEX uq_enfant_garde_active
|
||||
ON enfants_assistantes_maternelles (id_enfant)
|
||||
WHERE date_fin IS NULL;
|
||||
|
||||
-- ==========================================================
|
||||
-- Table : dossier_famille (inscription parent — ticket #119)
|
||||
-- ==========================================================
|
||||
CREATE TABLE dossier_famille (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -271,9 +350,11 @@ CREATE TABLE notifications (
|
||||
-- ==========================================================
|
||||
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()
|
||||
);
|
||||
@@ -292,13 +373,10 @@ CREATE TABLE configuration (
|
||||
modifie_par UUID REFERENCES utilisateurs(id)
|
||||
);
|
||||
|
||||
-- Index pour performance
|
||||
CREATE INDEX idx_configuration_cle ON configuration(cle);
|
||||
CREATE INDEX idx_configuration_categorie ON configuration(categorie);
|
||||
|
||||
-- Seed initial de configuration
|
||||
INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||
-- === Configuration Email (SMTP) ===
|
||||
('smtp_host', 'localhost', 'string', 'email', 'Serveur SMTP (ex: mail.mairie-bezons.fr, smtp.gmail.com)'),
|
||||
('smtp_port', '25', 'number', 'email', 'Port SMTP (25, 465, 587)'),
|
||||
('smtp_secure', 'false', 'boolean', 'email', 'Utiliser SSL/TLS (true pour port 465)'),
|
||||
@@ -307,14 +385,10 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||
('smtp_password', '', 'encrypted', 'email', 'Mot de passe SMTP (chiffré en AES-256)'),
|
||||
('email_from_name', 'P''titsPas', 'string', 'email', 'Nom de l''expéditeur affiché dans les emails'),
|
||||
('email_from_address', 'no-reply@ptits-pas.fr', 'string', 'email', 'Adresse email de l''expéditeur'),
|
||||
|
||||
-- === Configuration Application ===
|
||||
('app_name', 'P''titsPas', 'string', 'app', 'Nom de l''application (affiché dans l''interface)'),
|
||||
('app_url', 'https://app.ptits-pas.fr', 'string', 'app', 'URL publique de l''application (pour les liens dans emails)'),
|
||||
('app_logo_url', '/assets/logo.png', 'string', 'app', 'URL du logo de l''application'),
|
||||
('setup_completed', 'false', 'boolean', 'app', 'Configuration initiale terminée'),
|
||||
|
||||
-- === Configuration Sécurité ===
|
||||
('password_reset_token_expiry_days', '7', 'number', 'security', 'Durée de validité des tokens de création/réinitialisation de mot de passe (en jours)'),
|
||||
('jwt_expiry_hours', '24', 'number', 'security', 'Durée de validité des sessions JWT (en heures)'),
|
||||
('max_upload_size_mb', '5', 'number', 'security', 'Taille maximale des fichiers uploadés (en MB)'),
|
||||
@@ -325,19 +399,18 @@ INSERT INTO configuration (cle, valeur, type, categorie, description) VALUES
|
||||
-- ==========================================================
|
||||
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
|
||||
type VARCHAR(50) NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
fichier_nom VARCHAR(255) NOT NULL,
|
||||
fichier_path VARCHAR(500) NOT NULL,
|
||||
fichier_hash VARCHAR(64) NOT NULL,
|
||||
actif BOOLEAN DEFAULT false,
|
||||
televerse_par UUID REFERENCES utilisateurs(id),
|
||||
televerse_le TIMESTAMPTZ DEFAULT now(),
|
||||
active_le TIMESTAMPTZ,
|
||||
UNIQUE(type, 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);
|
||||
|
||||
@@ -348,66 +421,23 @@ 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)
|
||||
type_document VARCHAR(50) NOT NULL,
|
||||
version_document INTEGER NOT NULL,
|
||||
accepte_le TIMESTAMPTZ DEFAULT now(),
|
||||
ip_address INET,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
-- Index pour performance
|
||||
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
|
||||
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
|
||||
|
||||
-- ==========================================================
|
||||
-- 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 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
|
||||
CREATE TABLE 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;
|
||||
|
||||
-- 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
|
||||
@@ -418,10 +448,7 @@ INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_
|
||||
|
||||
-- ==========================================================
|
||||
-- Seed : Super Administrateur par défaut
|
||||
-- ==========================================================
|
||||
-- Email: admin@ptits-pas.fr
|
||||
-- Mot de passe: 4dm1n1strateur (hashé bcrypt)
|
||||
-- IMPORTANT: Changer ce mot de passe en production !
|
||||
-- Email: admin@ptits-pas.fr | Mot de passe: 4dm1n1strateur
|
||||
-- ==========================================================
|
||||
INSERT INTO utilisateurs (
|
||||
email,
|
||||
|
||||
+6
-5
@@ -15,12 +15,13 @@ Ce projet contient la **base de données** pour l'application PtitsPas, avec scr
|
||||
|
||||
## Structure du projet
|
||||
|
||||
- `migrations/` : scripts SQL pour la création et l'import de la base
|
||||
- `bdd/data_test/` : fichiers CSV pour l'import de données de test
|
||||
- `docs/` : documentation métier et technique
|
||||
- `seed/` : scripts de seed
|
||||
- **`BDD.sql`** : schéma canonique (création from scratch — utilisé par `docker-compose.yml` racine)
|
||||
- `migrations/` : scripts SQL pour **mettre à jour** une BDD déjà en service
|
||||
- `bdd/data_test/` : fichiers CSV (import legacy)
|
||||
- `docs/` : documentation métier et technique (`ENUMS.md`, `FK_POLICIES.md`)
|
||||
- `seed/` : scripts de seed (`03_seed_test_data.sql` — jeu dashboard admin)
|
||||
- `tests/` : tests SQL
|
||||
- `docker-compose.dev.yml` : configuration Docker pour le développement
|
||||
- `docker-compose.dev.yml` : Postgres standalone (BDD.sql + seed auto)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo","est_multiple"
|
||||
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","actif","Emma","Dupont","F","2020-06-01",,,False,,False
|
||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","actif",,,,"2020-01-01","2025-01-01",,False,,False
|
||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","actif","Emma","Martin",,"2023-02-15",,,False,,False
|
||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","actif","Noah","Martin",,"2023-02-15",,,False,,False
|
||||
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","actif","Léa","Martin",,"2023-02-15",,,False,,False
|
||||
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","actif","Chloé","Rousseau",,"2022-04-20",,,False,,False
|
||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","actif","Hugo","Rousseau",,"2024-03-10",,,False,,False
|
||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","actif","Maxime","Lecomte",,"2023-04-15",,,False,,False
|
||||
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,,False
|
||||
"id","statut","prenom","nom","genre","date_naissance","date_prevue_naissance","photo_url","consentement_photo","date_consentement_photo"
|
||||
"5e8574b7-63e6-4d48-9af3-8d3bf7a6a6cf","sans_garde","Emma","Dupont","F","2020-06-01",,,False,
|
||||
"a5c3268e-07eb-41a4-9f6c-2f9f16f37c3d","sans_garde",,,,"2020-01-01","2025-01-01",,False,
|
||||
"e1a2b3c4-d5e6-4f7a-8b9c-1d2e3f4a5b6c","sans_garde","Emma","Martin",,"2023-02-15",,,False,
|
||||
"e2b3c4d5-e6f7-4a8b-9c1d-2e3f4a5b6c7d","sans_garde","Noah","Martin",,"2023-02-15",,,False,
|
||||
"e3c4d5e6-f7a8-4b9c-1d2e-3f4a5b6c7d8e","sans_garde","Léa","Martin",,"2023-02-15",,,False,
|
||||
"e4d5e6f7-a8b9-4c1d-2e3f-4a5b6c7d8e9f","sans_garde","Chloé","Rousseau",,"2022-04-20",,,False,
|
||||
"e5e6f7a8-b9c1-4d2e-3f4a-5b6c7d8e9f1a","sans_garde","Hugo","Rousseau",,"2024-03-10",,,False,
|
||||
"e6f7a8b9-c1d2-4e3f-5a6b-7c8d9e0f1a2b","sans_garde","Maxime","Lecomte",,"2023-04-15",,,False,
|
||||
"edd19cd1-bb67-4f14-8a37-c66b75c94537","scolarise","Lucas","Durand","H","2018-09-15",,,False,
|
||||
|
||||
|
@@ -14,9 +14,8 @@ services:
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- ./migrations/01_init.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||
- ./migrations/07_import.sql:/docker-entrypoint-initdb.d/07_import.sql
|
||||
- ./bdd/data_test:/bdd/data_test
|
||||
- ./BDD.sql:/docker-entrypoint-initdb.d/01_init.sql
|
||||
- ./seed/03_seed_test_data.sql:/docker-entrypoint-initdb.d/02_seed_test_data.sql
|
||||
- postgres_standalone_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- ptitspas_dev
|
||||
|
||||
@@ -51,12 +51,17 @@ Ce document recense **toutes les valeurs énumérées** utilisées dans la base
|
||||
| Valeur | Description |
|
||||
|---|---|
|
||||
| `a_naitre` | Enfant à naître (date prévue renseignée) |
|
||||
| `actif` | Enfant pris en charge / en cours de garde |
|
||||
| `sans_garde` | Enfant né, pas encore placé chez une AM (défaut à l'inscription) |
|
||||
| `garde` | Enfant placé chez une AM (`enfants_assistantes_maternelles` actif) |
|
||||
| `scolarise` | Enfant scolarisé, garde potentiellement périscolaire |
|
||||
|
||||
**Contraintes associées** :
|
||||
- `a_naitre` → **`date_prevue_naissance` obligatoire**
|
||||
- `actif`/`scolarise` → **`date_naissance` obligatoire**
|
||||
- `sans_garde` / `garde` / `scolarise` → **`date_naissance` obligatoire**
|
||||
|
||||
**Transitions** (API admin #131) :
|
||||
- Rattachement AM ↔ enfant → `garde` (sauf `a_naitre` / `scolarise`)
|
||||
- Détachement sans autre placement actif → `sans_garde`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
| **parents(id_co_parent)** → `utilisateurs(id)` | **SET NULL** | Conserver le parent principal si co-parent disparaît |
|
||||
| **enfants_parents(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Nettoyage liaisons N:N |
|
||||
| **enfants_parents(id_enfant)** → `enfants(id)` | **CASCADE** | Idem |
|
||||
| **enfants_assistantes_maternelles(id_am)** → `assistantes_maternelles(id_utilisateur)` | **CASCADE** | Placement supprimé avec l’AM |
|
||||
| **enfants_assistantes_maternelles(id_enfant)** → `enfants(id)` | **CASCADE** | Idem |
|
||||
| **enfants_assistantes_maternelles(cree_par)** → `utilisateurs(id)` | **SET NULL** | Historique placement conservé |
|
||||
| **dossiers(id_parent)** → `parents(id_utilisateur)` | **CASCADE** | Dossier n’a pas de sens sans parent |
|
||||
| **dossiers(id_enfant)** → `enfants(id)` | **CASCADE** | Dossier n’a pas de sens sans enfant |
|
||||
| **messages(id_dossier)** → `dossiers(id)` | **CASCADE** | Messages détruits avec le dossier |
|
||||
@@ -47,6 +50,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).
|
||||
|
||||
@@ -85,6 +89,7 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
|
||||
- `uploads.id_utilisateur` → **SET NULL**
|
||||
- `notifications.id_utilisateur` → **CASCADE**
|
||||
- `validations.id_utilisateur` → **SET NULL**
|
||||
- `validations.valide_par` → **SET NULL**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- #152 — Suppression grossesse multiple / est_multiple
|
||||
ALTER TABLE enfants DROP COLUMN IF EXISTS est_multiple;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Ticket #131 / #141 — Placement AM ↔ enfant + statuts garde/sans_garde
|
||||
-- ⚠️ BDD EXISTANTES UNIQUEMENT — ne pas exécuter sur une base créée via BDD.sql à jour.
|
||||
-- Idempotent : safe à rejouer sur recette / prod.
|
||||
|
||||
-- 1) Nouveaux statuts enfant
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'garde';
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TYPE statut_enfant_type ADD VALUE IF NOT EXISTS 'sans_garde';
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
-- actif → sans_garde (enfants sans placement AM connu au déploiement)
|
||||
UPDATE enfants SET statut = 'sans_garde' WHERE statut = 'actif';
|
||||
|
||||
-- 2) Table de placement AM ↔ enfant
|
||||
CREATE TABLE IF NOT EXISTS enfants_assistantes_maternelles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id_am UUID NOT NULL REFERENCES assistantes_maternelles(id_utilisateur) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
date_debut DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||
date_fin DATE NULL,
|
||||
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
cree_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_eam_am ON enfants_assistantes_maternelles(id_am);
|
||||
CREATE INDEX IF NOT EXISTS idx_eam_enfant ON enfants_assistantes_maternelles(id_enfant);
|
||||
|
||||
-- Au plus une garde active par enfant
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_enfant_garde_active
|
||||
ON enfants_assistantes_maternelles (id_enfant)
|
||||
WHERE date_fin IS 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.
|
||||
@@ -65,16 +65,16 @@ ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- Enfants
|
||||
-- - child A : déjà né (statut = 'actif' et date_naissance requise)
|
||||
-- - child A : déjà né (statut = 'sans_garde' et date_naissance requise)
|
||||
-- - child B : à naître (statut = 'a_naitre' et date_prevue_naissance requise)
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance, jumeau_multiple)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'actif', '2022-04-12', false)
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_naissance)
|
||||
VALUES ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Léo', 'Parent', 'sans_garde', '2022-04-12')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance, jumeau_multiple)
|
||||
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15', false)
|
||||
INSERT INTO enfants (id, prenom, nom, statut, date_prevue_naissance)
|
||||
VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Mila', 'Parent', 'a_naitre', '2026-02-15')
|
||||
ON CONFLICT (id) 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()
|
||||
)
|
||||
|
||||
@@ -18,15 +18,15 @@ BEGIN;
|
||||
|
||||
INSERT INTO utilisateurs (id, email, password, prenom, nom, role, statut, telephone, adresse, ville, code_postal, profession, situation_familiale, date_naissance, consentement_photo)
|
||||
VALUES
|
||||
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||
('a0000001-0001-0001-0001-000000000001', 'sophie.bernard@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Sophie', 'BERNARD', 'administrateur', 'actif', '0678123456', '12 Avenue Gabriel Péri', 'Bezons', '95870', 'Responsable administrative', 'marie', '1978-03-15', false),
|
||||
('a0000002-0002-0002-0002-000000000002', 'lucas.moreau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Lucas', 'MOREAU', 'gestionnaire', 'actif', '0687234567', '8 Rue Jean Jaurès', 'Bezons', '95870', 'Gestionnaire des placements', 'celibataire', '1985-09-22', false),
|
||||
('a0000003-0003-0003-0003-000000000003', 'marie.dubois@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Marie', 'DUBOIS', 'assistante_maternelle', 'actif', '0696345678', '25 Rue de la République', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1980-06-08', true),
|
||||
('a0000004-0004-0004-0004-000000000004', 'fatima.elmansouri@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Fatima', 'EL MANSOURI', 'assistante_maternelle', 'actif', '0675456789', '17 Boulevard Aristide Briand', 'Bezons', '95870', 'Assistante maternelle', 'marie', '1975-11-12', true),
|
||||
('a0000005-0005-0005-0005-000000000005', 'claire.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Claire', 'MARTIN', 'parent', 'actif', '0689567890', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Infirmière', 'marie', '1990-04-03', false),
|
||||
('a0000006-0006-0006-0006-000000000006', 'thomas.martin@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Thomas', 'MARTIN', 'parent', 'actif', '0678456789', '5 Avenue du Général de Gaulle', 'Bezons', '95870', 'Ingénieur', 'marie', '1988-07-18', false),
|
||||
('a0000007-0007-0007-0007-000000000007', 'amelie.durand@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Amélie', 'DURAND', 'parent', 'actif', '0667788990', '23 Rue Victor Hugo', 'Bezons', '95870', 'Comptable', 'divorce', '1987-12-14', false),
|
||||
('a0000008-0008-0008-0008-000000000008', 'julien.rousseau@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'Julien', 'ROUSSEAU', 'parent', 'actif', '0656677889', '14 Rue Pasteur', 'Bezons', '95870', 'Commercial', 'divorce', '1985-08-29', false),
|
||||
('a0000009-0009-0009-0009-000000000009', 'david.lecomte@ptits-pas.fr', '$2b$10$vhzSJ6qGzul3jhtmXUJoV.3sGzPghdB0dDBx3Di1CHKrMZOwP7RGS', 'David', 'LECOMTE', 'parent', 'actif', '0645566778', '31 Rue Émile Zola', 'Bezons', '95870', 'Développeur web', 'parent_isole', '1992-10-07', false)
|
||||
ON CONFLICT (email) DO NOTHING;
|
||||
|
||||
-- ========== PARENTS (avec co-parent pour le couple Martin) ==========
|
||||
@@ -49,14 +49,14 @@ VALUES
|
||||
ON CONFLICT (id_utilisateur) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS ==========
|
||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut, est_multiple)
|
||||
INSERT INTO enfants (id, prenom, nom, genre, date_naissance, statut)
|
||||
VALUES
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'actif', true),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'actif', true),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'actif', false),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'actif', false),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'actif', false)
|
||||
('e0000001-0001-0001-0001-000000000001', 'Emma', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
|
||||
('e0000002-0002-0002-0002-000000000002', 'Noah', 'MARTIN', 'H', '2023-02-15', 'sans_garde'),
|
||||
('e0000003-0003-0003-0003-000000000003', 'Léa', 'MARTIN', 'F', '2023-02-15', 'sans_garde'),
|
||||
('e0000004-0004-0004-0004-000000000004', 'Chloé', 'ROUSSEAU', 'F', '2022-04-20', 'sans_garde'),
|
||||
('e0000005-0005-0005-0005-000000000005', 'Hugo', 'ROUSSEAU', 'H', '2024-03-10', 'sans_garde'),
|
||||
('e0000006-0006-0006-0006-000000000006', 'Maxime', 'LECOMTE', 'H', '2023-04-15', 'sans_garde')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ========== ENFANTS_PARENTS (liaison N:N) ==========
|
||||
|
||||
+3
-2
@@ -36,6 +36,7 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
|
||||
- [**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
|
||||
- [**28 - Évolution famille et responsables**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) - Modèle dossier/famille, recompositions, v1.0.0 vs post-1.0.0
|
||||
- [**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)
|
||||
|
||||
@@ -49,7 +50,7 @@ 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`
|
||||
- [`EVOLUTIONS_CDC.md`](./EVOLUTIONS_CDC.md) — écarts CDC / app ; voir aussi [**28 - Évolution famille**](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)
|
||||
- `SuperNounou_Cahier_Des_Charges_Complet_V1.1.md`
|
||||
- `SuperNounou_SSS-001.md`
|
||||
|
||||
@@ -89,5 +90,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin
|
||||
|
||||
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
|
||||
|
||||
Dernière mise à jour : Novembre 2025
|
||||
Dernière mise à jour : Juin 2026
|
||||
|
||||
|
||||
@@ -117,7 +117,6 @@ Table des enfants pris en charge.
|
||||
| `photo_url` | TEXT | | URL de la photo |
|
||||
| `consentement_photo` | BOOLEAN | DEFAULT false | Consentement photo |
|
||||
| `date_consentement_photo` | TIMESTAMPTZ | | Date du consentement |
|
||||
| `est_multiple` | BOOLEAN | DEFAULT false | Indique si grossesse multiple |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+48
-15
@@ -1,15 +1,15 @@
|
||||
# 🎫 Liste Complète des Tickets - Projet P'titsPas
|
||||
|
||||
**Version** : 1.7
|
||||
**Date** : 25 Mars 2026
|
||||
**Version** : 1.9
|
||||
**Date** : 23 Avril 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
**Estimation totale** : ~208h
|
||||
**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 25 mars 2026 ; #106, #107, #109, #119 fermés via API).
|
||||
**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 |
|
||||
|--------|----------------|--------|
|
||||
@@ -102,6 +102,7 @@
|
||||
| 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*
|
||||
|
||||
@@ -118,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** | |
|
||||
|
||||
---
|
||||
|
||||
@@ -591,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`
|
||||
**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)
|
||||
|
||||
@@ -912,6 +914,37 @@ Créer l'écran de création de mot de passe (lien reçu par email).
|
||||
|
||||
---
|
||||
|
||||
### 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`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 📋 Décisions Projet - P'titsPas
|
||||
|
||||
**Version** : 1.1
|
||||
**Date** : 9 Février 2026
|
||||
**Version** : 1.2
|
||||
**Date** : 16 Juin 2026
|
||||
**Auteur** : Équipe PtitsPas
|
||||
|
||||
---
|
||||
@@ -122,6 +122,27 @@ ptitspas-app/
|
||||
|
||||
---
|
||||
|
||||
### 5bis. Familles recomposées — contournement v1.0.0
|
||||
|
||||
**Décision** : ✅ **Second compte / second email pour les cas multi-contextes en v1.0.0** ; évolution structurelle reportée post-1.0.0
|
||||
|
||||
**Contexte** :
|
||||
- Le numéro de dossier et le graphe « famille » (co-parent + enfants partagés) conviennent aux cas simples (un couple, N enfants).
|
||||
- Ils ne couvrent pas proprement une même personne responsable dans **plusieurs unités familiales** (recompositions, plusieurs co-responsables successifs, tuteur/GP sur plusieurs contextes).
|
||||
|
||||
**Décision v1.0.0** :
|
||||
- **Contournement opérationnel** : créer un **second compte** avec **email distinct** et un **second numéro de dossier** (souvent via le gestionnaire).
|
||||
- Le numéro de dossier reste la **norme** pour les cas simples, **sans obligation absolue** pour les cas complexes.
|
||||
- Rôle applicatif unique **`parent`** pour tous les responsables (tuteur, GP inclus) ; qualification juridique = évolution ultérieure.
|
||||
|
||||
**Évolution post-1.0.0** :
|
||||
- **Parcours gestionnaire « famille complexe »** (§ 7.5 doc 28) : N responsables, **M un seul compte** avec tous ses enfants ; **A / B** limités à leur enfant via `enfants_parents`.
|
||||
- Visibilité et workflows **sans fusion graphe familial** ; numéro de dossier optionnel ; qualification du lien responsable–enfant.
|
||||
|
||||
**Référence** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) — § 4.4, § 7.5
|
||||
|
||||
---
|
||||
|
||||
### 6. Genre enfant obligatoire (H/F)
|
||||
|
||||
**Décision** : ✅ **Genre obligatoire (H/F uniquement)**
|
||||
@@ -562,6 +583,7 @@ docs/
|
||||
| 14 | Migration données | ❌ Rejeté | N/A |
|
||||
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
|
||||
| 31 | Logs Winston | ✅ Phase 1 | Monitoring |
|
||||
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
|
||||
|
||||
---
|
||||
|
||||
@@ -571,10 +593,12 @@ docs/
|
||||
|------|---------|---------------|
|
||||
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales |
|
||||
| 09/02/2026 | 1.1 | Configuration initiale : un seul panneau Paramètres (3 sections) dans le dashboard, plus de Setup Wizard dédié ; navigation bloquée jusqu'à sauvegarde |
|
||||
| 16/06/2026 | 1.2 | Décision 5bis — familles recomposées, contournement v1.0.0 ; lien doc [28](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md) |
|
||||
| 16/06/2026 | 1.3 | Précision 5bis — cible post-1.0.0 : parcours gestionnaire § 7.5 (#139), visibilité par enfant |
|
||||
|
||||
---
|
||||
|
||||
**Dernière mise à jour** : 9 Février 2026
|
||||
**Version** : 1.1
|
||||
**Dernière mise à jour** : 16 Juin 2026
|
||||
**Version** : 1.2
|
||||
**Statut** : ✅ Document validé
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
# Évolution — Modèle famille, responsables légaux et dossiers
|
||||
|
||||
**Version** : 1.1
|
||||
**Date** : 16 juin 2026
|
||||
**Statut** : Réflexions produit / architecture — complément au [CDC](./01_CAHIER-DES-CHARGES.md)
|
||||
**Documents liés** : [EVOLUTIONS_CDC.md](./EVOLUTIONS_CDC.md), [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md), [23_LISTE-TICKETS.md](./23_LISTE-TICKETS.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Objet de ce document
|
||||
|
||||
Ce document **trace les réflexions** menées en 2026 sur :
|
||||
|
||||
- les **limites du modèle « famille / numéro de dossier »** en v1.0.0 ;
|
||||
- les **contournements** acceptés pour la release **1.0.0** ;
|
||||
- les **évolutions** envisagées post-1.0.0 (unités de dossier, affiliation parent–enfant, terminologie).
|
||||
|
||||
Il ne remplace pas le CDC : il documente l’**écart assumé** entre le modèle idéal long terme et ce qui est livré en **1.0.0**, ainsi que la **feuille de route** pour aller plus loin.
|
||||
|
||||
---
|
||||
|
||||
## 2. Modèle actuel (v1.0.0) — rappel
|
||||
|
||||
| Concept | Implémentation |
|
||||
|--------|----------------|
|
||||
| **Responsable inscrit** | Rôle applicatif `parent` + entité `parents` |
|
||||
| **Second adulte** | Co-parent optionnel (`id_co_parent`) — **un seul** |
|
||||
| **Enfant** | Entité `enfants` |
|
||||
| **Affiliation** | Table `enfants_parents` (liens many-to-many) |
|
||||
| **Dossier famille** | `dossier_famille` + `dossier_famille_enfants` (motivation, etc.) |
|
||||
| **Numéro de dossier** | Format `AAAA-NNNNNN`, sur `utilisateurs` et `parents` |
|
||||
| **« Famille » calculée** | Graphe : co-parent **ou** enfants partagés (`getFamilyUserIds`) |
|
||||
| **Workflows** | Validation, refus, reprise : souvent **par `numero_dossier`** ou par ce graphe |
|
||||
|
||||
Le numéro de dossier est aujourd’hui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **l’identifiant métier** de la famille — ce qui pose problème dans les cas complexes.
|
||||
|
||||
---
|
||||
|
||||
## 3. Limitation connue v1.0.0 — familles recomposées et multi-contextes
|
||||
|
||||
### 3.1 Exemple type (illustration)
|
||||
|
||||
```
|
||||
Année 1 : Responsable M + co-responsable A → enfant α
|
||||
Année 2 : Responsable M + co-responsable B → enfant β
|
||||
```
|
||||
|
||||
Même personne **M** au centre, **deux contextes de vie** distincts. **A** n’est pas parent de β ; **B** n’est pas parent de α.
|
||||
|
||||
### 3.2 Autres cas couverts par la même limitation
|
||||
|
||||
Les exemples « maman / papa » sont **illustratifs**. La limitation s’applique **à toute configuration** :
|
||||
|
||||
| Configuration | Même règle |
|
||||
|---------------|------------|
|
||||
| Couple **HH** ou **FF** | Oui |
|
||||
| **Père** avec deux partenaires et deux enfants (même ville, écarts d’âge courts) | Oui |
|
||||
| **Grand-parent** en tutelle ou **tuteur légal** | Oui *(voir § 5)* |
|
||||
| Recomposition, demi-fratrie, garde alternée complexe | Oui |
|
||||
|
||||
### 3.3 Pourquoi le modèle casse
|
||||
|
||||
1. **Un `numero_dossier` par user** — M ne peut pas appartenir proprement à deux unités.
|
||||
2. **Un seul co-parent** par fiche `parents`.
|
||||
3. **Graphe famille trop large** — M liée à A (via α) et à B (via β) → A, B et M peuvent être fusionnés en **une seule « famille »** pour validation/refus/reprise.
|
||||
4. **`dossier_famille`** — une motivation / une ancre par numéro, pas deux contextes pour la même personne.
|
||||
|
||||
---
|
||||
|
||||
## 4. Décision v1.0.0 — contournement opérationnel
|
||||
|
||||
### 4.1 Principe
|
||||
|
||||
> **Le numéro de dossier reste la norme pour les cas simples, pas une obligation absolue.**
|
||||
> Pour les cas trop complexes, le **gestionnaire** compose manuellement (création admin, rattachements) ou applique le contournement ci-dessous.
|
||||
|
||||
### 4.2 Contournement accepté pour la 1.0.0
|
||||
|
||||
**Créer un second compte** avec une **adresse e-mail distincte** et un **second dossier** (second `numero_dossier`).
|
||||
|
||||
| Dossier | Compte | Co-responsable | Enfant |
|
||||
|---------|--------|----------------|--------|
|
||||
| 1 | `m.personne@…` | A | α |
|
||||
| 2 | `m.personne.famille2@…` *(ou alias)* | B | β |
|
||||
|
||||
**Conséquences assumées :**
|
||||
|
||||
- Une **même personne physique** peut avoir **deux identités** dans l’app.
|
||||
- Pas de vue unifiée « une personne, deux contextes » en v1.0.0.
|
||||
- Procédure interne gestionnaire recommandée (note « même personne physique »).
|
||||
- Emails distincts **volontaires** (alias, +tag, boîte dédiée selon infra mail).
|
||||
|
||||
### 4.3 Nuance — inscription publique
|
||||
|
||||
Si le **dossier 1** existe déjà avec l’email de P (titulaire ou co-parent), une **2ᵉ inscription publique** où l’on saisit **le même email** comme co-parent est **bloquée** (*email déjà utilisé*).
|
||||
|
||||
Le **2ᵉ dossier** passe donc surtout par :
|
||||
|
||||
- **création / composition par le gestionnaire** (tickets admin #129+), ou
|
||||
- **2ᵉ email** dès le départ pour la même personne physique.
|
||||
|
||||
### 4.4 Piste cible — parcours gestionnaire « famille complexe » *(réflexion juin 2026)*
|
||||
|
||||
Le contournement § 4.2 reste valable en **v1.0.0**. La **solution produit visée** pour les cas complexes est différente :
|
||||
|
||||
> **Seul le gestionnaire** dispose d’un **parcours de création dédié** permettant de constituer une configuration avec **plus de deux responsables** (parents, co-parents, tuteurs…) **sans** se limiter au seul champ `co_parent`, en s’appuyant sur **`enfants_parents`** comme vérité de visibilité.
|
||||
|
||||
**Exemple M / A / B / α / β :**
|
||||
|
||||
| Compte | Enfants visibles / rattachés |
|
||||
|--------|------------------------------|
|
||||
| **M** (un seul compte, un email) | α **et** β |
|
||||
| **A** | α uniquement |
|
||||
| **B** | β uniquement |
|
||||
|
||||
```
|
||||
α ─── M ─── β
|
||||
│ │
|
||||
A B
|
||||
```
|
||||
|
||||
- **M** voit et gère **ses deux enfants** sur **le même compte** (plus besoin d’un 2ᵉ email pour M).
|
||||
- **A** et **B** ne voient **que** l’enfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
|
||||
- Le parcours n’est **pas** proposé à l’inscription publique (trop error-prone) : **réservé au gestionnaire** (#129+ ou ticket dédié « famille complexe »).
|
||||
|
||||
**Conséquences techniques (post-1.0.0) :**
|
||||
|
||||
1. **Visibilité** : filtrer listes, fiches et actions parent par **liens `enfants_parents`**, pas par `getFamilyUserIds` / co-parent.
|
||||
2. **Workflows** (validation, refus, reprise) : périmètre par **enfant** ou **soumission**, pas par graphe familial élargi.
|
||||
3. **`co_parent`** : reste utile pour le cas simple (couple + N enfants communs) ; **insuffisant seul** pour les cas complexes — le gestionnaire compose les liens enfant par enfant.
|
||||
4. **Numéro de dossier** : optionnel ou secondaire ; peut rester sur M ou sur une « unité » admin, sans imposer un numéro par co-responsable.
|
||||
|
||||
*Détail : § 7.5.*
|
||||
|
||||
---
|
||||
|
||||
## 5. Terminologie — « parent » aujourd’hui, qualification demain
|
||||
|
||||
### 5.1 v1.0.0
|
||||
|
||||
- Rôle technique : **`parent`** pour tout responsable inscrit (y compris tuteur, grand-parent tutrice, etc.).
|
||||
- UI : « Parent 1 », « co-parent », parfois exemples maman/papa — **pas de statut juridique distinct**.
|
||||
|
||||
### 5.2 Évolution post-1.0.0 (piste)
|
||||
|
||||
Séparer deux niveaux :
|
||||
|
||||
| Niveau | Évolution |
|
||||
|--------|-----------|
|
||||
| **Rôle applicatif** | Conserver `parent` = « responsable du dossier » (auth, API) |
|
||||
| **Qualification métier** | Nouveau champ, ex. `qualite_responsable` / `lien_avec_enfant` |
|
||||
|
||||
**Valeurs possibles (exemples)** : parent biologique ou adoptif, co-parent, tuteur légal, grand-parent exerçant la garde, autre responsable légal.
|
||||
|
||||
**Emplacement recommandé** : sur le **lien** `enfants_parents` (par enfant), pas seulement sur le user global.
|
||||
|
||||
**UI** :
|
||||
|
||||
- Libellés neutres : « Responsable 1 / 2 », « 2ᵉ responsable » ;
|
||||
- **Combobox** pour qualifier le lien (admin + éventuellement inscription).
|
||||
|
||||
---
|
||||
|
||||
## 6. Évolutions UI / fonctionnelles liées (backlog)
|
||||
|
||||
Réflexions dashboard admin / gestionnaire (complément CDC §4.5.2–4.5.3).
|
||||
|
||||
> **Statut implémentation (juin 2026)** : §6.1 et §6.2 **en cours de livraison** (tickets #130–#131, #115–#116, #137–#138). §6.3 reporté post-1.0.0 (#129).
|
||||
|
||||
### 6.1 Fiche parent (#131)
|
||||
|
||||
- Modale **éditable** dès l’ouverture (pas lecture seule + « Modifier » factice).
|
||||
- **Retirer l’ID** UUID ; option : **n° de dossier** en lecture seule.
|
||||
- **Statut** en combobox (règles métier à cadrer vs validation/refus #110).
|
||||
- Bas de modale :
|
||||
- `Nombre d'enfants : N` (lecture seule) ;
|
||||
- **Liste des prénoms/noms** (cadre) — consultation, clic → fiche enfant (#138).
|
||||
|
||||
### 6.2 Affiliation parent ↔ enfant (#115, #116, #138)
|
||||
|
||||
- **Vérité métier** : `enfants_parents`.
|
||||
- **Modifier l’affiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
|
||||
- ne pas supprimer l’enfant pour retirer un lien ;
|
||||
- au moins un responsable par enfant ;
|
||||
- prudence sur fusion « famille » et co-parents.
|
||||
- **Création enfant** : prioritaire depuis **fiche parent** (contexte famille) ; onglet **Enfants** (#137) pour vue globale + rattachement.
|
||||
|
||||
### 6.3 Création admin sans numéro (tickets #129+)
|
||||
|
||||
Le gestionnaire doit pouvoir **tout créer** depuis l’interface ; le numéro reste **généré si utile**, **optionnel** si le cas est trop complexe — **objectif post-1.0.0** (unité de dossier).
|
||||
|
||||
---
|
||||
|
||||
## 7. Évolution structurelle post-1.0.0 — « unité de dossier »
|
||||
|
||||
### 7.1 Principe cible
|
||||
|
||||
| Aujourd’hui | Cible |
|
||||
|-------------|--------|
|
||||
| `numero_dossier` = clé de la famille | **Liens parent↔enfant** = vérité |
|
||||
| Famille déduite du graphe | **Unité de dossier** = regroupement **optionnel** |
|
||||
| Un numéro par inscription | Numéro **optionnel** ; plusieurs unités par personne possibles |
|
||||
|
||||
### 7.2 Exemple cible (cas M / A / B) — deux approches
|
||||
|
||||
**Approche A — unités de dossier séparées** *(piste initiale § 7)* :
|
||||
|
||||
```
|
||||
Unité 1 : numero 2026-000021 — M, A, α
|
||||
Unité 2 : sans numéro (ou 2026-000089) — M, B, β
|
||||
```
|
||||
|
||||
M appartient à **deux unités** ; A et B ne partagent pas la même unité. Peut impliquer **deux contextes de connexion** ou une agrégation côté M.
|
||||
|
||||
**Approche B — parcours gestionnaire « famille complexe »** *(préférée, § 4.4)* :
|
||||
|
||||
```
|
||||
Compte M → enfants α, β
|
||||
Compte A → enfant α
|
||||
Compte B → enfant β
|
||||
(liens enfants_parents ; pas de fusion A↔B)
|
||||
```
|
||||
|
||||
- **Un seul compte pour M** avec **les deux enfants**.
|
||||
- **A** et **B** isolés sur **leur** enfant respectif.
|
||||
- Création **uniquement** par le gestionnaire ; inscription publique inchangée (couple + co-parent classique).
|
||||
|
||||
Les deux approches supposent de **cesser de déduire une « famille » unique** pour les workflows lorsque les liens enfant par enfant divergent. L’approche B maximise l’UX du responsable central (M) sans dupliquer son identité.
|
||||
|
||||
### 7.5 Parcours gestionnaire — création « famille complexe »
|
||||
|
||||
#### 7.5.1 Objectif
|
||||
|
||||
Permettre au **gestionnaire** de monter un dossier où :
|
||||
|
||||
- **N responsables** (≥ 2, parents ou tuteurs) sont créés ou rattachés ;
|
||||
- chaque enfant est lié **explicitement** à un ou plusieurs responsables via `enfants_parents` ;
|
||||
- un responsable (ex. **M**) peut être lié à **plusieurs enfants** dont les **autres** responsables (A, B) ne partagent **pas** la garde.
|
||||
|
||||
#### 7.5.2 Règles produit
|
||||
|
||||
| Règle | Détail |
|
||||
|-------|--------|
|
||||
| **Accès** | Parcours **gestionnaire uniquement** (dashboard), pas inscription publique |
|
||||
| **Responsables** | Création ou rattachement de comptes `parent` ; qualification future sur le lien (§ 5) |
|
||||
| **Visibilité** | Chaque responsable ne voit que **ses** enfants (liens `enfants_parents`) |
|
||||
| **Co-parent UI** | Ne pas forcer « Parent 1 + co-parent unique » ; composition libre côté staff |
|
||||
| **Garde-fous** | Au moins un responsable par enfant ; pas de suppression enfant pour retirer un lien |
|
||||
|
||||
#### 7.5.3 Esquisse du parcours UI (gestionnaire)
|
||||
|
||||
1. **Créer ou identifier** le responsable principal (ex. M).
|
||||
2. **Ajouter d’autres responsables** (A, B, tuteur…) — comptes distincts.
|
||||
3. **Créer les enfants** (α, β…) et, pour chaque enfant, **cocher les responsables** rattachés.
|
||||
4. **Motivation / dossier** : texte global ou par enfant (à cadrer).
|
||||
5. **Validation** : par soumission ou par enfant, sans valider « toute la famille » d’un coup si A et B ne doivent pas être fusionnés.
|
||||
|
||||
#### 7.5.4 Impacts techniques majeurs
|
||||
|
||||
| Domaine | Changement |
|
||||
|---------|------------|
|
||||
| **Auth / API parent** | `GET` enfants, dashboard parent : filtre `enfants_parents` pour l’utilisateur courant |
|
||||
| **`getFamilyUserIds`** | Ne plus utiliser pour visibilité parent ; réservé au cas simple ou deprecated progressivement |
|
||||
| **Validation / refus** | Périmètre enfant ou responsable, pas fusion A+B via graphe |
|
||||
| **Reprise (#112)** | Token / périmètre = enfants liés au compte refusé, pas toute la composante connexe |
|
||||
| **Admin (#115–#138)** | Prérequis : rattachement/détachement déjà en place ; ce parcours **compose** ces briques |
|
||||
|
||||
#### 7.5.5 Lien avec v1.0.0
|
||||
|
||||
| Phase | Comportement |
|
||||
|-------|--------------|
|
||||
| **v1.0.0** | Contournement § 4.2 (2ᵉ email M) + composition manuelle gestionnaire (#115–#138) |
|
||||
| **Post-1.0.0** | Parcours § 7.5 + refonte visibilité / workflows |
|
||||
|
||||
### 7.3 Impacts workflows
|
||||
|
||||
| Flux | Adaptation future |
|
||||
|------|-------------------|
|
||||
| Validation / refus (#110) | Par **enfant / soumission**, pas par graphe global |
|
||||
| Reprise (#112) | Périmètre = enfants liés au compte, pas composante connexe |
|
||||
| Liste « à valider » | Par soumission ou par enfant |
|
||||
| Recherche gestionnaire | Numéro **ou** nom **ou** enfant |
|
||||
| Visibilité parent | **Uniquement** enfants via `enfants_parents` (approche B § 7.2) |
|
||||
|
||||
### 7.4 Migration
|
||||
|
||||
- **Court terme** : contournement § 4 + tickets admin.
|
||||
- **Moyen terme** : table **unité de dossier**, `numero_dossier` nullable.
|
||||
- **Long terme** : workflows branchés sur l’unité.
|
||||
- Dossiers **existants** (couple + enfants) : une unité = un numéro → **comportement actuel préservé**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Tickets Gitea associés
|
||||
|
||||
| Ticket | Sujet |
|
||||
|--------|--------|
|
||||
| #110 | Refus dossier (token reprise) — livré |
|
||||
| #112 | Reprise après refus — livré |
|
||||
| #115 / #116 | Rattachement parent — backend / front |
|
||||
| #129+ | Création dossier admin (parent / AM) |
|
||||
| #131 | Fiche parent éditable (dashboard) |
|
||||
| #137 | Onglet Enfants — liste globale |
|
||||
| #138 | Fiche enfant + liste dans fiche parent |
|
||||
| *(à créer)* | Epic « Unité de dossier / numéro optionnel » |
|
||||
| #139 | **Parcours gestionnaire « famille complexe »** (§ 7.5) — N responsables, visibilité par enfant |
|
||||
| *(à créer)* | Qualification responsable légal (combobox sur lien enfant) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Formulation type — release notes / doc gestionnaire (v1.0.0)
|
||||
|
||||
> **Familles recomposées ou responsabilités multiples**
|
||||
> Une même personne ne peut pas gérer proprement deux unités familiales distinctes (recompositions, plusieurs co-responsables successifs, tuteur/GP pour plusieurs contextes) avec **un seul compte**.
|
||||
> **Contournement v1.0.0** : second compte avec **email distinct** et second numéro de dossier.
|
||||
> S’applique à **tous les responsables inscrits** (couples HH/FF, tuteurs, grands-parents, etc.).
|
||||
> **Évolution post-1.0.0** : parcours **gestionnaire** « famille complexe » (un compte M, enfants α+β ; A et B limités à leur enfant) ; visibilité par `enfants_parents` ; workflows sans fusion graphe familial.
|
||||
|
||||
---
|
||||
|
||||
## 10. Historique des mises à jour
|
||||
|
||||
| Date | Auteur | Modification |
|
||||
|------|--------|--------------|
|
||||
| 2026-06-16 | Équipe / session produit | Création — synthèse réflexions famille, tutelle, v1.0.0 vs post-1.0.0 |
|
||||
| 2026-06-16 | Implémentation ch.6 | Back : `PATCH /parents/:id/fiche`, attach/detach enfant. Front : modale parent éditable, onglet Enfants, fiche enfant |
|
||||
| 2026-06-16 | Réflexion produit | § 4.4 / § 7.5 — parcours gestionnaire famille complexe : M un compte (α+β), A/B visibilité restreinte par enfant |
|
||||
|
||||
---
|
||||
|
||||
*Ce document sera enrichi au fil des décisions. Pour les décisions formelles archivées, voir aussi [24_DECISIONS-PROJET.md](./24_DECISIONS-PROJET.md).*
|
||||
@@ -276,9 +276,6 @@ export class Enfants {
|
||||
@Column({ name: 'consentement_photo', type: 'boolean', default: false })
|
||||
consentementPhoto: boolean;
|
||||
|
||||
@Column({ name: 'est_multiple', type: 'boolean', default: false })
|
||||
estMultiple: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: StatutEnfantType,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée.
|
||||
|
||||
> **Document complémentaire (juin 2026)** — réflexions sur le **modèle famille / numéro de dossier**, familles recomposées, tuteurs et responsables légaux : voir **[28 - Évolution famille et responsables](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md)**.
|
||||
|
||||
## 1. Gestion des Enfants
|
||||
|
||||
### Modifications à apporter dans la section "Création de compte parent"
|
||||
@@ -276,3 +278,28 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
|
||||
- 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.
|
||||
|
||||
## 9. Modèle famille, numéro de dossier et responsables légaux
|
||||
|
||||
### 9.1 Situation actuelle (v1.0.0)
|
||||
|
||||
- Un **numéro de dossier** par inscription (`AAAA-NNNNNN`), fortement utilisé dans les workflows (validation, refus, reprise).
|
||||
- **Un co-parent** par fiche `parents` ; affiliation réelle via `enfants_parents`.
|
||||
- La « famille » est **déduite** du graphe (co-parent + enfants partagés) — fusion parfois trop large en cas de recompositions.
|
||||
|
||||
### 9.2 Limitation v1.0.0 — familles recomposées
|
||||
|
||||
Cas type : même responsable M avec co-responsable A (enfant α) puis co-responsable B (enfant β). Le modèle actuel ne permet pas de séparer proprement **deux unités familiales** pour une même personne.
|
||||
|
||||
**Contournement accepté pour la release 1.0.0** : second compte avec **email distinct** et second numéro de dossier (procédure gestionnaire). S'applique aussi aux couples HH/FF, tuteurs, grands-parents, etc.
|
||||
|
||||
### 9.3 Évolutions post-1.0.0 (pistes)
|
||||
|
||||
| Sujet | Piste |
|
||||
|-------|--------|
|
||||
| **Unité de dossier** | Numéro **optionnel** ; plusieurs unités par personne |
|
||||
| **Affiliation** | Gestion admin des liens parent↔enfant (#115, #116, #138) |
|
||||
| **Qualification** | Combobox « lien responsable–enfant » (tuteur, GP, parent…) sur `enfants_parents` |
|
||||
| **UI admin** | Fiche parent éditable (#131), liste enfants en bas de fiche |
|
||||
|
||||
**Détail complet, scénarios, tickets et formulation release notes** : [28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md](./28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md).
|
||||
@@ -0,0 +1,127 @@
|
||||
# #131 — En-tête fiche parent : co-parent (note front → back)
|
||||
|
||||
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||
**Date :** 2026-06-01
|
||||
**Statut front :** livré (en-tête dynamique)
|
||||
**Modif backend demandée :** **aucune fonctionnelle** — ce document fixe le contrat attendu ; le back valide `co_parent` et masque les champs sensibles.
|
||||
|
||||
---
|
||||
|
||||
## 1. Comportement UI (front)
|
||||
|
||||
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
||||
|
||||
| Zone | Contenu |
|
||||
|------|---------|
|
||||
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
||||
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
||||
|
||||
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
||||
Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints consommés
|
||||
|
||||
| Méthode | Route | Usage front |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
|
||||
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
|
||||
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
|
||||
|
||||
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
|
||||
|
||||
---
|
||||
|
||||
## 3. Contrat JSON attendu pour `co_parent`
|
||||
|
||||
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
|
||||
|
||||
### Champs minimum utilisés pour le sous-titre
|
||||
|
||||
| Clé JSON | Usage |
|
||||
|----------|--------|
|
||||
| `co_parent` | Objet ou absent/`null` |
|
||||
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
|
||||
| `co_parent.prenom` | Affichage |
|
||||
| `co_parent.nom` | Affichage |
|
||||
|
||||
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
|
||||
|
||||
### Exemple de fragment de réponse (`GET /parents/:id`)
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "33333333-3333-3333-3333-333333333333",
|
||||
"numero_dossier": "2026-000042",
|
||||
"user": {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"email": "parent1@example.com",
|
||||
"prenom": "Paul",
|
||||
"nom": "PARENT",
|
||||
"statut": "actif",
|
||||
"telephone": "0601020304"
|
||||
},
|
||||
"co_parent": {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"email": "coparent1@example.com",
|
||||
"prenom": "Clara",
|
||||
"nom": "COPARENT",
|
||||
"role": "parent",
|
||||
"statut": "actif"
|
||||
},
|
||||
"parentChildren": []
|
||||
}
|
||||
```
|
||||
|
||||
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
|
||||
|
||||
---
|
||||
|
||||
## 4. État backend
|
||||
|
||||
### Relations (déjà en place)
|
||||
|
||||
- `findAll()` et `findOne(user_id)` chargent **`co_parent`** ;
|
||||
- FK : `parents.id_co_parent` → `utilisateurs.id` ;
|
||||
- inscription couple : les deux sens renseignés en principe (`auth.service.ts`).
|
||||
|
||||
### Livraison back (#131)
|
||||
|
||||
- `mapParentForApi` / `sanitizeUserForApi` : réponses `GET/PATCH/POST/DELETE` parents **sans** `password`, `token_creation_mdp`, `password_reset_*` sur `user` et `co_parent`.
|
||||
|
||||
**Checklist validation :**
|
||||
|
||||
- [x] `GET /parents/:id` renvoie `co_parent` peuplé quand `id_co_parent` est non null
|
||||
- [x] `GET /parents` (liste) inclut `co_parent`
|
||||
- [x] `prenom` / `nom` du co-parent présents
|
||||
- [x] Pas de fuite `password` / tokens sur `user` ni `co_parent`
|
||||
|
||||
---
|
||||
|
||||
## 5. Points d’attention (hors périmètre immédiat)
|
||||
|
||||
| Sujet | Détail |
|
||||
|-------|--------|
|
||||
| **Lien inverse** | Si B est co-parent de A (`A.id_co_parent = B`) mais `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Pas de résolution inverse côté front. |
|
||||
| **Familles > 2 adultes** | Sous-titre = co-parent direct (`id_co_parent`) uniquement. |
|
||||
| **Trou AM ↔ enfants en garde** | Pas de lien AM–enfant aujourd’hui (à documenter / traiter plus tard). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fichiers back concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `backend/src/routes/parents/parents.service.ts` | `findOne`, `findAll` + relations |
|
||||
| `backend/src/routes/parents/parents.controller.ts` | `mapParentForApi` sur les réponses |
|
||||
| `backend/src/routes/parents/parents.mapper.ts` | Sérialisation API |
|
||||
| `backend/src/common/utils/sanitize-user-for-api.ts` | Masquage secrets |
|
||||
| `backend/src/entities/parents.entity.ts` | relation `co_parent` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Références
|
||||
|
||||
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
|
||||
- Ticket Gitea **#131**
|
||||
@@ -0,0 +1,244 @@
|
||||
# #112 — Alignement front après évolution back (reprise dossier complet)
|
||||
|
||||
**Branche déployée :** `feature/112-reprise-apres-refus-front`
|
||||
**Commit back :** `d70577b1` — `feat(#112): reprise après refus — dossier complet GET/PATCH`
|
||||
**Date :** 2026-06-16
|
||||
|
||||
Ce document décrit le **contrat API réel** après extension du back, et ce que le front doit encore brancher pour exploiter le dossier complet (au-delà de l’identité seule).
|
||||
|
||||
---
|
||||
|
||||
## 1. Endpoints (inchangés côté URL)
|
||||
|
||||
| Méthode | Route | Auth |
|
||||
|---------|-------|------|
|
||||
| `GET` | `/api/v1/auth/reprise-dossier?token={uuid}` | Public |
|
||||
| `PATCH` | `/api/v1/auth/reprise-resoumettre` | Public |
|
||||
| `POST` | `/api/v1/auth/reprise-identify` | Public (inchangé) |
|
||||
|
||||
> **Note :** le ticket #111 parlait de `PUT` ; l’implémentation reste en **`PATCH`** (comme avant).
|
||||
|
||||
---
|
||||
|
||||
## 2. `GET /auth/reprise-dossier` — réponse enrichie
|
||||
|
||||
### Champs communs (toujours présents)
|
||||
|
||||
Identiques à avant : `id`, `email`, `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal`, `numero_dossier`, `role`, `photo_url`, `genre`, `situation_familiale`.
|
||||
|
||||
### Rôle `parent` (+ champs #119)
|
||||
|
||||
Alignés sur `DossierFamilleCompletDto` :
|
||||
|
||||
```json
|
||||
{
|
||||
"parents": [
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"email": "…",
|
||||
"prenom": "…",
|
||||
"nom": "…",
|
||||
"telephone": "…",
|
||||
"adresse": "…",
|
||||
"ville": "…",
|
||||
"code_postal": "…",
|
||||
"statut": "refuse",
|
||||
"co_parent_id": "uuid-parent-entity"
|
||||
}
|
||||
],
|
||||
"enfants": [
|
||||
{
|
||||
"id": "uuid-enfant",
|
||||
"first_name": "Emma",
|
||||
"last_name": "MARTIN",
|
||||
"genre": "F",
|
||||
"status": "actif",
|
||||
"birth_date": "2023-02-15T00:00:00.000Z",
|
||||
"due_date": null,
|
||||
"photo_url": "/uploads/photos/…",
|
||||
"consent_photo": true,
|
||||
"est_multiple": false
|
||||
}
|
||||
],
|
||||
"texte_motivation": "Nous recherchons…"
|
||||
}
|
||||
```
|
||||
|
||||
**Mapping front suggéré :**
|
||||
|
||||
| JSON back | Modèle / wizard parent |
|
||||
|-----------|-------------------------|
|
||||
| `parents[]` | `UserRegistrationData.parent1` + `parent2` (matcher par `email` ou ordre : titulaire = `id` du GET racine) |
|
||||
| `enfants[].first_name` / `last_name` | `ChildData.firstName` / `lastName` |
|
||||
| `enfants[].birth_date` | `ChildData.birthDate` (ISO → `DateTime`) |
|
||||
| `enfants[].due_date` | `ChildData.dueDate` (enfant `a_naitre`) |
|
||||
| `enfants[].status` | `actif` = né, `a_naitre` = à naître |
|
||||
| `enfants[].photo_url` | `ApiConfig.absoluteMediaUrl()` + conserver pour reprise sans re-upload |
|
||||
| `enfants[].id` | **Obligatoire** pour le PATCH (update par id) |
|
||||
| `enfants[].est_multiple` | `grossesse_multiple` si utilisé |
|
||||
| `texte_motivation` | étape présentation / motivation |
|
||||
|
||||
Si `numero_dossier` absent : pas de `parents[]` / `enfants[]` / `texte_motivation` (identité seule).
|
||||
|
||||
### Rôle `assistante_maternelle`
|
||||
|
||||
Champs racine + fiche pro (structure **aplatie**, pas de sous-objet `user`) :
|
||||
|
||||
```json
|
||||
{
|
||||
"consentement_photo": true,
|
||||
"date_naissance": "1985-03-12T00:00:00.000Z",
|
||||
"lieu_naissance_ville": "Paris",
|
||||
"lieu_naissance_pays": "France",
|
||||
"numero_agrement": "AGR-2024-12345",
|
||||
"nir": "123456789012345",
|
||||
"date_agrement": "2024-06-01T00:00:00.000Z",
|
||||
"nb_max_enfants": 4,
|
||||
"place_disponible": 2,
|
||||
"biographie": "…"
|
||||
}
|
||||
```
|
||||
|
||||
**Mapping `AmRegistrationData` :**
|
||||
|
||||
| JSON back | Champ front |
|
||||
|-----------|-------------|
|
||||
| `nb_max_enfants` | `capaciteAccueil` |
|
||||
| `place_disponible` | `placesDisponibles` |
|
||||
| `numero_agrement` | `numeroAgrement` |
|
||||
| `biographie` | `biographie` / présentation |
|
||||
| `photo_url` | déjà géré via `RepriseSession.photoUrl` |
|
||||
|
||||
---
|
||||
|
||||
## 3. `PATCH /auth/reprise-resoumettre` — body étendu
|
||||
|
||||
### Commun
|
||||
|
||||
```json
|
||||
{ "token": "uuid-reprise" }
|
||||
```
|
||||
|
||||
### Parent — champs à envoyer depuis le wizard
|
||||
|
||||
| Champ PATCH | Source wizard | Notes |
|
||||
|-------------|---------------|-------|
|
||||
| `prenom`, `nom`, `telephone`, `adresse`, `ville`, `code_postal` | Parent 1 (titulaire token) | Champs racine |
|
||||
| `co_parent_prenom`, `co_parent_nom`, `co_parent_telephone` | Parent 2 | |
|
||||
| `co_parent_meme_adresse`, `co_parent_adresse`, `co_parent_code_postal`, `co_parent_ville` | Parent 2 adresse | |
|
||||
| `texte_motivation` **ou** `presentation_dossier` | Étape motivation | Les deux alias acceptés |
|
||||
| `enfants[]` | Liste enfants | Voir ci-dessous |
|
||||
|
||||
**Structure `enfants[]` (miroir inscription + `id` obligatoire) :**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid-enfant-existant",
|
||||
"prenom": "Emma",
|
||||
"nom": "MARTIN",
|
||||
"date_naissance": "2023-02-15",
|
||||
"date_previsionnelle_naissance": null,
|
||||
"genre": "F",
|
||||
"photo_base64": "data:image/jpeg;base64,…",
|
||||
"photo_filename": "emma.jpg",
|
||||
"grossesse_multiple": false
|
||||
}
|
||||
```
|
||||
|
||||
- **v1 back :** update par `id` uniquement — pas de création/suppression d’enfant.
|
||||
- Si `id` inconnu pour ce dossier → **400** `Enfant inconnu pour ce dossier : {id}`.
|
||||
- Sans nouvelle photo : ne pas envoyer `photo_base64` (l’existant est conservé).
|
||||
|
||||
### AM — champs à envoyer
|
||||
|
||||
| Champ PATCH | Source |
|
||||
|-------------|--------|
|
||||
| Identité + `photo_url` ou `photo_base64` + `photo_filename` | Étapes 1–2 |
|
||||
| `consentement_photo`, `date_naissance`, `lieu_naissance_ville`, `lieu_naissance_pays` | Identité |
|
||||
| `numero_agrement`, `nir`, `date_agrement` | Pro |
|
||||
| `capacite_accueil`, `places_disponibles` | Pro |
|
||||
| `biographie` | Présentation |
|
||||
|
||||
Validation NIR identique à l’inscription si `nir` fourni.
|
||||
|
||||
### Réponse succès (nouveau format)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Dossier resoumis avec succès. Il est de nouveau en attente de validation.",
|
||||
"statut": "en_attente",
|
||||
"user_id": "uuid",
|
||||
"numero_dossier": "2026-000021"
|
||||
}
|
||||
```
|
||||
|
||||
Code HTTP : **200** (pas de corps `Users` brut comme l’ancien back).
|
||||
|
||||
### Effet métier
|
||||
|
||||
- **Parent :** tous les users `role=parent` avec le même `numero_dossier` passent en `en_attente` ; `token_reprise` invalidé sur **tous** (symétrique refus #110).
|
||||
- **AM :** un seul user.
|
||||
|
||||
### E-mail accusé resoumission (parent)
|
||||
|
||||
Après `PATCH` réussi, un e-mail est envoyé à **chaque parent** du dossier (`sendResoumissionPendingEmail`) :
|
||||
- confirmation de resoumission ;
|
||||
- rappel du **numéro de dossier** ;
|
||||
- mention « en attente de validation ».
|
||||
|
||||
Échec SMTP : logué, **ne bloque pas** la resoumission (même règle que l'inscription initiale).
|
||||
|
||||
---
|
||||
|
||||
## 4. Fichiers front à modifier (checklist)
|
||||
|
||||
### Modèles
|
||||
|
||||
- [ ] `lib/models/reprise_dossier.dart` — parser `parents[]`, `enfants[]`, `texte_motivation`, champs AM
|
||||
- [ ] Réutiliser ou mapper vers `DossierFamilleEnfant` / structures existantes (#119 admin) si possible
|
||||
|
||||
### Session / préremplissage
|
||||
|
||||
- [ ] `lib/services/reprise_session.dart`
|
||||
- `applyToParent` : remplir parent1/parent2 depuis `parents[]`, enfants, motivation
|
||||
- `applyToAm` : remplir tous les champs AM
|
||||
|
||||
### API
|
||||
|
||||
- [ ] `lib/services/auth_service.dart` — `resoumettreReprise()` : accepter body complet (parent + AM), pas seulement identité
|
||||
- [ ] Étendre `UserRegistrationData` / `AmRegistrationData` helpers `toReprisePatchBody()` si utile
|
||||
|
||||
### Écrans fin de parcours
|
||||
|
||||
- [ ] `parent_register_step5_screen.dart` — PATCH avec co-parent, enfants, motivation
|
||||
- [ ] `am_register_step4_screen.dart` — PATCH avec fiche AM complète
|
||||
|
||||
### Hors scope back (inchangé)
|
||||
|
||||
RIB / IBAN / attestation CAF (étape 5 wizard parent) : **non persistés** — rien à envoyer en reprise.
|
||||
|
||||
### Non implémenté front (ticket #112 initial)
|
||||
|
||||
- [ ] Modale login « J’ai un numéro de dossier » → `POST /auth/reprise-identify` (back prêt, front absent)
|
||||
|
||||
---
|
||||
|
||||
## 5. Tests manuels suggérés
|
||||
|
||||
1. Refuser un dossier parent complet (≥1 enfant + co-parent + motivation).
|
||||
2. Ouvrir le lien mail `/reprise?token=…`.
|
||||
3. Vérifier dans DevTools que le GET contient `enfants[]` et `texte_motivation`.
|
||||
4. Après branchement front : wizard prérempli sur toutes les étapes.
|
||||
5. Resoumettre → statut `en_attente` pour les deux parents ; dossier visible file validation admin (#119).
|
||||
|
||||
---
|
||||
|
||||
## 6. Références code back
|
||||
|
||||
```
|
||||
backend/src/routes/auth/dto/reprise-dossier.dto.ts
|
||||
backend/src/routes/auth/dto/resoumettre-reprise.dto.ts
|
||||
backend/src/routes/auth/dto/enfant-reprise.dto.ts
|
||||
backend/src/routes/auth/auth.service.ts → getRepriseDossier, resoumettreReprise
|
||||
backend/src/routes/parents/dto/dossier-famille-complet.dto.ts
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# #131 — Fiche AM éditable + affiliation enfants (note front → back)
|
||||
|
||||
**Ticket :** #131 (partie AM, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||
**Date :** 2026-06-01
|
||||
**Statut front :** modale livrée (2 onglets) — **API affiliation AM↔enfant à implémenter**
|
||||
|
||||
---
|
||||
|
||||
## 1. Comportement UI (front)
|
||||
|
||||
Modale `AdminAmEditModal` — même shell que la fiche parent (~930 px) :
|
||||
|
||||
| Onglet | Contenu |
|
||||
|--------|---------|
|
||||
| **Identité & professionnel** | `IdentityBlock` éditable + grille pro (agrément, ville résidence, capacité, places, NIR/agrément date en lecture seule, biographie, switch disponible) + gélule statut |
|
||||
| **Enfants accueillis** | Liste cartes enfants (réutilise `AdminChildrenAffiliationPanel` / `AdminEnfantUserCard`) + rattacher / détacher |
|
||||
|
||||
En-tête : prénom nom · sous-titre `Zone · Agrément · Dossier`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints consommés
|
||||
|
||||
### Déjà existants (partiels)
|
||||
|
||||
| Méthode | Route | Usage |
|
||||
|---------|-------|-------|
|
||||
| `GET` | `/api/v1/assistantes-maternelles` | Liste AM |
|
||||
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Détail (403 possible pour `administrateur` → fallback liste) |
|
||||
| `PATCH` | `/api/v1/users/:userId` | Identité + statut (admin / super_admin uniquement) |
|
||||
| `PATCH` | `/api/v1/assistantes-maternelles/:userId` | Champs pro (gestionnaire / super_admin) |
|
||||
|
||||
### À créer (recommandé — miroir parent #131 / #115)
|
||||
|
||||
| Méthode | Route | Rôle |
|
||||
|---------|-------|------|
|
||||
| `PATCH` | `/api/v1/assistantes-maternelles/:userId/fiche` | Mise à jour unifiée identité + pro + statut (`super_admin`, `gestionnaire`, `administrateur`) |
|
||||
| `POST` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Rattacher un enfant |
|
||||
| `DELETE` | `/api/v1/assistantes-maternelles/:userId/enfants/:enfantId` | Détacher un enfant |
|
||||
| `GET` | `/api/v1/assistantes-maternelles/:userId` | Inclure `amChildren[]` (relation enfant) |
|
||||
|
||||
Le front appelle déjà ces routes ; en l’absence de `PATCH …/fiche`, il tente un fallback `PATCH users` + `PATCH assistantes-maternelles` (échoue selon le rôle connecté).
|
||||
|
||||
---
|
||||
|
||||
## 3. Modèle de données affiliation AM ↔ enfant
|
||||
|
||||
**À définir côté BDD** (pas de table dédiée aujourd’hui, contrairement à `enfants_parents`) :
|
||||
|
||||
Proposition alignée parent :
|
||||
|
||||
```sql
|
||||
-- Piste : enfants_assistantes_maternelles
|
||||
CREATE TABLE enfants_assistantes_maternelles (
|
||||
id_am UUID NOT NULL REFERENCES utilisateurs(id) ON DELETE CASCADE,
|
||||
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (id_am, id_enfant)
|
||||
);
|
||||
```
|
||||
|
||||
Réponse API attendue sur `GET /assistantes-maternelles/:id` :
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid-am",
|
||||
"user": { "id": "…", "prenom": "Claire", "nom": "MARTIN", "statut": "actif" },
|
||||
"approval_number": "AGR-2024-12345",
|
||||
"residence_city": "Bezons",
|
||||
"max_children": 4,
|
||||
"places_available": 2,
|
||||
"available": true,
|
||||
"amChildren": [
|
||||
{
|
||||
"child": {
|
||||
"id": "uuid-enfant",
|
||||
"first_name": "Emma",
|
||||
"last_name": "MARTIN",
|
||||
"status": "actif",
|
||||
"birth_date": "2023-02-15"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Le front parse `amChildren` / `am_children` / `assistanteChildren` (même logique que `parentChildren`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Body `PATCH …/fiche` suggéré
|
||||
|
||||
```json
|
||||
{
|
||||
"nom": "MARTIN",
|
||||
"prenom": "Claire",
|
||||
"email": "claire@example.com",
|
||||
"telephone": "0612345678",
|
||||
"adresse": "5 place Bellecour",
|
||||
"ville": "Lyon",
|
||||
"code_postal": "69002",
|
||||
"statut": "actif",
|
||||
"approval_number": "AGR-2024-12345",
|
||||
"residence_city": "Lyon",
|
||||
"max_children": 4,
|
||||
"places_available": 2,
|
||||
"biography": "…",
|
||||
"available": true
|
||||
}
|
||||
```
|
||||
|
||||
NIR et date d’agrément : lecture seule dans la modale (modification hors périmètre admin v1).
|
||||
|
||||
---
|
||||
|
||||
## 5. Fichiers front concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `frontend/lib/widgets/admin/common/admin_am_edit_modal.dart` | Modale 2 onglets |
|
||||
| `frontend/lib/widgets/admin/common/admin_children_affiliation_panel.dart` | Liste enfants partagée parent/AM |
|
||||
| `frontend/lib/widgets/admin/common/admin_status_capsule.dart` | Gélule statut partagée |
|
||||
| `frontend/lib/models/assistante_maternelle_model.dart` | Parse champs pro + `amChildren` |
|
||||
| `frontend/lib/services/user_service.dart` | `getAssistanteMaternelle`, `updateAmFiche`, `attachEnfantToAm`, `detachEnfantFromAm` |
|
||||
| `frontend/lib/widgets/admin/assistante_maternelle_management_widget.dart` | Ouverture modale au clic Modifier |
|
||||
|
||||
---
|
||||
|
||||
## 6. Références
|
||||
|
||||
- Fiche parent : `PATCH /parents/:id/fiche`, `POST|DELETE /parents/:id/enfants/:enfantId`
|
||||
- Ticket Gitea **#131**, **#115**
|
||||
- `docs/archive/temporaires/TEMP_131-back-fiche-parent-co-parent.md`
|
||||
@@ -0,0 +1,124 @@
|
||||
# #131 — En-tête fiche parent : co-parent (note front → back)
|
||||
|
||||
**Ticket :** #131 (fiche parent dashboard, doc `28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1)
|
||||
**Date :** 2026-06-01
|
||||
**Statut front :** livré (en-tête dynamique)
|
||||
**Modif backend demandée :** **aucune** — ce document fixe le contrat attendu et invite à valider que l’existant le couvre.
|
||||
|
||||
---
|
||||
|
||||
## 1. Comportement UI (front)
|
||||
|
||||
Dans la modale **fiche parent** (`AdminParentEditModal`) :
|
||||
|
||||
| Zone | Contenu |
|
||||
|------|---------|
|
||||
| **Titre** | `prenom` + `nom` du parent affiché (plus le libellé fixe « Fiche parent ») |
|
||||
| **Sous-titre** | `Co-parent : {prenom} {nom}` — affiché **uniquement** si un co-parent est connu |
|
||||
|
||||
Le titre se met à jour en direct pendant l’édition des champs nom/prénom.
|
||||
Le sous-titre provient du co-parent **chargé depuis l’API** (pas saisi à la main dans la modale).
|
||||
|
||||
---
|
||||
|
||||
## 2. Endpoints consommés
|
||||
|
||||
| Méthode | Route | Usage front |
|
||||
|---------|-------|-------------|
|
||||
| `GET` | `/api/v1/parents` | Liste parents (onglet Parents) |
|
||||
| `GET` | `/api/v1/parents/:userId` | Rechargement fiche après rattachement/détachement enfant |
|
||||
| `PATCH` | `/api/v1/parents/:userId/fiche` | Sauvegarde identité + statut (inchangé) |
|
||||
|
||||
Rôles : `super_admin`, `gestionnaire`, `administrateur` (selon route).
|
||||
|
||||
---
|
||||
|
||||
## 3. Contrat JSON attendu pour `co_parent`
|
||||
|
||||
Le front parse `ParentModel.fromJson` avec la clé **`co_parent`** (snake_case), objet utilisateur imbriqué.
|
||||
|
||||
### Champs minimum utilisés pour le sous-titre
|
||||
|
||||
| Clé JSON | Usage |
|
||||
|----------|--------|
|
||||
| `co_parent` | Objet ou absent/`null` |
|
||||
| `co_parent.id` | Identifiant (futur lien cliquable éventuel) |
|
||||
| `co_parent.prenom` | Affichage |
|
||||
| `co_parent.nom` | Affichage |
|
||||
|
||||
Affichage front : `'{prenom} {nom}'.trim()` → libellé `Co-parent : …`.
|
||||
|
||||
### Exemple de fragment de réponse (`GET /parents/:id`)
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "33333333-3333-3333-3333-333333333333",
|
||||
"numero_dossier": "2026-000042",
|
||||
"user": {
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"email": "parent1@example.com",
|
||||
"prenom": "Paul",
|
||||
"nom": "PARENT",
|
||||
"statut": "actif",
|
||||
"telephone": "0601020304"
|
||||
},
|
||||
"co_parent": {
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"email": "coparent1@example.com",
|
||||
"prenom": "Clara",
|
||||
"nom": "COPARENT",
|
||||
"role": "parent",
|
||||
"statut": "actif"
|
||||
},
|
||||
"parentChildren": []
|
||||
}
|
||||
```
|
||||
|
||||
> **Note :** le front lit `user` (pas `utilisateur`). La doc `11_API.md` § Parents mentionne encore `utilisateur` / `id_co_parent` seul — le contrat **effectif** côté Nest/TypeORM est l’entité `Parents` sérialisée (`user`, `co_parent`, `parentChildren`, …).
|
||||
|
||||
---
|
||||
|
||||
## 4. État backend (à valider, pas à refaire)
|
||||
|
||||
D’après le code actuel (`parents.service.ts`) :
|
||||
|
||||
- `findAll()` et `findOne(user_id)` chargent déjà la relation **`co_parent`** ;
|
||||
- la FK métier est `parents.id_co_parent` → `utilisateurs.id` ;
|
||||
- à l’inscription couple, les deux sens sont en principe renseignés (`auth.service.ts`).
|
||||
|
||||
**Checklist validation back :**
|
||||
|
||||
- [ ] `GET /parents/:id` renvoie bien `co_parent` peuplé quand `id_co_parent` est non null
|
||||
- [ ] `GET /parents` (liste) inclut aussi `co_parent` (sous-titre disponible dès l’ouverture sans re-fetch)
|
||||
- [ ] Les champs `prenom` / `nom` du co-parent sont présents dans la réponse JSON
|
||||
|
||||
Si ces trois points passent en recette, **aucun changement backend n’est nécessaire** pour cette fonctionnalité.
|
||||
|
||||
---
|
||||
|
||||
## 5. Points d’attention (hors périmètre immédiat)
|
||||
|
||||
| Sujet | Détail |
|
||||
|-------|--------|
|
||||
| **Lien inverse** | Si le parent B est le co-parent de A (`A.id_co_parent = B`) mais que `B.id_co_parent` est `null`, le sous-titre **ne s’affichera pas** sur la fiche de B. Le front ne fait pas de résolution inverse. À traiter côté back **seulement si** des données legacy ont un lien à sens unique. |
|
||||
| **Familles > 2 adultes** | Le sous-titre n’affiche que le co-parent direct (`id_co_parent`). Les autres responsables liés uniquement via `enfants_parents` ne sont pas listés ici (cf. doc 28 §6). |
|
||||
| **Données sensibles** | Vérifier que la sérialisation de `co_parent` n’expose pas `password` / tokens (même remarque que pour `user`). |
|
||||
|
||||
---
|
||||
|
||||
## 6. Fichiers front concernés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `frontend/lib/models/parent_model.dart` | Parse `co_parent` → `AppUser? coParent` |
|
||||
| `frontend/lib/widgets/admin/common/admin_parent_edit_modal.dart` | Titre + sous-titre |
|
||||
| `frontend/lib/services/user_service.dart` | `getParents()` / `getParent()` |
|
||||
|
||||
---
|
||||
|
||||
## 7. Références
|
||||
|
||||
- `docs/28_EVOLUTION-FAMILLE-ET-RESPONSABLES.md` §6.1
|
||||
- `backend/src/routes/parents/parents.service.ts` — `findOne`, `findAll`
|
||||
- `backend/src/entities/parents.entity.ts` — relation `co_parent`
|
||||
- Ticket Gitea **#131**
|
||||
@@ -0,0 +1,104 @@
|
||||
# Analyse d’empreinte — P'titsPas
|
||||
|
||||
**Date :** 2026-07-22
|
||||
**Contexte :** snapshot pour mémoire (après alerte disque plein + purge cache Gitea)
|
||||
**Périmètre :** application `jmartin/petitspas` déployée sur le VPS (`/home/deploy/dev/ptitspas-app`)
|
||||
|
||||
---
|
||||
|
||||
## 1. Lignes de code
|
||||
|
||||
Comptage brut (`wc -l`), hors `node_modules` / builds / `.dart_tool`.
|
||||
|
||||
| Zone | Lignes | Fichiers |
|
||||
|------|--------|----------|
|
||||
| **Frontend** (Dart `frontend/lib/`) | ~29 800 | 141 |
|
||||
| **Backend** (TypeScript `backend/src/`) | ~10 000 | 141 |
|
||||
| **BDD** (SQL sous `database/`) | ~1 250 | ~15 |
|
||||
| **Total** | **~41 000** | |
|
||||
|
||||
### Frontend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `widgets/` | ~18 400 |
|
||||
| `screens/` | ~5 000 |
|
||||
| `services/` | ~2 300 |
|
||||
| `models/` | ~2 200 |
|
||||
| `utils/` | ~1 400 |
|
||||
| reste | ~500 |
|
||||
|
||||
### Backend (détail)
|
||||
|
||||
| Dossier | Lignes |
|
||||
|---------|--------|
|
||||
| `routes/` | ~6 500 |
|
||||
| `modules/` | ~1 700 |
|
||||
| `entities/` | ~1 000 |
|
||||
| `common/` + `config/` | ~400 |
|
||||
|
||||
### BDD (détail)
|
||||
|
||||
| Élément | Lignes |
|
||||
|---------|--------|
|
||||
| `BDD.sql` (schéma) | ~470 |
|
||||
| seeds | ~300 |
|
||||
| migrations / patches | ~280 |
|
||||
| tests SQL | ~205 |
|
||||
|
||||
---
|
||||
|
||||
## 2. Empreinte disque — runtime (Docker)
|
||||
|
||||
| Composant | Taille | Notes |
|
||||
|-----------|--------|--------|
|
||||
| Image `ptitspas-app-backend` | ~300 Mo | NestJS |
|
||||
| Image `ptitspas-app-frontend` | ~122 Mo | Flutter web + Nginx |
|
||||
| Image `postgres:17` | ~454 Mo | |
|
||||
| Image `dpage/pgadmin4` | ~534 Mo | optionnel |
|
||||
| Volume `postgres_data` | ~49 Mo | données BDD live |
|
||||
| Volume `backend_uploads` | ~20 Mo | photos |
|
||||
| Volume `backend_documents_legaux` | ~0 | |
|
||||
| **Stack complète (avec pgAdmin)** | **~1,5 Go** | |
|
||||
| **Stack prod sans pgAdmin** | **~945 Mo** | |
|
||||
|
||||
---
|
||||
|
||||
## 3. Empreinte disque — code source
|
||||
|
||||
| Élément | Taille |
|
||||
|---------|--------|
|
||||
| Clone `/home/deploy/dev/ptitspas-app` | ~701 Mo |
|
||||
| dont `backend/node_modules` | ~354 Mo |
|
||||
| dont `frontend` (+ `.dart_tool`) | ~114 Mo |
|
||||
| **Code utile** (hors deps / `.git` / builds) | **~51 Mo** |
|
||||
| Repo Gitea `petitspas.git` | ~109 Mo |
|
||||
| Dump `database/BDD.sql` | ~19 Ko |
|
||||
|
||||
---
|
||||
|
||||
## 4. Pour (re)déployer
|
||||
|
||||
Minimum requis :
|
||||
|
||||
1. Images custom backend + frontend (~422 Mo), ou code + build Docker
|
||||
2. Image `postgres:17` (~454 Mo)
|
||||
3. Volumes persistants (~70 Mo au snapshot)
|
||||
4. Optionnel : pgAdmin (~534 Mo)
|
||||
|
||||
**Ordre de grandeur :** ~1 Go pour faire tourner l’app en prod (sans pgAdmin).
|
||||
|
||||
---
|
||||
|
||||
## 5. Notes infra du jour (lié)
|
||||
|
||||
- Disque VPS passé de **100 %** à ~**44 %** après :
|
||||
- purge cache Docker / journals
|
||||
- purge officielle Gitea `delete_repo_archives` (~24 Go de zip/bundle `petitspas`)
|
||||
- Crons Gitea activés :
|
||||
- `archive_cleanup` @midnight (`OLDER_THAN = 24h`)
|
||||
- `delete_repo_archives` @weekly
|
||||
|
||||
---
|
||||
|
||||
*Fichier généré pour historique projet — ne pas considérer comme métrique CI automatisée.*
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user