This commit is contained in:
Paul Coral
2025-08-04 22:38:48 +02:00
parent 6050d9d3a3
commit 333fd824e3
41 changed files with 580 additions and 98 deletions

View File

@@ -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",

View File

@@ -0,0 +1,7 @@
import { AppErrorFilter } from './app-error.filter';
describe('AppErrorFilter', () => {
it('should be defined', () => {
expect(new AppErrorFilter()).toBeDefined();
});
});

View File

@@ -0,0 +1,13 @@
import { Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import { AppError } from './lib/app-error';
@Catch(AppError)
export class AppErrorFilter<T extends AppError> implements ExceptionFilter {
catch(exception: T) {
if (exception.status) {
throw new HttpException(exception.message, exception.status, {
cause: exception,
});
}
}
}

View File

@@ -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 {}

View File

@@ -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();
}

View File

@@ -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(

View File

@@ -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);
}
}

View File

@@ -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,
});
}
}

View File

@@ -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>(UsersRegistry);
});
it('should be defined', () => {
expect(provider).toBeDefined();
});
});

View File

@@ -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<Result<User, UserEmailAlreadyExistError>> {
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<User>({
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,
});
}
}

View File

@@ -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>(UsersController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -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);
}
}

View File

@@ -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>(UsersService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -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<void> {
// 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: `<h1>Welcome to COWSI</h1>
<p>Your account has been successfully created</p>
`,
});
}
}

View File

@@ -0,0 +1,4 @@
{
"mail.create-user.title": "Welcome to Cowsi",
"mail.create-user.body-html": "<h1>Welcome to Cowsi<h1>"
}

View File

@@ -0,0 +1,4 @@
{
"mail.create-user.title": "Bienvenue sur Cowsi",
"mail.create-user.body-html": "<h1>Bienvenue sur Cowsi<h1>"
}

View File

@@ -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<z.infer<typeof envParser>>;
@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);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,7 @@
import { ZodValidationPipe } from './zod-validation.pipe';
describe('ZodValidationPipe', () => {
it('should be defined', () => {
expect(new ZodValidationPipe()).toBeDefined();
});
});

View File

@@ -0,0 +1,20 @@
import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod';
export class ZodValidationPipe<Z extends ZodSchema> 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;
}
}

View File

@@ -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();

View File

@@ -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<DB>;
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,
}),
});

View File

@@ -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<z.infer<typeof envParser>>;
@Injectable()
export class EnvService {
readonly config: EnvConfig;
constructor() {
const res = dotenv.config();
if (res.error) {
throw res.error;
}
this.config = envParser.parse(res.parsed);
}
}

View File

@@ -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>(UserRegistryService);
service = module.get<LocalizationService>(LocalizationService);
});
it('should be defined', () => {

View File

@@ -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<i18nextBackend.FsBackendOptions>({
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);
}
}

View File

@@ -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>(LoggerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class LoggerService {}

View File

@@ -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>(MailerService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@@ -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<SentMessageInfo, Options>;
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" <info@cowsi.ch>',
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');
}
}
}