Skip to content

Commit 9f1da26

Browse files
committed
feat(teams): implement team tags and paginated retrieval
1 parent a923f56 commit 9f1da26

10 files changed

Lines changed: 160 additions & 34 deletions

File tree

src/modules/auth/controller/auth.controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { ApiBaseController } from '../../../shared/decorators';
22
import { Body, HttpCode, Post, Req, Res, UseGuards } from '@nestjs/common';
3-
import { AuthService } from '../services/auth.service';
3+
import { AuthService } from '../services';
44
import {
55
PostLoginSwagger,
66
PostLogoutSwagger,

src/modules/teams/controller/teams.controller.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from '@nestjs/common';
1313
import { ApiBaseController, GetUserId } from 'src/shared/decorators';
1414
import { TeamsService } from '../services';
15+
import { FindTagsQuery, SyncTagsDto } from '../dtos';
1516
import {
1617
CreateTeamSwagger,
1718
FindAllTeamsSwagger,
@@ -38,6 +39,12 @@ export class TeamsController {
3839
return this.facade.getAll(userId, query);
3940
}
4041

42+
@Get('tags/all')
43+
@GetAllTagsSwagger()
44+
async getAllTags(@Query() query: FindTagsQuery) {
45+
return this.facade.getAllTags(query);
46+
}
47+
4148
@Get(':slug')
4249
@FindOneTeamSwagger()
4350
async findOne(@Param('slug') slug: string) {
@@ -59,13 +66,7 @@ export class TeamsController {
5966

6067
@Put(':slug/tags')
6168
@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);
69+
async syncTags(@Param('slug') slug: string, @Body() dto: SyncTagsDto) {
70+
return this.facade.syncTags(slug, dto.tags);
7071
}
7172
}

src/modules/teams/controller/teams.swagger.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { applyDecorators } from '@nestjs/common';
2-
import { ApiBody, ApiOperation, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
2+
import { ApiBody, ApiOperation, ApiParam, ApiResponse } from '@nestjs/swagger';
33
import { ActionResponse } from 'src/shared/dtos';
44
import {
55
ApiConflict,
@@ -8,7 +8,7 @@ import {
88
ApiUnauthorized,
99
ApiValidationError,
1010
} from 'src/shared/error';
11-
import { CreateTeamDto, InviteMemberDto, SyncTagsDto, TagResponse, UpdateTeamDto } from '../dtos';
11+
import { CreateTeamDto, InviteMemberDto, SyncTagsDto, UpdateTeamDto, TagsResponse } from '../dtos';
1212

1313
export const CreateTeamSwagger = () =>
1414
applyDecorators(
@@ -91,14 +91,14 @@ export const SyncTeamTagsSwagger = () =>
9191
export const GetAllTagsSwagger = () =>
9292
applyDecorators(
9393
ApiOperation({
94-
summary: 'Получить список всех тегов',
95-
description: 'Используется для поиска и автокомплита при создании команд.',
94+
summary: 'Получить список всех тегов с пагинацией',
95+
description:
96+
'Возвращает список всех тегов в системе с пагинацией. Используется для поиска и автокомплита при создании/редактировании команд.',
9697
}),
97-
ApiQuery({ name: 'search', required: false, description: 'Поиск по названию тега' }),
9898
ApiResponse({
9999
status: 200,
100100
description: 'Список тегов успешно получен',
101-
type: [TagResponse.Output],
101+
type: TagsResponse.Output,
102102
}),
103103
ApiUnauthorized(),
104104
);

src/modules/teams/dtos/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
export { InviteMemberDto } from './member.dto';
2-
export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagResponse } from './team.dto';
2+
export { CreateTeamDto, UpdateTeamDto, FindTagsQuery, SyncTagsDto, TagsResponse } from './team.dto';

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod/v4';
22
import { createZodDto } from 'nestjs-zod';
3+
import { createPaginationSchema } from '../../../shared/schemas';
34

45
export const CreateTeamSchema = z.object({
56
name: z.string().min(2).max(100).describe('Название команды, отображаемое в интерфейсе'),
@@ -35,14 +36,16 @@ export const SyncTagsSchema = z.object({
3536

3637
const FindTagsQuerySchema = z.object({
3738
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-
)
39+
page: z.coerce.number().int().min(1).default(1).describe('Номер страницы (от 1)'),
40+
limit: z.coerce
41+
.number()
42+
.int()
43+
.min(1)
44+
.max(100)
45+
.default(20)
4346
.describe('Количество возвращаемых результатов (1-100)'),
4447
});
4548

46-
export class TagResponse extends createZodDto(TagSchema) {}
49+
export class TagsResponse extends createZodDto(createPaginationSchema(TagSchema)) {}
4750
export class SyncTagsDto extends createZodDto(SyncTagsSchema) {}
4851
export class FindTagsQuery extends createZodDto(FindTagsQuerySchema) {}

src/modules/teams/repository/teams.repository.interface.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,11 @@ export interface ITeamsRepository {
1212
pagination: { search?: string; limit?: number; offset?: number },
1313
): Promise<Team[]>;
1414

15-
findAllTags(search?: string): Promise<Tag[]>;
15+
findAllTags(options: {
16+
search?: string;
17+
limit?: number;
18+
offset?: number;
19+
}): Promise<{ data: Tag[]; total: number }>;
1620
syncTags(teamId: string, tagNames: string[]): Promise<boolean>;
1721

1822
addMember(dto: NewTeamMember): Promise<TeamMember>;

src/modules/teams/repository/teams.repository.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Inject, Logger } from '@nestjs/common';
22
import { ITeamsRepository } from './teams.repository.interface';
33
import { DATABASE_SERVICE, DatabaseService } from '@libs/database';
44
import * as schema from '../entities';
5+
import { asc, count, eq, ilike, inArray } from 'drizzle-orm';
56

67
export class TeamsRepository implements ITeamsRepository {
78
private logger = new Logger(TeamsRepository.name);
@@ -29,9 +30,30 @@ export class TeamsRepository implements ITeamsRepository {
2930
return [];
3031
};
3132

32-
public findAllTags = async (search?: string) => {
33-
this.logger.log(search);
34-
return [];
33+
public findAllTags = async (options: { search?: string; limit?: number; offset?: number }) => {
34+
const cleanSearch = options.search?.trim();
35+
const escapedSearch = cleanSearch?.replace(/([%_\\])/g, '\\$1');
36+
37+
const whereCondition = escapedSearch
38+
? ilike(schema.tags.name, `%${escapedSearch}%`)
39+
: undefined;
40+
41+
const [data, [{ total }]] = await Promise.all([
42+
this.db
43+
.select()
44+
.from(schema.tags)
45+
.where(whereCondition)
46+
.limit(options.limit)
47+
.offset(options.offset)
48+
.orderBy(asc(schema.tags.name)),
49+
50+
this.db.select({ total: count() }).from(schema.tags).where(whereCondition),
51+
]);
52+
53+
return {
54+
data,
55+
total: Number(total ?? 0),
56+
};
3557
};
3658

3759
public findBySlug = async (slug: string) => {
@@ -49,9 +71,30 @@ export class TeamsRepository implements ITeamsRepository {
4971
return Promise.resolve(true);
5072
};
5173

52-
public syncTags = async (teamId: string, tags: string[]) => {
53-
this.logger.log(teamId, tags);
54-
return Promise.resolve(true);
74+
public syncTags = async (teamId: string, tagNames: string[]) => {
75+
await this.db.transaction(async (tx) => {
76+
await tx.delete(schema.teamsToTags).where(eq(schema.teamsToTags.teamId, teamId));
77+
78+
if (tagNames.length === 0) {
79+
return;
80+
}
81+
82+
await tx
83+
.insert(schema.tags)
84+
.values(tagNames.map((name) => ({ name })))
85+
.onConflictDoNothing({ target: schema.tags.name });
86+
87+
const existingTags = await tx
88+
.select({ id: schema.tags.id })
89+
.from(schema.tags)
90+
.where(inArray(schema.tags.name, tagNames));
91+
92+
await tx
93+
.insert(schema.teamsToTags)
94+
.values(existingTags.map((tag) => ({ teamId, tagId: tag.id })));
95+
});
96+
97+
return true;
5598
};
5699

57100
public update = async (id: string, dto: Partial<schema.Team>) => {

src/modules/teams/services/teams.service.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { Inject, Injectable } from '@nestjs/common';
1+
import {
2+
Inject,
3+
Injectable,
4+
InternalServerErrorException,
5+
NotFoundException,
6+
} from '@nestjs/common';
27
import { ITeamsRepository } from '../repository';
8+
import { FindTagsQuery } from '../dtos';
39

410
@Injectable()
511
export class TeamsService {
@@ -20,8 +26,26 @@ export class TeamsService {
2026
return { slug };
2127
};
2228

23-
public syncTags = (slug: string, tags: string[]) => {
24-
return { slug, tags };
29+
public syncTags = async (slug: string, tags: string[]) => {
30+
const team = await this.teamsRepo.findBySlug(slug);
31+
if (!team) {
32+
throw new NotFoundException({
33+
code: 'TEAM_NOT_FOUND',
34+
message: 'Команда не найдена',
35+
});
36+
}
37+
38+
const normalizedTags = [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
39+
const isSynced = await this.teamsRepo.syncTags(team.id, normalizedTags);
40+
41+
if (!isSynced) {
42+
throw new InternalServerErrorException('Не удалось обновить теги команды');
43+
}
44+
45+
return {
46+
success: true,
47+
message: 'Теги команды обновлены',
48+
};
2549
};
2650

2751
public getAll = (userId: string, pagination: Record<string, string>) => {
@@ -32,8 +56,29 @@ export class TeamsService {
3256
return { slug };
3357
};
3458

35-
public getAllTags = (search?: string) => {
36-
return { search };
59+
public getAllTags = async (query: FindTagsQuery) => {
60+
const safePage = Math.max(query.page ?? 1, 1);
61+
const safeLimit = Math.min(Math.max(query.limit ?? 20, 1), 50);
62+
const offset = (safePage - 1) * safeLimit;
63+
64+
const { data, total } = await this.teamsRepo.findAllTags({
65+
search: query.search,
66+
limit: safeLimit,
67+
offset,
68+
});
69+
70+
const totalPages = total === 0 ? 0 : Math.ceil(total / safeLimit);
71+
return {
72+
data,
73+
meta: {
74+
hasNextPage: safePage < totalPages,
75+
hasPrevPage: safePage > 1,
76+
total,
77+
totalPages,
78+
page: safePage,
79+
limit: safeLimit,
80+
},
81+
};
3782
};
3883

3984
public getMembers = (slug: string) => {

src/shared/schemas/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './pagination-response.schema';
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { z } from 'zod/v4';
2+
3+
export const paginationResponseSchema = z.object({
4+
hasNextPage: z
5+
.boolean()
6+
.describe('Флаг наличия следующей страницы. True, если текущая страница не последняя.'),
7+
hasPrevPage: z
8+
.boolean()
9+
.describe('Флаг наличия предыдущей страницы. True, если текущая страница больше первой.'),
10+
total: z
11+
.number()
12+
.int()
13+
.nonnegative()
14+
.describe('Общее количество записей, соответствующих поисковому запросу/фильтрам.'),
15+
totalPages: z
16+
.number()
17+
.int()
18+
.nonnegative()
19+
.describe('Общее количество страниц, рассчитанное на основе limit.'),
20+
page: z.number().int().positive().describe('Номер текущей страницы (начиная с 1).'),
21+
limit: z.number().int().positive().describe('Количество элементов на одну страницу.'),
22+
});
23+
24+
export const createPaginationSchema = <T extends z.ZodTypeAny>(itemSchema: T) => {
25+
return z.object({
26+
data: z.array(itemSchema),
27+
meta: paginationResponseSchema,
28+
});
29+
};

0 commit comments

Comments
 (0)