improve
This commit is contained in:
@@ -27,7 +27,10 @@
|
|||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"dotenv": "^17.2.1",
|
"dotenv": "^17.2.1",
|
||||||
|
"i18next": "^25.3.2",
|
||||||
|
"i18next-fs-backend": "^2.6.0",
|
||||||
"kysely": "^0.28.3",
|
"kysely": "^0.28.3",
|
||||||
|
"nodemailer": "^7.0.5",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
@@ -44,6 +47,7 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/pg": "^8.15.4",
|
"@types/pg": "^8.15.4",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
|
|||||||
7
apps/backend/src/app-error.filter.spec.ts
Normal file
7
apps/backend/src/app-error.filter.spec.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppErrorFilter } from './app-error.filter';
|
||||||
|
|
||||||
|
describe('AppErrorFilter', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(new AppErrorFilter()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
13
apps/backend/src/app-error.filter.ts
Normal file
13
apps/backend/src/app-error.filter.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthController } from './app/auth/auth.controller';
|
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 { AuthService } from './app/auth/auth.service';
|
||||||
import { UserRegistryService } from './app/users/user-registry.service';
|
import { UsersService } from './app/users/users.service';
|
||||||
import { EnvService } from './env/env.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({
|
@Module({
|
||||||
imports: [],
|
imports: [],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, UsersController],
|
||||||
providers: [DatabaseService, AuthService, UserRegistryService, EnvService],
|
providers: [
|
||||||
|
DatabaseService,
|
||||||
|
AuthService,
|
||||||
|
UsersService,
|
||||||
|
EnvService,
|
||||||
|
UsersRegistry,
|
||||||
|
MailerService,
|
||||||
|
localizationServiceProvider,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import { AuthService } from './auth.service';
|
|||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private authService: AuthService) {}
|
constructor(private authService: AuthService) {}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
|
||||||
@Post('credentials')
|
@Post('credentials')
|
||||||
// eslint-disable-next-line @typescript-eslint/require-await
|
|
||||||
async postCredentials() {
|
async postCredentials() {
|
||||||
return this.authService.verifyCredentials();
|
return this.authService.verifyCredentials();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
import { UserRegistryService } from '../users/user-registry.service';
|
import { UsersRegistry } from '../users/users-registry';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private userRegistry: UserRegistryService) {}
|
constructor(private userRegistry: UsersRegistry) {}
|
||||||
|
|
||||||
async verifyCredentials() {
|
async verifyCredentials() {
|
||||||
const isValidCredential = await this.userRegistry.existsEmailPassword(
|
const isValidCredential = await this.userRegistry.existsEmailPassword(
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
apps/backend/src/app/users/users-error.ts
Normal file
11
apps/backend/src/app/users/users-error.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/backend/src/app/users/users-registry.spec.ts
Normal file
18
apps/backend/src/app/users/users-registry.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
67
apps/backend/src/app/users/users-registry.ts
Normal file
67
apps/backend/src/app/users/users-registry.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/backend/src/app/users/users.controller.spec.ts
Normal file
18
apps/backend/src/app/users/users.controller.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
apps/backend/src/app/users/users.controller.ts
Normal file
15
apps/backend/src/app/users/users.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/backend/src/app/users/users.service.spec.ts
Normal file
18
apps/backend/src/app/users/users.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
31
apps/backend/src/app/users/users.service.ts
Normal file
31
apps/backend/src/app/users/users.service.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/backend/src/assets/i18n/en.json
Normal file
4
apps/backend/src/assets/i18n/en.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"mail.create-user.title": "Welcome to Cowsi",
|
||||||
|
"mail.create-user.body-html": "<h1>Welcome to Cowsi<h1>"
|
||||||
|
}
|
||||||
4
apps/backend/src/assets/i18n/fr.json
Normal file
4
apps/backend/src/assets/i18n/fr.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"mail.create-user.title": "Bienvenue sur Cowsi",
|
||||||
|
"mail.create-user.body-html": "<h1>Bienvenue sur Cowsi<h1>"
|
||||||
|
}
|
||||||
27
apps/backend/src/env/env.service.ts
vendored
27
apps/backend/src/env/env.service.ts
vendored
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
14
apps/backend/src/lib/app-error.ts
Normal file
14
apps/backend/src/lib/app-error.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ZodValidationPipe } from './zod-validation.pipe';
|
||||||
|
|
||||||
|
describe('ZodValidationPipe', () => {
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(new ZodValidationPipe()).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { AppErrorFilter } from './app-error.filter';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
app.useGlobalFilters(new AppErrorFilter());
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import { Kysely, PostgresDialect } from 'kysely';
|
import { Kysely, PostgresDialect } from 'kysely';
|
||||||
import { DB } from './db';
|
import { DB } from '../../database/db';
|
||||||
import { EnvService } from '../env/env.service';
|
import { EnvService } from '../env/env.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -10,14 +10,14 @@ export class DatabaseService {
|
|||||||
database: Kysely<DB>;
|
database: Kysely<DB>;
|
||||||
|
|
||||||
constructor(envService: EnvService) {
|
constructor(envService: EnvService) {
|
||||||
const config = envService.config;
|
const config = envService.config.database;
|
||||||
const dialect = new PostgresDialect({
|
const dialect = new PostgresDialect({
|
||||||
pool: new Pool({
|
pool: new Pool({
|
||||||
database: config.DATABASE,
|
database: config.name,
|
||||||
host: config.DATABASE_HOST,
|
host: config.host,
|
||||||
user: config.DATABASE_USER,
|
user: config.user,
|
||||||
password: config.DATABASE_PASSWORD,
|
password: config.password,
|
||||||
port: config.DATABASE_PORT,
|
port: config.port,
|
||||||
max: 10,
|
max: 10,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
46
apps/backend/src/services/env/env.service.ts
vendored
Normal file
46
apps/backend/src/services/env/env.service.ts
vendored
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { UserRegistryService } from './user-registry.service';
|
import { LocalizationService } from './localization.service';
|
||||||
|
|
||||||
describe('UserRegistryService', () => {
|
describe('LocalizationService', () => {
|
||||||
let service: UserRegistryService;
|
let service: LocalizationService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [UserRegistryService],
|
providers: [LocalizationService],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<UserRegistryService>(UserRegistryService);
|
service = module.get<LocalizationService>(LocalizationService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
apps/backend/src/services/logger/logger.service.spec.ts
Normal file
18
apps/backend/src/services/logger/logger.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
4
apps/backend/src/services/logger/logger.service.ts
Normal file
4
apps/backend/src/services/logger/logger.service.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoggerService {}
|
||||||
18
apps/backend/src/services/mailer/mailer.service.spec.ts
Normal file
18
apps/backend/src/services/mailer/mailer.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
43
apps/backend/src/services/mailer/mailer.service.ts
Normal file
43
apps/backend/src/services/mailer/mailer.service.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
23
documentation/bruno-rest-api/LocalAPI/Create user.bru
Normal file
23
documentation/bruno-rest-api/LocalAPI/Create user.bru
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: asdf
|
|
||||||
type: http
|
|
||||||
seq: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: localhost:5047
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
meta {
|
|
||||||
name: hello
|
|
||||||
type: http
|
|
||||||
seq: 2
|
|
||||||
}
|
|
||||||
|
|
||||||
get {
|
|
||||||
url: {{BASE_URL}}/hello
|
|
||||||
body: none
|
|
||||||
auth: inherit
|
|
||||||
}
|
|
||||||
|
|
||||||
settings {
|
|
||||||
encodeUrl: true
|
|
||||||
}
|
|
||||||
8
documentation/bruno-rest-api/LocalAPI/users/folder.bru
Normal file
8
documentation/bruno-rest-api/LocalAPI/users/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
meta {
|
||||||
|
name: users
|
||||||
|
seq: 3
|
||||||
|
}
|
||||||
|
|
||||||
|
auth {
|
||||||
|
mode: inherit
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./users";
|
export * from "./users";
|
||||||
|
export * from "./lang";
|
||||||
|
|||||||
6
packages/common/src/models/lang.ts
Normal file
6
packages/common/src/models/lang.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -5,12 +5,22 @@ export enum UserTypeEnum {
|
|||||||
Professional = "professional",
|
Professional = "professional",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userParser = z.object({
|
const userParser = z.object({
|
||||||
id: z.string({ message: "inavlid user id" }),
|
id: z.string({ message: "inavlid user id" }),
|
||||||
firstName: z.string({ message: "invalid user first name" }),
|
firstName: z.string({ message: "invalid user first name" }),
|
||||||
lastName: z.string({ message: "invalid last name" }),
|
lastName: z.string({ message: "invalid last name" }),
|
||||||
email: z.string().email({ message: "invalid email" }),
|
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<typeof userParser>;
|
export type User = z.infer<typeof userParser>;
|
||||||
|
export type CreateUser = z.infer<typeof createUserParser>;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from "./common";
|
export * from "./common";
|
||||||
|
export * from "./result";
|
||||||
|
|||||||
32
packages/common/src/utils/result.ts
Normal file
32
packages/common/src/utils/result.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export type Result<S, E> = ResSuccess<S> | ResError<E>;
|
||||||
|
|
||||||
|
export const Result = {
|
||||||
|
success<S>(data: S): Result<S, never> {
|
||||||
|
return new ResSuccess(data);
|
||||||
|
},
|
||||||
|
error<E>(error: E): Result<never, E> {
|
||||||
|
return new ResError(error);
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface BaseResult<S, E> {
|
||||||
|
map<T>(f: (s: S) => T): Result<T, E>;
|
||||||
|
}
|
||||||
|
class ResSuccess<S> implements BaseResult<S, never> {
|
||||||
|
readonly ok: true = true;
|
||||||
|
|
||||||
|
constructor(readonly data: S) {}
|
||||||
|
|
||||||
|
map<T>(f: (t: S) => T): Result<T, never> {
|
||||||
|
return new ResSuccess(f(this.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class ResError<E> implements BaseResult<never, E> {
|
||||||
|
readonly ok: false = false;
|
||||||
|
|
||||||
|
constructor(readonly err: E) {}
|
||||||
|
|
||||||
|
map<T>(): Result<T, E> {
|
||||||
|
return this as Result<T, E>;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
pnpm-lock.yaml
generated
50
pnpm-lock.yaml
generated
@@ -25,9 +25,18 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.2.1
|
specifier: ^17.2.1
|
||||||
version: 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:
|
kysely:
|
||||||
specifier: ^0.28.3
|
specifier: ^0.28.3
|
||||||
version: 0.28.3
|
version: 0.28.3
|
||||||
|
nodemailer:
|
||||||
|
specifier: ^7.0.5
|
||||||
|
version: 7.0.5
|
||||||
pg:
|
pg:
|
||||||
specifier: ^8.16.3
|
specifier: ^8.16.3
|
||||||
version: 8.16.3
|
version: 8.16.3
|
||||||
@@ -71,6 +80,9 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.7
|
specifier: ^22.10.7
|
||||||
version: 22.16.3
|
version: 22.16.3
|
||||||
|
'@types/nodemailer':
|
||||||
|
specifier: ^6.4.17
|
||||||
|
version: 6.4.17
|
||||||
'@types/pg':
|
'@types/pg':
|
||||||
specifier: ^8.15.4
|
specifier: ^8.15.4
|
||||||
version: 8.15.4
|
version: 8.15.4
|
||||||
@@ -324,6 +336,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@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':
|
'@babel/template@7.27.2':
|
||||||
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -1034,6 +1050,9 @@ packages:
|
|||||||
'@types/node@22.16.3':
|
'@types/node@22.16.3':
|
||||||
resolution: {integrity: sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==}
|
resolution: {integrity: sha512-sr4Xz74KOUeYadexo1r8imhRtlVXcs+j3XK3TcoiYk7B1t3YRVJgtaD3cwX73NYb71pmVuMLNRhJ9XKdoDB74g==}
|
||||||
|
|
||||||
|
'@types/nodemailer@6.4.17':
|
||||||
|
resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==}
|
||||||
|
|
||||||
'@types/pg@8.15.4':
|
'@types/pg@8.15.4':
|
||||||
resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==}
|
resolution: {integrity: sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==}
|
||||||
|
|
||||||
@@ -2146,6 +2165,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
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:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -2710,6 +2740,10 @@ packages:
|
|||||||
node-releases@2.0.19:
|
node-releases@2.0.19:
|
||||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
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:
|
normalize-path@3.0.0:
|
||||||
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -3927,6 +3961,8 @@ snapshots:
|
|||||||
'@babel/core': 7.28.0
|
'@babel/core': 7.28.0
|
||||||
'@babel/helper-plugin-utils': 7.27.1
|
'@babel/helper-plugin-utils': 7.27.1
|
||||||
|
|
||||||
|
'@babel/runtime@7.28.2': {}
|
||||||
|
|
||||||
'@babel/template@7.27.2':
|
'@babel/template@7.27.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.27.1
|
'@babel/code-frame': 7.27.1
|
||||||
@@ -4743,6 +4779,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
|
'@types/nodemailer@6.4.17':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.16.3
|
||||||
|
|
||||||
'@types/pg@8.15.4':
|
'@types/pg@8.15.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.16.3
|
'@types/node': 22.16.3
|
||||||
@@ -6045,6 +6085,14 @@ snapshots:
|
|||||||
|
|
||||||
human-signals@2.1.0: {}
|
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:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@@ -6703,6 +6751,8 @@ snapshots:
|
|||||||
|
|
||||||
node-releases@2.0.19: {}
|
node-releases@2.0.19: {}
|
||||||
|
|
||||||
|
nodemailer@7.0.5: {}
|
||||||
|
|
||||||
normalize-path@3.0.0: {}
|
normalize-path@3.0.0: {}
|
||||||
|
|
||||||
normalize-url@8.0.2: {}
|
normalize-url@8.0.2: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user