diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json index b501a83..95df221 100644 --- a/packages/api/package-lock.json +++ b/packages/api/package-lock.json @@ -434,7 +434,6 @@ "../../node_modules/.pnpm/hono@4.11.9/node_modules/hono": { "version": "4.11.9", "license": "MIT", - "peer": true, "devDependencies": { "@hono/eslint-config": "^2.0.5", "@hono/node-server": "^1.13.5", @@ -757,7 +756,6 @@ "../../node_modules/.pnpm/zod@4.3.6/node_modules/zod": { "version": "4.3.6", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/api/src/__tests__/submissions.test.ts b/packages/api/src/__tests__/submissions.test.ts index c183bf6..0932e6a 100644 --- a/packages/api/src/__tests__/submissions.test.ts +++ b/packages/api/src/__tests__/submissions.test.ts @@ -294,7 +294,7 @@ describe('POST /api/submissions/:id/dispute', () => { Authorization: 'Bearer valid.token', 'Content-Type': 'application/json' }, - body: JSON.stringify({ evidence_links: ['not-a-url'] }) + body: JSON.stringify({ evidenceLinks: ['not-a-url'] }) }); expect(res.status).toBe(400); @@ -400,7 +400,7 @@ describe('POST /api/submissions/:id/dispute', () => { Authorization: 'Bearer valid.token', 'Content-Type': 'application/json' }, - body: JSON.stringify({ reason: 'Unfair rejection', evidence_links: ['https://example.com'] }) + body: JSON.stringify({ reason: 'Unfair rejection', evidenceLinks: ['https://example.com'] }) }); expect(res.status).toBe(201); diff --git a/packages/api/src/routes/submissions.ts b/packages/api/src/routes/submissions.ts index effa59c..6c6f63c 100644 --- a/packages/api/src/routes/submissions.ts +++ b/packages/api/src/routes/submissions.ts @@ -18,8 +18,8 @@ const rejectSchema = z.object({ }); const disputeSchema = z.object({ - reason: z.string().min(1, { message: 'Reason is required' }), - evidence_links: z.array(z.string().url()).optional(), + reason: z.string().min(1).max(2000), + evidenceLinks: z.array(z.string().url()).optional().default([]), }); const idSchema = z.object({ @@ -121,7 +121,8 @@ submissionsRouter.get( /** * POST /api/submissions/:id/dispute - * Allows developer to dispute a rejected submission with reason and evidence_links + * Opens a dispute for a rejected submission. + * Validates that submission was rejected and belongs to the authenticated user. */ submissionsRouter.post( '/:id/dispute', @@ -134,54 +135,95 @@ submissionsRouter.post( } const { id } = c.req.valid('param'); - const { reason, evidence_links } = c.req.valid('json'); + const { reason, evidenceLinks } = c.req.valid('json'); - const result = await db.select() - .from(submissions) - .where(and(eq(submissions.id, id), eq(submissions.developerId, user.id))); - - if (result.length === 0) { - return c.json({ error: 'Submission not found' }, 404); - } + try { + // Use transaction to ensure atomicity + const result = await db.transaction(async (tx) => { + // Check if submission exists and belongs to user + const [submission] = await tx + .select() + .from(submissions) + .where(and( + eq(submissions.id, id), + eq(submissions.developerId, user.id) + )); - const submission = result[0]; + if (!submission) { + return { error: 'Submission not found', status: 404 }; + } - if (submission.status !== 'rejected') { - return c.json({ error: 'Only rejected submissions can be disputed' }, 400); - } + // Validate submission was rejected + if (submission.status !== 'rejected') { + return { + error: 'Only rejected submissions can be disputed', + currentStatus: submission.status, + status: 400 + }; + } - // Check if a dispute already exists - const existingDisputes = await db.select() - .from(disputes) - .where(eq(disputes.submissionId, id)); + // Check if dispute already exists + const [existingDispute] = await tx + .select() + .from(disputes) + .where(eq(disputes.submissionId, id)); + + if (existingDispute) { + return { + error: 'Dispute already exists for this submission', + disputeId: existingDispute.id, + status: 409 + }; + } - if (existingDisputes.length > 0) { - return c.json({ error: 'A dispute already exists for this submission' }, 400); - } + // Create dispute record + const [dispute] = await tx + .insert(disputes) + .values({ + submissionId: id, + reason, + evidenceLinks, + status: 'open', + }) + .returning(); - // Use a transaction to mark the submission as disputed and record the dispute - try { - const [createdDispute] = await db.transaction(async (tx) => { - const updated = await tx.update(submissions) + // Update submission status to disputed with optimistic locking + const [updated] = await tx + .update(submissions) .set({ status: 'disputed' }) - .where(and(eq(submissions.id, id), eq(submissions.status, 'rejected'))) + .where(and( + eq(submissions.id, id), + eq(submissions.status, 'rejected') + )) .returning({ id: submissions.id }); - if (updated.length === 0) { + if (!updated) { tx.rollback(); } - return await tx.insert(disputes).values({ - submissionId: id, - reason, - evidenceLinks: evidence_links, - status: 'open' - }).returning(); + return { dispute, status: 201 }; }); - return c.json({ data: createdDispute }, 201); + if ('error' in result) { + return c.json( + { + error: result.error, + ...('disputeId' in result && { disputeId: result.disputeId }), + ...('currentStatus' in result && { currentStatus: result.currentStatus }) + }, + result.status as 400 | 404 | 409 + ); + } + + return c.json({ + data: result.dispute, + message: 'Dispute opened successfully', + }, 201); } catch (error) { - return c.json({ error: 'Failed to create dispute or submission was modified concurrently' }, 409); + console.error('Transaction failed:', error); + return c.json({ + error: 'Failed to create dispute or submission was modified concurrently' + }, 409); } } ); @@ -247,7 +289,7 @@ submissionsRouter.post( bountyId: bounty.id, status: 'pending' }); - + // Update the bounty status to completed await tx.update(bounties) .set({ status: 'completed' }) @@ -304,7 +346,7 @@ submissionsRouter.post( try { await db.transaction(async (tx) => { const updated = await tx.update(submissions) - .set({ + .set({ status: 'rejected', rejectionReason: rejection_reason }) @@ -313,7 +355,7 @@ submissionsRouter.post( eq(submissions.status, submission.status) )) .returning({ id: submissions.id }); - + if (updated.length === 0) { tx.rollback(); } @@ -330,4 +372,4 @@ submissionsRouter.post( } ); -export default submissionsRouter; +export default submissionsRouter; \ No newline at end of file