Skip to content

Commit b0552be

Browse files
authored
refactor(core): unify exception filter and resolve technical debt (#22)
* refactor(core): unify exception filter and resolve technical debt * refactor: finalize unified error handling and sync test suites
1 parent a8b08cb commit b0552be

45 files changed

Lines changed: 1168 additions & 529 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ DATABASE_URL=postgres://${DB_USERNAME}:${DB_PASSWORD}@localhost:${DB_PORT}/${DB_
2525
REDIS_HOST=127.0.0.1
2626
REDIS_PORT=7000
2727

28+
JWT_AUDIENCE="task-tracker-client"
29+
2830
JWT_ACCESS_SECRET=same-same-same-same-same
2931
JWT_ACCESS_EXPIRES_IN=15m
3032

libs/bootstrap/src/bootstrap.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ export async function bootstrapApp(options: BootstrapOptions) {
3636

3737
let rootModule = appModule;
3838

39-
// TODO: Improve merging modules (in case of multiple features needed) or migrate to fastify throttle
4039
if (throttlerOptions) {
4140
rootModule = setupThrottler(rootModule, throttlerOptions);
4241
}

libs/config/src/config.schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ export const ConfigSchema = z.object({
3535
.min(1, "CORS_ALLOWED_ORIGINS can't be empty")
3636
.transform((val) => val.split(',').map((s) => s.trim()))
3737
.pipe(z.array(z.string().url('Each origin must be a valid URL'))),
38+
JWT_AUDIENCE: z
39+
.string({
40+
error: 'JWT_AUDIENCE is required',
41+
})
42+
.min(1),
3843
JWT_ACCESS_SECRET: z.string().refine(jwtSecretValidation, {
3944
message:
4045
'JWT_ACCESS_SECRET must be at least 32 characters long OR contain at least 5 words separated by hyphens',

libs/health/src/controller/health.controller.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { Controller, Get, HttpException, HttpStatus, Inject, Logger } from '@nestjs/common';
1+
import { Controller, Get, HttpStatus, Inject, Logger } from '@nestjs/common';
22
import { SkipThrottle } from '@nestjs/throttler';
33
import { HealthService } from '../health.service';
44
import { GetHealthSwagger, GetPingSwagger } from './health.swagger';
55
import { ApiTags } from '@nestjs/swagger';
6+
import { BaseException } from '@shared/error';
67

78
@SkipThrottle()
89
@Controller()
@@ -22,8 +23,18 @@ export class HealthController {
2223

2324
if (pingData.status !== 'up') {
2425
this.logger.error(`${this.serviceName} is unhealthy!`);
25-
throw new HttpException(
26-
`${this.serviceName} service is unhealthy.`,
26+
throw new BaseException(
27+
{
28+
code: 'SERVICE_UNHEALTHY',
29+
message: `Сервис ${this.serviceName} временно недоступен или работает некорректно`,
30+
details: [
31+
{
32+
target: this.serviceName,
33+
status: pingData.status,
34+
timestamp: new Date().toISOString(),
35+
},
36+
],
37+
},
2738
HttpStatus.SERVICE_UNAVAILABLE,
2839
);
2940
}

libs/health/src/controller/health.controlller.spec.ts

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,20 +15,21 @@ describe('HealthController', () => {
1515
vi.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
1616
});
1717

18-
describe('checkHealth', () => {
19-
it('should return "healthy" when service status is "up"', async () => {
20-
healthServiceMock.getHealthData.mockResolvedValue({ status: 'up' });
21-
22-
await expect(controller.checkHealth()).resolves.toBe('healthy');
23-
});
24-
25-
it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => {
26-
healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' });
27-
28-
await expect(controller.checkHealth()).rejects.toMatchObject({
29-
status: HttpStatus.SERVICE_UNAVAILABLE,
30-
response: `${SERVICE_NAME} service is unhealthy.`,
31-
});
18+
it('should throw SERVICE_UNAVAILABLE when service status is "down"', async () => {
19+
healthServiceMock.getHealthData.mockResolvedValue({ status: 'down' });
20+
21+
await expect(controller.checkHealth()).rejects.toMatchObject({
22+
status: HttpStatus.SERVICE_UNAVAILABLE,
23+
response: {
24+
code: 'SERVICE_UNHEALTHY',
25+
message: expect.stringContaining(SERVICE_NAME),
26+
details: expect.arrayContaining([
27+
expect.objectContaining({
28+
status: 'down',
29+
target: SERVICE_NAME,
30+
}),
31+
]),
32+
},
3233
});
3334
});
3435

src/modules/auth/repository/session.repository.interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export interface ISessionRepository {
77
create(data: SessionInsert): Promise<SessionSelect>;
88
findById(id: string): Promise<SessionSelect | null>;
99
findAllByUserId(userId: string): Promise<SessionSelect[]>;
10-
revoke(id: string): Promise<void>;
10+
revoke(id: string): Promise<boolean>;
1111
revokeAllByUserId(userId: string, exceptSessionId?: string): Promise<void>;
1212
deleteExpired(): Promise<number>;
1313
}

src/modules/auth/repository/session.repository.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
22
import { eq, and, ne, lt, desc } from 'drizzle-orm';
33
import * as schema from '../entities';
44
import { DATABASE_SERVICE, DatabaseService } from '@libs/database';
5-
import {
6-
ISessionRepository,
7-
type SessionInsert,
8-
SessionSelect,
9-
} from './session.repository.interface';
5+
import { ISessionRepository, type SessionInsert } from './session.repository.interface';
106

117
@Injectable()
128
export class SessionRepository implements ISessionRepository {
@@ -15,12 +11,12 @@ export class SessionRepository implements ISessionRepository {
1511
private readonly db: DatabaseService<typeof schema>,
1612
) {}
1713

18-
async create(data: SessionInsert): Promise<SessionSelect> {
14+
async create(data: SessionInsert) {
1915
const [result] = await this.db.insert(schema.sessions).values(data).returning();
2016
return result;
2117
}
2218

23-
async findById(id: string): Promise<SessionSelect | null> {
19+
async findById(id: string) {
2420
const [result] = await this.db
2521
.select()
2622
.from(schema.sessions)
@@ -30,22 +26,24 @@ export class SessionRepository implements ISessionRepository {
3026
return result || null;
3127
}
3228

33-
async findAllByUserId(userId: string): Promise<SessionSelect[]> {
29+
async findAllByUserId(userId: string) {
3430
return this.db
3531
.select()
3632
.from(schema.sessions)
3733
.where(and(eq(schema.sessions.userId, userId), eq(schema.sessions.isRevoked, false)))
3834
.orderBy(desc(schema.sessions.createdAt));
3935
}
4036

41-
async revoke(id: string): Promise<void> {
42-
await this.db
37+
async revoke(id: string) {
38+
const { rowCount } = await this.db
4339
.update(schema.sessions)
4440
.set({ isRevoked: true, updatedAt: new Date() })
4541
.where(eq(schema.sessions.id, id));
42+
43+
return (rowCount ?? 0) > 0;
4644
}
4745

48-
async revokeAllByUserId(userId: string, exceptSessionId?: string): Promise<void> {
46+
async revokeAllByUserId(userId: string, exceptSessionId?: string) {
4947
const filters = [eq(schema.sessions.userId, userId)];
5048

5149
if (exceptSessionId) {
@@ -58,7 +56,7 @@ export class SessionRepository implements ISessionRepository {
5856
.where(and(...filters));
5957
}
6058

61-
async deleteExpired(): Promise<number> {
59+
async deleteExpired() {
6260
const result = await this.db
6361
.delete(schema.sessions)
6462
.where(lt(schema.sessions.expiresAt, new Date()));

src/modules/auth/services/auth.service.ts

Lines changed: 89 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
1-
import {
2-
BadRequestException,
3-
ConflictException,
4-
Inject,
5-
Injectable,
6-
UnauthorizedException,
7-
} from '@nestjs/common';
1+
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
82
import { InjectRedis } from '@nestjs-modules/ioredis';
93
import Redis from 'ioredis';
104
import { SignInDto, SignUpDto, VerifyDto } from '../dtos';
@@ -18,6 +12,7 @@ import { InjectQueue } from '@nestjs/bullmq';
1812
import { Queues, RegisterCodeEvent } from '@shared/workers';
1913
import type { Queue } from 'bullmq';
2014
import { MailJobs } from '@shared/workers/enum';
15+
import { BaseException } from '@shared/error';
2116

2217
@Injectable()
2318
export class AuthService {
@@ -39,20 +34,27 @@ export class AuthService {
3934
const cachedData = await this.redis.get(redisKey);
4035

4136
if (cachedData) {
42-
throw new BadRequestException({
43-
code: 'REGISTRATION_IN_PROGRESS',
44-
message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.',
45-
});
37+
throw new BaseException(
38+
{
39+
code: 'REGISTRATION_IN_PROGRESS',
40+
message: 'Код уже был отправлен. Проверьте почту или подождите 15 минут.',
41+
details: [{ target: 'email', message: 'Verification code already sent' }],
42+
},
43+
HttpStatus.BAD_REQUEST,
44+
);
4645
}
4746

4847
const isExists = await this.findUserCommand.execute({ email: dto.email });
4948

5049
if (isExists) {
51-
throw new ConflictException({
52-
code: 'USER_ALREADY_EXISTS',
53-
message: 'Email уже занят другим аккаунтом',
54-
details: { email: dto.email },
55-
});
50+
throw new BaseException(
51+
{
52+
code: 'USER_ALREADY_EXISTS',
53+
message: 'Email уже занят другим аккаунтом',
54+
details: [{ target: 'email', value: dto.email }],
55+
},
56+
HttpStatus.CONFLICT,
57+
);
5658
}
5759

5860
const hashPass = await argon.hash(dto.password);
@@ -95,10 +97,13 @@ export class AuthService {
9597
const cachedData = await this.redis.get(redisKey);
9698

9799
if (!cachedData) {
98-
throw new BadRequestException({
99-
code: 'REGISTRATION_EXPIRED',
100-
message: 'Срок регистрации истек или email не найден. Попробуйте снова.',
101-
});
100+
throw new BaseException(
101+
{
102+
code: 'REGISTRATION_EXPIRED',
103+
message: 'Срок регистрации истек или email не найден. Попробуйте снова.',
104+
},
105+
HttpStatus.GONE,
106+
);
102107
}
103108

104109
const userData = JSON.parse(cachedData);
@@ -114,10 +119,14 @@ export class AuthService {
114119
});
115120

116121
if (!verifyResult.valid) {
117-
throw new BadRequestException({
118-
code: 'INVALID_OTP',
119-
message: 'Неверный или истекший код подтверждения',
120-
});
122+
throw new BaseException(
123+
{
124+
code: 'INVALID_OTP',
125+
message: 'Неверный или истекший код подтверждения',
126+
details: [{ target: 'code', message: 'OTP code is invalid or expired' }],
127+
},
128+
HttpStatus.BAD_REQUEST,
129+
);
121130
}
122131

123132
const user = await this.createUserCommand.execute({
@@ -145,19 +154,25 @@ export class AuthService {
145154
const { user, security } = await this.findUserCommand.execute({ email: dto.email });
146155

147156
if (!user || !security) {
148-
throw new UnauthorizedException({
149-
code: 'INVALID_CREDENTIALS',
150-
message: 'Неверный email или пароль',
151-
});
157+
throw new BaseException(
158+
{
159+
code: 'INVALID_CREDENTIALS',
160+
message: 'Неверный email или пароль',
161+
},
162+
HttpStatus.UNAUTHORIZED,
163+
);
152164
}
153165

154166
const isPasswordValid = await argon.verify(security.passwordHash, dto.password);
155167

156168
if (!isPasswordValid) {
157-
throw new UnauthorizedException({
158-
code: 'INVALID_CREDENTIALS',
159-
message: 'Неверный email или пароль',
160-
});
169+
throw new BaseException(
170+
{
171+
code: 'INVALID_CREDENTIALS',
172+
message: 'Неверный email или пароль',
173+
},
174+
HttpStatus.UNAUTHORIZED,
175+
);
161176
}
162177

163178
const { id } = await this.sessionRepo.create({
@@ -181,30 +196,39 @@ export class AuthService {
181196
public refresh = async (token: string, metadata: DeviceMetadata) => {
182197
const payload = await this.tokenService.validateToken(token, 'refresh');
183198

184-
if (!payload || !payload.jti) {
185-
throw new UnauthorizedException({
186-
code: 'INVALID_TOKEN',
187-
message: 'Сессия недействительна или истекла',
188-
});
199+
if (!payload?.jti) {
200+
throw new BaseException(
201+
{
202+
code: 'INVALID_TOKEN',
203+
message: 'Сессия недействительна или истекла',
204+
},
205+
HttpStatus.UNAUTHORIZED,
206+
);
189207
}
190208

191209
const session = await this.sessionRepo.findById(payload.jti);
192210

193211
if (!session || session.isRevoked) {
194-
throw new UnauthorizedException({
195-
code: 'SESSION_REVOKED',
196-
message: 'Ваша сессия была отозвана или завершена',
197-
});
212+
throw new BaseException(
213+
{
214+
code: 'SESSION_REVOKED',
215+
message: 'Ваша сессия была отозвана или завершена',
216+
},
217+
HttpStatus.UNAUTHORIZED,
218+
);
198219
}
199220

200221
const { user } = await this.findUserCommand.execute({ id: session.userId });
201222

202223
if (!user) {
203224
await this.sessionRepo.revoke(session.id);
204-
throw new UnauthorizedException({
205-
code: 'USER_NOT_FOUND',
206-
message: 'Аккаунт пользователя не найден',
207-
});
225+
throw new BaseException(
226+
{
227+
code: 'USER_NOT_FOUND',
228+
message: 'Аккаунт пользователя не найден',
229+
},
230+
HttpStatus.UNAUTHORIZED,
231+
);
208232
}
209233

210234
await this.sessionRepo.revoke(session.id);
@@ -228,20 +252,32 @@ export class AuthService {
228252
const payload = await this.tokenService.validateToken(token, 'refresh');
229253

230254
if (!payload?.jti) {
231-
throw new UnauthorizedException({ code: 'SESSION_EXPIRED', message: 'Сессия истекла' });
255+
throw new BaseException(
256+
{
257+
code: 'SESSION_EXPIRED',
258+
message: 'Сессия уже истекла',
259+
},
260+
HttpStatus.UNAUTHORIZED,
261+
);
232262
}
233263

234264
const session = await this.sessionRepo.findById(payload.jti);
235265

236-
if (!session) {
237-
throw new UnauthorizedException({
238-
code: 'SESSION_NOT_FOUND',
239-
message: 'Сессия не найдена',
240-
});
266+
if (session) {
267+
const isRevoked = await this.sessionRepo.revoke(session.id);
268+
269+
if (!isRevoked) {
270+
throw new BaseException(
271+
{
272+
code: 'SIGNOUT_FAILED',
273+
message: 'Не удалось завершить сессию на сервере. Попробуйте позже.',
274+
details: [{ target: 'database', message: 'Session revocation failed' }],
275+
},
276+
HttpStatus.SERVICE_UNAVAILABLE,
277+
);
278+
}
241279
}
242280

243-
await this.sessionRepo.revoke(session.id);
244-
245281
return { success: true, message: 'Успешно вышли из системы!' };
246282
};
247283
}

0 commit comments

Comments
 (0)