Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5a02d8d
Support relative bounty start dates on create and update
devkiran Jul 20, 2026
2340f91
Merge branch 'main' into dynamic-bounty-start-date
devkiran Jul 21, 2026
811f6a1
Support dynamic bounty periods based on partner enrollment dates.
devkiran Jul 21, 2026
2bd1c3d
Keep submitted bounties visible and centralize partner eligibility ch…
devkiran Jul 21, 2026
1884bdc
Use effective bounty periods for submissions and social metrics sync.
devkiran Jul 21, 2026
e1f0547
Keep archived submitted bounties visible and clarify partner eligibil…
devkiran Jul 21, 2026
87b8dc2
Set groupJoinedAt on partner enrollment and group moves.
devkiran Jul 21, 2026
971d3e7
Update get-bounty-or-throw.ts
devkiran Jul 21, 2026
2c9fd2a
Prefer activity logs when backfilling groupJoinedAt.
devkiran Jul 21, 2026
30d83d6
Align relative bounty timing across PATCH, queries, cron, and webhooks.
devkiran Jul 21, 2026
b770f43
Fix partner bounty visibility queries and eligibility where helpers.
devkiran Jul 21, 2026
2c35dd6
Restrict relative bounties to new partners and fix duration form sync.
devkiran Jul 21, 2026
bcbf746
Allow relative-start bounties with a fixed end date and fix related f…
devkiran Jul 21, 2026
90b43ca
Disable partner notification emails for relative-start bounties.
devkiran Jul 21, 2026
1ccf5a2
Move embed bounty submissions under bountyId and tighten eligibility …
devkiran Jul 21, 2026
5abd92f
Gate draft submission cron on partner eligibility and trim bounty sel…
devkiran Jul 21, 2026
441a1c0
Handle nullable startsAt for relative bounties and skip partner notif…
devkiran Jul 21, 2026
93d013d
Use partner eligibility helpers in complete bounty workflows.
devkiran Jul 21, 2026
30d467c
Update execute-complete-bounty-workflow.ts
devkiran Jul 21, 2026
47fee24
Update route.ts
devkiran Jul 21, 2026
31c2f3b
Hide send-notification checkbox for relative bounty creation.
devkiran Jul 21, 2026
8ce9960
Make workflow programEnrollment optional and replace invalid font-reg…
devkiran Jul 21, 2026
44e2cf5
Merge branch 'main' into dynamic-bounty-start-date
devkiran Jul 22, 2026
26dce41
Remove groupJoinedAt field
devkiran Jul 22, 2026
e3146af
Swap bounty custom start/end to inline calendar popover
devkiran Jul 22, 2026
9b5aa9e
Align bounty period checks with partner eligibility helper
devkiran Jul 22, 2026
39ea833
Move unban partner side effects to a background job.
devkiran Jul 22, 2026
e50d417
Update index.test.ts
devkiran Jul 22, 2026
8658231
Merge branch 'main' into dynamic-bounty-start-date
devkiran Jul 22, 2026
c45faea
Merge branch 'main' into dynamic-bounty-start-date
devkiran Jul 23, 2026
7609483
Merge branch 'main' into dynamic-bounty-start-date
steven-tey Jul 23, 2026
b407c20
Merge branch 'main' into dynamic-bounty-start-date
steven-tey Jul 24, 2026
47735f4
fix merge conflicts
steven-tey Jul 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 94 additions & 33 deletions apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { parseRequestBody } from "@/lib/api/utils";
import { WorkflowCondition } from "@/lib/api/workflows/types";
import { validateWorkflowConditions } from "@/lib/api/workflows/validate-workflow-conditions";
import { withWorkspace } from "@/lib/auth";
import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-availability";
import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name";
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details";
import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes";
import { validateBounty } from "@/lib/bounty/api/validate-bounty";
Expand All @@ -19,7 +21,7 @@ import {
updateBountySchema,
} from "@/lib/zod/schemas/bounties";
import { arrayEqual, deepEqual } from "@dub/utils";
import { PartnerGroup, Prisma } from "@prisma/client";
import { BountyStartMode, PartnerGroup, Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

Expand Down Expand Up @@ -55,6 +57,8 @@ export const PATCH = withWorkspace(
description,
startsAt,
endsAt,
startMode,
endsAfterDays,
submissionsOpenAt,
submissionFrequency,
maxSubmissions,
Expand All @@ -65,26 +69,57 @@ export const PATCH = withWorkspace(
groupIds,
} = updateBountySchema.parse(await parseRequestBody(req));

const bounty = await prisma.bounty.findUniqueOrThrow({
where: {
id: bountyId,
programId,
},
const bounty = await getBountyOrThrow({
bountyId,
programId,
include: {
groups: true,
workflow: true,
_count: {
select: {
submissions: true,
},
},
...bountyEligibilityIncludes,
},
});

const nextStartMode =
startMode !== undefined ? startMode : bounty.startMode;

// Absolute end dates are cleared when switching to relative (unless the
// client explicitly sends endsAt) or when setting endsAfterDays.
let endsAtUpdate: { endsAt?: Date | null } = {};

if (endsAt !== undefined) {
endsAtUpdate = { endsAt };
} else if (endsAfterDays != null) {
endsAtUpdate = { endsAt: null };
} else if (
nextStartMode === BountyStartMode.relative &&
bounty.endsAt != null
) {
endsAtUpdate = { endsAt: null };
}

validateBounty({
type: bounty.type,
startsAt,
endsAt: endsAt !== undefined ? endsAt : bounty.endsAt,
// Relative bounties never store startsAt; coerce so mode switches don't
// fail validation against a leftover absolute startsAt.
startsAt:
nextStartMode === BountyStartMode.relative
? null
: startsAt !== undefined
? startsAt
: bounty.startsAt,
endsAt:
endsAtUpdate.endsAt !== undefined ? endsAtUpdate.endsAt : bounty.endsAt,
startMode: nextStartMode,
endsAfterDays:
endsAfterDays !== undefined
? endsAfterDays
: nextStartMode === BountyStartMode.absolute
? null
: bounty.endsAfterDays,
submissionsOpenAt,
submissionFrequency:
submissionFrequency !== undefined
Expand Down Expand Up @@ -121,17 +156,22 @@ export const PATCH = withWorkspace(

// if groupIds is provided and is different from the current groupIds, update the groups
let updatedPartnerGroups: PartnerGroup[] | undefined = undefined;
if (
groupIds &&
!arrayEqual(
bounty.groups.map((group) => group.groupId),
groupIds,
)
) {
updatedPartnerGroups = await throwIfInvalidGroupIds({
programId,
groupIds,
});
let shouldUpdatePartnerGroups = false;

if (groupIds !== undefined) {
const currentGroupIds = bounty.groups.map((group) => group.groupId);
const newGroupIds = groupIds || [];

if (!arrayEqual(currentGroupIds, newGroupIds)) {
if (newGroupIds.length > 0) {
updatedPartnerGroups = await throwIfInvalidGroupIds({
programId,
groupIds: newGroupIds,
});
}

shouldUpdatePartnerGroups = true;
}
}

// Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions
Expand Down Expand Up @@ -187,6 +227,19 @@ export const PATCH = withWorkspace(
});
}

// Relative bounties start when a partner joins, so startsAt is cleared.
// For absolute bounties, only update startsAt when explicitly provided.
let startsAtUpdate: { startsAt?: Date | null } = {};

if (nextStartMode === BountyStartMode.relative) {
startsAtUpdate = { startsAt: null };
} else if (startsAt !== undefined) {
startsAtUpdate = { startsAt: startsAt ?? new Date() };
} else if (bounty.startsAt === null) {
// Switching relative -> absolute without a startsAt: default to now
startsAtUpdate = { startsAt: new Date() };
}

const data = await prisma.$transaction(async (tx) => {
const updatedBounty = await tx.bounty.update({
where: {
Expand All @@ -195,8 +248,15 @@ export const PATCH = withWorkspace(
data: {
name: bountyName ?? undefined,
description,
startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4)
endsAt,
...startsAtUpdate,
...endsAtUpdate,
...(startMode !== undefined && { startMode }),
...(endsAfterDays !== undefined
? { endsAfterDays }
: nextStartMode === BountyStartMode.absolute &&
bounty.endsAfterDays != null
? { endsAfterDays: null }
: {}),
submissionsOpenAt:
bounty.type === "submission" ? submissionsOpenAt : null,
...(bounty.type === "submission" &&
Expand All @@ -212,18 +272,21 @@ export const PATCH = withWorkspace(
submissionRequirements !== undefined && {
submissionRequirements: submissionRequirements ?? Prisma.DbNull,
}),
...(updatedPartnerGroups && {
...(shouldUpdatePartnerGroups && {
groups: {
deleteMany: {},
create: updatedPartnerGroups.map((group) => ({
groupId: group.id,
})),
...(updatedPartnerGroups &&
updatedPartnerGroups.length > 0 && {
create: updatedPartnerGroups.map((group) => ({
groupId: group.id,
})),
}),
},
}),
},
include: {
workflow: true,
groups: true,
...bountyEligibilityIncludes,
},
});

Expand Down Expand Up @@ -289,19 +352,17 @@ export const DELETE = withWorkspace(
const { bountyId } = params;
const programId = getDefaultProgramIdOrThrow(workspace);

const bounty = await prisma.bounty.findUniqueOrThrow({
where: {
id: bountyId,
programId,
},
const bounty = await getBountyOrThrow({
bountyId,
programId,
include: {
groups: true,
workflow: true,
_count: {
select: {
submissions: true,
},
},
...bountyEligibilityIncludes,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ export const GET = withWorkspace(
await getBountyOrThrow({
bountyId,
programId,
include: {
groups: true,
},
});

const {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { DubApiError } from "@/lib/api/errors";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability";
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates";
import { isBountyEnded, isBountyStarted } from "@/lib/bounty/bounty-period";
import { resolveBountyDetails } from "@/lib/bounty/utils";
import { qstash } from "@/lib/cron";
import { prisma } from "@/lib/prisma";
Expand Down Expand Up @@ -52,6 +54,11 @@ export const POST = withWorkspace(
urls: true,
status: true,
partner: true,
programEnrollment: {
select: {
createdAt: true,
},
},
},
},
}
Expand All @@ -67,58 +74,60 @@ export const POST = withWorkspace(
});
}

const submission = submissionId ? bounty.submissions?.[0] : undefined;

if (submissionId) {
if (!submission) {
throw new DubApiError({
code: "not_found",
message: `Submission ${submissionId} not found.`,
});
}
// Bounty-wide sync (no submissionId): run asynchronously via a background job
if (!submissionId) {
const response = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,
method: "POST",
body: {
bountyId,
},
});

if (submission.status === "approved") {
if (!response.messageId) {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can't be synced for an approved submission.",
message: "Could not sync social metrics for this bounty now.",
});
}

return NextResponse.json({});
}

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

if (bounty.startsAt && bounty.startsAt > now) {
if (!submission || !submission.programEnrollment) {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can only be synced after the bounty starts.",
code: "not_found",
message: `Submission ${submissionId} not found.`,
});
}

if (bounty.endsAt && bounty.endsAt < now) {
if (submission.status === "approved") {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can't be synced after the bounty ends.",
message: "Social metrics can't be synced for an approved submission.",
});
}

// Do the sync in a background job if no submissionId is provided
if (!submissionId) {
const response = await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,
method: "POST",
body: {
bountyId,
},
});
const { startsAt, endsAt } = getEffectiveBountyPeriod({
programEnrollment: submission.programEnrollment,
bounty,
});

if (!response.messageId) {
throw new DubApiError({
code: "bad_request",
message: "Could not sync social metrics for this bounty now.",
});
}
if (!isBountyStarted(startsAt)) {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can only be synced after the bounty starts.",
});
}

return NextResponse.json({});
if (isBountyEnded(endsAt)) {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can't be synced after the bounty ends.",
});
}

// Otherwise, do the sync for the specific submission
Expand Down
Loading
Loading