diff --git a/apps/backend/assets/ejs-templates/mail/account-created.ejs b/apps/backend/assets/ejs-templates/mail/account-created.ejs new file mode 100644 index 0000000..86f3550 --- /dev/null +++ b/apps/backend/assets/ejs-templates/mail/account-created.ejs @@ -0,0 +1,121 @@ + + + + + + <%= title %> + + + + + + + + + + + + + + + + diff --git a/apps/backend/assets/i18n/en.json b/apps/backend/assets/i18n/en.json index 09b7159..5fa674d 100644 --- a/apps/backend/assets/i18n/en.json +++ b/apps/backend/assets/i18n/en.json @@ -1,4 +1,4 @@ { - "mail.create-user.title": "Welcome to Cowsi", - "mail.create-user.body-html": "

Welcome to Cowsi

" + "mail.create-user.title": "Welcome to Cowsi!", + "mail.create-user.body-title": "Welcome to Cowsi {{name}}!" } \ No newline at end of file diff --git a/apps/backend/assets/i18n/fr.json b/apps/backend/assets/i18n/fr.json index 48cf347..706fb7d 100644 --- a/apps/backend/assets/i18n/fr.json +++ b/apps/backend/assets/i18n/fr.json @@ -1,4 +1,4 @@ { - "mail.create-user.title": "Bienvenue sur Cowsi", - "mail.create-user.body-html": "

Bienvenue sur Cowsi

" + "mail.create-user.title": "Bienvenue sur Cowsi!", + "mail.create-user.body-title": "Bienvenue sur Cowsi {{name}}!" } \ No newline at end of file diff --git a/apps/backend/package.json b/apps/backend/package.json index d3fa778..9831a77 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -26,7 +26,9 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "dayjs": "^1.11.13", "dotenv": "^17.2.1", + "ejs": "^3.1.10", "i18next": "^25.3.2", "i18next-fs-backend": "^2.6.0", "kysely": "^0.28.3", @@ -44,6 +46,7 @@ "@nestjs/testing": "^11.0.1", "@swc/cli": "^0.6.0", "@swc/core": "^1.10.7", + "@types/ejs": "^3.1.5", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 223f156..263465c 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -7,7 +7,8 @@ import { EnvService } from './services/env/env.service'; import { UsersRegistry } from './app/users/users-registry'; import { UsersController } from './app/users/users.controller'; import { MailerService } from './services/mailer/mailer.service'; -import { localizationServiceProvider } from './services/localization/localization.service'; +import { LOCALIZATION_SERVICE_PROVIDER } from './services/localization/localization.service'; +import { TemplateRendererService } from './services/template-renderer/template-renderer.service'; @Module({ imports: [], @@ -19,7 +20,8 @@ import { localizationServiceProvider } from './services/localization/localizatio EnvService, UsersRegistry, MailerService, - localizationServiceProvider, + LOCALIZATION_SERVICE_PROVIDER, + TemplateRendererService, ], }) export class AppModule {} diff --git a/apps/backend/src/app/users/users.service.ts b/apps/backend/src/app/users/users.service.ts index 87595e5..9e75fa5 100644 --- a/apps/backend/src/app/users/users.service.ts +++ b/apps/backend/src/app/users/users.service.ts @@ -6,6 +6,8 @@ import { LOCALIZATION_SERVICE, LocalizationService, } from '../../services/localization/localization.service'; +import { TemplateRendererService } from '../../services/template-renderer/template-renderer.service'; +import { MailTemplateEnum } from '../../services/template-renderer/mail-template-data'; @Injectable() export class UsersService { @@ -14,18 +16,31 @@ export class UsersService { private mailerService: MailerService, @Inject(LOCALIZATION_SERVICE) private localizationService: LocalizationService, + private templateRendererService: TemplateRendererService, ) {} async createUser(newUser: CreateUser): Promise { // FIXME : return result once auth is done await this.userRegistry.addUser(newUser); - this.localizationService.translate('mail.create-user.title'); + + const mailTitle = this.localizationService.translate( + 'mail.create-user.title', + ); + const mailBodyTitle = this.localizationService.translate( + 'mail.create-user.body-title', + { name: newUser.firstName }, + ); + const mailHtml = await this.templateRendererService.renderMailTemplate( + MailTemplateEnum.CreateAccount, + { + title: mailTitle, + bodyTitle: mailBodyTitle, + }, + ); void this.mailerService.sendEmail({ toAdresses: [newUser.email], - subject: 'Your account has been created!', - html: `

Welcome to COWSI

-

Your account has been successfully created

- `, + subject: mailTitle, + html: mailHtml, }); } } diff --git a/apps/backend/src/services/localization/localization.service.ts b/apps/backend/src/services/localization/localization.service.ts index 05b8940..06195f8 100644 --- a/apps/backend/src/services/localization/localization.service.ts +++ b/apps/backend/src/services/localization/localization.service.ts @@ -6,32 +6,33 @@ import * as i18nextBackend from 'i18next-fs-backend'; export const LOCALIZATION_SERVICE = 'LOCALIZATION_SERVICE'; -export const localizationServiceProvider: FactoryProvider = - { - provide: LOCALIZATION_SERVICE, - scope: Scope.REQUEST, - useFactory: async (req: Request) => { - const instance = i18next.createInstance().use(i18nextBackend as any); - const supportedLang = req - .acceptsLanguages() - .find((l) => isSupportedLang(l)); - await instance.init({ - lng: supportedLang, - fallbackLng: 'en', - debug: false, - backend: { - loadPath: `assets/i18n/{{lng}}.json`, - }, - }); - return new LocalizationService(instance); - }, - inject: ['REQUEST'], - }; +export const LOCALIZATION_SERVICE_PROVIDER: Readonly< + FactoryProvider +> = { + provide: LOCALIZATION_SERVICE, + scope: Scope.REQUEST, + useFactory: async (req: Request) => { + const instance = i18next.createInstance().use(i18nextBackend as any); + const supportedLang = req + .acceptsLanguages() + .find((l) => isSupportedLang(l)); + await instance.init({ + lng: supportedLang, + fallbackLng: 'en', + debug: false, + backend: { + loadPath: `assets/i18n/{{lng}}.json`, + }, + }); + return new LocalizationService(instance); + }, + inject: ['REQUEST'], +}; export class LocalizationService { constructor(private readonly i18n: i18next.i18n) {} - translate(key: string) { - this.i18n.t(key); + translate(key: string, args?: Record): string { + return this.i18n.t(key, args); } } diff --git a/apps/backend/src/services/template-renderer/mail-template-data.ts b/apps/backend/src/services/template-renderer/mail-template-data.ts new file mode 100644 index 0000000..c946b20 --- /dev/null +++ b/apps/backend/src/services/template-renderer/mail-template-data.ts @@ -0,0 +1,27 @@ +const MAIL_BASE = 'assets/ejs-templates/mail'; + +export interface BaseMailTempalteData { + currentYear: number | string; +} + +export enum MailTemplateEnum { + CreateAccount = 'create-account', +} + +export interface MailTemplateTypeMap extends Record { + [MailTemplateEnum.CreateAccount]: CreateAccountMailTemplateData; +} + +const MAIL_TEMPLATE_PATH_MAP = { + [MailTemplateEnum.CreateAccount]: 'account-created.ejs', +} as const satisfies Record; + +export interface CreateAccountMailTemplateData { + title: string; + bodyTitle: string; +} + +export function getMailTemplatePath(template: MailTemplateEnum): string { + const suffix = MAIL_TEMPLATE_PATH_MAP[template]; + return `${MAIL_BASE}/${suffix}`; +} diff --git a/apps/backend/src/services/template-renderer/template-renderer.service.spec.ts b/apps/backend/src/services/template-renderer/template-renderer.service.spec.ts new file mode 100644 index 0000000..a96c4ea --- /dev/null +++ b/apps/backend/src/services/template-renderer/template-renderer.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { TemplateRendererService } from './template-renderer.service'; + +describe('TemplateRendererService', () => { + let service: TemplateRendererService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [TemplateRendererService], + }).compile(); + + service = module.get(TemplateRendererService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/services/template-renderer/template-renderer.service.ts b/apps/backend/src/services/template-renderer/template-renderer.service.ts new file mode 100644 index 0000000..47da8a8 --- /dev/null +++ b/apps/backend/src/services/template-renderer/template-renderer.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import * as ejs from 'ejs'; +import { + BaseMailTempalteData, + getMailTemplatePath, + MailTemplateEnum, + MailTemplateTypeMap, +} from './mail-template-data'; +import * as dayjs from 'dayjs'; + +@Injectable() +export class TemplateRendererService { + renderMailTemplate( + template: T, + data: MailTemplateTypeMap[T], + ): Promise { + return this.render(getMailTemplatePath(template), data); + } + + private render(file: string, data: ejs.Data): Promise { + const base: BaseMailTempalteData = { + currentYear: dayjs().year(), + }; + return ejs.renderFile(file, { + ...data, + ...base, + }); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 094e84c..bb96df2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,9 +22,15 @@ importers: '@nestjs/platform-express': specifier: ^11.0.1 version: 11.1.3(@nestjs/common@11.1.3(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.3) + dayjs: + specifier: ^1.11.13 + version: 1.11.13 dotenv: specifier: ^17.2.1 version: 17.2.1 + ejs: + specifier: ^3.1.10 + version: 3.1.10 i18next: specifier: ^25.3.2 version: 25.3.2(typescript@5.7.3) @@ -71,6 +77,9 @@ importers: '@swc/core': specifier: ^1.10.7 version: 1.12.11 + '@types/ejs': + specifier: ^3.1.5 + version: 3.1.5 '@types/express': specifier: ^5.0.0 version: 5.0.3 @@ -1002,6 +1011,9 @@ packages: '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -1646,6 +1658,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + dayjs@1.11.13: + resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -4721,6 +4736,8 @@ snapshots: '@types/cookiejar@2.1.5': {} + '@types/ejs@3.1.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -5530,6 +5547,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + dayjs@1.11.13: {} + debug@4.4.1: dependencies: ms: 2.1.3