end fase 2

This commit is contained in:
2026-01-15 00:18:35 +03:00
parent 85e7966c97
commit 739a7d172d
63 changed files with 3194 additions and 322 deletions

View File

@ -5,6 +5,8 @@ import { APP_GUARD } from '@nestjs/core';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { IdeasModule } from './ideas/ideas.module';
import { CommentsModule } from './comments/comments.module';
import { TeamModule } from './team/team.module';
import { AuthModule, JwtAuthGuard } from './auth';
@Module({
@ -30,6 +32,8 @@ import { AuthModule, JwtAuthGuard } from './auth';
}),
AuthModule,
IdeasModule,
CommentsModule,
TeamModule,
],
controllers: [AppController],
providers: [

View File

@ -0,0 +1,37 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { CommentsService } from './comments.service';
import { CreateCommentDto } from './dto';
@Controller('api')
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get('ideas/:ideaId/comments')
findByIdeaId(@Param('ideaId', ParseUUIDPipe) ideaId: string) {
return this.commentsService.findByIdeaId(ideaId);
}
@Post('ideas/:ideaId/comments')
create(
@Param('ideaId', ParseUUIDPipe) ideaId: string,
@Body() createCommentDto: CreateCommentDto,
) {
return this.commentsService.create(ideaId, createCommentDto);
}
@Delete('comments/:id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.commentsService.remove(id);
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Comment } from './entities/comment.entity';
import { CommentsService } from './comments.service';
import { CommentsController } from './comments.controller';
@Module({
imports: [TypeOrmModule.forFeature([Comment])],
controllers: [CommentsController],
providers: [CommentsService],
exports: [CommentsService],
})
export class CommentsModule {}

View File

@ -0,0 +1,36 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Comment } from './entities/comment.entity';
import { CreateCommentDto } from './dto';
@Injectable()
export class CommentsService {
constructor(
@InjectRepository(Comment)
private readonly commentsRepository: Repository<Comment>,
) {}
async findByIdeaId(ideaId: string): Promise<Comment[]> {
return this.commentsRepository.find({
where: { ideaId },
order: { createdAt: 'ASC' },
});
}
async create(ideaId: string, createCommentDto: CreateCommentDto): Promise<Comment> {
const comment = this.commentsRepository.create({
...createCommentDto,
ideaId,
});
return this.commentsRepository.save(comment);
}
async remove(id: string): Promise<void> {
const comment = await this.commentsRepository.findOne({ where: { id } });
if (!comment) {
throw new NotFoundException(`Comment with ID "${id}" not found`);
}
await this.commentsRepository.remove(comment);
}
}

View File

@ -0,0 +1,12 @@
import { IsString, IsNotEmpty, IsOptional, MaxLength } from 'class-validator';
export class CreateCommentDto {
@IsString()
@IsNotEmpty()
text: string;
@IsOptional()
@IsString()
@MaxLength(255)
author?: string;
}

View File

@ -0,0 +1 @@
export * from './create-comment.dto';

View File

@ -0,0 +1,35 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Idea } from '../../ideas/entities/idea.entity';
@Entity('comments')
export class Comment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'text' })
text: string;
@Column({ type: 'varchar', length: 255, nullable: true })
author: string | null;
@Column({ name: 'idea_id', type: 'uuid' })
ideaId: string;
@ManyToOne(() => Idea, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'idea_id' })
idea: Idea;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,5 @@
export * from './comments.module';
export * from './comments.service';
export * from './comments.controller';
export * from './entities/comment.entity';
export * from './dto';

View File

@ -18,6 +18,10 @@ export class QueryIdeasDto {
@IsString()
search?: string;
@IsOptional()
@IsString()
color?: string;
@IsOptional()
@IsString()
sortBy?: string;

View File

@ -26,6 +26,7 @@ export class IdeasService {
priority,
module,
search,
color,
sortBy = 'order',
sortOrder = 'ASC',
page = 1,
@ -60,6 +61,10 @@ export class IdeasService {
queryBuilder.andWhere('idea.module = :module', { module });
}
if (color) {
queryBuilder.andWhere('idea.color = :color', { color });
}
if (search) {
queryBuilder.andWhere(
'(idea.title ILIKE :search OR idea.description ILIKE :search)',

View File

@ -0,0 +1,26 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCommentsTable1736899200000 implements MigrationInterface {
name = 'CreateCommentsTable1736899200000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE "comments" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"text" text NOT NULL,
"author" character varying(255),
"idea_id" uuid NOT NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_comments_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_comments_idea_id" FOREIGN KEY ("idea_id") REFERENCES "ideas"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`CREATE INDEX "IDX_comments_idea_id" ON "comments" ("idea_id")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX "IDX_comments_idea_id"`);
await queryRunner.query(`DROP TABLE "comments"`);
}
}

View File

@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateTeamMembersTable1736899300000 implements MigrationInterface {
name = 'CreateTeamMembersTable1736899300000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TYPE "public"."team_members_role_enum" AS ENUM('backend', 'frontend', 'ai_ml', 'devops', 'qa', 'ui_ux', 'pm')`,
);
await queryRunner.query(`
CREATE TABLE "team_members" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(255) NOT NULL,
"role" "public"."team_members_role_enum" NOT NULL,
"productivity" jsonb NOT NULL DEFAULT '{"trivial": 1, "simple": 4, "medium": 12, "complex": 32, "veryComplex": 60}',
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_team_members_id" PRIMARY KEY ("id")
)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "team_members"`);
await queryRunner.query(`DROP TYPE "public"."team_members_role_enum"`);
}
}

View File

@ -0,0 +1,93 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateRolesTable1736899400000 implements MigrationInterface {
name = 'CreateRolesTable1736899400000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 1. Создаём таблицу roles
await queryRunner.query(`
CREATE TABLE "roles" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying(100) NOT NULL,
"label" character varying(255) NOT NULL,
"sortOrder" integer NOT NULL DEFAULT 0,
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT "PK_roles_id" PRIMARY KEY ("id"),
CONSTRAINT "UQ_roles_name" UNIQUE ("name")
)
`);
// 2. Добавляем начальные роли (из старого enum)
await queryRunner.query(`
INSERT INTO "roles" ("name", "label", "sortOrder") VALUES
('backend', 'Backend-разработчик', 0),
('frontend', 'Frontend-разработчик', 1),
('ai_ml', 'AI/ML-инженер', 2),
('devops', 'DevOps-инженер', 3),
('qa', 'QA-инженер', 4),
('ui_ux', 'UI/UX-дизайнер', 5),
('pm', 'Project Manager', 6)
`);
// 3. Добавляем колонку role_id в team_members (nullable сначала)
await queryRunner.query(`
ALTER TABLE "team_members" ADD COLUMN "role_id" uuid
`);
// 4. Мигрируем данные: связываем team_members с roles по name
await queryRunner.query(`
UPDATE "team_members" tm
SET "role_id" = r."id"
FROM "roles" r
WHERE tm."role"::text = r."name"
`);
// 5. Делаем role_id NOT NULL
await queryRunner.query(`
ALTER TABLE "team_members" ALTER COLUMN "role_id" SET NOT NULL
`);
// 6. Добавляем foreign key
await queryRunner.query(`
ALTER TABLE "team_members"
ADD CONSTRAINT "FK_team_members_role" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE RESTRICT
`);
// 7. Удаляем старую колонку role и enum
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role"`);
await queryRunner.query(`DROP TYPE "public"."team_members_role_enum"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
// 1. Восстанавливаем enum
await queryRunner.query(
`CREATE TYPE "public"."team_members_role_enum" AS ENUM('backend', 'frontend', 'ai_ml', 'devops', 'qa', 'ui_ux', 'pm')`,
);
// 2. Добавляем колонку role
await queryRunner.query(`
ALTER TABLE "team_members" ADD COLUMN "role" "public"."team_members_role_enum"
`);
// 3. Мигрируем данные обратно
await queryRunner.query(`
UPDATE "team_members" tm
SET "role" = r."name"::"public"."team_members_role_enum"
FROM "roles" r
WHERE tm."role_id" = r."id"
`);
// 4. Делаем role NOT NULL
await queryRunner.query(`
ALTER TABLE "team_members" ALTER COLUMN "role" SET NOT NULL
`);
// 5. Удаляем foreign key и role_id
await queryRunner.query(`ALTER TABLE "team_members" DROP CONSTRAINT "FK_team_members_role"`);
await queryRunner.query(`ALTER TABLE "team_members" DROP COLUMN "role_id"`);
// 6. Удаляем таблицу roles
await queryRunner.query(`DROP TABLE "roles"`);
}
}

View File

@ -0,0 +1,16 @@
import { IsString, IsNotEmpty, IsOptional, IsInt, Min } from 'class-validator';
export class CreateRoleDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
label: string;
@IsOptional()
@IsInt()
@Min(0)
sortOrder?: number;
}

View File

@ -0,0 +1,48 @@
import {
IsString,
IsNotEmpty,
IsUUID,
IsOptional,
IsObject,
IsNumber,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
class ProductivityMatrixDto {
@IsNumber()
@Min(0)
trivial: number;
@IsNumber()
@Min(0)
simple: number;
@IsNumber()
@Min(0)
medium: number;
@IsNumber()
@Min(0)
complex: number;
@IsNumber()
@Min(0)
veryComplex: number;
}
export class CreateTeamMemberDto {
@IsString()
@IsNotEmpty()
name: string;
@IsUUID()
roleId: string;
@IsOptional()
@IsObject()
@ValidateNested()
@Type(() => ProductivityMatrixDto)
productivity?: ProductivityMatrixDto;
}

View File

@ -0,0 +1,4 @@
export * from './create-team-member.dto';
export * from './update-team-member.dto';
export * from './create-role.dto';
export * from './update-role.dto';

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateRoleDto } from './create-role.dto';
export class UpdateRoleDto extends PartialType(CreateRoleDto) {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateTeamMemberDto } from './create-team-member.dto';
export class UpdateTeamMemberDto extends PartialType(CreateTeamMemberDto) {}

View File

@ -0,0 +1,33 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { TeamMember } from './team-member.entity';
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 100, unique: true })
name: string;
@Column({ type: 'varchar', length: 255 })
label: string;
@Column({ type: 'int', default: 0 })
sortOrder: number;
@OneToMany(() => TeamMember, (member) => member.role)
members: TeamMember[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,44 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Role } from './role.entity';
// Матрица производительности: время в часах на задачи разной сложности
export interface ProductivityMatrix {
trivial: number; // < 1 часа
simple: number; // 1-4 часа
medium: number; // 4-16 часов
complex: number; // 16-40 часов
veryComplex: number; // > 40 часов
}
@Entity('team_members')
export class TeamMember {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255 })
name: string;
@ManyToOne(() => Role, (role) => role.members, { eager: true })
@JoinColumn({ name: 'role_id' })
role: Role;
@Column({ name: 'role_id', type: 'uuid' })
roleId: string;
@Column({ type: 'jsonb', default: { trivial: 1, simple: 4, medium: 12, complex: 32, veryComplex: 60 } })
productivity: ProductivityMatrix;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@ -0,0 +1,5 @@
export * from './team.module';
export * from './team.service';
export * from './team.controller';
export * from './entities/team-member.entity';
export * from './dto';

View File

@ -0,0 +1,49 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { RolesService } from './roles.service';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
@Controller('api/roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Get()
findAll() {
return this.rolesService.findAll();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.rolesService.findOne(id);
}
@Post()
create(@Body() createDto: CreateRoleDto) {
return this.rolesService.create(createDto);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: UpdateRoleDto,
) {
return this.rolesService.update(id, updateDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.rolesService.remove(id);
}
}

View File

@ -0,0 +1,69 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Role } from './entities/role.entity';
import { CreateRoleDto } from './dto/create-role.dto';
import { UpdateRoleDto } from './dto/update-role.dto';
@Injectable()
export class RolesService {
constructor(
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
) {}
async findAll(): Promise<Role[]> {
return this.roleRepository.find({
order: { sortOrder: 'ASC', name: 'ASC' },
});
}
async findOne(id: string): Promise<Role> {
const role = await this.roleRepository.findOne({ where: { id } });
if (!role) {
throw new NotFoundException(`Role with ID "${id}" not found`);
}
return role;
}
async create(createDto: CreateRoleDto): Promise<Role> {
const existing = await this.roleRepository.findOne({
where: { name: createDto.name },
});
if (existing) {
throw new ConflictException(`Role with name "${createDto.name}" already exists`);
}
const maxSortOrder = await this.roleRepository
.createQueryBuilder('role')
.select('MAX(role.sortOrder)', 'max')
.getRawOne<{ max: number | null }>();
const role = this.roleRepository.create({
...createDto,
sortOrder: createDto.sortOrder ?? (maxSortOrder?.max ?? -1) + 1,
});
return this.roleRepository.save(role);
}
async update(id: string, updateDto: UpdateRoleDto): Promise<Role> {
const role = await this.findOne(id);
if (updateDto.name && updateDto.name !== role.name) {
const existing = await this.roleRepository.findOne({
where: { name: updateDto.name },
});
if (existing) {
throw new ConflictException(`Role with name "${updateDto.name}" already exists`);
}
}
Object.assign(role, updateDto);
return this.roleRepository.save(role);
}
async remove(id: string): Promise<void> {
const role = await this.findOne(id);
await this.roleRepository.remove(role);
}
}

View File

@ -0,0 +1,53 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Body,
Param,
ParseUUIDPipe,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { TeamService } from './team.service';
import { CreateTeamMemberDto, UpdateTeamMemberDto } from './dto';
@Controller('api/team')
export class TeamController {
constructor(private readonly teamService: TeamService) {}
@Get()
findAll() {
return this.teamService.findAll();
}
@Get('summary')
getSummary() {
return this.teamService.getSummary();
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.teamService.findOne(id);
}
@Post()
create(@Body() createDto: CreateTeamMemberDto) {
return this.teamService.create(createDto);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() updateDto: UpdateTeamMemberDto,
) {
return this.teamService.update(id, updateDto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.teamService.remove(id);
}
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TeamMember } from './entities/team-member.entity';
import { Role } from './entities/role.entity';
import { TeamService } from './team.service';
import { TeamController } from './team.controller';
import { RolesService } from './roles.service';
import { RolesController } from './roles.controller';
@Module({
imports: [TypeOrmModule.forFeature([TeamMember, Role])],
controllers: [TeamController, RolesController],
providers: [TeamService, RolesService],
exports: [TeamService, RolesService],
})
export class TeamModule {}

View File

@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TeamMember } from './entities/team-member.entity';
import { Role } from './entities/role.entity';
import { CreateTeamMemberDto } from './dto/create-team-member.dto';
import { UpdateTeamMemberDto } from './dto/update-team-member.dto';
@Injectable()
export class TeamService {
constructor(
@InjectRepository(TeamMember)
private readonly teamRepository: Repository<TeamMember>,
@InjectRepository(Role)
private readonly roleRepository: Repository<Role>,
) {}
async findAll(): Promise<TeamMember[]> {
return this.teamRepository.find({
order: { role: { sortOrder: 'ASC' }, name: 'ASC' },
relations: ['role'],
});
}
async findOne(id: string): Promise<TeamMember> {
const member = await this.teamRepository.findOne({
where: { id },
relations: ['role'],
});
if (!member) {
throw new NotFoundException(`Team member with ID "${id}" not found`);
}
return member;
}
async create(createDto: CreateTeamMemberDto): Promise<TeamMember> {
// Проверяем что роль существует
const role = await this.roleRepository.findOne({
where: { id: createDto.roleId },
});
if (!role) {
throw new NotFoundException(`Role with ID "${createDto.roleId}" not found`);
}
const member = this.teamRepository.create(createDto);
const saved = await this.teamRepository.save(member);
return this.findOne(saved.id);
}
async update(id: string, updateDto: UpdateTeamMemberDto): Promise<TeamMember> {
const member = await this.findOne(id);
if (updateDto.roleId) {
const role = await this.roleRepository.findOne({
where: { id: updateDto.roleId },
});
if (!role) {
throw new NotFoundException(`Role with ID "${updateDto.roleId}" not found`);
}
}
Object.assign(member, updateDto);
await this.teamRepository.save(member);
return this.findOne(id);
}
async remove(id: string): Promise<void> {
const member = await this.findOne(id);
await this.teamRepository.remove(member);
}
async getSummary(): Promise<{ roleId: string; label: string; count: number }[]> {
// Получаем все роли
const roles = await this.roleRepository.find({
order: { sortOrder: 'ASC' },
});
// Получаем количество участников по ролям
const result = await this.teamRepository
.createQueryBuilder('member')
.select('member.role_id', 'roleId')
.addSelect('COUNT(*)', 'count')
.groupBy('member.role_id')
.getRawMany<{ roleId: string; count: string }>();
// Возвращаем все роли с количеством
return roles.map((role) => ({
roleId: role.id,
label: role.label,
count: parseInt(result.find((r) => r.roleId === role.id)?.count ?? '0', 10),
}));
}
}