Skip to content

Commit

Permalink
add api-events registry
Browse files Browse the repository at this point in the history
  • Loading branch information
alexeh authored and andresgnlez committed Jul 29, 2024
1 parent 430eb0e commit ed2002a
Show file tree
Hide file tree
Showing 11 changed files with 204 additions and 9 deletions.
2 changes: 2 additions & 0 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { JwtAuthGuard } from '@api/guards/jwt-auth.guard';
import { TsRestModule } from '@ts-rest/nest';
import { EmailModule } from './modules/email/email.module';
import { ContactMailer } from '@api/contact.mailer';
import { ApiEventsModule } from '@api/modules/api-events/api-events.module';

@Module({
imports: [
Expand All @@ -19,6 +20,7 @@ import { ContactMailer } from '@api/contact.mailer';
UsersModule,
AuthModule,
EmailModule,
ApiEventsModule,
],
controllers: [AppController],
providers: [
Expand Down
13 changes: 13 additions & 0 deletions api/src/modules/api-events/api-events.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Global, Module } from '@nestjs/common';

import { TypeOrmModule } from '@nestjs/typeorm';
import { ApiEventEntity } from '@shared/dto/api-events/api-events.entity';
import { ApiEventsService } from '@api/modules/api-events/api-events.service';

@Global()
@Module({
imports: [TypeOrmModule.forFeature([ApiEventEntity])],
providers: [ApiEventsService],
exports: [ApiEventsService],
})
export class ApiEventsModule {}
45 changes: 45 additions & 0 deletions api/src/modules/api-events/api-events.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ApiEventEntity } from '@shared/dto/api-events/api-events.entity';
import { API_EVENT_TYPES } from '@shared/dto/api-events/api-event.types';

/**
* @description: At some point we might need to extend this to use events to decouple the event creation from the logic.
* it might be useful to create custom decorators for methods to fire events instead of calling this service directly in each method.
* this will also allow us to split event types but it will also increase complexity. i.e we could have a handler/service for each event group
*/

@Injectable()
export class ApiEventsService {
public eventMap = {
USER_EVENTS: {
USER_SIGNED_UP: API_EVENT_TYPES.USER_SIGNED_UP,
USER_REQUESTED_PASSWORD_RECOVERY:
API_EVENT_TYPES.USER_REQUESTED_PASSWORD_RECOVERY,
USER_RECOVERED_PASSWORD: API_EVENT_TYPES.USER_RECOVERED_PASSWORD,
USER_NOT_FOUND_FOR_PASSWORD_RECOVERY:
API_EVENT_TYPES.USER_NOT_FOUND_FOR_PASSWORD_RECOVERY,
},
};
constructor(
@InjectRepository(ApiEventEntity)
readonly eventRepo: Repository<ApiEventEntity>,
) {}

async saveEvent(event: ApiEventEntity): Promise<void> {
await this.eventRepo.insert(event);
}

async createEvent(
type: API_EVENT_TYPES,
payload: { associatedId?: string; data?: Record<string, unknown> },
): Promise<void> {
const { associatedId, data } = payload;
const event = new ApiEventEntity();
event.type = type;
event.associatedId = associatedId;
event.data = data;
await this.saveEvent(event);
}
}
16 changes: 15 additions & 1 deletion api/src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
PasswordRecoveryEmailService,
} from '@api/modules/auth/password/password-recovery-email.service';
import { AppConfig } from '@api/utils/app-config';
import { ApiEventsService } from '@api/modules/api-events/api-events.service';

@Injectable()
export class AuthService {
Expand All @@ -28,14 +29,19 @@ export class AuthService {
private readonly passwordService: PasswordService,
private readonly passwordMailer: PasswordRecoveryEmailService,
private readonly jwtService: JwtService,
private readonly events: ApiEventsService,
) {}

async signUp(dto: SignUpDto): Promise<void> {
const passwordHash = await this.passwordService.hashPassword(dto.password);
await this.usersService.createUser({
const user = await this.usersService.createUser({
email: dto.email,
password: passwordHash,
});
await this.events.createEvent(
this.events.eventMap.USER_EVENTS.USER_SIGNED_UP,
{ associatedId: user.id },
);
}

async validateUser(email: string, password: string): Promise<User> {
Expand Down Expand Up @@ -76,6 +82,10 @@ export class AuthService {
`User with email ${passwordRecovery.email} not found when trying to recover password`,
);
// if user does not exist, we should not return anything
await this.events.createEvent(
this.events.eventMap.USER_EVENTS.USER_NOT_FOUND_FOR_PASSWORD_RECOVERY,
{ data: { email: passwordRecovery.email } },
);
return;
}
const payload: JwtPayload = { id: user.id };
Expand All @@ -88,5 +98,9 @@ export class AuthService {
url: passwordRecovery.url,
token,
});
await this.events.createEvent(
this.events.eventMap.USER_EVENTS.USER_REQUESTED_PASSWORD_RECOVERY,
{ associatedId: user.id },
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
IEmailServiceInterface,
IEmailServiceToken,
} from '@api/modules/email/email.service.interface';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { AppConfig } from '@api/utils/app-config';

export type PasswordRecovery = {
Expand All @@ -13,7 +13,6 @@ export type PasswordRecovery = {

@Injectable()
export class PasswordRecoveryEmailService {
logger: Logger = new Logger(PasswordRecoveryEmailService.name);
constructor(
@Inject(IEmailServiceToken)
private readonly emailService: IEmailServiceInterface,
Expand Down Expand Up @@ -45,9 +44,6 @@ export class PasswordRecoveryEmailService {
subject: 'Recover Password',
html: htmlContent,
});
this.logger.log(
`Password recovery email sent to ${passwordRecovery.email}`,
);
}
}

Expand Down
9 changes: 7 additions & 2 deletions api/src/modules/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CustomChartsService } from '@api/modules/custom-charts/custom-charts.se
import { AuthService } from '@api/modules/auth/auth.service';
import { UpdateUserPasswordDto } from '@shared/dto/users/update-user-password.dto';
import { PasswordService } from '@api/modules/auth/password/password.service';
import { ApiEventsService } from '@api/modules/api-events/api-events.service';

@Injectable()
export class UsersService extends AppBaseService<
Expand All @@ -24,8 +25,9 @@ export class UsersService extends AppBaseService<
private readonly customChartService: CustomChartsService,
private readonly authService: AuthService,
private readonly passwordService: PasswordService,
private readonly events: ApiEventsService,
) {
super(userRepository, UsersService.name);
super(userRepository, 'user', 'users');
}

async createUser(createUserDto: CreateUserDto) {
Expand Down Expand Up @@ -59,6 +61,9 @@ export class UsersService extends AppBaseService<
async resetPassword(user: User, newPassword: string) {
user.password = await this.passwordService.hashPassword(newPassword);
await this.userRepository.save(user);
this.logger.log(`User ${user.email} password has been reset`);
await this.events.createEvent(
this.events.eventMap.USER_EVENTS.USER_RECOVERED_PASSWORD,
{ associatedId: user.id },
);
}
}
62 changes: 62 additions & 0 deletions api/test/api-events/api-events.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { TestManager } from '../utils/test-manager';
import { Repository } from 'typeorm';
import { AuthService } from '@api/modules/auth/auth.service';
import { ApiEventEntity } from '@shared/dto/api-events/api-events.entity';
import { API_EVENT_TYPES } from '@shared/dto/api-events/api-event.types';
import { UsersService } from '@api/modules/users/users.service';

describe('Api Events', () => {
let testManager: TestManager<any>;
let authService: AuthService;
let usersService: UsersService;
let apiEventsRepository: Repository<ApiEventEntity>;

beforeAll(async () => {
testManager = await TestManager.createTestManager();
authService = testManager.moduleFixture.get<AuthService>(AuthService);
usersService = testManager.moduleFixture.get<UsersService>(UsersService);
apiEventsRepository = testManager.dataSource.getRepository(ApiEventEntity);
});

afterEach(async () => {
await testManager.clearDatabase();
});
it('an api event should be registered if a user has requested password recovery but the provided email is not found in the system', async () => {
await authService.recoverPassword({ email: '[email protected]' });
const apiEvent = await apiEventsRepository.findOne({
where: { type: API_EVENT_TYPES.USER_NOT_FOUND_FOR_PASSWORD_RECOVERY },
});

expect(apiEvent).toBeDefined();
expect(apiEvent.data.email).toEqual('[email protected]');
});
it('an api event should be registered if a user has requested password recovery successfully', async () => {
const user = await testManager
.mocks()
.createUser({ email: '[email protected]' });
await authService.recoverPassword({ email: user.email });
const apiEvent = await apiEventsRepository.findOne({
where: { type: API_EVENT_TYPES.USER_REQUESTED_PASSWORD_RECOVERY },
});
expect(apiEvent).toBeDefined();
expect(apiEvent.associatedId).toEqual(user.id);
});
it('an api event should be registered when a user signs up', async () => {
await authService.signUp({ email: '[email protected]', password: '12345678' });
const apiEvent = await apiEventsRepository.findOne({
where: { type: API_EVENT_TYPES.USER_SIGNED_UP },
});
expect(apiEvent).toBeDefined();
});
it('an api event should be registered when a user recovers password', async () => {
const user = await testManager
.mocks()
.createUser({ email: '[email protected]' });
await usersService.resetPassword(user, 'new-password');
const apiEvent = await apiEventsRepository.findOne({
where: { type: API_EVENT_TYPES.USER_RECOVERED_PASSWORD },
});
expect(apiEvent).toBeDefined();
expect(apiEvent.associatedId).toEqual(user.id);
});
});
25 changes: 24 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions shared/dto/api-events/api-event.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Types of events that will be stored in the database.

export enum API_EVENT_TYPES {
USER_SIGNED_UP = 'user_signed-up',
USER_REQUESTED_PASSWORD_RECOVERY = 'user_requested-password-recovery',
USER_NOT_FOUND_FOR_PASSWORD_RECOVERY = 'user_not-found-for-password-recovery',
USER_RECOVERED_PASSWORD = 'user_recovered-password',
}
25 changes: 25 additions & 0 deletions shared/dto/api-events/api-events.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { API_EVENT_TYPES } from '@shared/dto/api-events/api-event.types';

// TODO: create appropriate indexes for generating views later on

@Entity({ name: 'api_events' })
export class ApiEventEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;

@Column('timestamp', { default: () => 'now()' })
timestamp!: Date;

// Type of event, providing a brief description of the event.
@Column({ type: 'varchar', enum: API_EVENT_TYPES })
type!: API_EVENT_TYPES;

// Id of the resource associated with the event.
@Column({ type: 'uuid', nullable: true })
associatedId: string;

// Payload of the event
@Column({ type: 'jsonb', nullable: true })
data: Record<string, unknown>;
}
2 changes: 2 additions & 0 deletions shared/lib/db-entities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { EntitySchema } from 'typeorm/entity-schema/EntitySchema';
import { CustomChart } from '@shared/dto/custom-charts/custom-chart.entity';
import { User } from '@shared/dto/users/user.entity';
import { ChartFilter } from '@shared/dto/custom-charts/custom-chart-filter.entity';
import { ApiEventEntity } from '@shared/dto/api-events/api-events.entity';

export const DB_ENTITIES: MixedList<Function | string | EntitySchema> = [
User,
CustomChart,
ChartFilter,
ApiEventEntity,
];

0 comments on commit ed2002a

Please sign in to comment.