refactor: organize backend by business responsibility

This commit is contained in:
2026-09-13 13:52:57 +03:00
parent 7fde3ece6e
commit 8ba005346d
145 changed files with 3027 additions and 2346 deletions
@@ -0,0 +1,21 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {},
}));
import { AuthController } from './auth.controller';
describe('AuthController response statuses', () => {
it.each(['register', 'login', 'refresh'] as const)(
'%s explicitly returns HTTP 200',
(handler) => {
// Nest stores @HttpCode metadata on the unbound handler function.
const handlerTarget = Object.getOwnPropertyDescriptor(
AuthController.prototype,
handler,
)?.value as object;
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
},
);
});
@@ -0,0 +1,143 @@
import {
Body,
Controller,
Get,
HttpCode,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiNoContentResponse,
ApiOkResponse,
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto';
import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types';
import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token';
const COOKIE_PATH = '/api/v1/auth';
class UserDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare email: string;
}
class AuthenticationDto {
@ApiProperty()
declare accessToken: string;
@ApiProperty({ type: UserDto })
declare user: UserDto;
}
@ApiTags('auth')
@ApiProblemResponses()
@Controller('auth')
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post('register')
@HttpCode(200)
@ApiOkResponse({ type: AuthenticationDto })
async register(
@Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response,
) {
return this.respond(
response,
await this.auth.register(credentials.email, credentials.password),
);
}
@Post('login')
@HttpCode(200)
@ApiOkResponse({ type: AuthenticationDto })
async login(
@Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response,
) {
return this.respond(
response,
await this.auth.login(credentials.email, credentials.password),
);
}
@Post('refresh')
@HttpCode(200)
@ApiOkResponse({ type: AuthenticationDto })
async refresh(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
try {
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
this.clearCookie(response);
throw error;
}
}
@Post('logout')
@HttpCode(204)
@ApiNoContentResponse()
async logout(
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
): Promise<void> {
await this.auth.logout(this.refreshToken(request));
this.clearCookie(response);
}
@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOkResponse({ type: UserDto })
async me(@Req() request: Request & { user: AccessPayload }) {
const user = await this.auth.userById(request.user.sub);
return this.auth.toUser(user);
}
private respond(
response: Response,
result: {
user: { id: string; email: string };
accessToken: string;
refreshToken: string;
},
) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken };
}
private clearCookie(response: Response): void {
response.clearCookie(REFRESH_COOKIE, this.cookieOptions());
}
private refreshToken(request: Request): string | undefined {
const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined;
}
private cookieOptions() {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH,
};
}
}
+57
View File
@@ -0,0 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import {
IsString,
MinLength,
Validate,
ValidatorConstraint,
} from 'class-validator';
@ValidatorConstraint({ name: 'email' })
class EmailConstraint {
validate(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length <= 254 &&
value.split('@').length === 2 &&
!value
.split('')
.some(
(character) =>
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127,
) &&
value.split('@').every(Boolean)
);
}
defaultMessage(): string {
return 'must be a valid email address';
}
}
@ValidatorConstraint({ name: 'passwordBytes' })
class PasswordBytesConstraint {
validate(value: unknown): boolean {
return typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= 72;
}
defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes';
}
}
export class CredentialsDto {
@ApiProperty({ example: 'user@example.com' })
@IsString()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
)
@Validate(EmailConstraint)
declare email: string;
@ApiProperty({ minLength: 12, maxLength: 72, example: 'a secure password' })
@IsString()
@MinLength(12)
@Validate(PasswordBytesConstraint)
declare password: string;
}
@@ -0,0 +1,21 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const allowed = await super.canActivate(context);
const request = context
.switchToHttp()
.getRequest<{ user?: AccessPayload }>();
if (request.user?.sub)
this.requestContext.set({ userId: request.user.sub });
return Boolean(allowed);
}
}