Skip to content

Commit 242758a

Browse files
authored
Fix: draft proposals counted toward max submissions limit (#359)
1 parent d399bda commit 242758a

4 files changed

Lines changed: 111 additions & 8 deletions

File tree

__tests__/lib/proposal/utils.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
extractSpeakersFromProposal,
33
extractSpeakerIds,
44
calculateReviewScore,
5+
countActiveProposals,
56
} from '@/lib/proposal/utils'
67
import { Status, Language, Format, Level, Audience } from '@/lib/proposal/types'
78
import type { ProposalExisting } from '@/lib/proposal/types'
@@ -203,3 +204,79 @@ describe('calculateReviewScore', () => {
203204
expect(calculateReviewScore(reviews, 'speaker')).toBe(0)
204205
})
205206
})
207+
208+
describe('countActiveProposals', () => {
209+
it('returns 0 for null', () => {
210+
expect(countActiveProposals(null)).toBe(0)
211+
})
212+
213+
it('returns 0 for undefined', () => {
214+
expect(countActiveProposals(undefined)).toBe(0)
215+
})
216+
217+
it('returns 0 for empty array', () => {
218+
expect(countActiveProposals([])).toBe(0)
219+
})
220+
221+
it('counts submitted proposals', () => {
222+
const proposals = [
223+
makeProposal({ status: Status.submitted }),
224+
makeProposal({ status: Status.submitted }),
225+
]
226+
expect(countActiveProposals(proposals)).toBe(2)
227+
})
228+
229+
it('excludes draft proposals from count', () => {
230+
const proposals = [
231+
makeProposal({ status: Status.submitted }),
232+
makeProposal({ status: Status.draft }),
233+
]
234+
expect(countActiveProposals(proposals)).toBe(1)
235+
})
236+
237+
it('excludes deleted proposals from count', () => {
238+
const proposals = [
239+
makeProposal({ status: Status.submitted }),
240+
makeProposal({ status: Status.deleted }),
241+
]
242+
expect(countActiveProposals(proposals)).toBe(1)
243+
})
244+
245+
it('returns 0 when all proposals are drafts or deleted', () => {
246+
const proposals = [
247+
makeProposal({ status: Status.draft }),
248+
makeProposal({ status: Status.draft }),
249+
makeProposal({ status: Status.deleted }),
250+
]
251+
expect(countActiveProposals(proposals)).toBe(0)
252+
})
253+
254+
it('counts accepted, confirmed, and waitlisted proposals', () => {
255+
const proposals = [
256+
makeProposal({ status: Status.accepted }),
257+
makeProposal({ status: Status.confirmed }),
258+
makeProposal({ status: Status.waitlisted }),
259+
]
260+
expect(countActiveProposals(proposals)).toBe(3)
261+
})
262+
263+
it('3 drafts do not trigger the cap, allowing a new submission', () => {
264+
const proposals = [
265+
makeProposal({ status: Status.draft }),
266+
makeProposal({ status: Status.draft }),
267+
makeProposal({ status: Status.draft }),
268+
]
269+
expect(countActiveProposals(proposals)).toBe(0)
270+
expect(countActiveProposals(proposals) < 3).toBe(true)
271+
})
272+
273+
it('3 submitted proposals trigger the cap', () => {
274+
const proposals = [
275+
makeProposal({ status: Status.submitted }),
276+
makeProposal({ status: Status.submitted }),
277+
makeProposal({ status: Status.submitted }),
278+
]
279+
expect(countActiveProposals(proposals)).toBe(3)
280+
expect(countActiveProposals(proposals) >= 3).toBe(true)
281+
})
282+
})

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ProposalExisting,
1010
} from '@/lib/proposal/types'
1111
import { getProposalSanity } from '@/lib/proposal/server'
12+
import { countActiveProposals } from '@/lib/proposal/utils'
1213
import { Speaker } from '@/lib/speaker/types'
1314
import { ProposalForm } from '@/components/cfp/ProposalForm'
1415
import { ProposalGuidanceSidebar } from '@/components/cfp/ProposalGuidanceSidebar'
@@ -102,16 +103,13 @@ export default async function ProposalPage({
102103

103104
if (!proposalId && conference && !loadingError) {
104105
const { getProposals } = await import('@/lib/proposal/data/sanity')
105-
const { Status } = await import('@/lib/proposal/types')
106106
const { proposals: existingProposals } = await getProposals({
107107
speakerId: session.speaker._id,
108108
conferenceId: conference._id,
109109
returnAll: false,
110110
})
111111

112-
const proposalCount = (existingProposals || []).filter(
113-
(p) => p.status !== Status.deleted,
114-
).length
112+
const proposalCount = countActiveProposals(existingProposals)
115113

116114
if (proposalCount >= 3) {
117115
loadingError = {

src/lib/proposal/utils.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
1-
import { ProposalExisting } from './types'
1+
import { ProposalExisting, Status } from './types'
22
import { SpeakerWithReviewInfo } from '@/lib/speaker/types'
33
import { Reference } from 'sanity'
44

5+
export function countActiveProposals(
6+
proposals: ProposalExisting[] | null | undefined,
7+
): number {
8+
return (proposals || []).filter(
9+
(p) => p.status !== Status.deleted && p.status !== Status.draft,
10+
).length
11+
}
12+
513
export function extractSpeakersFromProposal(
614
proposal: ProposalExisting,
715
): SpeakerWithReviewInfo[] {

src/server/routers/proposal.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { createReference } from '@/lib/sanity/helpers'
3737
import type { ProposalInput } from '@/lib/proposal/types'
3838
import { Status } from '@/lib/proposal/types'
3939
import { actionStateMachine } from '@/lib/proposal'
40+
import { countActiveProposals } from '@/lib/proposal/utils'
4041
import { Speaker } from '@/lib/speaker/types'
4142
import { eventBus } from '@/lib/events/bus'
4243
import { ProposalStatusChangeEvent } from '@/lib/events/types'
@@ -253,9 +254,7 @@ export const proposalRouter = router({
253254
returnAll: false,
254255
})
255256

256-
const proposalCount = (existingProposals || []).filter(
257-
(p) => p.status !== Status.deleted,
258-
).length
257+
const proposalCount = countActiveProposals(existingProposals)
259258

260259
if (proposalCount >= 3) {
261260
throw new TRPCError({
@@ -453,6 +452,27 @@ export const proposalRouter = router({
453452
})
454453
}
455454

455+
// Enforce cap when submitting a draft (draft → submitted transition)
456+
if (
457+
proposal.status === Status.draft &&
458+
status === Status.submitted &&
459+
!ctx.speaker.isOrganizer
460+
) {
461+
const { proposals: existingProposals } = await getProposals({
462+
speakerId: ctx.speaker._id,
463+
conferenceId: conference._id,
464+
returnAll: false,
465+
})
466+
467+
if (countActiveProposals(existingProposals) >= 3) {
468+
throw new TRPCError({
469+
code: 'FORBIDDEN',
470+
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.',
472+
})
473+
}
474+
}
475+
456476
// Handle deletion separately
457477
if (status === Status.deleted) {
458478
const { err: deleteError } = await deleteProposal(id)

0 commit comments

Comments
 (0)