Skip to content

Commit

Permalink
feat: add base controller
Browse files Browse the repository at this point in the history
  • Loading branch information
fasenderos committed Sep 12, 2023
1 parent 9f8946f commit 3e53f05
Show file tree
Hide file tree
Showing 8 changed files with 336 additions and 24 deletions.
14 changes: 8 additions & 6 deletions packages/api-gateway/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,11 +254,13 @@ export class AuthService {

const token = createRandomString();
const hashedToken = await hash(token, 10);
await this.recoveryToken.create({
userId: user.id,
token: hashedToken,
expiresAt: new Date(Date.now() + this.expResetPassword),
});
await this.recoveryToken.create(
{
token: hashedToken,
expiresAt: new Date(Date.now() + this.expResetPassword),
},
user.id,
);
const payload: EmailResetPasswordDto = {
token,
email,
Expand All @@ -274,7 +276,7 @@ export class AuthService {
): Promise<void> {
const hashedToken = await hash(token, 10);
const exist = await this.recoveryToken.findOne({
where: { token: hashedToken },
token: hashedToken,
});
if (!exist) throw new UnauthorizedException('Invalid password reset token');

Expand Down
223 changes: 223 additions & 0 deletions packages/api-gateway/src/base/base.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import {
Body,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Type,
UseGuards,
UsePipes,
} from '@nestjs/common';
import { AbstractValidationPipe } from '../common/pipes/abstract-validation.pipe';
import { IBaseController } from './interfaces/base-controller.interface';
import { IBaseService } from './interfaces/base-service.interface';
import { BaseEntity } from './base.entity';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { JwtGuard } from '../auth/guards/jwt.guard';
import { UserRole } from '../app.roles';
import { DeepPartial, FindManyOptions, FindOptionsWhere } from 'typeorm';
import { Collections } from '../common/constants';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
import { User } from '../user/entities/user.entity';
import { ACGuard, UseRoles } from 'nest-access-control';

/**
* Every controller in the application can use the ControllerFactory in order
* to avoid code duplication. Magically you have all CRUD methods to create, read,
* updated and delete an entity protected by the Role Base ACL and the api documentaion.
*/
export function ControllerFactory<
Entity extends BaseEntity,
CreateDTO extends DeepPartial<Entity>,
UpdateDTO extends QueryDeepPartialEntity<Entity>,
>(
createDto: Type<CreateDTO>,
updateDto: Type<UpdateDTO>,
resource: Collections,
): Type<IBaseController<Entity, CreateDTO, UpdateDTO>> {
const createPipe = new AbstractValidationPipe(
{
forbidNonWhitelisted: true,
forbidUnknownValues: true,
whitelist: true,
transform: true,
stopAtFirstError: true,
},
{ body: createDto },
);
const updatePipe = new AbstractValidationPipe(
{
forbidNonWhitelisted: true,
forbidUnknownValues: true,
whitelist: true,
transform: true,
stopAtFirstError: true,
},
{ body: updateDto },
);

class BaseController<
Entity extends BaseEntity,
CreateDTO extends DeepPartial<Entity>,
UpdateDTO extends QueryDeepPartialEntity<Entity>,
> implements IBaseController<Entity, CreateDTO, UpdateDTO>
{
protected readonly service: IBaseService<Entity, CreateDTO, UpdateDTO>;
constructor(service: IBaseService<Entity, CreateDTO, UpdateDTO>) {
this.service = service;
}

/**
* Create a given entity
* @param {CreateDTO} dto The entity to be created
* @param {User} user The user that is making the request
* @returns The created resource
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@UseGuards(JwtGuard, ACGuard)
@UseRoles({ resource, action: 'create', possession: 'own' })
@UsePipes(createPipe)
@ApiBearerAuth()
@ApiResponse({
status: HttpStatus.CREATED,
description: 'The record has been successfully created.',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized.',
})
create(@Body() dto: CreateDTO, @CurrentUser() user: User): Promise<Entity> {
// Create owned resource
return this.service.create(dto, user.id);
}

/**
* Find entity by ID. If entity was not found in the database - returns null.
* @param {string} id The ID of the entity
* @param {User} user The user that is making the request
* @returns The entity that match the conditions or null.
*/
@Get(':id')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard, ACGuard)
@UseRoles({ resource, action: 'read', possession: 'own' })
@ApiBearerAuth()
@ApiResponse({
status: HttpStatus.OK,
description: 'Return the record or null.',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized.',
})
findById(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<Entity | null> {
// Admin can view any resource
if (user.role.includes(UserRole.ADMIN)) {
return this.service.findById(id);
}
// Member can view owned resource only
return this.service.findOne({
id,
userId: user.id,
} as unknown as FindOptionsWhere<Entity>);
}

/**
* Find all entities.
* @param {User} user The user that is making the request
* @returns All the entities
*/
@Get()
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard, ACGuard)
@UseRoles({ resource, action: 'read', possession: 'own' })
@ApiBearerAuth()
@ApiResponse({
status: HttpStatus.OK,
description: 'Return an array of records.',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized.',
})
findAll(@CurrentUser() user: User): Promise<Entity[]> {
// Admin can view any resources
if (user.role.includes(UserRole.ADMIN)) {
return this.service.find();
}
// Member can view owned resources only
return this.service.find({
where: { userId: user.id },
} as unknown as FindManyOptions<Entity>);
}

/**
* Updates entity by a given conditions.
* @param {string} id The ID of the entity
* @param {UpdateDTO} dto The payload to update the entity
* @param {User} user The user that is making the request
* @returns NO_CONTENT
*/
@Patch(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtGuard, ACGuard)
@UseRoles({ resource, action: 'update', possession: 'own' })
@UsePipes(updatePipe)
@ApiBearerAuth()
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: 'The record has been successfully updated.',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized.',
})
async updateById(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateDTO,
@CurrentUser() user: User,
): Promise<void> {
// Update owned resource
await this.service.updateById(id, dto, user.id);
}

/**
* Soft delete entity by ID, which means an update of the deletedAt column.
* The record still exists in the database, but will not be retireved by any find/update query.
* @param {string} id The ID of the entity
* @param {User} user The user that is making the request
* @returns NO_CONTENT
*/
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@UseGuards(JwtGuard, ACGuard)
@UseRoles({ resource, action: 'delete', possession: 'own' })
@ApiBearerAuth()
@ApiResponse({
status: HttpStatus.NO_CONTENT,
description: 'The record has been successfully deleted.',
})
@ApiResponse({
status: HttpStatus.UNAUTHORIZED,
description: 'Unauthorized.',
})
async deleteById(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: User,
): Promise<void> {
// Delete owned resource
await this.service.deleteById(id, user.id);
}
}

return BaseController;
}
45 changes: 31 additions & 14 deletions packages/api-gateway/src/base/base.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
import {
DeepPartial,
FindManyOptions,
FindOneOptions,
FindOptionsWhere,
Repository,
} from 'typeorm';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { BaseEntity } from './base.entity';
import { IBaseService } from './interfaces/base-service.interface';

export abstract class BaseService<Entity extends BaseEntity> {
export abstract class BaseService<
Entity extends BaseEntity,
CreateDTO extends DeepPartial<Entity>,
UpdateDTO extends QueryDeepPartialEntity<Entity>,
> implements IBaseService<Entity, CreateDTO, UpdateDTO>
{
constructor(private readonly repo: Repository<Entity>) {}

create(data: DeepPartial<Entity>): Promise<Entity> {
return this.repo.save(data);
/**
* Saves a given entity in the database.
* @param {CreateDTO} data The entity to be created
* @param {string} userId The userId of the user owner of the resource
* @returns The created resource
*/
create(data: CreateDTO, userId: string): Promise<Entity> {
return this.repo.save({ ...data, userId: userId });
}

/**
Expand All @@ -27,11 +38,11 @@ export abstract class BaseService<Entity extends BaseEntity> {
/**
* Finds first entity that matches given where condition.
* If entity was not found in the database - returns null.
* @param {FindOneOptions<Entity>} filter The matching conditions for finding
* @param {FindOptionsWhere<Entity>} filter The matching conditions for finding
* @returns The entity that match the conditions or null.
*/
findOne(where: FindOneOptions<Entity>): Promise<Entity | null> {
return this.repo.findOne(where);
findOne(filter: FindOptionsWhere<Entity>): Promise<Entity | null> {
return this.repo.findOneBy(filter);
}

/**
Expand All @@ -40,8 +51,7 @@ export abstract class BaseService<Entity extends BaseEntity> {
* @returns The entity that match the conditions or null.
*/
findById(id: string): Promise<Entity | null> {
// @ts-expect-error don't know why ts raise err: Argument of type '{ id: string; }' is not assignable to parameter of type 'FindOptionsWhere<T> | FindOptionsWhere<T>[]'
return this.findOne({ where: { id } });
return this.findOne({ id } as FindOptionsWhere<Entity>);
}

/**
Expand All @@ -50,24 +60,31 @@ export abstract class BaseService<Entity extends BaseEntity> {
* @param {FindOptionsWhere<Entity>} filter The matching conditions for updating
* @param {UpdateDTO} data The payload to update the entity
*/
async update(filter: FindOptionsWhere<Entity>, data: Entity): Promise<void> {
await this.repo.update(filter, data as QueryDeepPartialEntity<Entity>);
async update(
filter: FindOptionsWhere<Entity>,
data: UpdateDTO,
): Promise<void> {
await this.repo.update(filter, data);
}

/**
* Updates entity partially by ID.
* Does not check if entity exist in the database.
* @param {string} id The ID of the entity to update
* @param {Entity} data The payload to update the entity
* @param {UpdateDTO} data The payload to update the entity
* @param {string} userId The userId of the user owner of the resource
*/
async updateById(id: string, data: Entity, userId?: string): Promise<void> {
async updateById(
id: string,
data: UpdateDTO,
userId?: string,
): Promise<void> {
await this.repo.update(
{
id,
...(userId ? { userId: userId } : {}),
} as FindOptionsWhere<Entity>,
data as QueryDeepPartialEntity<Entity>,
data,
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { DeepPartial } from 'typeorm';
import { BaseEntity } from '../base.entity';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
import { User } from '../../user/entities/user.entity';

export interface IBaseController<
T extends BaseEntity,
C extends DeepPartial<T>,
U extends QueryDeepPartialEntity<T>,
> {
create(data: C, user: User): Promise<T>;
findById(id: string, user: User): Promise<T | null>;
findAll(user: User): Promise<T[]>;
updateById(id: string, dto: U, user: User): Promise<void>;
deleteById(id: string, user: User): Promise<void>;
}
18 changes: 18 additions & 0 deletions packages/api-gateway/src/base/interfaces/base-service.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DeepPartial, FindManyOptions, FindOptionsWhere } from 'typeorm';
import { BaseEntity } from '../base.entity';
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';

export interface IBaseService<
T extends BaseEntity,
C extends DeepPartial<T>,
U extends QueryDeepPartialEntity<T>,
> {
create(data: C, userId: string): Promise<T>;
find(options?: FindManyOptions<T>): Promise<T[]>;
findOne(filter: FindOptionsWhere<T>): Promise<T | null>;
findById(id: string): Promise<T | null>;
update(filter: FindOptionsWhere<T>, data: U): Promise<void>;
updateById(id: string, data: U, userId?: string): Promise<void>;
delete(filter: FindOptionsWhere<T>, soft?: boolean): Promise<void>;
deleteById(id: string, userId?: string, soft?: boolean): Promise<void>;
}
Loading

0 comments on commit 3e53f05

Please sign in to comment.