Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "submissions" ADD COLUMN "rejection_reason" TEXT;
1 change: 1 addition & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
8 changes: 8 additions & 0 deletions backend/src/missions/dto/reject-submission.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';

export class RejectSubmissionDto {
@IsOptional()
@IsString()
@MaxLength(1000)
reason?: string;
}
44 changes: 40 additions & 4 deletions backend/src/missions/missions.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ describe('MissionsController', () => {
listPublicMissions: jest.Mock;
getMissionSubmissions: jest.Mock;
getMission: jest.Mock;

approveSubmission: jest.Mock;
rejectSubmission: jest.Mock;
saveDraft: jest.Mock;
};

Expand All @@ -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(),
};

Expand Down Expand Up @@ -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);
Expand Down
31 changes: 31 additions & 0 deletions backend/src/missions/missions.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<unknown> {
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<unknown> {
return this.missionsService.rejectSubmission(
missionId,
id,
req.user.address,
dto.reason,
);
}

@Post('drafts')
@UseGuards(JwtAuthGuard)
saveDraft(
Expand Down
130 changes: 127 additions & 3 deletions backend/src/missions/missions.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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 } },
Expand All @@ -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;
Expand All @@ -38,6 +46,8 @@ describe('MissionsService', () => {
},
submission: {
findMany: jest.fn(),
findUnique: jest.fn(),
updateMany: jest.fn(),
},

missionDraft: {
Expand Down Expand Up @@ -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);
Expand Down
94 changes: 93 additions & 1 deletion backend/src/missions/missions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -156,4 +157,95 @@ export class MissionsService {

return submissions;
}

async approveSubmission(
missionId: string,
submissionId: string,
ownerAddress: string,
): Promise<unknown> {
return this.reviewSubmission(
missionId,
submissionId,
ownerAddress,
SubmissionStatus.APPROVED,
);
}

async rejectSubmission(
missionId: string,
submissionId: string,
ownerAddress: string,
reason?: string,
): Promise<unknown> {
return this.reviewSubmission(
missionId,
submissionId,
ownerAddress,
SubmissionStatus.REJECTED,
reason,
);
}

private async reviewSubmission(
missionId: string,
submissionId: string,
ownerAddress: string,
status: SubmissionStatus,
rejectionReason?: string,
): Promise<unknown> {
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 },
});
}
}
Loading