Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/solutions/dto/batch-solution-action.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 6 additions & 0 deletions src/solutions/entities/solution.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,10 @@ export class Solution {

@Column({ nullable: true })
meshNodeId: number;

@Column({ default: false })
isBookmarked: boolean;

@Column({ nullable: true })
bookmarkedBy: string;
}
86 changes: 86 additions & 0 deletions src/solutions/solutions.batch.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 16 additions & 1 deletion src/solutions/solutions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,34 @@ import {
Param,
ParseIntPipe,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { SolutionsService } from './solutions.service';
import { CursorPaginationQueryDto } from '../common/dto/cursor-pagination-query.dto';
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);
Expand Down
70 changes: 69 additions & 1 deletion src/solutions/solutions.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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');
Expand Down