From e93dd61774a6103abc4921e05ac73e41fb58bab6 Mon Sep 17 00:00:00 2001 From: Copstud3 Date: Tue, 28 Jul 2026 15:27:37 +0100 Subject: [PATCH] Add submission approval and rejection functionality - Implemented approveSubmission and rejectSubmission methods in MissionsController and MissionsService. - Added RejectSubmissionDto for handling rejection reasons. - Updated Submission model to include rejectionReason field. - Enhanced service tests to cover submission review scenarios. - Created migration for adding rejection_reason column to submissions table. --- .../migration.sql | 1 + backend/prisma/schema.prisma | 1 + .../src/missions/dto/reject-submission.dto.ts | 8 ++ .../src/missions/missions.controller.spec.ts | 44 +++++- backend/src/missions/missions.controller.ts | 31 +++++ backend/src/missions/missions.service.spec.ts | 130 +++++++++++++++++- backend/src/missions/missions.service.ts | 94 ++++++++++++- 7 files changed, 301 insertions(+), 8 deletions(-) create mode 100644 backend/prisma/migrations/20260728000000_add_submission_rejection_reason/migration.sql create mode 100644 backend/src/missions/dto/reject-submission.dto.ts diff --git a/backend/prisma/migrations/20260728000000_add_submission_rejection_reason/migration.sql b/backend/prisma/migrations/20260728000000_add_submission_rejection_reason/migration.sql new file mode 100644 index 0000000..c3488f0 --- /dev/null +++ b/backend/prisma/migrations/20260728000000_add_submission_rejection_reason/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "submissions" ADD COLUMN "rejection_reason" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e239a76..f7580ca 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -91,6 +91,7 @@ model Submission { textPayload String? @map("text_payload") sentiment Float? @map("sentiment") status SubmissionStatus @default(PENDING) @map("status") + rejectionReason String? @map("rejection_reason") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/missions/dto/reject-submission.dto.ts b/backend/src/missions/dto/reject-submission.dto.ts new file mode 100644 index 0000000..4494e15 --- /dev/null +++ b/backend/src/missions/dto/reject-submission.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class RejectSubmissionDto { + @IsOptional() + @IsString() + @MaxLength(1000) + reason?: string; +} diff --git a/backend/src/missions/missions.controller.spec.ts b/backend/src/missions/missions.controller.spec.ts index 6a74583..530416d 100644 --- a/backend/src/missions/missions.controller.spec.ts +++ b/backend/src/missions/missions.controller.spec.ts @@ -14,7 +14,8 @@ describe('MissionsController', () => { listPublicMissions: jest.Mock; getMissionSubmissions: jest.Mock; getMission: jest.Mock; - + approveSubmission: jest.Mock; + rejectSubmission: jest.Mock; saveDraft: jest.Mock; }; @@ -23,10 +24,9 @@ describe('MissionsController', () => { listPublicMissions: jest.fn(), getMissionSubmissions: jest.fn(), - - getMissionSubmissions: jest.fn(), - getMission: jest.fn(), + approveSubmission: jest.fn(), + rejectSubmission: jest.fn(), saveDraft: jest.fn(), }; @@ -86,6 +86,42 @@ describe('MissionsController', () => { ).rejects.toThrow(NotFoundException); }); + it('delegates approval with the authenticated owner address', async () => { + const approved = { id: 'sub-1', status: 'APPROVED' }; + missionsService.approveSubmission.mockResolvedValue(approved); + + await expect( + controller.approveSubmission('mission-1', 'sub-1', { + user: { userId: 'user-1', address: '0xabc' }, + } as any), + ).resolves.toEqual(approved); + expect(missionsService.approveSubmission).toHaveBeenCalledWith( + 'mission-1', + 'sub-1', + '0xabc', + ); + }); + + it('delegates rejection with an optional reason', async () => { + const rejected = { id: 'sub-1', status: 'REJECTED' }; + missionsService.rejectSubmission.mockResolvedValue(rejected); + + await expect( + controller.rejectSubmission( + 'mission-1', + 'sub-1', + { reason: 'Incomplete work' }, + { user: { userId: 'user-1', address: '0xabc' } } as any, + ), + ).resolves.toEqual(rejected); + expect(missionsService.rejectSubmission).toHaveBeenCalledWith( + 'mission-1', + 'sub-1', + '0xabc', + 'Incomplete work', + ); + }); + it('forwards the mission id to the service and returns the result', async () => { const mockMission = { id: 'mission-1', title: 'Test' }; missionsService.getMission.mockResolvedValue(mockMission); diff --git a/backend/src/missions/missions.controller.ts b/backend/src/missions/missions.controller.ts index 90a2838..a166713 100644 --- a/backend/src/missions/missions.controller.ts +++ b/backend/src/missions/missions.controller.ts @@ -13,6 +13,7 @@ import { Request } from 'express'; import { MissionsService } from './missions.service'; import { ListMissionsQueryDto } from './dto/list-missions-query.dto'; import { SaveDraftDto } from './dto/save-draft.dto'; +import { RejectSubmissionDto } from './dto/reject-submission.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { Prisma } from '@prisma/client'; @@ -52,6 +53,36 @@ export class MissionsController { return this.missionsService.getMissionSubmissions(id, req.user.address); } + @Post(':missionId/submissions/:id/approve') + @UseGuards(JwtAuthGuard) + approveSubmission( + @Param('missionId') missionId: string, + @Param('id') id: string, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.missionsService.approveSubmission( + missionId, + id, + req.user.address, + ); + } + + @Post(':missionId/submissions/:id/reject') + @UseGuards(JwtAuthGuard) + rejectSubmission( + @Param('missionId') missionId: string, + @Param('id') id: string, + @Body() dto: RejectSubmissionDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.missionsService.rejectSubmission( + missionId, + id, + req.user.address, + dto.reason, + ); + } + @Post('drafts') @UseGuards(JwtAuthGuard) saveDraft( diff --git a/backend/src/missions/missions.service.spec.ts b/backend/src/missions/missions.service.spec.ts index a253a67..19c4f34 100644 --- a/backend/src/missions/missions.service.spec.ts +++ b/backend/src/missions/missions.service.spec.ts @@ -1,11 +1,15 @@ -import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + ForbiddenException, + NotFoundException, +} from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { MissionsService } from './missions.service'; import { MissionListSort } from './dto/list-missions-query.dto'; -import { MissionStatus } from '@prisma/client'; +import { MissionStatus, SubmissionStatus } from '@prisma/client'; const listInclude = { owner: { select: { address: true, displayName: true } }, @@ -21,7 +25,11 @@ describe('MissionsService', () => { let service: MissionsService; let prisma: { mission: { findMany: jest.Mock; findUnique: jest.Mock }; - submission: { findMany: jest.Mock }; + submission: { + findMany: jest.Mock; + findUnique: jest.Mock; + updateMany: jest.Mock; + }; missionDraft: { findFirst: jest.Mock; @@ -38,6 +46,8 @@ describe('MissionsService', () => { }, submission: { findMany: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn(), }, missionDraft: { @@ -208,6 +218,120 @@ describe('MissionsService', () => { }); }); + describe('submission review', () => { + beforeEach(() => { + prisma.mission.findUnique.mockResolvedValue({ + ownerAddress: '0xowner', + }); + prisma.submission.findUnique + .mockResolvedValueOnce({ + id: 'sub-1', + missionId: 'mission-1', + status: SubmissionStatus.PENDING, + }) + .mockResolvedValueOnce({ + id: 'sub-1', + missionId: 'mission-1', + status: SubmissionStatus.APPROVED, + }); + prisma.submission.updateMany.mockResolvedValue({ count: 1 }); + }); + + it('approves a pending submission for the mission owner', async () => { + const result = await service.approveSubmission( + 'mission-1', + 'sub-1', + '0xowner', + ); + + expect(prisma.submission.updateMany).toHaveBeenCalledWith({ + where: { + id: 'sub-1', + missionId: 'mission-1', + status: SubmissionStatus.PENDING, + }, + data: { + status: SubmissionStatus.APPROVED, + rejectionReason: null, + }, + }); + expect(result).toEqual( + expect.objectContaining({ status: SubmissionStatus.APPROVED }), + ); + }); + + it('rejects a pending submission and persists the trimmed reason', async () => { + prisma.submission.findUnique + .mockReset() + .mockResolvedValueOnce({ + id: 'sub-1', + missionId: 'mission-1', + status: SubmissionStatus.PENDING, + }) + .mockResolvedValueOnce({ + id: 'sub-1', + status: SubmissionStatus.REJECTED, + rejectionReason: 'Incomplete work', + }); + + await service.rejectSubmission( + 'mission-1', + 'sub-1', + '0xowner', + ' Incomplete work ', + ); + + expect(prisma.submission.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + data: { + status: SubmissionStatus.REJECTED, + rejectionReason: 'Incomplete work', + }, + }), + ); + }); + + it('returns 403 without changing the submission for a non-owner', async () => { + await expect( + service.approveSubmission('mission-1', 'sub-1', '0xother'), + ).rejects.toThrow(ForbiddenException); + expect(prisma.submission.updateMany).not.toHaveBeenCalled(); + }); + + it('rejects a transition from a terminal review status', async () => { + prisma.submission.findUnique.mockReset().mockResolvedValue({ + id: 'sub-1', + missionId: 'mission-1', + status: SubmissionStatus.REJECTED, + }); + + await expect( + service.approveSubmission('mission-1', 'sub-1', '0xowner'), + ).rejects.toThrow(ConflictException); + expect(prisma.submission.updateMany).not.toHaveBeenCalled(); + }); + + it('rejects a concurrent transition when the pending update loses the race', async () => { + prisma.submission.updateMany.mockResolvedValue({ count: 0 }); + + await expect( + service.approveSubmission('mission-1', 'sub-1', '0xowner'), + ).rejects.toThrow(ConflictException); + }); + + it('returns 404 when the submission belongs to another mission', async () => { + prisma.submission.findUnique.mockReset().mockResolvedValue({ + id: 'sub-1', + missionId: 'mission-2', + status: SubmissionStatus.PENDING, + }); + + await expect( + service.rejectSubmission('mission-1', 'sub-1', '0xowner'), + ).rejects.toThrow(NotFoundException); + }); + }); + describe('saveDraft', () => { it('creates a draft when no existing draft is found', async () => { prisma.missionDraft.findFirst.mockResolvedValue(null); diff --git a/backend/src/missions/missions.service.ts b/backend/src/missions/missions.service.ts index d3efe72..ca28902 100644 --- a/backend/src/missions/missions.service.ts +++ b/backend/src/missions/missions.service.ts @@ -2,9 +2,10 @@ import { Injectable, ForbiddenException, NotFoundException, + ConflictException, } from '@nestjs/common'; -import { MissionStatus, Prisma } from '@prisma/client'; +import { MissionStatus, Prisma, SubmissionStatus } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -156,4 +157,95 @@ export class MissionsService { return submissions; } + + async approveSubmission( + missionId: string, + submissionId: string, + ownerAddress: string, + ): Promise { + return this.reviewSubmission( + missionId, + submissionId, + ownerAddress, + SubmissionStatus.APPROVED, + ); + } + + async rejectSubmission( + missionId: string, + submissionId: string, + ownerAddress: string, + reason?: string, + ): Promise { + return this.reviewSubmission( + missionId, + submissionId, + ownerAddress, + SubmissionStatus.REJECTED, + reason, + ); + } + + private async reviewSubmission( + missionId: string, + submissionId: string, + ownerAddress: string, + status: SubmissionStatus, + rejectionReason?: string, + ): Promise { + const mission = await this.prisma.mission.findUnique({ + where: { id: missionId }, + select: { ownerAddress: true }, + }); + + if (!mission) { + throw new NotFoundException(`Mission ${missionId} not found`); + } + + if (mission.ownerAddress !== ownerAddress) { + throw new ForbiddenException( + 'You are not authorized to review submissions for this mission', + ); + } + + const submission = await this.prisma.submission.findUnique({ + where: { id: submissionId }, + select: { id: true, missionId: true, status: true }, + }); + + if (!submission || submission.missionId !== missionId) { + throw new NotFoundException(`Submission ${submissionId} not found`); + } + + if (submission.status !== SubmissionStatus.PENDING) { + throw new ConflictException( + `Submission ${submissionId} cannot transition from ${submission.status} to ${status}`, + ); + } + + const result = await this.prisma.submission.updateMany({ + where: { + id: submissionId, + missionId, + status: SubmissionStatus.PENDING, + }, + data: { + status, + rejectionReason: + status === SubmissionStatus.REJECTED + ? rejectionReason?.trim() || null + : null, + }, + }); + + if (result.count !== 1) { + throw new ConflictException( + `Submission ${submissionId} is no longer pending`, + ); + } + + return this.prisma.submission.findUnique({ + where: { id: submissionId }, + }); + } }