Skip to content

Commit 6ee1797

Browse files
committed
fix: enhance proposal management by allowing withdrawal and updating error messages
1 parent a688908 commit 6ee1797

9 files changed

Lines changed: 188 additions & 11 deletions

File tree

__tests__/api/trpc/proposal.test.ts

Lines changed: 119 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,7 +223,10 @@ describe('proposal router', () => {
223223
data: validProposalData,
224224
status: Status.submitted,
225225
}),
226-
).rejects.toMatchObject({ code: 'FORBIDDEN' })
226+
).rejects.toMatchObject({
227+
code: 'FORBIDDEN',
228+
message: expect.stringContaining('Call for Papers is currently closed'),
229+
})
227230
})
228231

229232
it('should enforce max 3 proposals per conference', async () => {
@@ -243,7 +246,58 @@ describe('proposal router', () => {
243246
data: validProposalData,
244247
status: Status.submitted,
245248
}),
246-
).rejects.toMatchObject({ code: 'FORBIDDEN' })
249+
).rejects.toMatchObject({
250+
code: 'FORBIDDEN',
251+
message: expect.stringContaining('maximum of 3 proposals'),
252+
})
253+
})
254+
255+
it('should allow new proposal when one of 3 is withdrawn', async () => {
256+
vi.mocked(isCfpOpen).mockReturnValue(true)
257+
vi.mocked(getProposals).mockResolvedValue({
258+
proposals: [
259+
{ ...mockProposal, _id: 'p1', status: Status.submitted },
260+
{ ...mockProposal, _id: 'p2', status: Status.accepted },
261+
{ ...mockProposal, _id: 'p3', status: Status.withdrawn },
262+
] as any,
263+
proposalsError: null,
264+
})
265+
vi.mocked(createProposal).mockResolvedValue({
266+
proposal: mockProposal as any,
267+
err: null,
268+
})
269+
270+
const caller = createAuthenticatedCaller(regularSpeaker._id)
271+
const result = await caller.proposal.create({
272+
data: validProposalData,
273+
status: Status.submitted,
274+
})
275+
expect(result._id).toBe('proposal-1')
276+
expect(createProposal).toHaveBeenCalled()
277+
})
278+
279+
it('should allow new proposal when one of 3 is rejected', async () => {
280+
vi.mocked(isCfpOpen).mockReturnValue(true)
281+
vi.mocked(getProposals).mockResolvedValue({
282+
proposals: [
283+
{ ...mockProposal, _id: 'p1', status: Status.submitted },
284+
{ ...mockProposal, _id: 'p2', status: Status.accepted },
285+
{ ...mockProposal, _id: 'p3', status: Status.rejected },
286+
] as any,
287+
proposalsError: null,
288+
})
289+
vi.mocked(createProposal).mockResolvedValue({
290+
proposal: mockProposal as any,
291+
err: null,
292+
})
293+
294+
const caller = createAuthenticatedCaller(regularSpeaker._id)
295+
const result = await caller.proposal.create({
296+
data: validProposalData,
297+
status: Status.submitted,
298+
})
299+
expect(result._id).toBe('proposal-1')
300+
expect(createProposal).toHaveBeenCalled()
247301
})
248302

249303
it('should create a draft without strict validation', async () => {
@@ -328,7 +382,8 @@ describe('proposal router', () => {
328382
).rejects.toMatchObject({ code: 'BAD_REQUEST' })
329383
})
330384

331-
it('should allow speaker to unsubmit their proposal', async () => {
385+
it('should allow speaker to unsubmit their proposal when CFP is open', async () => {
386+
vi.mocked(isCfpOpen).mockReturnValue(true)
332387
vi.mocked(getProposalSanity).mockResolvedValue({
333388
proposal: mockProposal as any,
334389
proposalError: null,
@@ -346,6 +401,62 @@ describe('proposal router', () => {
346401
expect(result.proposalStatus).toBe(Status.draft)
347402
})
348403

404+
it('should block speaker from unsubmitting after CFP closes', async () => {
405+
vi.mocked(isCfpOpen).mockReturnValue(false)
406+
vi.mocked(getProposalSanity).mockResolvedValue({
407+
proposal: mockProposal as any,
408+
proposalError: null,
409+
})
410+
411+
const caller = createAuthenticatedCaller(regularSpeaker._id)
412+
await expect(
413+
caller.proposal.action({
414+
id: 'proposal-1',
415+
action: Action.unsubmit,
416+
}),
417+
).rejects.toMatchObject({
418+
code: 'FORBIDDEN',
419+
message: expect.stringContaining('Call for Papers has closed'),
420+
})
421+
})
422+
423+
it('should allow organizer to unsubmit even after CFP closes', async () => {
424+
vi.mocked(isCfpOpen).mockReturnValue(false)
425+
vi.mocked(getProposalSanity).mockResolvedValue({
426+
proposal: mockProposal as any,
427+
proposalError: null,
428+
})
429+
vi.mocked(updateProposalStatus).mockResolvedValue({
430+
proposal: { ...mockProposal, status: Status.draft } as any,
431+
err: null,
432+
})
433+
434+
const caller = createAdminCaller()
435+
const result = await caller.proposal.action({
436+
id: 'proposal-1',
437+
action: Action.unsubmit,
438+
})
439+
expect(result.proposalStatus).toBe(Status.draft)
440+
})
441+
442+
it('should allow speaker to withdraw a submitted proposal', async () => {
443+
vi.mocked(getProposalSanity).mockResolvedValue({
444+
proposal: mockProposal as any,
445+
proposalError: null,
446+
})
447+
vi.mocked(updateProposalStatus).mockResolvedValue({
448+
proposal: { ...mockProposal, status: Status.withdrawn } as any,
449+
err: null,
450+
})
451+
452+
const caller = createAuthenticatedCaller(regularSpeaker._id)
453+
const result = await caller.proposal.action({
454+
id: 'proposal-1',
455+
action: Action.withdraw,
456+
})
457+
expect(result.proposalStatus).toBe(Status.withdrawn)
458+
})
459+
349460
it('should allow organizer to accept a submitted proposal', async () => {
350461
vi.mocked(getProposalSanity).mockResolvedValue({
351462
proposal: mockProposal as any,
@@ -378,6 +489,7 @@ describe('proposal router', () => {
378489
})
379490

380491
it('should enforce proposal cap when submitting a draft', async () => {
492+
vi.mocked(isCfpOpen).mockReturnValue(true)
381493
const draftProposal = { ...mockProposal, status: Status.draft }
382494
vi.mocked(getProposalSanity).mockResolvedValue({
383495
proposal: draftProposal as any,
@@ -395,7 +507,10 @@ describe('proposal router', () => {
395507
const caller = createAuthenticatedCaller(regularSpeaker._id)
396508
await expect(
397509
caller.proposal.action({ id: 'proposal-1', action: Action.submit }),
398-
).rejects.toMatchObject({ code: 'FORBIDDEN' })
510+
).rejects.toMatchObject({
511+
code: 'FORBIDDEN',
512+
message: expect.stringContaining('maximum of 3 proposals'),
513+
})
399514
})
400515

401516
it('should return NOT_FOUND for nonexistent proposal', async () => {

__tests__/lib/proposal/state-machine.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,15 @@ describe('actionStateMachine', () => {
7272
const result = actionStateMachine(Status.submitted, Action.reject, false)
7373
expect(result.isValidAction).toBe(false)
7474
})
75+
76+
it('allows withdraw action', () => {
77+
const result = actionStateMachine(
78+
Status.submitted,
79+
Action.withdraw,
80+
false,
81+
)
82+
expect(result).toEqual({ status: Status.withdrawn, isValidAction: true })
83+
})
7584
})
7685

7786
describe('accepted status', () => {

__tests__/lib/proposal/utils.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,22 @@ describe('countActiveProposals', () => {
251251
expect(countActiveProposals(proposals)).toBe(0)
252252
})
253253

254+
it('excludes withdrawn proposals from count', () => {
255+
const proposals = [
256+
makeProposal({ status: Status.submitted }),
257+
makeProposal({ status: Status.withdrawn }),
258+
]
259+
expect(countActiveProposals(proposals)).toBe(1)
260+
})
261+
262+
it('excludes rejected proposals from count', () => {
263+
const proposals = [
264+
makeProposal({ status: Status.submitted }),
265+
makeProposal({ status: Status.rejected }),
266+
]
267+
expect(countActiveProposals(proposals)).toBe(1)
268+
})
269+
254270
it('counts accepted, confirmed, and waitlisted proposals', () => {
255271
const proposals = [
256272
makeProposal({ status: Status.accepted }),

src/app/(cfp)/cfp/proposal/[[...id]]/page.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { XCircleIcon } from '@heroicons/react/24/solid'
2+
import Link from 'next/link'
23
import {
34
Format,
45
Language,
@@ -115,7 +116,8 @@ export default async function ProposalPage({
115116
loadingError = {
116117
type: 'Maximum Reached',
117118
message:
118-
'You have reached the maximum of 3 proposals per conference. Please edit or withdraw an existing proposal if you need to submit a new one.',
119+
'You have reached the maximum of 3 proposals per conference. Please go to your proposals list to unsubmit or withdraw an existing proposal if you need to submit a new one.',
120+
link: { href: '/cfp/list', label: 'Go to your proposals' },
119121
}
120122
}
121123
}
@@ -209,6 +211,16 @@ export default async function ProposalPage({
209211
</h3>
210212
<div className="font-inter mt-2 text-blue-800 dark:text-blue-300">
211213
<p>{loadingError.message}</p>
214+
{loadingError.link && (
215+
<p className="mt-3">
216+
<Link
217+
href={loadingError.link.href}
218+
className="font-semibold text-brand-cloud-blue underline hover:text-brand-cloud-blue-hover dark:text-blue-400 dark:hover:text-blue-300"
219+
>
220+
{loadingError.link.label}
221+
</Link>
222+
</p>
223+
)}
212224
</div>
213225
</div>
214226
</div>

src/components/cfp/ProposalForm.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use client'
22

33
import { Conference } from '@/lib/conference/types'
4+
import { isCfpOpen } from '@/lib/conference/state'
45
import { api } from '@/lib/trpc/client'
56
import {
67
Format,
@@ -216,14 +217,19 @@ export function ProposalForm({
216217
actionMutation.mutate({ id: currentProposalId, action })
217218
}
218219

220+
const cfpOpen = isCfpOpen(conference)
219221
const canUnsubmit =
220-
mode === 'user' && currentProposalId && initialStatus === Status.submitted
222+
mode === 'user' &&
223+
currentProposalId &&
224+
initialStatus === Status.submitted &&
225+
cfpOpen
221226
const canWithdraw =
222227
mode === 'user' &&
223228
currentProposalId &&
224229
(initialStatus === Status.accepted ||
225230
initialStatus === Status.waitlisted ||
226-
initialStatus === Status.confirmed)
231+
initialStatus === Status.confirmed ||
232+
(initialStatus === Status.submitted && !cfpOpen))
227233
const canDeleteDraft =
228234
mode === 'user' && currentProposalId && initialStatus === Status.draft
229235

src/lib/proposal/business/state-machine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export function actionStateMachine(
2121
case Status.submitted:
2222
if (action === Action.unsubmit) {
2323
status = Status.draft
24+
} else if (action === Action.withdraw) {
25+
status = Status.withdrawn
2426
} else if (isOrganizer && action === Action.accept) {
2527
status = Status.accepted
2628
} else if (isOrganizer && action === Action.waitlist) {

src/lib/proposal/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ export interface FormError {
126126
message: string
127127
type: string
128128
validationErrors?: FormValidationError[]
129+
link?: { href: string; label: string }
129130
}
130131

131132
export interface FormValidationError {

src/lib/proposal/utils.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ export function countActiveProposals(
66
proposals: ProposalExisting[] | null | undefined,
77
): number {
88
return (proposals || []).filter(
9-
(p) => p.status !== Status.deleted && p.status !== Status.draft,
9+
(p) =>
10+
p.status !== Status.deleted &&
11+
p.status !== Status.draft &&
12+
p.status !== Status.withdrawn &&
13+
p.status !== Status.rejected,
1014
).length
1115
}
1216

src/server/routers/proposal.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import { getConferenceForCurrentDomain } from '@/lib/conference/sanity'
3535
import { clientWrite } from '@/lib/sanity/client'
3636
import { createReference } from '@/lib/sanity/helpers'
3737
import type { ProposalInput } from '@/lib/proposal/types'
38-
import { Status } from '@/lib/proposal/types'
38+
import { Action, Status } from '@/lib/proposal/types'
3939
import { actionStateMachine } from '@/lib/proposal'
4040
import { countActiveProposals } from '@/lib/proposal/utils'
4141
import { Speaker } from '@/lib/speaker/types'
@@ -260,7 +260,7 @@ export const proposalRouter = router({
260260
throw new TRPCError({
261261
code: 'FORBIDDEN',
262262
message:
263-
'You have reached the maximum of 3 proposals per conference. Please edit or withdraw an existing proposal if you need to submit a new one.',
263+
'You have reached the maximum of 3 proposals per conference. Please unsubmit or withdraw an existing proposal from your proposals list if you need to submit a new one.',
264264
})
265265
}
266266

@@ -452,6 +452,18 @@ export const proposalRouter = router({
452452
})
453453
}
454454

455+
// Block unsubmit after CFP closes — speakers should withdraw instead
456+
if (action === Action.unsubmit && !ctx.speaker.isOrganizer) {
457+
const { isCfpOpen } = await import('@/lib/conference/state')
458+
if (!isCfpOpen(conference)) {
459+
throw new TRPCError({
460+
code: 'FORBIDDEN',
461+
message:
462+
'The Call for Papers has closed. You can no longer move proposals back to draft. Use withdraw instead if you want to remove your proposal.',
463+
})
464+
}
465+
}
466+
455467
// Enforce cap when submitting a draft (draft → submitted transition)
456468
if (
457469
proposal.status === Status.draft &&
@@ -468,7 +480,7 @@ export const proposalRouter = router({
468480
throw new TRPCError({
469481
code: 'FORBIDDEN',
470482
message:
471-
'You have reached the maximum of 3 proposals per conference. Please edit or withdraw an existing proposal if you need to submit a new one.',
483+
'You have reached the maximum of 3 proposals per conference. Please unsubmit or withdraw an existing proposal from your proposals list if you need to submit a new one.',
472484
})
473485
}
474486
}

0 commit comments

Comments
 (0)