diff --git a/apps/backend/package.json b/apps/backend/package.json index b31c411..d3fa778 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -27,7 +27,10 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "dotenv": "^17.2.1", + "i18next": "^25.3.2", + "i18next-fs-backend": "^2.6.0", "kysely": "^0.28.3", + "nodemailer": "^7.0.5", "pg": "^8.16.3", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", @@ -44,6 +47,7 @@ "@types/express": "^5.0.0", "@types/jest": "^29.5.14", "@types/node": "^22.10.7", + "@types/nodemailer": "^6.4.17", "@types/pg": "^8.15.4", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", diff --git a/apps/backend/src/app-error.filter.spec.ts b/apps/backend/src/app-error.filter.spec.ts new file mode 100644 index 0000000..5c3b9a1 --- /dev/null +++ b/apps/backend/src/app-error.filter.spec.ts @@ -0,0 +1,7 @@ +import { AppErrorFilter } from './app-error.filter'; + +describe('AppErrorFilter', () => { + it('should be defined', () => { + expect(new AppErrorFilter()).toBeDefined(); + }); +}); diff --git a/apps/backend/src/app-error.filter.ts b/apps/backend/src/app-error.filter.ts new file mode 100644 index 0000000..500f4ef --- /dev/null +++ b/apps/backend/src/app-error.filter.ts @@ -0,0 +1,13 @@ +import { Catch, ExceptionFilter, HttpException } from '@nestjs/common'; +import { AppError } from './lib/app-error'; + +@Catch(AppError) +export class AppErrorFilter implements ExceptionFilter { + catch(exception: T) { + if (exception.status) { + throw new HttpException(exception.message, exception.status, { + cause: exception, + }); + } + } +} diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 5aa3c94..223f156 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -1,13 +1,25 @@ import { Module } from '@nestjs/common'; import { AuthController } from './app/auth/auth.controller'; -import { DatabaseService } from './database/database.service'; +import { DatabaseService } from './services/database/database.service'; import { AuthService } from './app/auth/auth.service'; -import { UserRegistryService } from './app/users/user-registry.service'; -import { EnvService } from './env/env.service'; +import { UsersService } from './app/users/users.service'; +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'; @Module({ imports: [], - controllers: [AuthController], - providers: [DatabaseService, AuthService, UserRegistryService, EnvService], + controllers: [AuthController, UsersController], + providers: [ + DatabaseService, + AuthService, + UsersService, + EnvService, + UsersRegistry, + MailerService, + localizationServiceProvider, + ], }) export class AppModule {} diff --git a/apps/backend/src/app/auth/auth.controller.ts b/apps/backend/src/app/auth/auth.controller.ts index 6cae994..8db7df3 100644 --- a/apps/backend/src/app/auth/auth.controller.ts +++ b/apps/backend/src/app/auth/auth.controller.ts @@ -5,9 +5,7 @@ import { AuthService } from './auth.service'; export class AuthController { constructor(private authService: AuthService) {} - // eslint-disable-next-line @typescript-eslint/no-misused-promises @Post('credentials') - // eslint-disable-next-line @typescript-eslint/require-await async postCredentials() { return this.authService.verifyCredentials(); } diff --git a/apps/backend/src/app/auth/auth.service.ts b/apps/backend/src/app/auth/auth.service.ts index 21e5365..0702160 100644 --- a/apps/backend/src/app/auth/auth.service.ts +++ b/apps/backend/src/app/auth/auth.service.ts @@ -1,9 +1,9 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { UserRegistryService } from '../users/user-registry.service'; +import { UsersRegistry } from '../users/users-registry'; @Injectable() export class AuthService { - constructor(private userRegistry: UserRegistryService) {} + constructor(private userRegistry: UsersRegistry) {} async verifyCredentials() { const isValidCredential = await this.userRegistry.existsEmailPassword( diff --git a/apps/backend/src/app/users/user-registry.service.ts b/apps/backend/src/app/users/user-registry.service.ts deleted file mode 100644 index 88aa160..0000000 --- a/apps/backend/src/app/users/user-registry.service.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DatabaseService } from '../../database/database.service'; -import { isDefined } from '@my-monorepo/common'; - -@Injectable() -export class UserRegistryService { - constructor(private databaseService: DatabaseService) {} - - async existsEmailPassword(email: string, hashedPassword: string) { - const id = await this.databaseService.database - .selectFrom('users') - .select('id') - .where((eb) => eb('email', '=', email).and('hash', '=', hashedPassword)) - .executeTakeFirst(); - - return isDefined(id); - } -} diff --git a/apps/backend/src/app/users/users-error.ts b/apps/backend/src/app/users/users-error.ts new file mode 100644 index 0000000..b670d77 --- /dev/null +++ b/apps/backend/src/app/users/users-error.ts @@ -0,0 +1,11 @@ +import { AppError } from '../../lib/app-error'; +import { HttpStatus } from '@nestjs/common'; + +export class UserEmailAlreadyExistError extends AppError { + constructor() { + super({ + message: 'User email already exists', + status: HttpStatus.BAD_REQUEST, + }); + } +} diff --git a/apps/backend/src/app/users/users-registry.spec.ts b/apps/backend/src/app/users/users-registry.spec.ts new file mode 100644 index 0000000..5b29aa5 --- /dev/null +++ b/apps/backend/src/app/users/users-registry.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersRegistry } from './users-registry'; + +describe('UsersRegistry', () => { + let provider: UsersRegistry; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersRegistry], + }).compile(); + + provider = module.get(UsersRegistry); + }); + + it('should be defined', () => { + expect(provider).toBeDefined(); + }); +}); diff --git a/apps/backend/src/app/users/users-registry.ts b/apps/backend/src/app/users/users-registry.ts new file mode 100644 index 0000000..6d4dd12 --- /dev/null +++ b/apps/backend/src/app/users/users-registry.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; +import { DatabaseService } from '../../services/database/database.service'; +import { CreateUser, isDefined, Result, User } from '@my-monorepo/common'; +import { UserEmailAlreadyExistError } from './users-error'; + +@Injectable() +export class UsersRegistry { + get database() { + return this.databaseService.database; + } + + constructor(private databaseService: DatabaseService) {} + + async existsEmailPassword(email: string, hashedPassword: string) { + const id = await this.database + .selectFrom('users') + .select('id') + .where((eb) => eb('email', '=', email).and('hash', '=', hashedPassword)) + .executeTakeFirst(); + + return isDefined(id); + } + + async addUser( + newUser: CreateUser, + ): Promise> { + const rowWithEmail = await this.database + .selectFrom('users') + .select('id') + .where('email', '=', newUser.email) + .executeTakeFirst(); + + if (isDefined(rowWithEmail)) { + return Result.error(new UserEmailAlreadyExistError()); + } + + const userQuery = this.database + .insertInto('users') + .values({ + email: newUser.email, + first_name: newUser.firstName, + last_name: newUser.lastName, + }) + .returning([ + 'id', + 'email', + 'created_at', + 'updated_at', + 'deleted_at', + 'first_name', + 'last_name', + ]); + const user = await userQuery.executeTakeFirst(); + if (!isDefined(user)) { + return Result.error(new UserEmailAlreadyExistError()); + } + return Result.success({ + id: user.id, + firstName: user.first_name, + lastName: user.last_name, + email: user.email, + createdAt: user.created_at, + updatedAt: user.updated_at, + deletedAt: user.deleted_at ?? undefined, + }); + } +} diff --git a/apps/backend/src/app/users/users.controller.spec.ts b/apps/backend/src/app/users/users.controller.spec.ts new file mode 100644 index 0000000..3e27c39 --- /dev/null +++ b/apps/backend/src/app/users/users.controller.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersController } from './users.controller'; + +describe('UsersController', () => { + let controller: UsersController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + }).compile(); + + controller = module.get(UsersController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/apps/backend/src/app/users/users.controller.ts b/apps/backend/src/app/users/users.controller.ts new file mode 100644 index 0000000..6035100 --- /dev/null +++ b/apps/backend/src/app/users/users.controller.ts @@ -0,0 +1,15 @@ +import { CreateUser, createUserParser } from '@my-monorepo/common'; +import { Body, Controller, Post, UsePipes } from '@nestjs/common'; +import { ZodValidationPipe } from '../../lib/nestjs/zod-validation/zod-validation.pipe'; +import { UsersService } from './users.service'; + +@Controller('users') +export class UsersController { + constructor(private userService: UsersService) {} + + @Post() + @UsePipes(new ZodValidationPipe(createUserParser)) + async createUser(@Body() newUser: CreateUser) { + return this.userService.createUser(newUser); + } +} diff --git a/apps/backend/src/app/users/users.service.spec.ts b/apps/backend/src/app/users/users.service.spec.ts new file mode 100644 index 0000000..62815ba --- /dev/null +++ b/apps/backend/src/app/users/users.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UsersService } from './users.service'; + +describe('UsersService', () => { + let service: UsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UsersService], + }).compile(); + + service = module.get(UsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/app/users/users.service.ts b/apps/backend/src/app/users/users.service.ts new file mode 100644 index 0000000..87595e5 --- /dev/null +++ b/apps/backend/src/app/users/users.service.ts @@ -0,0 +1,31 @@ +import { CreateUser } from '@my-monorepo/common'; +import { Inject, Injectable } from '@nestjs/common'; +import { MailerService } from '../../services/mailer/mailer.service'; +import { UsersRegistry } from './users-registry'; +import { + LOCALIZATION_SERVICE, + LocalizationService, +} from '../../services/localization/localization.service'; + +@Injectable() +export class UsersService { + constructor( + private userRegistry: UsersRegistry, + private mailerService: MailerService, + @Inject(LOCALIZATION_SERVICE) + private localizationService: LocalizationService, + ) {} + + async createUser(newUser: CreateUser): Promise { + // FIXME : return result once auth is done + await this.userRegistry.addUser(newUser); + this.localizationService.translate('mail.create-user.title'); + void this.mailerService.sendEmail({ + toAdresses: [newUser.email], + subject: 'Your account has been created!', + html: `

Welcome to COWSI

+

Your account has been successfully created

+ `, + }); + } +} diff --git a/apps/backend/src/assets/i18n/en.json b/apps/backend/src/assets/i18n/en.json new file mode 100644 index 0000000..09b7159 --- /dev/null +++ b/apps/backend/src/assets/i18n/en.json @@ -0,0 +1,4 @@ +{ + "mail.create-user.title": "Welcome to Cowsi", + "mail.create-user.body-html": "

Welcome to Cowsi

" +} \ No newline at end of file diff --git a/apps/backend/src/assets/i18n/fr.json b/apps/backend/src/assets/i18n/fr.json new file mode 100644 index 0000000..48cf347 --- /dev/null +++ b/apps/backend/src/assets/i18n/fr.json @@ -0,0 +1,4 @@ +{ + "mail.create-user.title": "Bienvenue sur Cowsi", + "mail.create-user.body-html": "

Bienvenue sur Cowsi

" +} \ No newline at end of file diff --git a/apps/backend/src/env/env.service.ts b/apps/backend/src/env/env.service.ts deleted file mode 100644 index 4b09fd0..0000000 --- a/apps/backend/src/env/env.service.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import z from 'zod'; -import * as dotenv from 'dotenv'; - -const envParser = z.object({ - DATABASE: z.string(), - DATABASE_USER: z.string(), - DATABASE_PASSWORD: z.string(), - DATABASE_HOST: z.string(), - DATABASE_PORT: z.coerce.number(), -}); - -export type EnvConfig = Readonly>; - -@Injectable() -export class EnvService { - readonly config: EnvConfig; - - constructor() { - console.log('paul-debug', dotenv); - const res = dotenv.config(); - if (res.error) { - throw res.error; - } - this.config = envParser.parse(res.parsed); - } -} diff --git a/apps/backend/src/lib/app-error.ts b/apps/backend/src/lib/app-error.ts new file mode 100644 index 0000000..6676bb7 --- /dev/null +++ b/apps/backend/src/lib/app-error.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; + +export class AppError extends Error { + public readonly status?: HttpStatus = 500; + + constructor(opts: { + message: string; + status?: HttpStatus; + options?: ErrorOptions; + }) { + super(opts.message, opts.options); + this.status = opts.status; + } +} diff --git a/apps/backend/src/lib/nestjs/zod-validation/zod-validation.pipe.spec.ts b/apps/backend/src/lib/nestjs/zod-validation/zod-validation.pipe.spec.ts new file mode 100644 index 0000000..1b0803e --- /dev/null +++ b/apps/backend/src/lib/nestjs/zod-validation/zod-validation.pipe.spec.ts @@ -0,0 +1,7 @@ +import { ZodValidationPipe } from './zod-validation.pipe'; + +describe('ZodValidationPipe', () => { + it('should be defined', () => { + expect(new ZodValidationPipe()).toBeDefined(); + }); +}); diff --git a/apps/backend/src/lib/nestjs/zod-validation/zod-validation.pipe.ts b/apps/backend/src/lib/nestjs/zod-validation/zod-validation.pipe.ts new file mode 100644 index 0000000..aebd3d9 --- /dev/null +++ b/apps/backend/src/lib/nestjs/zod-validation/zod-validation.pipe.ts @@ -0,0 +1,20 @@ +import { PipeTransform, BadRequestException } from '@nestjs/common'; +import { ZodSchema } from 'zod'; + +export class ZodValidationPipe implements PipeTransform { + constructor(private schema: Z) {} + + transform(value: unknown) { + const parsedValue = this.schema.safeParse(value); + if (!parsedValue.success) { + const invalidFields = parsedValue.error.errors + .map((v) => v.path.join('.')) + .join(', '); + throw new BadRequestException( + `Validation failed for fields : ${invalidFields}`, + ); + } + // FIXME improve this typing if possible + return parsedValue.data as unknown; + } +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index f76bc8d..d5c787b 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,8 +1,10 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { AppErrorFilter } from './app-error.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalFilters(new AppErrorFilter()); await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/apps/backend/src/database/database.service.spec.ts b/apps/backend/src/services/database/database.service.spec.ts similarity index 100% rename from apps/backend/src/database/database.service.spec.ts rename to apps/backend/src/services/database/database.service.spec.ts diff --git a/apps/backend/src/database/database.service.ts b/apps/backend/src/services/database/database.service.ts similarity index 73% rename from apps/backend/src/database/database.service.ts rename to apps/backend/src/services/database/database.service.ts index ec6b38c..67dbb8f 100644 --- a/apps/backend/src/database/database.service.ts +++ b/apps/backend/src/services/database/database.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { Pool } from 'pg'; import { Kysely, PostgresDialect } from 'kysely'; -import { DB } from './db'; +import { DB } from '../../database/db'; import { EnvService } from '../env/env.service'; @Injectable() @@ -10,14 +10,14 @@ export class DatabaseService { database: Kysely; constructor(envService: EnvService) { - const config = envService.config; + const config = envService.config.database; const dialect = new PostgresDialect({ pool: new Pool({ - database: config.DATABASE, - host: config.DATABASE_HOST, - user: config.DATABASE_USER, - password: config.DATABASE_PASSWORD, - port: config.DATABASE_PORT, + database: config.name, + host: config.host, + user: config.user, + password: config.password, + port: config.port, max: 10, }), }); diff --git a/apps/backend/src/env/env.service.spec.ts b/apps/backend/src/services/env/env.service.spec.ts similarity index 100% rename from apps/backend/src/env/env.service.spec.ts rename to apps/backend/src/services/env/env.service.spec.ts diff --git a/apps/backend/src/services/env/env.service.ts b/apps/backend/src/services/env/env.service.ts new file mode 100644 index 0000000..71c51f7 --- /dev/null +++ b/apps/backend/src/services/env/env.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import z from 'zod'; +import * as dotenv from 'dotenv'; + +const envParser = z + .object({ + DATABASE: z.string(), + DATABASE_USER: z.string(), + DATABASE_PASSWORD: z.string(), + DATABASE_HOST: z.string(), + DATABASE_PORT: z.coerce.number(), + MAIL_HOST: z.string(), + MAIL_PORT: z.coerce.number(), + MAIL_USER: z.string().email(), + MAIL_PASSWORD: z.string(), + }) + .transform((parsed) => ({ + database: { + name: parsed.DATABASE, + user: parsed.DATABASE_USER, + password: parsed.DATABASE_PASSWORD, + host: parsed.DATABASE_HOST, + port: parsed.DATABASE_PORT, + }, + mail: { + host: parsed.MAIL_HOST, + port: parsed.MAIL_PORT, + user: parsed.MAIL_USER, + password: parsed.MAIL_PASSWORD, + }, + })); + +export type EnvConfig = Readonly>; + +@Injectable() +export class EnvService { + readonly config: EnvConfig; + + constructor() { + const res = dotenv.config(); + if (res.error) { + throw res.error; + } + this.config = envParser.parse(res.parsed); + } +} diff --git a/apps/backend/src/app/users/user-registry.service.spec.ts b/apps/backend/src/services/localization/localization.service.spec.ts similarity index 51% rename from apps/backend/src/app/users/user-registry.service.spec.ts rename to apps/backend/src/services/localization/localization.service.spec.ts index d18db6e..840a350 100644 --- a/apps/backend/src/app/users/user-registry.service.spec.ts +++ b/apps/backend/src/services/localization/localization.service.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { UserRegistryService } from './user-registry.service'; +import { LocalizationService } from './localization.service'; -describe('UserRegistryService', () => { - let service: UserRegistryService; +describe('LocalizationService', () => { + let service: LocalizationService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [UserRegistryService], + providers: [LocalizationService], }).compile(); - service = module.get(UserRegistryService); + service = module.get(LocalizationService); }); it('should be defined', () => { diff --git a/apps/backend/src/services/localization/localization.service.ts b/apps/backend/src/services/localization/localization.service.ts new file mode 100644 index 0000000..bcd6115 --- /dev/null +++ b/apps/backend/src/services/localization/localization.service.ts @@ -0,0 +1,34 @@ +import { isSupportedLang } from '@my-monorepo/common'; +import { FactoryProvider, Scope } from '@nestjs/common'; +import { Request } from 'express'; +import * as i18next from 'i18next'; +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)) ?? 'fr'; + await instance.init({ + lng: supportedLang, + debug: true, + backend: { + loadPath: `${__dirname}/src/assets/i18n/{{lng}}.json`, + }, + }); + new LocalizationService(instance); + }, + inject: ['REQUEST'], +}; + +export class LocalizationService { + constructor(private readonly i18n: i18next.i18n) {} + + translate(key: string) { + this.i18n.t(key); + } +} diff --git a/apps/backend/src/services/logger/logger.service.spec.ts b/apps/backend/src/services/logger/logger.service.spec.ts new file mode 100644 index 0000000..03ac924 --- /dev/null +++ b/apps/backend/src/services/logger/logger.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LoggerService } from './logger.service'; + +describe('LoggerService', () => { + let service: LoggerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [LoggerService], + }).compile(); + + service = module.get(LoggerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/services/logger/logger.service.ts b/apps/backend/src/services/logger/logger.service.ts new file mode 100644 index 0000000..5c31077 --- /dev/null +++ b/apps/backend/src/services/logger/logger.service.ts @@ -0,0 +1,4 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class LoggerService {} diff --git a/apps/backend/src/services/mailer/mailer.service.spec.ts b/apps/backend/src/services/mailer/mailer.service.spec.ts new file mode 100644 index 0000000..1ab5e82 --- /dev/null +++ b/apps/backend/src/services/mailer/mailer.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { MailerService } from './mailer.service'; + +describe('MailerService', () => { + let service: MailerService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [MailerService], + }).compile(); + + service = module.get(MailerService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/apps/backend/src/services/mailer/mailer.service.ts b/apps/backend/src/services/mailer/mailer.service.ts new file mode 100644 index 0000000..3ad6d83 --- /dev/null +++ b/apps/backend/src/services/mailer/mailer.service.ts @@ -0,0 +1,43 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { SentMessageInfo, Options } from 'nodemailer/lib/smtp-transport'; +import { EnvService } from '../env/env.service'; + +@Injectable() +export class MailerService { + private readonly logger = new Logger('LoggerService'); + + private readonly transport: nodemailer.Transporter; + constructor(envService: EnvService) { + const config = envService.config.mail; + this.transport = nodemailer.createTransport({ + host: config.host, + port: config.port, + secure: true, + auth: { + user: config.user, + pass: config.password, + }, + }); + } + + async sendEmail(opts: { + toAdresses: string[]; + subject: string; + html: string; + text?: string; + }) { + const res = await this.transport.sendMail({ + from: '"Cowsi" ', + to: opts.toAdresses.join(','), + subject: opts.subject, + text: opts.text, + html: opts.html, + }); + if (res.rejected.length > 0) { + this.logger.error('An issue occurred while sending email', res.rejected); + } else { + this.logger.log('Email sent successfuly'); + } + } +} diff --git a/documentation/bruno-rest-api/LocalAPI/Create user.bru b/documentation/bruno-rest-api/LocalAPI/Create user.bru new file mode 100644 index 0000000..2af33f3 --- /dev/null +++ b/documentation/bruno-rest-api/LocalAPI/Create user.bru @@ -0,0 +1,23 @@ +meta { + name: Create user + type: http + seq: 2 +} + +post { + url: {{BASE_URL}}/users + body: json + auth: inherit +} + +body:json { + { + "firstName": "Paul", + "lastName": "Test", + "email": "paul@coral.ch" + } +} + +settings { + encodeUrl: true +} diff --git a/documentation/bruno-rest-api/LocalAPI/asdf.bru b/documentation/bruno-rest-api/LocalAPI/asdf.bru deleted file mode 100644 index 24d76b6..0000000 --- a/documentation/bruno-rest-api/LocalAPI/asdf.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: asdf - type: http - seq: 3 -} - -get { - url: localhost:5047 - body: none - auth: inherit -} - -settings { - encodeUrl: true -} diff --git a/documentation/bruno-rest-api/LocalAPI/hello.bru b/documentation/bruno-rest-api/LocalAPI/hello.bru deleted file mode 100644 index a2c6941..0000000 --- a/documentation/bruno-rest-api/LocalAPI/hello.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: hello - type: http - seq: 2 -} - -get { - url: {{BASE_URL}}/hello - body: none - auth: inherit -} - -settings { - encodeUrl: true -} diff --git a/documentation/bruno-rest-api/LocalAPI/users/folder.bru b/documentation/bruno-rest-api/LocalAPI/users/folder.bru new file mode 100644 index 0000000..83a5cc9 --- /dev/null +++ b/documentation/bruno-rest-api/LocalAPI/users/folder.bru @@ -0,0 +1,8 @@ +meta { + name: users + seq: 3 +} + +auth { + mode: inherit +} diff --git a/packages/common/src/models/index.ts b/packages/common/src/models/index.ts index ddf77b4..a54a3b3 100644 --- a/packages/common/src/models/index.ts +++ b/packages/common/src/models/index.ts @@ -1 +1,2 @@ export * from "./users"; +export * from "./lang"; diff --git a/packages/common/src/models/lang.ts b/packages/common/src/models/lang.ts new file mode 100644 index 0000000..1e0d7c3 --- /dev/null +++ b/packages/common/src/models/lang.ts @@ -0,0 +1,6 @@ +export const SUPPORTED_LANGS = ["fr", "en"]; +export type SupportedLangs = (typeof SUPPORTED_LANGS)[number]; + +export function isSupportedLang(obj: unknown): obj is SupportedLangs { + return (SUPPORTED_LANGS as unknown[]).includes(obj); +} diff --git a/packages/common/src/models/users/user.ts b/packages/common/src/models/users/user.ts index b4e7eb6..f8fc7d1 100644 --- a/packages/common/src/models/users/user.ts +++ b/packages/common/src/models/users/user.ts @@ -5,12 +5,22 @@ export enum UserTypeEnum { Professional = "professional", } -export const userParser = z.object({ +const userParser = z.object({ id: z.string({ message: "inavlid user id" }), firstName: z.string({ message: "invalid user first name" }), lastName: z.string({ message: "invalid last name" }), email: z.string().email({ message: "invalid email" }), - type: z.nativeEnum(UserTypeEnum, { message: "invalid user type" }), + //type: z.nativeEnum(UserTypeEnum, { message: "invalid user type" }), + createdAt: z.date(), + updatedAt: z.date(), + deletedAt: z.date().optional(), +}); + +export const createUserParser = userParser.pick({ + firstName: true, + lastName: true, + email: true, }); export type User = z.infer; +export type CreateUser = z.infer; diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index 6b57160..08491d6 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -1 +1,2 @@ export * from "./common"; +export * from "./result"; diff --git a/packages/common/src/utils/result.ts b/packages/common/src/utils/result.ts new file mode 100644 index 0000000..eb021d4 --- /dev/null +++ b/packages/common/src/utils/result.ts @@ -0,0 +1,32 @@ +export type Result = ResSuccess | ResError; + +export const Result = { + success(data: S): Result { + return new ResSuccess(data); + }, + error(error: E): Result { + return new ResError(error); + }, +} as const; + +interface BaseResult { + map(f: (s: S) => T): Result; +} +class ResSuccess implements BaseResult { + readonly ok: true = true; + + constructor(readonly data: S) {} + + map(f: (t: S) => T): Result { + return new ResSuccess(f(this.data)); + } +} +class ResError implements BaseResult { + readonly ok: false = false; + + constructor(readonly err: E) {} + + map(): Result { + return this as Result; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 096ac2c..094e84c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,9 +25,18 @@ importers: dotenv: specifier: ^17.2.1 version: 17.2.1 + i18next: + specifier: ^25.3.2 + version: 25.3.2(typescript@5.7.3) + i18next-fs-backend: + specifier: ^2.6.0 + version: 2.6.0 kysely: specifier: ^0.28.3 version: 0.28.3 + nodemailer: + specifier: ^7.0.5 + version: 7.0.5 pg: specifier: ^8.16.3 version: 8.16.3 @@ -71,6 +80,9 @@ importers: '@types/node': specifier: ^22.10.7 version: 22.16.3 + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 '@types/pg': specifier: ^8.15.4 version: 8.15.4 @@ -324,6 +336,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.2': + resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.27.2': resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} engines: {node: '>=6.9.0'} @@ -1034,6 +1050,9 @@ packages: '@types/node@22.16.3': resolution: {integrity: sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==} + '@types/nodemailer@6.4.17': + resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + '@types/pg@8.15.4': resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==} @@ -2146,6 +2165,17 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + i18next-fs-backend@2.6.0: + resolution: {integrity: sha512-3ZlhNoF9yxnM8pa8bWp5120/Ob6t4lVl1l/tbLmkml/ei3ud8IWySCHt2lrY5xWRlSU5D9IV2sm5bEbGuTqwTw==} + + i18next@25.3.2: + resolution: {integrity: sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -2710,6 +2740,10 @@ packages: node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + nodemailer@7.0.5: + resolution: {integrity: sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3927,6 +3961,8 @@ snapshots: '@babel/core': 7.28.0 '@babel/helper-plugin-utils': 7.27.1 + '@babel/runtime@7.28.2': {} + '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 @@ -4743,6 +4779,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@6.4.17': + dependencies: + '@types/node': 22.16.3 + '@types/pg@8.15.4': dependencies: '@types/node': 22.16.3 @@ -6045,6 +6085,14 @@ snapshots: human-signals@2.1.0: {} + i18next-fs-backend@2.6.0: {} + + i18next@25.3.2(typescript@5.7.3): + dependencies: + '@babel/runtime': 7.28.2 + optionalDependencies: + typescript: 5.7.3 + iconv-lite@0.4.24: dependencies: safer-buffer: 2.1.2 @@ -6703,6 +6751,8 @@ snapshots: node-releases@2.0.19: {} + nodemailer@7.0.5: {} + normalize-path@3.0.0: {} normalize-url@8.0.2: {}