diff --git a/src/solutions/dto/batch-solution-action.dto.ts b/src/solutions/dto/batch-solution-action.dto.ts new file mode 100644 index 0000000..2e926b3 --- /dev/null +++ b/src/solutions/dto/batch-solution-action.dto.ts @@ -0,0 +1,18 @@ +import { IsArray, ArrayMaxSize, IsEnum, IsOptional, IsString, IsIn } from 'class-validator'; + +export const BATCH_ACTIONS = ['delete', 'bookmark', 'update-status'] as const; +export type BatchSolutionAction = (typeof BATCH_ACTIONS)[number]; + +export class BatchSolutionActionDto { + @IsIn(BATCH_ACTIONS, { message: 'action must be one of delete, bookmark, update-status' }) + action: BatchSolutionAction; + + @IsArray() + @ArrayMaxSize(50, { message: 'Batch size cannot exceed 50 items' }) + @IsString({ each: true }) + solutionIds: string[]; + + @IsOptional() + @IsIn(['active', 'hidden'], { message: 'status must be active or hidden' }) + status?: string; +} diff --git a/src/solutions/entities/solution.entity.ts b/src/solutions/entities/solution.entity.ts index 036dba9..da5dfdc 100644 --- a/src/solutions/entities/solution.entity.ts +++ b/src/solutions/entities/solution.entity.ts @@ -28,4 +28,10 @@ export class Solution { @Column({ nullable: true }) meshNodeId: number; + + @Column({ default: false }) + isBookmarked: boolean; + + @Column({ nullable: true }) + bookmarkedBy: string; } diff --git a/src/solutions/solutions.batch.spec.ts b/src/solutions/solutions.batch.spec.ts new file mode 100644 index 0000000..bc6e168 --- /dev/null +++ b/src/solutions/solutions.batch.spec.ts @@ -0,0 +1,86 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { BadRequestException } from '@nestjs/common'; +import { SolutionsService } from './solutions.service'; +import { Solution } from './entities/solution.entity'; +import { SolutionRevision } from './entities/solution-revision.entity'; +import { ReputationService } from '../users/reputation.service'; + +describe('SolutionsService.batchAction', () => { + let service: SolutionsService; + let solutionRepo: { + findOneBy: jest.Mock; + save: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(async () => { + solutionRepo = { + findOneBy: jest.fn(), + save: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SolutionsService, + { + provide: getRepositoryToken(Solution), + useValue: solutionRepo, + }, + { provide: getRepositoryToken(SolutionRevision), useValue: {} }, + { provide: ReputationService, useValue: { addPoints: jest.fn() } }, + ], + }).compile(); + + service = module.get(SolutionsService); + }); + + it('returns per-item results for delete operations and skips missing solutions', async () => { + solutionRepo.findOneBy.mockImplementation(({ id }) => { + if (id === 'existing') { + return Promise.resolve({ id, status: 'active' }); + } + return Promise.resolve(null); + }); + + const result = await service.batchAction( + { action: 'delete', solutionIds: ['existing', 'missing'] }, + { role: 'moderator' }, + ); + + expect(result).toEqual({ + results: [ + { id: 'existing', success: true }, + { id: 'missing', success: false, error: 'Not found' }, + ], + }); + expect(solutionRepo.delete).toHaveBeenCalledWith('existing'); + }); + + it('bookmarks solutions for authenticated users', async () => { + solutionRepo.findOneBy.mockResolvedValue({ id: 's-1', isBookmarked: false }); + + const result = await service.batchAction( + { action: 'bookmark', solutionIds: ['s-1'] }, + { id: 'user-1' }, + ); + + expect(result.results).toEqual([{ id: 's-1', success: true }]); + expect(solutionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 's-1', isBookmarked: true, bookmarkedBy: 'user-1' }), + ); + }); + + it('rejects batches larger than 50 items', async () => { + await expect( + service.batchAction( + { + action: 'delete', + solutionIds: Array.from({ length: 51 }, (_, index) => `s-${index}`), + }, + { role: 'admin' }, + ), + ).rejects.toThrow(BadRequestException); + }); +}); diff --git a/src/solutions/solutions.controller.ts b/src/solutions/solutions.controller.ts index 4df606b..6123984 100644 --- a/src/solutions/solutions.controller.ts +++ b/src/solutions/solutions.controller.ts @@ -7,6 +7,8 @@ import { Param, ParseIntPipe, Query, + UseGuards, + Request, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { SolutionsService } from './solutions.service'; @@ -14,12 +16,25 @@ import { CursorPaginationQueryDto } from '../common/dto/cursor-pagination-query. import { ApiCursorPaginated } from '../common/decorators/api-cursor-paginated.decorator'; import { ApiAppErrorResponse } from '../common/decorators/api-error-response.decorator'; import { Solution } from './entities/solution.entity'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/roles.decorator'; +import { BatchSolutionActionDto } from './dto/batch-solution-action.dto'; @ApiTags('Solutions') -@Controller('v1/solutions') +@Controller(['v1/solutions', 'api/v1/solutions']) +@UseGuards(JwtAuthGuard) export class SolutionsController { constructor(private readonly solutionsService: SolutionsService) {} + @Post('batch') + batchAction( + @Body() body: BatchSolutionActionDto, + @Request() req: any, + ) { + return this.solutionsService.batchAction(body, req.user); + } + @Post() create(@Body() body: { meshNodeId: number; content: string; authorId: string }) { return this.solutionsService.create(body); diff --git a/src/solutions/solutions.service.ts b/src/solutions/solutions.service.ts index 9095c51..dc57ab2 100644 --- a/src/solutions/solutions.service.ts +++ b/src/solutions/solutions.service.ts @@ -1,4 +1,9 @@ -import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; +import { + Injectable, + BadRequestException, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Solution } from './entities/solution.entity'; @@ -9,6 +14,7 @@ import { DEFAULT_PAGE_SIZE, } from '../common/dto/cursor-pagination-query.dto'; import { CursorPaginatedResponse } from '../common/interfaces/cursor-paginated-response.interface'; +import { BatchSolutionActionDto } from './dto/batch-solution-action.dto'; import { CursorSpec, buildCursorPage, @@ -118,6 +124,68 @@ export class SolutionsService { })); } + async batchAction(dto: BatchSolutionActionDto, user?: any) { + if (dto.solutionIds.length > 50) { + throw new BadRequestException('Batch size cannot exceed 50 items'); + } + + const results = [] as Array<{ id: string; success: boolean; error?: string }>; + + for (const solutionId of dto.solutionIds) { + try { + const solution = await this.solutionRepo.findOneBy({ id: solutionId }); + if (!solution) { + results.push({ id: solutionId, success: false, error: 'Not found' }); + continue; + } + + const canPerform = this.canPerformAction(dto.action, solution, user); + if (!canPerform) { + results.push({ id: solutionId, success: false, error: 'Not authorized' }); + continue; + } + + switch (dto.action) { + case 'delete': + await this.solutionRepo.delete(solution.id); + break; + case 'bookmark': + solution.isBookmarked = true; + solution.bookmarkedBy = user?.id; + await this.solutionRepo.save(solution); + break; + case 'update-status': + solution.status = dto.status ?? 'active'; + await this.solutionRepo.save(solution); + break; + default: + results.push({ id: solutionId, success: false, error: 'Unsupported action' }); + continue; + } + + results.push({ id: solutionId, success: true }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + results.push({ id: solutionId, success: false, error: message }); + } + } + + return { results }; + } + + private canPerformAction(action: BatchSolutionActionDto['action'], solution: Solution, user?: any) { + if (action === 'bookmark') { + return Boolean(user?.id); + } + + if (action === 'delete' || action === 'update-status') { + const role = user?.role?.toLowerCase(); + return role === 'moderator' || role === 'admin'; + } + + return false; + } + async update(id: string, dto: { content: string; editorId: string }) { const solution = await this.solutionRepo.findOneBy({ id }); if (!solution) throw new NotFoundException('Solution not found');