Compare commits

..
Author SHA1 Message Date
jmartinandCursor 7f8104e7e4 fix(auth): correctif login et parsing user (version stable)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 15:54:24 +01:00
jmartinandCursor c4d93ee458 Merge develop into master (squash)
- Ticket #92: endpoint GET /gestionnaires, dashboard admin données réelles
- Seed données de test, script reset-and-seed-db.sh
- Modèles frontend (Parent, AM), user_service, widgets admin

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 22:55:38 +01:00
251 changed files with 3337 additions and 24745 deletions
-2
View File
@@ -12,7 +12,6 @@ dist/
.env.*.local .env.*.local
# IDE # IDE
.cursor/
.idea/ .idea/
.vscode/ .vscode/
*.swp *.swp
@@ -33,7 +32,6 @@ yarn-error.log*
*.sqlite3 *.sqlite3
# Flutter # Flutter
**/android/local.properties
.flutter-plugins .flutter-plugins
.flutter-plugins-dependencies .flutter-plugins-dependencies
.pub-cache/ .pub-cache/
-8
View File
@@ -22,13 +22,5 @@ JWT_EXPIRATION_TIME=7d
# Environnement # Environnement
NODE_ENV=development NODE_ENV=development
# Photos inscription (fichiers écrits depuis base64). Préférer un chemin ABSOLU.
# Local : laisser vide → ./uploads/photos (relatif au cwd du processus).
# Docker (docker-compose) : UPLOAD_PHOTOS_DIR=/app/uploads/photos + volume nommé (voir docker-compose.yml).
# UPLOAD_PHOTOS_DIR=
#
# Reverse proxy : si Nginx devant lAPI, augmenter la taille du corps (ex. client_max_body_size 16m;).
# Traefik en reverse proxy simple ne limite en général pas le corps ; si middleware buffering, prévoir ~16 Mo+.
# Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front # Log de chaque appel API (mode debug) — mettre à true pour tracer les requêtes front
# LOG_API_REQUESTS=true # LOG_API_REQUESTS=true
+2 -3
View File
@@ -32,9 +32,8 @@ COPY --from=builder /app/dist ./dist
RUN addgroup -g 1001 -S nodejs RUN addgroup -g 1001 -S nodejs
RUN adduser -S nestjs -u 1001 RUN adduser -S nestjs -u 1001
# Dossiers écriture runtime (nestjs non-root) : photos + documents légaux (PDF) # Créer le dossier uploads et donner les permissions
RUN mkdir -p /app/uploads/photos /app/documents/legaux && \ RUN mkdir -p /app/uploads/photos && chown -R nestjs:nodejs /app/uploads
chown -R nestjs:nodejs /app/uploads /app/documents
USER nestjs USER nestjs
+1 -7
View File
@@ -3,12 +3,6 @@
"collection": "@nestjs/schematics", "collection": "@nestjs/schematics",
"sourceRoot": "src", "sourceRoot": "src",
"compilerOptions": { "compilerOptions": {
"deleteOutDir": true, "deleteOutDir": true
"assets": [
{
"include": "**/*.hbs",
"watchAssets": true
}
]
} }
} }
+20 -179
View File
@@ -22,11 +22,8 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"handlebars": "^4.7.8",
"joi": "^18.0.0", "joi": "^18.0.0",
"mapped-types": "^0.0.1", "mapped-types": "^0.0.1",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
@@ -42,11 +39,8 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/handlebars": "^4.1.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
"@types/nodemailer": "^6.4.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"eslint": "^9.18.0", "eslint": "^9.18.0",
@@ -2468,82 +2462,6 @@
"@nestjs/core": "^11.0.0" "@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": { "node_modules/@nestjs/schematics": {
"version": "11.0.7", "version": "11.0.7",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz",
@@ -3695,17 +3613,6 @@
"@types/send": "*" "@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": { "node_modules/@types/http-errors": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
@@ -3781,16 +3688,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/mysql": {
"version": "2.15.27", "version": "2.15.27",
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz", "resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz",
@@ -3809,16 +3706,6 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/passport": {
"version": "1.0.17", "version": "1.0.17",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
@@ -5691,56 +5578,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "1.6.2", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [ "engines": [
"node >= 0.8" "node >= 6.0"
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"buffer-from": "^1.0.0", "buffer-from": "^1.0.0",
"inherits": "^2.0.3", "inherits": "^2.0.3",
"readable-stream": "^2.2.2", "readable-stream": "^3.0.2",
"typedarray": "^0.0.6" "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": { "node_modules/consola": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -5807,6 +5658,7 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cors": { "node_modules/cors": {
@@ -7152,6 +7004,7 @@
"version": "4.7.8", "version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minimist": "^1.2.5", "minimist": "^1.2.5",
@@ -7173,6 +7026,7 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -9035,22 +8889,21 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "1.4.5-lts.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"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", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.0.0", "busboy": "^1.6.0",
"concat-stream": "^1.5.2", "concat-stream": "^2.0.0",
"mkdirp": "^0.5.4", "mkdirp": "^0.5.6",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
"type-is": "^1.6.4", "type-is": "^1.6.18",
"xtend": "^4.0.0" "xtend": "^4.0.2"
}, },
"engines": { "engines": {
"node": ">= 6.0.0" "node": ">= 10.16.0"
} }
}, },
"node_modules/multer/node_modules/media-typer": { "node_modules/multer/node_modules/media-typer": {
@@ -9142,6 +8995,7 @@
"version": "2.6.2", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-abort-controller": { "node_modules/node-abort-controller": {
@@ -9195,15 +9049,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -9861,12 +9706,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11695,6 +11534,7 @@
"version": "3.19.3", "version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"bin": { "bin": {
@@ -12173,6 +12013,7 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
+1 -4
View File
@@ -19,8 +19,7 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:cov": "jest --coverage", "test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e": "jest --config ./test/jest-e2e.json"
"test:api-dossiers": "node scripts/test-api-dossiers.js"
}, },
"dependencies": { "dependencies": {
"@nestjs/common": "^11.1.6", "@nestjs/common": "^11.1.6",
@@ -36,7 +35,6 @@
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.2", "class-validator": "^0.14.2",
"handlebars": "^4.7.8",
"joi": "^18.0.0", "joi": "^18.0.0",
"mapped-types": "^0.0.1", "mapped-types": "^0.0.1",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
@@ -56,7 +54,6 @@
"@nestjs/testing": "^11.0.1", "@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/handlebars": "^4.1.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/multer": "^1.4.12", "@types/multer": "^1.4.12",
"@types/node": "^22.10.7", "@types/node": "^22.10.7",
-107
View File
@@ -1,107 +0,0 @@
/**
* Commentaire de clôture + fermeture issue Gitea #120.
* Usage: node backend/scripts/close-gitea-issue-120.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
const ISSUE = 120;
const REPO = 'jmartin/petitspas';
const body = `## Fermeture ticket #120 — livré sur \`develop\`
Branche **\`feature/120-inscription-am-photo\`** mergée dans **\`develop\`** (livraison : inscription AM alignée parents + panneau validation gestionnaire).
### Inscription AM (alignement parents)
- Photo, consentement, lieux de naissance : parcours et API alignés sur le modèle parents (DTO, entité user, migration SQL, écrans inscription AM, \`registration_photo_slot\`, scripts de test Node).
### Panneau gestionnaire — onglet « À valider »
- Bouton **Ouvrir** : visible au **survol** (même principe que les cartes admin), **icône centrée** sur la ligne et **taille doublée** (\`iconSize\` 34).
### Wizard validation dossier AM
- Titres : **Identité et coordonnées** · **Dossier professionnel** (au-dessus des champs à droite, pas de la photo) · **Présentation**.
- **Photo** à gauche (ratio identité 35×45) ; **grille droite** \`[2,2,2,2]\` : NIR | date de naissance, ville | pays de naissance, n° agrément | date d'agrément, capacité | places.
- **\`AppUser\`** : \`date_naissance\`, \`lieu_naissance_ville\`, \`lieu_naissance_pays\` ; affichage dates **\`dd/MM/yyyy\`** (\`formatIsoDateFr\`).
- **\`ValidationDetailSection\`** : titre **optionnel** (wizard AM).
### Wizard validation famille
- Étape 4 : titre **« Présentation »** (plus « Présentation / Motivation »).
### Nettoyage
- Suppression des **\`debugPrint\`** liés aux médias / images / chargement dossier (\`api_config\`, \`auth_network_image\`, \`dossier_unifie\`, \`user_service\`, carte enfant validation).
---
*Issue fermée après merge sur \`develop\` et recette.*`;
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé : .gitea-token ou GITEA_TOKEN');
process.exit(1);
}
function request(method, apiPath, payloadObj) {
const payload = payloadObj ? JSON.stringify(payloadObj) : null;
return new Promise((resolve, reject) => {
const opts = {
hostname: 'git.ptits-pas.fr',
path: `/api/v1/repos/${REPO}${apiPath}`,
method,
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
if (res.statusCode !== 200 && res.statusCode !== 201) {
reject(new Error(`HTTP ${res.statusCode}: ${d}`));
return;
}
try {
resolve(d ? JSON.parse(d) : {});
} catch (_) {
resolve({});
}
});
});
req.on('error', reject);
if (payload) req.write(payload);
req.end();
});
}
(async () => {
try {
console.log(`POST commentaire issue #${ISSUE}...`);
await request('POST', `/issues/${ISSUE}/comments`, { body });
console.log('Commentaire publié.');
console.log(`PATCH fermeture issue #${ISSUE}...`);
await request('PATCH', `/issues/${ISSUE}`, { state: 'closed' });
console.log(`Issue #${ISSUE} fermée.`);
} catch (e) {
console.error(e.message || e);
process.exit(1);
}
})();
@@ -1,95 +0,0 @@
/**
* Crée l'issue Gitea "[Frontend] Inscription Parent Branchement soumission formulaire à l'API"
* Usage: node backend/scripts/create-gitea-issue-parent-api.js
* Token : .gitea-token (racine du dépôt), sinon GITEA_TOKEN, sinon
* docs/27_BRIEFING-FRONTEND.md (voir docs/26_GITEA-API.md)
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) {
token = fs.readFileSync(tokenFile, 'utf8').trim();
}
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error(
'Token non trouvé : créer .gitea-token à la racine ou export GITEA_TOKEN (voir docs/26_GITEA-API.md)',
);
process.exit(1);
}
const body = `## Description
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale puis redirige vers le login.
**Estimation** : 4h | **Labels** : frontend, p3, auth, cdc
## Tâches
- [ ] Créer un service ou méthode (ex. AuthService.registerParent) appelant POST /api/v1/auth/register/parent
- [ ] Construire le body (DTO) à partir de UserRegistrationData (parent1, parent2, children, motivationText, CGU) en cohérence avec le backend (#18)
- [ ] Dans ParentRegisterStep5Screen, au clic « Soumettre » : appel API puis modale + redirection ou message d'erreur
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
## Référence
20_WORKFLOW-CREATION-COMPTE.md § Étape 3 Inscription d'un parent, backend #18`;
const payload = JSON.stringify({
title: "[Frontend] Inscription Parent Branchement soumission formulaire à l'API",
body,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues',
method: 'POST',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const o = JSON.parse(d);
if (o.number) {
console.log('NUMBER:', o.number);
console.log('URL:', o.html_url);
} else {
console.error('Erreur API:', o.message || d);
process.exit(1);
}
} catch (e) {
console.error('Réponse:', d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payload);
req.end();
-67
View File
@@ -1,67 +0,0 @@
/**
* Liste toutes les issues Gitea (ouvertes + fermées) pour jmartin/petitspas.
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé');
process.exit(1);
}
function get(path) {
return new Promise((resolve, reject) => {
const opts = { hostname: 'git.ptits-pas.fr', path, method: 'GET', headers: { Authorization: 'token ' + token } };
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try { resolve(JSON.parse(d)); } catch (e) { reject(e); }
});
});
req.on('error', reject);
req.end();
});
}
async function main() {
const seen = new Map();
for (const state of ['open', 'closed']) {
for (let page = 1; ; page++) {
const raw = await get('/api/v1/repos/jmartin/petitspas/issues?state=' + state + '&limit=50&page=' + page + '&type=issues');
if (raw && raw.message && !Array.isArray(raw)) {
console.error('API:', raw.message);
process.exit(1);
}
const list = Array.isArray(raw) ? raw : [];
for (const i of list) {
if (!i.pull_request) seen.set(i.number, { number: i.number, title: i.title, state: i.state });
}
if (list.length < 50) break;
}
}
const all = [...seen.values()].sort((a, b) => a.number - b.number);
console.log(JSON.stringify(all, null, 2));
}
main().catch((e) => { console.error(e); process.exit(1); });
-122
View File
@@ -1,122 +0,0 @@
#!/usr/bin/env node
/**
* Test API GET /dossiers/:numeroDossier (dossier unifié AM ou famille).
*
* Prérequis : backend démarré (npm run start:dev dans backend/).
*
* Usage:
* node scripts/test-api-dossiers.js
* NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy NUMERO_DOSSIER=2026-000001 node scripts/test-api-dossiers.js
*
* Sans TEST_EMAIL/TEST_PASSWORD : 401 sur les routes protégées.
* NUMERO_DOSSIER : optionnel ; si absent, utilise le premier numero_dossier de pending-families (avec token).
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
const TEST_EMAIL = process.env.TEST_EMAIL;
const TEST_PASSWORD = process.env.TEST_PASSWORD;
const NUMERO_DOSSIER = process.env.NUMERO_DOSSIER;
async function request(method, path, body = null, token = null) {
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
};
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
return { status: res.status, data };
}
async function main() {
console.log('Base URL:', BASE_URL);
console.log('Numéro dossier (env):', NUMERO_DOSSIER ?? '(sera déduit si token fourni)');
console.log('');
let token = null;
if (TEST_EMAIL && TEST_PASSWORD) {
console.log('1. Login...');
const loginRes = await request('POST', '/auth/login', {
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (loginRes.status !== 200 && loginRes.status !== 201) {
console.log(' Échec login:', loginRes.status, loginRes.data);
process.exit(1);
}
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
if (!token) {
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
process.exit(1);
}
console.log(' OK, token reçu.');
console.log('');
} else {
console.log('TEST_EMAIL / TEST_PASSWORD non définis : GET /dossiers/:numero nécessite un token (401 attendu).');
console.log('');
}
let numeroDossier = NUMERO_DOSSIER;
if (!numeroDossier && token) {
console.log('2. Récupération d\'un numéro de dossier (GET /parents/pending-families)...');
const pendingRes = await request('GET', '/parents/pending-families', null, token);
if (pendingRes.status === 200 && Array.isArray(pendingRes.data) && pendingRes.data.length > 0) {
numeroDossier = pendingRes.data[0].numero_dossier || null;
console.log(' Premier numero_dossier:', numeroDossier);
} else {
console.log(' Aucune famille en attente ou erreur. Utilisez NUMERO_DOSSIER=2026-000001');
}
console.log('');
}
if (!numeroDossier) {
numeroDossier = '2026-000001';
console.log('2. Pas de numéro fourni, test avec numéro par défaut:', numeroDossier);
} else {
console.log('2. GET /dossiers/' + encodeURIComponent(numeroDossier));
}
const dossierRes = await request(
'GET',
'/dossiers/' + encodeURIComponent(numeroDossier),
null,
token
);
console.log(' Status:', dossierRes.status);
if (dossierRes.status === 200 && dossierRes.data) {
const d = dossierRes.data;
console.log(' type:', d.type);
console.log(' dossier (clés):', d.dossier ? Object.keys(d.dossier) : '-');
if (d.dossier && Array.isArray(d.dossier.enfants)) {
console.log(' enfants:', d.dossier.enfants.length);
d.dossier.enfants.forEach((e, i) => {
console.log(
` [${i + 1}] id=${e.id} first_name=${e.first_name} last_name=${e.last_name} birth_date=${e.birth_date} gender=${e.gender} genre=${e.genre} status=${e.status}`
);
});
}
console.log('');
console.log('Réponse brute (dossier):');
console.log(JSON.stringify(d.dossier, null, 2));
} else {
console.log(' Réponse:', JSON.stringify(dossierRes.data, null, 2));
}
console.log('');
console.log('Fin du test.');
}
main().catch((err) => {
console.error('Erreur:', err.message || err);
process.exit(1);
});
-110
View File
@@ -1,110 +0,0 @@
#!/usr/bin/env node
/**
* Test des endpoints "comptes en attente" (ticket #107).
*
* Prérequis : backend démarré (npm run start:dev dans backend/).
*
* Usage:
* node scripts/test-pending-api.js
* TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
* BASE_URL=https://app.ptits-pas.fr/api/v1 TEST_EMAIL=xxx TEST_PASSWORD=yyy node scripts/test-pending-api.js
*
* Sans TEST_EMAIL/TEST_PASSWORD : les GET protégés renverront 401 (normal).
* Avec un compte gestionnaire ou admin : affiche les listes en attente.
*/
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000/api/v1';
const TEST_EMAIL = process.env.TEST_EMAIL;
const TEST_PASSWORD = process.env.TEST_PASSWORD;
async function request(method, path, body = null, token = null) {
const url = path.startsWith('http') ? path : `${BASE_URL}${path}`;
const opts = {
method,
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
};
if (token) opts.headers.Authorization = `Bearer ${token}`;
if (body) opts.body = JSON.stringify(body);
const res = await fetch(url, opts);
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch (_) {
data = text;
}
return { status: res.status, data };
}
async function main() {
console.log('Base URL:', BASE_URL);
console.log('');
let token = null;
if (TEST_EMAIL && TEST_PASSWORD) {
console.log('1. Login...');
const loginRes = await request('POST', '/auth/login', {
email: TEST_EMAIL,
password: TEST_PASSWORD,
});
if (loginRes.status !== 200 && loginRes.status !== 201) {
console.log(' Échec login:', loginRes.status, loginRes.data);
process.exit(1);
}
token = loginRes.data?.access_token ?? loginRes.data?.accessToken ?? null;
if (!token) {
console.log(' Réponse login sans token:', JSON.stringify(loginRes.data, null, 2));
process.exit(1);
}
console.log(' OK, token reçu.');
console.log('');
} else {
console.log('TEST_EMAIL / TEST_PASSWORD non définis : les appels protégés vont renvoyer 401.');
console.log('Exemple: TEST_EMAIL=admin@example.com TEST_PASSWORD=xxx node scripts/test-pending-api.js');
console.log('');
}
console.log('2. GET /users/pending?role=assistante_maternelle');
const pendingUsersRes = await request(
'GET',
'/users/pending?role=assistante_maternelle',
null,
token
);
console.log(' Status:', pendingUsersRes.status);
if (pendingUsersRes.status === 200) {
const list = Array.isArray(pendingUsersRes.data) ? pendingUsersRes.data : [];
console.log(' Nombre d\'utilisateurs en attente (AM):', list.length);
list.forEach((u, i) => {
console.log(
` [${i + 1}] id=${u.id} email=${u.email} role=${u.role} statut=${u.statut} numero_dossier=${u.numero_dossier ?? '-'}`
);
});
} else {
console.log(' Réponse:', JSON.stringify(pendingUsersRes.data, null, 2));
}
console.log('');
console.log('3. GET /parents/pending-families');
const pendingFamiliesRes = await request('GET', '/parents/pending-families', null, token);
console.log(' Status:', pendingFamiliesRes.status);
if (pendingFamiliesRes.status === 200) {
const list = Array.isArray(pendingFamiliesRes.data) ? pendingFamiliesRes.data : [];
console.log(' Nombre de familles en attente:', list.length);
list.forEach((f, i) => {
console.log(
` [${i + 1}] libelle=${f.libelle} parentIds=${JSON.stringify(f.parentIds)} numero_dossier=${f.numero_dossier ?? '-'}`
);
});
} else {
console.log(' Réponse:', JSON.stringify(pendingFamiliesRes.data, null, 2));
}
console.log('');
console.log('Fin du test.');
}
main().catch((err) => {
console.error('Erreur:', err.message || err);
process.exit(1);
});
+14 -19
View File
@@ -1,32 +1,27 @@
#!/bin/bash #!/bin/bash
# Inscription AM complète : jeux officiels alignés sur database/seed/03_seed_test_data.sql # Test POST /auth/register/am (ticket #90)
# node tests/scripts/register-am-dubois-test.mjs [BASE_URL]
# node tests/scripts/register-am-mansouri-test.mjs [BASE_URL]
#
# Smoke curl (email + NIR jetables, hors seed — ne pas mélanger avec Marie / Fatima) :
# Usage: ./scripts/test-register-am.sh [BASE_URL] # Usage: ./scripts/test-register-am.sh [BASE_URL]
# Exemple: ./scripts/test-register-am.sh http://localhost:3000/api/v1 # Exemple: ./scripts/test-register-am.sh https://app.ptits-pas.fr/api/v1
# ./scripts/test-register-am.sh http://localhost:3000/api/v1
BASE_URL="${1:-http://localhost:3000/api/v1}" BASE_URL="${1:-http://localhost:3000/api/v1}"
echo "POST $BASE_URL/auth/register/am (profil smoke, pas les AM du seed)" echo "Testing POST $BASE_URL/auth/register/am"
echo "---" echo "---"
curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \ curl -s -w "\n\nHTTP %{http_code}\n" -X POST "$BASE_URL/auth/register/am" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"email": "smoke.am.curl@ptits-pas.fr", "email": "marie.dupont.test@ptits-pas.fr",
"prenom": "Smoke", "prenom": "Marie",
"nom": "CURLTEST", "nom": "DUPONT",
"telephone": "0612345678", "telephone": "0612345678",
"adresse": "1 rue Smoke", "adresse": "1 rue Test",
"code_postal": "95870", "code_postal": "75001",
"ville": "Bezons", "ville": "Paris",
"consentement_photo": false, "consentement_photo": true,
"date_naissance": "1986-12-15", "nir": "123456789012345",
"nir": "186127500100279", "numero_agrement": "AGR-2024-001",
"numero_agrement": "AGR-SMOKE-CURL-001", "capacite_accueil": 4,
"capacite_accueil": 3,
"places_disponibles": 2,
"acceptation_cgu": true, "acceptation_cgu": true,
"acceptation_privacy": true "acceptation_privacy": true
}' }'
@@ -1,100 +0,0 @@
/**
* Met à jour l'issue Gitea #119 : endpoint unifié GET /dossiers/:numeroDossier (option A)
* Usage: node backend/scripts/update-gitea-issue-119-dossiers.js
* Token : .gitea-token (racine), GITEA_TOKEN, ou docs/27_BRIEFING-FRONTEND.md
*/
const https = require('https');
const fs = require('fs');
const path = require('path');
const repoRoot = path.join(__dirname, '../..');
let token = process.env.GITEA_TOKEN;
if (!token) {
try {
const tokenFile = path.join(repoRoot, '.gitea-token');
if (fs.existsSync(tokenFile)) token = fs.readFileSync(tokenFile, 'utf8').trim();
} catch (_) {}
}
if (!token) {
try {
const briefing = fs.readFileSync(
path.join(repoRoot, 'docs/27_BRIEFING-FRONTEND.md'),
'utf8',
);
const m = briefing.match(/Token:\s*(giteabu_[a-f0-9]+)/);
if (m) token = m[1].trim();
} catch (_) {}
}
if (!token) {
console.error('Token non trouvé');
process.exit(1);
}
const body = `## Besoin
Un **seul** endpoint **GET par numéro de dossier** qui renvoie le dossier complet, **AM ou famille** selon le numéro. Clé unique = numéro de dossier (usage : modale de validation, consultation gestionnaire, reprise, etc.).
**Option A Endpoint unifié**
- **Route** : \`GET /api/v1/dossiers/:numeroDossier\` (ou \`GET /dossiers/:numeroDossier\` selon préfixe API).
- Le backend détermine si le numéro appartient à une **AM** ou à une **famille** (ex. lookup \`users\` / \`parents\` / \`assistantes_maternelles\`).
- **Réponse** avec discriminent :
- \`{ type: 'family', dossier: { numero_dossier, parents, enfants, presentation } }\`
- \`{ type: 'am', dossier: { numero_dossier, user, ... } }\` (fiche AM complète, champs utiles sans secrets)
- **Rôles** : SUPER_ADMIN, ADMINISTRATEUR, GESTIONNAIRE.
- **Réponses** : 200 (dossier), 403, 404 (numéro inconnu).
Aucun filtre par statut : on renvoie le dossier s'il existe ; le front affiche Valider/Refuser selon le statut.
**Labels suggérés** : backend, api, dossiers, gestionnaire
---
## Implémentation
- **Nouveau module ou route** : \`GET /dossiers/:numeroDossier\`.
- **Service** : trouver qui possède ce \`numero_dossier\` (famille → \`parents\`, AM → \`users\` + \`assistantes_maternelles\`). Appeler la logique existante dossier-famille ou construire le payload AM, puis retourner \`{ type, dossier }\`.
- **Réutiliser** : la logique actuelle \`GET /parents/dossier-famille/:numeroDossier\` peut être appelée en interne pour \`type: 'family'\` ; ajouter une branche \`type: 'am'\` avec un DTO « dossier AM complet ».
- DTO(s) : garder \`DossierFamilleCompletDto\` pour la famille ; ajouter un DTO pour le dossier AM (user sans secrets + infos AM). Réponse unifiée : \`{ type: 'am' | 'family', dossier: ... }\`.`;
const payload = JSON.stringify({
title: 'Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille)',
body,
});
const opts = {
hostname: 'git.ptits-pas.fr',
path: '/api/v1/repos/jmartin/petitspas/issues/119',
method: 'PATCH',
headers: {
Authorization: 'token ' + token,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
};
const req = https.request(opts, (res) => {
let d = '';
res.on('data', (c) => (d += c));
res.on('end', () => {
try {
const o = JSON.parse(d);
if (o.number || o.id) {
console.log('Issue #119 mise à jour.');
console.log('URL:', o.html_url || 'https://git.ptits-pas.fr/jmartin/petitspas/issues/119');
} else {
console.error('Erreur API:', o.message || d);
process.exit(1);
}
} catch (e) {
console.error('Réponse:', d);
process.exit(1);
}
});
});
req.on('error', (e) => {
console.error(e);
process.exit(1);
});
req.write(payload);
req.end();
@@ -1,153 +0,0 @@
/**
* 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();
-4
View File
@@ -16,8 +16,6 @@ import { AllExceptionsFilter } from './common/filters/all_exceptions.filters';
import { EnfantsModule } from './routes/enfants/enfants.module'; import { EnfantsModule } from './routes/enfants/enfants.module';
import { AppConfigModule } from './modules/config/config.module'; import { AppConfigModule } from './modules/config/config.module';
import { DocumentsLegauxModule } from './modules/documents-legaux'; import { DocumentsLegauxModule } from './modules/documents-legaux';
import { RelaisModule } from './routes/relais/relais.module';
import { DossiersModule } from './routes/dossiers/dossiers.module';
@Module({ @Module({
imports: [ imports: [
@@ -55,8 +53,6 @@ import { DossiersModule } from './routes/dossiers/dossiers.module';
AuthModule, AuthModule,
AppConfigModule, AppConfigModule,
DocumentsLegauxModule, DocumentsLegauxModule,
RelaisModule,
DossiersModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [
-109
View File
@@ -1,109 +0,0 @@
/**
* Utilitaire de validation du NIR (numéro de sécurité sociale français).
* - Format 15 caractères (chiffres ou 2A/2B pour la Corse).
* - Clé de contrôle : 97 - (NIR13 mod 97). Pour 2A/2B, conversion temporaire (INSEE : 2A→19, 2B→20).
* - En cas d'incohérence avec les données (sexe, date, lieu) : warning uniquement, pas de rejet.
*/
const NIR_CORSE_2A = '19';
const NIR_CORSE_2B = '20';
/** Regex 15 caractères : sexe (1-3) + 4 chiffres + (2A|2B|2 chiffres) + 6 chiffres + 2 chiffres clé */
const NIR_FORMAT = /^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/i;
/**
* Convertit le NIR en chaîne de 13 chiffres pour le calcul de la clé (2A→19, 2B→20).
*/
export function nirTo13Digits(nir: string): string {
const n = nir.toUpperCase().replace(/\s/g, '');
if (n.length !== 15) return '';
const dept = n.slice(5, 7);
let deptNum: string;
if (dept === '2A') deptNum = NIR_CORSE_2A;
else if (dept === '2B') deptNum = NIR_CORSE_2B;
else deptNum = dept;
return n.slice(0, 5) + deptNum + n.slice(7, 13);
}
/**
* Vérifie que le format NIR est valide (15 caractères, 2A/2B acceptés).
*/
export function isNirFormatValid(nir: string): boolean {
if (!nir || typeof nir !== 'string') return false;
const n = nir.replace(/\s/g, '').toUpperCase();
return NIR_FORMAT.test(n);
}
/**
* Calcule la clé de contrôle attendue (97 - (NIR13 mod 97)).
* Retourne un nombre entre 1 et 97.
*/
export function computeNirKey(nir13: string): number {
const num = parseInt(nir13, 10);
if (Number.isNaN(num) || nir13.length !== 13) return -1;
return 97 - (num % 97);
}
/**
* Vérifie la clé de contrôle du NIR (15 caractères).
* Retourne true si le NIR est valide (format + clé).
*/
export function isNirKeyValid(nir: string): boolean {
const n = nir.replace(/\s/g, '').toUpperCase();
if (n.length !== 15) return false;
const nir13 = nirTo13Digits(n);
if (nir13.length !== 13) return false;
const expectedKey = computeNirKey(nir13);
const actualKey = parseInt(n.slice(13, 15), 10);
return expectedKey === actualKey;
}
export interface NirValidationResult {
valid: boolean;
error?: string;
warning?: string;
}
/**
* Valide le NIR (format + clé). En cas d'incohérence avec date de naissance ou sexe, ajoute un warning sans invalider.
*/
export function validateNir(
nir: string,
options?: { dateNaissance?: string; genre?: 'H' | 'F' },
): NirValidationResult {
const n = (nir || '').replace(/\s/g, '').toUpperCase();
if (n.length === 0) return { valid: false, error: 'Le NIR est requis' };
if (!isNirFormatValid(n)) {
return { valid: false, error: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)' };
}
if (!isNirKeyValid(n)) {
return { valid: false, error: 'Clé de contrôle du NIR invalide' };
}
let warning: string | undefined;
if (options?.genre) {
const sexNir = n[0];
const expectedSex = options.genre === 'F' ? '2' : '1';
if (sexNir !== expectedSex) {
warning = 'Le NIR ne correspond pas au genre indiqué (position 1 du NIR).';
}
}
if (options?.dateNaissance) {
try {
const d = new Date(options.dateNaissance);
if (!Number.isNaN(d.getTime())) {
const year2 = d.getFullYear() % 100;
const month = d.getMonth() + 1;
const nirYear = parseInt(n.slice(1, 3), 10);
const nirMonth = parseInt(n.slice(3, 5), 10);
if (nirYear !== year2 || nirMonth !== month) {
warning = warning
? `${warning} Le NIR ne correspond pas à la date de naissance (positions 2-5).`
: 'Le NIR ne correspond pas à la date de naissance indiquée (positions 2-5).';
}
}
} catch {
// ignore
}
}
return { valid: true, warning };
}
-15
View File
@@ -1,15 +0,0 @@
import { DataSource } from 'typeorm';
import { config } from 'dotenv';
config();
export default new DataSource({
type: 'postgres',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT || '5432', 10),
username: process.env.DATABASE_USERNAME,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: ['src/**/*.entity.ts'],
migrations: ['src/migrations/*.ts'],
});
@@ -48,7 +48,4 @@ export class AssistanteMaternelle {
@Column( { name: 'place_disponible', type: 'integer', nullable: true }) @Column( { name: 'place_disponible', type: 'integer', nullable: true })
places_available?: number; places_available?: number;
/** Numéro de dossier (format AAAA-NNNNNN), même valeur que sur utilisateurs (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
} }
@@ -1,65 +0,0 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
JoinColumn,
} from 'typeorm';
import { Parents } from './parents.entity';
import { Children } from './children.entity';
import { StatutDossierType } from './dossiers.entity';
/** Un dossier = une famille, N enfants (texte de motivation unique, liste d'enfants). */
@Entity('dossier_famille')
export class DossierFamille {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'numero_dossier', length: 20 })
numero_dossier: string;
@ManyToOne(() => Parents, { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'id_parent', referencedColumnName: 'user_id' })
parent: Parents;
@Column({ type: 'text', nullable: true })
presentation?: string;
@Column({
type: 'enum',
enum: StatutDossierType,
enumName: 'statut_dossier_type',
default: StatutDossierType.ENVOYE,
name: 'statut',
})
statut: StatutDossierType;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
@OneToMany(() => DossierFamilleEnfant, (dfe) => dfe.dossier_famille)
enfants: DossierFamilleEnfant[];
}
@Entity('dossier_famille_enfants')
export class DossierFamilleEnfant {
@Column({ name: 'id_dossier_famille', primary: true })
id_dossier_famille: string;
@Column({ name: 'id_enfant', primary: true })
id_enfant: string;
@ManyToOne(() => DossierFamille, (df) => df.enfants, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_dossier_famille' })
dossier_famille: DossierFamille;
@ManyToOne(() => Children, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'id_enfant' })
enfant: Children;
}
+1 -5
View File
@@ -1,5 +1,5 @@
import { import {
Entity, PrimaryColumn, Column, OneToOne, JoinColumn, Entity, PrimaryColumn, OneToOne, JoinColumn,
ManyToOne, OneToMany ManyToOne, OneToMany
} from 'typeorm'; } from 'typeorm';
import { Users } from './users.entity'; import { Users } from './users.entity';
@@ -21,10 +21,6 @@ export class Parents {
@JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' }) @JoinColumn({ name: 'id_co_parent', referencedColumnName: 'id' })
co_parent?: Users; co_parent?: Users;
/** Numéro de dossier famille (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
@Column({ name: 'numero_dossier', length: 20, nullable: true })
numero_dossier?: string;
// Lien vers enfants via la table enfants_parents // Lien vers enfants via la table enfants_parents
@OneToMany(() => ParentsChildren, pc => pc.parent) @OneToMany(() => ParentsChildren, pc => pc.parent)
parentChildren: ParentsChildren[]; parentChildren: ParentsChildren[];
-35
View File
@@ -1,35 +0,0 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Users } from './users.entity';
@Entity('relais', { schema: 'public' })
export class Relais {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'nom' })
nom: string;
@Column({ name: 'adresse' })
adresse: string;
@Column({ type: 'jsonb', name: 'horaires_ouverture', nullable: true })
horaires_ouverture?: any;
@Column({ name: 'ligne_fixe', nullable: true })
ligne_fixe?: string;
@Column({ default: true, name: 'actif' })
actif: boolean;
@Column({ type: 'text', name: 'notes', nullable: true })
notes?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date;
@UpdateDateColumn({ name: 'modifie_le', type: 'timestamptz' })
modifie_le: Date;
@OneToMany(() => Users, user => user.relais)
gestionnaires: Users[];
}
+2 -35
View File
@@ -1,12 +1,11 @@
import { import {
Entity, PrimaryGeneratedColumn, Column, Entity, PrimaryGeneratedColumn, Column,
CreateDateColumn, UpdateDateColumn, CreateDateColumn, UpdateDateColumn,
OneToOne, OneToMany, ManyToOne, JoinColumn OneToOne, OneToMany
} from 'typeorm'; } from 'typeorm';
import { AssistanteMaternelle } from './assistantes_maternelles.entity'; import { AssistanteMaternelle } from './assistantes_maternelles.entity';
import { Parents } from './parents.entity'; import { Parents } from './parents.entity';
import { Message } from './messages.entity'; import { Message } from './messages.entity';
import { Relais } from './relais.entity';
// Enums alignés avec la BDD PostgreSQL // Enums alignés avec la BDD PostgreSQL
export enum RoleType { export enum RoleType {
@@ -29,7 +28,6 @@ export enum StatutUtilisateurType {
EN_ATTENTE = 'en_attente', EN_ATTENTE = 'en_attente',
ACTIF = 'actif', ACTIF = 'actif',
SUSPENDU = 'suspendu', SUSPENDU = 'suspendu',
REFUSE = 'refuse',
} }
export enum SituationFamilialeType { export enum SituationFamilialeType {
@@ -82,7 +80,7 @@ export class Users {
type: 'enum', type: 'enum',
enum: StatutUtilisateurType, enum: StatutUtilisateurType,
enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql enumName: 'statut_utilisateur_type', // correspond à l'enum de la db psql
default: StatutUtilisateurType.ACTIF, default: StatutUtilisateurType.EN_ATTENTE,
name: 'statut' name: 'statut'
}) })
statut: StatutUtilisateurType; statut: StatutUtilisateurType;
@@ -119,20 +117,6 @@ export class Users {
@Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' }) @Column({ type: 'timestamptz', nullable: true, name: 'token_creation_mdp_expire_le' })
token_creation_mdp_expire_le?: Date; token_creation_mdp_expire_le?: Date;
/** Ticket #127 — réinitialisation mot de passe oublié (distinct de token_creation_mdp / inscription) */
@Column({ nullable: true, name: 'password_reset_token', length: 255 })
password_reset_token?: string;
@Column({ type: 'timestamptz', nullable: true, name: 'password_reset_expires' })
password_reset_expires?: Date;
/** Token pour reprise après refus (lien email), ticket #110 */
@Column({ nullable: true, name: 'token_reprise', length: 255 })
token_reprise?: string;
@Column({ type: 'timestamptz', nullable: true, name: 'token_reprise_expire_le' })
token_reprise_expire_le?: Date;
@Column({ nullable: true, name: 'ville' }) @Column({ nullable: true, name: 'ville' })
ville?: string; ville?: string;
@@ -145,12 +129,6 @@ export class Users {
@Column({ name: 'date_naissance', type: 'date', nullable: true }) @Column({ name: 'date_naissance', type: 'date', nullable: true })
date_naissance?: Date; date_naissance?: Date;
@Column({ name: 'lieu_naissance_ville', length: 100, nullable: true })
lieu_naissance_ville?: string;
@Column({ name: 'lieu_naissance_pays', length: 100, nullable: true })
lieu_naissance_pays?: string;
@CreateDateColumn({ name: 'cree_le', type: 'timestamptz' }) @CreateDateColumn({ name: 'cree_le', type: 'timestamptz' })
cree_le: Date; cree_le: Date;
@@ -169,15 +147,4 @@ export class Users {
@OneToMany(() => Parents, parent => parent.co_parent) @OneToMany(() => Parents, parent => parent.co_parent)
co_parent_in?: Parents[]; co_parent_in?: Parents[];
@Column({ nullable: true, name: 'relais_id' })
relaisId?: string;
/** Numéro de dossier (format AAAA-NNNNNN), attribué à la soumission (ticket #103) */
@Column({ nullable: true, name: 'numero_dossier', length: 20 })
numero_dossier?: string;
@ManyToOne(() => Relais, relais => relais.gestionnaires, { nullable: true })
@JoinColumn({ name: 'relais_id' })
relais?: Relais;
} }
+6 -67
View File
@@ -1,87 +1,26 @@
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module'; import { SwaggerModule } from '@nestjs/swagger/dist/swagger-module';
import { DocumentBuilder } from '@nestjs/swagger'; import { DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor'; import { LogRequestInterceptor } from './common/interceptors/log-request.interceptor';
import * as path from 'path';
import * as express from 'express';
/** GET/HEAD photos : CORS + CORP pour Flutter web (cross-origin `Image.network`). Pas de cookie sur ces URLs. */
function setStaticImageCorsHeaders(res: express.Response): void {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
}
const staticImageServeOptions = {
index: false,
fallthrough: true,
setHeaders: (res: express.Response) => setStaticImageCorsHeaders(res),
};
/** Préflight si le navigateur interroge OPTIONS sur les médias. */
function uploadsCorsPreflight(
req: express.Request,
res: express.Response,
next: express.NextFunction,
): void {
if (req.method === 'OPTIONS') {
setStaticImageCorsHeaders(res);
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
res.setHeader('Access-Control-Max-Age', '86400');
res.status(204).end();
return;
}
next();
}
/** Répertoire disque contenant le dossier `photos` (ex. /app/uploads si photos dans /app/uploads/photos). */
function resolveUploadsFilesystemRoot(): string {
const raw = process.env.UPLOAD_PHOTOS_DIR?.trim();
const photosDir = raw
? path.isAbsolute(raw)
? raw
: path.resolve(process.cwd(), raw)
: path.join(process.cwd(), 'uploads', 'photos');
return path.dirname(photosDir);
}
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule, { const app = await NestFactory.create(AppModule,
logger: ['error', 'warn', 'log', 'debug', 'verbose'], { logger: ['error', 'warn', 'log', 'debug', 'verbose'] });
});
// Inscription (photos base64 dans le JSON) : sans limite > défaut Express (~100 ko) → 413/500 ou corps tronqué. // Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useBodyParser('json', { limit: '15mb' }); app.useGlobalInterceptors(new LogRequestInterceptor());
app.useBodyParser('urlencoded', { extended: true, limit: '15mb' });
// CORS global **avant** les fichiers statiques pour que les réponses API et idéalement la chaîne Express restent cohérentes. // Configuration CORS pour autoriser les requêtes depuis localhost (dev) et production
app.enableCors({ app.enableCors({
origin: true, // Reflète lOrigin (dev / prod) — routes API + cookies JWT origin: true, // Autorise toutes les origines (dev) - à restreindre en prod
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'Accept'], allowedHeaders: ['Content-Type', 'Authorization', 'Accept'],
credentials: true, credentials: true,
}); });
const expressApp = app.getHttpAdapter().getInstance();
const uploadsRoot = resolveUploadsFilesystemRoot();
// Photos : chemins stockés en base type /uploads/photos/...
// En-têtes explicites sur les réponses fichier (les statics peuvent répondre sans repasser par la même couche que les contrôleurs).
expressApp.use('/uploads', uploadsCorsPreflight);
expressApp.use('/api/v1/uploads', uploadsCorsPreflight);
app.useStaticAssets(uploadsRoot, {
prefix: '/uploads/',
...staticImageServeOptions,
});
expressApp.use('/api/v1/uploads', express.static(uploadsRoot, staticImageServeOptions));
// Log de chaque appel API si LOG_API_REQUESTS=true (mode debug)
app.useGlobalInterceptors(new LogRequestInterceptor());
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
whitelist: true, whitelist: true,
@@ -101,9 +101,8 @@ export class DocumentsLegauxController {
throw new BadRequestException('Aucun fichier fourni'); throw new BadRequestException('Aucun fichier fourni');
} }
// TODO: Récupérer l'ID utilisateur depuis le guard quand l'auth sera branchée. // TODO: Récupérer l'ID utilisateur depuis le guard
// En attendant, on n'associe pas d'utilisateur plutôt que d'envoyer un UUID fictif invalide. const userId = '00000000-0000-0000-0000-000000000000'; // Temporaire
const userId: string | null = null;
const document = await this.documentsService.uploadNouvelleVersion( const document = await this.documentsService.uploadNouvelleVersion(
uploadDto.type, uploadDto.type,
@@ -43,7 +43,7 @@ export class DocumentsLegauxService {
async uploadNouvelleVersion( async uploadNouvelleVersion(
type: 'cgu' | 'privacy', type: 'cgu' | 'privacy',
file: Express.Multer.File, file: Express.Multer.File,
userId?: string | null, userId: string,
): Promise<DocumentLegal> { ): Promise<DocumentLegal> {
// Validation du type de fichier // Validation du type de fichier
if (file.mimetype !== 'application/pdf') { if (file.mimetype !== 'application/pdf') {
@@ -84,7 +84,7 @@ export class DocumentsLegauxService {
fichier_path: filePath, fichier_path: filePath,
fichier_hash: hash, fichier_hash: hash,
actif: false, // Pas actif par défaut actif: false, // Pas actif par défaut
televersePar: userId ? ({ id: userId } as any) : null, televersePar: { id: userId } as any,
televerseLe: new Date(), televerseLe: new Date(),
}); });
-10
View File
@@ -1,10 +0,0 @@
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
import { AppConfigModule } from '../config/config.module';
@Module({
imports: [AppConfigModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
@@ -1,118 +0,0 @@
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');
});
});
-412
View File
@@ -1,412 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { readFileSync } from 'fs';
import { join } from 'path';
import * as Handlebars from 'handlebars';
import type SMTPTransport from 'nodemailer/lib/smtp-transport';
import { AppConfigService } from '../config/config.service';
/** Ticket #28 — quel template d'email envoyer après validation de compte */
export type ValidationAccountEmailKind = 'parent' | 'am';
type MailTransporter = {
sendMail: (options: Record<string, unknown>) => Promise<unknown>;
};
/** Erreurs SMTP temporaires (surcharge auth, coupure réseau…) — retentables. */
export function isTransientSmtpError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
const code = typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code ?? '')
: '';
const responseCode =
typeof error === 'object' && error !== null && 'responseCode' in error
? Number((error as { responseCode?: unknown }).responseCode)
: undefined;
if (responseCode === 454 || responseCode === 421 || responseCode === 450 || responseCode === 451) {
return true;
}
const transientCodes = new Set(['ECONNRESET', 'ETIMEDOUT', 'ESOCKET', 'ECONNREFUSED', 'EPIPE']);
if (transientCodes.has(code)) {
return true;
}
const transientPatterns = [
/temporary authentication failure/i,
/connection lost to authentication server/i,
/connection reset/i,
/timeout/i,
/try again later/i,
];
return transientPatterns.some((pattern) => pattern.test(message));
}
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private readonly smtpMaxAttempts = 3;
private readonly smtpRetryBaseDelayMs = 1000;
/** Cache des templates Handlebars compilés (fichiers .hbs) */
private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
/** Transporteur SMTP réutilisé (évite une auth TCP/SASL par email). */
private transporter: MailTransporter | null = null;
private transporterConfigKey: string | null = null;
constructor(private readonly configService: AppConfigService) {}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private buildSmtpTransportConfig(): SMTPTransport.Options {
const smtpHost = this.configService.get<string>('smtp_host');
const smtpPort = this.configService.get<number>('smtp_port');
const smtpSecure = this.configService.get<boolean>('smtp_secure');
const smtpAuthRequired = this.configService.get<boolean>('smtp_auth_required');
const smtpUser = this.configService.get<string>('smtp_user');
const smtpPassword = this.configService.get<string>('smtp_password');
const transportConfig = {
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
pool: true,
maxConnections: 1,
maxMessages: 100,
...(smtpAuthRequired && smtpUser && smtpPassword
? { auth: { user: smtpUser, pass: smtpPassword } }
: {}),
} as SMTPTransport.Options;
return transportConfig;
}
private getSmtpConfigKey(config: SMTPTransport.Options): string {
const auth = config.auth as { user?: string; pass?: string } | undefined;
return JSON.stringify({
host: config.host,
port: config.port,
secure: config.secure,
user: auth?.user ?? '',
pass: auth?.pass ?? '',
});
}
private resetTransporter(): void {
const current = this.transporter as { close?: () => void } | null;
current?.close?.();
this.transporter = null;
this.transporterConfigKey = null;
}
private async getTransporter(): Promise<MailTransporter> {
const transportConfig = this.buildSmtpTransportConfig();
const configKey = this.getSmtpConfigKey(transportConfig);
if (this.transporter && this.transporterConfigKey === configKey) {
return this.transporter;
}
this.resetTransporter();
const nodemailer = await import('nodemailer');
this.transporter = nodemailer.createTransport(transportConfig) as MailTransporter;
this.transporterConfigKey = configKey;
return this.transporter;
}
private templateBasename(kind: ValidationAccountEmailKind): string {
const map: Record<ValidationAccountEmailKind, string> = {
parent: 'account-validated-parent',
am: 'account-validated-am',
};
return map[kind];
}
private getCompiledTemplate(kind: ValidationAccountEmailKind): Handlebars.TemplateDelegate {
let compiled = this.compiledTemplates.get(kind);
if (!compiled) {
const filePath = join(__dirname, 'templates', `${this.templateBasename(kind)}.hbs`);
const source = readFileSync(filePath, 'utf8');
compiled = Handlebars.compile(source);
this.compiledTemplates.set(kind, compiled);
}
return compiled;
}
/**
* Email post-validation : lien création MDP (token existant ou régénéré côté appelant).
* Ticket #28 — app_name, app_url, expéditeur via ConfigService (sendEmail).
*/
async sendValidatedAccountPasswordSetupEmail(
recipient: {
email: string;
prenom: string;
nom: string;
token: string;
numeroDossier?: string | null;
},
kind: ValidationAccountEmailKind,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
const createPasswordUrl = `${appUrl}/create-password?token=${encodeURIComponent(recipient.token)}`;
const data: Record<string, string> = {
prenom: recipient.prenom || '',
nom: recipient.nom || '',
appName,
appUrl,
createPasswordUrl,
numeroDossier: recipient.numeroDossier || '',
};
const html = this.getCompiledTemplate(kind)(data) as string;
const subjects: Record<ValidationAccountEmailKind, string> = {
parent: `${appName} — Compte parent validé : créez votre mot de passe`,
am: `${appName} — Inscription validée : créez votre mot de passe`,
};
await this.sendEmail(recipient.email, subjects[kind], html);
}
/**
* Envoi d'un email générique
* @param to Destinataire
* @param subject Sujet
* @param html Contenu HTML
* @param text Contenu texte (optionnel)
*/
async sendEmail(to: string, subject: string, html: string, text?: string): Promise<void> {
const emailFromName = this.configService.get<string>('email_from_name');
const emailFromAddress = this.configService.get<string>('email_from_address');
const mailOptions = {
from: `"${emailFromName}" <${emailFromAddress}>`,
to,
subject,
text: text || html.replace(/<[^>]*>?/gm, ''),
html,
};
let lastError: unknown;
for (let attempt = 1; attempt <= this.smtpMaxAttempts; attempt++) {
try {
const transporter = await this.getTransporter();
await transporter.sendMail(mailOptions);
this.logger.log(`📧 Email envoyé à ${to} : ${subject}`);
return;
} catch (error) {
lastError = error;
const canRetry = attempt < this.smtpMaxAttempts && isTransientSmtpError(error);
if (canRetry) {
const delayMs = this.smtpRetryBaseDelayMs * 2 ** (attempt - 1);
this.logger.warn(
`SMTP temporairement indisponible pour ${to} (tentative ${attempt}/${this.smtpMaxAttempts}), nouvel essai dans ${delayMs}ms`,
error instanceof Error ? error.message : error,
);
this.resetTransporter();
await this.sleep(delayMs);
continue;
}
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, error);
this.resetTransporter();
throw error;
}
}
this.logger.error(`❌ Erreur lors de l'envoi de l'email à ${to}`, lastError);
this.resetTransporter();
throw lastError;
}
/**
* Envoi de l'email de bienvenue pour un gestionnaire
* @param to Email du gestionnaire
* @param prenom Prénom
* @param nom Nom
* @param token Token de création de mot de passe (si applicable) ou mot de passe temporaire (si applicable)
* @note Pour l'instant, on suppose que le gestionnaire doit définir son mot de passe via "Mot de passe oublié" ou un lien d'activation
* Mais le ticket #17 parle de "Flag changement_mdp_obligatoire = TRUE", ce qui implique qu'on lui donne un mot de passe temporaire ou qu'on lui envoie un lien.
* Le ticket #24 parle de "API Création mot de passe" via token.
* Pour le ticket #17, on crée le gestionnaire avec un mot de passe (hashé).
* Si on suit le ticket #35 (Frontend), on saisit un mot de passe.
* Donc on envoie juste un email de confirmation de création de compte.
*/
async sendGestionnaireWelcomeEmail(to: string, prenom: string, nom: string): Promise<void> {
const appName = this.configService.get<string>('app_name', 'P\'titsPas');
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const subject = `Bienvenue sur ${appName}`;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bienvenue ${prenom} ${nom} !</h2>
<p>Votre compte gestionnaire sur <strong>${appName}</strong> a été créé avec succès.</p>
<p>Vous pouvez dès à présent vous connecter avec l'adresse email <strong>${to}</strong> et le mot de passe qui vous a été communiqué.</p>
<p>Lors de votre première connexion, il vous sera demandé de modifier votre mot de passe pour des raisons de sécurité.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${appUrl}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Accéder à l'application</a>
</div>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">
Cet email a été envoyé automatiquement. Merci de ne pas y répondre.
</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
/**
* Accusé de réception inscription (parent ou assistante maternelle) :
* demande enregistrée + n° de dossier. En attente de validation ; pas de lien création MDP à ce stade.
*/
async sendRegistrationPendingEmail(
to: string,
prenom: string,
nom: string,
numeroDossier: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = this.configService.get<string>('app_url', 'https://app.ptits-pas.fr');
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre demande d'inscription sur ${appName} — dossier ${safe(numeroDossier)}`;
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Nous avons bien enregistré votre demande de création de compte sur <strong>${safe(appName)}</strong>.</p>
<p><strong>Numéro de dossier :</strong> ${safe(numeroDossier)}</p>
<p>Votre dossier est <strong>en attente de validation</strong> par notre équipe. Vous recevrez un email lorsquil 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);
}
/**
* 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
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
*/
async sendRefusEmail(
to: string,
prenom: string,
nom: string,
comment: string | undefined,
token: string,
): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
const repriseLink = `${appUrl}/reprise?token=${encodeURIComponent(token)}`;
const safe = (s: string) =>
(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const subject = `Votre dossier compléments demandés`;
const commentText = safe(comment || 'Le gestionnaire vous demande de compléter votre dossier avant une nouvelle validation.');
const html = `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4CAF50;">Bonjour ${safe(prenom)} ${safe(nom)},</h2>
<p>Votre dossier d'inscription sur <strong>${safe(appName)}</strong> n'a pas pu être validé en l'état.</p>
<div style="background-color: #F4FBF5; border-left: 4px solid #4CAF50; padding: 12px 16px; margin: 20px 0;">
<p style="margin: 0 0 8px 0;"><strong>Message du gestionnaire :</strong></p>
<p style="margin: 0;">${commentText}</p>
</div>
<p>Vous pouvez corriger les éléments indiqués et soumettre à nouveau votre dossier en cliquant sur le lien ci-dessous.</p>
<div style="text-align: center; margin: 30px 0;">
<a href="${repriseLink}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Reprendre mon dossier</a>
</div>
<p style="color: #666; font-size: 12px;">Ce lien est valable 7 jours. Si vous n'avez pas demandé cette reprise, vous pouvez ignorer cet email.</p>
<hr style="border: 1px solid #eee; margin: 20px 0;">
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement. Merci de ne pas y répondre.</p>
</div>
`;
await this.sendEmail(to, subject, html);
}
/**
* Ticket #127 — lien application `/reset-password?token=` (pas create-password).
*/
async sendPasswordResetEmail(recipient: {
email: string;
prenom: string;
nom: string;
token: string;
}): Promise<void> {
const appName = this.configService.get<string>('app_name', "P'titsPas");
const appUrl = (this.configService.get<string>('app_url', 'https://app.ptits-pas.fr') || '').replace(/\/+$/, '');
const resetPasswordUrl = `${appUrl}/reset-password?token=${encodeURIComponent(recipient.token)}`;
const filePath = join(__dirname, 'templates', 'password-reset.hbs');
const source = readFileSync(filePath, 'utf8');
const compiled = Handlebars.compile(source);
const html = compiled({
prenom: recipient.prenom || '',
nom: recipient.nom || '',
appName,
resetPasswordUrl,
}) as string;
const subject = `${appName} — Réinitialisation de votre mot de passe`;
await this.sendEmail(recipient.email, subject, html);
}
}
@@ -1,14 +0,0 @@
<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>
@@ -1,14 +0,0 @@
<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>
@@ -1,12 +0,0 @@
<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>
@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { NumeroDossierService } from './numero-dossier.service';
@Module({
providers: [NumeroDossierService],
exports: [NumeroDossierService],
})
export class NumeroDossierModule {}
@@ -1,55 +0,0 @@
import { Injectable } from '@nestjs/common';
import { EntityManager } from 'typeorm';
const FORMAT_MAX_SEQUENCE = 990000;
/**
* Service de génération du numéro de dossier (ticket #103).
* Format AAAA-NNNNNN (année + 6 chiffres), séquence par année.
* Si séquence >= 990000, overflowWarning est true (alerte gestionnaire).
*/
@Injectable()
export class NumeroDossierService {
/**
* Génère le prochain numéro de dossier dans le cadre d'une transaction.
* À appeler avec le manager de la transaction pour garantir l'unicité.
*/
async getNextNumeroDossier(manager: EntityManager): Promise<{
numero: string;
overflowWarning: boolean;
}> {
const year = new Date().getFullYear();
// Garantir l'existence de la ligne pour l'année
await manager.query(
`INSERT INTO numero_dossier_sequence (annee, prochain)
VALUES ($1, 1)
ON CONFLICT (annee) DO NOTHING`,
[year],
);
// Prendre le prochain numéro et incrémenter (FOR UPDATE pour concurrence)
const selectRows = await manager.query(
`SELECT prochain FROM numero_dossier_sequence WHERE annee = $1 FOR UPDATE`,
[year],
);
const currentVal = selectRows?.[0]?.prochain ?? 1;
await manager.query(
`UPDATE numero_dossier_sequence SET prochain = prochain + 1 WHERE annee = $1`,
[year],
);
const nextVal = currentVal;
const overflowWarning = nextVal >= FORMAT_MAX_SEQUENCE;
if (overflowWarning) {
// Log pour alerte gestionnaire (ticket #103)
console.warn(
`[NumeroDossierService] Séquence année ${year} >= ${FORMAT_MAX_SEQUENCE} (valeur ${nextVal}). Prévoir renouvellement ou format.`,
);
}
const numero = `${year}-${String(nextVal).padStart(6, '0')}`;
return { numero, overflowWarning };
}
}
@@ -1,26 +1,12 @@
import { BadRequestException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
describe('AuthController', () => { describe('AuthController', () => {
let controller: AuthController; let controller: AuthController;
const authServiceMock = {
verifyCreatePasswordToken: jest.fn(),
createPasswordWithToken: jest.fn(),
requestPasswordReset: jest.fn(),
resetPasswordWithResetToken: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController], controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authServiceMock },
{ provide: UserService, useValue: {} },
],
}).compile(); }).compile();
controller = module.get<AuthController>(AuthController); controller = module.get<AuthController>(AuthController);
@@ -29,82 +15,4 @@ describe('AuthController', () => {
it('should be defined', () => { it('should be defined', () => {
expect(controller).toBeDefined(); 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');
});
}); });
+5 -107
View File
@@ -1,39 +1,19 @@
import { import { Body, Controller, Get, Post, Req, UnauthorizedException, BadRequestException, UseGuards } from '@nestjs/common';
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Patch,
Post,
Query,
Req,
UnauthorizedException,
BadRequestException,
UseGuards,
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Public } from 'src/common/decorators/public.decorator'; import { Public } from 'src/common/decorators/public.decorator';
import { RegisterDto } from './dto/register.dto'; import { RegisterDto } from './dto/register.dto';
import { RegisterParentCompletDto } from './dto/register-parent-complet.dto'; import { RegisterParentCompletDto } from './dto/register-parent-complet.dto';
import { RegisterAMCompletDto } from './dto/register-am-complet.dto'; import { RegisterAMCompletDto } from './dto/register-am-complet.dto';
import { RegisterAmResponseDto } from './dto/register-am-response.dto';
import { ChangePasswordRequiredDto } from './dto/change-password.dto'; import { ChangePasswordRequiredDto } from './dto/change-password.dto';
import { CreatePasswordDto } from './dto/create-password.dto'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
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 { AuthGuard } from 'src/common/guards/auth.guard';
import type { Request } from 'express'; import type { Request } from 'express';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { ProfileResponseDto } from './dto/profile_response.dto'; import { ProfileResponseDto } from './dto/profile_response.dto';
import { RefreshTokenDto } from './dto/refresh_token.dto'; import { RefreshTokenDto } from './dto/refresh_token.dto';
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
import { RepriseIdentifyBodyDto } from './dto/reprise-identify.dto';
import { User } from 'src/common/decorators/user.decorator'; import { User } from 'src/common/decorators/user.decorator';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
@ApiTags('Authentification') @ApiTags('Authentification')
@Controller('auth') @Controller('auth')
@@ -76,44 +56,15 @@ export class AuthController {
@Post('register/am') @Post('register/am')
@ApiOperation({ @ApiOperation({
summary: 'Inscription Assistante Maternelle COMPLÈTE', summary: 'Inscription Assistante Maternelle COMPLÈTE',
description: description: 'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction',
'Crée User AM + entrée assistantes_maternelles (identité + infos pro + photo + CGU) en une transaction. Si photo_base64 est fourni sans photo_filename, le serveur utilise le nom par défaut photo_am.jpg (extension du fichier stocké = type du data-URL).',
}) })
@ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation', type: RegisterAmResponseDto }) @ApiResponse({ status: 201, description: 'Inscription réussie - Dossier en attente de validation' })
@ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' }) @ApiResponse({ status: 400, description: 'Données invalides ou CGU non acceptées' })
@ApiResponse({ status: 409, description: 'Email déjà utilisé' }) @ApiResponse({ status: 409, description: 'Email déjà utilisé' })
async inscrireAMComplet(@Body() dto: RegisterAMCompletDto): Promise<RegisterAmResponseDto> { async inscrireAMComplet(@Body() dto: RegisterAMCompletDto) {
return this.authService.inscrireAMComplet(dto); return this.authService.inscrireAMComplet(dto);
} }
@Public()
@Get('reprise-dossier')
@ApiOperation({ summary: 'Dossier pour reprise (token seul)' })
@ApiQuery({ name: 'token', required: true, description: 'Token reprise (lien email)' })
@ApiResponse({ status: 200, description: 'Données dossier pour préremplir', type: RepriseDossierDto })
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
async getRepriseDossier(@Query('token') token: string): Promise<RepriseDossierDto> {
return this.authService.getRepriseDossier(token);
}
@Public()
@Patch('reprise-resoumettre')
@ApiOperation({ summary: 'Resoumettre le dossier (mise à jour + statut en_attente, invalide le token)' })
@ApiResponse({ status: 200, description: 'Dossier resoumis' })
@ApiResponse({ status: 404, description: 'Token invalide ou expiré' })
async resoumettreReprise(@Body() dto: ResoumettreRepriseDto) {
return this.authService.resoumettreReprise(dto);
}
@Public()
@Post('reprise-identify')
@ApiOperation({ summary: 'Modale reprise : numéro + email → type + token' })
@ApiResponse({ status: 201, description: 'type (parent/AM) + token pour GET reprise-dossier / PUT reprise-resoumettre' })
@ApiResponse({ status: 404, description: 'Aucun dossier en reprise pour ce numéro et email' })
async repriseIdentify(@Body() dto: RepriseIdentifyBodyDto) {
return this.authService.identifyReprise(dto.numero_dossier, dto.email);
}
@Public() @Public()
@Post('refresh') @Post('refresh')
@ApiBearerAuth('refresh_token') @ApiBearerAuth('refresh_token')
@@ -124,59 +75,6 @@ export class AuthController {
return this.authService.refreshTokens(dto.refresh_token); return this.authService.refreshTokens(dto.refresh_token);
} }
@Public()
@Get('verify-token')
@ApiOperation({ summary: 'Vérifier un token de création de mot de passe' })
@ApiQuery({ name: 'token', required: true, description: 'Token UUID reçu par email' })
@ApiResponse({ status: 200, description: 'Token valide' })
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
async verifyCreatePasswordToken(@Query('token') token: string) {
return this.authService.verifyCreatePasswordToken(token);
}
@Public()
@Post('create-password')
@ApiOperation({ summary: 'Créer le mot de passe initial via token email' })
@ApiResponse({ status: 201, description: 'Mot de passe créé avec succès' })
@ApiResponse({ status: 400, description: 'Confirmation invalide ou mot de passe invalide' })
@ApiResponse({ status: 404, description: 'Token invalide, expiré, ou déjà utilisé' })
async createPassword(@Body() dto: CreatePasswordDto) {
if (dto.password !== dto.password_confirmation) {
throw new BadRequestException('Les mots de passe ne correspondent pas');
}
return this.authService.createPasswordWithToken(dto.token, dto.password);
}
@Public()
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Demande de réinitialisation du mot de passe (ticket #127)',
description:
'Réponse identique que le-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') @Get('me')
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
+1 -19
View File
@@ -10,30 +10,12 @@ import { Parents } from 'src/entities/parents.entity';
import { Children } from 'src/entities/children.entity'; import { Children } from 'src/entities/children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { AppConfigModule } from 'src/modules/config'; 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({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([ TypeOrmModule.forFeature([Users, Parents, Children, AssistanteMaternelle]),
Users,
Parents,
Children,
AssistanteMaternelle,
DossierFamille,
DossierFamilleEnfant,
ParentsChildren,
]),
forwardRef(() => UserModule), forwardRef(() => UserModule),
ParentsModule,
DossiersModule,
AppConfigModule, AppConfigModule,
MailModule,
NumeroDossierModule,
JwtModule.registerAsync({ JwtModule.registerAsync({
imports: [ConfigModule], imports: [ConfigModule],
useFactory: (config: ConfigService) => ({ useFactory: (config: ConfigService) => ({
+2 -206
View File
@@ -1,79 +1,12 @@
import { NotFoundException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; 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 { 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 (#118 create-password API)', () => { describe('AuthService', () => {
let service: AuthService; 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 () => { 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({ const module: TestingModule = await Test.createTestingModule({
providers: [ providers: [AuthService],
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(); }).compile();
service = module.get<AuthService>(AuthService); service = module.get<AuthService>(AuthService);
@@ -82,141 +15,4 @@ describe('AuthService (#118 create-password API)', () => {
it('should be defined', () => { it('should be defined', () => {
expect(service).toBeDefined(); 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: 'actif' }],
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);
});
});
}); });
+7 -806
View File
@@ -1,13 +1,11 @@
import { import {
ConflictException, ConflictException,
Injectable, Injectable,
Logger,
NotFoundException,
UnauthorizedException, UnauthorizedException,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { EntityManager, In, QueryFailedError, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { UserService } from '../user/user.service'; import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
@@ -23,41 +21,22 @@ import { Parents } from 'src/entities/parents.entity';
import { Children, StatutEnfantType } from 'src/entities/children.entity'; import { Children, StatutEnfantType } from 'src/entities/children.entity';
import { ParentsChildren } from 'src/entities/parents_children.entity'; import { ParentsChildren } from 'src/entities/parents_children.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity'; import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { DossierFamille, DossierFamilleEnfant } from 'src/entities/dossier_famille.entity';
import { StatutDossierType } from 'src/entities/dossiers.entity';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { RepriseDossierDto } from './dto/reprise-dossier.dto';
import { ResoumettreRepriseDto } from './dto/resoumettre-reprise.dto';
import { RepriseIdentifyResponseDto } from './dto/reprise-identify.dto';
import { AppConfigService } from 'src/modules/config/config.service'; import { AppConfigService } from 'src/modules/config/config.service';
import { validateNir } from 'src/common/utils/nir.util';
import { NumeroDossierService } from 'src/modules/numero-dossier/numero-dossier.service';
import { MailService } from 'src/modules/mail/mail.service';
import { ParentsService } from '../parents/parents.service';
import { DossiersService } from '../dossiers/dossiers.service';
import { DossierAmCompletDto } from '../dossiers/dto/dossier-am-complet.dto';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor( constructor(
private readonly usersService: UserService, private readonly usersService: UserService,
private readonly jwtService: JwtService, private readonly jwtService: JwtService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly appConfigService: AppConfigService, private readonly appConfigService: AppConfigService,
private readonly mailService: MailService,
private readonly numeroDossierService: NumeroDossierService,
private readonly parentsService: ParentsService,
private readonly dossiersService: DossiersService,
@InjectRepository(Parents) @InjectRepository(Parents)
private readonly parentsRepo: Repository<Parents>, private readonly parentsRepo: Repository<Parents>,
@InjectRepository(Users) @InjectRepository(Users)
private readonly usersRepo: Repository<Users>, private readonly usersRepo: Repository<Users>,
@InjectRepository(Children) @InjectRepository(Children)
private readonly childrenRepo: Repository<Children>, private readonly childrenRepo: Repository<Children>,
@InjectRepository(AssistanteMaternelle)
private readonly assistantesMaternellesRepo: Repository<AssistanteMaternelle>,
) { } ) { }
/** /**
@@ -114,12 +93,6 @@ export class AuthService {
throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.'); throw new UnauthorizedException('Votre compte a été suspendu. Contactez un administrateur.');
} }
if (user.statut === StatutUtilisateurType.REFUSE) {
throw new UnauthorizedException(
'Votre compte a été refusé. Vous pouvez corriger votre dossier et le soumettre à nouveau ; un gestionnaire pourra le réexaminer.',
);
}
return this.generateTokens(user.id, user.email, user.role); return this.generateTokens(user.id, user.email, user.role);
} }
@@ -143,236 +116,6 @@ export class AuthService {
} }
} }
async verifyCreatePasswordToken(token: string) {
const cleanedToken = token?.trim();
if (!cleanedToken) {
// #118: côté front, un token absent doit être traité comme lien invalide/expiré.
// On renvoie donc la même réponse neutre 404 que pour les autres cas invalides.
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
const user = await this.usersRepo.findOne({
where: { token_creation_mdp: cleanedToken },
select: ['id', 'token_creation_mdp_expire_le', 'password'],
});
if (!user) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
if (user.password) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
if (!user.token_creation_mdp_expire_le || user.token_creation_mdp_expire_le <= new Date()) {
// Invalidation explicite d'un token expiré pour éviter toute réutilisation ultérieure.
await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
token_creation_mdp: () => 'NULL',
token_creation_mdp_expire_le: () => 'NULL',
})
.where('id = :id', { id: user.id })
.execute();
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
return {
valid: true,
message: 'Token valide',
};
}
async createPasswordWithToken(token: string, plainPassword: string) {
const cleanedToken = token?.trim();
if (!cleanedToken) {
throw new BadRequestException('Token manquant');
}
const existingUser = await this.usersRepo.findOne({
where: {
token_creation_mdp: cleanedToken,
},
select: ['id', 'password', 'token_creation_mdp_expire_le'],
});
if (!existingUser) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
if (existingUser.password) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
if (
!existingUser.token_creation_mdp_expire_le ||
existingUser.token_creation_mdp_expire_le <= new Date()
) {
await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
token_creation_mdp: () => 'NULL',
token_creation_mdp_expire_le: () => 'NULL',
})
.where('id = :id', { id: existingUser.id })
.execute();
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
const sel = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(plainPassword, sel);
// Usage unique atomique : une seule requête UPDATE avec garde-fous.
const updateResult = await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
password: hashedPassword,
token_creation_mdp: () => 'NULL',
token_creation_mdp_expire_le: () => 'NULL',
statut: StatutUtilisateurType.ACTIF,
changement_mdp_obligatoire: false,
})
.where('token_creation_mdp = :token', { token: cleanedToken })
.andWhere('token_creation_mdp_expire_le > NOW()')
.andWhere('password IS NULL')
.execute();
if (!updateResult.affected) {
this.logger.warn(
'[createPasswordWithToken] Token non consommé (course concurrente ou token invalidé)',
);
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
this.logger.log('[createPasswordWithToken] Mot de passe initial créé avec succès');
return {
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.',
userId: existingUser.id,
};
}
/** Réponse unique pour POST forgot-password (anti-énumération), ticket #127 */
private static readonly FORGOT_PASSWORD_GENERIC_MESSAGE =
'Si cette adresse est associée à un compte pour lequel un mot de passe existe, vous recevrez un e-mail avec les instructions.';
/**
* Ticket #127 — demande de réinitialisation (e-mail avec lien /reset-password).
* Toujours la même réponse 200 ; nutilise pas token_creation_mdp (#118 inscription).
*/
async requestPasswordReset(rawEmail: string): Promise<{ message: string }> {
const generic = { message: AuthService.FORGOT_PASSWORD_GENERIC_MESSAGE };
const email = (rawEmail ?? '').trim().toLowerCase();
if (!email) {
return generic;
}
const user = await this.usersRepo
.createQueryBuilder('u')
.where('LOWER(TRIM(u.email)) = :email', { email })
.select(['u.id', 'u.email', 'u.prenom', 'u.nom', 'u.password'])
.getOne();
if (!user?.password) {
return generic;
}
const expiryDaysRaw = this.appConfigService.get<number>('password_reset_token_expiry_days', 7);
const expiryDays = Number(expiryDaysRaw) > 0 ? Number(expiryDaysRaw) : 7;
const expires = new Date();
expires.setDate(expires.getDate() + expiryDays);
const token = crypto.randomUUID();
await this.usersRepo.update(
{ id: user.id },
{
password_reset_token: token,
password_reset_expires: expires,
},
);
try {
await this.mailService.sendPasswordResetEmail({
email: user.email,
prenom: user.prenom ?? '',
nom: user.nom ?? '',
token,
});
} catch (err) {
this.logger.error(
'[requestPasswordReset] Échec envoi e-mail (réponse inchangée pour anti-énumération)',
err,
);
}
return generic;
}
/**
* Ticket #127 — consomme password_reset_token (distinct de create-password / token_creation_mdp).
*/
async resetPasswordWithResetToken(token: string, plainPassword: string): Promise<{ message: string }> {
const cleanedToken = token?.trim();
if (!cleanedToken) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
const existingUser = await this.usersRepo.findOne({
where: { password_reset_token: cleanedToken },
select: ['id', 'password', 'password_reset_expires'],
});
if (!existingUser?.password) {
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
if (!existingUser.password_reset_expires || existingUser.password_reset_expires <= new Date()) {
await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
password_reset_token: () => 'NULL',
password_reset_expires: () => 'NULL',
})
.where('id = :id', { id: existingUser.id })
.execute();
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
const sel = await bcrypt.genSalt(12);
const hashedPassword = await bcrypt.hash(plainPassword, sel);
const updateResult = await this.usersRepo
.createQueryBuilder()
.update(Users)
.set({
password: hashedPassword,
password_reset_token: () => 'NULL',
password_reset_expires: () => 'NULL',
changement_mdp_obligatoire: false,
})
.where('password_reset_token = :token', { token: cleanedToken })
.andWhere('password_reset_expires > NOW()')
.execute();
if (!updateResult.affected) {
this.logger.warn(
'[resetPasswordWithResetToken] Token non consommé (course concurrente ou token invalidé)',
);
throw new NotFoundException('Token invalide, expiré, ou déjà utilisé.');
}
this.logger.log('[resetPasswordWithResetToken] Mot de passe réinitialisé avec succès');
return {
message: 'Mot de passe mis à jour. Vous pouvez vous connecter.',
};
}
/** /**
* Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM() * Inscription utilisateur OBSOLÈTE - Utiliser inscrireParentComplet() ou registerAM()
* @deprecated * @deprecated
@@ -434,11 +177,6 @@ export class AuthService {
} }
if (dto.co_parent_email) { if (dto.co_parent_email) {
if (dto.email.trim().toLowerCase() === dto.co_parent_email.trim().toLowerCase()) {
throw new BadRequestException(
'L\'email du parent et du co-parent doivent être différents.',
);
}
const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email); const coParentExiste = await this.usersService.findByEmailOrNull(dto.co_parent_email);
if (coParentExiste) { if (coParentExiste) {
throw new ConflictException('L\'email du co-parent est déjà utilisé'); throw new ConflictException('L\'email du co-parent est déjà utilisé');
@@ -454,18 +192,7 @@ export class AuthService {
const dateExpiration = new Date(); const dateExpiration = new Date();
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let resultat: { const resultat = await this.usersRepo.manager.transaction(async (manager) => {
parent1: Users;
parent2: Users | null;
enfants: Children[];
tokenCreationMdp: string;
tokenCoParent: string | null;
};
try {
resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const parent1 = manager.create(Users, { const parent1 = manager.create(Users, {
email: dto.email, email: dto.email,
prenom: dto.prenom, prenom: dto.prenom,
@@ -478,7 +205,6 @@ export class AuthService {
ville: dto.ville, ville: dto.ville,
token_creation_mdp: tokenCreationMdp, token_creation_mdp: tokenCreationMdp,
token_creation_mdp_expire_le: dateExpiration, token_creation_mdp_expire_le: dateExpiration,
numero_dossier: numeroDossier,
}); });
const parent1Enregistre = await manager.save(Users, parent1); const parent1Enregistre = await manager.save(Users, parent1);
@@ -503,7 +229,6 @@ export class AuthService {
ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville, ville: dto.co_parent_meme_adresse ? dto.ville : dto.co_parent_ville,
token_creation_mdp: tokenCoParent, token_creation_mdp: tokenCoParent,
token_creation_mdp_expire_le: dateExpirationCoParent, token_creation_mdp_expire_le: dateExpirationCoParent,
numero_dossier: numeroDossier,
}); });
parent2Enregistre = await manager.save(Users, parent2); parent2Enregistre = await manager.save(Users, parent2);
@@ -511,7 +236,6 @@ export class AuthService {
const entiteParent = manager.create(Parents, { const entiteParent = manager.create(Parents, {
user_id: parent1Enregistre.id, user_id: parent1Enregistre.id,
numero_dossier: numeroDossier,
}); });
entiteParent.user = parent1Enregistre; entiteParent.user = parent1Enregistre;
if (parent2Enregistre) { if (parent2Enregistre) {
@@ -523,7 +247,6 @@ export class AuthService {
if (parent2Enregistre) { if (parent2Enregistre) {
const entiteCoParent = manager.create(Parents, { const entiteCoParent = manager.create(Parents, {
user_id: parent2Enregistre.id, user_id: parent2Enregistre.id,
numero_dossier: numeroDossier,
}); });
entiteCoParent.user = parent2Enregistre; entiteCoParent.user = parent2Enregistre;
entiteCoParent.co_parent = parent1Enregistre; entiteCoParent.co_parent = parent1Enregistre;
@@ -573,25 +296,6 @@ export class AuthService {
} }
} }
// Dossier famille : motivation (texte_motivation côté GET) + liaisons enfants (ticket #119)
const presentationTrim = dto.presentation_dossier?.trim();
const dossierFamilleEnt = manager.create(DossierFamille, {
numero_dossier: numeroDossier,
presentation: presentationTrim || undefined,
statut: StatutDossierType.ENVOYE,
parent: entiteParent,
});
const dossierFamilleSaved = await manager.save(DossierFamille, dossierFamilleEnt);
for (const enfantEnregistre of enfantsEnregistres) {
await manager.save(
DossierFamilleEnfant,
manager.create(DossierFamilleEnfant, {
id_dossier_famille: dossierFamilleSaved.id,
id_enfant: enfantEnregistre.id,
}),
);
}
return { return {
parent1: parent1Enregistre, parent1: parent1Enregistre,
parent2: parent2Enregistre, parent2: parent2Enregistre,
@@ -600,38 +304,6 @@ export class AuthService {
tokenCoParent, tokenCoParent,
}; };
}); });
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException(
'Un compte avec cet email existe déjà (contrainte unique en base).',
);
}
throw err;
}
const numeroDossier = resultat.parent1.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent1.email,
resultat.parent1.prenom ?? '',
resultat.parent1.nom ?? '',
numeroDossier,
);
if (resultat.parent2) {
await this.mailService.sendRegistrationPendingEmail(
resultat.parent2.email,
resultat.parent2.prenom ?? '',
resultat.parent2.nom ?? '',
numeroDossier,
);
}
} catch (err) {
this.logger.error(
"[inscrireParentComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
return { return {
message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', message: 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
@@ -639,7 +311,6 @@ export class AuthService {
co_parent_id: resultat.parent2?.id, co_parent_id: resultat.parent2?.id,
enfants_ids: resultat.enfants.map(e => e.id), enfants_ids: resultat.enfants.map(e => e.id),
statut: StatutUtilisateurType.EN_ATTENTE, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
}; };
} }
@@ -654,50 +325,11 @@ export class AuthService {
); );
} }
if (dto.places_disponibles > dto.capacite_accueil) {
throw new BadRequestException(
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
);
}
const nirNormalized = (dto.nir || '').replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance,
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
if (nirValidation.warning) {
// Warning uniquement : on ne bloque pas (AM souvent étrangères, DOM-TOM, Corse)
console.warn('[inscrireAMComplet] NIR warning:', nirValidation.warning, 'email=', dto.email);
}
const existe = await this.usersService.findByEmailOrNull(dto.email); const existe = await this.usersService.findByEmailOrNull(dto.email);
if (existe) { if (existe) {
throw new ConflictException('Un compte avec cet email existe déjà'); throw new ConflictException('Un compte avec cet email existe déjà');
} }
const nirDejaUtilise = await this.assistantesMaternellesRepo.findOne({
where: { nir: nirNormalized },
});
if (nirDejaUtilise) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
);
}
const numeroAgrement = (dto.numero_agrement || '').trim();
if (numeroAgrement) {
const agrementDejaUtilise = await this.assistantesMaternellesRepo.findOne({
where: { approval_number: numeroAgrement },
});
if (agrementDejaUtilise) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
);
}
}
const joursExpirationToken = await this.appConfigService.get<number>( const joursExpirationToken = await this.appConfigService.get<number>(
'password_reset_token_expiry_days', 'password_reset_token_expiry_days',
7, 7,
@@ -707,21 +339,14 @@ export class AuthService {
dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken); dateExpiration.setDate(dateExpiration.getDate() + joursExpirationToken);
let urlPhoto: string | null = null; let urlPhoto: string | null = null;
const photoB64 = (dto.photo_base64 ?? '').trim(); if (dto.photo_base64 && dto.photo_filename) {
if (photoB64) { urlPhoto = await this.sauvegarderPhotoDepuisBase64(dto.photo_base64, dto.photo_filename);
const nomFichier =
(dto.photo_filename ?? '').trim() || 'photo_am.jpg';
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
} }
const dateConsentementPhoto = const dateConsentementPhoto =
dto.consentement_photo ? new Date() : undefined; dto.consentement_photo ? new Date() : undefined;
let resultat: { user: Users }; const resultat = await this.usersRepo.manager.transaction(async (manager) => {
try {
resultat = await this.usersRepo.manager.transaction(async (manager) => {
const { numero: numeroDossier } = await this.numeroDossierService.getNextNumeroDossier(manager);
const user = manager.create(Users, { const user = manager.create(Users, {
email: dto.email, email: dto.email,
prenom: dto.prenom, prenom: dto.prenom,
@@ -738,9 +363,6 @@ export class AuthService {
consentement_photo: dto.consentement_photo, consentement_photo: dto.consentement_photo,
date_consentement_photo: dateConsentementPhoto, date_consentement_photo: dateConsentementPhoto,
date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined, date_naissance: dto.date_naissance ? new Date(dto.date_naissance) : undefined,
lieu_naissance_ville: dto.lieu_naissance_ville,
lieu_naissance_pays: dto.lieu_naissance_pays,
numero_dossier: numeroDossier,
}); });
const userEnregistre = await manager.save(Users, user); const userEnregistre = await manager.save(Users, user);
@@ -748,48 +370,23 @@ export class AuthService {
const am = amRepo.create({ const am = amRepo.create({
user_id: userEnregistre.id, user_id: userEnregistre.id,
approval_number: dto.numero_agrement, approval_number: dto.numero_agrement,
nir: nirNormalized, nir: dto.nir,
max_children: dto.capacite_accueil, max_children: dto.capacite_accueil,
places_available: dto.places_disponibles,
biography: dto.biographie, biography: dto.biographie,
residence_city: dto.ville ?? undefined, residence_city: dto.ville ?? undefined,
agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined, agreement_date: dto.date_agrement ? new Date(dto.date_agrement) : undefined,
available: true, available: true,
numero_dossier: numeroDossier,
}); });
await amRepo.save(am); await amRepo.save(am);
return { user: userEnregistre }; return { user: userEnregistre };
}); });
} catch (err) {
if (this.isPostgresUniqueViolation(err)) {
throw new ConflictException('Un compte avec cet email existe déjà (contrainte unique en base).');
}
throw err;
}
const numeroDossier = resultat.user.numero_dossier ?? '';
try {
await this.mailService.sendRegistrationPendingEmail(
resultat.user.email,
resultat.user.prenom ?? '',
resultat.user.nom ?? '',
numeroDossier,
);
} catch (err) {
this.logger.error(
"[inscrireAMComplet] Échec envoi email d'accusé de réception (inscription conservée)",
err instanceof Error ? err.stack : String(err),
);
}
return { return {
message: message:
'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.', 'Inscription réussie. Votre dossier est en attente de validation par un gestionnaire.',
user_id: resultat.user.id, user_id: resultat.user.id,
statut: StatutUtilisateurType.EN_ATTENTE, statut: StatutUtilisateurType.EN_ATTENTE,
numero_dossier: numeroDossier,
}; };
} }
@@ -805,12 +402,7 @@ export class AuthService {
const extension = correspondances[1]; const extension = correspondances[1];
const tamponImage = Buffer.from(correspondances[2], 'base64'); const tamponImage = Buffer.from(correspondances[2], 'base64');
const rawDir = process.env.UPLOAD_PHOTOS_DIR?.trim(); const dossierUpload = '/app/uploads/photos';
const dossierUpload = rawDir
? path.isAbsolute(rawDir)
? rawDir
: path.resolve(process.cwd(), rawDir)
: path.join(process.cwd(), 'uploads', 'photos');
await fs.mkdir(dossierUpload, { recursive: true }); await fs.mkdir(dossierUpload, { recursive: true });
const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`; const nomFichierUnique = `${Date.now()}-${crypto.randomUUID()}.${extension}`;
@@ -878,395 +470,4 @@ export class AuthService {
async logout(userId: string) { async logout(userId: string) {
return { success: true, message: 'Deconnexion'} return { success: true, message: 'Deconnexion'}
} }
/** GET dossier reprise token seul. Ticket #111, enrichi #112 */
async getRepriseDossier(token: string): Promise<RepriseDossierDto> {
const user = await this.usersService.findByTokenReprise(token);
if (!user) {
throw new NotFoundException('Token reprise invalide ou expiré.');
}
const base = this.userToRepriseBase(user);
if (user.role === RoleType.PARENT && user.numero_dossier) {
try {
const famille = await this.parentsService.getDossierFamilleByNumero(user.numero_dossier);
return {
...base,
parents: famille.parents,
enfants: famille.enfants,
texte_motivation: famille.texte_motivation,
};
} catch (err) {
if (err instanceof NotFoundException) {
return base;
}
throw err;
}
}
if (user.role === RoleType.ASSISTANTE_MATERNELLE && user.numero_dossier) {
try {
const unifie = await this.dossiersService.getDossierByNumero(user.numero_dossier);
if (unifie.type === 'am') {
return this.mergeAmRepriseDossier(base, unifie.dossier as DossierAmCompletDto);
}
} catch (err) {
if (err instanceof NotFoundException) {
return base;
}
throw err;
}
}
return base;
}
/** PATCH resoumission reprise dossier complet. Ticket #111, enrichi #112 */
async resoumettreReprise(dto: ResoumettreRepriseDto): Promise<{
message: string;
statut: StatutUtilisateurType;
user_id: string;
numero_dossier?: string;
}> {
const user = await this.usersService.findByTokenReprise(dto.token);
if (!user) {
throw new NotFoundException('Token reprise invalide ou expiré.');
}
if (user.role === RoleType.PARENT) {
return this.resoumettreRepriseParent(user, dto);
}
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
return this.resoumettreRepriseAM(user, dto);
}
throw new BadRequestException('Rôle non pris en charge pour la reprise.');
}
private userToRepriseBase(user: Users): RepriseDossierDto {
return {
id: user.id,
email: user.email,
prenom: user.prenom,
nom: user.nom,
telephone: user.telephone,
adresse: user.adresse,
ville: user.ville,
code_postal: user.code_postal,
numero_dossier: user.numero_dossier,
role: user.role,
photo_url: user.photo_url,
genre: user.genre,
situation_familiale: user.situation_familiale,
};
}
private mergeAmRepriseDossier(base: RepriseDossierDto, dossier: DossierAmCompletDto): RepriseDossierDto {
const u = dossier.user;
return {
...base,
email: u.email,
prenom: u.prenom,
nom: u.nom,
telephone: u.telephone,
adresse: u.adresse,
ville: u.ville,
code_postal: u.code_postal,
photo_url: u.photo_url,
consentement_photo: u.consentement_photo,
date_naissance: u.date_naissance,
lieu_naissance_ville: u.lieu_naissance_ville,
lieu_naissance_pays: u.lieu_naissance_pays,
numero_agrement: dossier.numero_agrement,
nir: dossier.nir,
date_agrement: dossier.date_agrement,
nb_max_enfants: dossier.nb_max_enfants,
place_disponible: dossier.place_disponible,
biographie: dossier.biographie,
};
}
private applyTitulaireUserFields(user: Users, dto: ResoumettreRepriseDto): void {
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
}
private async resoumettreRepriseParent(
titulaire: Users,
dto: ResoumettreRepriseDto,
): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> {
const motivation =
dto.texte_motivation?.trim() ??
dto.presentation_dossier?.trim();
const result = await this.usersRepo.manager.transaction(async (manager) => {
const familyUsers = titulaire.numero_dossier
? await manager.find(Users, {
where: {
role: RoleType.PARENT,
numero_dossier: titulaire.numero_dossier,
},
})
: [titulaire];
const titulaireLive = familyUsers.find((u) => u.id === titulaire.id) ?? titulaire;
this.applyTitulaireUserFields(titulaireLive, dto);
const coParentUser = familyUsers.find((u) => u.id !== titulaire.id);
if (coParentUser) {
if (dto.co_parent_prenom !== undefined) coParentUser.prenom = dto.co_parent_prenom;
if (dto.co_parent_nom !== undefined) coParentUser.nom = dto.co_parent_nom;
if (dto.co_parent_telephone !== undefined) coParentUser.telephone = dto.co_parent_telephone;
if (dto.co_parent_meme_adresse) {
coParentUser.adresse = titulaireLive.adresse;
coParentUser.code_postal = titulaireLive.code_postal;
coParentUser.ville = titulaireLive.ville;
} else {
if (dto.co_parent_adresse !== undefined) coParentUser.adresse = dto.co_parent_adresse;
if (dto.co_parent_code_postal !== undefined) coParentUser.code_postal = dto.co_parent_code_postal;
if (dto.co_parent_ville !== undefined) coParentUser.ville = dto.co_parent_ville;
}
}
if (titulaire.numero_dossier && motivation !== undefined) {
const dossierFamille = await manager.findOne(DossierFamille, {
where: { numero_dossier: titulaire.numero_dossier },
});
if (dossierFamille) {
dossierFamille.presentation = motivation || undefined;
await manager.save(DossierFamille, dossierFamille);
}
}
if (dto.enfants?.length) {
const familyChildIds = await this.getFamilyChildrenIds(manager, titulaireLive.id);
for (const enfantDto of dto.enfants) {
if (!familyChildIds.has(enfantDto.id)) {
throw new BadRequestException(
`Enfant inconnu pour ce dossier : ${enfantDto.id}`,
);
}
const enfant = await manager.findOne(Children, { where: { id: enfantDto.id } });
if (!enfant) {
throw new BadRequestException(`Enfant introuvable : ${enfantDto.id}`);
}
if (enfantDto.prenom !== undefined) enfant.first_name = enfantDto.prenom;
if (enfantDto.nom !== undefined) enfant.last_name = enfantDto.nom;
if (enfantDto.genre !== undefined) enfant.gender = enfantDto.genre;
if (enfantDto.date_naissance !== undefined) {
enfant.birth_date = new Date(enfantDto.date_naissance);
enfant.status = StatutEnfantType.ACTIF;
}
if (enfantDto.date_previsionnelle_naissance !== undefined) {
enfant.due_date = new Date(enfantDto.date_previsionnelle_naissance);
if (!enfantDto.date_naissance) {
enfant.status = StatutEnfantType.A_NAITRE;
}
}
if (enfantDto.grossesse_multiple !== undefined) {
enfant.is_multiple = enfantDto.grossesse_multiple;
}
if (enfantDto.photo_base64 && enfantDto.photo_filename) {
enfant.photo_url = await this.sauvegarderPhotoDepuisBase64(
enfantDto.photo_base64,
enfantDto.photo_filename,
);
}
await manager.save(Children, enfant);
}
}
for (const familyUser of familyUsers) {
familyUser.statut = StatutUtilisateurType.EN_ATTENTE;
familyUser.token_reprise = undefined;
familyUser.token_reprise_expire_le = undefined;
await manager.save(Users, familyUser);
}
return {
message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.',
statut: StatutUtilisateurType.EN_ATTENTE,
user_id: titulaireLive.id,
numero_dossier: titulaireLive.numero_dossier,
parentsPourEmail: familyUsers,
};
});
const numeroDossier = result.numero_dossier ?? '';
try {
for (const parent of result.parentsPourEmail) {
await this.mailService.sendResoumissionPendingEmail(
parent.email,
parent.prenom ?? '',
parent.nom ?? '',
numeroDossier,
);
}
} catch (err) {
this.logger.error(
"[resoumettreRepriseParent] Échec envoi email accusé resoumission (dossier conservé)",
err instanceof Error ? err.stack : String(err),
);
}
const { parentsPourEmail: _parentsPourEmail, ...response } = result;
return response;
}
private async resoumettreRepriseAM(
user: Users,
dto: ResoumettreRepriseDto,
): Promise<{ message: string; statut: StatutUtilisateurType; user_id: string; numero_dossier?: string }> {
if (
dto.capacite_accueil !== undefined &&
dto.places_disponibles !== undefined &&
dto.places_disponibles > dto.capacite_accueil
) {
throw new BadRequestException(
'Le nombre de places disponibles ne peut pas dépasser la capacité d\'accueil.',
);
}
let urlPhoto: string | undefined;
const photoB64 = (dto.photo_base64 ?? '').trim();
if (photoB64) {
const nomFichier = (dto.photo_filename ?? '').trim() || 'photo_am.jpg';
urlPhoto = await this.sauvegarderPhotoDepuisBase64(photoB64, nomFichier);
} else if (dto.photo_url !== undefined) {
urlPhoto = dto.photo_url;
}
if (dto.nir !== undefined) {
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
const nirValidation = validateNir(nirNormalized, {
dateNaissance: dto.date_naissance ?? user.date_naissance?.toISOString().slice(0, 10),
});
if (!nirValidation.valid) {
throw new BadRequestException(nirValidation.error || 'NIR invalide');
}
}
return this.usersRepo.manager.transaction(async (manager) => {
const userLive = await manager.findOne(Users, { where: { id: user.id } });
if (!userLive) {
throw new NotFoundException('Utilisateur introuvable.');
}
this.applyTitulaireUserFields(userLive, dto);
if (urlPhoto !== undefined) userLive.photo_url = urlPhoto;
if (dto.consentement_photo !== undefined) {
userLive.consentement_photo = dto.consentement_photo;
if (dto.consentement_photo) {
userLive.date_consentement_photo = new Date();
}
}
if (dto.date_naissance !== undefined) {
userLive.date_naissance = new Date(dto.date_naissance);
}
if (dto.lieu_naissance_ville !== undefined) {
userLive.lieu_naissance_ville = dto.lieu_naissance_ville;
}
if (dto.lieu_naissance_pays !== undefined) {
userLive.lieu_naissance_pays = dto.lieu_naissance_pays;
}
const am = await manager.findOne(AssistanteMaternelle, { where: { user_id: user.id } });
if (!am) {
throw new NotFoundException('Fiche assistante maternelle introuvable.');
}
if (dto.nir !== undefined) {
const nirNormalized = dto.nir.replace(/\s/g, '').toUpperCase();
const nirDejaUtilise = await manager.findOne(AssistanteMaternelle, {
where: { nir: nirNormalized },
});
if (nirDejaUtilise && nirDejaUtilise.user_id !== user.id) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro NIR existe déjà.',
);
}
am.nir = nirNormalized;
}
if (dto.numero_agrement !== undefined) {
const agrement = dto.numero_agrement.trim();
const agrementDejaUtilise = await manager.findOne(AssistanteMaternelle, {
where: { approval_number: agrement },
});
if (agrementDejaUtilise && agrementDejaUtilise.user_id !== user.id) {
throw new ConflictException(
'Un compte assistante maternelle avec ce numéro d\'agrément existe déjà.',
);
}
am.approval_number = agrement;
}
if (dto.date_agrement !== undefined) {
am.agreement_date = new Date(dto.date_agrement);
}
if (dto.capacite_accueil !== undefined) {
am.max_children = dto.capacite_accueil;
}
if (dto.places_disponibles !== undefined) {
am.places_available = dto.places_disponibles;
}
if (dto.biographie !== undefined) {
am.biography = dto.biographie;
}
userLive.statut = StatutUtilisateurType.EN_ATTENTE;
userLive.token_reprise = undefined;
userLive.token_reprise_expire_le = undefined;
await manager.save(AssistanteMaternelle, am);
await manager.save(Users, userLive);
return {
message: 'Dossier resoumis avec succès. Il est de nouveau en attente de validation.',
statut: StatutUtilisateurType.EN_ATTENTE,
user_id: userLive.id,
numero_dossier: userLive.numero_dossier,
};
});
}
private async getFamilyChildrenIds(
manager: EntityManager,
parentUserId: string,
): Promise<Set<string>> {
const familyUserIds = await this.parentsService.getFamilyUserIds(parentUserId);
const links = await manager.find(ParentsChildren, {
where: { parentId: In(familyUserIds) },
});
return new Set(links.map((l) => l.enfantId));
}
/** POST reprise-identify : numero_dossier + email → type + token. Ticket #111 */
async identifyReprise(numero_dossier: string, email: string): Promise<RepriseIdentifyResponseDto> {
const user = await this.usersService.findByNumeroDossierAndEmailForReprise(numero_dossier, email);
if (!user || !user.token_reprise) {
throw new NotFoundException('Aucun dossier en reprise trouvé pour ce numéro et cet email.');
}
return {
type: user.role === RoleType.PARENT ? 'parent' : 'assistante_maternelle',
token: user.token_reprise,
};
}
/** Violation unique PostgreSQL (ex. email déjà présent malgré course entre requêtes). */
private isPostgresUniqueViolation(err: unknown): boolean {
if (!(err instanceof QueryFailedError)) {
return false;
}
const code = (err.driverError as { code?: string } | undefined)?.code;
return code === '23505';
}
} }
@@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength, Matches } from 'class-validator';
export class CreatePasswordDto {
@ApiProperty({ description: 'Token de création de mot de passe reçu par email' })
@IsString()
token: string;
@ApiProperty({
description: 'Nouveau mot de passe (min 8 caractères, 1 majuscule, 1 chiffre)',
minLength: 8,
})
@IsString()
@MinLength(8, { message: 'Le mot de passe doit contenir au moins 8 caractères' })
@Matches(/^(?=.*[A-Z])(?=.*\d)/, {
message: 'Le mot de passe doit contenir au moins une majuscule et un chiffre',
})
password: string;
@ApiProperty({ description: 'Confirmation du nouveau mot de passe' })
@IsString()
password_confirmation: string;
}
@@ -1,11 +0,0 @@
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;
}
@@ -1,20 +0,0 @@
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,5 +1,4 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { import {
IsEmail, IsEmail,
IsNotEmpty, IsNotEmpty,
@@ -77,12 +76,7 @@ export class RegisterAMCompletDto {
@IsString() @IsString()
photo_base64?: string; photo_base64?: string;
@ApiProperty({ @ApiProperty({ example: 'photo_profil.jpg', required: false })
example: 'photo_profil.jpg',
required: false,
description:
'Nom du fichier photo (optionnel si photo_base64 est fourni ; défaut serveur : photo_am.jpg). Lextension réelle du fichier stocké suit le type MIME du data-URL.',
})
@IsOptional() @IsOptional()
@IsString() @IsString()
photo_filename?: string; photo_filename?: string;
@@ -97,28 +91,22 @@ export class RegisterAMCompletDto {
@IsDateString() @IsDateString()
date_naissance?: string; date_naissance?: string;
@ApiProperty({ example: 'Paris', description: 'Ville de naissance (obligatoire)' }) @ApiProperty({ example: 'Paris', required: false, description: 'Ville de naissance' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsOptional()
@IsString() @IsString()
@IsNotEmpty({ message: 'La ville de naissance est requise' })
@MinLength(2, { message: 'La ville de naissance doit contenir au moins 2 caractères' })
@MaxLength(100) @MaxLength(100)
lieu_naissance_ville: string; lieu_naissance_ville?: string;
@ApiProperty({ example: 'France', description: 'Pays de naissance (obligatoire)' }) @ApiProperty({ example: 'France', required: false, description: 'Pays de naissance' })
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) @IsOptional()
@IsString() @IsString()
@IsNotEmpty({ message: 'Le pays de naissance est requis' })
@MinLength(2, { message: 'Le pays de naissance doit contenir au moins 2 caractères' })
@MaxLength(100) @MaxLength(100)
lieu_naissance_pays: string; lieu_naissance_pays?: string;
@ApiProperty({ example: '123456789012345', description: 'NIR 15 caractères (chiffres, ou 2A/2B pour la Corse)' }) @ApiProperty({ example: '123456789012345', description: 'NIR 15 chiffres' })
@IsString() @IsString()
@IsNotEmpty({ message: 'Le NIR est requis' }) @IsNotEmpty({ message: 'Le NIR est requis' })
@Matches(/^[1-3]\d{4}(?:2A|2B|\d{2})\d{6}\d{2}$/, { @Matches(/^\d{15}$/, { message: 'Le NIR doit contenir exactement 15 chiffres' })
message: 'Le NIR doit contenir 15 caractères (chiffres, ou 2A/2B pour la Corse)',
})
nir: string; nir: string;
@ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" }) @ApiProperty({ example: 'AGR-2024-12345', description: "Numéro d'agrément" })
@@ -138,17 +126,6 @@ export class RegisterAMCompletDto {
@Max(10, { message: 'La capacité ne peut pas dépasser 10' }) @Max(10, { message: 'La capacité ne peut pas dépasser 10' })
capacite_accueil: number; capacite_accueil: number;
@ApiProperty({
example: 2,
description: 'Nombre de places libres actuellement (≤ capacité d\'accueil)',
minimum: 0,
maximum: 10,
})
@IsInt()
@Min(0, { message: 'Les places disponibles ne peuvent pas être négatives' })
@Max(10, { message: 'Les places disponibles ne peuvent pas dépasser 10' })
places_disponibles: number;
// ============================================ // ============================================
// ÉTAPE 3 : PRÉSENTATION (Optionnel) // ÉTAPE 3 : PRÉSENTATION (Optionnel)
// ============================================ // ============================================
@@ -1,17 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Réponse 201 POST /auth/register/am (alignée sur les champs clés de /auth/register/parent). */
export class RegisterAmResponseDto {
@ApiProperty()
message: string;
@ApiProperty({ format: 'uuid' })
user_id: string;
@ApiProperty({ enum: StatutUtilisateurType, example: StatutUtilisateurType.EN_ATTENTE })
statut: StatutUtilisateurType;
@ApiProperty({ example: '2026-000015', description: 'Numéro de dossier attribué à linscription' })
numero_dossier: string;
}
@@ -1,91 +0,0 @@
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 dossier complet pour préremplir le wizard reprise. #111 + #112 */
export class RepriseDossierDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ required: false })
numero_dossier?: string;
@ApiProperty({ enum: RoleType })
role: RoleType;
@ApiProperty({ required: false, description: 'Pour AM' })
photo_url?: string;
@ApiProperty({ required: false })
genre?: string;
@ApiProperty({ required: false })
situation_familiale?: string;
// --- 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;
}
@@ -1,23 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MaxLength } from 'class-validator';
/** Body POST /auth/reprise-identify numéro + email pour obtenir token reprise. Ticket #111 */
export class RepriseIdentifyBodyDto {
@ApiProperty({ example: '2026-000001' })
@IsString()
@MaxLength(20)
numero_dossier: string;
@ApiProperty({ example: 'parent@example.com' })
@IsEmail()
email: string;
}
/** Réponse POST /auth/reprise-identify */
export class RepriseIdentifyResponseDto {
@ApiProperty({ enum: ['parent', 'assistante_maternelle'] })
type: 'parent' | 'assistante_maternelle';
@ApiProperty({ description: 'Token à utiliser pour GET reprise-dossier et PUT reprise-resoumettre' })
token: string;
}
@@ -1,25 +0,0 @@
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,205 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
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 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()
@MaxLength(100)
prenom?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(100)
nom?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(20)
telephone?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
adresse?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(150)
ville?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
@MaxLength(10)
code_postal?: string;
@ApiProperty({ required: false, description: 'Pour AM (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;
}
@@ -1,26 +0,0 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { DossiersService } from './dossiers.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
@ApiTags('Dossiers')
@Controller('dossiers')
@UseGuards(AuthGuard, RolesGuard)
export class DossiersController {
constructor(private readonly dossiersService: DossiersService) {}
@Get(':numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Dossier complet par numéro (AM ou famille) Ticket #119' })
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
@ApiResponse({ status: 200, description: 'Dossier famille ou AM', type: DossierUnifieDto })
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
getDossier(@Param('numeroDossier') numeroDossier: string): Promise<DossierUnifieDto> {
return this.dossiersService.getDossierByNumero(numeroDossier);
}
}
+1 -25
View File
@@ -1,28 +1,4 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsModule } from '../parents/parents.module';
import { DossiersController } from './dossiers.controller';
import { DossiersService } from './dossiers.service';
@Module({ @Module({})
imports: [
TypeOrmModule.forFeature([Parents, AssistanteMaternelle]),
ParentsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [DossiersController],
providers: [DossiersService],
exports: [DossiersService],
})
export class DossiersModule {} export class DossiersModule {}
@@ -1,100 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity';
import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entity';
import { ParentsService } from '../parents/parents.service';
import { DossierUnifieDto } from './dto/dossier-unifie.dto';
import { DossierAmCompletDto, DossierAmUserDto } from './dto/dossier-am-complet.dto';
/**
* Endpoint unifié GET /dossiers/:numeroDossier AM ou famille. Ticket #119.
*/
@Injectable()
export class DossiersService {
constructor(
@InjectRepository(Parents)
private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle)
private readonly amRepository: Repository<AssistanteMaternelle>,
private readonly parentsService: ParentsService,
) {}
async getDossierByNumero(numeroDossier: string): Promise<DossierUnifieDto> {
const num = numeroDossier?.trim();
if (!num) {
throw new NotFoundException('Numéro de dossier requis.');
}
// 1) Famille : un parent a ce numéro ?
const parentWithNum = await this.parentsRepository.findOne({
where: { numero_dossier: num },
select: ['user_id'],
});
if (parentWithNum) {
const dossier = await this.parentsService.getDossierFamilleByNumero(num);
return { type: 'family', dossier };
}
// 2) AM : une assistante maternelle a ce numéro ?
const am = await this.amRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (am?.user) {
const dossier: DossierAmCompletDto = {
numero_dossier: num,
user: this.toDossierAmUserDto(am.user),
numero_agrement: am.approval_number,
nir: am.nir,
biographie: am.biography,
disponible: am.available,
ville_residence: am.residence_city,
date_agrement: am.agreement_date,
annees_experience: am.years_experience,
specialite: am.specialty,
nb_max_enfants: am.max_children,
place_disponible: am.places_available,
};
return { type: 'am', dossier };
}
throw new NotFoundException('Aucun dossier trouvé pour ce numéro.');
}
private toDossierAmUserDto(user: {
id: string;
email: string;
prenom?: string;
nom?: string;
telephone?: string;
adresse?: string;
ville?: string;
code_postal?: string;
profession?: string;
date_naissance?: Date;
lieu_naissance_ville?: string;
lieu_naissance_pays?: string;
photo_url?: string;
consentement_photo?: boolean;
statut: any;
}): DossierAmUserDto {
return {
id: user.id,
email: user.email,
prenom: user.prenom,
nom: user.nom,
telephone: user.telephone,
adresse: user.adresse,
ville: user.ville,
code_postal: user.code_postal,
profession: user.profession,
date_naissance: user.date_naissance,
lieu_naissance_ville: user.lieu_naissance_ville,
lieu_naissance_pays: user.lieu_naissance_pays,
photo_url: user.photo_url,
consentement_photo: user.consentement_photo,
statut: user.statut,
};
}
}
@@ -1,64 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
/** Utilisateur AM sans données sensibles (pour dossier AM complet). Ticket #119 */
export class DossierAmUserDto {
@ApiProperty()
id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ required: false })
profession?: string;
@ApiProperty({ required: false })
date_naissance?: Date;
@ApiProperty({ required: false, description: 'Ville de naissance' })
lieu_naissance_ville?: string;
@ApiProperty({ required: false, description: 'Pays de naissance' })
lieu_naissance_pays?: string;
@ApiProperty({ required: false })
photo_url?: string;
@ApiProperty({ required: false, description: 'Consentement utilisation photo' })
consentement_photo?: boolean;
@ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType;
}
/** Dossier AM complet (fiche AM sans secrets). Ticket #119 */
export class DossierAmCompletDto {
@ApiProperty({ example: '2026-000003', description: 'Numéro de dossier AM' })
numero_dossier: string;
@ApiProperty({ type: DossierAmUserDto, description: 'Utilisateur (sans mot de passe ni tokens)' })
user: DossierAmUserDto;
@ApiProperty({ required: false })
numero_agrement?: string;
@ApiProperty({ required: false })
nir?: string;
@ApiProperty({ required: false })
biographie?: string;
@ApiProperty({ required: false })
disponible?: boolean;
@ApiProperty({ required: false })
ville_residence?: string;
@ApiProperty({ required: false })
date_agrement?: Date;
@ApiProperty({ required: false })
annees_experience?: number;
@ApiProperty({ required: false })
specialite?: string;
@ApiProperty({ required: false })
nb_max_enfants?: number;
@ApiProperty({ required: false })
place_disponible?: number;
}
@@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { DossierFamilleCompletDto } from '../../parents/dto/dossier-famille-complet.dto';
import { DossierAmCompletDto } from './dossier-am-complet.dto';
/** Réponse unifiée GET /dossiers/:numeroDossier AM ou famille. Ticket #119 */
export class DossierUnifieDto {
@ApiProperty({ enum: ['family', 'am'], description: 'Type de dossier' })
type: 'family' | 'am';
@ApiProperty({
description: 'Dossier famille (si type=family) ou dossier AM (si type=am)',
})
dossier: DossierFamilleCompletDto | DossierAmCompletDto;
}
@@ -83,12 +83,7 @@ export class EnfantsController {
return this.enfantsService.findOne(id, currentUser); return this.enfantsService.findOne(id, currentUser);
} }
@Roles( @Roles(RoleType.ADMINISTRATEUR, RoleType.SUPER_ADMIN, RoleType.PARENT)
RoleType.PARENT,
RoleType.ADMINISTRATEUR,
RoleType.SUPER_ADMIN,
RoleType.GESTIONNAIRE,
)
@Patch(':id') @Patch(':id')
update( update(
@Param('id', new ParseUUIDPipe()) id: string, @Param('id', new ParseUUIDPipe()) id: string,
@@ -78,10 +78,10 @@ export class EnfantsService {
return this.findOne(child.id, currentUser); return this.findOne(child.id, currentUser);
} }
// Liste des enfants (admin/gestionnaire) // Liste des enfants
async findAll(): Promise<Children[]> { async findAll(): Promise<Children[]> {
return this.childrenRepository.find({ return this.childrenRepository.find({
relations: ['parentLinks', 'parentLinks.parent', 'parentLinks.parent.user'], relations: ['parentLinks'],
order: { last_name: 'ASC', first_name: 'ASC' }, order: { last_name: 'ASC', first_name: 'ASC' },
}); });
} }
@@ -1,72 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { StatutUtilisateurType } from 'src/entities/users.entity';
import { StatutEnfantType, GenreType } from 'src/entities/children.entity';
/** Parent dans le dossier famille (infos utilisateur + parent) */
export class DossierFamilleParentDto {
@ApiProperty()
user_id: string;
@ApiProperty()
email: string;
@ApiProperty({ required: false })
prenom?: string;
@ApiProperty({ required: false })
nom?: string;
@ApiProperty({ required: false })
telephone?: string;
@ApiProperty({ required: false })
adresse?: string;
@ApiProperty({ required: false })
ville?: string;
@ApiProperty({ required: false })
code_postal?: string;
@ApiProperty({ enum: StatutUtilisateurType })
statut: StatutUtilisateurType;
@ApiProperty({ required: false, description: 'Id du co-parent si couple' })
co_parent_id?: string;
}
/** Enfant dans le dossier famille */
export class DossierFamilleEnfantDto {
@ApiProperty()
id: string;
@ApiProperty({ required: false })
first_name?: string;
@ApiProperty({ required: false })
last_name?: string;
@ApiProperty({ required: false, enum: GenreType })
genre?: GenreType;
@ApiProperty({ required: false })
birth_date?: Date;
@ApiProperty({ required: false })
due_date?: Date;
@ApiProperty({ enum: StatutEnfantType })
status: StatutEnfantType;
@ApiProperty({
required: false,
description: 'Chemin ou URL de la photo (souvent relatif, ex. /uploads/photos/...)',
})
photo_url?: string;
@ApiProperty({
required: false,
description: 'Consentement affichage photo (colonne consentement_photo)',
})
consent_photo?: boolean;
@ApiProperty({ required: false, description: 'Grossesse multiple (est_multiple)' })
est_multiple?: boolean;
}
/** Réponse GET /parents/dossier-famille/:numeroDossier dossier famille complet. Ticket #119 */
export class DossierFamilleCompletDto {
@ApiProperty({ example: '2026-000001', description: 'Numéro de dossier famille' })
numero_dossier: string;
@ApiProperty({ type: [DossierFamilleParentDto] })
parents: DossierFamilleParentDto[];
@ApiProperty({ type: [DossierFamilleEnfantDto], description: 'Enfants de la famille' })
enfants: DossierFamilleEnfantDto[];
@ApiProperty({ required: false, description: 'Texte de présentation / motivation (un seul par famille)' })
texte_motivation?: string;
}
@@ -1,63 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ParentPendingSummaryDto {
@ApiProperty({ description: 'UUID utilisateur' })
id: string;
@ApiProperty()
email: string;
@ApiPropertyOptional({ nullable: true })
telephone?: string | null;
@ApiPropertyOptional({ nullable: true })
code_postal?: string | null;
@ApiPropertyOptional({ nullable: true })
ville?: string | null;
}
export class PendingFamilyDto {
@ApiProperty({ example: 'Famille Dupont', description: 'Libellé affiché pour la famille' })
libelle: string;
@ApiProperty({
type: [String],
example: ['uuid-parent-1', 'uuid-parent-2'],
description: 'IDs utilisateur des parents de la famille',
})
parentIds: string[];
@ApiProperty({
nullable: true,
example: '2026-000001',
description: 'Numéro de dossier famille (format AAAA-NNNNNN)',
})
numero_dossier: string | null;
@ApiProperty({
nullable: true,
example: '2026-01-12T10:00:00.000Z',
description: 'Date de référence dossier soumis / en attente : MIN(cree_le) des parents en_attente du groupe (ISO 8601)',
})
date_soumission: string | null;
@ApiProperty({
example: 3,
description: 'Nombre denfants distincts liés aux parents de la famille (enfants_parents)',
})
nombre_enfants: number;
@ApiPropertyOptional({
type: [String],
example: ['parent1@example.com', 'parent2@example.com'],
description: 'Emails des parents du groupe (ordre stable : nom, prénom)',
})
emails?: string[];
@ApiPropertyOptional({
type: [ParentPendingSummaryDto],
description: 'Résumé des parents (ordre stable, aligné sur parentIds/emails)',
})
parents?: ParentPendingSummaryDto[];
}
@@ -1,57 +0,0 @@
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;
}
@@ -6,80 +6,19 @@ import {
Param, Param,
Patch, Patch,
Post, Post,
UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ParentsService } from './parents.service'; import { ParentsService } from './parents.service';
import { UserService } from '../user/user.service';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { Users } from 'src/entities/users.entity';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType, StatutUtilisateurType } from 'src/entities/users.entity'; import { RoleType } from 'src/entities/users.entity';
import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger';
import { CreateParentDto } from '../user/dto/create_parent.dto'; import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { User } from 'src/common/decorators/user.decorator';
import { PendingFamilyDto } from './dto/pending-family.dto';
import { DossierFamilleCompletDto } from './dto/dossier-famille-complet.dto';
@ApiTags('Parents') @ApiTags('Parents')
@Controller('parents') @Controller('parents')
@UseGuards(AuthGuard, RolesGuard)
export class ParentsController { export class ParentsController {
constructor( constructor(private readonly parentsService: ParentsService) {}
private readonly parentsService: ParentsService,
private readonly userService: UserService,
) {}
@Get('pending-families')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Liste des familles en attente (une entrée par famille)' })
@ApiResponse({
status: 200,
description:
'Liste des familles (libellé, parentIds, numero_dossier, date_soumission, nombre_enfants, emails, parents)',
type: [PendingFamilyDto],
})
@ApiResponse({ status: 403, description: 'Accès refusé' })
getPendingFamilies(): Promise<PendingFamilyDto[]> {
return this.parentsService.getPendingFamilies();
}
@Get('dossier-famille/:numeroDossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Dossier famille complet par numéro de dossier (Ticket #119)' })
@ApiParam({ name: 'numeroDossier', description: 'Numéro de dossier (ex: 2026-000001)' })
@ApiResponse({ status: 200, description: 'Dossier famille (numero_dossier, parents, enfants, presentation)', type: DossierFamilleCompletDto })
@ApiResponse({ status: 404, description: 'Aucun dossier pour ce numéro' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
getDossierFamille(@Param('numeroDossier') numeroDossier: string): Promise<DossierFamilleCompletDto> {
return this.parentsService.getDossierFamilleByNumero(numeroDossier);
}
@Post(':parentId/valider-dossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Valider tout le dossier famille (les 2 parents en une fois)' })
@ApiParam({ name: 'parentId', description: "UUID d'un des parents (user_id)" })
@ApiResponse({ status: 200, description: 'Utilisateurs validés (famille)' })
@ApiResponse({ status: 404, description: 'Parent introuvable' })
@ApiResponse({ status: 403, description: 'Accès refusé' })
async validerDossierFamille(
@Param('parentId') parentId: string,
@User() currentUser: Users,
@Body('comment') comment?: string,
): Promise<Users[]> {
const familyIds = await this.parentsService.getFamilyUserIds(parentId);
const validated: Users[] = [];
for (const userId of familyIds) {
const user = await this.userService.findOne(userId);
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) continue;
const saved = await this.userService.validateUser(userId, currentUser, comment);
validated.push(saved);
}
return validated;
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@Get() @Get()
@@ -89,7 +28,7 @@ export class ParentsController {
return this.parentsService.findAll(); return this.parentsService.findAll();
} }
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Get(':id') @Get(':id')
@ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' }) @ApiResponse({ status: 200, type: Parents, description: 'Détails du parent par ID utilisateur' })
@ApiResponse({ status: 404, description: 'Parent non trouvé' }) @ApiResponse({ status: 404, description: 'Parent non trouvé' })
@@ -107,45 +46,6 @@ export class ParentsController {
return this.parentsService.create(dto); return this.parentsService.create(dto);
} }
@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' })
updateFicheAdmin(
@Param('id') id: string,
@Body() dto: UpdateParentFicheAdminDto,
): Promise<Parents> {
return this.parentsService.updateFicheAdmin(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' })
attachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
return this.parentsService.attachEnfant(id, enfantId);
}
@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' })
detachEnfant(
@Param('id') id: string,
@Param('enfantId') enfantId: string,
): Promise<Parents> {
return this.parentsService.detachEnfant(id, enfantId);
}
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE)
@Patch(':id') @Patch(':id')
@ApiBody({ type: UpdateParentsDto }) @ApiBody({ type: UpdateParentsDto })
+2 -18
View File
@@ -1,28 +1,12 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { Parents } from 'src/entities/parents.entity'; 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 { ParentsController } from './parents.controller';
import { ParentsService } from './parents.service'; import { ParentsService } from './parents.service';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { UserModule } from '../user/user.module';
@Module({ @Module({
imports: [ imports: [TypeOrmModule.forFeature([Parents, Users])],
TypeOrmModule.forFeature([Parents, Users, DossierFamille, DossierFamilleEnfant, ParentsChildren]),
forwardRef(() => UserModule),
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
secret: config.get('jwt.accessSecret'),
signOptions: { expiresIn: config.get('jwt.accessExpiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [ParentsController], controllers: [ParentsController],
providers: [ParentsService], providers: [ParentsService],
exports: [ParentsService, exports: [ParentsService,
+3 -366
View File
@@ -5,21 +5,11 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { DossierFamille } from 'src/entities/dossier_famille.entity';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { CreateParentDto } from '../user/dto/create_parent.dto'; import { CreateParentDto } from '../user/dto/create_parent.dto';
import { UpdateParentsDto } from '../user/dto/update_parent.dto'; import { UpdateParentsDto } from '../user/dto/update_parent.dto';
import { PendingFamilyDto } from './dto/pending-family.dto';
import {
DossierFamilleCompletDto,
DossierFamilleParentDto,
DossierFamilleEnfantDto,
} from './dto/dossier-famille-complet.dto';
import { ParentsChildren } from 'src/entities/parents_children.entity';
import { Children } from 'src/entities/children.entity';
import { UpdateParentFicheAdminDto } from './dto/update-parent-fiche-admin.dto';
@Injectable() @Injectable()
export class ParentsService { export class ParentsService {
@@ -28,10 +18,6 @@ export class ParentsService {
private readonly parentsRepository: Repository<Parents>, private readonly parentsRepository: Repository<Parents>,
@InjectRepository(Users) @InjectRepository(Users)
private readonly usersRepository: Repository<Users>, private readonly usersRepository: Repository<Users>,
@InjectRepository(DossierFamille)
private readonly dossierFamilleRepository: Repository<DossierFamille>,
@InjectRepository(ParentsChildren)
private readonly parentsChildrenRepository: Repository<ParentsChildren>,
) {} ) {}
// Création dun parent // Création dun parent
@@ -66,7 +52,7 @@ export class ParentsService {
// Liste des parents // Liste des parents
async findAll(): Promise<Parents[]> { async findAll(): Promise<Parents[]> {
return this.parentsRepository.find({ return this.parentsRepository.find({
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
}); });
} }
@@ -74,7 +60,7 @@ export class ParentsService {
async findOne(user_id: string): Promise<Parents> { async findOne(user_id: string): Promise<Parents> {
const parent = await this.parentsRepository.findOne({ const parent = await this.parentsRepository.findOne({
where: { user_id }, where: { user_id },
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers'], relations: ['user', 'co_parent', 'parentChildren', 'dossiers'],
}); });
if (!parent) throw new NotFoundException('Parent introuvable'); if (!parent) throw new NotFoundException('Parent introuvable');
return parent; return parent;
@@ -85,353 +71,4 @@ export class ParentsService {
await this.parentsRepository.update(id, dto); await this.parentsRepository.update(id, dto);
return this.findOne(id); 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);
}
/**
* Rattacher un enfant existant à un parent (enfants_parents). Ticket #115 / doc 28 §6.2.
*/
async attachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const existing = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (existing) {
throw new ConflictException('Cet enfant est déjà rattaché à ce parent');
}
const child = await this.parentsRepository.manager.findOne(Children, { where: { id: enfantId } });
if (!child) {
throw new NotFoundException('Enfant introuvable');
}
await this.parentsChildrenRepository.save(
this.parentsChildrenRepository.create({ parentId: parentUserId, enfantId }),
);
return this.findOne(parentUserId);
}
/**
* Détacher un enfant d'un parent sans supprimer l'enfant. Ticket #115 / doc 28 §6.2.
*/
async detachEnfant(parentUserId: string, enfantId: string): Promise<Parents> {
await this.findOne(parentUserId);
const link = await this.parentsChildrenRepository.findOne({
where: { parentId: parentUserId, enfantId },
});
if (!link) {
throw new NotFoundException('Lien parent-enfant introuvable');
}
const totalLinks = await this.parentsChildrenRepository.count({ where: { enfantId } });
if (totalLinks <= 1) {
throw new BadRequestException('Un enfant doit rester rattaché à au moins un responsable');
}
await this.parentsChildrenRepository.delete({ parentId: parentUserId, 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).
* Uniquement les parents dont l'utilisateur a statut = en_attente.
*/
async getPendingFamilies(): Promise<PendingFamilyDto[]> {
let raw: {
libelle: string;
parentIds: unknown;
numero_dossier: string | null;
date_soumission: Date | string | null;
nombre_enfants: string | number | null;
emails: unknown;
parents: unknown;
}[];
try {
raw = await this.parentsRepository.query(`
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
)
SELECT
'Famille ' || string_agg(u.nom, ' - ' ORDER BY u.nom, u.prenom) AS libelle,
array_agg(p.id_utilisateur ORDER BY u.nom, u.prenom, u.id) AS "parentIds",
(array_agg(p.numero_dossier))[1] AS numero_dossier,
MIN(u.cree_le) AS date_soumission,
COALESCE((
SELECT COUNT(DISTINCT ep.id_enfant)::int
FROM enfants_parents ep
WHERE ep.id_parent IN (
SELECT frx.id FROM family_rep frx WHERE frx.rep = fr.rep
)
), 0) AS nombre_enfants,
array_agg(u.email ORDER BY u.nom, u.prenom, u.id) AS emails,
json_agg(
json_build_object(
'id', u.id::text,
'email', u.email,
'telephone', u.telephone,
'code_postal', u.code_postal,
'ville', u.ville
)
ORDER BY u.nom, u.prenom, u.id
) AS parents
FROM family_rep fr
JOIN parents p ON p.id_utilisateur = fr.id
JOIN utilisateurs u ON u.id = p.id_utilisateur
WHERE u.role = 'parent' AND u.statut = 'en_attente'
GROUP BY fr.rep
ORDER BY libelle
`);
} catch (err) {
throw err;
}
if (!Array.isArray(raw)) return [];
return raw.map((r) => ({
libelle: r.libelle ?? '',
parentIds: this.normalizeParentIds(r.parentIds),
numero_dossier: r.numero_dossier ?? null,
date_soumission: this.toIsoDateTimeOrNull(r.date_soumission),
nombre_enfants: this.normalizeNombreEnfants(r.nombre_enfants),
emails: this.normalizeEmails(r.emails),
parents: this.normalizeParents(r.parents),
}));
}
private toIsoDateTimeOrNull(value: Date | string | null | undefined): string | null {
if (value == null) return null;
if (value instanceof Date) return value.toISOString();
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
private normalizeNombreEnfants(v: string | number | null | undefined): number {
if (v == null) return 0;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
return Number.isFinite(n) && n >= 0 ? n : 0;
}
private normalizeEmails(emails: unknown): string[] {
if (Array.isArray(emails)) return emails.map(String);
if (typeof emails === 'string') {
const s = emails.replace(/^\{|\}$/g, '').trim();
return s ? s.split(',').map((x) => x.trim()) : [];
}
return [];
}
private normalizeParents(parents: unknown): { id: string; email: string; telephone: string | null; code_postal: string | null; ville: string | null }[] {
if (Array.isArray(parents)) {
return parents.map((p: any) => ({
id: String(p?.id ?? ''),
email: String(p?.email ?? ''),
telephone: p?.telephone != null ? String(p.telephone) : null,
code_postal: p?.code_postal != null ? String(p.code_postal) : null,
ville: p?.ville != null ? String(p.ville) : null,
}));
}
if (typeof parents === 'string') {
try {
const parsed = JSON.parse(parents);
return this.normalizeParents(parsed);
} catch {
return [];
}
}
return [];
}
/** Convertit parentIds (array ou chaîne PG) en string[] pour éviter 500 si le driver renvoie une chaîne. */
private childToDossierFamilleEnfantDto(child: Children): DossierFamilleEnfantDto {
return {
id: child.id,
first_name: child.first_name,
last_name: child.last_name,
genre: child.gender,
birth_date: child.birth_date,
due_date: child.due_date,
status: child.status,
photo_url: child.photo_url ?? undefined,
consent_photo: child.consent_photo,
est_multiple: child.is_multiple,
};
}
private normalizeParentIds(parentIds: unknown): string[] {
if (Array.isArray(parentIds)) return parentIds.map(String);
if (typeof parentIds === 'string') {
const s = parentIds.replace(/^\{|\}$/g, '').trim();
return s ? s.split(',').map((x) => x.trim()) : [];
}
return [];
}
/**
* Dossier famille complet par numéro de dossier. Ticket #119.
* Rôles : admin, gestionnaire.
* @throws NotFoundException si aucun parent avec ce numéro de dossier
*/
async getDossierFamilleByNumero(numeroDossier: string): Promise<DossierFamilleCompletDto> {
const num = numeroDossier?.trim();
if (!num) {
throw new NotFoundException('Numéro de dossier requis.');
}
const firstParent = await this.parentsRepository.findOne({
where: { numero_dossier: num },
relations: ['user'],
});
if (!firstParent || !firstParent.user) {
throw new NotFoundException('Aucun dossier famille trouvé pour ce numéro.');
}
const familyUserIds = await this.getFamilyUserIds(firstParent.user_id);
const parents = await this.parentsRepository.find({
where: { user_id: In(familyUserIds) },
relations: ['user', 'co_parent', 'parentChildren', 'parentChildren.child', 'dossiers', 'dossiers.child'],
});
const enfantsMap = new Map<string, DossierFamilleEnfantDto>();
let texte_motivation: string | undefined;
// Un dossier = une famille, un seul texte de motivation
const dossierFamille = await this.dossierFamilleRepository.findOne({
where: { numero_dossier: num },
relations: ['parent', 'enfants', 'enfants.enfant'],
});
if (dossierFamille?.presentation) {
texte_motivation = dossierFamille.presentation;
}
for (const p of parents) {
// Enfants via parentChildren
if (p.parentChildren) {
for (const pc of p.parentChildren) {
if (pc.child && !enfantsMap.has(pc.child.id)) {
enfantsMap.set(pc.child.id, this.childToDossierFamilleEnfantDto(pc.child));
}
}
}
// Fallback : anciens dossiers (un texte, on prend le premier)
if (texte_motivation == null && p.dossiers?.length) {
texte_motivation = p.dossiers[0].presentation ?? undefined;
}
}
// Enfants uniquement liés via dossier_famille_enfants (legacy / parcours alternatif)
if (dossierFamille?.enfants?.length) {
for (const dfe of dossierFamille.enfants) {
const c = dfe.enfant;
if (c && !enfantsMap.has(c.id)) {
enfantsMap.set(c.id, this.childToDossierFamilleEnfantDto(c));
}
}
}
const parentsDto: DossierFamilleParentDto[] = parents.map((p) => ({
user_id: p.user_id,
email: p.user.email,
prenom: p.user.prenom,
nom: p.user.nom,
telephone: p.user.telephone,
adresse: p.user.adresse,
ville: p.user.ville,
code_postal: p.user.code_postal,
statut: p.user.statut,
co_parent_id: p.co_parent?.id,
}));
return {
numero_dossier: num,
parents: parentsDto,
enfants: Array.from(enfantsMap.values()),
texte_motivation,
};
}
/**
* Retourne les user_id de tous les parents de la même famille (co_parent ou enfants partagés).
* @throws NotFoundException si parentId n'est pas un parent
*/
async getFamilyUserIds(parentId: string): Promise<string[]> {
const raw = await this.parentsRepository.query(
`
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, (MIN(rep::text))::uuid AS rep FROM rec GROUP BY id
),
input_rep AS (
SELECT rep FROM family_rep WHERE id = $1::uuid LIMIT 1
)
SELECT fr.id::text AS id
FROM family_rep fr
CROSS JOIN input_rep ir
WHERE fr.rep = ir.rep
`,
[parentId],
);
if (!raw || raw.length === 0) {
throw new NotFoundException('Parent introuvable ou pas encore enregistré en base.');
}
return raw.map((r: { id: string }) => r.id);
}
} }
@@ -1,34 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsObject } from 'class-validator';
export class CreateRelaisDto {
@ApiProperty({ example: 'Relais Petite Enfance Centre' })
@IsString()
@IsNotEmpty()
nom: string;
@ApiProperty({ example: '12 rue de la Mairie, 75000 Paris' })
@IsString()
@IsNotEmpty()
adresse: string;
@ApiProperty({ example: { lundi: '09:00-17:00' }, required: false })
@IsOptional()
@IsObject()
horaires_ouverture?: any;
@ApiProperty({ example: '0123456789', required: false })
@IsOptional()
@IsString()
ligne_fixe?: string;
@ApiProperty({ default: true, required: false })
@IsOptional()
@IsBoolean()
actif?: boolean;
@ApiProperty({ example: 'Notes internes...', required: false })
@IsOptional()
@IsString()
notes?: string;
}
@@ -1,4 +0,0 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRelaisDto } from './create-relais.dto';
export class UpdateRelaisDto extends PartialType(CreateRelaisDto) {}
@@ -1,57 +0,0 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common';
import { RelaisService } from './relais.service';
import { CreateRelaisDto } from './dto/create-relais.dto';
import { UpdateRelaisDto } from './dto/update-relais.dto';
import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { Roles } from 'src/common/decorators/roles.decorator';
import { RoleType } from 'src/entities/users.entity';
@ApiTags('Relais')
@ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard)
@Controller('relais')
export class RelaisController {
constructor(private readonly relaisService: RelaisService) {}
@Post()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Créer un relais' })
@ApiResponse({ status: 201, description: 'Le relais a été créé.' })
create(@Body() createRelaisDto: CreateRelaisDto) {
return this.relaisService.create(createRelaisDto);
}
@Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Lister tous les relais' })
@ApiResponse({ status: 200, description: 'Liste des relais.' })
findAll() {
return this.relaisService.findAll();
}
@Get(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Récupérer un relais par ID' })
@ApiResponse({ status: 200, description: 'Le relais trouvé.' })
findOne(@Param('id') id: string) {
return this.relaisService.findOne(id);
}
@Patch(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Mettre à jour un relais' })
@ApiResponse({ status: 200, description: 'Le relais a été mis à jour.' })
update(@Param('id') id: string, @Body() updateRelaisDto: UpdateRelaisDto) {
return this.relaisService.update(id, updateRelaisDto);
}
@Delete(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Supprimer un relais' })
@ApiResponse({ status: 200, description: 'Le relais a été supprimé.' })
remove(@Param('id') id: string) {
return this.relaisService.remove(id);
}
}
@@ -1,17 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RelaisService } from './relais.service';
import { RelaisController } from './relais.controller';
import { Relais } from 'src/entities/relais.entity';
import { AuthModule } from 'src/routes/auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Relais]),
AuthModule,
],
controllers: [RelaisController],
providers: [RelaisService],
exports: [RelaisService],
})
export class RelaisModule {}
@@ -1,42 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Relais } from 'src/entities/relais.entity';
import { CreateRelaisDto } from './dto/create-relais.dto';
import { UpdateRelaisDto } from './dto/update-relais.dto';
@Injectable()
export class RelaisService {
constructor(
@InjectRepository(Relais)
private readonly relaisRepository: Repository<Relais>,
) {}
create(createRelaisDto: CreateRelaisDto) {
const relais = this.relaisRepository.create(createRelaisDto);
return this.relaisRepository.save(relais);
}
findAll() {
return this.relaisRepository.find({ order: { nom: 'ASC' } });
}
async findOne(id: string) {
const relais = await this.relaisRepository.findOne({ where: { id } });
if (!relais) {
throw new NotFoundException(`Relais #${id} not found`);
}
return relais;
}
async update(id: string, updateRelaisDto: UpdateRelaisDto) {
const relais = await this.findOne(id);
Object.assign(relais, updateRelaisDto);
return this.relaisRepository.save(relais);
}
async remove(id: string) {
const relais = await this.findOne(id);
return this.relaisRepository.remove(relais);
}
}
@@ -1,14 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, Matches } from 'class-validator';
/** Format AAAA-NNNNNN (année + 6 chiffres) */
const NUMERO_DOSSIER_REGEX = /^\d{4}-\d{6}$/;
export class AffecterNumeroDossierDto {
@ApiProperty({ example: '2026-000004', description: 'Numéro de dossier (AAAA-NNNNNN)' })
@IsNotEmpty({ message: 'Le numéro de dossier est requis' })
@Matches(NUMERO_DOSSIER_REGEX, {
message: 'Le numéro de dossier doit être au format AAAA-NNNNNN (ex: 2026-000001)',
})
numero_dossier: string;
}
@@ -1,10 +1,4 @@
import { PickType } from "@nestjs/swagger"; import { OmitType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto"; import { CreateUserDto } from "./create_user.dto";
export class CreateAdminDto extends PickType(CreateUserDto, [ export class CreateAdminDto extends OmitType(CreateUserDto, ['role'] as const) {}
'nom',
'prenom',
'email',
'password',
'telephone'
] as const) {}
@@ -1,10 +1,4 @@
import { ApiProperty, OmitType } from "@nestjs/swagger"; import { OmitType } from "@nestjs/swagger";
import { CreateUserDto } from "./create_user.dto"; import { CreateUserDto } from "./create_user.dto";
import { IsOptional, IsUUID } from "class-validator";
export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role', 'adresse', 'genre', 'statut', 'situation_familiale', 'ville', 'code_postal', 'photo_url', 'consentement_photo', 'date_consentement_photo', 'changement_mdp_obligatoire'] as const) { export class CreateGestionnaireDto extends OmitType(CreateUserDto, ['role'] as const) {}
@ApiProperty({ required: false, description: 'ID du relais de rattachement' })
@IsOptional()
@IsUUID()
relaisId?: string;
}
@@ -36,10 +36,10 @@ export class CreateUserDto {
@MaxLength(100) @MaxLength(100)
nom: string; nom: string;
@ApiProperty({ enum: GenreType, required: false }) @ApiProperty({ enum: GenreType, required: false, default: GenreType.AUTRE })
@IsOptional() @IsOptional()
@IsEnum(GenreType) @IsEnum(GenreType)
genre?: GenreType; genre?: GenreType = GenreType.AUTRE;
@ApiProperty({ enum: RoleType }) @ApiProperty({ enum: RoleType })
@IsEnum(RoleType) @IsEnum(RoleType)
@@ -86,7 +86,7 @@ export class CreateUserDto {
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
consentement_photo?: boolean; consentement_photo?: boolean = false;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@IsOptional() @IsOptional()
@@ -96,7 +96,7 @@ export class CreateUserDto {
@ApiProperty({ default: false }) @ApiProperty({ default: false })
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
changement_mdp_obligatoire?: boolean; changement_mdp_obligatoire?: boolean = false;
@ApiProperty({ example: true }) @ApiProperty({ example: true })
@IsBoolean() @IsBoolean()
@@ -4,13 +4,11 @@ import { GestionnairesController } from './gestionnaires.controller';
import { Users } from 'src/entities/users.entity'; import { Users } from 'src/entities/users.entity';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthModule } from 'src/routes/auth/auth.module'; import { AuthModule } from 'src/routes/auth/auth.module';
import { MailModule } from 'src/modules/mail/mail.module';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Users]), TypeOrmModule.forFeature([Users]),
AuthModule, AuthModule,
MailModule,
], ],
controllers: [GestionnairesController], controllers: [GestionnairesController],
providers: [GestionnairesService], providers: [GestionnairesService],
@@ -5,18 +5,16 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { RoleType, StatutUtilisateurType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto'; import { CreateGestionnaireDto } from '../dto/create_gestionnaire.dto';
import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto'; import { UpdateGestionnaireDto } from '../dto/update_gestionnaire.dto';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { MailService } from 'src/modules/mail/mail.service';
@Injectable() @Injectable()
export class GestionnairesService { export class GestionnairesService {
constructor( constructor(
@InjectRepository(Users) @InjectRepository(Users)
private readonly gestionnaireRepository: Repository<Users>, private readonly gestionnaireRepository: Repository<Users>,
private readonly mailService: MailService,
) { } ) { }
// Création dun gestionnaire // Création dun gestionnaire
@@ -32,51 +30,30 @@ export class GestionnairesService {
password: hashedPassword, password: hashedPassword,
prenom: dto.prenom, prenom: dto.prenom,
nom: dto.nom, nom: dto.nom,
// genre: dto.genre, // Retiré genre: dto.genre,
// statut: dto.statut, // Retiré statut: dto.statut,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone, telephone: dto.telephone,
// adresse: dto.adresse, // Retiré adresse: dto.adresse,
// photo_url: dto.photo_url, // Retiré photo_url: dto.photo_url,
// consentement_photo: dto.consentement_photo ?? false, // Retiré consentement_photo: dto.consentement_photo ?? false,
// date_consentement_photo: dto.date_consentement_photo // Retiré date_consentement_photo: dto.date_consentement_photo
// ? new Date(dto.date_consentement_photo) ? new Date(dto.date_consentement_photo)
// : undefined, : undefined,
changement_mdp_obligatoire: true, changement_mdp_obligatoire: dto.changement_mdp_obligatoire ?? false,
role: RoleType.GESTIONNAIRE, role: RoleType.GESTIONNAIRE,
relaisId: dto.relaisId,
}); });
return this.gestionnaireRepository.save(entity);
const savedUser = await this.gestionnaireRepository.save(entity);
// Envoi de l'email de bienvenue
try {
await this.mailService.sendGestionnaireWelcomeEmail(
savedUser.email,
savedUser.prenom || '',
savedUser.nom || '',
);
} catch (error) {
// On ne bloque pas la création si l'envoi d'email échoue, mais on log l'erreur
console.error('Erreur lors de l\'envoi de l\'email de bienvenue au gestionnaire', error);
}
return savedUser;
} }
// Liste des gestionnaires // Liste des gestionnaires
async findAll(): Promise<Users[]> { async findAll(): Promise<Users[]> {
return this.gestionnaireRepository.find({ return this.gestionnaireRepository.find({ where: { role: RoleType.GESTIONNAIRE } });
where: { role: RoleType.GESTIONNAIRE },
relations: ['relais'],
});
} }
// Récupérer un gestionnaire par ID // Récupérer un gestionnaire par ID
async findOne(id: string): Promise<Users> { async findOne(id: string): Promise<Users> {
const gestionnaire = await this.gestionnaireRepository.findOne({ const gestionnaire = await this.gestionnaireRepository.findOne({
where: { id, role: RoleType.GESTIONNAIRE }, where: { id, role: RoleType.GESTIONNAIRE },
relations: ['relais'],
}); });
if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable'); if (!gestionnaire) throw new NotFoundException('Gestionnaire introuvable');
return gestionnaire; return gestionnaire;
@@ -91,7 +68,13 @@ export class GestionnairesService {
gestionnaire.password = await bcrypt.hash(dto.password, salt); gestionnaire.password = await bcrypt.hash(dto.password, salt);
} }
const { password, ...rest } = dto; if (dto.date_consentement_photo !== undefined) {
gestionnaire.date_consentement_photo = dto.date_consentement_photo
? new Date(dto.date_consentement_photo)
: undefined;
}
const { password, date_consentement_photo, ...rest } = dto;
Object.entries(rest).forEach(([key, value]) => { Object.entries(rest).forEach(([key, value]) => {
if (value !== undefined) { if (value !== undefined) {
(gestionnaire as any)[key] = value; (gestionnaire as any)[key] = value;
+4 -67
View File
@@ -1,34 +1,20 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from 'src/common/guards/auth.guard'; import { AuthGuard } from 'src/common/guards/auth.guard';
import { RolesGuard } from 'src/common/guards/roles.guard';
import { Roles } from 'src/common/decorators/roles.decorator'; import { Roles } from 'src/common/decorators/roles.decorator';
import { User } from 'src/common/decorators/user.decorator'; import { User } from 'src/common/decorators/user.decorator';
import { RoleType, Users } from 'src/entities/users.entity'; import { RoleType, Users } from 'src/entities/users.entity';
import { UserService } from './user.service'; import { UserService } from './user.service';
import { CreateUserDto } from './dto/create_user.dto'; import { CreateUserDto } from './dto/create_user.dto';
import { CreateAdminDto } from './dto/create_admin.dto';
import { UpdateUserDto } from './dto/update_user.dto'; import { UpdateUserDto } from './dto/update_user.dto';
import { AffecterNumeroDossierDto } from './dto/affecter-numero-dossier.dto';
@ApiTags('Utilisateurs') @ApiTags('Utilisateurs')
@ApiBearerAuth('access-token') @ApiBearerAuth('access-token')
@UseGuards(AuthGuard, RolesGuard) @UseGuards(AuthGuard)
@Controller('users') @Controller('users')
export class UserController { export class UserController {
constructor(private readonly userService: UserService) { } constructor(private readonly userService: UserService) { }
// Création d'un administrateur (réservée aux super admins)
@Post('admin')
@Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Créer un nouvel administrateur (super admin seulement)' })
createAdmin(
@Body() dto: CreateAdminDto,
@User() currentUser: Users
) {
return this.userService.createAdmin(dto, currentUser);
}
// Création d'un utilisateur (réservée aux super admins) // Création d'un utilisateur (réservée aux super admins)
@Post() @Post()
@Roles(RoleType.SUPER_ADMIN) @Roles(RoleType.SUPER_ADMIN)
@@ -40,26 +26,6 @@ export class UserController {
return this.userService.createUser(dto, currentUser); return this.userService.createUser(dto, currentUser);
} }
// Lister les utilisateurs en attente de validation
@Get('pending')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister les utilisateurs en attente de validation' })
findPendingUsers(
@Query('role') role?: RoleType
) {
return this.userService.findPendingUsers(role);
}
// Lister les comptes refusés (à corriger / reprise)
@Get('reprise')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({ summary: 'Lister les comptes refusés (reprise)' })
findRefusedUsers(
@Query('role') role?: RoleType
) {
return this.userService.findRefusedUsers(role);
}
// Lister tous les utilisateurs (super_admin uniquement) // Lister tous les utilisateurs (super_admin uniquement)
@Get() @Get()
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR)
@@ -77,9 +43,9 @@ export class UserController {
return this.userService.findOne(id); return this.userService.findOne(id);
} }
// Modifier un utilisateur (réservé super_admin et admin) // Modifier un utilisateur (réservé super_admin)
@Patch(':id') @Patch(':id')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN)
@ApiOperation({ summary: 'Mettre à jour un utilisateur' }) @ApiOperation({ summary: 'Mettre à jour un utilisateur' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" }) @ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
updateUser( updateUser(
@@ -90,23 +56,6 @@ export class UserController {
return this.userService.updateUser(id, dto, currentUser); return this.userService.updateUser(id, dto, currentUser);
} }
@Patch(':id/numero-dossier')
@Roles(RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE)
@ApiOperation({
summary: 'Affecter un numéro de dossier à un utilisateur',
description: 'Permet de rapprocher deux dossiers ou dattribuer un numéro existant à un parent/AM. Réservé aux gestionnaires et administrateurs.',
})
@ApiParam({ name: 'id', description: "UUID de l'utilisateur (parent ou AM)" })
@ApiResponse({ status: 200, description: 'Numéro de dossier affecté' })
@ApiResponse({ status: 400, description: 'Format invalide, rôle non éligible, ou dossier déjà associé à 2 parents' })
@ApiResponse({ status: 404, description: 'Utilisateur introuvable' })
affecterNumeroDossier(
@Param('id') id: string,
@Body() dto: AffecterNumeroDossierDto,
) {
return this.userService.affecterNumeroDossier(id, dto.numero_dossier);
}
@Patch(':id/valider') @Patch(':id/valider')
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Valider un compte utilisateur' }) @ApiOperation({ summary: 'Valider un compte utilisateur' })
@@ -122,18 +71,6 @@ export class UserController {
return this.userService.validateUser(id, currentUser, comment); return this.userService.validateUser(id, currentUser, comment);
} }
@Patch(':id/refuser')
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Refuser un compte (à corriger)' })
@ApiParam({ name: 'id', description: "UUID de l'utilisateur" })
refuse(
@Param('id') id: string,
@User() currentUser: Users,
@Body('comment') comment?: string,
) {
return this.userService.refuseUser(id, currentUser, comment);
}
@Patch(':id/suspendre') @Patch(':id/suspendre')
@Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR) @Roles(RoleType.SUPER_ADMIN, RoleType.GESTIONNAIRE, RoleType.ADMINISTRATEUR)
@ApiOperation({ summary: 'Suspendre un compte utilisateur' }) @ApiOperation({ summary: 'Suspendre un compte utilisateur' })
-4
View File
@@ -10,8 +10,6 @@ import { AssistanteMaternelle } from 'src/entities/assistantes_maternelles.entit
import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module'; import { AssistantesMaternellesModule } from '../assistantes_maternelles/assistantes_maternelles.module';
import { Parents } from 'src/entities/parents.entity'; import { Parents } from 'src/entities/parents.entity';
import { GestionnairesModule } from './gestionnaires/gestionnaires.module'; import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
import { MailModule } from 'src/modules/mail/mail.module';
import { AppConfigModule } from 'src/modules/config/config.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature( imports: [TypeOrmModule.forFeature(
@@ -24,8 +22,6 @@ import { AppConfigModule } from 'src/modules/config/config.module';
ParentsModule, ParentsModule,
AssistantesMaternellesModule, AssistantesMaternellesModule,
GestionnairesModule, GestionnairesModule,
MailModule,
AppConfigModule,
], ],
controllers: [UserController], controllers: [UserController],
providers: [UserService], providers: [UserService],
+4 -308
View File
@@ -1,22 +1,16 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException } from "@nestjs/common"; import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity"; import { RoleType, StatutUtilisateurType, Users } from "src/entities/users.entity";
import { In, MoreThan, Repository } from "typeorm"; import { In, Repository } from "typeorm";
import { CreateUserDto } from "./dto/create_user.dto"; import { CreateUserDto } from "./dto/create_user.dto";
import { CreateAdminDto } from "./dto/create_admin.dto";
import { UpdateUserDto } from "./dto/update_user.dto"; import { UpdateUserDto } from "./dto/update_user.dto";
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { StatutValidationType, Validation } from "src/entities/validations.entity"; import { StatutValidationType, Validation } from "src/entities/validations.entity";
import { Parents } from "src/entities/parents.entity"; import { Parents } from "src/entities/parents.entity";
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity"; import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
import { MailService, ValidationAccountEmailKind } from "src/modules/mail/mail.service";
import { AppConfigService } from "src/modules/config/config.service";
import * as crypto from 'crypto';
@Injectable() @Injectable()
export class UserService { export class UserService {
private readonly logger = new Logger(UserService.name);
constructor( constructor(
@InjectRepository(Users) @InjectRepository(Users)
private readonly usersRepository: Repository<Users>, private readonly usersRepository: Repository<Users>,
@@ -28,11 +22,7 @@ export class UserService {
private readonly parentsRepository: Repository<Parents>, private readonly parentsRepository: Repository<Parents>,
@InjectRepository(AssistanteMaternelle) @InjectRepository(AssistanteMaternelle)
private readonly assistantesRepository: Repository<AssistanteMaternelle>, private readonly assistantesRepository: Repository<AssistanteMaternelle>
private readonly mailService: MailService,
private readonly appConfigService: AppConfigService,
) { } ) { }
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> { async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
@@ -116,48 +106,6 @@ export class UserService {
return this.findOne(saved.id); return this.findOne(saved.id);
} }
async createAdmin(dto: CreateAdminDto, currentUser: Users): Promise<Users> {
if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Seuls les super administrateurs peuvent créer un administrateur');
}
const exist = await this.usersRepository.findOneBy({ email: dto.email });
if (exist) throw new BadRequestException('Email déjà utilisé');
const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(dto.password, salt);
const entity = this.usersRepository.create({
email: dto.email,
password: hashedPassword,
prenom: dto.prenom,
nom: dto.nom,
role: RoleType.ADMINISTRATEUR,
statut: StatutUtilisateurType.ACTIF,
telephone: dto.telephone,
changement_mdp_obligatoire: true,
});
return this.usersRepository.save(entity);
}
async findPendingUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.EN_ATTENTE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
/** Comptes refusés (à corriger) : liste pour reprise par le gestionnaire */
async findRefusedUsers(role?: RoleType): Promise<Users[]> {
const where: any = { statut: StatutUtilisateurType.REFUSE };
if (role) {
where.role = role;
}
return this.usersRepository.find({ where });
}
async findAll(): Promise<Users[]> { async findAll(): Promise<Users[]> {
return this.usersRepository.find(); return this.usersRepository.find();
} }
@@ -181,26 +129,11 @@ export class UserService {
async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> { async updateUser(id: string, dto: UpdateUserDto, currentUser: Users): Promise<Users> {
const user = await this.findOne(id); const user = await this.findOne(id);
// Le super administrateur conserve une identité figée.
if (
user.role === RoleType.SUPER_ADMIN &&
(dto.nom !== undefined || dto.prenom !== undefined)
) {
throw new ForbiddenException(
'Le nom et le prénom du super administrateur ne peuvent pas être modifiés',
);
}
// Interdire changement de rôle si pas super admin // Interdire changement de rôle si pas super admin
if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) { if (dto.role && currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
// Un admin ne peut pas modifier un super admin
if (currentUser.role === RoleType.ADMINISTRATEUR && user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Vous ne pouvez pas modifier un super administrateur');
}
// Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire // Empêcher de modifier le flag changement_mdp_obligatoire pour admin/gestionnaire
if ( if (
(user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) && (user.role === RoleType.ADMINISTRATEUR || user.role === RoleType.GESTIONNAIRE) &&
@@ -232,7 +165,7 @@ export class UserService {
return this.usersRepository.save(user); return this.usersRepository.save(user);
} }
// Valider un compte utilisateur (en_attente ou refuse -> actif) // Valider un compte utilisateur
async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> { async validateUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) { if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires'); throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
@@ -241,10 +174,6 @@ export class UserService {
const user = await this.usersRepository.findOne({ where: { id: user_id } }); const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable'); if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE && user.statut !== StatutUtilisateurType.REFUSE) {
throw new BadRequestException('Seuls les comptes en attente ou refusés (à corriger) peuvent être validés.');
}
user.statut = StatutUtilisateurType.ACTIF; user.statut = StatutUtilisateurType.ACTIF;
const savedUser = await this.usersRepository.save(user); const savedUser = await this.usersRepository.save(user);
if (user.role === RoleType.PARENT) { if (user.role === RoleType.PARENT) {
@@ -268,45 +197,6 @@ export class UserService {
comment, comment,
}); });
await this.validationRepository.save(validation); 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; return savedUser;
} }
@@ -331,204 +221,10 @@ export class UserService {
await this.validationRepository.save(suspend); await this.validationRepository.save(suspend);
return savedUser; return savedUser;
} }
/** Refuser un dossier (en_attente -> refuse) ; tracé validations, token reprise partagé, emails. Ticket #110 */
async refuseUser(user_id: string, currentUser: Users, comment?: string): Promise<Users> {
if (![RoleType.SUPER_ADMIN, RoleType.ADMINISTRATEUR, RoleType.GESTIONNAIRE].includes(currentUser.role)) {
throw new ForbiddenException('Accès réservé aux super admins, administrateurs et gestionnaires');
}
const user = await this.usersRepository.findOne({ where: { id: user_id } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.statut !== StatutUtilisateurType.EN_ATTENTE) {
throw new BadRequestException('Seul un dossier en attente peut être refusé.');
}
const tokenReprise = crypto.randomUUID();
const expireLe = new Date();
expireLe.setDate(expireLe.getDate() + 7);
const usersDossier = user.role === RoleType.PARENT && user.numero_dossier
? await this.usersRepository.find({
where: {
role: RoleType.PARENT,
numero_dossier: user.numero_dossier,
},
})
: [user];
const usersARefuser = usersDossier.length ? usersDossier : [user];
const utilisateurInvalide = usersARefuser.find(
(u) => u.statut !== StatutUtilisateurType.EN_ATTENTE,
);
if (utilisateurInvalide) {
throw new BadRequestException(
'Tous les comptes du dossier doivent être en attente pour refuser le dossier.',
);
}
const savedUsers = await this.usersRepository.manager.transaction(async (manager) => {
const updatedUsers: Users[] = [];
for (const userARefuser of usersARefuser) {
userARefuser.statut = StatutUtilisateurType.REFUSE;
userARefuser.token_reprise = tokenReprise;
userARefuser.token_reprise_expire_le = expireLe;
const savedUser = await manager.save(Users, userARefuser);
updatedUsers.push(savedUser);
const validation = manager.create(Validation, {
user: savedUser,
type: 'refus_compte',
status: StatutValidationType.REFUSE,
validated_by: currentUser,
comment,
});
await manager.save(Validation, validation);
}
return updatedUsers;
});
const emailFailures: string[] = [];
for (const savedUser of savedUsers) {
try {
await this.mailService.sendRefusEmail(
savedUser.email,
savedUser.prenom ?? '',
savedUser.nom ?? '',
comment,
tokenReprise,
);
} catch (err) {
emailFailures.push(savedUser.email);
this.logger.error(`Envoi email refus échoué pour ${savedUser.email}`, err);
}
}
if (emailFailures.length > 0) {
throw new ServiceUnavailableException(
`Le dossier a bien été refusé en base, mais l'envoi d'email a échoué pour : ${emailFailures.join(', ')}. Réessayez ou contactez le support.`,
);
}
return savedUsers.find((savedUser) => savedUser.id === user.id) ?? savedUsers[0];
}
/**
* Affecter ou modifier le numéro de dossier d'un utilisateur (parent ou AM).
* Permet au gestionnaire/admin de rapprocher deux dossiers (même numéro pour plusieurs personnes).
* Garde-fou : au plus 2 parents par numéro de dossier (couple / co-parents).
*/
async affecterNumeroDossier(userId: string, numeroDossier: string): Promise<Users> {
const user = await this.usersRepository.findOne({ where: { id: userId } });
if (!user) throw new NotFoundException('Utilisateur introuvable');
if (user.role !== RoleType.PARENT && user.role !== RoleType.ASSISTANTE_MATERNELLE) {
throw new BadRequestException(
'Le numéro de dossier ne peut être affecté qu\'à un parent ou une assistante maternelle',
);
}
if (user.role === RoleType.PARENT) {
const uneAMALe = await this.assistantesRepository.count({
where: { numero_dossier: numeroDossier },
});
if (uneAMALe > 0) {
throw new BadRequestException(
'Ce numéro de dossier est celui d\'une assistante maternelle. Un numéro AM ne peut pas être affecté à un parent.',
);
}
const parentsAvecCeNumero = await this.parentsRepository.count({
where: { numero_dossier: numeroDossier },
});
const userADejaCeNumero = user.numero_dossier === numeroDossier;
if (!userADejaCeNumero && parentsAvecCeNumero >= 2) {
throw new BadRequestException(
'Un numéro de dossier ne peut être associé qu\'à 2 parents au maximum (couple / co-parents). Ce dossier a déjà 2 parents.',
);
}
}
if (user.role === RoleType.ASSISTANTE_MATERNELLE) {
const unParentLA = await this.parentsRepository.count({
where: { numero_dossier: numeroDossier },
});
if (unParentLA > 0) {
throw new BadRequestException(
'Ce numéro de dossier est celui d\'une famille (parent). Un numéro famille ne peut pas être affecté à une assistante maternelle.',
);
}
}
user.numero_dossier = numeroDossier;
const savedUser = await this.usersRepository.save(user);
if (user.role === RoleType.PARENT) {
await this.parentsRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
} else {
await this.assistantesRepository.update({ user_id: userId }, { numero_dossier: numeroDossier });
}
return savedUser;
}
/** Trouve un user par token reprise valide (non expiré). Ticket #111 */
async findByTokenReprise(token: string): Promise<Users | null> {
return this.usersRepository.findOne({
where: {
token_reprise: token,
statut: StatutUtilisateurType.REFUSE,
token_reprise_expire_le: MoreThan(new Date()),
},
});
}
/** Resoumission reprise : met à jour les champs autorisés, passe en en_attente, invalide le token. Ticket #111 */
async resoumettreReprise(
token: string,
dto: { prenom?: string; nom?: string; telephone?: string; adresse?: string; ville?: string; code_postal?: string; photo_url?: string },
): Promise<Users> {
const user = await this.findByTokenReprise(token);
if (!user) {
throw new NotFoundException('Token reprise invalide ou expiré.');
}
if (dto.prenom !== undefined) user.prenom = dto.prenom;
if (dto.nom !== undefined) user.nom = dto.nom;
if (dto.telephone !== undefined) user.telephone = dto.telephone;
if (dto.adresse !== undefined) user.adresse = dto.adresse;
if (dto.ville !== undefined) user.ville = dto.ville;
if (dto.code_postal !== undefined) user.code_postal = dto.code_postal;
if (dto.photo_url !== undefined) user.photo_url = dto.photo_url;
user.statut = StatutUtilisateurType.EN_ATTENTE;
user.token_reprise = undefined;
user.token_reprise_expire_le = undefined;
return this.usersRepository.save(user);
}
/** Pour modale reprise : numero_dossier + email → user en REFUSE avec token valide. Ticket #111 */
async findByNumeroDossierAndEmailForReprise(numero_dossier: string, email: string): Promise<Users | null> {
const user = await this.usersRepository.findOne({
where: {
email: email.trim().toLowerCase(),
numero_dossier: numero_dossier.trim(),
statut: StatutUtilisateurType.REFUSE,
token_reprise_expire_le: MoreThan(new Date()),
},
});
return user ?? null;
}
async remove(id: string, currentUser: Users): Promise<void> { async remove(id: string, currentUser: Users): Promise<void> {
if (currentUser.role !== RoleType.SUPER_ADMIN) { if (currentUser.role !== RoleType.SUPER_ADMIN) {
throw new ForbiddenException('Accès réservé aux super admins'); throw new ForbiddenException('Accès réservé aux super admins');
} }
const user = await this.findOne(id);
if (user.role === RoleType.SUPER_ADMIN) {
throw new ForbiddenException(
'Le super administrateur ne peut pas être supprimé',
);
}
const result = await this.usersRepository.delete(id); const result = await this.usersRepository.delete(id);
if (result.affected === 0) { if (result.affected === 0) {
throw new NotFoundException('Utilisateur introuvable'); throw new NotFoundException('Utilisateur introuvable');
-7
View File
@@ -1,7 +0,0 @@
const bcrypt = require('bcrypt');
const pass = '!Bezons2014';
bcrypt.hash(pass, 10).then(hash => {
console.log('New Hash:', hash);
}).catch(err => console.error(err));
+6 -96
View File
@@ -8,10 +8,10 @@ DO $$ BEGIN
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; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'genre_type') THEN
CREATE TYPE genre_type AS ENUM ('H', 'F', 'Autre'); CREATE TYPE genre_type AS ENUM ('H', 'F');
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_utilisateur_type') THEN 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');
END IF; END IF;
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'statut_enfant_type') THEN 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','actif','scolarise');
@@ -55,16 +55,11 @@ CREATE TABLE utilisateurs (
telephone VARCHAR(20), -- Unifié (mobile privilégié) telephone VARCHAR(20), -- Unifié (mobile privilégié)
adresse TEXT, adresse TEXT,
date_naissance DATE, 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, -- Obligatoire pour AM, non utilisé pour parents
consentement_photo BOOLEAN DEFAULT false, consentement_photo BOOLEAN DEFAULT false,
date_consentement_photo TIMESTAMPTZ, date_consentement_photo TIMESTAMPTZ,
token_creation_mdp VARCHAR(255), -- Token pour créer MDP après validation 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_expire_le TIMESTAMPTZ, -- Expiration 7 jours
-- Ticket #127 : réinitialisation mot de passe oublié (distinct de token_creation_mdp)
password_reset_token VARCHAR(255) NULL,
password_reset_expires TIMESTAMPTZ NULL,
changement_mdp_obligatoire BOOLEAN DEFAULT false, changement_mdp_obligatoire BOOLEAN DEFAULT false,
cree_le TIMESTAMPTZ DEFAULT now(), cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now(), modifie_le TIMESTAMPTZ DEFAULT now(),
@@ -79,17 +74,13 @@ CREATE INDEX idx_utilisateurs_token_creation_mdp
ON utilisateurs(token_creation_mdp) ON utilisateurs(token_creation_mdp)
WHERE token_creation_mdp IS NOT NULL; WHERE token_creation_mdp IS NOT NULL;
CREATE INDEX idx_utilisateurs_password_reset_token
ON utilisateurs(password_reset_token)
WHERE password_reset_token IS NOT NULL;
-- ========================================================== -- ==========================================================
-- Table : assistantes_maternelles -- Table : assistantes_maternelles
-- ========================================================== -- ==========================================================
CREATE TABLE assistantes_maternelles ( CREATE TABLE assistantes_maternelles (
id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE, id_utilisateur UUID PRIMARY KEY REFERENCES utilisateurs(id) ON DELETE CASCADE,
numero_agrement VARCHAR(50), numero_agrement VARCHAR(50),
nir_chiffre CHAR(15) NOT NULL, nir_chiffre CHAR(15),
nb_max_enfants INT, nb_max_enfants INT,
biographie TEXT, biographie TEXT,
disponible BOOLEAN DEFAULT true, disponible BOOLEAN DEFAULT true,
@@ -134,33 +125,6 @@ CREATE TABLE enfants_parents (
PRIMARY KEY (id_parent, id_enfant) PRIMARY KEY (id_parent, id_enfant)
); );
-- ==========================================================
-- Table : dossier_famille (inscription parent, schéma simplifié — ticket #119)
-- ==========================================================
CREATE TABLE dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_dossier VARCHAR(20) NOT NULL,
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
presentation TEXT,
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_dossier_famille_numero ON dossier_famille(numero_dossier);
CREATE INDEX idx_dossier_famille_id_parent ON dossier_famille(id_parent);
-- ==========================================================
-- Table : dossier_famille_enfants
-- ==========================================================
CREATE TABLE dossier_famille_enfants (
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_dossier_famille, id_enfant)
);
CREATE INDEX idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
-- ========================================================== -- ==========================================================
-- Table : dossiers -- Table : dossiers
-- ========================================================== -- ==========================================================
@@ -276,17 +240,11 @@ CREATE TABLE notifications (
-- ========================================================== -- ==========================================================
-- Table : validations -- Table : validations
-- ========================================================== -- ==========================================================
-- Historique des décisions (validation / refus / suspension de compte).
-- Colonnes commentaire + valide_par : requises par lAPI Nest (TypeORM).
-- FK en ON DELETE SET NULL : conserver la ligne si lutilisateur référencé
-- est supprimé (voir database/docs/FK_POLICIES.md).
CREATE TABLE validations ( CREATE TABLE validations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE SET NULL, id_utilisateur UUID REFERENCES utilisateurs(id),
type VARCHAR(50), type VARCHAR(50),
statut statut_validation_type DEFAULT 'en_attente', statut statut_validation_type DEFAULT 'en_attente',
commentaire TEXT,
valide_par UUID REFERENCES utilisateurs(id) ON DELETE SET NULL,
cree_le TIMESTAMPTZ DEFAULT now(), cree_le TIMESTAMPTZ DEFAULT now(),
modifie_le TIMESTAMPTZ DEFAULT now() modifie_le TIMESTAMPTZ DEFAULT now()
); );
@@ -373,61 +331,13 @@ CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisate
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document); CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
-- ========================================================== -- ==========================================================
-- Table : relais -- Modification Table : utilisateurs (ajout colonnes documents)
-- ==========================================================
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 ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER, ADD COLUMN IF NOT EXISTS cgu_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER, ADD COLUMN IF NOT EXISTS privacy_version_acceptee INTEGER,
ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ, ADD COLUMN IF NOT EXISTS privacy_acceptee_le TIMESTAMPTZ;
ADD COLUMN IF NOT EXISTS relais_id UUID REFERENCES relais(id) ON DELETE SET NULL;
-- ==========================================================
-- Ticket #103 : Numéro de dossier (AAAA-NNNNNN, séquence par année)
-- ==========================================================
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
);
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE assistantes_maternelles ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
ALTER TABLE parents ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
-- ==========================================================
-- Ticket #110 : Token reprise après refus (lien email)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise ON utilisateurs(token_reprise) WHERE token_reprise IS NOT NULL;
-- ==========================================================
-- Ticket #127 : Mot de passe oublié (token dédié, ≠ inscription)
-- ==========================================================
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_token VARCHAR(255) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS password_reset_expires TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_password_reset_token ON utilisateurs(password_reset_token) WHERE password_reset_token IS NOT NULL;
-- Lieu de naissance (aligné CREATE TABLE utilisateurs — idempotent si colonnes déjà présentes)
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL;
ALTER TABLE utilisateurs ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
-- ========================================================== -- ==========================================================
-- Seed : Documents légaux génériques v1 -- Seed : Documents légaux génériques v1
-2
View File
@@ -47,7 +47,6 @@ 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 lauteur | | **uploads(id_utilisateur)**`utilisateurs(id)` | **SET NULL** | Fichier reste référencé sans lauteur |
| **notifications(id_utilisateur)**`utilisateurs(id)` | **CASCADE** | Notifications propres à lutilisateur | | **notifications(id_utilisateur)**`utilisateurs(id)` | **CASCADE** | Notifications propres à lutilisateur |
| **validations(id_utilisateur)**`utilisateurs(id)` | **SET NULL** | Garder lhistorique de décision | | **validations(id_utilisateur)**`utilisateurs(id)` | **SET NULL** | Garder lhistorique 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). > **ON UPDATE** : **NO ACTION** partout (les UUID ne changent pas).
@@ -86,7 +85,6 @@ Documenter, de façon unique et partagée, les règles de suppression/mise à jo
- `uploads.id_utilisateur`**SET NULL** - `uploads.id_utilisateur`**SET NULL**
- `notifications.id_utilisateur`**CASCADE** - `notifications.id_utilisateur`**CASCADE**
- `validations.id_utilisateur`**SET NULL** - `validations.id_utilisateur`**SET NULL**
- `validations.valide_par`**SET NULL**
--- ---
@@ -1,27 +0,0 @@
-- Un dossier = une famille, N enfants. Ticket #119 évolution.
-- Table: un enregistrement par famille (lien via numero_dossier / id_parent).
CREATE TABLE IF NOT EXISTS dossier_famille (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
numero_dossier VARCHAR(20) NOT NULL,
id_parent UUID NOT NULL REFERENCES parents(id_utilisateur) ON DELETE CASCADE,
presentation TEXT,
type_contrat VARCHAR(50),
repas BOOLEAN NOT NULL DEFAULT false,
budget NUMERIC(10,2),
planning_souhaite JSONB,
statut statut_dossier_type NOT NULL DEFAULT 'envoye',
cree_le TIMESTAMPTZ NOT NULL DEFAULT now(),
modifie_le TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_dossier_famille_numero ON dossier_famille(numero_dossier);
CREATE INDEX IF NOT EXISTS idx_dossier_famille_id_parent ON dossier_famille(id_parent);
-- Enfants concernés par ce dossier famille (N par dossier).
CREATE TABLE IF NOT EXISTS dossier_famille_enfants (
id_dossier_famille UUID NOT NULL REFERENCES dossier_famille(id) ON DELETE CASCADE,
id_enfant UUID NOT NULL REFERENCES enfants(id) ON DELETE CASCADE,
PRIMARY KEY (id_dossier_famille, id_enfant)
);
CREATE INDEX IF NOT EXISTS idx_dossier_famille_enfants_enfant ON dossier_famille_enfants(id_enfant);
@@ -1,5 +0,0 @@
-- Dossier famille = inscription uniquement, pas les données de dossier de garde (repas, type_contrat, budget, etc.)
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS repas;
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS type_contrat;
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS budget;
ALTER TABLE dossier_famille DROP COLUMN IF EXISTS planning_souhaite;
@@ -1,4 +0,0 @@
-- Lieu de naissance (inscription AM, affichage dossier / validation). Nullable pour l'historique.
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS lieu_naissance_ville VARCHAR(100) NULL,
ADD COLUMN IF NOT EXISTS lieu_naissance_pays VARCHAR(100) NULL;
@@ -1,16 +0,0 @@
-- Migration : rendre nir_chiffre NOT NULL (ticket #102)
-- À exécuter sur les bases existantes avant déploiement du schéma avec nir_chiffre NOT NULL.
-- Les lignes sans NIR reçoivent un NIR de test valide (format + clé) pour satisfaire la contrainte.
BEGIN;
-- Renseigner un NIR de test valide pour toute ligne où nir_chiffre est NULL
UPDATE assistantes_maternelles
SET nir_chiffre = '275119900100102'
WHERE nir_chiffre IS NULL;
-- Appliquer la contrainte NOT NULL
ALTER TABLE assistantes_maternelles
ALTER COLUMN nir_chiffre SET NOT NULL;
COMMIT;
@@ -1,33 +0,0 @@
-- Migration #103 : Numéro de dossier (format AAAA-NNNNNN, séquence par année)
-- Colonnes sur utilisateurs, assistantes_maternelles, parents.
-- Table de séquence par année pour génération unique.
BEGIN;
-- Table de séquence : une ligne par année, prochain = prochain numéro à attribuer (1..999999)
CREATE TABLE IF NOT EXISTS numero_dossier_sequence (
annee INT PRIMARY KEY,
prochain INT NOT NULL DEFAULT 1
);
-- Colonne sur utilisateurs (AM et parents : numéro attribué à la soumission)
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
-- Colonne sur assistantes_maternelles (redondant avec users pour accès direct)
ALTER TABLE assistantes_maternelles
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
-- Colonne sur parents (un numéro par famille, même valeur sur les deux lignes si co-parent)
ALTER TABLE parents
ADD COLUMN IF NOT EXISTS numero_dossier VARCHAR(20) NULL;
-- Index pour recherche par numéro
CREATE INDEX IF NOT EXISTS idx_utilisateurs_numero_dossier
ON utilisateurs(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_assistantes_maternelles_numero_dossier
ON assistantes_maternelles(numero_dossier) WHERE numero_dossier IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_parents_numero_dossier
ON parents(numero_dossier) WHERE numero_dossier IS NOT NULL;
COMMIT;
@@ -1,122 +0,0 @@
-- Backfill #103 : attribuer un numero_dossier aux entrées existantes (NULL)
-- Famille = lien co_parent OU partage d'au moins un enfant (même dossier).
-- Ordre : par année, AM puis familles (une entrée par famille), séquence 000001, 000002...
-- À exécuter après 2026_numero_dossier.sql
DO $$
DECLARE
yr INT;
seq INT;
num TEXT;
r RECORD;
family_user_ids UUID[];
BEGIN
-- Réinitialiser pour rejouer le backfill (cohérence AM + familles)
UPDATE parents SET numero_dossier = NULL;
UPDATE utilisateurs SET numero_dossier = NULL
WHERE role IN ('parent', 'assistante_maternelle');
UPDATE assistantes_maternelles SET numero_dossier = NULL;
FOR yr IN
SELECT DISTINCT EXTRACT(YEAR FROM u.cree_le)::INT
FROM utilisateurs u
WHERE (
(u.role = 'assistante_maternelle' AND u.numero_dossier IS NULL)
OR EXISTS (SELECT 1 FROM parents p WHERE p.id_utilisateur = u.id AND p.numero_dossier IS NULL)
)
ORDER BY 1
LOOP
seq := 0;
-- 1) AM : par ordre de création
FOR r IN
SELECT u.id
FROM utilisateurs u
WHERE u.role = 'assistante_maternelle'
AND u.numero_dossier IS NULL
AND EXTRACT(YEAR FROM u.cree_le) = yr
ORDER BY u.cree_le
LOOP
seq := seq + 1;
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
UPDATE utilisateurs SET numero_dossier = num WHERE id = r.id;
UPDATE assistantes_maternelles SET numero_dossier = num WHERE id_utilisateur = r.id;
END LOOP;
-- 2) Familles : une entrée par "dossier" (co_parent OU enfants partagés)
-- family_rep = min(id) de la composante connexe (lien co_parent + partage d'enfants)
FOR r IN
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
),
fam_ordered AS (
SELECT fr.rep AS family_rep, MIN(u.cree_le) AS cree_le
FROM family_rep fr
JOIN parents p ON p.id_utilisateur = fr.id
JOIN utilisateurs u ON u.id = p.id_utilisateur
WHERE p.numero_dossier IS NULL
AND EXTRACT(YEAR FROM u.cree_le) = yr
GROUP BY fr.rep
ORDER BY MIN(u.cree_le)
)
SELECT fo.family_rep
FROM fam_ordered fo
LOOP
seq := seq + 1;
num := yr || '-' || LPAD(seq::TEXT, 6, '0');
WITH RECURSIVE
links AS (
SELECT p.id_utilisateur AS p1, p.id_co_parent AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT p.id_co_parent AS p1, p.id_utilisateur AS p2 FROM parents p WHERE p.id_co_parent IS NOT NULL
UNION ALL
SELECT ep1.id_parent AS p1, ep2.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
UNION ALL
SELECT ep2.id_parent AS p1, ep1.id_parent AS p2
FROM enfants_parents ep1
JOIN enfants_parents ep2 ON ep2.id_enfant = ep1.id_enfant AND ep1.id_parent < ep2.id_parent
),
rec AS (
SELECT id_utilisateur AS id, id_utilisateur AS rep FROM parents
UNION
SELECT l.p2 AS id, LEAST(rec_alias.rep, l.p2) AS rep FROM links l JOIN rec rec_alias ON rec_alias.id = l.p1
),
family_rep AS (
SELECT id, MIN(rep::text)::uuid AS rep FROM rec GROUP BY id
)
SELECT array_agg(DISTINCT fr.id) INTO family_user_ids
FROM family_rep fr
WHERE fr.rep = r.family_rep;
UPDATE utilisateurs SET numero_dossier = num WHERE id = ANY(family_user_ids);
UPDATE parents SET numero_dossier = num WHERE id_utilisateur = ANY(family_user_ids);
END LOOP;
INSERT INTO numero_dossier_sequence (annee, prochain)
VALUES (yr, seq + 1)
ON CONFLICT (annee) DO UPDATE
SET prochain = GREATEST(numero_dossier_sequence.prochain, seq + 1);
END LOOP;
END $$;
@@ -1,4 +0,0 @@
-- Migration #105 : Statut utilisateur « refusé » (à corriger)
-- Ajout de la valeur 'refuse' à l'enum statut_utilisateur_type.
ALTER TYPE statut_utilisateur_type ADD VALUE IF NOT EXISTS 'refuse';
@@ -1,10 +0,0 @@
-- Migration #110 : Token reprise après refus (lien email)
-- Permet à l'utilisateur refusé de corriger et resoumettre via un lien sécurisé.
ALTER TABLE utilisateurs
ADD COLUMN IF NOT EXISTS token_reprise VARCHAR(255) NULL,
ADD COLUMN IF NOT EXISTS token_reprise_expire_le TIMESTAMPTZ NULL;
CREATE INDEX IF NOT EXISTS idx_utilisateurs_token_reprise
ON utilisateurs(token_reprise)
WHERE token_reprise IS NOT NULL;
@@ -1,8 +0,0 @@
-- 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;
@@ -1,13 +0,0 @@
-- 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;
-10
View File
@@ -1,10 +0,0 @@
# Correctifs SQL (hors init `BDD.sql`)
Scripts à appliquer **manuellement** sur les bases déjà créées (ex. `psql` ou client SQL), dans lordre des dates si plusieurs fichiers.
| Fichier | Objet |
|---------|--------|
| `2026-04-17-validations-commentaire-valide-par.sql` | Ajoute `commentaire` et `valide_par` sur `validations` (requis par lAPI / 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.
+4 -4
View File
@@ -58,9 +58,9 @@ INSERT INTO parents (id_utilisateur, id_co_parent)
VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent VALUES ('55555555-5555-5555-5555-555555555555', NULL) -- parent2 sans co-parent
ON CONFLICT (id_utilisateur) DO NOTHING; ON CONFLICT (id_utilisateur) DO NOTHING;
-- assistantes_maternelles (nir_chiffre NOT NULL depuis ticket #102) -- assistantes_maternelles
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, disponible, ville_residence) INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nb_max_enfants, disponible, ville_residence)
VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', '275119900100102', 3, true, 'Lille') VALUES ('66666666-6666-6666-6666-666666666666', 'AGR-2025-0001', 3, true, 'Lille')
ON CONFLICT (id_utilisateur) DO NOTHING; ON CONFLICT (id_utilisateur) DO NOTHING;
-- ------------------------------------------------------------ -- ------------------------------------------------------------
@@ -212,7 +212,7 @@ INSERT INTO validations (id, id_utilisateur, statut, commentaire, cree_le)
VALUES ( VALUES (
'v0000000-0000-0000-0000-000000000001', 'v0000000-0000-0000-0000-000000000001',
'66666666-6666-6666-6666-666666666666', '66666666-6666-6666-6666-666666666666',
'valide', 'accepte',
'Dossier AM vérifié par gestionnaire.', 'Dossier AM vérifié par gestionnaire.',
NOW() NOW()
) )
+2 -8
View File
@@ -2,10 +2,6 @@
-- 03_seed_test_data.sql : Données de test complètes (dashboard admin) -- 03_seed_test_data.sql : Données de test complètes (dashboard admin)
-- Aligné sur utilisateurs-test-complet.json -- Aligné sur utilisateurs-test-complet.json
-- Mot de passe universel : password (bcrypt) -- Mot de passe universel : password (bcrypt)
-- NIR : numéros de test (non réels), cohérents avec les données (date naissance, genre).
-- - Marie Dubois : née en Corse à Ajaccio → NIR 2A (test exception Corse).
-- - Fatima El Mansouri : née à l'étranger → NIR 99.
-- Même jeu : docs/test-data/README.md, utilisateurs-test.csv/.html, tests/scripts/register-am-*-test.mjs
-- À exécuter après BDD.sql (init DB) -- À exécuter après BDD.sql (init DB)
-- ============================================================ -- ============================================================
@@ -40,12 +36,10 @@ VALUES
ON CONFLICT (id_utilisateur) DO NOTHING; ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ASSISTANTES MATERNELLES ========== -- ========== ASSISTANTES MATERNELLES ==========
-- Marie Dubois (a0000003) : née en Corse à Ajaccio NIR 2A pour test exception Corse (1980-06-08, F).
-- Fatima El Mansouri (a0000004) : née à l'étranger NIR 99 pour test (1975-11-12, F).
INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible) INSERT INTO assistantes_maternelles (id_utilisateur, numero_agrement, nir_chiffre, nb_max_enfants, biographie, date_agrement, ville_residence, disponible, place_disponible)
VALUES VALUES
('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280062A00100191', 4, 'Assistante maternelle agréée depuis 2019. Née en Corse à Ajaccio. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2), ('a0000003-0003-0003-0003-000000000003', 'AGR-2019-095001', '280069512345671', 4, 'Assistante maternelle agréée depuis 2019. Spécialité bébés 0-18 mois. Accueil bienveillant et cadre sécurisant. 2 places disponibles.', '2019-09-01', 'Bezons', true, 2),
('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119900100102', 3, 'Assistante maternelle expérimentée. Née à l''étranger. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1) ('a0000004-0004-0004-0004-000000000004', 'AGR-2017-095002', '275119512345672', 3, 'Assistante maternelle expérimentée. Spécialité 1-3 ans. Accueil à la journée. 1 place disponible.', '2017-06-15', 'Bezons', true, 1)
ON CONFLICT (id_utilisateur) DO NOTHING; ON CONFLICT (id_utilisateur) DO NOTHING;
-- ========== ENFANTS ========== -- ========== ENFANTS ==========
-14
View File
@@ -57,12 +57,6 @@ services:
NODE_ENV: ${NODE_ENV} NODE_ENV: ${NODE_ENV}
LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false} LOG_API_REQUESTS: ${LOG_API_REQUESTS:-false}
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY} CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY}
# Photos inscription (base64) — chemin absolu dans le conteneur (voir volume backend_uploads)
UPLOAD_PHOTOS_DIR: ${UPLOAD_PHOTOS_DIR:-/app/uploads/photos}
volumes:
- backend_uploads:/app/uploads/photos
# PDF CGU / privacy (upload admin) — persistance entre redémarrages
- backend_documents_legaux:/app/documents/legaux
depends_on: depends_on:
- database - database
labels: labels:
@@ -72,12 +66,6 @@ services:
- "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver" - "traefik.http.routers.ptitspas-api.tls.certresolver=leresolver"
- "traefik.http.routers.ptitspas-api.priority=20" - "traefik.http.routers.ptitspas-api.priority=20"
- "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000" - "traefik.http.services.ptitspas-api.loadbalancer.server.port=3000"
# Fichiers statiques photos (URL relatives /uploads/...) — priorité > front (10)
- "traefik.http.routers.ptitspas-uploads.rule=Host(\"app.ptits-pas.fr\") && PathPrefix(\"/uploads\")"
- "traefik.http.routers.ptitspas-uploads.entrypoints=websecure"
- "traefik.http.routers.ptitspas-uploads.tls.certresolver=leresolver"
- "traefik.http.routers.ptitspas-uploads.priority=18"
- "traefik.http.routers.ptitspas-uploads.service=ptitspas-api"
networks: networks:
- ptitspas_network - ptitspas_network
- proxy_network - proxy_network
@@ -106,8 +94,6 @@ services:
volumes: volumes:
postgres_data: postgres_data:
backend_uploads:
backend_documents_legaux:
networks: networks:
ptitspas_network: ptitspas_network:
+2 -27
View File
@@ -19,40 +19,15 @@ Ce fichier sert d'index pour naviguer dans toute la documentation du projet.
### Développement ### Développement
- [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles - [**10 - Database Schema**](./10_DATABASE.md) - Schéma de la base de données et modèles
- [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST - [**11 - API Documentation**](./11_API.md) - Documentation complète des endpoints REST
- [**14 - Note backend config setup**](./14_NOTE-BACKEND-CONFIG-SETUP.md) - Setup configuration
- [**92 - Note backend gestionnaires**](./92_NOTE-BACKEND-GESTIONNAIRES.md) - Gestionnaires
- [**99 - Règles de codage**](./99_REGLES-CODAGE.md) - Conventions de code
### Workflows Fonctionnels ### Workflows Fonctionnels
- [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs - [**20 - Workflow Création de Compte**](./20_WORKFLOW-CREATION-COMPTE.md) - Workflow complet de création et validation des comptes utilisateurs
- [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique - [**21 - Configuration Système**](./21_CONFIGURATION-SYSTEME.md) - Configuration on-premise dynamique
- [**22 - Documents Légaux**](./juridique/22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning - [**22 - Documents Légaux**](./22_DOCUMENTS-LEGAUX.md) - Gestion CGU/Privacy avec versioning
### Juridique (sources & technique)
- [**Dossier juridique**](./juridique/README.md) - Index : CGU/CGC en Markdown,
export PDF, lien vers la doc technique n°22
### Projet & suivi (Gitea / tickets)
- [**23 - Liste des Tickets**](./23_LISTE-TICKETS.md) - 61 tickets Phase 1 détaillés - [**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 - [**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 - [**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) - [**26 - API Gitea**](./26_GITEA-API.md) - Procédure d'utilisation de l'API Gitea (issues, PR, branches, labels)
- [**27 - Briefing frontend**](./27_BRIEFING-FRONTEND.md) - Accès Git, priorités, scripts Gitea (token)
### Archive & convention de nommage
- [**Dossier archive**](./archive/README.md) - Fichiers **sans** `NN_` déplacés
(temporaires, obsolètes) ; règles de rangement et suppression
- Pointeur : [PROCEDURE-API-GITEA.md](./PROCEDURE-API-GITEA.md) → voir **26**
### Exceptions de nommage (racine `docs/`)
Fichiers **sans préfixe numérique** encore à la racine par **héritage** ou
références outils (`.cursorrules`, etc.) — **à renommer** en `NN_` quand
possible :
- `CHARTE_GRAPHIQUE.md`
- [`EVOLUTIONS_CDC.md`](./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`
### Administration (À créer) ### Administration (À créer)
- [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs - [**30 - Guide d'administration**](./30_ADMIN.md) - Gestion des utilisateurs, accès PgAdmin, logs
@@ -90,5 +65,5 @@ PgAdmin: https://app.ptits-pas.fr/pgadmin
Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr). Cette documentation est maintenue par Julien Martin (julien.martin@ptits-pas.fr).
Dernière mise à jour : Juin 2026 Dernière mise à jour : Novembre 2025
+1 -1
View File
@@ -1528,7 +1528,7 @@ sequenceDiagram
### Architecture Frontend ### Architecture Frontend
**Framework** : Flutter 3.29.3 **Framework** : Flutter 3.19.0
**Langage** : Dart 3.x **Langage** : Dart 3.x
**State Management** : Provider / Riverpod **State Management** : Provider / Riverpod
**HTTP Client** : Dio / http **HTTP Client** : Dio / http
+695 -5
View File
@@ -1,8 +1,698 @@
# Fichier déplacé # 📄 Documentation Technique - Gestion Documents Légaux (CGU/Privacy)
**Version** : 1.0
**Date** : 25 Novembre 2025
**Auteur** : Équipe PtitsPas
**Référence** : RGPD & Conformité juridique
---
## 📖 Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Architecture](#architecture)
3. [Tables BDD](#tables-bdd)
4. [Service Documents Légaux](#service-documents-légaux)
5. [Workflow Upload & Activation](#workflow-upload--activation)
6. [Workflow Acceptation Utilisateur](#workflow-acceptation-utilisateur)
7. [APIs](#apis)
8. [Interface Admin](#interface-admin)
9. [Conformité RGPD](#conformité-rgpd)
---
## 🎯 Vue d'ensemble
### Problématique
Chaque collectivité déployant P'titsPas on-premise doit pouvoir :
1.**Personnaliser** les CGU et la Politique de confidentialité
2.**Versionner** les documents (traçabilité juridique)
3.**Tracer** qui a accepté quelle version (RGPD)
4.**Prouver** l'acceptation (IP, User-Agent, horodatage)
5.**Empêcher** le retour en arrière (sécurité juridique)
### Solution
- **Documents génériques v1** fournis par défaut (rédigés avec juriste)
- **Upload de nouvelles versions** par l'admin (PDF uniquement)
- **Versioning automatique** (incrémentation sans retour arrière)
- **Activation manuelle** (prévisualisation avant mise en prod)
- **Traçabilité complète** (hash SHA-256, IP, User-Agent)
---
## 🏗️ Architecture
### Flux de données
```
┌─────────────────────────────────────────────────────────┐
│ Workflow Documents │
├─────────────────────────────────────────────────────────┤
│ │
│ 1. UPLOAD (Admin) │
│ Admin ──▶ API ──▶ File System ──▶ BDD │
│ /documents/legaux/ │
│ cgu_v4_<timestamp>.pdf │
│ │
│ 2. ACTIVATION (Admin) │
│ Admin ──▶ API ──▶ BDD (actif=true) │
│ │
│ 3. ACCEPTATION (Utilisateur) │
│ User ──▶ Frontend ──▶ API ──▶ BDD │
│ (inscription) (trace IP/UA) │
│ │
└─────────────────────────────────────────────────────────┘
```
### Composants
1. **Table `documents_legaux`** : Stockage versions + métadonnées
2. **Table `acceptations_documents`** : Traçabilité acceptations
3. **Service `DocumentsLegauxService`** : Upload, versioning, activation
4. **API REST** : CRUD documents
5. **Interface Admin** : Upload + activation
6. **Interface Inscription** : Affichage + acceptation
---
## 📊 Tables BDD
### Table 1 : `documents_legaux`
```sql
-- Table pour gérer les versions des documents légaux
CREATE TABLE documents_legaux (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
version INTEGER NOT NULL, -- Numéro de version (auto-incrémenté)
fichier_nom VARCHAR(255) NOT NULL, -- Nom original du fichier
fichier_path VARCHAR(500) NOT NULL, -- Chemin de stockage
fichier_hash VARCHAR(64) NOT NULL, -- Hash SHA-256 pour intégrité
actif BOOLEAN DEFAULT false, -- Version actuellement active
televerse_par UUID REFERENCES utilisateurs(id), -- Qui a uploadé
televerse_le TIMESTAMPTZ DEFAULT now(), -- Date d'upload
active_le TIMESTAMPTZ, -- Date d'activation
UNIQUE(type, version) -- Pas de doublon version
);
-- Index pour performance
CREATE INDEX idx_documents_legaux_type_actif ON documents_legaux(type, actif);
CREATE INDEX idx_documents_legaux_version ON documents_legaux(type, version DESC);
```
**Contraintes** :
- ✅ Un seul document `actif=true` par type à la fois
- ✅ Versioning auto-incrémenté (pas de gaps)
- ✅ Hash SHA-256 pour vérifier l'intégrité du fichier
---
### Table 2 : `acceptations_documents`
```sql
-- Table de traçabilité des acceptations (RGPD)
CREATE TABLE acceptations_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
id_utilisateur UUID REFERENCES utilisateurs(id) ON DELETE CASCADE,
id_document UUID REFERENCES documents_legaux(id),
type_document VARCHAR(50) NOT NULL, -- 'cgu' ou 'privacy'
version_document INTEGER NOT NULL, -- Version acceptée
accepte_le TIMESTAMPTZ DEFAULT now(), -- Date d'acceptation
ip_address INET, -- IP de l'utilisateur (RGPD)
user_agent TEXT -- Navigateur (preuve)
);
CREATE INDEX idx_acceptations_utilisateur ON acceptations_documents(id_utilisateur);
CREATE INDEX idx_acceptations_document ON acceptations_documents(id_document);
```
**Données capturées** :
-**Qui** : `id_utilisateur`
-**Quoi** : `type_document`, `version_document`
-**Quand** : `accepte_le`
-**Où** : `ip_address`
-**Comment** : `user_agent`
---
### Modification table `utilisateurs`
```sql
-- Ajouter colonnes pour référence rapide (optionnel)
ALTER TABLE utilisateurs
ADD COLUMN cgu_version_acceptee INTEGER,
ADD COLUMN cgu_acceptee_le TIMESTAMPTZ,
ADD COLUMN privacy_version_acceptee INTEGER,
ADD COLUMN privacy_acceptee_le TIMESTAMPTZ;
```
**Note** : Ces colonnes sont **redondantes** avec `acceptations_documents`, mais permettent un accès rapide sans JOIN.
---
### Seed initial
```sql
-- Documents génériques v1 (fournis par défaut)
INSERT INTO documents_legaux (type, version, fichier_nom, fichier_path, fichier_hash, actif, televerse_le, active_le) VALUES
('cgu', 1, 'cgu_v1_default.pdf', '/documents/legaux/cgu_v1_default.pdf', 'a3f8b2c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2', true, now(), now()),
('privacy', 1, 'privacy_v1_default.pdf', '/documents/legaux/privacy_v1_default.pdf', 'b4f9c3d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4', true, now(), now());
```
**Fichiers à fournir** :
- `/documents/legaux/cgu_v1_default.pdf` (rédigé avec juriste)
- `/documents/legaux/privacy_v1_default.pdf` (conforme RGPD)
---
## 🔧 Service Documents Légaux
### Responsabilités
1. **Récupérer documents actifs** : `getDocumentsActifs()`
2. **Uploader nouvelle version** : `uploadNouvelleVersion(type, file, userId)`
3. **Activer une version** : `activerVersion(documentId)`
4. **Lister versions** : `listerVersions(type)`
5. **Télécharger document** : `telechargerDocument(documentId)`
### Implémentation (TypeScript)
```typescript
// backend/src/documents-legaux/documents-legaux.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentLegal } from './entities/document-legal.entity';
import * as crypto from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
@Injectable()
export class DocumentsLegauxService {
private readonly UPLOAD_DIR = '/app/documents/legaux';
constructor(
@InjectRepository(DocumentLegal)
private docRepo: Repository<DocumentLegal>,
) {}
// Récupérer les documents actifs
async getDocumentsActifs(): Promise<{ cgu: DocumentLegal; privacy: DocumentLegal }> {
const cgu = await this.docRepo.findOne({
where: { type: 'cgu', actif: true },
});
const privacy = await this.docRepo.findOne({
where: { type: 'privacy', actif: true },
});
if (!cgu || !privacy) {
throw new Error('Documents légaux manquants');
}
return { cgu, privacy };
}
// Uploader une nouvelle version
async uploadNouvelleVersion(
type: 'cgu' | 'privacy',
file: Express.Multer.File,
userId: string,
): Promise<DocumentLegal> {
// 1. Calculer la prochaine version
const lastDoc = await this.docRepo.findOne({
where: { type },
order: { version: 'DESC' },
});
const nouvelleVersion = (lastDoc?.version || 0) + 1;
// 2. Calculer le hash du fichier
const fileBuffer = file.buffer;
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
// 3. Générer le nom de fichier unique
const timestamp = Date.now();
const fileName = `${type}_v${nouvelleVersion}_${timestamp}.pdf`;
const filePath = path.join(this.UPLOAD_DIR, fileName);
// 4. Sauvegarder le fichier
await fs.mkdir(this.UPLOAD_DIR, { recursive: true });
await fs.writeFile(filePath, fileBuffer);
// 5. Créer l'entrée en BDD
const document = this.docRepo.create({
type,
version: nouvelleVersion,
fichier_nom: file.originalname,
fichier_path: filePath,
fichier_hash: hash,
actif: false, // Pas actif par défaut
televerse_par: userId,
televerse_le: new Date(),
});
return await this.docRepo.save(document);
}
// Activer une version
async activerVersion(documentId: string): Promise<void> {
const document = await this.docRepo.findOne({ where: { id: documentId } });
if (!document) {
throw new Error('Document non trouvé');
}
// Transaction : désactiver l'ancienne version, activer la nouvelle
await this.docRepo.manager.transaction(async (manager) => {
// Désactiver toutes les versions de ce type
await manager.update(
DocumentLegal,
{ type: document.type, actif: true },
{ actif: false },
);
// Activer la nouvelle version
await manager.update(
DocumentLegal,
{ id: documentId },
{ actif: true, active_le: new Date() },
);
});
}
// Lister toutes les versions (pour l'admin)
async listerVersions(type: 'cgu' | 'privacy'): Promise<DocumentLegal[]> {
return await this.docRepo.find({
where: { type },
order: { version: 'DESC' },
relations: ['televerse_par'],
});
}
// Télécharger un document (stream)
async telechargerDocument(documentId: string): Promise<{ stream: Buffer; filename: string }> {
const document = await this.docRepo.findOne({ where: { id: documentId } });
if (!document) {
throw new Error('Document non trouvé');
}
const fileBuffer = await fs.readFile(document.fichier_path);
return {
stream: fileBuffer,
filename: document.fichier_nom,
};
}
// Vérifier l'intégrité d'un document
async verifierIntegrite(documentId: string): Promise<boolean> {
const document = await this.docRepo.findOne({ where: { id: documentId } });
if (!document) {
throw new Error('Document non trouvé');
}
const fileBuffer = await fs.readFile(document.fichier_path);
const hash = crypto.createHash('sha256').update(fileBuffer).digest('hex');
return hash === document.fichier_hash;
}
}
```
---
## 🔄 Workflow Upload & Activation
### Diagramme de séquence
```mermaid
sequenceDiagram
participant A as Admin
participant API as Backend API
participant FS as File System
participant DB as PostgreSQL
A->>API: POST /api/v1/documents-legaux<br/>{type: 'cgu', file: PDF}
API->>API: Validation fichier<br/>(PDF, max 10MB)
API->>API: Calcul hash SHA-256
API->>DB: SELECT MAX(version)<br/>WHERE type='cgu'
DB-->>API: version = 3
API->>API: Nouvelle version = 4
API->>FS: Enregistrer fichier<br/>/documents/legaux/cgu_v4_<timestamp>.pdf
FS-->>API: ✅ Fichier sauvegardé
API->>DB: INSERT INTO documents_legaux<br/>(type, version=4, actif=false)
DB-->>API: ✅ Document créé
API-->>A: 201 Created<br/>{id, version: 4, actif: false}
A->>A: Prévisualisation PDF
A->>API: PATCH /api/v1/documents-legaux/{id}/activer
API->>DB: BEGIN TRANSACTION
API->>DB: UPDATE documents_legaux<br/>SET actif=false WHERE type='cgu'
API->>DB: UPDATE documents_legaux<br/>SET actif=true, active_le=now()<br/>WHERE id={id}
API->>DB: COMMIT
API-->>A: ✅ CGU v4 activées
```
---
## 📥 Workflow Acceptation Utilisateur
### Diagramme de séquence
```mermaid
sequenceDiagram
participant U as Utilisateur
participant App as Frontend
participant API as Backend
participant DB as PostgreSQL
U->>App: Inscription (étape CGU)
App->>API: GET /api/v1/documents-legaux/actifs
API->>DB: SELECT * FROM documents_legaux<br/>WHERE actif=true
DB-->>API: {cgu: v4, privacy: v2}
API-->>App: {cgu: {version: 4, url: '...'}, privacy: {...}}
App->>App: Afficher liens PDF<br/>"CGU v4" et "Privacy v2"
U->>U: Lit les documents
U->>U: Coche "J'accepte"
App->>API: POST /api/v1/auth/register<br/>{..., cgu_version: 4, privacy_version: 2, ip, user_agent}
API->>DB: BEGIN TRANSACTION
API->>DB: INSERT INTO utilisateurs<br/>(..., cgu_version_acceptee=4, privacy_version_acceptee=2)
DB-->>API: id_utilisateur
API->>DB: INSERT INTO acceptations_documents<br/>(id_utilisateur, type='cgu', version=4, ip, user_agent)
API->>DB: INSERT INTO acceptations_documents<br/>(id_utilisateur, type='privacy', version=2, ip, user_agent)
API->>DB: COMMIT
API-->>App: ✅ Inscription réussie
```
---
## 🔌 APIs
### API 1 : Récupérer documents actifs (Public)
```http
GET /api/v1/documents-legaux/actifs
```
**Réponse 200** :
```json
{
"cgu": {
"id": "uuid-cgu-v4",
"type": "cgu",
"version": 4,
"url": "/api/v1/documents-legaux/uuid-cgu-v4/download",
"active_le": "2025-11-20T14:30:00Z"
},
"privacy": {
"id": "uuid-privacy-v2",
"type": "privacy",
"version": 2,
"url": "/api/v1/documents-legaux/uuid-privacy-v2/download",
"active_le": "2025-10-15T09:15:00Z"
}
}
```
---
### API 2 : Lister versions (Admin)
```http
GET /api/v1/documents-legaux/:type/versions
Authorization: Bearer <super_admin_token>
```
**Paramètres** :
- `type` : `cgu` | `privacy`
**Réponse 200** :
```json
[
{
"id": "uuid-cgu-v4",
"version": 4,
"fichier_nom": "CGU_Mairie_Bezons_2025.pdf",
"actif": true,
"televerse_par": {
"id": "uuid-admin",
"prenom": "Lucas",
"nom": "MOREAU"
},
"televerse_le": "2025-11-20T14:00:00Z",
"active_le": "2025-11-20T14:30:00Z"
},
{
"id": "uuid-cgu-v3",
"version": 3,
"fichier_nom": "CGU_v3.pdf",
"actif": false,
"televerse_par": {
"id": "uuid-admin",
"prenom": "Admin",
"nom": "Système"
},
"televerse_le": "2025-10-15T09:00:00Z",
"active_le": "2025-10-15T09:15:00Z"
}
]
```
---
### API 3 : Upload nouvelle version (Admin)
```http
POST /api/v1/documents-legaux
Authorization: Bearer <super_admin_token>
Content-Type: multipart/form-data
```
**Body** :
```
type: cgu
file: <fichier PDF>
```
**Réponse 201** :
```json
{
"id": "uuid-nouveau-doc",
"type": "cgu",
"version": 5,
"fichier_nom": "CGU_Mairie_Bezons_2025_v2.pdf",
"actif": false,
"televerse_le": "2025-11-25T10:00:00Z"
}
```
**Erreurs** :
- `400 Bad Request` : Fichier non PDF ou trop volumineux (>10MB)
- `401 Unauthorized` : Token manquant ou invalide
- `403 Forbidden` : Rôle insuffisant (pas super_admin)
---
### API 4 : Activer une version (Admin)
```http
PATCH /api/v1/documents-legaux/:id/activer
Authorization: Bearer <super_admin_token>
```
**Réponse 200** :
```json
{
"message": "Document activé avec succès",
"documentId": "uuid-nouveau-doc",
"type": "cgu",
"version": 5
}
```
---
### API 5 : Télécharger document (Public)
```http
GET /api/v1/documents-legaux/:id/download
```
**Réponse 200** :
```
Content-Type: application/pdf
Content-Disposition: attachment; filename="CGU_v5.pdf"
<binary PDF data>
```
---
### API 6 : Historique acceptations utilisateur (Admin)
```http
GET /api/v1/users/:userId/acceptations
Authorization: Bearer <super_admin_token>
```
**Réponse 200** :
```json
[
{
"type_document": "cgu",
"version_document": 4,
"accepte_le": "2025-11-20T15:30:00Z",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
},
{
"type_document": "privacy",
"version_document": 2,
"accepte_le": "2025-11-20T15:30:00Z",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
}
]
```
---
## 💻 Interface Admin
### Écran Gestion Documents Légaux
```
┌─────────────────────────────────────────────────────────┐
│ 📄 Gestion des Documents Légaux │
├─────────────────────────────────────────────────────────┤
│ [ CGU ] [ Politique de confidentialité ] │
├─────────────────────────────────────────────────────────┤
│ │
│ 📋 Conditions Générales d'Utilisation (CGU) │
│ │
│ Version active : v4 │
│ Activée le : 20/11/2025 14:30 │
│ Téléversée par : Lucas MOREAU │
│ │
│ [ 📥 Télécharger ] [ 👁️ Prévisualiser ] │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ 📤 Uploader une nouvelle version │
│ │
│ ⚠️ Attention : L'upload d'une nouvelle version │
│ créera la version v5. Vous pourrez la prévisualiser│
│ avant de l'activer. │
│ │
│ [ Choisir un fichier PDF ] (max 10MB) │
│ │
│ [ 📤 Uploader ] │
│ │
│ ───────────────────────────────────────────────── │
│ │
│ 📜 Historique des versions │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ ✅ v4 (Active) │ │
│ │ Activée le : 20/11/2025 14:30 │ │
│ │ Par : Lucas MOREAU │ │
│ │ Hash : a3f8b2c4...e0f1a2 ✓ │ │
│ │ [ 📥 Télécharger ] [ 👁️ Voir ] │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────┐ │
│ │ v3 (Inactive) │ │
│ │ Activée le : 15/10/2025 09:15 │ │
│ │ Par : Admin Système │ │
│ │ Hash : b4f9c3d6...f2a3b4 ✓ │ │
│ │ [ 📥 Télécharger ] [ 👁️ Voir ] [🔄 Réactiver]│ │
│ └───────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
---
## 🔒 Conformité RGPD
### Données capturées
| Donnée | Justification RGPD | Durée conservation |
|--------|-------------------|-------------------|
| `id_utilisateur` | Traçabilité acceptation | Durée du compte |
| `version_document` | Preuve version acceptée | Durée du compte |
| `accepte_le` | Horodatage légal | Durée du compte |
| `ip_address` | Preuve origine acceptation | 1 an (recommandé) |
| `user_agent` | Preuve navigateur/appareil | 1 an (recommandé) |
### Droits utilisateur
#### Droit d'accès (Article 15)
L'utilisateur peut demander :
- Quelles versions il a acceptées
- Quand il les a acceptées
- Depuis quelle IP
**API** : `GET /api/v1/users/me/acceptations`
#### Droit à l'oubli (Article 17)
Lors de la suppression du compte :
- Suppression des données personnelles
- Conservation des acceptations anonymisées (obligation légale)
**Implémentation** :
```sql
-- Anonymisation (pas suppression totale)
UPDATE acceptations_documents
SET ip_address = NULL,
user_agent = NULL
WHERE id_utilisateur = '<uuid>';
-- Puis suppression utilisateur
DELETE FROM utilisateurs WHERE id = '<uuid>';
```
---
## 📚 Références
### Documentation interne
- [01_CAHIER-DES-CHARGES.md](./01_CAHIER-DES-CHARGES.md)
- [10_DATABASE.md](./10_DATABASE.md)
- [11_API.md](./11_API.md)
- [21_CONFIGURATION-SYSTEME.md](./21_CONFIGURATION-SYSTEME.md)
### Documentation externe
- [RGPD - Article 7 (Consentement)](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre2#Article7)
- [RGPD - Article 15 (Droit d'accès)](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre3#Article15)
- [RGPD - Article 17 (Droit à l'oubli)](https://www.cnil.fr/fr/reglement-europeen-protection-donnees/chapitre3#Article17)
---
**Dernière mise à jour** : 25 Novembre 2025
**Version** : 1.0
**Statut** : ✅ Document validé
La documentation **Documents légaux** a été déplacée vers :
**[juridique/22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md)**
Voir aussi le dossier **[juridique/](./juridique/)** pour les sources **CGU**
et **CGC** en Markdown.
+61 -308
View File
@@ -1,15 +1,15 @@
# 🎫 Liste Complète des Tickets - Projet P'titsPas # 🎫 Liste Complète des Tickets - Projet P'titsPas
**Version** : 1.9 **Version** : 1.4
**Date** : 23 Avril 2026 **Date** : 9 Février 2026
**Auteur** : Équipe PtitsPas **Auteur** : Équipe PtitsPas
**Estimation totale** : ~220h **Estimation totale** : ~184h
--- ---
## 🔗 Liste des tickets Gitea ## 🔗 Liste des tickets Gitea
**Les numéros de section dans ce document = numéros dissues Gitea** (ticket #14 = issue Gitea #14, etc.). Le **numéro dune nouvelle issue** nest pas choisi dans ce fichier : il est **attribué par Gitea** (champ `number` dans la réponse JSON du `POST` création dissue, ou affiché dans lUI 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). **Les numéros de section dans ce document = numéros dissues Gitea.** Ticket #14 dans le doc = issue Gitea #14, etc. Source : dépôt `jmartin/petitspas` (état au 9 février 2026).
| Gitea # | Titre (dépôt) | Statut | | Gitea # | Titre (dépôt) | Statut |
|--------|----------------|--------| |--------|----------------|--------|
@@ -25,89 +25,13 @@
| 12 | [Backend] Guard Configuration Initiale | ✅ Fermé | | 12 | [Backend] Guard Configuration Initiale | ✅ Fermé |
| 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé | | 13 | [Backend] Adaptation MailService pour config dynamique | ✅ Fermé |
| 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert | | 14 | [Frontend] Panneau Paramètres / Configuration (première config + accès permanent) | Ouvert |
| 15 | [Frontend] Écran Paramètres (accès permanent) / Intégration panneau | Ouvert | | 15 | [Frontend] Écran Paramètres (accès permanent) | Ouvert |
| 16 | [Doc] Documentation configuration on-premise | Ouvert | | 16 | [Doc] Documentation configuration on-premise | Ouvert |
| 17 | [Backend] API Création gestionnaire | ✅ Terminé | | 1788 | (voir sections cidessous ; #82, #78, #79, #81, #83 ; #86, #87, #88 fermés en doublon) | — |
| 18 | [Backend] API Inscription Parent - REFONTE (Workflow complet 6 étapes) | ✅ Terminé | | 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | Ouvert |
| 19 | [Backend] API Inscription Parent (étape 2 - Parent 2) | ✅ Terminé |
| 20 | [Backend] API Inscription Parent (étape 3 - Enfants) | ✅ Terminé |
| 21 | [Backend] API Inscription Parent (étape 4-6 - Finalisation) | ✅ Terminé |
| 24 | [Backend] API Création mot de passe | Ouvert |
| 25 | [Backend] API Liste comptes en attente | ✅ Fermé (obsolète, couvert #103-#111) |
| 26 | [Backend] API Validation/Refus comptes | ✅ Fermé (obsolète, couvert #103-#111) |
| 27 | [Backend] Service Email - Installation Nodemailer | ✅ Fermé (obsolète, couvert #103-#111) |
| 28 | [Backend] Templates Email - Validation | Ouvert |
| 29 | [Backend] Templates Email - Refus | ✅ Fermé (obsolète, couvert #103-#111) |
| 30 | [Backend] Connexion - Vérification statut | ✅ Fermé (obsolète, couvert #103-#111) |
| 31 | [Backend] Changement MDP obligatoire première connexion | ✅ Terminé |
| 32 | [Backend] Service Documents Légaux | Ouvert |
| 33 | [Backend] API Documents Légaux | Ouvert |
| 34 | [Backend] Traçabilité acceptations documents | Ouvert |
| 35 | [Frontend] Écran Création Gestionnaire | Ouvert |
| 36 | [Frontend] Inscription Parent - Étape 1 (Parent 1) | ✅ Terminé |
| 37 | [Frontend] Inscription Parent - Étape 2 (Parent 2) | Ouvert |
| 38 | [Frontend] Inscription Parent - Étape 3 (Enfants) | ✅ Terminé |
| 39 | [Frontend] Inscription Parent - Étapes 4-6 (Finalisation) | ✅ Terminé |
| 40 | [Frontend] Inscription AM - Panneau 1 (Identité) | ✅ Terminé |
| 41 | [Frontend] Inscription AM - Panneau 2 (Infos pro) | ✅ Terminé |
| 42 | [Frontend] Inscription AM - Finalisation | ✅ Terminé |
| 43 | [Frontend] Écran Création Mot de Passe | Ouvert |
| 44 | [Frontend] Dashboard Gestionnaire - Structure | ✅ Terminé |
| 45 | [Frontend] Dashboard Gestionnaire - Liste Parents | ✅ Fermé (obsolète, couvert #103-#111) |
| 46 | [Frontend] Dashboard Gestionnaire - Liste AM | ✅ Fermé (obsolète, couvert #103-#111) |
| 47 | [Frontend] Écran Changement MDP Obligatoire | ✅ Fermé |
| 48 | [Frontend] Gestion Erreurs & Messages | Ouvert |
| 49 | [Frontend] Écran Gestion Documents Légaux (Admin) | Ouvert |
| 50 | [Frontend] Affichage dynamique CGU lors inscription | Ouvert |
| 51 | [Frontend] Écran Logs Admin (optionnel v1.1) | Ouvert |
| 52 | [Tests] Tests unitaires Backend | Ouvert |
| 53 | [Tests] Tests intégration Backend | Ouvert |
| 54 | [Tests] Tests E2E Frontend | Ouvert |
| 55 | [Doc] Documentation API OpenAPI/Swagger | Ouvert |
| 56 | [Backend] Service Upload & Stockage fichiers | Ouvert |
| 58 | [Backend] Service Logging (Winston) | Ouvert |
| 59 | [Infra] Volume Docker pour uploads | Ouvert |
| 60 | [Infra] Volume Docker pour documents légaux | Ouvert |
| 61 | [Doc] Guide installation & configuration | Ouvert |
| 62 | [Doc] Amendement CDC v1.4 - Suppression SMS | Ouvert |
| 63 | [Doc] Rédaction CGU/Privacy génériques v1 | Ouvert |
| 78 | [Frontend] Refonte Infrastructure Formulaires Multi-modes | ✅ Terminé |
| 79 | [Frontend] Renommer "Nanny" en "Assistante Maternelle" (AM) | ✅ Terminé |
| 81 | [Frontend] Corrections suite refactoring widgets | ✅ Terminé |
| 83 | [Frontend] Adapter RegisterChoiceScreen pour mobile | ✅ Terminé |
| 86 / 88 | Doublons fermés (voir #12, #14, #15) | ✅ Fermé |
| 89 | Log des appels API en mode debug | Ouvert |
| 91 | [Frontend] Inscription AM Branchement soumission formulaire à l'API | Ouvert |
| 92 | [Frontend] Dashboard Admin - Données réelles et branchement API | ✅ Terminé |
| 93 | [Frontend] Panneau Admin - Homogénéisation des onglets | ✅ Fermé |
| 94 | [Backend] Relais - Modèle, API CRUD et liaison gestionnaire | ✅ Terminé |
| 95 | [Frontend] Admin - Gestion des Relais et rattachement gestionnaire | ✅ Fermé |
| 96 | [Frontend] Admin - Création administrateur via modale (sans relais) | ✅ Terminé |
| 97 | [Backend] Harmoniser API création administrateur avec le contrat frontend | ✅ Terminé |
| 101 | [Frontend] Inscription Parent Branchement soumission formulaire à l'API | Ouvert |
| 103 | Numéro de dossier backend | Ouvert |
| 104 | Numéro de dossier frontend | Ouvert |
| 105 | Statut « refusé » | Ouvert |
| 106 | Liste familles en attente | ✅ Fermé |
| 107 | Onglet « À valider » + listes | ✅ Fermé |
| 108 | Validation dossier famille | Ouvert |
| 109 | Modale de validation | ✅ Fermé |
| 110 | Refus sans suppression | Ouvert |
| 111 | Reprise après refus backend | Ouvert |
| 112 | Reprise après refus frontend | Ouvert |
| 113 | Doublons à l'inscription | Ouvert |
| 114 | Doublons alerte gestionnaire | Ouvert |
| 115 | Rattachement parent backend | Ouvert |
| 116 | Rattachement parent frontend | Ouvert |
| 117 | Évolution du cahier des charges | Ouvert |
| 119 | Endpoint unifié GET /dossiers/:numeroDossier (AM ou famille) | ✅ Fermé |
| 120 | [Full-stack] Inscription AM — photo, API et UX alignés sur les parents (suite #91) | ✅ Fermé |
| 127 | [Full-stack] Mot de passe oublié — flux complet (demande, e-mail, réinitialisation) | ✅ Fermé · **milestone 0.1.0** + label **v0.1.0** (Gitea) |
*Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues* *Gitea #1 et #2 = anciens tickets de test (fermés). Liste complète : https://git.ptits-pas.fr/jmartin/petitspas/issues*
*Tickets #103 à #117 = périmètre « Validation des nouveaux comptes par le gestionnaire » (voir plan de spec).*
--- ---
## 📊 Vue d'ensemble ## 📊 Vue d'ensemble
@@ -119,11 +43,11 @@
| **P0** | 7 tickets | ~5h | Amendements BDD (BLOQUANT) | | **P0** | 7 tickets | ~5h | Amendements BDD (BLOQUANT) |
| **P1** | 7 tickets | ~22h | Configuration système (BLOQUANT) | | **P1** | 7 tickets | ~22h | Configuration système (BLOQUANT) |
| **P2** | 18 tickets | ~50h | Backend métier | | **P2** | 18 tickets | ~50h | Backend métier |
| **P3** | 23 tickets | ~83h | Frontend | | **P3** | 22 tickets | ~71h | Frontend |
| **P4** | 4 tickets | ~24h | Tests & Documentation | | **P4** | 4 tickets | ~24h | Tests & Documentation |
| **CRITIQUES** | 6 tickets | ~13h | Upload, Logs, Infra, CDC | | **CRITIQUES** | 6 tickets | ~13h | Upload, Logs, Infra, CDC |
| **JURIDIQUE** | 1 ticket | ~8h | Rédaction CGU/Privacy | | **JURIDIQUE** | 1 ticket | ~8h | Rédaction CGU/Privacy |
| **TOTAL** | **66 tickets** | **~220h** | | | **TOTAL** | **65 tickets** | **~184h** | |
--- ---
@@ -244,7 +168,7 @@ Créer les tables pour gérer les versions des documents légaux (CGU/Privacy) e
- [ ] Créer migration Prisma - [ ] Créer migration Prisma
- [ ] Tester migration - [ ] Tester migration
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md) **Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md)
--- ---
@@ -392,22 +316,21 @@ Rédiger la documentation pour aider les collectivités à configurer l'applicat
## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes ## 🟢 PRIORITÉ 2 : Backend - Authentification & Gestion Comptes
### Ticket #17 : [Backend] API Création gestionnaire ### Ticket #17 : [Backend] API Création gestionnaire
**Estimation** : 3h **Estimation** : 3h
**Labels** : `backend`, `p2`, `auth` **Labels** : `backend`, `p2`, `auth`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-23)
**Description** : **Description** :
Créer l'endpoint pour permettre au super admin de créer des gestionnaires. Créer l'endpoint pour permettre au super admin de créer des gestionnaires.
**Tâches** : **Tâches** :
- [x] Endpoint `POST /api/v1/gestionnaires` - [ ] Endpoint `POST /api/v1/gestionnaires`
- [x] Validation DTO - [ ] Validation DTO
- [x] Hash bcrypt - [ ] Hash bcrypt
- [x] Flag `changement_mdp_obligatoire = TRUE` - [ ] Flag `changement_mdp_obligatoire = TRUE`
- [x] Guards (super_admin only) - [ ] Guards (super_admin only)
- [x] Email de notification (utiliser MailService avec config dynamique) - [ ] Email de notification (utiliser MailService avec config dynamique)
- [x] Tests unitaires - [ ] Tests unitaires
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire) **Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-2--création-dun-gestionnaire)
@@ -520,24 +443,22 @@ Finalisation de l'inscription parent (présentation, CGU, récapitulatif - inté
--- ---
### Ticket #24 : [Backend] API Création mot de passe ### Ticket #24 : [Backend] API Création mot de passe
**Estimation** : 3h **Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `security` **Labels** : `backend`, `p2`, `auth`, `security`
**Statut** : ✅ TERMINÉ (livré sur `develop` + `master`, févr. 2026 — aligné ticket Gitea **#123** durcissement token)
**Description** : **Description** :
Créer les endpoints pour permettre aux utilisateurs de créer leur mot de passe via le lien reçu par email. Créer les endpoints pour permettre aux utilisateurs de créer leur mot de passe via le lien reçu par email.
**Tâches** : **Tâches** :
- [x] Endpoint `GET /api/v1/auth/verify-token?token=<uuid>` - [ ] Endpoint `GET /api/v1/auth/verify-token?token=<uuid>`
- [x] Endpoint `POST /api/v1/auth/create-password` (body : `token`, `password`, `password_confirmation`) - [ ] Endpoint `POST /api/v1/auth/create-password`
- [x] Validation token (existe, non expiré, non utilisé) + invalidation explicite si expiré - [ ] Validation token (existe, non expiré, non utilisé)
- [x] Hash bcrypt + suppression token (usage unique atomique côté SQL) - [ ] Hash bcrypt + suppression token
- [x] Activation compte (`statut = actif`, `changement_mdp_obligatoire = false`) - [ ] Activation compte (`statut = actif`)
- [ ] Tests unitaires / e2e (hors périmètre immédiat ; suite Jest actuelle à corriger séparément) - [ ] Tests unitaires
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-7--création-du-mot-de-passe) **Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-7--création-du-mot-de-passe)
**Suite** : alignement **frontend** → ticket **#43** (appels explicites ci-dessous).
--- ---
@@ -592,23 +513,22 @@ Installer et configurer Nodemailer pour l'envoi d'emails.
--- ---
### Ticket #28 : [Backend] Templates Email - Validation ### Ticket #28 : [Backend] Templates Email - Validation
**Estimation** : 3h **Estimation** : 3h
**Labels** : `backend`, `p2`, `email` **Labels** : `backend`, `p2`, `email`
**Statut** : ✅ TERMINÉ
**Description** : **Description** :
Créer les templates d'emails pour la validation des comptes (avec lien création MDP). Créer les templates d'emails pour la validation des comptes (avec lien création MDP).
**Tâches** : **Tâches** :
- [x] Template **Parent** (création MDP, parent principal ou co-parent) — `account-validated-parent.hbs` - [ ] Template Parent 1 (création MDP)
- [x] URL app : Lire depuis `ConfigService.get('app_url')` (lien `/create-password?token=…`) - [ ] URL app : Lire depuis `ConfigService.get('app_url')`
- [x] Nom app : Lire depuis `ConfigService.get('app_name')` - [ ] Nom app : Lire depuis `ConfigService.get('app_name')`
- [x] Expéditeur : via `sendEmail` (email_from_name / email_from_address) - [ ] Expéditeur : Lire depuis ConfigService
- [x] Template AM (création MDP) — `account-validated-am.hbs` - [ ] Template Parent 2 (co-parent création MDP)
- [x] Intégration Handlebars + copie `.hbs` dans `dist` (`nest-cli.json` assets) - [ ] Template AM (création MDP)
- [x] Envoi déclenché dans `UserService.validateUser` (parent / AM sans mot de passe) + renouvellement expiration token - [ ] Intégration Handlebars
- [x] Tests unitaires (`mail.service.spec.ts`, mock `sendEmail`) - [ ] Tests d'envoi
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-6--réception-de-la-notification) **Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-6--réception-de-la-notification)
@@ -647,18 +567,17 @@ Modifier l'endpoint de connexion pour bloquer les comptes en attente ou suspendu
--- ---
### Ticket #31 : [Backend] Changement MDP obligatoire première connexion ### Ticket #31 : [Backend] Changement MDP obligatoire première connexion
**Estimation** : 2h **Estimation** : 2h
**Labels** : `backend`, `p2`, `auth`, `security` **Labels** : `backend`, `p2`, `auth`, `security`
**Statut** : ✅ TERMINÉ
**Description** : **Description** :
Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion. Implémenter le changement de mot de passe obligatoire pour les gestionnaires/admins à la première connexion.
**Tâches** : **Tâches** :
- [x] Endpoint `POST /api/v1/auth/change-password-required` - [ ] Endpoint `POST /api/v1/auth/change-password-required`
- [x] Vérification flag `changement_mdp_obligatoire` - [ ] Vérification flag `changement_mdp_obligatoire`
- [x] Mise à jour flag après changement - [ ] Mise à jour flag après changement
- [ ] Tests unitaires - [ ] Tests unitaires
--- ---
@@ -681,7 +600,7 @@ Créer le service de gestion des documents légaux (CGU/Privacy) avec versioning
- [ ] Méthode `listerVersions()` - [ ] Méthode `listerVersions()`
- [ ] Tests unitaires - [ ] Tests unitaires
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#service-documents-légaux) **Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#service-documents-légaux)
--- ---
@@ -701,7 +620,7 @@ Créer les endpoints REST pour gérer les documents légaux.
- [ ] Guards + validation - [ ] Guards + validation
- [ ] Tests unitaires - [ ] Tests unitaires
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#apis) **Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#apis)
--- ---
@@ -718,40 +637,7 @@ Enregistrer les acceptations de documents légaux lors de l'inscription (traçab
- [ ] API `GET /api/v1/users/:id/acceptations` (admin) - [ ] API `GET /api/v1/users/:id/acceptations` (admin)
- [ ] Tests unitaires - [ ] Tests unitaires
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#workflow-acceptation-utilisateur) **Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#workflow-acceptation-utilisateur)
---
### Ticket #94 : [Backend] Relais - Modèle, API CRUD et liaison gestionnaire ✅
**Estimation** : 4h
**Labels** : `backend`, `p2`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-21)
**Description** :
Le back-office admin doit gérer des Relais avec des données réelles en base, et permettre une liaison simple avec les gestionnaires.
**Tâches** :
- [x] Créer le modèle `Relais` (nom, adresse, horaires, téléphone, actif, notes)
- [x] Exposer les endpoints admin CRUD pour les relais (`GET`, `POST`, `PATCH`, `DELETE`)
- [x] Ajouter la liaison : un gestionnaire peut être rattaché à un relais principal (`relais_id` dans `users` ?)
- [x] Validations (champs requis, format horaires)
---
### Ticket #97 : [Backend] Harmoniser API création administrateur avec le contrat frontend ✅
**Estimation** : 3h
**Labels** : `backend`, `p2`, `auth`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Rendre l'API de création administrateur cohérente et stable avec le besoin frontend (modale simplifiée), en définissant un contrat clair et minimal.
**Tâches** :
- [ ] Introduire un DTO dédié `CreateAdministrateurDto`
- [ ] Champs autorisés : nom, prenom, email, password, telephone
- [ ] Champs exclus : adresse, ville, photo, etc.
- [ ] Rôle forcé à `ADMINISTRATEUR`
- [ ] Validation stricte
--- ---
@@ -902,50 +788,17 @@ Créer les étapes finales de l'inscription AM (présentation, CGU, récapitulat
**Description** : **Description** :
Créer l'écran de création de mot de passe (lien reçu par email). Créer l'écran de création de mot de passe (lien reçu par email).
**Dépendance backend** : ticket **#24** / **#123** — routes disponibles sous préfixe global `api/v1` (voir `backend/src/main.ts`).
**Tâches** : **Tâches** :
- [ ] Page `/create-password?token=<uuid>` (lecture du `token` en query) - [ ] Page `/create-password?token=<uuid>`
- [ ] Au chargement (ou avant affichage formulaire) : `GET /api/v1/auth/verify-token?token=<uuid>` — si 404, message neutre type « lien invalide ou expiré » - [ ] Vérification token (appel API)
- [ ] Formulaire MDP + confirmation - [ ] Formulaire MDP + confirmation
- [ ] Validation côté client (alignée backend : min 8 car., 1 maj., 1 chiffre — même règle que `change-password-required`) - [ ] Validation côté client (8 car, 1 maj, 1 chiffre)
- [ ] Soumission : `POST /api/v1/auth/create-password` avec JSON `{ "token", "password", "password_confirmation" }` ; gérer 400 (ex. confirmation) / 404 (token) - [ ] Appel API création MDP
- [ ] Succès → redirection vers login + message de succès - [ ] Redirection vers login
--- ---
### Ticket #127 : [Full-stack] Mot de passe oublié — flux complet (demande, e-mail, réinitialisation) ### Ticket #44 : [Frontend] Dashboard Gestionnaire - Structure
**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`). Aujourdhui, 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 à linscription — documenter le choix
- [ ] `POST` (ex.) `/api/v1/auth/forgot-password` : body `{ "email" }` ; toujours réponse **neutre** (ex. 204 ou message identique que le-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 à lavenir, 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 **Estimation** : 2h
**Labels** : `frontend`, `p3`, `gestionnaire` **Labels** : `frontend`, `p3`, `gestionnaire`
@@ -953,10 +806,9 @@ Couvrir **tout** le parcours « Mot de passe oublié » : distinct du flux **cr
Créer la structure du dashboard gestionnaire avec 2 onglets. Créer la structure du dashboard gestionnaire avec 2 onglets.
**Tâches** : **Tâches** :
- [x] Dashboard gestionnaire = même shell que admin (sans onglet Paramètres), libellé « Gestionnaire » - [ ] Layout avec 2 onglets (Parents / AM)
- [x] Réutilisation du widget UserManagementPanel (ex-AdminUserManagementPanel) avec 3 onglets (Gestionnaires, Parents, Assistantes maternelles) ; onglet Administrateurs masqué - [ ] Navigation entre onglets
- [x] Redirection login rôle `gestionnaire` vers `/gestionnaire-dashboard` - [ ] État vide ("Aucune demande")
- [ ] État vide dédié ("Aucune demande") — optionnel, contenu actuel = listes existantes
--- ---
@@ -1038,14 +890,13 @@ Créer l'écran de gestion des documents légaux (CGU/Privacy) pour l'admin.
- [ ] Activation version - [ ] Activation version
- [ ] Historique versions - [ ] Historique versions
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md#interface-admin) **Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md#interface-admin)
--- ---
### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API ### Ticket #92 : [Frontend] Dashboard Admin - Données réelles et branchement API
**Estimation** : 8h **Estimation** : 8h
**Labels** : `frontend`, `p3`, `admin` **Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-17)
**Description** : **Description** :
Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md). Le dashboard admin (onglets Gestionnaires | Parents | Assistantes maternelles | Administrateurs) affiche actuellement des données en dur (mock). Remplacer par des appels API pour afficher les vrais utilisateurs et permettre les actions de gestion (voir, modifier, valider/refuser). Référence : [90_AUDIT.md](./90_AUDIT.md).
@@ -1167,89 +1018,6 @@ Adapter l'écran de choix Parent/AM pour une meilleure expérience mobile et coh
--- ---
### Ticket #91 : [Frontend] Inscription AM Branchement soumission formulaire à l'API
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Description** :
Branchement du formulaire d'inscription AM (étape 4) à l'endpoint d'inscription.
**Tâches** :
- [ ] Construire le body (DTO) à partir de `AmRegistrationData`
- [ ] Appel HTTP `POST /api/v1/auth/register/am`
- [ ] Gestion réponse (201 : succès + redirection ; 4xx : erreur)
- [ ] Conversion photo en base64 si nécessaire
---
### Ticket #101 : [Frontend] Inscription Parent Branchement soumission formulaire à l'API
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `auth`, `cdc`
**Description** :
Branchement du formulaire d'inscription parent (étape 5, récapitulatif) à l'endpoint d'inscription. Aujourd'hui la soumission n'appelle pas l'API : elle affiche uniquement une modale de confirmation puis redirige vers le login. Ce ticket vise à envoyer les données collectées (Parent 1, Parent 2 optionnel, enfants, présentation, CGU) à l'API.
**Tâches** :
- [ ] Créer un service ou méthode (ex. `AuthService.registerParent` ou `UserService`) appelant `POST /api/v1/auth/register/parent`
- [ ] Construire le body (DTO) à partir de `UserRegistrationData` (parent1, parent2, children, motivationText, CGU acceptée, etc.) en cohérence avec le contrat backend (voir ticket #18 refonte)
- [ ] Dans `ParentRegisterStep5Screen`, au clic « Soumettre » : appel API puis en cas de succès afficher la modale et redirection vers `/login` ; en cas d'erreur afficher le message (SnackBar/dialog)
- [ ] Gestion des photos enfants (base64 ou multipart selon API)
- [ ] Optionnel : réinitialiser ou conserver `UserRegistrationData` après succès (selon UX)
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-3--inscription-dun-parent), backend #18 (refonte API inscription parent).
**Création** : issue Gitea #101 créée. Pour recréer ou script : `node backend/scripts/create-gitea-issue-parent-api.js` (token dans `.gitea-token` ou voir [26_GITEA-API.md](./26_GITEA-API.md)).
---
### Ticket #93 : [Frontend] Panneau Admin - Homogénéisation des onglets ✅
**Estimation** : 4h
**Labels** : `frontend`, `p3`, `admin`, `ux`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Uniformiser l'UI/UX des 4 onglets du dashboard admin (Gestionnaires, Parents, AM, Admins).
**Tâches** :
- [ ] Standardiser le header de liste (Recherche, Filtres, Bouton Action)
- [ ] Standardiser les cartes utilisateurs (`ListTile` uniforme)
- [ ] Standardiser les états (Loading, Erreur, Vide)
- [ ] Factoriser les composants partagés
---
### Ticket #95 : [Frontend] Admin - Gestion des Relais et rattachement gestionnaire ✅
**Estimation** : 5h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Interface de gestion des Relais dans le dashboard admin et rattachement des gestionnaires.
**Tâches** :
- [ ] Section Relais avec 2 sous-onglets : Paramètres techniques / Paramètres territoriaux
- [ ] Liste, Création, Édition, Activation/Désactivation des relais
- [ ] Champs UI : nom, adresse, horaires, téléphone, statut, notes
- [ ] Onglet Gestionnaires : Ajout contrôle de rattachement au relais principal
---
### Ticket #96 : [Frontend] Admin - Création administrateur via modale (sans relais) ✅
**Estimation** : 3h
**Labels** : `frontend`, `p3`, `admin`
**Statut** : ✅ TERMINÉ (Fermé le 2026-02-24)
**Description** :
Permettre la création d'un administrateur via une modale simple depuis le dashboard admin.
**Tâches** :
- [x] Bouton "Créer administrateur" dans l'onglet Administrateurs
- [x] Modale avec formulaire simplifié (Nom, Prénom, Email, MDP, Téléphone)
- [x] Appel API `POST /users` (ou endpoint dédié si #97 implémenté)
- [x] Gestion succès/erreur et rafraîchissement liste
---
## 🔵 PRIORITÉ 4 : Tests & Documentation ## 🔵 PRIORITÉ 4 : Tests & Documentation
### Ticket #52 : [Tests] Tests unitaires Backend ### Ticket #52 : [Tests] Tests unitaires Backend
@@ -1365,20 +1133,6 @@ Mettre en place un système de logs centralisé avec Winston pour faciliter le d
--- ---
### Ticket #89 : Log des appels API en mode debug
**Estimation** : 2h
**Labels** : `backend`, `monitoring`
**Description** :
Ajouter des logs détaillés pour les appels API en mode debug pour faciliter le diagnostic.
**Tâches** :
- [ ] Middleware ou Intercepteur pour logger les requêtes entrantes (méthode, URL, body)
- [ ] Logger les réponses (status, temps d'exécution)
- [ ] Activable via variable d'environnement `DEBUG=true` ou niveau de log
---
### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1) ### Ticket #51 (réf.) : [Frontend] Écran Logs Admin (optionnel v1.1)
**Estimation** : 4h **Estimation** : 4h
**Labels** : `frontend`, `p3`, `monitoring`, `admin` **Labels** : `frontend`, `p3`, `monitoring`, `admin`
@@ -1481,29 +1235,28 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
## 📊 Résumé final ## 📊 Résumé final
**Total** : 72 tickets **Total** : 65 tickets
**Estimation** : ~208h de développement **Estimation** : ~184h de développement
### Par priorité ### Par priorité
- **P0 (Bloquant BDD)** : 7 tickets (~5h) - **P0 (Bloquant BDD)** : 7 tickets (~5h)
- **P1 (Bloquant Config)** : 7 tickets (~22h) - **P1 (Bloquant Config)** : 7 tickets (~22h)
- **P2 (Backend)** : 19 tickets (~54h) - **P2 (Backend)** : 18 tickets (~50h)
- **P3 (Frontend)** : 25 tickets (~83h) - **P3 (Frontend)** : 22 tickets (~71h) ← +1 mobile RegisterChoice
- **P4 (Tests/Doc)** : 4 tickets (~24h) - **P4 (Tests/Doc)** : 4 tickets (~24h)
- **Critiques** : 6 tickets (~13h) - **Critiques** : 6 tickets (~13h)
- **Juridique** : 1 ticket (~8h) - **Juridique** : 1 ticket (~8h)
### Par domaine ### Par domaine
- **BDD** : 7 tickets - **BDD** : 7 tickets
- **Backend** : 24 tickets - **Backend** : 23 tickets
- **Frontend** : 25 tickets - **Frontend** : 22 tickets ← +1 mobile RegisterChoice
- **Tests** : 3 tickets - **Tests** : 3 tickets
- **Documentation** : 5 tickets - **Documentation** : 5 tickets
- **Infra** : 2 tickets - **Infra** : 2 tickets
- **Juridique** : 1 ticket - **Juridique** : 1 ticket
### Modifications par rapport à la version initiale ### Modifications par rapport à la version initiale
-**v1.5** : Ajout tickets #91, #93, #94, #95. Ticket #92 terminé.
-**v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86#12, #87#14, #88#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea). -**v1.4** : Numéros de section du doc = numéros Gitea (Ticket #n = issue #n). Tableau et sections renumérotés. Doublons #86, #87, #88 fermés sur Gitea (#86#12, #87#14, #88#15) ; tickets sources #12, #14, #15 mis à jour (doc + body Gitea).
-**Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés. -**Concept v1.3** : Configuration initiale = un seul panneau Paramètres (3 sections) dans le dashboard ; plus de page dédiée « Setup Wizard » ; navigation bloquée jusqu’à sauvegarde au premier déploiement. Tickets #10, #12, #13 alignés.
-**Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire -**Supprimé** : Tickets "Renvoyer email validation" (backend + frontend) - Pas prioritaire
@@ -1517,7 +1270,7 @@ Rédiger les documents légaux génériques (CGU et Politique de confidentialit
--- ---
**Dernière mise à jour** : 25 Février 2026 **Dernière mise à jour** : 9 Février 2026
**Version** : 1.6 **Version** : 1.4
**Statut** : ✅ Aligné avec le dépôt Gitea (tickets #103-#117 créés) **Statut** : ✅ Aligné avec le dépôt Gitea
+6 -41
View File
@@ -1,7 +1,7 @@
# 📋 Décisions Projet - P'titsPas # 📋 Décisions Projet - P'titsPas
**Version** : 1.2 **Version** : 1.1
**Date** : 16 Juin 2026 **Date** : 9 Février 2026
**Auteur** : Équipe PtitsPas **Auteur** : Équipe PtitsPas
--- ---
@@ -95,7 +95,7 @@ ptitspas-app/
- Activation manuelle après prévisualisation - Activation manuelle après prévisualisation
- Documents génériques v1 fournis par défaut - Documents génériques v1 fournis par défaut
**Référence** : [22_DOCUMENTS-LEGAUX.md](./juridique/22_DOCUMENTS-LEGAUX.md) **Référence** : [22_DOCUMENTS-LEGAUX.md](./22_DOCUMENTS-LEGAUX.md)
--- ---
@@ -122,27 +122,6 @@ 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 responsableenfant.
**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) ### 6. Genre enfant obligatoire (H/F)
**Décision** : ✅ **Genre obligatoire (H/F uniquement)** **Décision** : ✅ **Genre obligatoire (H/F uniquement)**
@@ -434,18 +413,7 @@ docs/
├── 11_API.md ├── 11_API.md
├── 20_WORKFLOW-CREATION-COMPTE.md ├── 20_WORKFLOW-CREATION-COMPTE.md
├── 21_CONFIGURATION-SYSTEME.md ├── 21_CONFIGURATION-SYSTEME.md
├── 22_DOCUMENTS-LEGAUX.md # pointeur → juridique/ ├── 22_DOCUMENTS-LEGAUX.md
├── 27_BRIEFING-FRONTEND.md
├── PROCEDURE-API-GITEA.md # pointeur → 26_GITEA-API.md
├── juridique/
│ ├── README.md
│ ├── cgu.md
│ ├── cgc.md
│ └── 22_DOCUMENTS-LEGAUX.md
├── archive/
│ ├── README.md
│ ├── temporaires/
│ └── obsolete/
├── 23_LISTE-TICKETS.md ├── 23_LISTE-TICKETS.md
├── 24_DECISIONS-PROJET.md (ce document) ├── 24_DECISIONS-PROJET.md (ce document)
├── 90_AUDIT.md ├── 90_AUDIT.md
@@ -583,7 +551,6 @@ docs/
| 14 | Migration données | ❌ Rejeté | N/A | | 14 | Migration données | ❌ Rejeté | N/A |
| 15 | Doc utilisateur | ⏸️ Phase 2 | Formation | | 15 | Doc utilisateur | ⏸️ Phase 2 | Formation |
| 31 | Logs Winston | ✅ Phase 1 | Monitoring | | 31 | Logs Winston | ✅ Phase 1 | Monitoring |
| 5bis | Familles recomposées — 2ᵉ compte v1.0.0 | ✅ v1.0.0 | Métier / dossier |
--- ---
@@ -593,12 +560,10 @@ docs/
|------|---------|---------------| |------|---------|---------------|
| 25/11/2025 | 1.0 | Création du document - Toutes les décisions initiales | | 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 | | 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** : 16 Juin 2026 **Dernière mise à jour** : 9 Février 2026
**Version** : 1.2 **Version** : 1.1
**Statut** : ✅ Document validé **Statut** : ✅ Document validé
@@ -1,331 +0,0 @@
# É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 parentenfant, 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 aujourdhui une **aide forte** pour les cas simples (un couple, N enfants, une motivation), mais il tend à devenir **lidentifiant 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** nest pas parent de β ; **B** nest pas parent de α.
### 3.2 Autres cas couverts par la même limitation
Les exemples « maman / papa » sont **illustratifs**. La limitation sapplique **à 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 lapp.
- 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 lemail de P (titulaire ou co-parent), une **2ᵉ inscription publique** où lon 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 dun **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 sappuyant 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 dun 2ᵉ email pour M).
- **A** et **B** ne voient **que** lenfant qui leur est rattaché via `enfants_parents` — pas de fusion « famille » qui mélange A et B.
- Le parcours nest **pas** proposé à linscription 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 » aujourdhui, 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.24.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 louverture (pas lecture seule + « Modifier » factice).
- **Retirer lID** 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 laffiliation** ≠ modifier le téléphone : gestion des **liens** (détacher / rattacher), avec garde-fous :
- ne pas supprimer lenfant 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 linterface ; 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
| Aujourdhui | 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. Lapproche B maximise lUX 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 dautres 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 » dun 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 lutilisateur 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 lunité.
- 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.
> Sapplique à **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).*
@@ -1,9 +1,4 @@
# Architecture Technique - P'titsPas # Architecture Technique - P'titsPas
> **Archivé** (`docs/archive/obsolete/`) — non maintenu à la racine `docs/`.
> Voir [`02_ARCHITECTURE.md`](../../02_ARCHITECTURE.md) pour la référence
> courante.
## Guide d'Infrastructure et de Déploiement ## Guide d'Infrastructure et de Déploiement
--- ---
-47
View File
@@ -2,8 +2,6 @@
Ce document liste les modifications à apporter au cahier des charges original pour le rendre conforme à l'application développée. 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 ## 1. Gestion des Enfants
### Modifications à apporter dans la section "Création de compte parent" ### Modifications à apporter dans la section "Création de compte parent"
@@ -258,48 +256,3 @@ Pour chaque évolution identifiée, ce document suivra la structure suivante :
- Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`). - Modification du flux de sélection d'image dans les écrans concernés (ex: `parent_register_step3_screen.dart`).
- Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes. - Ajout potentiel de nouvelles dépendances et configurations spécifiques aux plateformes.
- Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée. - Mise à jour de la documentation utilisateur si cette fonctionnalité est implémentée.
## 8. Évolution future - Gouvernance intra-RPE
### 8.1 Niveaux d'accès et rôles différenciés dans un même Relais
#### 8.1.1 Situation actuelle
- Le périmètre actuel prévoit un rattachement simple entre gestionnaire et relais.
- Le rôle "gestionnaire" est traité de manière uniforme dans l'outil.
#### 8.1.2 Évolution à prévoir
- Introduire un modèle de rôles internes au relais (par exemple : responsable/coordinatrice, animatrice/référente, administratif).
- Permettre des niveaux d'autorité différents selon les actions (pilotage, validation, consultation, administration locale).
- Définir des permissions fines par fonctionnalité (lecture, création, modification, suppression, validation).
- Prévoir une gestion multi-utilisateurs par relais avec traçabilité des décisions.
#### 8.1.3 Impact attendu
- Évolution du modèle de données vers un RBAC intra-RPE.
- Adaptation des écrans d'administration pour gérer les rôles locaux.
- Renforcement des contrôles d'accès backend et des règles métier.
- Clarification des workflows décisionnels dans l'application.
## 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 responsableenfant » (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).

Some files were not shown because too many files have changed in this diff Show More