From 77c5dc4c9d3ee35d50161299f1596ad7c30cd14f Mon Sep 17 00:00:00 2001 From: edrizxabdulganiyu-blip Date: Tue, 30 Jun 2026 14:37:24 +0000 Subject: [PATCH] feat: add backend support for saving draft submissions - Add DRAFT status to SubmissionStatus enum - Add isDraft and draftSavedAt fields to SubmissionEntity and ISubmission - Add SaveDraftDto for create/update draft requests - Add saveDraft (upsert), findDraftsByUserId, and publishDraft to SubmissionService - Add POST /submissions/draft, GET /submissions/user/:userId/drafts, and POST /submissions/:id/publish endpoints to SubmissionController - Export SaveDraftDto from submissions index - Add 19 unit tests covering all draft methods (all passing) --- .../src/submissions/dto/save-draft.dto.ts | 23 ++ BackendAcademy/src/submissions/index.ts | 1 + .../interfaces/submission-status.enum.ts | 1 + .../interfaces/submission.interface.ts | 4 + .../src/submissions/submission.controller.ts | 33 +++ .../src/submissions/submission.entity.ts | 5 + .../submission.service.draft.spec.ts | 206 ++++++++++++++++++ .../src/submissions/submission.service.ts | 84 ++++++- 8 files changed, 356 insertions(+), 1 deletion(-) create mode 100644 BackendAcademy/src/submissions/dto/save-draft.dto.ts create mode 100644 BackendAcademy/src/submissions/submission.service.draft.spec.ts diff --git a/BackendAcademy/src/submissions/dto/save-draft.dto.ts b/BackendAcademy/src/submissions/dto/save-draft.dto.ts new file mode 100644 index 000000000..4695a97a6 --- /dev/null +++ b/BackendAcademy/src/submissions/dto/save-draft.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsOptional } from 'class-validator'; + +/** + * DTO for saving or updating a submission draft. + * + * All content fields are optional so partial progress can be persisted + * without requiring a fully formed submission. + */ +export class SaveDraftDto { + @IsString() + taskId: string; + + @IsString() + userId: string; + + @IsOptional() + @IsString() + content?: string; + + @IsOptional() + @IsString() + fileUrl?: string; +} diff --git a/BackendAcademy/src/submissions/index.ts b/BackendAcademy/src/submissions/index.ts index 0239187e3..b3e87b621 100644 --- a/BackendAcademy/src/submissions/index.ts +++ b/BackendAcademy/src/submissions/index.ts @@ -5,6 +5,7 @@ export { SubmissionStatus } from './interfaces/submission-status.enum'; export { ISubmission } from './interfaces/submission.interface'; export { CreateSubmissionDto } from './dto/create-submission.dto'; export { UpdateSubmissionDto } from './dto/update-submission.dto'; +export { SaveDraftDto } from './dto/save-draft.dto'; export { ReviewSubmissionDto } from './dto/review-submission.dto'; export { ReviewQueueQueryDto } from './dto/review-queue-query.dto'; export { TutorReviewService } from './tutor-review.service'; diff --git a/BackendAcademy/src/submissions/interfaces/submission-status.enum.ts b/BackendAcademy/src/submissions/interfaces/submission-status.enum.ts index 8cf2cf898..ca94cf041 100644 --- a/BackendAcademy/src/submissions/interfaces/submission-status.enum.ts +++ b/BackendAcademy/src/submissions/interfaces/submission-status.enum.ts @@ -1,4 +1,5 @@ export enum SubmissionStatus { + DRAFT = 'draft', PENDING = 'pending', APPROVED = 'approved', REJECTED = 'rejected', diff --git a/BackendAcademy/src/submissions/interfaces/submission.interface.ts b/BackendAcademy/src/submissions/interfaces/submission.interface.ts index 7698f817f..0cb756a70 100644 --- a/BackendAcademy/src/submissions/interfaces/submission.interface.ts +++ b/BackendAcademy/src/submissions/interfaces/submission.interface.ts @@ -9,6 +9,10 @@ export interface ISubmission { status: SubmissionStatus; feedback?: string; score?: number; + /** True when this submission is saved as a draft and not yet submitted. */ + isDraft: boolean; + /** Timestamp of the last time this draft was saved. Null for non-drafts. */ + draftSavedAt?: Date; submittedAt: Date; reviewedAt?: Date; reviewedBy?: string; diff --git a/BackendAcademy/src/submissions/submission.controller.ts b/BackendAcademy/src/submissions/submission.controller.ts index 78567f709..f70ae6897 100644 --- a/BackendAcademy/src/submissions/submission.controller.ts +++ b/BackendAcademy/src/submissions/submission.controller.ts @@ -14,6 +14,7 @@ import { SubmissionService } from './submission.service'; import { GradingResultService } from './grading-result.service'; import { CreateSubmissionDto } from './dto/create-submission.dto'; import { UpdateSubmissionDto } from './dto/update-submission.dto'; +import { SaveDraftDto } from './dto/save-draft.dto'; import { SaveGradingResultDto } from './dto/save-grading-result.dto'; import { SubmissionStatus } from './interfaces/submission-status.enum'; @@ -48,6 +49,11 @@ export class SubmissionController { return this.submissionService.findByUserId(userId); } + @Get('user/:userId/drafts') + async findDraftsByUserId(@Param('userId') userId: string) { + return this.submissionService.findDraftsByUserId(userId); + } + @Get('status/:status') async findByStatus(@Param('status') status: SubmissionStatus) { return this.submissionService.findByStatus(status); @@ -82,6 +88,33 @@ export class SubmissionController { return this.submissionService.remove(id); } + // --------------------------------------------------------------------------- + // Draft endpoints + // --------------------------------------------------------------------------- + + /** + * POST /submissions/draft + * + * Create or update a draft submission. If a draft already exists for the + * same userId + taskId it is updated (upsert). Otherwise a new draft is + * created with status = DRAFT. + */ + @Post('draft') + async saveDraft(@Body() dto: SaveDraftDto) { + return this.submissionService.saveDraft(dto); + } + + /** + * POST /submissions/:id/publish + * + * Promote a draft submission to PENDING status, entering the normal review + * workflow. Returns 400 if the submission is not a draft. + */ + @Post(':id/publish') + async publishDraft(@Param('id', ParseUUIDPipe) id: string) { + return this.submissionService.publishDraft(id); + } + // --------------------------------------------------------------------------- // Grading results // --------------------------------------------------------------------------- diff --git a/BackendAcademy/src/submissions/submission.entity.ts b/BackendAcademy/src/submissions/submission.entity.ts index d2e7f7d89..9dabf7048 100644 --- a/BackendAcademy/src/submissions/submission.entity.ts +++ b/BackendAcademy/src/submissions/submission.entity.ts @@ -9,6 +9,10 @@ export class SubmissionEntity { status: SubmissionStatus; feedback?: string; score?: number; + /** True when this submission is saved as a draft and not yet submitted. */ + isDraft: boolean; + /** Timestamp of the last time this draft was saved. Null for non-drafts. */ + draftSavedAt?: Date; submittedAt: Date; reviewedAt?: Date; reviewedBy?: string; @@ -18,6 +22,7 @@ export class SubmissionEntity { constructor(partial: Partial) { Object.assign(this, partial); this.status = this.status || SubmissionStatus.PENDING; + this.isDraft = this.isDraft ?? false; this.submittedAt = this.submittedAt || new Date(); this.createdAt = this.createdAt || new Date(); this.updatedAt = this.updatedAt || new Date(); diff --git a/BackendAcademy/src/submissions/submission.service.draft.spec.ts b/BackendAcademy/src/submissions/submission.service.draft.spec.ts new file mode 100644 index 000000000..299a0e76f --- /dev/null +++ b/BackendAcademy/src/submissions/submission.service.draft.spec.ts @@ -0,0 +1,206 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { SubmissionService } from './submission.service'; +import { SubmissionStatus } from './interfaces/submission-status.enum'; +import { SaveDraftDto } from './dto/save-draft.dto'; + +describe('SubmissionService — draft methods', () => { + let service: SubmissionService; + + const USER_A = 'user-aaa'; + const USER_B = 'user-bbb'; + const TASK_1 = 'task-001'; + const TASK_2 = 'task-002'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [SubmissionService], + }).compile(); + + service = module.get(SubmissionService); + }); + + // ------------------------------------------------------------------------- + // saveDraft + // ------------------------------------------------------------------------- + + describe('saveDraft', () => { + it('creates a new draft with DRAFT status and isDraft=true', async () => { + const dto: SaveDraftDto = { userId: USER_A, taskId: TASK_1, content: 'partial code' }; + const draft = await service.saveDraft(dto); + + expect(draft.id).toBeDefined(); + expect(draft.userId).toBe(USER_A); + expect(draft.taskId).toBe(TASK_1); + expect(draft.content).toBe('partial code'); + expect(draft.status).toBe(SubmissionStatus.DRAFT); + expect(draft.isDraft).toBe(true); + expect(draft.draftSavedAt).toBeInstanceOf(Date); + }); + + it('defaults content to an empty string when not provided', async () => { + const dto: SaveDraftDto = { userId: USER_A, taskId: TASK_1 }; + const draft = await service.saveDraft(dto); + + expect(draft.content).toBe(''); + }); + + it('stores the fileUrl when provided', async () => { + const dto: SaveDraftDto = { + userId: USER_A, + taskId: TASK_1, + fileUrl: 'https://example.com/file.rs', + }; + const draft = await service.saveDraft(dto); + + expect(draft.fileUrl).toBe('https://example.com/file.rs'); + }); + + it('upserts an existing draft for the same userId+taskId instead of creating a new one', async () => { + const first = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'v1' }); + const second = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'v2' }); + + expect(second.id).toBe(first.id); + expect(second.content).toBe('v2'); + }); + + it('updates draftSavedAt on upsert', async () => { + const first = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'v1' }); + const savedAtFirst = first.draftSavedAt!.getTime(); + + // Advance time slightly before saving again + await new Promise(resolve => setTimeout(resolve, 5)); + + const second = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'v2' }); + expect(second.draftSavedAt!.getTime()).toBeGreaterThanOrEqual(savedAtFirst); + }); + + it('creates separate drafts for different userId+taskId combinations', async () => { + const d1 = await service.saveDraft({ userId: USER_A, taskId: TASK_1 }); + const d2 = await service.saveDraft({ userId: USER_A, taskId: TASK_2 }); + const d3 = await service.saveDraft({ userId: USER_B, taskId: TASK_1 }); + + expect(d1.id).not.toBe(d2.id); + expect(d1.id).not.toBe(d3.id); + expect(d2.id).not.toBe(d3.id); + }); + + it('does not affect non-draft submissions when checking for existing drafts', async () => { + // Create a regular (published) submission first + const regular = await service.create({ userId: USER_A, taskId: TASK_1, content: 'final' }); + expect(regular.isDraft).toBe(false); + + // Saving a draft should create a new entity, not upsert the regular one + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'wip' }); + expect(draft.id).not.toBe(regular.id); + expect(draft.isDraft).toBe(true); + }); + }); + + // ------------------------------------------------------------------------- + // findDraftsByUserId + // ------------------------------------------------------------------------- + + describe('findDraftsByUserId', () => { + it('returns an empty array when the user has no drafts', async () => { + const drafts = await service.findDraftsByUserId(USER_A); + expect(drafts).toEqual([]); + }); + + it('returns only the drafts belonging to the given user', async () => { + await service.saveDraft({ userId: USER_A, taskId: TASK_1 }); + await service.saveDraft({ userId: USER_A, taskId: TASK_2 }); + await service.saveDraft({ userId: USER_B, taskId: TASK_1 }); + + const draftsA = await service.findDraftsByUserId(USER_A); + expect(draftsA).toHaveLength(2); + expect(draftsA.every(d => d.userId === USER_A)).toBe(true); + + const draftsB = await service.findDraftsByUserId(USER_B); + expect(draftsB).toHaveLength(1); + expect(draftsB[0].userId).toBe(USER_B); + }); + + it('does not include published (non-draft) submissions', async () => { + // Save a draft then publish it + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'wip' }); + await service.publishDraft(draft.id); + + const drafts = await service.findDraftsByUserId(USER_A); + expect(drafts).toHaveLength(0); + }); + + it('does not include regular submissions created via create()', async () => { + await service.create({ userId: USER_A, taskId: TASK_1, content: 'final' }); + const drafts = await service.findDraftsByUserId(USER_A); + expect(drafts).toHaveLength(0); + }); + }); + + // ------------------------------------------------------------------------- + // publishDraft + // ------------------------------------------------------------------------- + + describe('publishDraft', () => { + it('transitions a draft to PENDING status', async () => { + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'done' }); + const published = await service.publishDraft(draft.id); + + expect(published.status).toBe(SubmissionStatus.PENDING); + }); + + it('clears isDraft flag after publishing', async () => { + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1 }); + const published = await service.publishDraft(draft.id); + + expect(published.isDraft).toBe(false); + }); + + it('clears draftSavedAt after publishing', async () => { + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1 }); + const published = await service.publishDraft(draft.id); + + expect(published.draftSavedAt).toBeUndefined(); + }); + + it('sets submittedAt to the publish time', async () => { + const before = new Date(); + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1 }); + const published = await service.publishDraft(draft.id); + const after = new Date(); + + expect(published.submittedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); + expect(published.submittedAt.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('throws NotFoundException when submission does not exist', async () => { + const fakeId = '00000000-0000-0000-0000-000000000000'; + await expect(service.publishDraft(fakeId)).rejects.toThrow(NotFoundException); + }); + + it('throws BadRequestException when submission is not a draft', async () => { + // Create a regular submission (isDraft = false) + const regular = await service.create({ userId: USER_A, taskId: TASK_1, content: 'code' }); + await expect(service.publishDraft(regular.id)).rejects.toThrow(BadRequestException); + }); + + it('allows the published submission to be found via findByUserId', async () => { + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1, content: 'done' }); + await service.publishDraft(draft.id); + + const all = await service.findByUserId(USER_A); + const match = all.find(s => s.id === draft.id); + expect(match).toBeDefined(); + expect(match!.isDraft).toBe(false); + expect(match!.status).toBe(SubmissionStatus.PENDING); + }); + + it('makes the submission no longer appear in findDraftsByUserId', async () => { + const draft = await service.saveDraft({ userId: USER_A, taskId: TASK_1 }); + await service.publishDraft(draft.id); + + const drafts = await service.findDraftsByUserId(USER_A); + expect(drafts.find(d => d.id === draft.id)).toBeUndefined(); + }); + }); +}); diff --git a/BackendAcademy/src/submissions/submission.service.ts b/BackendAcademy/src/submissions/submission.service.ts index 4ff9d5b76..05d4649a0 100644 --- a/BackendAcademy/src/submissions/submission.service.ts +++ b/BackendAcademy/src/submissions/submission.service.ts @@ -1,7 +1,8 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { SubmissionEntity } from './submission.entity'; import { CreateSubmissionDto } from './dto/create-submission.dto'; import { UpdateSubmissionDto } from './dto/update-submission.dto'; +import { SaveDraftDto } from './dto/save-draft.dto'; import { SubmissionStatus } from './interfaces/submission-status.enum'; @Injectable() @@ -79,4 +80,85 @@ export class SubmissionService { async remove(id: string): Promise { return this.submissions.delete(id); } + + // --------------------------------------------------------------------------- + // Draft support + // --------------------------------------------------------------------------- + + /** + * Create or update a draft submission. + * + * If an existing draft already exists for the same `userId` + `taskId` + * combination it is updated in-place (idempotent upsert). Otherwise a new + * draft entity is created with `status = DRAFT` and `isDraft = true`. + * + * @returns The saved (or updated) draft entity. + */ + async saveDraft(dto: SaveDraftDto): Promise { + // Check for an existing draft for this user/task to allow upsert behaviour + const existing = Array.from(this.submissions.values()).find( + s => s.userId === dto.userId && s.taskId === dto.taskId && s.isDraft, + ); + + if (existing) { + // Update fields that were provided + if (dto.content !== undefined) existing.content = dto.content; + if (dto.fileUrl !== undefined) existing.fileUrl = dto.fileUrl; + existing.draftSavedAt = new Date(); + existing.updatedAt = new Date(); + return existing; + } + + const draft = new SubmissionEntity({ + id: crypto.randomUUID(), + taskId: dto.taskId, + userId: dto.userId, + content: dto.content ?? '', + fileUrl: dto.fileUrl, + status: SubmissionStatus.DRAFT, + isDraft: true, + draftSavedAt: new Date(), + }); + + this.submissions.set(draft.id, draft); + return draft; + } + + /** + * Return all draft submissions for a given user. + */ + async findDraftsByUserId(userId: string): Promise { + return Array.from(this.submissions.values()).filter( + s => s.userId === userId && s.isDraft, + ); + } + + /** + * Promote a draft submission to a regular (PENDING) submission. + * + * The `isDraft` flag is cleared, `draftSavedAt` is nulled out, and the + * status is set to `PENDING` so the submission enters the normal review + * workflow. + * + * @throws NotFoundException if no submission with the given ID exists. + * @throws BadRequestException if the submission is not currently a draft. + */ + async publishDraft(id: string): Promise { + const submission = this.submissions.get(id); + if (!submission) { + throw new NotFoundException(`Submission ${id} not found`); + } + if (!submission.isDraft) { + throw new BadRequestException( + `Submission ${id} is not a draft and cannot be published`, + ); + } + + submission.isDraft = false; + submission.draftSavedAt = undefined; + submission.status = SubmissionStatus.PENDING; + submission.submittedAt = new Date(); + submission.updatedAt = new Date(); + return submission; + } }