Skip to content

Commit 9a65eb9

Browse files
authored
Merge pull request #4187 from dubinc/dynamic-bounty-start-date
FEAT: Dynamic bounty start date
2 parents 18e537a + 2b6c3ea commit 9a65eb9

53 files changed

Lines changed: 2531 additions & 1244 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/web/app/(ee)/api/bounties/[bountyId]/route.ts

Lines changed: 97 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { parseRequestBody } from "@/lib/api/utils";
66
import { WorkflowCondition } from "@/lib/api/workflows/types";
77
import { validateWorkflowConditions } from "@/lib/api/workflows/validate-workflow-conditions";
88
import { withWorkspace } from "@/lib/auth";
9+
import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-availability";
910
import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name";
11+
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
1012
import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details";
1113
import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes";
1214
import { shouldUpsertDraftSubmissionsOnReopen } from "@/lib/bounty/api/upsert-draft-bounty-submissions";
@@ -21,7 +23,7 @@ import {
2123
updateBountySchema,
2224
} from "@/lib/zod/schemas/bounties";
2325
import { APP_DOMAIN_WITH_NGROK, arrayEqual, deepEqual } from "@dub/utils";
24-
import { PartnerGroup, Prisma } from "@prisma/client";
26+
import { BountyStartMode, PartnerGroup, Prisma } from "@prisma/client";
2527
import { waitUntil } from "@vercel/functions";
2628
import { NextResponse } from "next/server";
2729

@@ -57,6 +59,8 @@ export const PATCH = withWorkspace(
5759
description,
5860
startsAt,
5961
endsAt,
62+
startMode,
63+
endsAfterDays,
6064
submissionsOpenAt,
6165
submissionFrequency,
6266
maxSubmissions,
@@ -67,26 +71,57 @@ export const PATCH = withWorkspace(
6771
groupIds,
6872
} = updateBountySchema.parse(await parseRequestBody(req));
6973

70-
const bounty = await prisma.bounty.findUniqueOrThrow({
71-
where: {
72-
id: bountyId,
73-
programId,
74-
},
74+
const bounty = await getBountyOrThrow({
75+
bountyId,
76+
programId,
7577
include: {
76-
groups: true,
7778
workflow: true,
7879
_count: {
7980
select: {
8081
submissions: true,
8182
},
8283
},
84+
...bountyEligibilityIncludes,
8385
},
8486
});
8587

88+
const nextStartMode =
89+
startMode !== undefined ? startMode : bounty.startMode;
90+
91+
// Absolute end dates are cleared when switching to relative (unless the
92+
// client explicitly sends endsAt) or when setting endsAfterDays.
93+
let endsAtUpdate: { endsAt?: Date | null } = {};
94+
95+
if (endsAt !== undefined) {
96+
endsAtUpdate = { endsAt };
97+
} else if (endsAfterDays != null) {
98+
endsAtUpdate = { endsAt: null };
99+
} else if (
100+
nextStartMode === BountyStartMode.relative &&
101+
bounty.endsAt != null
102+
) {
103+
endsAtUpdate = { endsAt: null };
104+
}
105+
86106
validateBounty({
87107
type: bounty.type,
88-
startsAt,
89-
endsAt: endsAt !== undefined ? endsAt : bounty.endsAt,
108+
// Relative bounties never store startsAt; coerce so mode switches don't
109+
// fail validation against a leftover absolute startsAt.
110+
startsAt:
111+
nextStartMode === BountyStartMode.relative
112+
? null
113+
: startsAt !== undefined
114+
? startsAt
115+
: bounty.startsAt,
116+
endsAt:
117+
endsAtUpdate.endsAt !== undefined ? endsAtUpdate.endsAt : bounty.endsAt,
118+
startMode: nextStartMode,
119+
endsAfterDays:
120+
endsAfterDays !== undefined
121+
? endsAfterDays
122+
: nextStartMode === BountyStartMode.absolute
123+
? null
124+
: bounty.endsAfterDays,
90125
submissionsOpenAt,
91126
submissionFrequency:
92127
submissionFrequency !== undefined
@@ -123,17 +158,22 @@ export const PATCH = withWorkspace(
123158

124159
// if groupIds is provided and is different from the current groupIds, update the groups
125160
let updatedPartnerGroups: PartnerGroup[] | undefined = undefined;
126-
if (
127-
groupIds &&
128-
!arrayEqual(
129-
bounty.groups.map((group) => group.groupId),
130-
groupIds,
131-
)
132-
) {
133-
updatedPartnerGroups = await throwIfInvalidGroupIds({
134-
programId,
135-
groupIds,
136-
});
161+
let shouldUpdatePartnerGroups = false;
162+
163+
if (groupIds !== undefined) {
164+
const currentGroupIds = bounty.groups.map((group) => group.groupId);
165+
const newGroupIds = groupIds || [];
166+
167+
if (!arrayEqual(currentGroupIds, newGroupIds)) {
168+
if (newGroupIds.length > 0) {
169+
updatedPartnerGroups = await throwIfInvalidGroupIds({
170+
programId,
171+
groupIds: newGroupIds,
172+
});
173+
}
174+
175+
shouldUpdatePartnerGroups = true;
176+
}
137177
}
138178

139179
// Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions
@@ -189,6 +229,19 @@ export const PATCH = withWorkspace(
189229
});
190230
}
191231

232+
// Relative bounties start when a partner joins, so startsAt is cleared.
233+
// For absolute bounties, only update startsAt when explicitly provided.
234+
let startsAtUpdate: { startsAt?: Date | null } = {};
235+
236+
if (nextStartMode === BountyStartMode.relative) {
237+
startsAtUpdate = { startsAt: null };
238+
} else if (startsAt !== undefined) {
239+
startsAtUpdate = { startsAt: startsAt ?? new Date() };
240+
} else if (bounty.startsAt === null) {
241+
// Switching relative -> absolute without a startsAt: default to now
242+
startsAtUpdate = { startsAt: new Date() };
243+
}
244+
192245
const data = await prisma.$transaction(async (tx) => {
193246
const updatedBounty = await tx.bounty.update({
194247
where: {
@@ -197,8 +250,15 @@ export const PATCH = withWorkspace(
197250
data: {
198251
name: bountyName ?? undefined,
199252
description,
200-
startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4)
201-
endsAt,
253+
...startsAtUpdate,
254+
...endsAtUpdate,
255+
...(startMode !== undefined && { startMode }),
256+
...(endsAfterDays !== undefined
257+
? { endsAfterDays }
258+
: nextStartMode === BountyStartMode.absolute &&
259+
bounty.endsAfterDays != null
260+
? { endsAfterDays: null }
261+
: {}),
202262
submissionsOpenAt:
203263
bounty.type === "submission" ? submissionsOpenAt : null,
204264
...(bounty.type === "submission" &&
@@ -214,18 +274,21 @@ export const PATCH = withWorkspace(
214274
submissionRequirements !== undefined && {
215275
submissionRequirements: submissionRequirements ?? Prisma.DbNull,
216276
}),
217-
...(updatedPartnerGroups && {
277+
...(shouldUpdatePartnerGroups && {
218278
groups: {
219279
deleteMany: {},
220-
create: updatedPartnerGroups.map((group) => ({
221-
groupId: group.id,
222-
})),
280+
...(updatedPartnerGroups &&
281+
updatedPartnerGroups.length > 0 && {
282+
create: updatedPartnerGroups.map((group) => ({
283+
groupId: group.id,
284+
})),
285+
}),
223286
},
224287
}),
225288
},
226289
include: {
227290
workflow: true,
228-
groups: true,
291+
...bountyEligibilityIncludes,
229292
},
230293
});
231294

@@ -290,7 +353,9 @@ export const PATCH = withWorkspace(
290353
body: {
291354
bountyId: bounty.id,
292355
},
293-
notBefore: Math.floor(data.startsAt.getTime() / 1000),
356+
...(data.startsAt && {
357+
notBefore: Math.floor(data.startsAt.getTime() / 1000),
358+
}),
294359
}),
295360
]),
296361
);
@@ -309,19 +374,17 @@ export const DELETE = withWorkspace(
309374
const { bountyId } = params;
310375
const programId = getDefaultProgramIdOrThrow(workspace);
311376

312-
const bounty = await prisma.bounty.findUniqueOrThrow({
313-
where: {
314-
id: bountyId,
315-
programId,
316-
},
377+
const bounty = await getBountyOrThrow({
378+
bountyId,
379+
programId,
317380
include: {
318-
groups: true,
319381
workflow: true,
320382
_count: {
321383
select: {
322384
submissions: true,
323385
},
324386
},
387+
...bountyEligibilityIncludes,
325388
},
326389
});
327390

apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,6 @@ export const GET = withWorkspace(
1717
await getBountyOrThrow({
1818
bountyId,
1919
programId,
20-
include: {
21-
groups: true,
22-
},
2320
});
2421

2522
const {

apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts

Lines changed: 42 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { DubApiError } from "@/lib/api/errors";
22
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
33
import { parseRequestBody } from "@/lib/api/utils";
44
import { withWorkspace } from "@/lib/auth";
5+
import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability";
56
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
67
import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates";
8+
import { isBountyEnded, isBountyStarted } from "@/lib/bounty/bounty-period";
79
import { resolveBountyDetails } from "@/lib/bounty/utils";
810
import { qstash } from "@/lib/cron";
911
import { prisma } from "@/lib/prisma";
@@ -52,6 +54,11 @@ export const POST = withWorkspace(
5254
urls: true,
5355
status: true,
5456
partner: true,
57+
programEnrollment: {
58+
select: {
59+
createdAt: true,
60+
},
61+
},
5562
},
5663
},
5764
}
@@ -67,58 +74,60 @@ export const POST = withWorkspace(
6774
});
6875
}
6976

70-
const submission = submissionId ? bounty.submissions?.[0] : undefined;
71-
72-
if (submissionId) {
73-
if (!submission) {
74-
throw new DubApiError({
75-
code: "not_found",
76-
message: `Submission ${submissionId} not found.`,
77-
});
78-
}
77+
// Bounty-wide sync (no submissionId): run asynchronously via a background job
78+
if (!submissionId) {
79+
const response = await qstash.publishJSON({
80+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,
81+
method: "POST",
82+
body: {
83+
bountyId,
84+
},
85+
});
7986

80-
if (submission.status === "approved") {
87+
if (!response.messageId) {
8188
throw new DubApiError({
8289
code: "bad_request",
83-
message: "Social metrics can't be synced for an approved submission.",
90+
message: "Could not sync social metrics for this bounty now.",
8491
});
8592
}
93+
94+
return NextResponse.json({});
8695
}
8796

88-
const now = new Date();
97+
// Single-submission sync
98+
const submission = bounty.submissions?.[0];
8999

90-
if (bounty.startsAt && bounty.startsAt > now) {
100+
if (!submission || !submission.programEnrollment) {
91101
throw new DubApiError({
92-
code: "bad_request",
93-
message: "Social metrics can only be synced after the bounty starts.",
102+
code: "not_found",
103+
message: `Submission ${submissionId} not found.`,
94104
});
95105
}
96106

97-
if (bounty.endsAt && bounty.endsAt < now) {
107+
if (submission.status === "approved") {
98108
throw new DubApiError({
99109
code: "bad_request",
100-
message: "Social metrics can't be synced after the bounty ends.",
110+
message: "Social metrics can't be synced for an approved submission.",
101111
});
102112
}
103113

104-
// Do the sync in a background job if no submissionId is provided
105-
if (!submissionId) {
106-
const response = await qstash.publishJSON({
107-
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,
108-
method: "POST",
109-
body: {
110-
bountyId,
111-
},
112-
});
114+
const { startsAt, endsAt } = getEffectiveBountyPeriod({
115+
programEnrollment: submission.programEnrollment,
116+
bounty,
117+
});
113118

114-
if (!response.messageId) {
115-
throw new DubApiError({
116-
code: "bad_request",
117-
message: "Could not sync social metrics for this bounty now.",
118-
});
119-
}
119+
if (!isBountyStarted(startsAt)) {
120+
throw new DubApiError({
121+
code: "bad_request",
122+
message: "Social metrics can only be synced after the bounty starts.",
123+
});
124+
}
120125

121-
return NextResponse.json({});
126+
if (isBountyEnded(endsAt)) {
127+
throw new DubApiError({
128+
code: "bad_request",
129+
message: "Social metrics can't be synced after the bounty ends.",
130+
});
122131
}
123132

124133
// Otherwise, do the sync for the specific submission

0 commit comments

Comments
 (0)