Skip to content
Closed
Show file tree
Hide file tree
Changes from 54 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
9eae7e4
Limit bounty eligibility to selected partner tags
devkiran Jun 16, 2026
b1c9f8f
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jun 18, 2026
f1954d3
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jun 19, 2026
c0564f1
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jun 22, 2026
d1a47dd
Filter partner bounties by partner tag eligibility
devkiran Jun 22, 2026
2609375
Consolidate bounty eligibility logic into shared module
devkiran Jun 22, 2026
f027ea8
Enforce partner tag eligibility on partner bounty endpoints
devkiran Jun 22, 2026
10e25c6
Enforce bounty eligibility on submission and upload flows
devkiran Jun 22, 2026
15bdd7e
Enforce partner tag eligibility in workflows and fix eligibility logic
devkiran Jun 22, 2026
038f88c
Centralize bounty eligibility includes and standardize bounty lookups
devkiran Jun 22, 2026
7e0c44f
Apply partner tag eligibility to draft submissions and bounty APIs
devkiran Jun 22, 2026
c3a8552
Trigger draft bounty submissions when partner tags are added
devkiran Jun 22, 2026
f68639d
Add bounty eligibility UI for groups and partner tags
devkiran Jun 22, 2026
abc40dc
Update route.ts
devkiran Jun 22, 2026
f435665
Fix bounty group and partner tag removal on update
devkiran Jun 22, 2026
07835fa
address CR comments
devkiran Jun 23, 2026
d07b97a
Update route.ts
devkiran Jun 23, 2026
556ed95
Update execute-complete-bounty-workflow.ts
devkiran Jun 23, 2026
23e9135
Add groupJoinedAt to track when partners join their current group
devkiran Jun 23, 2026
90e572b
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jun 23, 2026
a255dc2
Merge branch 'limit-bounties-partner-tags' into dynamic-bounty-start-…
devkiran Jun 23, 2026
6cea2db
Add dynamic bounty timing with start mode and duration presets
devkiran Jun 23, 2026
bb85bfa
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jun 24, 2026
f02f422
code cleanup
devkiran Jun 24, 2026
fc8c125
add throwIfPartnerNotEligibleForBounty
devkiran Jun 24, 2026
6e93fb9
Update trigger-draft-bounty-submissions.ts
devkiran Jun 24, 2026
6c080e5
Show partner tags on bounty card and detail page
devkiran Jun 24, 2026
86734fe
Merge branch 'limit-bounties-partner-tags' into dynamic-bounty-start-…
devkiran Jun 24, 2026
d6df37e
Update route.ts
devkiran Jun 24, 2026
5ac895e
Compute per-partner bounty dates from enrollment and start mode
devkiran Jun 24, 2026
bc54ba2
Use effective bounty dates in partner APIs and submission flows
devkiran Jun 24, 2026
e979197
Consolidate partner bounty availability checks into a shared helper.
devkiran Jun 24, 2026
37846db
Separate bounty access checks from effective date resolution.
devkiran Jun 25, 2026
0f1e005
Allow partners to view expired bounties and split read/write eligibility
devkiran Jun 25, 2026
5ea73f6
Unify bounty duration presets and fix custom date selection
devkiran Jun 25, 2026
dae7570
Rename endDurationDays to endsAfterDays across bounty schema and API
devkiran Jun 25, 2026
6f3ef0b
Show stored bounty timing in edit sheet with inline calendar picker
devkiran Jun 25, 2026
3c883e8
Use per-enrollment timing for relative bounties across crons and work…
devkiran Jun 25, 2026
de91e2a
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jun 30, 2026
e53b734
Merge branch 'limit-bounties-partner-tags' into dynamic-bounty-start-…
devkiran Jun 30, 2026
ba8dc9f
Use effective bounty period for partner-facing bounty filtering
devkiran Jun 30, 2026
3609737
Add bounty timing fields to webhooks and return effective partner dates
devkiran Jun 30, 2026
4e4638f
Use partner bounty eligibility for draft submission triggers
devkiran Jun 30, 2026
71d15b1
Use partner eligibility and single-pass loop in draft submission cron
devkiran Jun 30, 2026
22e6644
Merge branch 'main' into dynamic-bounty-start-date
devkiran Jul 1, 2026
26617ff
Merge branch 'main' into limit-bounties-partner-tags
devkiran Jul 1, 2026
013b191
Merge branch 'limit-bounties-partner-tags' of https://github.com/dubi…
devkiran Jul 1, 2026
8a25e9a
Make bounty startsAt optional for relative start modes
devkiran Jul 1, 2026
78866e0
Support relative bounty start dates and unify active bounty filtering
devkiran Jul 1, 2026
d763f69
Centralize bounty not-started checks and fix period edge cases
devkiran Jul 1, 2026
ad8f1a8
Rename isBountyNotStarted to isBountyStarted across bounty period checks
devkiran Jul 1, 2026
8d1d08b
Respect partner tag restrictions in group bounty listing and move emails
devkiran Jul 1, 2026
f4ae6d7
Move embed bounty submissions endpoint under bounties/[bountyId]
devkiran Jul 1, 2026
567c798
Fix build
devkiran Jul 1, 2026
d2c446d
Update bounty card with progress footer and relative period labels
devkiran Jul 1, 2026
b36399c
simplify submissionId branching in social-metrics sync route
general-adhoc Jul 1, 2026
e037e0a
refactor duplicate code
general-adhoc Jul 1, 2026
516c1a0
Default startsAt to now when switching a bounty from relative to abso…
general-adhoc Jul 1, 2026
3bfd77e
Require approved enrollment status for bounty submissions
general-adhoc Jul 1, 2026
f02ee48
Return 404 for bounties partners cannot view and centralize visibili…
general-adhoc Jul 2, 2026
f8585dc
Keep bounties visible to partners with submissions after they lose el…
general-adhoc Jul 2, 2026
abdf24d
Merge pull request #4106 from dubinc/bounties-cleanup
devkiran Jul 3, 2026
c5a915a
Delete apps/web/lib/bounty/PARTNER_PROFILE_BOUNTY_ROUTES.md
devkiran Jul 20, 2026
b84f85b
Merge branch 'main' into limit-bounties-partner-tags
steven-tey Jul 20, 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
131 changes: 99 additions & 32 deletions apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility";
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 @@ -18,7 +21,7 @@ import {
updateBountySchema,
} from "@/lib/zod/schemas/bounties";
import { arrayEqual, deepEqual } from "@dub/utils";
import { PartnerGroup, Prisma } from "@prisma/client";
import { PartnerGroup, PartnerTag, Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

Expand Down Expand Up @@ -61,6 +64,8 @@ export const PATCH = withWorkspace(
description,
startsAt,
endsAt,
startMode,
endsAfterDays,
submissionsOpenAt,
submissionFrequency,
maxSubmissions,
Expand All @@ -69,28 +74,33 @@ export const PATCH = withWorkspace(
submissionRequirements,
performanceCondition,
groupIds,
partnerTagIds,
} = 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;

validateBounty({
type: bounty.type,
startsAt,
startsAt: startsAt !== undefined ? startsAt : bounty.startsAt,
endsAt: endsAt !== undefined ? endsAt : bounty.endsAt,
startMode: nextStartMode,
endsAfterDays:
endsAfterDays !== undefined ? endsAfterDays : bounty.endsAfterDays,
submissionsOpenAt,
submissionFrequency:
submissionFrequency !== undefined
Expand Down Expand Up @@ -120,17 +130,44 @@ 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;
}
}

// if partnerTagIds is provided and is different from the current partnerTagIds, update the partner tags
let updatedPartnerTags: PartnerTag[] | undefined = undefined;
let shouldUpdatePartnerTags = false;

if (partnerTagIds !== undefined) {
const currentPartnerTagIds = bounty.partnerTags.map(
(tag) => tag.partnerTagId,
);
const newPartnerTagIds = partnerTagIds || [];

if (!arrayEqual(currentPartnerTagIds, newPartnerTagIds)) {
if (newPartnerTagIds.length > 0) {
updatedPartnerTags = await throwIfInvalidPartnerTagIds({
programId,
partnerTagIds: newPartnerTagIds,
});
}

shouldUpdatePartnerTags = true;
}
}

// Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions
Expand Down Expand Up @@ -186,6 +223,16 @@ 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 === "relative") {
startsAtUpdate = { startsAt: null };
} else if (startsAt !== undefined) {
startsAtUpdate = { startsAt: startsAt ?? new Date() };
}

const data = await prisma.$transaction(async (tx) => {
const updatedBounty = await tx.bounty.update({
where: {
Expand All @@ -194,8 +241,10 @@ 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,
...(endsAt !== undefined && { endsAt }),
...(startMode !== undefined && { startMode }),
...(endsAfterDays !== undefined && { endsAfterDays }),
submissionsOpenAt:
bounty.type === "submission" ? submissionsOpenAt : null,
...(bounty.type === "submission" &&
Expand All @@ -211,18 +260,32 @@ 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,
})),
}),
},
}),
...(shouldUpdatePartnerTags && {
partnerTags: {
deleteMany: {},
...(updatedPartnerTags &&
updatedPartnerTags.length > 0 && {
create: updatedPartnerTags.map((tag) => ({
partnerTagId: tag.id,
})),
}),
},
}),
},
include: {
...bountyEligibilityIncludes,
workflow: true,
groups: true,
},
});

Expand All @@ -246,6 +309,9 @@ export const PATCH = withWorkspace(
const updatedBounty = BountySchema.parse({
...data,
groups: data.groups.map(({ groupId }) => ({ id: groupId })),
partnerTags: data.partnerTags.map(({ partnerTagId }) => ({
id: partnerTagId,
})),
performanceCondition: data.workflow?.triggerConditions?.[0],
});

Expand Down Expand Up @@ -295,19 +361,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 Expand Up @@ -338,6 +402,9 @@ export const DELETE = withWorkspace(
const deletedBounty = BountySchema.parse({
...bounty,
groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({
id: partnerTagId,
})),
performanceCondition: bounty.workflow?.triggerConditions?.[0],
});

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 @@ -4,6 +4,11 @@ import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates";
import {
getEffectiveBountyPeriod,
isBountyExpired,
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 +57,12 @@ export const POST = withWorkspace(
urls: true,
status: true,
partner: true,
programEnrollment: {
select: {
groupJoinedAt: true,
createdAt: true,
},
},
},
},
}
Expand All @@ -67,10 +78,12 @@ export const POST = withWorkspace(
});
}

let startsAt: Date | null = null;
let endsAt: Date | null = null;
const submission = submissionId ? bounty.submissions?.[0] : undefined;

if (submissionId) {
if (!submission) {
if (!submission || !submission.programEnrollment) {
throw new DubApiError({
code: "not_found",
message: `Submission ${submissionId} not found.`,
Expand All @@ -83,18 +96,24 @@ export const POST = withWorkspace(
message: "Social metrics can't be synced for an approved submission.",
});
}
}

const now = new Date();
const effectiveDateRange = getEffectiveBountyPeriod({
programEnrollment: submission.programEnrollment,
bounty,
});

startsAt = effectiveDateRange.startsAt;
endsAt = effectiveDateRange.endsAt;
}

if (bounty.startsAt && bounty.startsAt > now) {
if (startsAt && !isBountyStarted(startsAt)) {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can only be synced after the bounty starts.",
});
}

if (bounty.endsAt && bounty.endsAt < now) {
if (isBountyExpired(endsAt)) {
throw new DubApiError({
code: "bad_request",
message: "Social metrics can't be synced after the bounty ends.",
Expand Down
Loading
Loading