Skip to content

Commit a923f56

Browse files
committed
feat(teams): implement core structure, entities and swagger documentation
1 parent 95cb450 commit a923f56

15 files changed

Lines changed: 497 additions & 13 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { TeamsController } from './teams.controller';
2+
export { MembersController } from './members.controller';
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { ApiBaseController, GetUserId } from 'src/shared/decorators';
2+
import { TeamsService } from '../services';
3+
import { Body, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common';
4+
import {
5+
GetMembersSwagger,
6+
InviteMemberSwagger,
7+
RemoveMemberSwagger,
8+
UpdateMemberSwagger,
9+
} from './teams.swagger';
10+
11+
@ApiBaseController('teams/:slug', 'Teams', true)
12+
export class MembersController {
13+
constructor(private readonly facade: TeamsService) {}
14+
15+
@Get('members')
16+
@GetMembersSwagger()
17+
async getMembers(@Param('slug') slug: string) {
18+
return this.facade.getMembers(slug);
19+
}
20+
21+
@Post('invitations')
22+
@InviteMemberSwagger()
23+
async invite(@Param('slug') slug: string, @GetUserId() inviterId: string, @Body() dto: any) {
24+
return this.facade.invite(slug, inviterId, dto);
25+
}
26+
27+
@Patch('members/:userId')
28+
@UpdateMemberSwagger()
29+
async updateMember(
30+
@Param('slug') slug: string,
31+
@Param('userId') userId: string,
32+
@Body() dto: any,
33+
) {
34+
return this.facade.updateMember(slug, userId, dto);
35+
}
36+
37+
@Delete('members/:userId')
38+
@RemoveMemberSwagger()
39+
@HttpCode(HttpStatus.NO_CONTENT)
40+
async removeMember(@Param('slug') slug: string, @Param('userId') userId: string) {
41+
return this.facade.removeMember(slug, userId);
42+
}
43+
}
Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,71 @@
1-
import { ApiBaseController } from 'src/shared/decorators';
1+
import {
2+
Body,
3+
Delete,
4+
Get,
5+
HttpCode,
6+
HttpStatus,
7+
Param,
8+
Patch,
9+
Post,
10+
Put,
11+
Query,
12+
} from '@nestjs/common';
13+
import { ApiBaseController, GetUserId } from 'src/shared/decorators';
214
import { TeamsService } from '../services';
15+
import {
16+
CreateTeamSwagger,
17+
FindAllTeamsSwagger,
18+
FindOneTeamSwagger,
19+
GetAllTagsSwagger,
20+
RemoveTeamSwagger,
21+
SyncTeamTagsSwagger,
22+
UpdateTeamSwagger,
23+
} from './teams.swagger';
324

4-
@ApiBaseController('teams', 'Teams')
25+
@ApiBaseController('teams', 'Teams', true)
526
export class TeamsController {
627
constructor(private readonly facade: TeamsService) {}
28+
29+
@Post()
30+
@CreateTeamSwagger()
31+
async create(@Body() dto: any, @GetUserId() userId: string) {
32+
return this.facade.create(userId, dto);
33+
}
34+
35+
@Get()
36+
@FindAllTeamsSwagger()
37+
async findAll(@GetUserId() userId: string, @Query() query: any) {
38+
return this.facade.getAll(userId, query);
39+
}
40+
41+
@Get(':slug')
42+
@FindOneTeamSwagger()
43+
async findOne(@Param('slug') slug: string) {
44+
return this.facade.getOne(slug);
45+
}
46+
47+
@Patch(':slug')
48+
@UpdateTeamSwagger()
49+
async update(@Param('slug') slug: string, @Body() dto: any) {
50+
return this.facade.update(slug, dto);
51+
}
52+
53+
@Delete(':slug')
54+
@RemoveTeamSwagger()
55+
@HttpCode(HttpStatus.NO_CONTENT)
56+
async remove(@Param('slug') slug: string) {
57+
return this.facade.remove(slug);
58+
}
59+
60+
@Put(':slug/tags')
61+
@SyncTeamTagsSwagger()
62+
async syncTags(@Param('slug') slug: string, @Body('tags') tags: string[]) {
63+
return this.facade.syncTags(slug, tags);
64+
}
65+
66+
@Get('tags/all')
67+
@GetAllTagsSwagger()
68+
async getAllTags(@Query('search') search?: string) {
69+
return this.facade.getAllTags(search);
70+
}
771
}
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { applyDecorators } from '@nestjs/common';
2+
import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
3+
import { ActionResponse } from 'src/shared/dtos';
4+
import {
5+
ApiConflict,
6+
ApiForbidden,
7+
ApiNotFound,
8+
ApiUnauthorized,
9+
ApiValidationError,
10+
} from 'src/shared/error';
11+
import { CreateTeamDto, InviteMemberDto, SyncTagsDto, TagResponse, UpdateTeamDto } from '../dtos';
12+
13+
export const CreateTeamSwagger = () =>
14+
applyDecorators(
15+
ApiOperation({ summary: 'Создать новую команду' }),
16+
ApiBody({ type: CreateTeamDto.Output }),
17+
ApiResponse({
18+
status: 201,
19+
description: 'Команда успешно создана',
20+
type: ActionResponse.Output,
21+
}),
22+
ApiConflict('Команда с таким slug уже существует'),
23+
ApiValidationError(),
24+
ApiUnauthorized(),
25+
);
26+
27+
export const FindAllTeamsSwagger = () =>
28+
applyDecorators(
29+
ApiOperation({ summary: 'Получить список команд пользователя' }),
30+
ApiResponse({
31+
status: 200,
32+
description: 'Список команд получен',
33+
type: [Object],
34+
}),
35+
ApiUnauthorized(),
36+
);
37+
38+
export const FindOneTeamSwagger = () =>
39+
applyDecorators(
40+
ApiOperation({ summary: 'Получить детальную информацию о команде по slug' }),
41+
ApiParam({ name: 'slug', description: 'Уникальный идентификатор (слаг) команды' }),
42+
ApiResponse({
43+
status: 200,
44+
description: 'Данные команды получены',
45+
type: Object,
46+
}),
47+
ApiNotFound('Команда не найдена'),
48+
ApiUnauthorized(),
49+
);
50+
51+
export const UpdateTeamSwagger = () =>
52+
applyDecorators(
53+
ApiOperation({ summary: 'Обновить данные команды' }),
54+
ApiBody({ type: UpdateTeamDto.Output }),
55+
ApiParam({ name: 'slug', description: 'Слаг команды для редактирования' }),
56+
ApiResponse({
57+
status: 200,
58+
description: 'Команда успешно обновлена',
59+
type: ActionResponse.Output,
60+
}),
61+
ApiForbidden(),
62+
ApiNotFound(),
63+
ApiValidationError(),
64+
ApiUnauthorized(),
65+
);
66+
67+
export const RemoveTeamSwagger = () =>
68+
applyDecorators(
69+
ApiOperation({ summary: 'Удалить команду' }),
70+
ApiParam({ name: 'slug', description: 'Слаг команды для удаления' }),
71+
ApiResponse({
72+
status: 204,
73+
description: 'Команда успешно удалена',
74+
type: ActionResponse.Output,
75+
}),
76+
ApiForbidden(),
77+
ApiNotFound(),
78+
ApiUnauthorized(),
79+
);
80+
81+
export const SyncTeamTagsSwagger = () =>
82+
applyDecorators(
83+
ApiOperation({ summary: 'Синхронизировать теги команды' }),
84+
ApiBody({ type: SyncTagsDto.Output }),
85+
ApiResponse({ status: 200, description: 'Теги обновлены', type: ActionResponse.Output }),
86+
ApiForbidden(),
87+
ApiNotFound(),
88+
ApiUnauthorized(),
89+
);
90+
91+
export const GetAllTagsSwagger = () =>
92+
applyDecorators(
93+
ApiOperation({
94+
summary: 'Получить список всех тегов',
95+
description: 'Используется для поиска и автокомплита при создании команд.',
96+
}),
97+
ApiQuery({ name: 'search', required: false, description: 'Поиск по названию тега' }),
98+
ApiResponse({
99+
status: 200,
100+
description: 'Список тегов успешно получен',
101+
type: [TagResponse.Output],
102+
}),
103+
ApiUnauthorized(),
104+
);
105+
106+
export const GetMembersSwagger = () =>
107+
applyDecorators(
108+
ApiOperation({ summary: 'Получить список всех участников команды' }),
109+
ApiParam({ name: 'slug', description: 'Слаг команды' }),
110+
ApiResponse({
111+
status: 200,
112+
description: 'Список участников получен',
113+
type: [Object],
114+
}),
115+
ApiUnauthorized(),
116+
ApiForbidden(),
117+
);
118+
119+
export const InviteMemberSwagger = () =>
120+
applyDecorators(
121+
ApiOperation({ summary: 'Пригласить пользователя в команду по Email' }),
122+
ApiBody({ type: InviteMemberDto.Output }),
123+
ApiParam({ name: 'slug', description: 'Слаг команды' }),
124+
ApiResponse({ status: 201, description: 'Инвайт создан и отправлен' }),
125+
ApiValidationError('Ошибка в формате email или данных'),
126+
ApiUnauthorized(),
127+
ApiForbidden(),
128+
);
129+
130+
export const UpdateMemberSwagger = () =>
131+
applyDecorators(
132+
ApiOperation({ summary: 'Изменить роль или статус участника' }),
133+
ApiParam({ name: 'slug', description: 'Слаг команды' }),
134+
ApiParam({ name: 'userId', description: 'ID пользователя' }),
135+
ApiResponse({
136+
status: 200,
137+
description: 'Данные участника обновлены',
138+
type: ActionResponse.Output,
139+
}),
140+
ApiNotFound('Участник или команда не найдены'),
141+
ApiUnauthorized(),
142+
ApiForbidden(),
143+
);
144+
145+
export const RemoveMemberSwagger = () =>
146+
applyDecorators(
147+
ApiOperation({ summary: 'Удалить участника из команды' }),
148+
ApiParam({ name: 'slug', description: 'Слаг команды' }),
149+
ApiParam({ name: 'userId', description: 'ID пользователя' }),
150+
ApiResponse({
151+
status: 204,
152+
type: ActionResponse.Output,
153+
description: 'Участник успешно удален',
154+
}),
155+
ApiNotFound(),
156+
ApiUnauthorized(),
157+
ApiForbidden(),
158+
);

src/modules/teams/dtos/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { InviteMemberDto } from './member.dto';
2+
export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagResponse } from './team.dto';
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { z } from 'zod/v4';
2+
import { createZodDto } from 'nestjs-zod';
3+
4+
export const InviteMemberSchema = z.object({
5+
email: z.string().email().describe('Email пользователя, которого нужно пригласить'),
6+
role: z
7+
.string()
8+
.default('member')
9+
.describe('Роль, которая будет назначена пользователю после принятия инвайта'),
10+
});
11+
12+
export class InviteMemberDto extends createZodDto(InviteMemberSchema) {}
13+
14+
export class UpdateMemberDto extends createZodDto(
15+
z.object({
16+
role: z.string().optional().describe('Новая роль участника'),
17+
status: z.string().optional().describe('Новый статус (active, blocked и т.д.)'),
18+
}),
19+
) {}

src/modules/teams/dtos/team.dto.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { z } from 'zod/v4';
2+
import { createZodDto } from 'nestjs-zod';
3+
4+
export const CreateTeamSchema = z.object({
5+
name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'),
6+
description: z
7+
.string()
8+
.max(500)
9+
.optional()
10+
.describe('Краткое описание деятельности или целей команды'),
11+
avatarUrl: z.string().url().optional().describe('Ссылка на изображение профиля команды'),
12+
tags: z
13+
.array(z.string())
14+
.optional()
15+
.describe('Список строковых названий тегов для классификации'),
16+
});
17+
18+
export class CreateTeamDto extends createZodDto(CreateTeamSchema) {}
19+
export class UpdateTeamDto extends createZodDto(CreateTeamSchema.partial()) {}
20+
21+
export const TagSchema = z.object({
22+
id: z.string().describe('Уникальный идентификатор тега (CUID2)'),
23+
name: z.string().min(1).max(50).describe('Название тега (например, "Backend", "Design")'),
24+
});
25+
26+
export const SyncTagsSchema = z.object({
27+
tags: z
28+
.array(z.string())
29+
.min(1, 'Список тегов не может быть пустым')
30+
.max(15, 'Нельзя добавить более 15 тегов за раз')
31+
.describe(
32+
'Массив названий тегов для привязки к команде. Если тега нет в базе, он будет создан.',
33+
),
34+
});
35+
36+
const FindTagsQuerySchema = z.object({
37+
search: z.string().optional().describe('Поисковый запрос для фильтрации тегов по названию'),
38+
limit: z
39+
.preprocess(
40+
(val) => (val ? parseInt(val as string, 10) : 20),
41+
z.number().min(1).max(100).default(20),
42+
)
43+
.describe('Количество возвращаемых результатов (1-100)'),
44+
});
45+
46+
export class TagResponse extends createZodDto(TagSchema) {}
47+
export class SyncTagsDto extends createZodDto(SyncTagsSchema) {}
48+
export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { tags, teamsToTags, teams, teamMembers } from './teams.entity';
22
export { roleEnum, statusEnum } from './enums';
3+
export * from './teams.domain';
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm';
2+
import { teams, teamMembers, tags, teamsToTags } from './teams.entity';
3+
4+
export type Team = InferSelectModel<typeof teams>;
5+
export type NewTeam = InferInsertModel<typeof teams>;
6+
7+
export type TeamMember = InferSelectModel<typeof teamMembers>;
8+
export type NewTeamMember = InferInsertModel<typeof teamMembers>;
9+
10+
export type Tag = InferSelectModel<typeof tags>;
11+
export type NewTag = InferInsertModel<typeof tags>;
12+
13+
export type TeamToTag = InferSelectModel<typeof teamsToTags>;
14+
export type NewTeamToTag = InferInsertModel<typeof teamsToTags>;
15+
16+
export type TeamWithMembers = Team & {
17+
members: TeamMember[];
18+
};
19+
20+
export type TeamWithTags = Team & {
21+
tags: Tag[];
22+
};

0 commit comments

Comments
 (0)