Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/CI-workflow.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ jobs:
JWT_SECRET: test
JWT_EXPIRES_IN: 7d
NODE_ENV: test
CORS_ALLOWED_ORIGINS: http://localhost:3000

steps:
- name: Checkout
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ PORT=3005
DATABASE_URL="postgresql://postgres:root@localhost:5432/task-tracker"
JWT_SECRET=jwt-secret
JWT_EXPIRES_IN=7d
NODE_ENV=dev
NODE_ENV=dev
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://my-jira-clone.com
3 changes: 2 additions & 1 deletion apps/backend/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ PORT=3001
DATABASE_URL="postgresql://test:test@localhost:5432/test"
JWT_SECRET=test-secret
JWT_EXPIRES_IN=7d
NODE_ENV=test
NODE_ENV=test
CORS_ALLOWED_ORIGINS=http://localhost:3000
18 changes: 18 additions & 0 deletions apps/backend/nest-cli.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"classValidatorShim": true,
"introspectComments": true,
"dtoKeyOfComment": "description"
}
}
]
}
}
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"socket.io": "^4.8.3",
"zod": "^4.3.6"
},
Expand Down
19 changes: 19 additions & 0 deletions apps/backend/src/config/cors.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { FastifyCorsOptions } from '@fastify/cors';
import { env } from '../env';

export const corsConfig: FastifyCorsOptions = {
origin: (origin, callback) => {
if (!origin) {
return callback(null, true);
}

if (env.CORS_ALLOWED_ORIGINS.includes(origin)) {
return callback(null, true);
}

callback(new Error(`CORS Policy: Origin ${origin} not allowed`), false);
},
credentials: true,
methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
};
22 changes: 22 additions & 0 deletions apps/backend/src/config/swagger.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { ApiResponse, ApiResponsePaginated } from '../shared/dto';
import { INestApplication } from '@nestjs/common';

export function setupSwagger(app: INestApplication): void {
const config = new DocumentBuilder()
.setTitle('Task Tracker API')
.setDescription('API documentation for Task Tracker')
.setVersion('1.0')
.addCookieAuth('access_token', {
type: 'apiKey',
in: 'cookie',
description: 'JWT token in HttpOnly cookie',
})
.build();

const document = SwaggerModule.createDocument(app, config, {
extraModels: [ApiResponse, ApiResponsePaginated],
});

SwaggerModule.setup('doc', app, document);
}
12 changes: 12 additions & 0 deletions apps/backend/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ const envSchema = z.object({
JWT_SECRET: z.string(),
JWT_EXPIRES_IN: z.custom<JwtExpires>(),
NODE_ENV: z.enum(NodeEnv),
CORS_ALLOWED_ORIGINS: z
.string()
.min(1, "CORS_ALLOWED_ORIGINS can't be empty")
.transform((val) => val.split(','))
.pipe(
z.array(
z.url({ error: 'Origin must be valid URL' }).refine((val) => {
const url = new URL(val);
return url.origin === val;
}, 'Invalid CORS origin')
)
),
});

export const env = envSchema.parse(process.env);
Expand Down
20 changes: 6 additions & 14 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import fastifyCompress from '@fastify/compress';
import fastifyCors from '@fastify/cors';
import fastifyCookie from '@fastify/cookie';
import { env } from './env';
import { TransformResponseInterceptor } from './shared/interceptors';
import { corsConfig } from './config/cors.config';
import { setupSwagger } from './config/swagger.config';

async function bootstrap() {
const PORT = Number(env.PORT);
if (Number.isNaN(PORT)) {
throw new Error('Не задан порт в .env');
}

const config = new DocumentBuilder().setTitle('API').setDescription('Tracker API').build();

const adapter = new FastifyAdapter();

const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, {
rawBody: true,
});
Expand All @@ -29,14 +28,7 @@ async function bootstrap() {
threshold: 1024,
});

await app
.getHttpAdapter()
.getInstance()
.register(fastifyCors, {
origin: '*',
methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
});
await app.getHttpAdapter().getInstance().register(fastifyCors, corsConfig);

app.useGlobalPipes(
new ValidationPipe({
Expand All @@ -47,9 +39,9 @@ async function bootstrap() {
app.setGlobalPrefix('api/v1', {
exclude: ['/'],
});
app.useGlobalInterceptors(new TransformResponseInterceptor());

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('doc', app, document);
setupSwagger(app);

await app.listen(PORT, '0.0.0.0', () =>
console.log(`\x1b[34mServer started on port = ${PORT}\x1b[0m`)
Expand Down
13 changes: 9 additions & 4 deletions apps/backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import { Body, Controller, HttpCode, Post, Res } from '@nestjs/common';
import { AuthService } from './auth.service';
import { ApiResponse } from '../../shared/types';
import { PrivateUserDto } from '../user/dto/user.dto';
import { CreateUserDto } from '../user/dto/create-user.dto';
import { isProd } from '../../env';
import { type FastifyReply } from 'fastify';
import { LoginRequestDto } from './dto/login.dto';
import { ApiOkResponseDto } from '../../shared/decorators';
import { ApiResponseHttpCodes } from '../../shared/decorators/api-response-http-codes.decorator';

@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Post('login')
@HttpCode(200)
@ApiOkResponseDto(PrivateUserDto)
@ApiResponseHttpCodes(401, 400)
async login(
@Body() loginUserDto: LoginRequestDto,
@Res({ passthrough: true }) res: FastifyReply
): Promise<ApiResponse<PrivateUserDto>> {
): Promise<PrivateUserDto> {
const { accessToken, user } = await this.authService.login(loginUserDto);

res.setCookie('access_token', accessToken, {
Expand All @@ -26,16 +29,18 @@ export class AuthController {
path: '/',
});

return { data: user };
return user;
}

@Post('logout')
@HttpCode(204)
async logout(@Res({ passthrough: true }) res: FastifyReply) {
@ApiResponseHttpCodes()
async logout(@Res({ passthrough: true }) res: FastifyReply): Promise<void> {
res.clearCookie('access_token', { path: '/' });
}

@Post('registration')
@ApiResponseHttpCodes(409, 400)
async registration(@Body() createUserDto: CreateUserDto): Promise<void> {
await this.authService.registration(createUserDto);
}
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/src/modules/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { IsEmail, IsString } from 'class-validator';
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class LoginRequestDto {
@ApiProperty({ example: 'test@mail.com' })
@IsEmail()
email: string;

@ApiProperty({ example: '123456' })
@IsString()
@MinLength(6)
password: string;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';

export class PaginatedListExampleType {
@ApiProperty({ example: '1' })
id: string;

@ApiProperty({ example: 'Ivan' })
name: string;
}
4 changes: 4 additions & 0 deletions apps/backend/src/modules/user/dto/create-user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { IsEmail, IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
@ApiProperty({ example: 'Ivan' })
@IsString()
name: string;

@ApiProperty({ example: 'test@mail.com' })
@IsEmail()
email: string;

@ApiProperty({ example: '123456' })
@IsString()
password: string;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { IsString } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class UpdateAuthUserPasswordDto {
@ApiProperty({ example: '123456' })
@IsString()
oldPassword: string;

@ApiProperty({ example: '654321' })
@IsString()
newPassword: string;
}
11 changes: 11 additions & 0 deletions apps/backend/src/modules/user/dto/user.dto.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import type { UserRole } from '@prisma/client';
import { PrivateUserPayload, PublicUserPayload } from '../selectors/user.selectors';
import { ApiProperty } from '@nestjs/swagger';

export class PrivateUserDto implements PrivateUserPayload {
@ApiProperty({ example: 'cmnc4mk5s0000uu54csfyvooh' })
id: string;

@ApiProperty({ example: 'Ivan' })
name: string;

@ApiProperty({ example: 'test@mail.com' })
email: string;

@ApiProperty({ example: 'USER' })
role: UserRole;
}

export class PublicUserDto implements PublicUserPayload {
@ApiProperty({ example: 'cmnc4mk5s0000uu54csfyvooh' })
id: string;

@ApiProperty({ example: 'Ivan' })
name: string;
}
51 changes: 37 additions & 14 deletions apps/backend/src/modules/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import { Controller, Get, Body, Patch, Param, Delete, HttpCode, UseGuards } from '@nestjs/common';
import { UserService } from './user.service';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiResponse } from '../../shared/types';
import type { CurrentUserType } from '../auth/types/jwt-payload.type';
import { ParseCuidPipe } from '../../shared/pipes';
import { JwtAuthGuard } from '../../shared/guards';
import { CurrentUser } from '../../shared/decorators';
import { ApiOkResponseDto, ApiOkResponsePaginatedDto, CurrentUser } from '../../shared/decorators';
import { PrivateUserDto, PublicUserDto } from './dto/user.dto';
import type { UpdateAuthUserPasswordDto } from './dto/update-auth-user-password.dto';
import { UpdateAuthUserPasswordDto } from './dto/update-auth-user-password.dto';
import { ApiResponseHttpCodes } from '../../shared/decorators/api-response-http-codes.decorator';
import { PaginatedListExampleType } from './dto/PaginatedListExampleType.dto';
import { PaginatedResult } from '../../shared/types';

@Controller('users')
export class UserController {
constructor(private readonly userService: UserService) {}

@UseGuards(JwtAuthGuard)
@Get('me')
async me(@CurrentUser() user: CurrentUserType): Promise<ApiResponse<PrivateUserDto>> {
const userData = await this.userService.me(user.id);

return { data: userData };
@ApiOkResponseDto(PrivateUserDto)
@ApiResponseHttpCodes(401)
async me(@CurrentUser() user: CurrentUserType): Promise<PrivateUserDto> {
return this.userService.me(user.id);
}

@UseGuards(JwtAuthGuard)
@HttpCode(204)
@Patch('me/password')
@ApiResponseHttpCodes(404, 400, 401)
async updatePassword(
@Body() updatePasswordDto: UpdateAuthUserPasswordDto,
@CurrentUser() user: CurrentUserType
Expand All @@ -33,25 +36,45 @@ export class UserController {

@UseGuards(JwtAuthGuard)
@Get(':id')
async findById(@Param('id', ParseCuidPipe) id: string): Promise<ApiResponse<PublicUserDto>> {
const user = await this.userService.findPublicById(id);
return { data: user };
@ApiOkResponseDto(PublicUserDto)
@ApiResponseHttpCodes(404, 401, 400)
async findById(@Param('id', ParseCuidPipe) id: string): Promise<PublicUserDto> {
return this.userService.findPublicById(id);
}

@UseGuards(JwtAuthGuard)
@Patch('/me')
@ApiOkResponseDto(PrivateUserDto)
@ApiResponseHttpCodes(401, 404, 400)
async update(
@Body() updateUserDto: UpdateUserDto,
@CurrentUser() user: CurrentUserType
): Promise<ApiResponse<PrivateUserDto>> {
const updatedUser = await this.userService.update(user.id, updateUserDto);
return { data: updatedUser };
): Promise<PrivateUserDto> {
return this.userService.update(user.id, updateUserDto);
}

@UseGuards(JwtAuthGuard)
@Delete('/me')
@HttpCode(204)
async remove(@CurrentUser() user: CurrentUserType) {
@ApiResponseHttpCodes(401, 404)
async remove(@CurrentUser() user: CurrentUserType): Promise<void> {
await this.userService.remove(user.id);
}

// Example for paginated list response
@Get('/PaginatedListExample')
@ApiOkResponsePaginatedDto(PaginatedListExampleType)
async PaginatedListExample(): Promise<PaginatedResult<PaginatedListExampleType>> {
const lst = [
{ id: '1', name: 'test1' },
{ id: '2', name: 'test2' },
];

return {
items: lst,
total: 2,
page: 1,
limit: 10,
};
}
}
Loading
Loading