feat(backend): add expenses

This commit is contained in:
Sergey Krylov 2025-08-28 05:05:15 +03:00
parent 2c1a8ea127
commit d60aae26e6
52 changed files with 1242 additions and 106 deletions

View File

@ -14,6 +14,63 @@ export default [
'@typescript-eslint/parameter-properties': 'off', '@typescript-eslint/parameter-properties': 'off',
'@typescript-eslint/class-methods-use-this': 'off', '@typescript-eslint/class-methods-use-this': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off', '@typescript-eslint/strict-boolean-expressions': 'off',
'import/order': [
'error', {
pathGroups: [
{
pattern: '__spec__/**',
group: 'builtin',
position: 'before',
},
{
pattern: 'apps/**',
group: 'internal',
position: 'after',
},
{
pattern: 'pages/**',
group: 'internal',
position: 'after',
},
{
pattern: 'widgets/**',
group: 'internal',
position: 'after',
},
{
pattern: 'features/**',
group: 'internal',
position: 'after',
},
{
pattern: 'entities/**',
group: 'internal',
position: 'after',
},
{
pattern: 'shared/**',
group: 'internal',
position: 'after',
},
],
distinctGroup: true,
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'object',
'index',
'type',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
}, },
}, },
]; ];

View File

@ -14,6 +14,46 @@ model Account {
password String password String
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
userId String @unique
user User @relation(fields: [userId], references: [id])
@@map("accounts") @@map("accounts")
} }
model User {
id String @id @default(uuid())
account Account?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
expensesLists ExpenseList[]
@@map("users")
}
model ExpenseList {
id String @id @default(uuid())
items ExpenseItem[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
title String
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("expenses_lists")
}
model ExpenseItem {
id String @id @default(uuid())
expenseList ExpenseList @relation(fields: [expenseListId], references: [id], onDelete: Cascade)
expenseListId String
title String
description String?
date DateTime
amount Float
currency String @default("RUB")
count Float @default(1)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("expenses_items")
}

View File

@ -1,14 +1,17 @@
import * as process from 'node:process';
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as process from 'node:process';
import * as cookieParser from 'cookie-parser'; import * as cookieParser from 'cookie-parser';
import { AppModule } from './modules/app.module'; import { AppModule } from './modules/app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const config = new DocumentBuilder() const config = new DocumentBuilder()
.addBearerAuth()
.setTitle('Investments Control API') .setTitle('Investments Control API')
.setDescription('Documentation for **Investments Control API**') .setDescription('Documentation for **Investments Control API**')
.setVersion(process.env.npm_package_version ?? '0.0.0') .setVersion(process.env.npm_package_version ?? '0.0.0')

View File

@ -1,7 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { ExpensesModule } from './expenses/expenses.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({ @Module({
imports: [ imports: [
@ -10,6 +12,7 @@ import { AuthModule } from './auth/auth.module';
}), }),
PrismaModule, PrismaModule,
AuthModule, AuthModule,
ExpensesModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@ -1,69 +1,40 @@
import { import {
Body, Body,
Controller, Get, HttpCode, HttpStatus, Controller,
Post, Req, Res, Get,
Post,
Req,
Res,
} from '@nestjs/common'; } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiConflictResponse, ApiNotFoundResponse,
ApiOkResponse,
ApiOperation, ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
import type { Response } from 'express';
import { Authorized } from '@/shared/decorators';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { Authorization } from './decorators/authorization.decorator';
import { Authorized } from './decorators/authorized.decorator';
import { import {
LoginDto, Login,
LoginValidationDto, Logout,
IncorrectLoginDto, Me,
SuccessLoginDto, Refresh,
} from './dto/login'; Register,
import { CurrentProfileDto } from './dto/profile'; } from './decorators';
import { import { LoginDto, RegisterDto } from './dto';
RegisterValidationDto,
ConflictRegisterDto, import type { Request } from '@/shared/types';
RegisterDto, import type { Response } from 'express';
} from './dto/register';
import { Request } from './types/request';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
constructor(private readonly authService: AuthService) {} constructor(private readonly authService: AuthService) {}
@ApiOperation({
summary: 'Регистрация',
description: 'Регистрация аккаунта',
})
@ApiOkResponse({ description: 'Успешная регистрация', type: RegisterDto })
@ApiBadRequestResponse({
description: 'Ошибка валидации',
type: RegisterValidationDto,
})
@ApiConflictResponse({
description: 'Ошибка создания аккаунта',
type: ConflictRegisterDto,
})
@Post('register') @Post('register')
@Register()
public async register(@Body() body: RegisterDto) { public async register(@Body() body: RegisterDto) {
return this.authService.register(body); return this.authService.register(body);
} }
@ApiOperation({
summary: 'Логин',
description: 'Вход в аккаунт',
})
@ApiOkResponse({ description: 'Успешный вход в аккаунт', type: SuccessLoginDto })
@ApiBadRequestResponse({
description: 'Неверный логин или пароль',
type: IncorrectLoginDto,
})
@ApiBadRequestResponse({
description: 'Ошибка валидации',
type: LoginValidationDto,
})
@Post('login') @Post('login')
@Login()
public async login( public async login(
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
@Body() body: LoginDto, @Body() body: LoginDto,
@ -71,35 +42,20 @@ export class AuthController {
return this.authService.login(response, body); return this.authService.login(response, body);
} }
@ApiOperation({
summary: 'Логаут',
description: 'Выход из аккаунта',
})
@ApiOkResponse({ description: 'Успешный выход из аккаунт', type: Boolean })
@Authorization()
@Post('logout') @Post('logout')
@Logout()
public logout(@Res({ passthrough: true }) response: Response) { public logout(@Res({ passthrough: true }) response: Response) {
return this.authService.logout(response); return this.authService.logout(response);
} }
@ApiOperation({
summary: 'Получить профиль',
description: 'Получить данные о своем профиле',
})
@ApiOkResponse({ description: 'Успешное обновление токенов', type: CurrentProfileDto })
@ApiUnauthorizedResponse({ description: 'Пользователь не авторизован' })
@Authorization()
@Get('me') @Get('me')
@HttpCode(HttpStatus.OK) @Me()
public me(@Authorized('id') user: Account['id']) { public profile(@Authorized('id') user: Account['id']) {
return { id: user }; return { id: user };
} }
@ApiOperation({ summary: 'Обновление access и refresh токенов' })
@ApiOkResponse({ description: 'Успешное обновление токенов', type: SuccessLoginDto })
@ApiUnauthorizedResponse({ description: 'Пользователь не авторизован' })
@ApiNotFoundResponse({ description: '' })
@Post('refresh') @Post('refresh')
@Refresh()
public async refresh( public async refresh(
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,

View File

@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { getJwtConfig } from '../../configs/jwt.config'; import { getJwtConfig } from '../../configs/jwt.config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategies'; import { JwtStrategy } from './strategies/jwt.strategies';
@Module({ @Module({

View File

@ -1,16 +1,22 @@
import { import {
BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException, BadRequestException,
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common'; } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Account } from '@prisma/client'; import { Account } from '@prisma/client';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import type { Response } from 'express';
import { JwtPayload, Request } from '@/shared/types';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { LoginDto, SuccessLoginDto } from './dto/login';
import { RegisterDto } from './dto/register'; import { LoginDto, RegisterDto, SuccessLoginDto } from './dto';
import { JwtPayload } from './types/jwt-payload';
import { Request } from './types/request'; import type { Response } from 'express';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@ -36,43 +42,46 @@ export class AuthService {
const salt = await bcrypt.genSalt(); const salt = await bcrypt.genSalt();
const hashedPassword = await bcrypt.hash(password, salt); const hashedPassword = await bcrypt.hash(password, salt);
const existingUser = await this.prismaService.account.findUnique({ const existingAccount = await this.prismaService.account.findUnique({
where: { email }, where: { email },
}); });
if (existingUser) { if (existingAccount) {
throw new ConflictException('Пользователь с такой почтой уже зарегистрирован'); throw new ConflictException('Пользователь с такой почтой уже зарегистрирован');
} }
const user = await this.prismaService.account.create({ const account = await this.prismaService.account.create({
data: { data: {
email, email,
password: hashedPassword, password: hashedPassword,
user: {
create: {},
},
}, },
}); });
return { return {
id: user.id, id: account.id,
email: user.email, email: account.email,
}; };
} }
public async login(response: Response, dto: LoginDto): Promise<SuccessLoginDto> { public async login(response: Response, dto: LoginDto): Promise<SuccessLoginDto> {
const { email, password } = dto; const { email, password } = dto;
const existingUser = await this.prismaService.account.findUnique({ const existingAccount = await this.prismaService.account.findUnique({
where: { email }, where: { email },
}); });
if (!existingUser) { if (!existingAccount) {
throw new BadRequestException('Неверный логин или пароль'); throw new BadRequestException('Неверный логин или пароль');
} }
const isMatch = await bcrypt.compare(password, existingUser.password); const isMatch = await bcrypt.compare(password, existingAccount.password);
if (!isMatch) { if (!isMatch) {
throw new BadRequestException('Неверный логин или пароль'); throw new BadRequestException('Неверный логин или пароль');
} }
const { accessToken } = this.auth(response, existingUser.id); const { accessToken } = this.auth(response, existingAccount.id);
return { return {
accessToken, accessToken,
@ -86,17 +95,17 @@ export class AuthService {
} }
public async validate(id: string) { public async validate(id: string) {
const existingUser = await this.prismaService.account.findUnique({ const existingAccount = await this.prismaService.account.findUnique({
where: { where: {
id, id,
}, },
}); });
if (!existingUser) { if (!existingAccount) {
throw new NotFoundException(); throw new NotFoundException();
} }
return existingUser; return existingAccount;
} }
public async refresh(req: Request, res: Response): Promise<SuccessLoginDto> { public async refresh(req: Request, res: Response): Promise<SuccessLoginDto> {
@ -107,15 +116,15 @@ export class AuthService {
} }
const payload: JwtPayload = await this.jwtService.verifyAsync(refreshToken); const payload: JwtPayload = await this.jwtService.verifyAsync(refreshToken);
const existingUser = await this.prismaService.account.findUnique({ const existingAccount = await this.prismaService.account.findUnique({
where: { id: payload.id }, where: { id: payload.id },
}); });
if (!existingUser) { if (!existingAccount) {
throw new NotFoundException('Пользователь не нейден'); throw new NotFoundException('Пользователь не нейден');
} }
return this.auth(res, existingUser.id); return this.auth(res, existingAccount.id);
} }
private auth(response: Response, id: Account['id']) { private auth(response: Response, id: Account['id']) {

View File

@ -0,0 +1,5 @@
export { Register } from './register.decorator';
export { Login } from './login.decorator';
export { Logout } from './logout.decorator';
export { Me } from './me.decorator';
export { Refresh } from './refresh.decorator';

View File

@ -0,0 +1,22 @@
import { applyDecorators } from '@nestjs/common';
import { ApiBadRequestResponse, ApiOkResponse, ApiOperation } from '@nestjs/swagger';
import { IncorrectLoginDto, LoginValidationDto, SuccessLoginDto } from '../dto';
export function Login() {
return applyDecorators(
ApiOperation({
summary: 'Логин',
description: 'Вход в аккаунт',
}),
ApiOkResponse({ description: 'Успешный вход в аккаунт', type: SuccessLoginDto }),
ApiBadRequestResponse({
description: 'Неверный логин или пароль',
type: IncorrectLoginDto,
}),
ApiBadRequestResponse({
description: 'Ошибка валидации',
type: LoginValidationDto,
}),
);
}

View File

@ -0,0 +1,21 @@
import { applyDecorators } from '@nestjs/common';
import { ApiOkResponse, ApiOperation } from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Логаут',
description: 'Выход из аккаунта',
}),
ApiOkResponse({ description: 'Успешный выход из аккаунт', type: Boolean }),
);
}
export function Logout() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,23 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { CurrentProfileDto } from '@/modules/auth/dto';
import { Authorization } from '@/shared/decorators';
export function Me() {
return applyDecorators(
Authorization(),
ApiOperation({
summary: 'Получить профиль',
description: 'Получить данные о своем профиле',
}),
ApiBearerAuth(),
ApiOkResponse({ description: 'Успешное обновление токенов', type: CurrentProfileDto }),
ApiUnauthorizedResponse({ description: 'Пользователь не авторизован' }),
);
}

View File

@ -0,0 +1,18 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { SuccessLoginDto } from '../dto';
export function Refresh() {
return applyDecorators(
ApiOperation({ summary: 'Обновление access и refresh токенов' }),
ApiOkResponse({ description: 'Успешное обновление токенов', type: SuccessLoginDto }),
ApiUnauthorizedResponse({ description: 'Пользователь не авторизован' }),
ApiNotFoundResponse({ description: '' }),
);
}

View File

@ -0,0 +1,27 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiOkResponse,
ApiOperation,
} from '@nestjs/swagger';
import { ConflictRegisterDto, RegisterDto, RegisterValidationDto } from '../dto';
export function Register() {
return applyDecorators(
ApiOperation({
summary: 'Регистрация',
description: 'Регистрация аккаунта',
}),
ApiOkResponse({ description: 'Успешная регистрация', type: RegisterDto }),
ApiBadRequestResponse({
description: 'Ошибка валидации',
type: RegisterValidationDto,
}),
ApiConflictResponse({
description: 'Ошибка создания аккаунта',
type: ConflictRegisterDto,
}),
);
}

View File

@ -0,0 +1,10 @@
export { IncorrectLoginDto } from './login/incorrect-login.dto';
export { LoginValidationDto } from './login/login-validation.dto';
export { LoginDto } from './login/login.dto';
export { SuccessLoginDto } from './login/success-login.dto';
export { CurrentProfileDto } from './profile/current-profile.dto';
export { ConflictRegisterDto } from './register/conflict-register.dto';
export { RegisterValidationDto } from './register/register-validation.dto';
export { RegisterDto } from './register/register.dto';

View File

@ -1,4 +0,0 @@
export { IncorrectLoginDto } from './incorrect-login.dto';
export { LoginValidationDto } from './login-validation.dto';
export { LoginDto } from './login.dto';
export { SuccessLoginDto } from './success-login.dto';

View File

@ -1 +0,0 @@
export { CurrentProfileDto } from './current-profile.dto';

View File

@ -1,3 +0,0 @@
export { ConflictRegisterDto } from './conflict-register.dto';
export { RegisterValidationDto } from './register-validation.dto';
export { RegisterDto } from './register.dto';

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthService } from '../auth.service'; import { AuthService } from '../auth.service';
type JwtPayload = { type JwtPayload = {

View File

@ -0,0 +1,30 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListItemDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Создать элемент расхода',
description: 'Создать элемент расхода',
}),
ApiBearerAuth(),
ApiParam({ name: 'id', type: String, description: 'ID листа расходов' }),
ApiOkResponse({ description: 'Успешно создан элемент расхода', type: ExpenseListItemDto }),
);
}
export function CreateExpenseListItem() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,33 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListItemDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Удалить элемент расхода',
description: 'Удалить элемент расхода',
}),
ApiBearerAuth(),
ApiParam({ name: 'listId', type: String, description: 'ID листа расходов' }),
ApiParam({ name: 'id', type: String, description: 'ID элемента' }),
ApiNotFoundResponse({ description: 'Лист расходов не найден' }),
ApiNotFoundResponse({ description: 'Позиция расходов не найдена' }),
ApiOkResponse({ description: 'Успешно удален элемент расхода', type: ExpenseListItemDto }),
);
}
export function DeleteExpenseListItem() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,33 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListItemDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Редактировать элемент расхода',
description: 'Редактировать элемент расхода',
}),
ApiBearerAuth(),
ApiParam({ name: 'listId', type: String, description: 'ID листа расходов' }),
ApiParam({ name: 'id', type: String, description: 'ID элемента' }),
ApiOkResponse({ description: 'Успешно изменен элемент расхода', type: ExpenseListItemDto }),
ApiNotFoundResponse({ description: 'Лист расходов не найден' }),
ApiNotFoundResponse({ description: 'Позиция расходов не найдена' }),
);
}
export function EditExpenseListItem() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,34 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListItemDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Найти элемент расхода',
description: 'Найти элемент расхода',
}),
ApiBearerAuth(),
ApiParam({ name: 'listId', type: String, description: 'ID листа расходов' }),
ApiParam({ name: 'id', type: String, description: 'ID элемента' }),
ApiNotFoundResponse({ description: 'Лист расходов не найден' }),
ApiNotFoundResponse({ description: 'Позиция расходов не найдена' }),
ApiOkResponse({ description: 'Успешно найден элемент расхода', type: ExpenseListItemDto }),
);
}
export function FindExpenseListItem() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,24 @@
import { applyDecorators } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation } from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Создать лист расходов',
description: 'Создать лист расходов',
}),
ApiBearerAuth(),
ApiOkResponse({ description: 'Успешно создан лист расходов', type: ExpenseListDto }),
);
}
export function CreateExpenseList() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,32 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { DeletedExpenseListDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Удалить лист расходов',
description: 'Удалить лист расходов',
}),
ApiBearerAuth(),
ApiParam({ name: 'id', type: String, description: 'ID листа расходов' }),
ApiNotFoundResponse({ description: 'Лист расходов не найден' }),
ApiOkResponse({ description: 'Успешно удален лист расходов', type: DeletedExpenseListDto }),
);
}
export function DeleteExpenseList() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,32 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiOperation({
summary: 'Редактировать лист расходов',
description: 'Редактировать лист расходов',
}),
ApiBearerAuth(),
ApiParam({ name: 'id', type: String, description: 'ID листа расходов' }),
ApiNotFoundResponse({ description: 'Лист расходов не найден' }),
ApiOkResponse({ description: 'Успешно изменен лист расходов', type: ExpenseListDto }),
);
}
export function EditExpenseList() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,28 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOkResponse,
ApiOperation,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiBearerAuth(),
ApiOperation({
summary: 'Найти все листы расходов',
description: 'Найти все листы расходов',
}),
ApiOkResponse({ description: 'Успешно создан лист расходов', type: [ExpenseListDto] }),
);
}
export function FindAllExpenseLists() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,32 @@
import { applyDecorators } from '@nestjs/common';
import {
ApiBearerAuth,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiParam,
} from '@nestjs/swagger';
import { Authorization } from '@/shared/decorators';
import { ExpenseListDto } from '../../dto';
function Documentation() {
return applyDecorators(
ApiBearerAuth(),
ApiOperation({
summary: 'Найти лист расходов',
description: 'Найти лист расходов',
}),
ApiParam({ name: 'id', type: String, description: 'ID листа расходов' }),
ApiNotFoundResponse({ description: 'Лист расходов не найден' }),
ApiOkResponse({ description: 'Успешно создан лист расходов', type: ExpenseListDto }),
);
}
export function FindExpenseList() {
return applyDecorators(
Authorization(),
Documentation(),
);
}

View File

@ -0,0 +1,12 @@
export { CreateExpenseList } from './expense-list/create-expense-list.decorator';
export { CreateExpenseListItem } from './expense-list-item/create-expense-list-item.decorator';
export { DeleteExpenseList } from './expense-list/delete-expense-list.decorator';
export { DeleteExpenseListItem } from './expense-list-item/delete-expense-list-item.decorator';
export { EditExpenseList } from './expense-list/edit-expense-list.decorator';
export { EditExpenseListItem } from './expense-list-item/edit-expense-list-item.decorator';
export { FindExpenseList } from './expense-list/find-expense-list.decorator';
export { FindAllExpenseLists } from './expense-list/find-all-expense-list.decorator';
export { FindExpenseListItem } from './expense-list-item/find-expense-list-item.decorator';

View File

@ -0,0 +1,70 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { IsISODateTimeString } from '../../../../shared/validators';
export class CreateExpenseListItemDto {
@ApiProperty({
description: 'Заголовок расхода',
example: 'Кофе',
type: String,
})
@IsString({ message: 'Заголовок расхода должен быть строкой.' })
@IsNotEmpty({ message: 'Заголовок расхода обязателен к заполнению.' })
title: string;
@ApiProperty({
description: 'Описание',
example: 'Перед работой',
type: String,
nullable: true,
})
@IsOptional()
@IsString({ message: 'Описание расхода должно быть строкой.' })
description?: string;
@ApiProperty({
description: 'Дата',
example: '2025-08-22T04:12:03.726Z',
type: Date,
})
@IsNotEmpty({ message: 'Дата расхода обязателен к заполнению.' })
@IsISODateTimeString({ message: 'Дата должна быть строкой в формате ISO 8601' })
date: Date;
@ApiProperty({
description: 'Сумма расхода',
example: 175.50,
type: Number,
})
@IsNotEmpty({ message: 'Сумма расхода обязательна к заполнению.' })
@IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 2 }, { message: 'Сумма расхода должна быть числом.' })
amount: number;
@ApiProperty({
description: 'Валюта расхода',
example: 'RUB',
type: String,
default: 'RUB',
nullable: true,
})
@IsOptional()
@IsString({ message: 'Валюта расхода должна быть строкой.' })
currency?: string;
@ApiProperty({
description: 'Количество',
example: 2,
type: Number,
default: 1,
nullable: true,
})
@IsOptional()
@IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 2 }, { message: 'Количество должно быть числом.' })
count?: number;
}

View File

@ -0,0 +1,69 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsNumber,
IsOptional,
IsString,
} from 'class-validator';
import { IsISODateTimeString } from '../../../../shared/validators';
export class EditExpenseListItemDto {
@ApiProperty({
description: 'Заголовок расхода',
example: 'Кофе',
type: String,
})
@IsOptional()
@IsString({ message: 'Заголовок расхода должен быть строкой.' })
title?: string;
@ApiProperty({
description: 'Описание',
example: 'Перед работой',
type: String,
nullable: true,
})
@IsOptional()
@IsString({ message: 'Описание расхода должно быть строкой.' })
description?: string;
@ApiProperty({
description: 'Дата',
example: '2025-08-22T04:12:03.726Z',
type: Date,
})
@IsOptional()
@IsISODateTimeString({ message: 'Дата должна быть строкой в формате ISO 8601' })
date?: Date;
@ApiProperty({
description: 'Сумма расхода',
example: 175.50,
type: Number,
})
@IsOptional()
@IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 2 }, { message: 'Сумма расхода должна быть числом.' })
amount?: number;
@ApiProperty({
description: 'Валюта расхода',
example: 'RUB',
type: String,
default: 'RUB',
nullable: true,
})
@IsOptional()
@IsString({ message: 'Валюта расхода должна быть строкой.' })
currency?: string;
@ApiProperty({
description: 'Количество',
example: 2,
type: Number,
default: 1,
nullable: true,
})
@IsOptional()
@IsNumber({ allowInfinity: false, allowNaN: false, maxDecimalPlaces: 2 }, { message: 'Количество должно быть числом.' })
count?: number;
}

View File

@ -0,0 +1,75 @@
import { ApiProperty } from '@nestjs/swagger';
import { ExpenseItem, ExpenseList } from '@prisma/client';
export class ExpenseListItemDto {
@ApiProperty({
description: 'ID',
type: String,
})
id: ExpenseItem['id'];
@ApiProperty({
description: 'ID списка',
type: String,
})
expenseListId: ExpenseList['id'];
@ApiProperty({
description: 'Заголовок расхода',
example: 'Кофе',
type: String,
})
title: ExpenseItem['title'];
@ApiProperty({
description: 'Описание',
example: 'Перед работой',
type: String,
nullable: true,
})
description: ExpenseItem['description'];
@ApiProperty({
description: 'Дата',
example: '2025-08-22T04:12:03.726Z',
type: Date,
})
date: ExpenseItem['date'];
@ApiProperty({
description: 'Сумма расхода',
example: 175.50,
type: Number,
})
amount: ExpenseItem['amount'];
@ApiProperty({
description: 'Валюта расхода',
example: 'RUB',
type: String,
default: 'RUB',
nullable: true,
})
currency: ExpenseItem['currency'];
@ApiProperty({
description: 'Количество',
example: 2,
type: Number,
default: 1,
nullable: true,
})
count: ExpenseItem['count'];
@ApiProperty({
description: 'Дата создания',
type: Date,
})
createdAt: Date;
@ApiProperty({
description: 'Дата обновления',
type: Date,
})
updatedAt: Date;
}

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class CreateExpenseListDto {
@ApiProperty({
description: 'Название листа',
example: 'Ежедневные покупки',
type: String,
})
@IsString({ message: 'Название листа должен быть строкой.' })
@IsNotEmpty({ message: 'Название листа обязателно к заполнению.' })
title: string;
}

View File

@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class DeleteExpenseListDto {
@ApiProperty({
description: 'ID листа',
type: String,
})
@IsString({ message: 'ID должен быть строкой.' })
@IsNotEmpty({ message: 'ID обязателно к заполнению.' })
id: string;
}

View File

@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class DeletedExpenseListDto {
@ApiProperty({
description: 'ID',
type: String,
})
id: string;
}

View File

@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
export class EditExpenseListDto {
@ApiProperty({
description: 'Название листа',
example: 'Ежедневные покупки',
type: String,
})
@IsString({ message: 'Название листа должен быть строкой.' })
@IsNotEmpty({ message: 'Название листа обязателно к заполнению.' })
title?: string;
}

View File

@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { ExpenseItem } from '@prisma/client';
import { ExpenseListItemDto } from '../expense-list-item/expense-list-item.dto';
export class ExpenseListDto {
@ApiProperty({
description: 'ID',
type: String,
})
id: string;
@ApiProperty({
description: 'Название листа',
type: String,
})
title: string;
@ApiProperty({
description: 'Список расходов',
type: [ExpenseListItemDto],
nullable: true,
})
items?: ExpenseItem[];
@ApiProperty({
description: 'Дата создания',
type: Date,
})
createdAt: Date;
@ApiProperty({
description: 'Дата обновления',
type: Date,
})
updatedAt: Date;
}

View File

@ -0,0 +1,11 @@
export { CreateExpenseListDto } from './expense-list/create-expense-list.dto';
export { CreateExpenseListItemDto } from './expense-list-item/create-expense-list.dto';
export { DeleteExpenseListDto } from './expense-list/delete-expense-list.dto';
export { DeletedExpenseListDto } from './expense-list/deleted-expense-list.dto';
export { EditExpenseListDto } from './expense-list/edit-expense-list.dto';
export { EditExpenseListItemDto } from './expense-list-item/edit-expense-list.dto';
export { ExpenseListDto } from './expense-list/expense-list.dto';
export { ExpenseListItemDto } from './expense-list-item/expense-list-item.dto';

View File

@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
import { ExpenseItem, ExpenseList, User } from '@prisma/client';
import { Authorized } from '@/shared/decorators';
import {
CreateExpenseList,
CreateExpenseListItem,
DeleteExpenseList,
DeleteExpenseListItem,
EditExpenseList,
EditExpenseListItem,
FindAllExpenseLists,
FindExpenseList,
FindExpenseListItem,
} from './decorators';
import {
CreateExpenseListItemDto,
EditExpenseListItemDto,
CreateExpenseListDto,
EditExpenseListDto,
} from './dto';
import { ExpensesService } from './expenses.service';
@Controller('expenses')
export class ExpensesController {
constructor(private readonly expensesService: ExpensesService) {}
@Post('list')
@CreateExpenseList()
async createList(
@Body() body: CreateExpenseListDto,
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.createList(body, userId);
}
@Get('list/:id')
@FindExpenseList()
async findList(
@Param('id') id: ExpenseList['id'],
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.getListById(id, userId);
}
@Get('list')
@FindAllExpenseLists()
async findAllLists(@Authorized('userId') userId: User['id']) {
return this.expensesService.getAllLists(userId);
}
@Patch('list/:id')
@EditExpenseList()
async editList(
@Param('id') id: ExpenseList['id'],
@Body() body: EditExpenseListDto,
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.editList(id, body, userId);
}
@Delete('list/:id')
@DeleteExpenseList()
async deleteList(
@Param('id') id: ExpenseList['id'],
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.deleteList(id, userId);
}
@Post('list/:id/items')
@CreateExpenseListItem()
async createListItem(
@Body() body: CreateExpenseListItemDto,
@Param('id') listId: ExpenseList['id'],
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.createListItem(body, listId, userId);
}
@Get('list/:listId/items/:id')
@FindExpenseListItem()
async findListItem(
@Param('listId') listId: ExpenseList['id'],
@Param('id') id: ExpenseItem['id'],
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.findListItemById(id, listId, userId);
}
@Patch('list/:listId/items/:id')
@EditExpenseListItem()
async editListItem(
@Body() body: EditExpenseListItemDto,
@Param('listId') listId: ExpenseList['id'],
@Param('id') id: ExpenseItem['id'],
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.editListItem(body, id, listId, userId);
}
@Delete('list/:listId/items/:id')
@DeleteExpenseListItem()
async deleteListItem(
@Param('listId') listId: ExpenseList['id'],
@Param('id') id: ExpenseItem['id'],
@Authorized('userId') userId: User['id'],
) {
return this.expensesService.deleteListItem(id, listId, userId);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ExpensesController } from './expenses.controller';
import { ExpensesService } from './expenses.service';
@Module({
controllers: [ExpensesController],
providers: [ExpensesService],
})
export class ExpensesModule {}

View File

@ -0,0 +1,179 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ExpenseItem, ExpenseList, User } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateExpenseListDto,
DeletedExpenseListDto,
EditExpenseListDto,
ExpenseListDto,
CreateExpenseListItemDto,
EditExpenseListItemDto,
ExpenseListItemDto,
} from './dto';
@Injectable()
export class ExpensesService {
constructor(
private readonly prismaService: PrismaService,
) {}
async createList(dto: CreateExpenseListDto, userId: User['id']): Promise<ExpenseListDto> {
const { title } = dto;
const expenseList = await this.prismaService.expenseList.create({
data: { title, user: { connect: { id: userId } } },
});
return {
id: expenseList.id,
title: expenseList.title,
createdAt: expenseList.createdAt,
updatedAt: expenseList.updatedAt,
};
}
async getListById(id: ExpenseList['id'], userId: User['id']): Promise<ExpenseListDto> {
const list = await this.findList(id, userId);
return {
id: list.id,
title: list.title,
items: list.items,
createdAt: list.createdAt,
updatedAt: list.updatedAt,
};
}
async getAllLists(userId: User['id']): Promise<ExpenseListDto[]> {
return this.prismaService.expenseList.findMany({
where: { userId },
include: { items: true },
});
}
async editList(id: ExpenseList['id'], dto: EditExpenseListDto, userId: User['id']): Promise<ExpenseListDto> {
const { title } = dto;
const expenseList = await this.findList(id, userId);
const updatedList = await this.prismaService.expenseList.update({
where: { id: expenseList.id },
data: { title },
});
return {
id: updatedList.id,
title: updatedList.title,
createdAt: updatedList.createdAt,
updatedAt: updatedList.updatedAt,
};
}
async deleteList(id: ExpenseList['id'], userId: User['id']): Promise<DeletedExpenseListDto> {
const { id: listId } = await this.findList(id, userId);
const deletedList = await this.prismaService.expenseList.delete({ where: { id: listId } });
return {
id: deletedList.id,
};
}
async createListItem(dto: CreateExpenseListItemDto, listId: ExpenseList['id'], userId: User['id']): Promise<ExpenseListItemDto> {
const {
title,
description,
date,
amount,
currency,
count,
} = dto;
const list = await this.findList(listId, userId);
return this.prismaService.expenseItem.create({
data: {
title,
description,
date,
amount,
currency,
count,
expenseList: {
connect: { id: list.id },
},
},
});
}
async findListItemById(id: ExpenseItem['id'], listId: ExpenseList['id'], userId: User['id']) {
return this.findListItem(id, listId, userId);
}
// eslint-disable-next-line @typescript-eslint/max-params
async editListItem(
dto: EditExpenseListItemDto,
itemId: ExpenseItem['id'],
listId: ExpenseList['id'],
userId: User['id'],
): Promise<ExpenseListItemDto> {
const item = await this.findListItem(itemId, listId, userId);
const {
title = item.title,
description = item.description,
date = item.date,
amount = item.amount,
currency = item.currency,
count = item.count,
} = dto;
return this.prismaService.expenseItem.update({
where: { id: item.id },
data: {
title,
description,
date,
amount,
currency,
count,
},
});
}
async deleteListItem(
itemId: ExpenseItem['id'],
listId: ExpenseList['id'],
userId: User['id'],
): Promise<ExpenseListItemDto> {
const item = await this.findListItem(itemId, listId, userId);
return this.prismaService.expenseItem.delete({
where: { id: item.id },
});
}
private async findList(id: ExpenseList['id'], userId: User['id']) {
const existingList = await this.prismaService.expenseList.findUnique({
where: { id, userId },
include: { items: true },
});
if (!existingList) {
throw new NotFoundException('Лист расходов не найден');
}
return existingList;
}
private async findListItem(id: ExpenseItem['id'], listId: ExpenseList['id'], userId: User['id']) {
const list = await this.findList(listId, userId);
const existingItem = await this.prismaService.expenseItem.findUnique({ where: { id } });
if (!existingItem || existingItem.expenseListId !== list.id) {
throw new NotFoundException('Позиция расходов не найдена');
}
return existingItem;
}
}

View File

@ -8,7 +8,7 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
await this.$connect(); await this.$connect();
} }
public async onModuleDestroy() { public onModuleDestroy() {
await this.$disconnect; void this.$disconnect;
} }
} }

View File

@ -1,5 +1,6 @@
import { applyDecorators, UseGuards } from '@nestjs/common'; import { applyDecorators, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../guards/jwt.guard';
import { JwtGuard } from '../../guards';
export function Authorization() { export function Authorization() {
return applyDecorators(UseGuards(JwtGuard)); return applyDecorators(UseGuards(JwtGuard));

View File

@ -1,7 +1,8 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common'; import { createParamDecorator } from '@nestjs/common';
import type { Request } from '../../types';
import type { ExecutionContext } from '@nestjs/common';
import type { Account } from '@prisma/client'; import type { Account } from '@prisma/client';
import type { Request } from '../types/request';
export const Authorized = createParamDecorator( export const Authorized = createParamDecorator(
(data: keyof Account | undefined, ctx: ExecutionContext) => { (data: keyof Account | undefined, ctx: ExecutionContext) => {

View File

@ -0,0 +1,2 @@
export { Authorization } from './Auth/authorization.decorator';
export { Authorized } from './Auth/authorized.decorator';

View File

@ -0,0 +1 @@
export { JwtGuard } from './JwtGuard/jwt.guard';

View File

@ -0,0 +1,2 @@
export type { Request } from './request';
export type { JwtPayload } from './jwt-payload';

View File

@ -0,0 +1,28 @@
import { registerDecorator } from 'class-validator';
import type { ValidationOptions } from 'class-validator';
export function IsISODateTimeString(validationOptions?: ValidationOptions) {
// eslint-disable-next-line func-names
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isISODateTimeString',
target: object.constructor,
propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return false;
// Разрешаем только "2025-08-22T04:12:03.726Z" или без миллисекунд
const regex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
return regex.test(value);
},
defaultMessage() {
return 'Дата должна быть в формате YYYY-MM-DDTHH:mm:ss(.sss)Z';
},
},
});
};
}

View File

@ -0,0 +1 @@
export { IsISODateTimeString } from './IsISODateTimeString/IsISODateTimeString.validator';

View File

@ -16,7 +16,10 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"noImplicitAny": false, "noImplicitAny": false,
"strictBindCallApply": false, "strictBindCallApply": false,
"noFallthroughCasesInSwitch": false "noFallthroughCasesInSwitch": false,
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": [ "include": [
"src", "src",