feat(backend): #28 templates email validation compte (Handlebars)
- Templates .hbs parent principal, co-parent, AM + lien create-password - app_name / app_url / expéditeur via ConfigService (sendEmail) - validateUser: renouvelle token création MDP + envoi email si sans password - Dist: assets *.hbs (nest-cli) - Tests unitaires MailService (mock sendEmail) - Doc liste tickets #28 terminé Made-with: Cursor
This commit is contained in:
parent
68efa91c39
commit
94d9428c43
@ -3,6 +3,12 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{
|
||||
"include": "**/*.hbs",
|
||||
"watchAssets": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
199
backend/package-lock.json
generated
199
backend/package-lock.json
generated
@ -22,8 +22,11 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"joi": "^18.0.0",
|
||||
"mapped-types": "^0.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^6.9.16",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pg": "^8.16.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@ -39,8 +42,11 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/nodemailer": "^6.4.16",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"eslint": "^9.18.0",
|
||||
@ -2462,6 +2468,82 @@
|
||||
"@nestjs/core": "^11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/platform-express/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/schematics": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.7.tgz",
|
||||
@ -3613,6 +3695,17 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/handlebars": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/handlebars/-/handlebars-4.1.0.tgz",
|
||||
"integrity": "sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==",
|
||||
"deprecated": "This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"handlebars": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
@ -3688,6 +3781,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/multer": {
|
||||
"version": "1.4.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.13.tgz",
|
||||
"integrity": "sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mysql": {
|
||||
"version": "2.15.27",
|
||||
"resolved": "https://registry.npmjs.org/@types/mysql/-/mysql-2.15.27.tgz",
|
||||
@ -3706,6 +3809,16 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.23",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
|
||||
"integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/passport": {
|
||||
"version": "1.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz",
|
||||
@ -5578,20 +5691,56 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
"node >= 0.8"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"readable-stream": "^2.2.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/consola": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
|
||||
@ -5658,7 +5807,6 @@
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
@ -7004,7 +7152,6 @@
|
||||
"version": "4.7.8",
|
||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
|
||||
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.5",
|
||||
@ -7026,7 +7173,6 @@
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@ -8889,21 +9035,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
@ -8995,7 +9142,6 @@
|
||||
"version": "2.6.2",
|
||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abort-controller": {
|
||||
@ -9049,6 +9195,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
@ -9706,6 +9861,12 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
@ -11534,7 +11695,6 @@
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
@ -12013,7 +12173,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
|
||||
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
|
||||
@ -36,6 +36,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.2",
|
||||
"handlebars": "^4.7.8",
|
||||
"joi": "^18.0.0",
|
||||
"mapped-types": "^0.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
@ -55,6 +56,7 @@
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/handlebars": "^4.1.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.7",
|
||||
|
||||
72
backend/src/modules/mail/mail.service.spec.ts
Normal file
72
backend/src/modules/mail/mail.service.spec.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { MailService, ValidationAccountEmailKind } from './mail.service';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
|
||||
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_primary', 'Compte validé'],
|
||||
['parent_coparent', 'Co-parent'],
|
||||
['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)}`);
|
||||
});
|
||||
});
|
||||
@ -1,12 +1,80 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import { AppConfigService } from '../config/config.service';
|
||||
|
||||
/** Ticket #28 — quel template d'email envoyer après validation de compte */
|
||||
export type ValidationAccountEmailKind = 'parent_primary' | 'parent_coparent' | 'am';
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
private readonly logger = new Logger(MailService.name);
|
||||
|
||||
/** Cache des templates Handlebars compilés (fichiers .hbs) */
|
||||
private readonly compiledTemplates = new Map<ValidationAccountEmailKind, Handlebars.TemplateDelegate>();
|
||||
|
||||
constructor(private readonly configService: AppConfigService) {}
|
||||
|
||||
private templateBasename(kind: ValidationAccountEmailKind): string {
|
||||
const map: Record<ValidationAccountEmailKind, string> = {
|
||||
parent_primary: 'account-validated-parent-primary',
|
||||
parent_coparent: 'account-validated-parent-coparent',
|
||||
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_primary: `${appName} — Compte validé : créez votre mot de passe`,
|
||||
parent_coparent: `${appName} — Co-parent : 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
|
||||
|
||||
14
backend/src/modules/mail/templates/account-validated-am.hbs
Normal file
14
backend/src/modules/mail/templates/account-validated-am.hbs
Normal file
@ -0,0 +1,14 @@
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||
<p>Votre demande d'inscription en tant qu'<strong>assistante maternelle</strong> sur <strong>{{appName}}</strong> a été <strong>validée</strong>.</p>
|
||||
{{#if numeroDossier}}
|
||||
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
|
||||
{{/if}}
|
||||
<p>Pour finaliser l'activation de votre compte, veuillez <strong>créer votre mot de passe</strong> en cliquant sur le bouton ci-dessous.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
@ -0,0 +1,14 @@
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||
<p>Le dossier d'inscription sur <strong>{{appName}}</strong> auquel vous êtes rattaché·e en tant que <strong>co-parent</strong> a été <strong>validé</strong>.</p>
|
||||
{{#if numeroDossier}}
|
||||
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
|
||||
{{/if}}
|
||||
<p>Vous devez maintenant <strong>définir votre mot de passe</strong> pour accéder à l'application avec votre propre compte.</p>
|
||||
<div style="text-align: center; margin: 30px 0;">
|
||||
<a href="{{{createPasswordUrl}}}" style="background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; font-weight: bold;">Créer mon mot de passe</a>
|
||||
</div>
|
||||
<p style="color: #666; font-size: 13px;">Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :<br /><span style="word-break: break-all;">{{{createPasswordUrl}}}</span></p>
|
||||
<hr style="border: 1px solid #eee; margin: 20px 0;" />
|
||||
<p style="color: #666; font-size: 12px;">Cet email a été envoyé automatiquement par {{appName}}. Merci de ne pas y répondre.</p>
|
||||
</div>
|
||||
@ -0,0 +1,14 @@
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #4CAF50;">Bonjour {{prenom}} {{nom}},</h2>
|
||||
<p>Votre demande d'inscription sur <strong>{{appName}}</strong> a été <strong>validée</strong>.</p>
|
||||
{{#if numeroDossier}}
|
||||
<p><strong>Numéro de dossier :</strong> {{numeroDossier}}</p>
|
||||
{{/if}}
|
||||
<p>En tant que <strong>demandeur principal</strong>, vous devez maintenant <strong>définir votre mot de passe</strong> pour accéder à l'application.</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>
|
||||
@ -11,6 +11,7 @@ import { AssistantesMaternellesModule } from '../assistantes_maternelles/assista
|
||||
import { Parents } from 'src/entities/parents.entity';
|
||||
import { GestionnairesModule } from './gestionnaires/gestionnaires.module';
|
||||
import { MailModule } from 'src/modules/mail/mail.module';
|
||||
import { AppConfigModule } from 'src/modules/config/config.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature(
|
||||
@ -24,6 +25,7 @@ import { MailModule } from 'src/modules/mail/mail.module';
|
||||
AssistantesMaternellesModule,
|
||||
GestionnairesModule,
|
||||
MailModule,
|
||||
AppConfigModule,
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
|
||||
@ -9,7 +9,8 @@ import * as bcrypt from 'bcrypt';
|
||||
import { StatutValidationType, Validation } from "src/entities/validations.entity";
|
||||
import { Parents } from "src/entities/parents.entity";
|
||||
import { AssistanteMaternelle } from "src/entities/assistantes_maternelles.entity";
|
||||
import { MailService } from "src/modules/mail/mail.service";
|
||||
import { MailService, ValidationAccountEmailKind } from "src/modules/mail/mail.service";
|
||||
import { AppConfigService } from "src/modules/config/config.service";
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
@Injectable()
|
||||
@ -30,6 +31,8 @@ export class UserService {
|
||||
private readonly assistantesRepository: Repository<AssistanteMaternelle>,
|
||||
|
||||
private readonly mailService: MailService,
|
||||
|
||||
private readonly appConfigService: AppConfigService,
|
||||
) { }
|
||||
|
||||
async createUser(dto: CreateUserDto, currentUser?: Users): Promise<Users> {
|
||||
@ -265,6 +268,57 @@ export class UserService {
|
||||
comment,
|
||||
});
|
||||
await this.validationRepository.save(validation);
|
||||
|
||||
// Ticket #28 — email création MDP (parents / AM), Handlebars + lien app_url
|
||||
const roleCibleMdp =
|
||||
savedUser.role === RoleType.PARENT || savedUser.role === RoleType.ASSISTANTE_MATERNELLE;
|
||||
if (roleCibleMdp && !savedUser.password) {
|
||||
try {
|
||||
const joursExpiration = await this.appConfigService.get<number>(
|
||||
'password_reset_token_expiry_days',
|
||||
7,
|
||||
);
|
||||
if (!savedUser.token_creation_mdp) {
|
||||
savedUser.token_creation_mdp = crypto.randomUUID();
|
||||
}
|
||||
const exp = new Date();
|
||||
exp.setDate(exp.getDate() + joursExpiration);
|
||||
savedUser.token_creation_mdp_expire_le = exp;
|
||||
await this.usersRepository.save(savedUser);
|
||||
|
||||
let kind: ValidationAccountEmailKind = 'am';
|
||||
if (savedUser.role === RoleType.ASSISTANTE_MATERNELLE) {
|
||||
kind = 'am';
|
||||
} else if (savedUser.numero_dossier) {
|
||||
const parentsDossier = await this.usersRepository.find({
|
||||
where: { numero_dossier: savedUser.numero_dossier, role: RoleType.PARENT },
|
||||
order: { cree_le: 'ASC' },
|
||||
});
|
||||
const premier = parentsDossier[0];
|
||||
kind =
|
||||
premier && premier.id !== savedUser.id ? 'parent_coparent' : 'parent_primary';
|
||||
} else {
|
||||
kind = 'parent_primary';
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -591,22 +591,24 @@ Installer et configurer Nodemailer pour l'envoi d'emails.
|
||||
|
||||
---
|
||||
|
||||
### Ticket #28 : [Backend] Templates Email - Validation
|
||||
### Ticket #28 : [Backend] Templates Email - Validation ✅
|
||||
**Estimation** : 3h
|
||||
**Labels** : `backend`, `p2`, `email`
|
||||
**Statut** : ✅ TERMINÉ
|
||||
|
||||
**Description** :
|
||||
Créer les templates d'emails pour la validation des comptes (avec lien création MDP).
|
||||
|
||||
**Tâches** :
|
||||
- [ ] Template Parent 1 (création MDP)
|
||||
- [ ] URL app : Lire depuis `ConfigService.get('app_url')`
|
||||
- [ ] Nom app : Lire depuis `ConfigService.get('app_name')`
|
||||
- [ ] Expéditeur : Lire depuis ConfigService
|
||||
- [ ] Template Parent 2 (co-parent création MDP)
|
||||
- [ ] Template AM (création MDP)
|
||||
- [ ] Intégration Handlebars
|
||||
- [ ] Tests d'envoi
|
||||
- [x] Template Parent 1 (création MDP) — `account-validated-parent-primary.hbs`
|
||||
- [x] URL app : Lire depuis `ConfigService.get('app_url')` (lien `/create-password?token=…`)
|
||||
- [x] Nom app : Lire depuis `ConfigService.get('app_name')`
|
||||
- [x] Expéditeur : via `sendEmail` (email_from_name / email_from_address)
|
||||
- [x] Template Parent 2 (co-parent création MDP) — `account-validated-parent-coparent.hbs`
|
||||
- [x] Template AM (création MDP) — `account-validated-am.hbs`
|
||||
- [x] Intégration Handlebars + copie `.hbs` dans `dist` (`nest-cli.json` assets)
|
||||
- [x] Envoi déclenché dans `UserService.validateUser` (parent / AM sans mot de passe) + renouvellement expiration token
|
||||
- [x] Tests unitaires (`mail.service.spec.ts`, mock `sendEmail`)
|
||||
|
||||
**Référence** : [20_WORKFLOW-CREATION-COMPTE.md](./20_WORKFLOW-CREATION-COMPTE.md#étape-6--réception-de-la-notification)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user