From 5a02d8d3a1f37a157ec5097d15403070499de214 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 20 Jul 2026 21:33:25 +0530 Subject: [PATCH 01/27] Support relative bounty start dates on create and update --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 106 +++++++++++----- .../bounties/[bountyId]/submissions/route.ts | 3 - apps/web/app/(ee)/api/bounties/route.ts | 118 +++++++++++------- apps/web/lib/bounty/api/bounty-eligibility.ts | 58 +++++++++ .../lib/bounty/api/get-bounty-with-details.ts | 4 + apps/web/lib/bounty/api/validate-bounty.ts | 39 +++++- apps/web/lib/zod/schemas/bounties.ts | 7 +- apps/web/lib/zod/schemas/partner-profile.ts | 1 + apps/web/prisma/schema/bounty.prisma | 9 +- apps/web/prisma/schema/program.prisma | 1 + 10 files changed, 259 insertions(+), 87 deletions(-) create mode 100644 apps/web/lib/bounty/api/bounty-eligibility.ts diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index a89c3934939..53520faca3e 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -4,7 +4,9 @@ import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group- import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; 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"; @@ -54,6 +56,8 @@ export const PATCH = withWorkspace( description, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, @@ -64,26 +68,41 @@ 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; + validateBounty({ type: bounty.type, - startsAt, + // Relative bounties never store startsAt; coerce so mode switches don't + // fail validation against a leftover absolute startsAt. + startsAt: + nextStartMode === "relative" + ? null + : startsAt !== undefined + ? startsAt + : bounty.startsAt, endsAt: endsAt !== undefined ? endsAt : bounty.endsAt, + startMode: nextStartMode, + endsAfterDays: + endsAfterDays !== undefined + ? endsAfterDays + : nextStartMode === "absolute" + ? null + : bounty.endsAfterDays, submissionsOpenAt, submissionFrequency: submissionFrequency !== undefined @@ -113,17 +132,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 @@ -179,6 +203,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 === "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: { @@ -187,8 +224,14 @@ 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 } + : nextStartMode === "absolute" && bounty.endsAfterDays != null + ? { endsAfterDays: null } + : {}), submissionsOpenAt: bounty.type === "submission" ? submissionsOpenAt : null, ...(bounty.type === "submission" && @@ -204,18 +247,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, }, }); @@ -281,19 +327,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, }, }); diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts index f6ba6d42236..34faf5a188c 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts @@ -17,9 +17,6 @@ export const GET = withWorkspace( await getBountyOrThrow({ bountyId, programId, - include: { - groups: true, - }, }); const { diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 873593f51f1..b7e7aa2a00e 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -6,6 +6,11 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { + bountyEligibilityIncludes, + buildBountyEligibilityWhere, + getEffectiveBountyPeriod, +} from "@/lib/bounty/api/bounty-eligibility"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; import { qstash } from "@/lib/cron"; @@ -41,52 +46,33 @@ export const GET = withWorkspace( partnerId, programId, include: { - program: true, + program: { + select: { + defaultGroupId: true, + }, + }, }, }) : null; + const partnerGroupId = + programEnrollment?.groupId || programEnrollment?.program.defaultGroupId; + const [bounties, allBountiesSubmissionsCount] = await Promise.all([ prisma.bounty.findMany({ where: { programId, - // Filter only bounties the specified partner is eligible for ...(programEnrollment && { - AND: [ - // Filter out expired bounties - { - OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }], - }, - // Filter by partner's group eligibility - { - OR: [ - { - groups: { - none: {}, - }, - }, - { - groups: { - some: { - groupId: - programEnrollment.groupId || - programEnrollment.program.defaultGroupId, - }, - }, - }, - ], - }, - ], + ...buildBountyEligibilityWhere({ + groupId: partnerGroupId, + }), }), }, include: { - groups: { - select: { - groupId: true, - }, - }, + ...bountyEligibilityIncludes, }, }), + includeSubmissionsCount ? prisma.bountySubmission.groupBy({ by: ["bountyId", "status"], @@ -127,14 +113,35 @@ export const GET = withWorkspace( }; }; - const data = bounties.map((bounty) => { - return BountyListSchema.parse({ - ...bounty, - groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), - ...(allBountiesSubmissionsCount && { - submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id), + // Transform the bounties to the response schema + const now = new Date(); + const data = bounties.flatMap((bounty) => { + if (programEnrollment) { + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + if (now < startsAt || (endsAt && now > endsAt)) { + return []; + } + + bounty = { + ...bounty, + startsAt, + endsAt, + }; + } + + return [ + BountyListSchema.parse({ + ...bounty, + ...(allBountiesSubmissionsCount && { + submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id), + }), + groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), }), - }); + ]; }); return NextResponse.json(data); @@ -167,11 +174,10 @@ export const POST = withWorkspace( performanceCondition, performanceScope, sendNotificationEmails, + startMode, + endsAfterDays, } = parsedBody; - // Use current date as default if startsAt is not provided - startsAt = startsAt || new Date(); - validateBounty(parsedBody); const { canUseBountySocialMetrics, canSendEmailCampaigns } = @@ -206,6 +212,10 @@ export const POST = withWorkspace( }); } + // startsAt is only stored for absolute bounties (defaulting to now when + // omitted); relative bounties start when a partner joins, so it stays null. + startsAt = startMode === "absolute" ? startsAt || new Date() : null; + const bounty = await prisma.$transaction(async (tx) => { let workflow: Workflow | null = null; const bountyId = createId({ prefix: "bnty_" }); @@ -248,6 +258,8 @@ export const POST = withWorkspace( rewardAmount, rewardDescription, performanceScope: type === "performance" ? performanceScope : null, + startMode, + endsAfterDays, ...(submissionRequirements && type === "submission" && { submissionRequirements, @@ -264,7 +276,7 @@ export const POST = withWorkspace( }, include: { workflow: true, - groups: true, + ...bountyEligibilityIncludes, }, }); }); @@ -276,7 +288,14 @@ export const POST = withWorkspace( }); const shouldScheduleDraftSubmissions = - bounty.type === "performance" && bounty.performanceScope === "lifetime"; + bounty.type === "performance" && + bounty.performanceScope === "lifetime" && + bounty.startMode === "absolute"; + + const shouldSchedulePartnerNotifications = + sendNotificationEmails && + canSendEmailCampaigns && + bounty.startMode === "absolute"; waitUntil( Promise.allSettled([ @@ -301,14 +320,15 @@ export const POST = withWorkspace( data: createdBounty, }), - sendNotificationEmails && - canSendEmailCampaigns && + shouldSchedulePartnerNotifications && qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`, body: { bountyId: bounty.id, }, - notBefore: Math.floor(bounty.startsAt.getTime() / 1000), + ...(bounty.startsAt && { + notBefore: Math.floor(bounty.startsAt.getTime() / 1000), + }), }), shouldScheduleDraftSubmissions && @@ -317,7 +337,9 @@ export const POST = withWorkspace( body: { bountyId: bounty.id, }, - notBefore: Math.floor(bounty.startsAt.getTime() / 1000), + ...(bounty.startsAt && { + notBefore: Math.floor(bounty.startsAt.getTime() / 1000), + }), }), ]), ); diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts new file mode 100644 index 00000000000..28b518e445c --- /dev/null +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -0,0 +1,58 @@ +import { Bounty, Prisma, ProgramEnrollment } from "@prisma/client"; +import { addDays } from "date-fns"; + +export function buildBountyEligibilityWhere({ + groupId, +}: { + groupId: string | undefined; +}): Prisma.BountyWhereInput { + return { + OR: [ + { + groups: { + none: {}, + }, + }, + ...(groupId + ? [ + { + groups: { + some: { + groupId, + }, + }, + }, + ] + : []), + ], + }; +} + +export const bountyEligibilityIncludes = { + groups: { + select: { + groupId: true, + }, + }, +} satisfies Prisma.BountyInclude; + +export function getEffectiveBountyPeriod({ + programEnrollment, + bounty, +}: { + programEnrollment: Pick; + bounty: Pick; +}) { + const { createdAt, groupJoinedAt } = programEnrollment; + const { startsAt, endsAt, endsAfterDays, startMode } = bounty; + + // If startMode is absolute, use the startsAt (Assumed to be set). + // If startMode is relative, use the groupJoinedAt or createdAt. + const bountyStartDate = + startMode === "absolute" ? startsAt! : groupJoinedAt || createdAt; + + return { + startsAt: bountyStartDate, + endsAt: endsAfterDays ? addDays(bountyStartDate, endsAfterDays) : endsAt, + }; +} diff --git a/apps/web/lib/bounty/api/get-bounty-with-details.ts b/apps/web/lib/bounty/api/get-bounty-with-details.ts index a3740ef9825..e7e3f239803 100644 --- a/apps/web/lib/bounty/api/get-bounty-with-details.ts +++ b/apps/web/lib/bounty/api/get-bounty-with-details.ts @@ -16,6 +16,8 @@ export const getBountyWithDetails = async ({ b.type, b.startsAt, b.endsAt, + b.startMode, + b.endsAfterDays, b.submissionsOpenAt, b.submissionFrequency, b.maxSubmissions, @@ -63,6 +65,8 @@ export const getBountyWithDetails = async ({ type: bounty.type, startsAt: bounty.startsAt, endsAt: bounty.endsAt, + startMode: bounty.startMode, + endsAfterDays: bounty.endsAfterDays, submissionsOpenAt: bounty.submissionsOpenAt, submissionFrequency: bounty.submissionFrequency, maxSubmissions: bounty.maxSubmissions, diff --git a/apps/web/lib/bounty/api/validate-bounty.ts b/apps/web/lib/bounty/api/validate-bounty.ts index 19c28f21cec..a4067bde800 100644 --- a/apps/web/lib/bounty/api/validate-bounty.ts +++ b/apps/web/lib/bounty/api/validate-bounty.ts @@ -5,6 +5,8 @@ export function validateBounty({ type, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, @@ -12,9 +14,40 @@ export function validateBounty({ rewardDescription, performanceScope, }: Partial) { - startsAt = startsAt || new Date(); + startMode = startMode ?? "absolute"; - if (endsAt && endsAt < startsAt) { + // startsAt is required when startMode is absolute and must be null when + // startMode is relative (relative bounties start when a partner joins). + if (startMode === "relative") { + if (startsAt != null) { + throw new DubApiError({ + message: + "startsAt is not supported when the bounty starts when a partner joins. It must be null for relative bounties.", + code: "bad_request", + }); + } + } else { + // Default to now when an absolute bounty doesn't specify a start date + startsAt = startsAt || new Date(); + } + + if (endsAt && endsAfterDays) { + throw new DubApiError({ + message: + "Bounty cannot have both an end date (endsAt) and endsAfterDays.", + code: "bad_request", + }); + } + + if (startMode === "absolute" && endsAfterDays) { + throw new DubApiError({ + message: + "endsAfterDays is only supported when the bounty starts when a partner joins.", + code: "bad_request", + }); + } + + if (endsAt && startsAt && endsAt < startsAt) { throw new DubApiError({ message: "Bounty end date (endsAt) must be on or after start date (startsAt).", @@ -31,7 +64,7 @@ export function validateBounty({ }); } - if (submissionsOpenAt < startsAt) { + if (startsAt && submissionsOpenAt < startsAt) { throw new DubApiError({ message: "Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).", diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index 12ea84a8b4d..00b2762e6fd 100644 --- a/apps/web/lib/zod/schemas/bounties.ts +++ b/apps/web/lib/zod/schemas/bounties.ts @@ -12,6 +12,7 @@ import { } from "@/lib/bounty/social-content"; import { BountyPerformanceScope, + BountyStartMode, BountySubmissionFrequency, BountySubmissionRejectionReason, BountySubmissionStatus, @@ -126,6 +127,8 @@ export const createBountySchema = z.object({ performanceCondition: bountyPerformanceConditionSchema.nullish(), performanceScope: z.enum(BountyPerformanceScope).nullish(), sendNotificationEmails: z.boolean().optional(), + startMode: z.enum(BountyStartMode), + endsAfterDays: z.number().int().positive().nullish(), }); export const updateBountySchema = createBountySchema @@ -148,8 +151,10 @@ export const BountySchema = z.object({ name: z.string().nullable(), description: z.string().nullable(), type: z.enum(BountyType), - startsAt: z.date(), + startsAt: z.date().nullable(), endsAt: z.date().nullable(), + startMode: z.enum(BountyStartMode), + endsAfterDays: z.number().nullable(), submissionsOpenAt: z.date().nullable(), submissionFrequency: z.enum(BountySubmissionFrequency).nullable(), maxSubmissions: z.number(), diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 303279daf98..a04b49a8da6 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -166,6 +166,7 @@ export const PartnerBountySchema = BountySchema.omit({ groups: true, socialMetricsLastSyncedAt: true, }).extend({ + startsAt: z.date(), // Always resolved to the partner's effective start date (never null) submissions: z.array(partnerBountySubmissionSchema), performanceCondition: bountyPerformanceConditionSchema .nullable() diff --git a/apps/web/prisma/schema/bounty.prisma b/apps/web/prisma/schema/bounty.prisma index 321f46b2c35..ea6d2986151 100644 --- a/apps/web/prisma/schema/bounty.prisma +++ b/apps/web/prisma/schema/bounty.prisma @@ -29,6 +29,11 @@ enum BountySubmissionFrequency { month } +enum BountyStartMode { + absolute + relative +} + model Bounty { id String @id programId String @@ -36,8 +41,10 @@ model Bounty { name String description String? @db.Text type BountyType - startsAt DateTime + startsAt DateTime? // Set only when startMode is absolute endsAt DateTime? + startMode BountyStartMode @default(absolute) + endsAfterDays Int? // Set only when startMode is relative submissionsOpenAt DateTime? submissionFrequency BountySubmissionFrequency? maxSubmissions Int @default(1) diff --git a/apps/web/prisma/schema/program.prisma b/apps/web/prisma/schema/program.prisma index 7d88029486e..5fff2bd9169 100644 --- a/apps/web/prisma/schema/program.prisma +++ b/apps/web/prisma/schema/program.prisma @@ -143,6 +143,7 @@ model ProgramEnrollment { customerDataSharingEnabledAt DateTime? groupMoveDisabledAt DateTime? bannedAt DateTime? + groupJoinedAt DateTime? // date when the partner joined their current group bannedReason PartnerBannedReason? reapplicationTimeframe ReapplicationTimeframe @default(standard) riskMonitoringDisabledAt DateTime? From 811f6a1d9c8822065b912ee66cc60f1365583883 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 11:07:22 +0530 Subject: [PATCH 02/27] Support dynamic bounty periods based on partner enrollment dates. --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 2 +- apps/web/app/(ee)/api/bounties/route.ts | 6 +- .../[programId]/bounties/[bountyId]/route.ts | 41 +- .../referrals/get-referrals-embed-data.ts | 17 +- .../bounties/[bountyId]/bounty-info.tsx | 84 +--- .../add-edit-bounty/add-edit-bounty-sheet.tsx | 140 +----- .../add-edit-bounty/bounty-duration.tsx | 472 ++++++++++++++++++ .../confirm-create-bounty-modal.tsx | 32 +- .../use-add-edit-bounty-form.ts | 212 +++++--- .../(ee)/program/bounties/bounty-card.tsx | 240 ++++----- ...-eligibility.ts => bounty-availability.ts} | 28 +- .../bounty/api/get-bounties-for-partner.ts | 86 +++- apps/web/lib/bounty/bounty-period.ts | 136 +++++ 13 files changed, 1023 insertions(+), 473 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx rename apps/web/lib/bounty/api/{bounty-eligibility.ts => bounty-availability.ts} (67%) create mode 100644 apps/web/lib/bounty/bounty-period.ts diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 53520faca3e..d06dc099acd 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -4,7 +4,7 @@ import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group- import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; -import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility"; +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"; diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index b7e7aa2a00e..88bfe2e456a 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -10,7 +10,7 @@ import { bountyEligibilityIncludes, buildBountyEligibilityWhere, getEffectiveBountyPeriod, -} from "@/lib/bounty/api/bounty-eligibility"; +} from "@/lib/bounty/api/bounty-availability"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; import { qstash } from "@/lib/cron"; @@ -63,9 +63,7 @@ export const GET = withWorkspace( where: { programId, ...(programEnrollment && { - ...buildBountyEligibilityWhere({ - groupId: partnerGroupId, - }), + ...buildBountyEligibilityWhere(partnerGroupId), }), }, include: { diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts index 5ff1989bd6c..bebc88a9dd2 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts @@ -1,11 +1,16 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; +import { isBountyExpired, isBountyStarted } from "@/lib/bounty/bounty-period"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; -import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; +// TODO: +// Allow partners to see a bounty if they have a submission + // GET /api/partner-profile/programs/[programId]/bounties/[bountyId] – get a single bounty for an enrolled program export const GET = withPartnerProfile(async ({ partner, params }) => { const { programId, bountyId } = params; @@ -20,11 +25,9 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }); - const bounty = await prisma.bounty.findUnique({ - where: { - id: bountyId, - programId: program.id, - }, + const bounty = await getBountyOrThrow({ + programId, + bountyId, include: { workflow: { select: { @@ -50,27 +53,25 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }); - if (!bounty) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } + const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const partnerCanSeeBounty = + bountyGroupIds.length === 0 || + (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); - if (bounty.startsAt > new Date()) { + if (!partnerCanSeeBounty) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", }); } - const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; - const bountyGroupIds = bounty.groups.map((g) => g.groupId); - const partnerCanSeeBounty = - bountyGroupIds.length === 0 || - (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); - if (!partnerCanSeeBounty) { + if (!isBountyStarted(startsAt) || isBountyExpired(endsAt)) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", @@ -82,6 +83,8 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { return NextResponse.json( PartnerBountySchema.parse({ ...bountyWithoutGroups, + startsAt, + endsAt, performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, partner: { ...aggregatePartnerLinksStats(links), diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts b/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts index d5502ff8e24..840b6083a06 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts @@ -17,7 +17,6 @@ export const getReferralsEmbedData = async (token: string) => { notFound(); } - const now = new Date(); const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId, programId, @@ -51,18 +50,6 @@ export const getReferralsEmbedData = async (token: string) => { termsUrl: true, embedData: true, resources: true, - _count: { - select: { - bounties: { - where: { - startsAt: { - lte: now, - }, - OR: [{ endsAt: null }, { endsAt: { gte: now } }], - }, - }, - }, - }, }, }, links: true, @@ -112,9 +99,7 @@ export const getReferralsEmbedData = async (token: string) => { }, }), - program._count.bounties > 0 - ? getBountiesForPartner(programEnrollment) - : Promise.resolve([]), + getBountiesForPartner(programEnrollment), ]); return { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx index b334839fcf6..c22bfdd1963 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx @@ -1,19 +1,14 @@ "use client"; +import { getProgramBountyMeta } from "@/lib/bounty/bounty-period"; import useBounty from "@/lib/swr/use-bounty"; -import { - SubmissionsCountByStatus, - useBountySubmissionsCount, -} from "@/lib/swr/use-bounty-submissions-count"; import useGroups from "@/lib/swr/use-groups"; -import { usePartnersCountByGroupIds } from "@/lib/swr/use-partners-count-by-groupids"; import useWorkspace from "@/lib/swr/use-workspace"; import { BountyRewardDescription } from "@/ui/partners/bounties/bounty-reward-description"; import { BountyThumbnailImage } from "@/ui/partners/bounties/bounty-thumbnail-image"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { ScrollableTooltipContent, Tooltip } from "@dub/ui"; import { Calendar6, Users, Users6 } from "@dub/ui/icons"; -import { formatDate, nFormatter, pluralize } from "@dub/utils"; import { useMemo } from "react"; import { BountyActionButton } from "../bounty-action-button"; @@ -21,28 +16,6 @@ export function BountyInfo() { const { bounty, loading } = useBounty(); const { isOwner } = useWorkspace(); - const { submissionsCount } = useBountySubmissionsCount< - SubmissionsCountByStatus[] - >({ - ignoreParams: true, - enabled: Boolean(bounty), - }); - - const totalSubmissions = useMemo(() => { - return submissionsCount - ?.filter((s) => s.status === "submitted" || s.status === "approved") - ?.reduce((acc, curr) => acc + curr.count, 0); - }, [submissionsCount]); - - const readyForReviewSubmissions = useMemo(() => { - return submissionsCount?.find((s) => s.status === "submitted")?.count ?? 0; - }, [submissionsCount]); - - const { totalPartners, loading: totalPartnersForBountyLoading } = - usePartnersCountByGroupIds({ - groupIds: bounty?.groups?.map((group) => group.id) ?? [], - }); - const { groups } = useGroups(); const eligibleGroups = useMemo(() => { @@ -62,6 +35,8 @@ export function BountyInfo() { return null; } + const { dateRangeLabel, partnerAudienceLabel } = getProgramBountyMeta(bounty); + return (
@@ -80,63 +55,14 @@ export function BountyInfo() {
- - {formatDate(bounty.startsAt, { month: "short" })} - {" → "} - {bounty.endsAt - ? formatDate(bounty.endsAt, { month: "short" }) - : "No end date"} - + {dateRangeLabel}
-
- {totalPartnersForBountyLoading ? ( - - ) : totalPartners === 0 ? ( - <> - 0{" "} - {pluralize("partner", 0)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} - - ) : totalSubmissions === totalPartners ? ( - <> - All{" "} - - {nFormatter(totalPartners, { full: true })} - {" "} - {pluralize("partner", totalPartners)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} - - ) : ( - <> - - {nFormatter(totalSubmissions ?? 0, { - full: true, - })} - {" "} - of{" "} - - {nFormatter(totalPartners, { full: true })} - {" "} - {pluralize("partner", totalPartners)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} - - )} - {readyForReviewSubmissions > 0 && ( - <> - {" "} - ( - - {nFormatter(readyForReviewSubmissions, { full: true })} - {" "} - awaiting review) - - )} -
+ {partnerAudienceLabel}
{isOwner && ( diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx index c5499163d7a..5d2600e9b19 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx @@ -21,7 +21,6 @@ import { } from "@/ui/shared/inline-badge-popover"; import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; import { - AnimatedSizeContainer, Button, CalendarIcon, CardSelector, @@ -34,7 +33,6 @@ import { RichTextProvider, RichTextToolbar, Sheet, - SmartDateTimePicker, Switch, Tooltip, TooltipContent, @@ -45,6 +43,7 @@ import { BountySubmissionFrequency } from "@prisma/client"; import { Dispatch, SetStateAction, useState } from "react"; import { Controller, FormProvider } from "react-hook-form"; import { BountyCriteria } from "./bounty-criteria"; +import { BountyDuration } from "./bounty-duration"; import { useAddEditBountyForm } from "./use-add-edit-bounty-form"; interface BountySheetProps { @@ -78,11 +77,12 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { form, openAccordions, setOpenAccordions, - hasStartDate, - handleStartDateToggle, hasEndDate, - handleEndDateToggle, - handleEndDateChange, + startsAt, + endsAt, + startMode, + endsAfterDays, + handleTimingChange, allowedSubmissions, handleAllowedSubmissionsChange, maxAllowedSubmissions, @@ -246,126 +246,16 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
- -
- - -
- - {hasStartDate && ( -
- ( - - field.onChange(date ?? undefined) - } - placeholder='E.g. "2026-02-28", "Last Thursday", "2 hours ago"' - /> - )} - /> -
- )} -
- - {type === "performance" && ( - -
- - -
- - {hasEndDate && ( -
- ( - - handleEndDateChange(date ?? null) - } - placeholder='E.g. "2026-12-01", "Next Thursday", "After 10 days"' - /> - )} - /> -
- )} -
- )} - - {type === "submission" && ( - -
- - -
- - {hasEndDate && ( -
- ( - - handleEndDateChange(date ?? null) - } - placeholder='E.g. "2026-12-01", "Next Thursday", "After 10 days"' - /> - )} - /> -
- )} -
- )} + onChange={handleTimingChange} + /> {type === "submission" && ( <> diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx new file mode 100644 index 00000000000..372381bc17c --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { + BOUNTY_DURATION_DAYS, + BOUNTY_DURATION_PRESETS, + DurationPreset, + EndPreset, + resolveBountyTiming, + StartPreset, +} from "@/lib/bounty/bounty-period"; +import { + InlineBadgePopover, + InlineBadgePopoverMenu, +} from "@/ui/shared/inline-badge-popover"; +import { CalendarIcon, DatePicker } from "@dub/ui"; +import { formatDate } from "@dub/utils"; +import { addDays, addMonths, addWeeks } from "date-fns"; +import { useEffect, useState } from "react"; + +type PresetOption = { value: T; label: string }; +type BountyTimingInput = ReturnType; +type ParsedPresets = { + startPreset: StartPreset; + endPreset: EndPreset; + customStartsAt: Date | null; + customEndsAt: Date | null; + customEndsAfterDays: number | null; +}; + +const DURATION_LABELS: Record = + { + twoWeeks: { start: "in 2 weeks", end: "2 weeks" }, + oneMonth: { start: "in 1 month", end: "1 month" }, + sixMonths: { start: "in 6 months", end: "6 months" }, + }; + +const START_OPTIONS = [ + { value: "today", label: "today" }, + ...BOUNTY_DURATION_PRESETS.map((p) => ({ + value: p, + label: DURATION_LABELS[p].start, + })), + { value: "onPartnerJoin", label: "when a new partner joins" }, + { value: "custom", label: "custom" }, +] satisfies PresetOption[]; + +const END_OPTIONS = [ + { value: "never", label: "never" }, + ...BOUNTY_DURATION_PRESETS.map((p) => ({ + value: p, + label: DURATION_LABELS[p].end, + })), + { value: "custom", label: "custom" }, +] satisfies PresetOption[]; + +const START_DURATION_DATES: Record Date> = { + twoWeeks: (now) => addWeeks(now, 2), + oneMonth: (now) => addMonths(now, 1), + sixMonths: (now) => addMonths(now, 6), +}; + +const DATE_TOLERANCE_MS = 60_000; + +function datesAreClose(a: Date, b: Date, toleranceMs = DATE_TOLERANCE_MS) { + return Math.abs(a.getTime() - b.getTime()) <= toleranceMs; +} + +function isSameCalendarDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function findDurationPresetByDays(days: number): DurationPreset | null { + return ( + (Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][]).find( + ([, durationDays]) => durationDays === days, + )?.[0] ?? null + ); +} + +function getPresetLabel( + preset: T, + options: PresetOption[], + customDate?: Date | null, + fallback?: string, +) { + if (preset === "custom" && customDate) { + return formatDate(customDate, { month: "short" }); + } + + return options.find((option) => option.value === preset)?.label ?? fallback; +} + +function parsePresets(value: BountyTimingInput): ParsedPresets { + let startPreset: StartPreset; + let customStartsAt: Date | null; + + if (value.startMode === "relative") { + startPreset = "onPartnerJoin"; + customStartsAt = null; + } else { + const now = new Date(); + + if (isSameCalendarDay(value.startsAt, now)) { + startPreset = "today"; + customStartsAt = null; + } else { + const matchedStartPreset = BOUNTY_DURATION_PRESETS.find((preset) => + datesAreClose(value.startsAt, START_DURATION_DATES[preset](now)), + ); + + if (matchedStartPreset) { + startPreset = matchedStartPreset; + customStartsAt = null; + } else { + startPreset = "custom"; + customStartsAt = value.startsAt; + } + } + } + + let endPreset: EndPreset; + let customEndsAt: Date | null; + + if (value.endsAfterDays != null) { + const durationPreset = findDurationPresetByDays(value.endsAfterDays); + + if (durationPreset) { + return { + startPreset, + endPreset: durationPreset, + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + } + + if (!value.endsAt) { + endPreset = "never"; + customEndsAt = null; + } else if (value.startMode === "absolute") { + const matchedEndPreset = ( + Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][] + ).find(([, days]) => + datesAreClose(value.endsAt!, addDays(value.startsAt, days)), + )?.[0]; + + if (matchedEndPreset) { + endPreset = matchedEndPreset; + customEndsAt = null; + } else { + endPreset = "custom"; + customEndsAt = value.endsAt; + } + } else { + endPreset = "custom"; + customEndsAt = value.endsAt; + } + + return { + startPreset, + endPreset, + customStartsAt, + customEndsAt, + customEndsAfterDays: null, + }; +} + +function parsePresetsForEdit(value: BountyTimingInput): ParsedPresets { + if (value.startMode === "relative") { + const startPreset: StartPreset = "onPartnerJoin"; + const customStartsAt = null; + + if (value.endsAfterDays != null) { + const durationPreset = findDurationPresetByDays(value.endsAfterDays); + + if (durationPreset) { + return { + startPreset, + endPreset: durationPreset, + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + + return { + startPreset, + endPreset: "never", + customStartsAt, + customEndsAt: null, + customEndsAfterDays: value.endsAfterDays, + }; + } + + if (value.endsAt) { + return { + startPreset, + endPreset: "custom", + customStartsAt, + customEndsAt: value.endsAt, + customEndsAfterDays: null, + }; + } + + return { + startPreset, + endPreset: "never", + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + + const startPreset: StartPreset = "custom"; + const customStartsAt = value.startsAt; + + if (!value.endsAt) { + return { + startPreset, + endPreset: "never", + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + + return { + startPreset, + endPreset: "custom", + customStartsAt, + customEndsAt: value.endsAt, + customEndsAfterDays: null, + }; +} + +function parsePresetsFromValue( + value: BountyTimingInput, + isEditing: boolean, +): ParsedPresets { + return isEditing ? parsePresetsForEdit(value) : parsePresets(value); +} + +function CustomDatePickerIcon({ + value, + onChange, +}: { + value: Date | null | undefined; + onChange: (date: Date | null) => void; +}) { + return ( + { + if (!date) { + onChange(null); + return; + } + + const merged = new Date(date); + + if (value) { + merged.setHours( + value.getHours(), + value.getMinutes(), + value.getSeconds(), + value.getMilliseconds(), + ); + } + + onChange(merged); + }} + align="start" + showYearNavigation + trigger={() => ( + + )} + /> + ); +} + +interface BountyDurationProps { + value: BountyTimingInput; + onChange: (value: BountyTimingInput) => void; + isEditing?: boolean; +} + +export function BountyDuration({ + value, + onChange, + isEditing = false, +}: BountyDurationProps) { + const initialPresets = parsePresetsFromValue(value, isEditing); + + const [startPreset, setStartPreset] = useState( + initialPresets.startPreset, + ); + + const [endPreset, setEndPreset] = useState( + initialPresets.endPreset, + ); + + const [customStartsAt, setCustomStartsAt] = useState( + initialPresets.customStartsAt, + ); + + const [customEndsAt, setCustomEndsAt] = useState( + initialPresets.customEndsAt, + ); + + const [customEndsAfterDays, setCustomEndsAfterDays] = useState( + initialPresets.customEndsAfterDays, + ); + + useEffect(() => { + const presets = parsePresetsFromValue(value, isEditing); + setStartPreset(presets.startPreset); + setEndPreset(presets.endPreset); + setCustomStartsAt(presets.customStartsAt); + setCustomEndsAt(presets.customEndsAt); + setCustomEndsAfterDays(presets.customEndsAfterDays); + }, [ + isEditing, + value.startMode, + value.startsAt, + value.endsAt, + value.endsAfterDays, + ]); + + const applyTiming = ({ + nextStartPreset = startPreset, + nextEndPreset = endPreset, + nextCustomStartsAt = customStartsAt, + nextCustomEndsAt = customEndsAt, + }: { + nextStartPreset?: StartPreset; + nextEndPreset?: EndPreset; + nextCustomStartsAt?: Date | null; + nextCustomEndsAt?: Date | null; + } = {}) => { + onChange( + resolveBountyTiming({ + startPreset: nextStartPreset, + endPreset: nextEndPreset, + customStartsAt: nextCustomStartsAt, + customEndsAt: nextCustomEndsAt, + }), + ); + }; + + const startLabel = getPresetLabel( + startPreset, + START_OPTIONS, + customStartsAt ?? value.startsAt, + "today", + ); + + const endLabel = + customEndsAfterDays != null + ? `${customEndsAfterDays} days` + : getPresetLabel( + endPreset, + END_OPTIONS, + customEndsAt ?? value.endsAt, + "never", + ); + + const endSuffix = + customEndsAfterDays != null || + (endPreset !== "never" && endPreset !== "custom") + ? value.startMode === "relative" + ? "after joining" + : "from start date" + : null; + + return ( +
+
+ + + Starts{" "} + + + ({ + value: option.value, + text: option.label, + }))} + selectedValue={startPreset} + onSelect={(preset) => { + setStartPreset(preset); + + if (preset === "custom") { + setCustomStartsAt(customStartsAt ?? value.startsAt); + return; + } + + applyTiming({ nextStartPreset: preset }); + }} + /> + + {startPreset === "custom" && ( + { + const nextCustomStartsAt = date ?? null; + setCustomStartsAt(nextCustomStartsAt); + applyTiming({ + nextStartPreset: "custom", + nextCustomStartsAt, + }); + }} + /> + )} + {" "} + and ends{" "} + + + ({ + value: option.value, + text: option.label, + }))} + selectedValue={ + customEndsAfterDays != null ? undefined : endPreset + } + onSelect={(preset) => { + setEndPreset(preset); + setCustomEndsAfterDays(null); + + if (preset === "custom") { + setCustomEndsAt( + customEndsAt ?? + value.endsAt ?? + addWeeks(value.startsAt, 2), + ); + return; + } + + applyTiming({ nextEndPreset: preset }); + }} + /> + + {endPreset === "custom" && ( + { + const nextCustomEndsAt = date ?? null; + setCustomEndsAt(nextCustomEndsAt); + applyTiming({ + nextEndPreset: "custom", + nextCustomEndsAt, + }); + }} + /> + )} + + {endSuffix && {endSuffix}} + +
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx index 5b425d1f7c5..189c0b8bec6 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx @@ -1,3 +1,4 @@ +import { getProgramBountyMeta } from "@/lib/bounty/bounty-period"; import { getBountyRewardDescription } from "@/lib/bounty/rewards"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import useGroups from "@/lib/swr/use-groups"; @@ -17,8 +18,8 @@ import { Tooltip, TooltipContent, } from "@dub/ui"; -import { Users6 } from "@dub/ui/icons"; -import { formatDate, nFormatter, pluralize } from "@dub/utils"; +import { Users, Users6 } from "@dub/ui/icons"; +import { nFormatter, pluralize } from "@dub/utils"; import { cn } from "@dub/utils/src"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; @@ -29,6 +30,8 @@ type ConfirmCreateBountyModalProps = { | "name" | "startsAt" | "endsAt" + | "startMode" + | "endsAfterDays" | "rewardAmount" | "rewardDescription" | "submissionRequirements" @@ -79,7 +82,13 @@ function ConfirmCreateBountyModal({ } }; - return bounty ? ( + if (!bounty) { + return null; + } + + const { dateRangeLabel, partnerAudienceLabel } = getProgramBountyMeta(bounty); + + return ( - - {formatDate(bounty.startsAt, { month: "short" })} - {bounty.endsAt && ( - <> - {" → "} - {formatDate(bounty.endsAt, { month: "short" })} - - )} - + {dateRangeLabel} {!isOwner && ( @@ -127,6 +128,11 @@ function ConfirmCreateBountyModal({ )} +
+ + {partnerAudienceLabel} +
+ {isOwner && (
@@ -236,7 +242,7 @@ function ConfirmCreateBountyModal({ />
- ) : null; + ); } export function useConfirmCreateBountyModal( diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts index 013e8ecc8ff..e34639ab5b2 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts @@ -2,6 +2,7 @@ import { isCurrencyAttribute } from "@/lib/api/workflows/utils"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; +import { resolveBountyTiming } from "@/lib/bounty/bounty-period"; import { BOUNTY_DESCRIPTION_MAX_LENGTH, BOUNTY_MAX_SUBMISSIONS, @@ -17,13 +18,18 @@ import { } from "@/lib/zod/schemas/bounties"; import { formatDate } from "@dub/utils"; import { BountySubmissionFrequency } from "@prisma/client"; -import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; +import { addDays } from "date-fns"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { - BountyTypeUI, - CreateBountyInputExtended, -} from "./bounty-form-context"; +import { BountyTypeUI, CreateBountyInputExtended } from "./bounty-form-context"; import { useConfirmCreateBountyModal } from "./confirm-create-bounty-modal"; const ACCORDION_ITEMS = [ @@ -50,6 +56,38 @@ const resolveSocialMetricsCriteria = ( const isEmpty = (value: unknown) => value === undefined || value === null || value === ""; +function getEffectiveEndsAt({ + startsAt, + endsAt, + endsAfterDays, +}: { + startsAt: Date; + endsAt: Date | null; + endsAfterDays: number | null; +}) { + if (endsAt) { + return endsAt; + } + + if (endsAfterDays != null) { + return addDays(startsAt, endsAfterDays); + } + + return null; +} + +function getSubmissionWindowFromBounty(bounty?: BountyProps): number | null { + if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null; + + const days = Math.ceil( + (new Date(bounty.endsAt).getTime() - + new Date(bounty.submissionsOpenAt).getTime()) / + (1000 * 60 * 60 * 24), + ); + + return days >= 2 && days <= 14 ? days : null; +} + export function useAddEditBountyForm({ bounty, setIsOpen, @@ -60,8 +98,14 @@ export function useAddEditBountyForm({ const { id: workspaceId } = useWorkspace(); const { makeRequest, isSubmitting } = useApiMutation(); - const [hasStartDate, setHasStartDate] = useState(!!bounty?.startsAt); - const [hasEndDate, setHasEndDate] = useState(!!bounty?.endsAt); + const defaultTiming = resolveBountyTiming({ + startPreset: "today", + endPreset: "never", + }); + + const [hasEndDate, setHasEndDate] = useState( + !!bounty?.endsAt || !!bounty?.endsAfterDays, + ); const [openAccordions, setOpenAccordions] = useState(ACCORDION_ITEMS); const [allowedSubmissions, setAllowedSubmissions] = useState( bounty?.maxSubmissions ?? 1, @@ -71,16 +115,8 @@ export function useAddEditBountyForm({ bounty?.submissionFrequency ?? null, ); - const [submissionWindow, setSubmissionWindow] = useState( - () => { - if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null; - const days = Math.ceil( - (new Date(bounty.endsAt).getTime() - - new Date(bounty.submissionsOpenAt).getTime()) / - (1000 * 60 * 60 * 24), - ); - return days >= 2 && days <= 14 ? days : null; - }, + const [submissionWindow, setSubmissionWindow] = useState(() => + getSubmissionWindowFromBounty(bounty), ); const initialSubmissionRequirements = (() => { @@ -106,8 +142,10 @@ export function useAddEditBountyForm({ defaultValues: { name: bounty?.name || undefined, description: bounty?.description || undefined, - startsAt: bounty?.startsAt || undefined, - endsAt: bounty?.endsAt || undefined, + startsAt: bounty?.startsAt || defaultTiming.startsAt, + endsAt: bounty?.endsAt ?? defaultTiming.endsAt, + startMode: bounty?.startMode ?? defaultTiming.startMode, + endsAfterDays: bounty?.endsAfterDays ?? defaultTiming.endsAfterDays, submissionsOpenAt: bounty?.submissionsOpenAt || undefined, rewardAmount: bounty?.rewardAmount ? bounty.rewardAmount / 100 @@ -154,6 +192,8 @@ export function useAddEditBountyForm({ const [ startsAt, endsAt, + startMode, + endsAfterDays, rewardAmount, rewardDescription, type, @@ -167,6 +207,8 @@ export function useAddEditBountyForm({ ] = watch([ "startsAt", "endsAt", + "startMode", + "endsAfterDays", "rewardAmount", "rewardDescription", "type", @@ -179,50 +221,59 @@ export function useAddEditBountyForm({ "submissionRequirements", ]); - const handleStartDateToggle = (checked: boolean) => { - setHasStartDate(checked); - if (!checked) { - setValue("startsAt", null, { shouldDirty: true, shouldValidate: true }); - } - }; + const handleTimingChange = useCallback( + ({ + startMode: nextStartMode, + startsAt: nextStartsAt, + endsAt: nextEndsAt, + endsAfterDays: nextEndsAfterDays, + }: ReturnType) => { + setValue("startMode", nextStartMode, { + shouldDirty: true, + shouldValidate: true, + }); - const handleEndDateToggle = (checked: boolean) => { - setHasEndDate(checked); - if (!checked) { - setValue("endsAt", null, { shouldDirty: true, shouldValidate: true }); - setSubmissionWindow(null); - setValue("submissionsOpenAt", null, { shouldDirty: true }); - } - }; + setValue("startsAt", nextStartsAt, { + shouldDirty: true, + shouldValidate: true, + }); - const handleEndDateChange = (date: Date | null) => { - setValue("endsAt", date, { - shouldDirty: true, - shouldValidate: true, - }); - if (date && submissionWindow != null) { - const submissionsOpenAt = new Date(date); - submissionsOpenAt.setDate(submissionsOpenAt.getDate() - submissionWindow); - setValue("submissionsOpenAt", submissionsOpenAt, { + setValue("endsAt", nextEndsAt, { shouldDirty: true, shouldValidate: true, }); - } - }; - const getInitialSubmissionWindow = () => { - if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null; - const days = Math.ceil( - (new Date(bounty.endsAt).getTime() - - new Date(bounty.submissionsOpenAt).getTime()) / - (1000 * 60 * 60 * 24), - ); - return days >= 2 && days <= 14 ? days : null; - }; + setValue("endsAfterDays", nextEndsAfterDays, { + shouldDirty: true, + shouldValidate: true, + }); + + setHasEndDate( + Boolean(nextEndsAt) || + (nextStartMode === "relative" && Boolean(nextEndsAfterDays)), + ); + + if (!nextEndsAt) { + setSubmissionWindow(null); + setValue("submissionsOpenAt", null, { shouldDirty: true }); + } else if (submissionWindow != null) { + const submissionsOpenAt = new Date(nextEndsAt); + submissionsOpenAt.setDate( + submissionsOpenAt.getDate() - submissionWindow, + ); + + setValue("submissionsOpenAt", submissionsOpenAt, { + shouldDirty: true, + shouldValidate: true, + }); + } + }, + [setValue, submissionWindow], + ); const handleSubmissionWindowToggle = (checked: boolean) => { if (checked) { - const val = getInitialSubmissionWindow() ?? 2; + const val = getSubmissionWindowFromBounty(bounty) ?? 2; setSubmissionWindow(val); if (endsAt) { const submissionsOpenAt = new Date(endsAt); @@ -280,11 +331,21 @@ export function useAddEditBountyForm({ } }; + const effectiveEndsAt = useMemo( + () => + getEffectiveEndsAt({ + startsAt: startsAt ? new Date(startsAt) : new Date(), + endsAt: endsAt ? new Date(endsAt) : null, + endsAfterDays: endsAfterDays ?? null, + }), + [startsAt, endsAt, endsAfterDays], + ); + const maxAllowedSubmissions = useMemo(() => { - if (!submissionFrequency || !endsAt) return BOUNTY_MAX_SUBMISSIONS; + if (!submissionFrequency || !effectiveEndsAt) return BOUNTY_MAX_SUBMISSIONS; const start = startsAt ? new Date(startsAt) : new Date(); - const end = new Date(endsAt); + const end = effectiveEndsAt; let count = 0; for (let i = 0; i < BOUNTY_MAX_SUBMISSIONS; i++) { @@ -298,7 +359,7 @@ export function useAddEditBountyForm({ } return count; - }, [submissionFrequency, startsAt, endsAt]); + }, [submissionFrequency, startsAt, effectiveEndsAt]); useEffect(() => { if (allowedSubmissions > maxAllowedSubmissions) { @@ -384,17 +445,21 @@ export function useAddEditBountyForm({ const effectiveStartDate = startsAt ? new Date(startsAt) : now; - if (endsAt) { - const endDate = new Date(endsAt); + const effectiveEndDate = endsAfterDays + ? addDays(effectiveStartDate, endsAfterDays) + : endsAt + ? new Date(endsAt) + : null; - if (endDate <= effectiveStartDate) { + if (effectiveEndDate) { + if (effectiveEndDate <= effectiveStartDate) { return `Please choose an end date that is after the start date (${formatDate(effectiveStartDate)}).`; } const minEndDate = new Date( effectiveStartDate.getTime() + 60 * 60 * 1000, ); - if (endDate < minEndDate) { + if (effectiveEndDate < minEndDate) { return "End date must be at least 1 hour after the start date."; } } @@ -459,7 +524,8 @@ export function useAddEditBountyForm({ if (!parsed.success) { return ( - parsed.error.issues[0]?.message ?? "Invalid social metrics criteria." + parsed.error.issues[0]?.message ?? + "Invalid social metrics criteria." ); } @@ -510,6 +576,7 @@ export function useAddEditBountyForm({ bounty, startsAt, endsAt, + endsAfterDays, submissionWindow, rewardAmount, rewardDescription, @@ -535,6 +602,11 @@ export function useAddEditBountyForm({ ...data } = form.getValues(); + // Relative bounties start when a partner joins, so startsAt must be null + if (data.startMode === "relative") { + data.startsAt = null; + } + const rawRewardAmount = data.rewardAmount; const numAmount = typeof rawRewardAmount === "number" && !Number.isNaN(rawRewardAmount) @@ -622,8 +694,10 @@ export function useAddEditBountyForm({ : performanceCondition, }) : name || "New bounty", - startsAt: startsAt || new Date(), - endsAt: endsAt || null, + startsAt: startMode === "relative" ? null : startsAt || new Date(), + endsAt: startMode === "relative" ? null : effectiveEndsAt, + startMode: startMode ?? "absolute", + endsAfterDays: endsAfterDays ?? null, rewardAmount: rewardAmount ? rewardAmount * 100 : null, rewardDescription: rewardDescription || null, submissionRequirements: submissionRequirements ?? null, @@ -645,10 +719,7 @@ export function useAddEditBountyForm({ return { form, - hasStartDate, - setHasStartDate, hasEndDate, - handleEndDateToggle, openAccordions, setOpenAccordions, type, @@ -660,8 +731,11 @@ export function useAddEditBountyForm({ watch, errors, isDirty, - handleStartDateToggle, - handleEndDateChange, + startsAt, + endsAt, + startMode, + endsAfterDays, + handleTimingChange, allowedSubmissions, handleAllowedSubmissionsChange, maxAllowedSubmissions, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx index 4b09f0f695a..8c81af42892 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx @@ -1,7 +1,12 @@ +import { getProgramBountyMeta } from "@/lib/bounty/bounty-period"; import useGroups from "@/lib/swr/use-groups"; import { usePartnersCountByGroupIds } from "@/lib/swr/use-partners-count-by-groupids"; import useWorkspace from "@/lib/swr/use-workspace"; import { BountyListProps } from "@/lib/types"; +import { + BountyProgressBarRow, + EmphasisNumber, +} from "@/ui/partners/bounties/bounty-progress-bar-row"; import { BountyRewardDescription } from "@/ui/partners/bounties/bounty-reward-description"; import { BountyThumbnailImage } from "@/ui/partners/bounties/bounty-thumbnail-image"; import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; @@ -12,7 +17,7 @@ import Link from "next/link"; import { useMemo } from "react"; export function BountyCard({ bounty }: { bounty: BountyListProps }) { - const { slug: workspaceSlug, isOwner } = useWorkspace(); + const { slug: workspaceSlug } = useWorkspace(); const { totalPartners, loading } = usePartnersCountByGroupIds({ groupIds: bounty.groups.map((group) => group.id), @@ -20,6 +25,8 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { const { groups } = useGroups(); + const { dateRangeLabel, partnerAudienceLabel } = getProgramBountyMeta(bounty); + const eligibleGroups = useMemo(() => { if (!groups || bounty.groups.length === 0) { return []; @@ -29,134 +36,119 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { .filter((g): g is NonNullable => g !== undefined); }, [groups, bounty.groups]); + const submissionsCount = bounty.submissionsCountData?.total ?? 0; + const progress = + totalPartners > 0 ? (submissionsCount / totalPartners) * 100 : 0; + return ( -
+
-
-
- -
+
+
+
+ +
-
- {bounty.submissionsCountData && - bounty.submissionsCountData.submitted > 0 && ( - +
+ {bounty.submissionsCountData && + bounty.submissionsCountData.submitted > 0 && ( + + )} + {bounty.endsAt && new Date(bounty.endsAt) < new Date() && ( + )} - {bounty.endsAt && new Date(bounty.endsAt) < new Date() && ( - - )} +
-
-
-

- {bounty.name} -

- -
- - - {formatDate(bounty.startsAt, { month: "short" })} - {bounty.endsAt && ( - <> - {" → "} - {formatDate(bounty.endsAt, { month: "short" })} - - )} - -
+
+

+ {bounty.name} +

+ +
+ + {dateRangeLabel} +
+ + e.preventDefault()} + /> - e.preventDefault()} - /> - -
- -
- {loading ? ( - - ) : totalPartners === 0 ? ( - <> - 0{" "} - {pluralize("partner", 0)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} - - ) : bounty.submissionsCountData?.total === totalPartners ? ( - <> - All{" "} - - {nFormatter(totalPartners, { full: true })} - {" "} - {pluralize("partner", totalPartners)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} - +
+ + {partnerAudienceLabel} +
+ +
+ + {bounty.groups.length === 0 ? ( + All groups + ) : eligibleGroups.length > 0 ? ( + 1 + ? { + content: ( + + {eligibleGroups.map((group) => ( +
+ + + {group.name} + +
+ ))} +
+ ), + } + : undefined + } + > +
+ + + {eligibleGroups[0].name}{" "} + {eligibleGroups.length > 1 + ? `+${eligibleGroups.length - 1}` + : ""} + +
+
) : ( - <> - - {nFormatter(bounty.submissionsCountData?.total ?? 0, { - full: true, - })} - {" "} - of{" "} - - {nFormatter(totalPartners, { full: true })} - {" "} - {pluralize("partner", totalPartners)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} - +
)}
+
-
- - {bounty.groups.length === 0 ? ( - All groups - ) : eligibleGroups.length > 0 ? ( - 1 - ? { - content: ( - - {eligibleGroups.map((group) => ( -
- - - {group.name} - -
- ))} -
- ), - } - : undefined - } - > -
- - - {eligibleGroups[0].name}{" "} - {eligibleGroups.length > 1 - ? `+${eligibleGroups.length - 1}` - : ""} - -
-
- ) : ( -
- )} -
+
+ {loading ? ( +
+
+
+
+ ) : ( + + + {nFormatter(submissionsCount, { full: true })} + {" "} + of{" "} + + {nFormatter(totalPartners, { full: true })} + {" "} + {bounty.type === "performance" ? "completed" : "submitted"} + + )}
@@ -181,10 +173,10 @@ function BountyEndedBadge({ endsAt }: { endsAt: Date }) { export function BountyCardSkeleton() { return ( -
-
+
+
-
+
@@ -192,7 +184,11 @@ export function BountyCardSkeleton() {
-
+
+
+
+
+
@@ -200,6 +196,12 @@ export function BountyCardSkeleton() {
+
+
+
+
+
+
); } diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-availability.ts similarity index 67% rename from apps/web/lib/bounty/api/bounty-eligibility.ts rename to apps/web/lib/bounty/api/bounty-availability.ts index 28b518e445c..6b8b0ac6a7f 100644 --- a/apps/web/lib/bounty/api/bounty-eligibility.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -1,11 +1,9 @@ import { Bounty, Prisma, ProgramEnrollment } from "@prisma/client"; import { addDays } from "date-fns"; -export function buildBountyEligibilityWhere({ - groupId, -}: { - groupId: string | undefined; -}): Prisma.BountyWhereInput { +export function buildBountyEligibilityWhere( + groupId: string | undefined, +): Prisma.BountyWhereInput { return { OR: [ { @@ -56,3 +54,23 @@ export function getEffectiveBountyPeriod({ endsAt: endsAfterDays ? addDays(bountyStartDate, endsAfterDays) : endsAt, }; } + +export const canPartnerSeeBounty = ({ + programEnrollment, + bounty, +}: { + programEnrollment: Pick; + bounty: Pick; +}) => { + // +}; + +export const canPartnerSubmitToBounty = ({ + programEnrollment, + bounty, +}: { + programEnrollment: Pick; + bounty: Pick; +}) => { + // +}; diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts index 75c31aa5600..e846f401eff 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -6,44 +6,69 @@ import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { Program, ProgramEnrollment } from "@prisma/client"; import * as z from "zod/v4"; +import { + buildBountyEligibilityWhere, + getEffectiveBountyPeriod, +} from "./bounty-availability"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, - "groupId" | "partnerId" | "totalCommissions" + "groupId" | "partnerId" | "totalCommissions" | "groupJoinedAt" | "createdAt" > & { links: PartnerLink[]; program: Pick; }; -export async function getBountiesForPartner( - params: GetBountiesForPartnerParams, -) { - const { groupId, partnerId, totalCommissions, program, links } = params; - +export async function getBountiesForPartner({ + partnerId, + groupId, + totalCommissions, + createdAt, + groupJoinedAt, + program, + links, +}: GetBountiesForPartnerParams) { const now = new Date(); + const partnerGroupId = groupId || program.defaultGroupId; const bounties = await prisma.bounty.findMany({ where: { programId: program.id, - startsAt: { - lte: now, - }, - // If bounty has no groups, it's available to all partners - // If bounty has groups, only partners in those groups can see it + archivedAt: null, OR: [ + // Relative bounties start when a partner joins (no startsAt filter). { - groups: { - none: {}, + startMode: "relative", + }, + + // Absolute bounties must have started and not expired. + { + startMode: "absolute", + startsAt: { + lt: now, }, + OR: [ + { + endsAt: null, + }, + { + endsAt: { + gt: now, + }, + }, + ], }, + + // Bounties the partner has a submission on stay visible { - groups: { + submissions: { some: { - groupId: groupId || program.defaultGroupId, + partnerId, }, }, }, ], + ...buildBountyEligibilityWhere(partnerGroupId), }, include: { workflow: { @@ -72,13 +97,28 @@ export async function getBountiesForPartner( const partnerLinkStats = aggregatePartnerLinksStats(links); return z.array(PartnerBountySchema).parse( - bounties.map((bounty) => ({ - ...bounty, - performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, - partner: { - ...partnerLinkStats, - totalCommissions, - }, - })), + bounties.map((bounty) => { + const performanceCondition = + bounty.workflow?.triggerConditions?.[0] || null; + + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment: { + createdAt, + groupJoinedAt, + }, + bounty, + }); + + return { + ...bounty, + startsAt, + endsAt, + performanceCondition, + partner: { + ...partnerLinkStats, + totalCommissions, + }, + }; + }), ); } diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts new file mode 100644 index 00000000000..a3116f04a4f --- /dev/null +++ b/apps/web/lib/bounty/bounty-period.ts @@ -0,0 +1,136 @@ +import { formatDate } from "@dub/utils"; +import { BountyStartMode } from "@prisma/client"; +import { addDays, addMonths, addWeeks } from "date-fns"; + +export const BOUNTY_DURATION_PRESETS = [ + "twoWeeks", + "oneMonth", + "sixMonths", +] as const; + +export type DurationPreset = (typeof BOUNTY_DURATION_PRESETS)[number]; + +export const BOUNTY_DURATION_DAYS: Record = { + twoWeeks: 14, + oneMonth: 30, + sixMonths: 180, +}; + +const ENDS_AFTER_DAYS_LABELS: Record = { + [BOUNTY_DURATION_DAYS.twoWeeks]: "2 weeks", + [BOUNTY_DURATION_DAYS.oneMonth]: "1 month", + [BOUNTY_DURATION_DAYS.sixMonths]: "6 months", +}; + +export type StartPreset = "today" | DurationPreset | "onPartnerJoin" | "custom"; +export type EndPreset = "never" | DurationPreset | "custom"; + +export function resolveBountyTiming({ + startPreset, + endPreset, + customStartsAt, + customEndsAt, +}: { + startPreset: StartPreset; + endPreset: EndPreset; + customStartsAt?: Date | null; + customEndsAt?: Date | null; +}) { + const now = new Date(); + + let startMode: BountyStartMode = "absolute"; + let startsAt = now; + + switch (startPreset) { + case "today": + startsAt = now; + break; + case "twoWeeks": + startsAt = addWeeks(now, 2); + break; + case "oneMonth": + startsAt = addMonths(now, 1); + break; + case "sixMonths": + startsAt = addMonths(now, 6); + break; + case "onPartnerJoin": + startMode = "relative"; + startsAt = now; + break; + case "custom": + startsAt = customStartsAt ?? now; + break; + } + + let endsAt: Date | null = null; + let endsAfterDays: number | null = null; + + switch (endPreset) { + case "never": + break; + case "twoWeeks": + case "oneMonth": + case "sixMonths": + if (startMode === "absolute") { + endsAt = addDays(startsAt, BOUNTY_DURATION_DAYS[endPreset]); + } else { + endsAfterDays = BOUNTY_DURATION_DAYS[endPreset]; + } + break; + case "custom": + endsAt = customEndsAt ?? null; + break; + } + + return { + startMode, + startsAt, + endsAt, + endsAfterDays, + }; +} + +export function isBountyStarted(startsAt: Date) { + return startsAt <= new Date(); +} + +export function isBountyExpired(endsAt: Date | null) { + return endsAt !== null && endsAt <= new Date(); +} + +export function getProgramBountyMeta({ + startsAt, + endsAt, + startMode, + endsAfterDays, +}: { + startsAt: Date | null; + endsAt: Date | null; + startMode: BountyStartMode; + endsAfterDays: number | null; +}) { + const isRelative = startMode === "relative" || !startsAt; + + let dateRangeLabel: string; + + if (isRelative) { + if (endsAfterDays != null) { + const durationLabel = + ENDS_AFTER_DAYS_LABELS[endsAfterDays] ?? `${endsAfterDays} days`; + dateRangeLabel = `${durationLabel} after joining`; + } else { + dateRangeLabel = "when a new partner joins"; + } + } else { + const startLabel = formatDate(startsAt, { month: "short" }); + dateRangeLabel = endsAt + ? `${startLabel} → ${formatDate(endsAt, { month: "short" })}` + : startLabel; + } + + return { + dateRangeLabel, + partnerAudienceLabel: isRelative ? "New partners only" : "All partners", + }; +} From 2bd1c3da6208e72b0f4b2d91cc3ec6fee74261e9 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 11:48:18 +0530 Subject: [PATCH 03/27] Keep submitted bounties visible and centralize partner eligibility checks. --- .../[programId]/bounties/[bountyId]/route.ts | 45 ++++---- .../[bountyId]/social-content-stats/route.ts | 60 +++++++++-- .../programs/[programId]/bounties/route.ts | 17 ++- .../web/lib/bounty/api/bounty-availability.ts | 100 +++++++++++++++--- .../api/get-bounty-submission-upload-url.ts | 82 +++++--------- 5 files changed, 205 insertions(+), 99 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts index bebc88a9dd2..59f4d901376 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts @@ -1,16 +1,15 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; -import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability"; +import { + canPartnerSeeBounty, + getEffectiveBountyPeriod, +} from "@/lib/bounty/api/bounty-availability"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; -import { isBountyExpired, isBountyStarted } from "@/lib/bounty/bounty-period"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; -// TODO: -// Allow partners to see a bounty if they have a submission - // GET /api/partner-profile/programs/[programId]/bounties/[bountyId] – get a single bounty for an enrolled program export const GET = withPartnerProfile(async ({ partner, params }) => { const { programId, bountyId } = params; @@ -20,8 +19,21 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { partnerId: partner.id, programId, include: { - program: true, - links: true, + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + links: { + select: { + clicks: true, + leads: true, + conversions: true, + sales: true, + saleAmount: true, + }, + }, }, }); @@ -53,13 +65,13 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }); - const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; - const bountyGroupIds = bounty.groups.map((g) => g.groupId); - const partnerCanSeeBounty = - bountyGroupIds.length === 0 || - (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); + const canSeeBounty = canPartnerSeeBounty({ + program, + bounty, + programEnrollment, + }); - if (!partnerCanSeeBounty) { + if (!canSeeBounty) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", @@ -71,13 +83,6 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { bounty, }); - if (!isBountyStarted(startsAt) || isBountyExpired(endsAt)) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } - const { groups, ...bountyWithoutGroups } = bounty; return NextResponse.json( diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts index 124e659a1f3..013741d7bc8 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts @@ -2,6 +2,7 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { canPartnerSubmitBounty } from "@/lib/bounty/api/bounty-availability"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { ratelimit } from "@/lib/upstash"; @@ -30,16 +31,44 @@ export const GET = withPartnerProfile( }); } - const programEnrollment = await getProgramEnrollmentOrThrow({ - partnerId: partner.id, - programId, - include: {}, - }); + const [programEnrollment, bounty] = await Promise.all([ + getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId, + include: { + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + }, + }), - const bounty = await getBountyOrThrow({ - bountyId, - programId: programEnrollment.programId, - }); + getBountyOrThrow({ + bountyId, + programId, + include: { + groups: { + select: { + groupId: true, + }, + }, + submissions: { + where: { + partnerId: partner.id, + }, + }, + }, + }), + ]); + + if (bounty.submissions.length > 0) { + throw new DubApiError({ + code: "bad_request", + message: "You have already submitted a social content for this bounty.", + }); + } const bountyInfo = resolveBountyDetails(bounty); @@ -50,6 +79,19 @@ export const GET = withPartnerProfile( }); } + const canSubmitBounty = canPartnerSubmitBounty({ + program: programEnrollment.program, + bounty, + programEnrollment, + }); + + if (!canSubmitBounty) { + throw new DubApiError({ + code: "not_found", + message: "Bounty not found.", + }); + } + const content = await getSocialContent({ platform: bountyInfo.socialMetrics.platform, url, diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts index c9622f3ed00..a8106c02099 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts @@ -9,8 +9,21 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { partnerId: partner.id, programId: params.programId, include: { - program: true, - links: true, + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + links: { + select: { + clicks: true, + leads: true, + conversions: true, + sales: true, + saleAmount: true, + }, + }, }, }); diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index 6b8b0ac6a7f..d358b0bc0cf 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -1,5 +1,13 @@ -import { Bounty, Prisma, ProgramEnrollment } from "@prisma/client"; +import { + Bounty, + BountyGroup, + BountySubmission, + Prisma, + Program, + ProgramEnrollment, +} from "@prisma/client"; import { addDays } from "date-fns"; +import { isBountyExpired, isBountyStarted } from "../bounty-period"; export function buildBountyEligibilityWhere( groupId: string | undefined, @@ -55,22 +63,86 @@ export function getEffectiveBountyPeriod({ }; } -export const canPartnerSeeBounty = ({ +type PartnerBountyEligibilityInput = { + program: Pick; + bounty: Pick< + Bounty, + "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" + > & { + groups: Pick[]; + }; + programEnrollment: Pick< + ProgramEnrollment, + "groupJoinedAt" | "createdAt" | "groupId" | "status" + >; +}; + +export function isPartnerEligibleForBounty({ + program, + bounty, programEnrollment, +}: PartnerBountyEligibilityInput): boolean { + // Archived bounties are not visible + if (bounty.archivedAt) { + return false; + } + + // If the bounty has groups, check if the partner is in one of them + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; + + if (bountyGroupIds.length > 0 && !bountyGroupIds.includes(partnerGroupId)) { + return false; + } + + // Check if the bounty is in the active period + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + // If the bounty is not in the active period, it is not visible + if (!isBountyStarted(startsAt) || isBountyExpired(endsAt)) { + return false; + } + + return true; +} + +export const canPartnerSeeBounty = ({ + program, bounty, -}: { - programEnrollment: Pick; - bounty: Pick; -}) => { - // + programEnrollment, +}: PartnerBountyEligibilityInput & { + bounty: PartnerBountyEligibilityInput["bounty"] & { + submissions: Pick[]; + }; +}): boolean => { + // Bounties the partner has a submission on stay visible + if (bounty.submissions.length > 0) { + return true; + } + + return isPartnerEligibleForBounty({ + program, + bounty, + programEnrollment, + }); }; -export const canPartnerSubmitToBounty = ({ - programEnrollment, +export const canPartnerSubmitBounty = ({ + program, bounty, -}: { - programEnrollment: Pick; - bounty: Pick; -}) => { - // + programEnrollment, +}: PartnerBountyEligibilityInput): boolean => { + // Only approved partners can submit bounties + if (programEnrollment.status !== "approved") { + return false; + } + + return isPartnerEligibleForBounty({ + program, + bounty, + programEnrollment, + }); }; diff --git a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts index a6432aee6d7..be4749878b3 100644 --- a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts +++ b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts @@ -1,10 +1,11 @@ import { DubApiError } from "@/lib/api/errors"; -import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { ratelimit } from "@/lib/upstash"; import { submissionRequirementsSchema } from "@/lib/zod/schemas/bounties"; import { nanoid, R2_URL } from "@dub/utils"; import { ProgramEnrollment } from "@prisma/client"; +import { canPartnerSubmitBounty } from "./bounty-availability"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; const MAX_ATTEMPTS = 25; const CACHE_KEY_PREFIX = "bounty:submission:file:upload"; @@ -16,7 +17,12 @@ type GetBountySubmissionUploadUrlParams = { contentLength: number; programEnrollment: Pick< ProgramEnrollment, - "programId" | "partnerId" | "groupId" + | "programId" + | "partnerId" + | "groupId" + | "status" + | "createdAt" + | "groupJoinedAt" >; }; @@ -74,73 +80,41 @@ export async function getBountySubmissionUploadUrl({ }); } - const bounty = await prisma.bounty.findUniqueOrThrow({ - where: { - id: bountyId, - }, - select: { - programId: true, - type: true, - startsAt: true, - endsAt: true, - archivedAt: true, - submissionRequirements: true, + const bounty = await getBountyOrThrow({ + bountyId, + programId, + include: { groups: { select: { groupId: true, }, }, + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, }, }); - if (bounty.programId !== programId) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is not for this program.", - }); - } - - if (bounty.groups.length > 0) { - const isInGroup = bounty.groups.find( - ({ groupId }) => groupId === programEnrollment.groupId, - ); - - if (!isInGroup) { - throw new DubApiError({ - code: "forbidden", - message: "You are not allowed to submit this bounty.", - }); - } - } - - // Validate the bounty dates - const now = new Date(); - - if (bounty.startsAt && bounty.startsAt > now) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is not yet available.", - }); - } - - if (bounty.endsAt && bounty.endsAt < now) { + if (bounty.type === "performance") { throw new DubApiError({ code: "forbidden", - message: "This bounty is no longer available.", + message: "You are not allowed to submit a performance bounty.", }); } - if (bounty.archivedAt) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is archived.", - }); - } + const canSubmitBounty = canPartnerSubmitBounty({ + program: bounty.program, + bounty, + programEnrollment, + }); - if (bounty.type === "performance") { + if (!canSubmitBounty) { throw new DubApiError({ - code: "forbidden", - message: "You are not allowed to submit a performance bounty.", + code: "not_found", + message: "Bounty not found.", }); } From 1884bdccf4d26f23ee166d10e49da827001d821d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 12:37:24 +0530 Subject: [PATCH 04/27] Use effective bounty periods for submissions and social metrics sync. --- .../[bountyId]/sync-social-metrics/route.ts | 76 +++++++----- .../bounty/api/create-bounty-submission.ts | 117 ++++++++---------- 2 files changed, 96 insertions(+), 97 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts index 26bec94e943..4090e76021a 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts @@ -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 { isBountyExpired, isBountyStarted } from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; @@ -52,6 +54,12 @@ export const POST = withWorkspace( urls: true, status: true, partner: true, + programEnrollment: { + select: { + groupJoinedAt: true, + createdAt: true, + }, + }, }, }, } @@ -67,58 +75,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 (isBountyExpired(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 diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index e652689e869..94c65a7dff2 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -3,6 +3,11 @@ import { DubApiError } from "@/lib/api/errors"; import { getWorkspaceUsers } from "@/lib/api/get-workspace-users"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; +import { + canPartnerSubmitBounty, + getEffectiveBountyPeriod, +} from "@/lib/bounty/api/bounty-availability"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { BOUNTY_MAX_SUBMISSION_URLS } from "@/lib/bounty/constants"; import { addFrequency, getCurrentPeriodNumber } from "@/lib/bounty/periods"; import { resolveBountyDetails } from "@/lib/bounty/utils"; @@ -57,7 +62,14 @@ export class BountySubmissionHandler { private submissions: BountySubmission[]; private submissionData: Partial; private programEnrollment: Prisma.ProgramEnrollmentGetPayload<{ - include: {}; + include: { + program: { + select: { + id: true; + defaultGroupId: true; + }; + }; + }; }>; constructor(params: CreateBountySubmissionParams) { @@ -99,13 +111,19 @@ export class BountySubmissionHandler { getProgramEnrollmentOrThrow({ partnerId: this.partner.id, programId: this.programId, - include: {}, + include: { + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + }, }), - prisma.bounty.findUniqueOrThrow({ - where: { - id: this.bountyId, - }, + getBountyOrThrow({ + bountyId: this.bountyId, + programId: this.programId, include: { groups: true, submissions: { @@ -155,9 +173,14 @@ export class BountySubmissionHandler { } // Multi-submission WITH frequency — time-gated + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment: this.programEnrollment, + bounty: this.bounty, + }); + const currentPeriod = getCurrentPeriodNumber({ - startsAt: this.bounty.startsAt, - endsAt: this.bounty.endsAt, + startsAt, + endsAt, submissionFrequency: this.bounty.submissionFrequency, maxSubmissions: this.bounty.maxSubmissions, }); @@ -186,7 +209,7 @@ export class BountySubmissionHandler { // Validate the period has started const periodStart = addFrequency({ - date: this.bounty.startsAt, + date: startsAt, frequency: this.bounty.submissionFrequency, amount: periodNumber - 1, }); @@ -210,20 +233,19 @@ export class BountySubmissionHandler { // Validate the eligibility of the submission private validateEligibility() { - if (!["approved", "pending"].includes(this.programEnrollment.status)) { + if ( + !canPartnerSubmitBounty({ + program: this.programEnrollment.program, + bounty: this.bounty, + programEnrollment: this.programEnrollment, + }) + ) { throw new DubApiError({ code: "forbidden", message: "You are not allowed to submit a bounty for this program.", }); } - if (this.bounty.programId !== this.programId) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is not for this program.", - }); - } - // Check existing submission for this period const existingSubmission = this.submissions.find( (s) => s.periodNumber === this.finalPeriodNumber, @@ -243,44 +265,6 @@ export class BountySubmissionHandler { } } - // Check group membership - if (this.bounty.groups.length > 0) { - const isInGroup = this.bounty.groups.find( - ({ groupId }) => groupId === this.programEnrollment.groupId, - ); - - if (!isInGroup) { - throw new DubApiError({ - code: "forbidden", - message: "You are not allowed to submit this bounty.", - }); - } - } - - // Validate bounty dates and status - const now = new Date(); - - if (this.bounty.startsAt && this.bounty.startsAt > now) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is not yet available.", - }); - } - - if (this.bounty.endsAt && this.bounty.endsAt < now) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is no longer available.", - }); - } - - if (this.bounty.archivedAt) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is archived.", - }); - } - if (this.bounty.type === "performance") { throw new DubApiError({ code: "forbidden", @@ -288,6 +272,8 @@ export class BountySubmissionHandler { }); } + const now = new Date(); + if ( !this.isDraft && this.bounty.submissionsOpenAt && @@ -522,16 +508,19 @@ export class BountySubmissionHandler { }); } - if ( - socialContent.publishedAt && - this.bounty.startsAt && - isBefore(socialContent.publishedAt, this.bounty.startsAt) - ) { - throw new DubApiError({ - code: "unprocessable_entity", - message: - "This content was published before the bounty started. Please submit content posted after the start date.", + if (socialContent.publishedAt) { + const { startsAt } = getEffectiveBountyPeriod({ + programEnrollment: this.programEnrollment, + bounty: this.bounty, }); + + if (isBefore(socialContent.publishedAt, startsAt)) { + throw new DubApiError({ + code: "unprocessable_entity", + message: + "This content was published before the bounty started. Please submit content posted after the start date.", + }); + } } this.submissionData = { From e1f05474e093c6b23f8a1ca05f1f1e6c91f737d6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 13:39:14 +0530 Subject: [PATCH 05/27] Keep archived submitted bounties visible and clarify partner eligibility. --- .../[programId]/bounties/[bountyId]/route.ts | 2 +- .../web/lib/bounty/api/bounty-availability.ts | 13 ++++- .../bounty/api/create-bounty-submission.ts | 3 +- .../bounty/api/get-bounties-for-partner.ts | 50 ++++++++++--------- .../web/lib/bounty/api/get-bounty-or-throw.ts | 4 +- 5 files changed, 43 insertions(+), 29 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts index 59f4d901376..f0503074786 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts @@ -38,7 +38,7 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }); const bounty = await getBountyOrThrow({ - programId, + programId: program.id, bountyId, include: { workflow: { diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index d358b0bc0cf..d81345b8d75 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -67,7 +67,7 @@ type PartnerBountyEligibilityInput = { program: Pick; bounty: Pick< Bounty, - "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" + "id" | "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" > & { groups: Pick[]; }; @@ -92,6 +92,9 @@ export function isPartnerEligibleForBounty({ const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; if (bountyGroupIds.length > 0 && !bountyGroupIds.includes(partnerGroupId)) { + console.log( + `Partner doesn't belong to any of the bounty's ${bounty.id} groups.`, + ); return false; } @@ -102,7 +105,13 @@ export function isPartnerEligibleForBounty({ }); // If the bounty is not in the active period, it is not visible - if (!isBountyStarted(startsAt) || isBountyExpired(endsAt)) { + if (!isBountyStarted(startsAt)) { + console.log(`Bounty ${bounty.id} is not started.`); + return false; + } + + if (isBountyExpired(endsAt)) { + console.log(`Bounty ${bounty.id} is expired.`); return false; } diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index 94c65a7dff2..68915130e8d 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -242,7 +242,8 @@ export class BountySubmissionHandler { ) { throw new DubApiError({ code: "forbidden", - message: "You are not allowed to submit a bounty for this program.", + message: + "You are not allowed to submit this bounty. Please contact the program if you think this is an error.", }); } diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts index e846f401eff..93d129f239b 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -34,41 +34,45 @@ export async function getBountiesForPartner({ const bounties = await prisma.bounty.findMany({ where: { programId: program.id, - archivedAt: null, OR: [ - // Relative bounties start when a partner joins (no startsAt filter). { - startMode: "relative", - }, - - // Absolute bounties must have started and not expired. - { - startMode: "absolute", - startsAt: { - lt: now, - }, + archivedAt: null, + ...buildBountyEligibilityWhere(partnerGroupId), OR: [ + // Relative bounties start when a partner joins (no startsAt filter). { - endsAt: null, + startMode: "relative", }, + + // Absolute bounties must have started and not expired. { - endsAt: { - gt: now, + startMode: "absolute", + startsAt: { + lt: now, }, + OR: [ + { + endsAt: null, + }, + { + endsAt: { + gt: now, + }, + }, + ], }, - ], - }, - // Bounties the partner has a submission on stay visible - { - submissions: { - some: { - partnerId, + // Bounties the partner has a submission on stay visible + { + submissions: { + some: { + partnerId, + }, + }, }, - }, + ], }, ], - ...buildBountyEligibilityWhere(partnerGroupId), }, include: { workflow: { diff --git a/apps/web/lib/bounty/api/get-bounty-or-throw.ts b/apps/web/lib/bounty/api/get-bounty-or-throw.ts index 8d310d99b29..fdc62924fed 100644 --- a/apps/web/lib/bounty/api/get-bounty-or-throw.ts +++ b/apps/web/lib/bounty/api/get-bounty-or-throw.ts @@ -25,14 +25,14 @@ export async function getBountyOrThrow({ if (!bounty) { throw new DubApiError({ code: "not_found", - message: `Bounty ${bountyId} not found.`, + message: `Bounty ${bountyId} not founde.`, }); } if (bounty.programId !== programId) { throw new DubApiError({ code: "not_found", - message: `Bounty ${bountyId} not found.`, + message: `Bounty ${bountyId} not foundee.`, }); } From 87b8dc2b15d740f002ef30a8d64ccd171ff0164e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 13:45:03 +0530 Subject: [PATCH 06/27] Set groupJoinedAt on partner enrollment and group moves. --- .../actions/partners/accept-program-invite.ts | 5 +- .../actions/partners/bulk-approve-partners.ts | 1 + .../lib/api/groups/move-partners-to-group.ts | 1 + .../partners/applications/approve-partner.ts | 7 ++- .../api/partners/create-and-enroll-partner.ts | 3 + apps/web/lib/firstpromoter/import-partners.ts | 1 + apps/web/lib/partnerstack/import-partners.ts | 1 + apps/web/lib/rewardful/import-partners.ts | 1 + apps/web/lib/tapfiliate/import-partners.ts | 1 + apps/web/lib/tolt/import-partners.ts | 1 + .../migrations/backfill-group-joined-at.ts | 55 +++++++++++++++++++ 11 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 apps/web/scripts/migrations/backfill-group-joined-at.ts diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts index 7036dacd291..516d5c2711c 100644 --- a/apps/web/lib/actions/partners/accept-program-invite.ts +++ b/apps/web/lib/actions/partners/accept-program-invite.ts @@ -21,6 +21,8 @@ export const acceptProgramInviteAction = authPartnerActionClient const { partner } = ctx; const { programId } = parsedInput; + const now = new Date(); + const enrollment = await prisma.programEnrollment.update({ where: { partnerId_programId: { @@ -31,7 +33,8 @@ export const acceptProgramInviteAction = authPartnerActionClient }, data: { status: "approved", - createdAt: new Date(), + createdAt: now, + groupJoinedAt: now, }, include: { links: true, diff --git a/apps/web/lib/actions/partners/bulk-approve-partners.ts b/apps/web/lib/actions/partners/bulk-approve-partners.ts index 0466c3562d9..e5b5e12b741 100644 --- a/apps/web/lib/actions/partners/bulk-approve-partners.ts +++ b/apps/web/lib/actions/partners/bulk-approve-partners.ts @@ -62,6 +62,7 @@ export const bulkApprovePartnersAction = authActionClient data: { status: "approved", createdAt: now, + groupJoinedAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, diff --git a/apps/web/lib/api/groups/move-partners-to-group.ts b/apps/web/lib/api/groups/move-partners-to-group.ts index bded179de36..cfbc9090c18 100644 --- a/apps/web/lib/api/groups/move-partners-to-group.ts +++ b/apps/web/lib/api/groups/move-partners-to-group.ts @@ -82,6 +82,7 @@ export async function movePartnersToGroup({ }, data: { groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/api/partners/applications/approve-partner.ts b/apps/web/lib/api/partners/applications/approve-partner.ts index 7065a944538..eec659b2fb8 100644 --- a/apps/web/lib/api/partners/applications/approve-partner.ts +++ b/apps/web/lib/api/partners/applications/approve-partner.ts @@ -79,6 +79,8 @@ export async function approvePartner({ groupId: finalGroupId, }); + const now = new Date(); + await prisma.$transaction(async (tx) => { throwIfPartnersLimitExceeded(program.workspace); @@ -91,7 +93,8 @@ export async function approvePartner({ }, data: { status: "approved", - createdAt: new Date(), + createdAt: now, + groupJoinedAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, @@ -107,7 +110,7 @@ export async function approvePartner({ id: programEnrollment.applicationId, }, data: { - reviewedAt: new Date(), + reviewedAt: now, rejectionReason: null, rejectionNote: null, userId, diff --git a/apps/web/lib/api/partners/create-and-enroll-partner.ts b/apps/web/lib/api/partners/create-and-enroll-partner.ts index a30ad9585fd..40325e38303 100644 --- a/apps/web/lib/api/partners/create-and-enroll-partner.ts +++ b/apps/web/lib/api/partners/create-and-enroll-partner.ts @@ -158,6 +158,9 @@ export const createAndEnrollPartner = async ({ ...(enrolledAt && { createdAt: enrolledAt, }), + ...(status === "approved" && { + groupJoinedAt: enrolledAt ?? new Date(), + }), }, }, }; diff --git a/apps/web/lib/firstpromoter/import-partners.ts b/apps/web/lib/firstpromoter/import-partners.ts index 025071d3368..3693fb052ee 100644 --- a/apps/web/lib/firstpromoter/import-partners.ts +++ b/apps/web/lib/firstpromoter/import-partners.ts @@ -183,6 +183,7 @@ async function createPartnerAndLinks({ partnerId: partner.id, status: "approved", groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts index a6f3eb65dee..6a81d151fd4 100644 --- a/apps/web/lib/partnerstack/import-partners.ts +++ b/apps/web/lib/partnerstack/import-partners.ts @@ -182,6 +182,7 @@ async function createPartner({ partnerId, status: "approved", groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/rewardful/import-partners.ts b/apps/web/lib/rewardful/import-partners.ts index c982377c2a5..5871a0c7747 100644 --- a/apps/web/lib/rewardful/import-partners.ts +++ b/apps/web/lib/rewardful/import-partners.ts @@ -196,6 +196,7 @@ async function createPartnerAndLinks({ programId: program.id, partnerId: partner.id, status: "approved", + groupJoinedAt: new Date(), ...defaultGroupAttributes, }, update: { diff --git a/apps/web/lib/tapfiliate/import-partners.ts b/apps/web/lib/tapfiliate/import-partners.ts index a4e1cce782e..5b595cd7bc0 100644 --- a/apps/web/lib/tapfiliate/import-partners.ts +++ b/apps/web/lib/tapfiliate/import-partners.ts @@ -226,6 +226,7 @@ async function createPartnerAndLinks({ partnerId: partner.id, status: "approved", groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/tolt/import-partners.ts b/apps/web/lib/tolt/import-partners.ts index 8055d55fb33..a97e8c3721a 100644 --- a/apps/web/lib/tolt/import-partners.ts +++ b/apps/web/lib/tolt/import-partners.ts @@ -160,6 +160,7 @@ async function createPartner({ programId: program.id, partnerId: partner.id, status: "approved", + groupJoinedAt: new Date(), ...defaultGroupAttributes, }, update: { diff --git a/apps/web/scripts/migrations/backfill-group-joined-at.ts b/apps/web/scripts/migrations/backfill-group-joined-at.ts new file mode 100644 index 00000000000..1addc5a520a --- /dev/null +++ b/apps/web/scripts/migrations/backfill-group-joined-at.ts @@ -0,0 +1,55 @@ +import "dotenv-flow/config"; + +import { prisma } from "@/lib/prisma"; + +async function main() { + while (true) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: { + not: null, + }, + groupJoinedAt: null, + }, + select: { + id: true, + createdAt: true, + }, + take: 100, + orderBy: { + createdAt: "asc", + }, + }); + + if (programEnrollments.length === 0) { + console.log("No more program enrollments to backfill, skipping..."); + break; + } + + await Promise.all( + programEnrollments.map(({ id, createdAt }) => + prisma.programEnrollment.update({ + where: { + id, + }, + data: { + groupJoinedAt: createdAt, + }, + }), + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + console.log(`Backfilled ${programEnrollments.length} enrollments...`); + } +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); From 971d3e7c6cdfabd1a39c547ee62039429d4ae505 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 13:46:20 +0530 Subject: [PATCH 07/27] Update get-bounty-or-throw.ts --- apps/web/lib/bounty/api/get-bounty-or-throw.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/bounty/api/get-bounty-or-throw.ts b/apps/web/lib/bounty/api/get-bounty-or-throw.ts index fdc62924fed..8d310d99b29 100644 --- a/apps/web/lib/bounty/api/get-bounty-or-throw.ts +++ b/apps/web/lib/bounty/api/get-bounty-or-throw.ts @@ -25,14 +25,14 @@ export async function getBountyOrThrow({ if (!bounty) { throw new DubApiError({ code: "not_found", - message: `Bounty ${bountyId} not founde.`, + message: `Bounty ${bountyId} not found.`, }); } if (bounty.programId !== programId) { throw new DubApiError({ code: "not_found", - message: `Bounty ${bountyId} not foundee.`, + message: `Bounty ${bountyId} not found.`, }); } From 2c9fd2a643c01d7a80949c4aaf7901a79d61d994 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 13:58:42 +0530 Subject: [PATCH 08/27] Prefer activity logs when backfilling groupJoinedAt. --- .../migrations/backfill-group-joined-at.ts | 85 +++++++++++++++++-- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/apps/web/scripts/migrations/backfill-group-joined-at.ts b/apps/web/scripts/migrations/backfill-group-joined-at.ts index 1addc5a520a..bf4e3091b5d 100644 --- a/apps/web/scripts/migrations/backfill-group-joined-at.ts +++ b/apps/web/scripts/migrations/backfill-group-joined-at.ts @@ -2,6 +2,13 @@ import "dotenv-flow/config"; import { prisma } from "@/lib/prisma"; +// Backfill ProgramEnrollment.groupJoinedAt for enrollments that have a groupId +// but no groupJoinedAt yet. + +// Resolution order: +// 1. Latest partner.groupChanged activity log for that partner + program +// 2. Program application reviewedAt (approval/review time) +// 3. Enrollment createdAt async function main() { while (true) { const programEnrollments = await prisma.programEnrollment.findMany({ @@ -13,7 +20,14 @@ async function main() { }, select: { id: true, + partnerId: true, + programId: true, createdAt: true, + application: { + select: { + reviewedAt: true, + }, + }, }, take: 100, orderBy: { @@ -26,22 +40,79 @@ async function main() { break; } + const partnerIds = programEnrollments.map(({ partnerId }) => partnerId); + const programIds = [ + ...new Set(programEnrollments.map(({ programId }) => programId)), + ]; + + const activityLogs = await prisma.activityLog.findMany({ + where: { + action: "partner.groupChanged", + resourceType: "partner", + resourceId: { + in: partnerIds, + }, + programId: { + in: programIds, + }, + }, + select: { + programId: true, + resourceId: true, + createdAt: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const latestGroupChangeByEnrollment = new Map(); + + for (const log of activityLogs) { + const key = `${log.programId}:${log.resourceId}`; + if (!latestGroupChangeByEnrollment.has(key)) { + latestGroupChangeByEnrollment.set(key, log.createdAt); + } + } + + let fromActivity = 0; + let fromReviewedAt = 0; + let fromCreatedAt = 0; + await Promise.all( - programEnrollments.map(({ id, createdAt }) => - prisma.programEnrollment.update({ + programEnrollments.map((enrollment) => { + const key = `${enrollment.programId}:${enrollment.partnerId}`; + const latestGroupChange = latestGroupChangeByEnrollment.get(key); + + let groupJoinedAt: Date; + + if (latestGroupChange) { + groupJoinedAt = latestGroupChange; + fromActivity++; + } else if (enrollment.application?.reviewedAt) { + groupJoinedAt = enrollment.application.reviewedAt; + fromReviewedAt++; + } else { + groupJoinedAt = enrollment.createdAt; + fromCreatedAt++; + } + + return prisma.programEnrollment.update({ where: { - id, + id: enrollment.id, }, data: { - groupJoinedAt: createdAt, + groupJoinedAt, }, - }), - ), + }); + }), ); await new Promise((resolve) => setTimeout(resolve, 1000)); - console.log(`Backfilled ${programEnrollments.length} enrollments...`); + console.log( + `Backfilled ${programEnrollments.length} enrollments (activity: ${fromActivity}, reviewedAt: ${fromReviewedAt}, createdAt: ${fromCreatedAt})...`, + ); } } From 30d83d6c350d5af6709f8e1169d55dd3ef7fb4ff Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 14:23:59 +0530 Subject: [PATCH 09/27] Align relative bounty timing across PATCH, queries, cron, and webhooks. --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 17 ++++++- .../queue-sync-social-metrics/route.ts | 44 +++++++---------- .../bounties/sync-social-metrics/route.ts | 48 ++++++++++++------- .../add-edit-bounty/bounty-duration.tsx | 14 +++++- .../web/lib/bounty/api/bounty-availability.ts | 27 +++++++++++ .../bounty/api/get-bounties-for-partner.ts | 25 +--------- .../webhook/sample-events/bounty-created.json | 2 + .../webhook/sample-events/bounty-updated.json | 2 + apps/web/tests/webhooks/index.test.ts | 14 +++++- 9 files changed, 122 insertions(+), 71 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index d06dc099acd..822b657e4e2 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -85,6 +85,18 @@ export const PATCH = withWorkspace( 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 === "relative" && bounty.endsAt != null) { + endsAtUpdate = { endsAt: null }; + } + validateBounty({ type: bounty.type, // Relative bounties never store startsAt; coerce so mode switches don't @@ -95,7 +107,8 @@ export const PATCH = withWorkspace( : startsAt !== undefined ? startsAt : bounty.startsAt, - endsAt: endsAt !== undefined ? endsAt : bounty.endsAt, + endsAt: + endsAtUpdate.endsAt !== undefined ? endsAtUpdate.endsAt : bounty.endsAt, startMode: nextStartMode, endsAfterDays: endsAfterDays !== undefined @@ -225,7 +238,7 @@ export const PATCH = withWorkspace( name: bountyName ?? undefined, description, ...startsAtUpdate, - ...(endsAt !== undefined && { endsAt }), + ...endsAtUpdate, ...(startMode !== undefined && { startMode }), ...(endsAfterDays !== undefined ? { endsAfterDays } diff --git a/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts index 5ca507dcf2c..2a79591fb97 100644 --- a/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts @@ -1,7 +1,8 @@ +import { buildBountyActivePeriodWhere } from "@/lib/bounty/api/bounty-availability"; import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@/lib/prisma"; -import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; +import { APP_DOMAIN_WITH_NGROK, chunk } from "@dub/utils"; import { Prisma } from "@prisma/client"; import { logAndRespond } from "../../utils"; @@ -9,32 +10,17 @@ export const dynamic = "force-dynamic"; // GET /api/cron/bounties/queue-sync-social-metrics - queue social metrics sync for bounties export const GET = withCron(async () => { - const now = new Date(); - const bounties = await prisma.bounty.findMany({ where: { type: "submission", - startsAt: { - lte: now, - }, - OR: [ - { - endsAt: null, - }, - { - endsAt: { - gt: now, - }, - }, - ], submissionRequirements: { path: "$.socialMetrics", not: Prisma.JsonNull, }, + ...buildBountyActivePeriodWhere(), }, select: { id: true, - submissionRequirements: true, }, }); @@ -42,16 +28,20 @@ export const GET = withCron(async () => { return logAndRespond("No bounties to sync social metrics for."); } - await enqueueBatchJobs( - bounties.map((bounty) => ({ - queueName: "sync-bounty-social-metrics", - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, - deduplicationId: bounty.id, - body: { - bountyId: bounty.id, - }, - })), - ); + const chunks = chunk(bounties, 100); + + for (const chunk of chunks) { + await enqueueBatchJobs( + chunk.map((bounty) => ({ + queueName: "sync-bounty-social-metrics", + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, + deduplicationId: bounty.id, + body: { + bountyId: bounty.id, + }, + })), + ); + } return logAndRespond( `Queued ${bounties.length} bounties to sync social metrics.`, diff --git a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts index 6a64fd63252..31f8e274fdf 100644 --- a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts @@ -1,4 +1,6 @@ +import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability"; import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; +import { isBountyExpired } from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; @@ -6,7 +8,7 @@ import { prisma } from "@/lib/prisma"; import { sendBatchEmail } from "@dub/email"; import BountyCompleted from "@dub/email/templates/bounty-completed"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; -import { Partner, Prisma } from "@prisma/client"; +import { BountySubmissionStatus, Partner, Prisma } from "@prisma/client"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; @@ -31,7 +33,13 @@ export const POST = withCron(async ({ rawBody }) => { id: bountyId, }, include: { - program: true, + program: { + select: { + name: true, + slug: true, + supportEmail: true, + }, + }, }, }); @@ -39,16 +47,6 @@ export const POST = withCron(async ({ rawBody }) => { return logAndRespond(`Bounty ${bountyId} not found. Skipping...`); } - const now = new Date(); - - if (bounty.startsAt && bounty.startsAt > now) { - return logAndRespond(`Bounty ${bountyId} has not started yet. Skipping...`); - } - - if (bounty.endsAt && bounty.endsAt < now) { - return logAndRespond(`Bounty ${bountyId} has ended. Skipping...`); - } - const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.hasSocialMetrics) { @@ -62,7 +60,10 @@ export const POST = withCron(async ({ rawBody }) => { bountyId, status: { // We only want to process submissions that are not rejected or approved. - notIn: ["rejected", "approved"], + notIn: [ + BountySubmissionStatus.rejected, + BountySubmissionStatus.approved, + ], }, }, select: { @@ -75,6 +76,12 @@ export const POST = withCron(async ({ rawBody }) => { email: true, }, }, + programEnrollment: { + select: { + groupJoinedAt: true, + createdAt: true, + }, + }, }, orderBy: { id: "asc", @@ -119,7 +126,16 @@ export const POST = withCron(async ({ rawBody }) => { } of newMetrics) { const submission = submissionById.get(id); - if (!submission) { + if (!submission || !submission.programEnrollment) { + continue; + } + + const { endsAt } = getEffectiveBountyPeriod({ + programEnrollment: submission.programEnrollment, + bounty, + }); + + if (isBountyExpired(endsAt)) { continue; } @@ -136,7 +152,7 @@ export const POST = withCron(async ({ rawBody }) => { if (shouldTransitionToSubmitted) { updateData.status = "submitted"; - updateData.completedAt = now; + updateData.completedAt = new Date(); if (submission.partner?.email) { notifications.push({ @@ -157,7 +173,7 @@ export const POST = withCron(async ({ rawBody }) => { await prisma.$transaction(updates); - if (notifications.length > 0 && bounty.program) { + if (notifications.length > 0) { await sendBatchEmail( notifications.map(({ email }) => ({ subject: "Bounty completed!", diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx index 372381bc17c..1108e29ce7f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -321,6 +321,14 @@ export function BountyDuration({ initialPresets.customEndsAfterDays, ); + const [endDateLocked] = useState( + () => isEditing && (value.endsAt != null || value.endsAfterDays != null), + ); + + const endOptions = endDateLocked + ? END_OPTIONS.filter((option) => option.value !== "never") + : END_OPTIONS; + useEffect(() => { const presets = parsePresetsFromValue(value, isEditing); setStartPreset(presets.startPreset); @@ -426,7 +434,7 @@ export function BountyDuration({ ({ + items={endOptions.map((option) => ({ value: option.value, text: option.label, }))} @@ -434,6 +442,10 @@ export function BountyDuration({ customEndsAfterDays != null ? undefined : endPreset } onSelect={(preset) => { + if (endDateLocked && preset === "never") { + return; + } + setEndPreset(preset); setCustomEndsAfterDays(null); diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index d81345b8d75..aa617d75c38 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -1,6 +1,7 @@ import { Bounty, BountyGroup, + BountyStartMode, BountySubmission, Prisma, Program, @@ -34,6 +35,32 @@ export function buildBountyEligibilityWhere( }; } +export function buildBountyActivePeriodWhere(): Prisma.BountyWhereInput[] { + const now = new Date(); + + return [ + { + startMode: BountyStartMode.relative, + }, + { + startMode: BountyStartMode.absolute, + startsAt: { + lte: now, + }, + OR: [ + { + endsAt: null, + }, + { + endsAt: { + gte: now, + }, + }, + ], + }, + ]; +} + export const bountyEligibilityIncludes = { groups: { select: { diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts index 93d129f239b..18d9c547644 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -7,6 +7,7 @@ import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { Program, ProgramEnrollment } from "@prisma/client"; import * as z from "zod/v4"; import { + buildBountyActivePeriodWhere, buildBountyEligibilityWhere, getEffectiveBountyPeriod, } from "./bounty-availability"; @@ -39,29 +40,7 @@ export async function getBountiesForPartner({ archivedAt: null, ...buildBountyEligibilityWhere(partnerGroupId), OR: [ - // Relative bounties start when a partner joins (no startsAt filter). - { - startMode: "relative", - }, - - // Absolute bounties must have started and not expired. - { - startMode: "absolute", - startsAt: { - lt: now, - }, - OR: [ - { - endsAt: null, - }, - { - endsAt: { - gt: now, - }, - }, - ], - }, - + ...buildBountyActivePeriodWhere(), // Bounties the partner has a submission on stay visible { submissions: { diff --git a/apps/web/lib/webhook/sample-events/bounty-created.json b/apps/web/lib/webhook/sample-events/bounty-created.json index eb772cfae8a..dd9f943f5ec 100644 --- a/apps/web/lib/webhook/sample-events/bounty-created.json +++ b/apps/web/lib/webhook/sample-events/bounty-created.json @@ -5,6 +5,8 @@ "type": "submission", "startsAt": "2025-08-01T17:34:00.000Z", "endsAt": "2025-09-01T17:34:00.000Z", + "startMode": "absolute", + "endsAfterDays": null, "submissionsOpenAt": null, "submissionFrequency": null, "maxSubmissions": 1, diff --git a/apps/web/lib/webhook/sample-events/bounty-updated.json b/apps/web/lib/webhook/sample-events/bounty-updated.json index 0d5605ac897..109d1a74cfc 100644 --- a/apps/web/lib/webhook/sample-events/bounty-updated.json +++ b/apps/web/lib/webhook/sample-events/bounty-updated.json @@ -5,6 +5,8 @@ "type": "submission", "startsAt": "2025-08-01T17:34:00.000Z", "endsAt": "2025-09-01T17:34:00.000Z", + "startMode": "absolute", + "endsAfterDays": null, "submissionsOpenAt": null, "submissionFrequency": null, "maxSubmissions": 1, diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 39864ea0728..863522a25f9 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -55,8 +55,18 @@ const commissionWebhookEventSchemaExtended = CommissionWebhookSchema.extend({ }); const bountyWebhookEventSchemaExtended = BountySchema.extend({ - startsAt: z.string().transform((str) => new Date(str)), - endsAt: z.string().transform((str) => (str ? new Date(str) : null)), + startsAt: z + .string() + .nullable() + .transform((str) => (str ? new Date(str) : null)), + endsAt: z + .string() + .nullable() + .transform((str) => (str ? new Date(str) : null)), + submissionsOpenAt: z + .string() + .nullable() + .transform((str) => (str ? new Date(str) : null)), }); const payoutWebhookEventSchemaExtended = payoutWebhookEventSchema.extend({ From b770f433e404d6714a2b5182def79db88a01e9bd Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 15:03:49 +0530 Subject: [PATCH 10/27] Fix partner bounty visibility queries and eligibility where helpers. --- apps/web/app/(ee)/api/bounties/route.ts | 4 +- .../web/lib/bounty/api/bounty-availability.ts | 74 ++++++++++--------- .../bounty/api/get-bounties-for-partner.ts | 51 ++++++++----- 3 files changed, 73 insertions(+), 56 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 88bfe2e456a..7d2e98c629a 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -62,9 +62,7 @@ export const GET = withWorkspace( prisma.bounty.findMany({ where: { programId, - ...(programEnrollment && { - ...buildBountyEligibilityWhere(partnerGroupId), - }), + ...(programEnrollment && buildBountyEligibilityWhere(partnerGroupId)), }, include: { ...bountyEligibilityIncludes, diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index aa617d75c38..50c33ed358c 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -10,6 +10,20 @@ import { import { addDays } from "date-fns"; import { isBountyExpired, isBountyStarted } from "../bounty-period"; +type PartnerBountyEligibilityInput = { + program: Pick; + bounty: Pick< + Bounty, + "id" | "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" + > & { + groups: Pick[]; + }; + programEnrollment: Pick< + ProgramEnrollment, + "groupJoinedAt" | "createdAt" | "groupId" | "status" + >; +}; + export function buildBountyEligibilityWhere( groupId: string | undefined, ): Prisma.BountyWhereInput { @@ -35,30 +49,32 @@ export function buildBountyEligibilityWhere( }; } -export function buildBountyActivePeriodWhere(): Prisma.BountyWhereInput[] { +export function buildBountyActivePeriodWhere(): Prisma.BountyWhereInput { const now = new Date(); - return [ - { - startMode: BountyStartMode.relative, - }, - { - startMode: BountyStartMode.absolute, - startsAt: { - lte: now, + return { + OR: [ + { + startMode: BountyStartMode.relative, }, - OR: [ - { - endsAt: null, + { + startMode: BountyStartMode.absolute, + startsAt: { + lte: now, }, - { - endsAt: { - gte: now, + OR: [ + { + endsAt: null, }, - }, - ], - }, - ]; + { + endsAt: { + gte: now, + }, + }, + ], + }, + ], + }; } export const bountyEligibilityIncludes = { @@ -90,21 +106,7 @@ export function getEffectiveBountyPeriod({ }; } -type PartnerBountyEligibilityInput = { - program: Pick; - bounty: Pick< - Bounty, - "id" | "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" - > & { - groups: Pick[]; - }; - programEnrollment: Pick< - ProgramEnrollment, - "groupJoinedAt" | "createdAt" | "groupId" | "status" - >; -}; - -export function isPartnerEligibleForBounty({ +function isPartnerEligibleForBounty({ program, bounty, programEnrollment, @@ -154,6 +156,10 @@ export const canPartnerSeeBounty = ({ submissions: Pick[]; }; }): boolean => { + if (bounty.archivedAt) { + return false; + } + // Bounties the partner has a submission on stay visible if (bounty.submissions.length > 0) { return true; diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts index 18d9c547644..ecc65517f63 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -7,53 +7,58 @@ import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { Program, ProgramEnrollment } from "@prisma/client"; import * as z from "zod/v4"; import { + bountyEligibilityIncludes, buildBountyActivePeriodWhere, buildBountyEligibilityWhere, + canPartnerSeeBounty, getEffectiveBountyPeriod, } from "./bounty-availability"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, - "groupId" | "partnerId" | "totalCommissions" | "groupJoinedAt" | "createdAt" + | "groupId" + | "partnerId" + | "totalCommissions" + | "groupJoinedAt" + | "createdAt" + | "status" > & { links: PartnerLink[]; program: Pick; }; export async function getBountiesForPartner({ - partnerId, - groupId, - totalCommissions, - createdAt, - groupJoinedAt, program, links, + ...programEnrollment }: GetBountiesForPartnerParams) { - const now = new Date(); + const { groupId, partnerId, totalCommissions, createdAt, groupJoinedAt } = + programEnrollment; + const partnerGroupId = groupId || program.defaultGroupId; const bounties = await prisma.bounty.findMany({ where: { programId: program.id, + archivedAt: null, OR: [ { - archivedAt: null, - ...buildBountyEligibilityWhere(partnerGroupId), - OR: [ - ...buildBountyActivePeriodWhere(), - // Bounties the partner has a submission on stay visible - { - submissions: { - some: { - partnerId, - }, - }, + submissions: { + some: { + partnerId, }, + }, + }, + { + AND: [ + buildBountyEligibilityWhere(partnerGroupId), + buildBountyActivePeriodWhere(), ], }, ], }, include: { + ...bountyEligibilityIncludes, workflow: { select: { triggerConditions: true, @@ -79,8 +84,16 @@ export async function getBountiesForPartner({ const partnerLinkStats = aggregatePartnerLinksStats(links); + const visibleBounties = bounties.filter((bounty) => + canPartnerSeeBounty({ + program, + bounty, + programEnrollment, + }), + ); + return z.array(PartnerBountySchema).parse( - bounties.map((bounty) => { + visibleBounties.map((bounty) => { const performanceCondition = bounty.workflow?.triggerConditions?.[0] || null; From 2c35dd6d9d2bb58025aeca4cb9c0d54652ff03de Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 15:40:25 +0530 Subject: [PATCH 11/27] Restrict relative bounties to new partners and fix duration form sync. --- .../add-edit-bounty/add-edit-bounty-sheet.tsx | 19 +++++++----- .../add-edit-bounty/bounty-duration.tsx | 23 ++++++++++----- .../web/lib/bounty/api/bounty-availability.ts | 21 +++++++++++++- apps/web/lib/bounty/api/validate-bounty.ts | 2 +- apps/web/tests/bounties/index.test.ts | 29 +++++++++++++++++++ 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx index 5d2600e9b19..cd807e7b15a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx @@ -40,7 +40,7 @@ import { } from "@dub/ui"; import { cn } from "@dub/utils"; import { BountySubmissionFrequency } from "@prisma/client"; -import { Dispatch, SetStateAction, useState } from "react"; +import { Dispatch, SetStateAction, useMemo, useState } from "react"; import { Controller, FormProvider } from "react-hook-form"; import { BountyCriteria } from "./bounty-criteria"; import { BountyDuration } from "./bounty-duration"; @@ -110,6 +110,16 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { const showBountySocialMetricsUpsell = bountyTypeUI === "socialMetrics" && !canUseBountySocialMetrics; + const bountyTimingValue = useMemo( + () => ({ + startMode: startMode ?? "absolute", + startsAt: startsAt ? new Date(startsAt) : new Date(), + endsAt: endsAt ? new Date(endsAt) : null, + endsAfterDays: endsAfterDays ?? null, + }), + [startMode, startsAt, endsAt, endsAfterDays], + ); + return (
@@ -248,12 +258,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx index 1108e29ce7f..f67d26c50fd 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -339,8 +339,8 @@ export function BountyDuration({ }, [ isEditing, value.startMode, - value.startsAt, - value.endsAt, + value.startsAt?.getTime(), + value.endsAt?.getTime(), value.endsAfterDays, ]); @@ -408,7 +408,12 @@ export function BountyDuration({ setStartPreset(preset); if (preset === "custom") { - setCustomStartsAt(customStartsAt ?? value.startsAt); + const nextCustomStartsAt = customStartsAt ?? value.startsAt; + setCustomStartsAt(nextCustomStartsAt); + applyTiming({ + nextStartPreset: "custom", + nextCustomStartsAt, + }); return; } @@ -450,11 +455,15 @@ export function BountyDuration({ setCustomEndsAfterDays(null); if (preset === "custom") { - setCustomEndsAt( + const nextCustomEndsAt = customEndsAt ?? - value.endsAt ?? - addWeeks(value.startsAt, 2), - ); + value.endsAt ?? + addWeeks(value.startsAt, 2); + setCustomEndsAt(nextCustomEndsAt); + applyTiming({ + nextEndPreset: "custom", + nextCustomEndsAt, + }); return; } diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index 50c33ed358c..f1516d2d442 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -14,7 +14,13 @@ type PartnerBountyEligibilityInput = { program: Pick; bounty: Pick< Bounty, - "id" | "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" + | "id" + | "startsAt" + | "endsAt" + | "endsAfterDays" + | "startMode" + | "archivedAt" + | "createdAt" > & { groups: Pick[]; }; @@ -127,6 +133,19 @@ function isPartnerEligibleForBounty({ return false; } + // Relative bounties are for new partners only (joined on/after bounty creation) + if (bounty.startMode === "relative") { + const partnerJoinedAt = + programEnrollment.groupJoinedAt || programEnrollment.createdAt; + + if (partnerJoinedAt < bounty.createdAt) { + console.log( + `Partner joined before relative bounty ${bounty.id} was created.`, + ); + return false; + } + } + // Check if the bounty is in the active period const { startsAt, endsAt } = getEffectiveBountyPeriod({ programEnrollment, diff --git a/apps/web/lib/bounty/api/validate-bounty.ts b/apps/web/lib/bounty/api/validate-bounty.ts index a4067bde800..ea9fda86676 100644 --- a/apps/web/lib/bounty/api/validate-bounty.ts +++ b/apps/web/lib/bounty/api/validate-bounty.ts @@ -114,7 +114,7 @@ export function validateBounty({ }); } - if (submissionFrequency && !endsAt) { + if (submissionFrequency && !endsAt && !endsAfterDays) { throw new DubApiError({ code: "bad_request", message: "An end date is required when submissionFrequency is set.", diff --git a/apps/web/tests/bounties/index.test.ts b/apps/web/tests/bounties/index.test.ts index c17a1b339df..5e9ecd8322c 100644 --- a/apps/web/tests/bounties/index.test.ts +++ b/apps/web/tests/bounties/index.test.ts @@ -392,6 +392,35 @@ describe.sequential( }); }); + test("POST /bounties - submissionFrequency with relative endsAfterDays is accepted", async () => { + const { status, data: bounty } = await http.post({ + path: "/bounties", + body: { + ...base, + startMode: "relative", + startsAt: null, + endsAt: null, + endsAfterDays: 30, + maxSubmissions: 4, + submissionFrequency: "week", + }, + }); + + expect(status).toEqual(200); + expect(bounty).toMatchObject({ + startMode: "relative", + startsAt: null, + endsAt: null, + endsAfterDays: 30, + maxSubmissions: 4, + submissionFrequency: "week", + }); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + }); + test("POST /bounties - submissionsOpenAt without endsAt is rejected", async () => { const submissionsOpenAt = addDays(bountyStartsAt, 5).toISOString(); From bcbf746f06cce1021ad26e275d793d31fc9c9cd0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 16:34:30 +0530 Subject: [PATCH 12/27] Allow relative-start bounties with a fixed end date and fix related form/API handling. --- .../[bountyId]/social-content-stats/route.ts | 55 ++++++++----------- .../add-edit-bounty/add-edit-bounty-sheet.tsx | 7 +-- .../use-add-edit-bounty-form.ts | 7 ++- .../web/lib/bounty/api/bounty-availability.ts | 3 +- apps/web/lib/bounty/bounty-period.ts | 2 + apps/web/lib/zod/schemas/bounties.ts | 2 +- 6 files changed, 37 insertions(+), 39 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts index 013741d7bc8..931b4efdd23 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts @@ -31,44 +31,35 @@ export const GET = withPartnerProfile( }); } - const [programEnrollment, bounty] = await Promise.all([ - getProgramEnrollmentOrThrow({ - partnerId: partner.id, - programId, - include: { - program: { - select: { - id: true, - defaultGroupId: true, - }, + const programEnrollment = await getProgramEnrollmentOrThrow({ + partnerId: partner.id, + programId, + include: { + program: { + select: { + id: true, + defaultGroupId: true, }, }, - }), + }, + }); - getBountyOrThrow({ - bountyId, - programId, - include: { - groups: { - select: { - groupId: true, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId: programEnrollment.programId, + include: { + groups: { + select: { + groupId: true, }, - submissions: { - where: { - partnerId: partner.id, - }, + }, + submissions: { + where: { + partnerId: partner.id, }, }, - }), - ]); - - if (bounty.submissions.length > 0) { - throw new DubApiError({ - code: "bad_request", - message: "You have already submitted a social content for this bounty.", - }); - } + }, + }); const bountyInfo = resolveBountyDetails(bounty); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx index cd807e7b15a..91ee6dc52ce 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx @@ -77,7 +77,6 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { form, openAccordions, setOpenAccordions, - hasEndDate, startsAt, endsAt, startMode, @@ -281,7 +280,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
1 ? "Decrease allowed submissions to 1 to use submission window." @@ -291,7 +290,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
1) && + (!endsAt || allowedSubmissions > 1) && "opacity-30", )} > @@ -301,7 +300,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { trackDimensions="w-8 h-4" thumbDimensions="w-3 h-3" thumbTranslate="translate-x-4" - disabled={!hasEndDate || allowedSubmissions > 1} + disabled={!endsAt || allowedSubmissions > 1} />
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts index e34639ab5b2..9d02709f69d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts @@ -695,7 +695,12 @@ export function useAddEditBountyForm({ }) : name || "New bounty", startsAt: startMode === "relative" ? null : startsAt || new Date(), - endsAt: startMode === "relative" ? null : effectiveEndsAt, + endsAt: + startMode === "relative" + ? endsAfterDays != null + ? null + : endsAt ?? null + : effectiveEndsAt, startMode: startMode ?? "absolute", endsAfterDays: endsAfterDays ?? null, rewardAmount: rewardAmount ? rewardAmount * 100 : null, diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index f1516d2d442..af8f22b7fed 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -112,13 +112,14 @@ export function getEffectiveBountyPeriod({ }; } -function isPartnerEligibleForBounty({ +export function isPartnerEligibleForBounty({ program, bounty, programEnrollment, }: PartnerBountyEligibilityInput): boolean { // Archived bounties are not visible if (bounty.archivedAt) { + console.log(`Bounty ${bounty.id} is archived.`); return false; } diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts index a3116f04a4f..3c4b7946765 100644 --- a/apps/web/lib/bounty/bounty-period.ts +++ b/apps/web/lib/bounty/bounty-period.ts @@ -119,6 +119,8 @@ export function getProgramBountyMeta({ const durationLabel = ENDS_AFTER_DAYS_LABELS[endsAfterDays] ?? `${endsAfterDays} days`; dateRangeLabel = `${durationLabel} after joining`; + } else if (endsAt) { + dateRangeLabel = `when a new partner joins → ${formatDate(endsAt, { month: "short" })}`; } else { dateRangeLabel = "when a new partner joins"; } diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index 00b2762e6fd..fc2a8e8b730 100644 --- a/apps/web/lib/zod/schemas/bounties.ts +++ b/apps/web/lib/zod/schemas/bounties.ts @@ -127,7 +127,7 @@ export const createBountySchema = z.object({ performanceCondition: bountyPerformanceConditionSchema.nullish(), performanceScope: z.enum(BountyPerformanceScope).nullish(), sendNotificationEmails: z.boolean().optional(), - startMode: z.enum(BountyStartMode), + startMode: z.enum(BountyStartMode).default("absolute"), endsAfterDays: z.number().int().positive().nullish(), }); From 90b43cad65c6d73f8e42a5984fb98f60166167f3 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 16:51:05 +0530 Subject: [PATCH 13/27] Disable partner notification emails for relative-start bounties. --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 16 +++++---- apps/web/app/(ee)/api/bounties/route.ts | 9 ++--- .../add-edit-bounty/add-edit-bounty-sheet.tsx | 4 +-- .../add-edit-bounty/bounty-duration.tsx | 9 ++--- .../confirm-create-bounty-modal.tsx | 34 ++++++++++++------- .../use-add-edit-bounty-form.ts | 16 +++++---- .../web/lib/bounty/api/bounty-availability.ts | 6 ++-- apps/web/lib/bounty/api/validate-bounty.ts | 7 ++-- apps/web/lib/bounty/bounty-period.ts | 8 ++--- apps/web/lib/zod/schemas/bounties.ts | 2 +- apps/web/tests/bounties/index.test.ts | 6 ++-- 11 files changed, 69 insertions(+), 48 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 822b657e4e2..daba15e74e1 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -20,7 +20,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"; @@ -93,7 +93,10 @@ export const PATCH = withWorkspace( endsAtUpdate = { endsAt }; } else if (endsAfterDays != null) { endsAtUpdate = { endsAt: null }; - } else if (nextStartMode === "relative" && bounty.endsAt != null) { + } else if ( + nextStartMode === BountyStartMode.relative && + bounty.endsAt != null + ) { endsAtUpdate = { endsAt: null }; } @@ -102,7 +105,7 @@ export const PATCH = withWorkspace( // Relative bounties never store startsAt; coerce so mode switches don't // fail validation against a leftover absolute startsAt. startsAt: - nextStartMode === "relative" + nextStartMode === BountyStartMode.relative ? null : startsAt !== undefined ? startsAt @@ -113,7 +116,7 @@ export const PATCH = withWorkspace( endsAfterDays: endsAfterDays !== undefined ? endsAfterDays - : nextStartMode === "absolute" + : nextStartMode === BountyStartMode.absolute ? null : bounty.endsAfterDays, submissionsOpenAt, @@ -220,7 +223,7 @@ export const PATCH = withWorkspace( // For absolute bounties, only update startsAt when explicitly provided. let startsAtUpdate: { startsAt?: Date | null } = {}; - if (nextStartMode === "relative") { + if (nextStartMode === BountyStartMode.relative) { startsAtUpdate = { startsAt: null }; } else if (startsAt !== undefined) { startsAtUpdate = { startsAt: startsAt ?? new Date() }; @@ -242,7 +245,8 @@ export const PATCH = withWorkspace( ...(startMode !== undefined && { startMode }), ...(endsAfterDays !== undefined ? { endsAfterDays } - : nextStartMode === "absolute" && bounty.endsAfterDays != null + : nextStartMode === BountyStartMode.absolute && + bounty.endsAfterDays != null ? { endsAfterDays: null } : {}), submissionsOpenAt: diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 7d2e98c629a..596e18c4c77 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -29,7 +29,7 @@ import { WORKFLOW_ATTRIBUTE_TRIGGER, } from "@/lib/zod/schemas/workflows"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; -import { Workflow } from "@prisma/client"; +import { BountyStartMode, Workflow } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -210,7 +210,8 @@ export const POST = withWorkspace( // startsAt is only stored for absolute bounties (defaulting to now when // omitted); relative bounties start when a partner joins, so it stays null. - startsAt = startMode === "absolute" ? startsAt || new Date() : null; + startsAt = + startMode === BountyStartMode.absolute ? startsAt || new Date() : null; const bounty = await prisma.$transaction(async (tx) => { let workflow: Workflow | null = null; @@ -286,12 +287,12 @@ export const POST = withWorkspace( const shouldScheduleDraftSubmissions = bounty.type === "performance" && bounty.performanceScope === "lifetime" && - bounty.startMode === "absolute"; + bounty.startMode === BountyStartMode.absolute; const shouldSchedulePartnerNotifications = sendNotificationEmails && canSendEmailCampaigns && - bounty.startMode === "absolute"; + bounty.startMode === BountyStartMode.absolute; waitUntil( Promise.allSettled([ diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx index 91ee6dc52ce..549b0fff01a 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx @@ -39,7 +39,7 @@ import { useRouterStuff, } from "@dub/ui"; import { cn } from "@dub/utils"; -import { BountySubmissionFrequency } from "@prisma/client"; +import { BountyStartMode, BountySubmissionFrequency } from "@prisma/client"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; import { Controller, FormProvider } from "react-hook-form"; import { BountyCriteria } from "./bounty-criteria"; @@ -111,7 +111,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { const bountyTimingValue = useMemo( () => ({ - startMode: startMode ?? "absolute", + startMode: startMode ?? BountyStartMode.absolute, startsAt: startsAt ? new Date(startsAt) : new Date(), endsAt: endsAt ? new Date(endsAt) : null, endsAfterDays: endsAfterDays ?? null, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx index f67d26c50fd..78e814c401b 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -14,6 +14,7 @@ import { } from "@/ui/shared/inline-badge-popover"; import { CalendarIcon, DatePicker } from "@dub/ui"; import { formatDate } from "@dub/utils"; +import { BountyStartMode } from "@prisma/client"; import { addDays, addMonths, addWeeks } from "date-fns"; import { useEffect, useState } from "react"; @@ -98,7 +99,7 @@ function parsePresets(value: BountyTimingInput): ParsedPresets { let startPreset: StartPreset; let customStartsAt: Date | null; - if (value.startMode === "relative") { + if (value.startMode === BountyStartMode.relative) { startPreset = "onPartnerJoin"; customStartsAt = null; } else { @@ -142,7 +143,7 @@ function parsePresets(value: BountyTimingInput): ParsedPresets { if (!value.endsAt) { endPreset = "never"; customEndsAt = null; - } else if (value.startMode === "absolute") { + } else if (value.startMode === BountyStartMode.absolute) { const matchedEndPreset = ( Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][] ).find(([, days]) => @@ -171,7 +172,7 @@ function parsePresets(value: BountyTimingInput): ParsedPresets { } function parsePresetsForEdit(value: BountyTimingInput): ParsedPresets { - if (value.startMode === "relative") { + if (value.startMode === BountyStartMode.relative) { const startPreset: StartPreset = "onPartnerJoin"; const customStartsAt = null; @@ -385,7 +386,7 @@ export function BountyDuration({ const endSuffix = customEndsAfterDays != null || (endPreset !== "never" && endPreset !== "custom") - ? value.startMode === "relative" + ? value.startMode === BountyStartMode.relative ? "after joining" : "from start date" : null; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx index 189c0b8bec6..a140a4e8c28 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx @@ -21,6 +21,7 @@ import { import { Users, Users6 } from "@dub/ui/icons"; import { nFormatter, pluralize } from "@dub/utils"; import { cn } from "@dub/utils/src"; +import { BountyStartMode } from "@prisma/client"; import { Dispatch, SetStateAction, useMemo, useState } from "react"; type ConfirmCreateBountyModalProps = { @@ -49,43 +50,50 @@ function ConfirmCreateBountyModal({ showConfirmCreateBountyModal: boolean; setShowConfirmCreateBountyModal: Dispatch>; } & ConfirmCreateBountyModalProps) { + const { groups } = useGroups(); const { plan, slug: workspaceSlug, isOwner } = useWorkspace(); const { canSendEmailCampaigns } = getPlanCapabilities(plan); const [isLoading, setIsLoading] = useState(false); + const [sendNotificationEmails, setSendNotificationEmails] = useState( canSendEmailCampaigns, ); + const isRelative = bounty?.startMode === BountyStartMode.relative; + const { totalPartners, loading } = usePartnersCountByGroupIds({ - groupIds: bounty?.groups?.map((group) => group.id) ?? [], + groupIds: isRelative + ? null + : bounty?.groups?.map((group) => group.id) ?? [], }); - const { groups } = useGroups(); - const eligibleGroups = useMemo(() => { if (!groups || !bounty || bounty.groups.length === 0) { return []; } + return bounty.groups .map((bountyGroup) => groups.find((g) => g.id === bountyGroup.id)) .filter((g): g is NonNullable => g !== undefined); }, [groups, bounty?.groups]); + if (!bounty) { + return null; + } + const handleConfirm = async () => { setIsLoading(true); try { - await onConfirm({ sendNotificationEmails }); + await onConfirm({ + sendNotificationEmails: isRelative ? false : sendNotificationEmails, + }); setShowConfirmCreateBountyModal(false); } finally { setIsLoading(false); } }; - if (!bounty) { - return null; - } - const { dateRangeLabel, partnerAudienceLabel } = getProgramBountyMeta(bounty); return ( @@ -177,7 +185,7 @@ function ConfirmCreateBountyModal({ setSendNotificationEmails(Boolean(checked)) } - disabled={!canSendEmailCampaigns} + disabled={!canSendEmailCampaigns || isRelative} className="data-[state=checked]:bg-black" /> Send notification to{" "} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts index 9d02709f69d..5ae2fc3bc6d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts @@ -17,7 +17,7 @@ import { bountySocialContentRequirementsSchema, } from "@/lib/zod/schemas/bounties"; import { formatDate } from "@dub/utils"; -import { BountySubmissionFrequency } from "@prisma/client"; +import { BountyStartMode, BountySubmissionFrequency } from "@prisma/client"; import { addDays } from "date-fns"; import { Dispatch, @@ -250,7 +250,8 @@ export function useAddEditBountyForm({ setHasEndDate( Boolean(nextEndsAt) || - (nextStartMode === "relative" && Boolean(nextEndsAfterDays)), + (nextStartMode === BountyStartMode.relative && + Boolean(nextEndsAfterDays)), ); if (!nextEndsAt) { @@ -603,7 +604,7 @@ export function useAddEditBountyForm({ } = form.getValues(); // Relative bounties start when a partner joins, so startsAt must be null - if (data.startMode === "relative") { + if (data.startMode === BountyStartMode.relative) { data.startsAt = null; } @@ -694,14 +695,17 @@ export function useAddEditBountyForm({ : performanceCondition, }) : name || "New bounty", - startsAt: startMode === "relative" ? null : startsAt || new Date(), + startsAt: + startMode === BountyStartMode.relative + ? null + : startsAt || new Date(), endsAt: - startMode === "relative" + startMode === BountyStartMode.relative ? endsAfterDays != null ? null : endsAt ?? null : effectiveEndsAt, - startMode: startMode ?? "absolute", + startMode: startMode ?? BountyStartMode.absolute, endsAfterDays: endsAfterDays ?? null, rewardAmount: rewardAmount ? rewardAmount * 100 : null, rewardDescription: rewardDescription || null, diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index af8f22b7fed..de10ac3a5fc 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -104,7 +104,9 @@ export function getEffectiveBountyPeriod({ // If startMode is absolute, use the startsAt (Assumed to be set). // If startMode is relative, use the groupJoinedAt or createdAt. const bountyStartDate = - startMode === "absolute" ? startsAt! : groupJoinedAt || createdAt; + startMode === BountyStartMode.absolute + ? startsAt! + : groupJoinedAt || createdAt; return { startsAt: bountyStartDate, @@ -135,7 +137,7 @@ export function isPartnerEligibleForBounty({ } // Relative bounties are for new partners only (joined on/after bounty creation) - if (bounty.startMode === "relative") { + if (bounty.startMode === BountyStartMode.relative) { const partnerJoinedAt = programEnrollment.groupJoinedAt || programEnrollment.createdAt; diff --git a/apps/web/lib/bounty/api/validate-bounty.ts b/apps/web/lib/bounty/api/validate-bounty.ts index ea9fda86676..1136f88e6e7 100644 --- a/apps/web/lib/bounty/api/validate-bounty.ts +++ b/apps/web/lib/bounty/api/validate-bounty.ts @@ -1,5 +1,6 @@ import { DubApiError } from "@/lib/api/errors"; import { CreateBountyInput } from "@/lib/types"; +import { BountyStartMode } from "@prisma/client"; export function validateBounty({ type, @@ -14,11 +15,11 @@ export function validateBounty({ rewardDescription, performanceScope, }: Partial) { - startMode = startMode ?? "absolute"; + startMode = startMode ?? BountyStartMode.absolute; // startsAt is required when startMode is absolute and must be null when // startMode is relative (relative bounties start when a partner joins). - if (startMode === "relative") { + if (startMode === BountyStartMode.relative) { if (startsAt != null) { throw new DubApiError({ message: @@ -39,7 +40,7 @@ export function validateBounty({ }); } - if (startMode === "absolute" && endsAfterDays) { + if (startMode === BountyStartMode.absolute && endsAfterDays) { throw new DubApiError({ message: "endsAfterDays is only supported when the bounty starts when a partner joins.", diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts index 3c4b7946765..ec1eaa3559f 100644 --- a/apps/web/lib/bounty/bounty-period.ts +++ b/apps/web/lib/bounty/bounty-period.ts @@ -38,7 +38,7 @@ export function resolveBountyTiming({ }) { const now = new Date(); - let startMode: BountyStartMode = "absolute"; + let startMode: BountyStartMode = BountyStartMode.absolute; let startsAt = now; switch (startPreset) { @@ -55,7 +55,7 @@ export function resolveBountyTiming({ startsAt = addMonths(now, 6); break; case "onPartnerJoin": - startMode = "relative"; + startMode = BountyStartMode.relative; startsAt = now; break; case "custom": @@ -72,7 +72,7 @@ export function resolveBountyTiming({ case "twoWeeks": case "oneMonth": case "sixMonths": - if (startMode === "absolute") { + if (startMode === BountyStartMode.absolute) { endsAt = addDays(startsAt, BOUNTY_DURATION_DAYS[endPreset]); } else { endsAfterDays = BOUNTY_DURATION_DAYS[endPreset]; @@ -110,7 +110,7 @@ export function getProgramBountyMeta({ startMode: BountyStartMode; endsAfterDays: number | null; }) { - const isRelative = startMode === "relative" || !startsAt; + const isRelative = startMode === BountyStartMode.relative || !startsAt; let dateRangeLabel: string; diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index fc2a8e8b730..64858abb6fc 100644 --- a/apps/web/lib/zod/schemas/bounties.ts +++ b/apps/web/lib/zod/schemas/bounties.ts @@ -127,7 +127,7 @@ export const createBountySchema = z.object({ performanceCondition: bountyPerformanceConditionSchema.nullish(), performanceScope: z.enum(BountyPerformanceScope).nullish(), sendNotificationEmails: z.boolean().optional(), - startMode: z.enum(BountyStartMode).default("absolute"), + startMode: z.enum(BountyStartMode).default(BountyStartMode.absolute), endsAfterDays: z.number().int().positive().nullish(), }); diff --git a/apps/web/tests/bounties/index.test.ts b/apps/web/tests/bounties/index.test.ts index 5e9ecd8322c..a67b51e1a31 100644 --- a/apps/web/tests/bounties/index.test.ts +++ b/apps/web/tests/bounties/index.test.ts @@ -1,4 +1,4 @@ -import { Bounty } from "@prisma/client"; +import { Bounty, BountyStartMode } from "@prisma/client"; import { addDays, addMonths, subDays } from "date-fns"; import { E2E_PARTNER_GROUP } from "tests/utils/resource"; import { describe, expect, onTestFinished, test } from "vitest"; @@ -397,7 +397,7 @@ describe.sequential( path: "/bounties", body: { ...base, - startMode: "relative", + startMode: BountyStartMode.relative, startsAt: null, endsAt: null, endsAfterDays: 30, @@ -408,7 +408,7 @@ describe.sequential( expect(status).toEqual(200); expect(bounty).toMatchObject({ - startMode: "relative", + startMode: BountyStartMode.relative, startsAt: null, endsAt: null, endsAfterDays: 30, From 1ccf5a2aec14ec0f13a003aa0379ccae1329e808 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 16:55:07 +0530 Subject: [PATCH 14/27] Move embed bounty submissions under bountyId and tighten eligibility checks. --- .../[bountyId]/social-content-stats/route.ts | 54 +++++++------------ .../[bountyId]}/submissions/route.ts | 8 +-- .../referrals/bounties/submission-fields.tsx | 28 +++++----- .../referrals/bounties/submission-form.tsx | 30 ++++++----- 4 files changed, 51 insertions(+), 69 deletions(-) rename apps/web/app/(ee)/api/embed/referrals/{ => bounties/[bountyId]}/submissions/route.ts (80%) diff --git a/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts index 0a1e80f6a4f..7496e7ccc44 100644 --- a/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts @@ -1,5 +1,6 @@ import { DubApiError } from "@/lib/api/errors"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; +import { canPartnerSubmitBounty } from "@/lib/bounty/api/bounty-availability"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; @@ -13,7 +14,7 @@ const searchParamsSchema = z.object({ // GET /api/embed/referrals/bounties/[bountyId]/social-content-stats export const GET = withReferralsEmbedToken( - async ({ programEnrollment, searchParams, params }) => { + async ({ program, programEnrollment, searchParams, params }) => { const { bountyId } = params; const { url } = searchParamsSchema.parse(searchParams); @@ -32,52 +33,33 @@ export const GET = withReferralsEmbedToken( bountyId, programId: programEnrollment.programId, include: { - groups: true, + groups: { + select: { + groupId: true, + }, + }, }, }); - if (bounty.groups.length > 0) { - const isInGroup = bounty.groups.some( - ({ groupId }) => groupId === programEnrollment.groupId, - ); - - if (!isInGroup) { - throw new DubApiError({ - code: "forbidden", - message: "You are not allowed to access this bounty.", - }); - } - } - - const now = new Date(); - - if (bounty.startsAt && bounty.startsAt > now) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is not yet available.", - }); - } - - if (bounty.endsAt && bounty.endsAt < now) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is no longer available.", - }); - } + const bountyInfo = resolveBountyDetails(bounty); - if (bounty.archivedAt) { + if (!bountyInfo?.socialMetrics) { throw new DubApiError({ code: "bad_request", - message: "This bounty is archived.", + message: "This bounty does not have social content requirements.", }); } - const bountyInfo = resolveBountyDetails(bounty); + const canSubmitBounty = canPartnerSubmitBounty({ + program, + bounty, + programEnrollment, + }); - if (!bountyInfo?.socialMetrics) { + if (!canSubmitBounty) { throw new DubApiError({ - code: "bad_request", - message: "This bounty does not have social content requirements.", + code: "not_found", + message: "Bounty not found.", }); } diff --git a/apps/web/app/(ee)/api/embed/referrals/submissions/route.ts b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/submissions/route.ts similarity index 80% rename from apps/web/app/(ee)/api/embed/referrals/submissions/route.ts rename to apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/submissions/route.ts index b2edc073633..5504f3704b1 100644 --- a/apps/web/app/(ee)/api/embed/referrals/submissions/route.ts +++ b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/submissions/route.ts @@ -5,11 +5,12 @@ import { prisma } from "@/lib/prisma"; import { createBountySubmissionInputSchema } from "@/lib/zod/schemas/bounties"; import { NextResponse } from "next/server"; -// POST /api/embed/referrals/submissions – submit a bounty via embed token +// POST /api/embed/referrals/bounties/[bountyId]/submissions – submit a bounty via embed token export const POST = withReferralsEmbedToken( - async ({ req, programEnrollment }) => { + async ({ req, programEnrollment, params }) => { + const { bountyId } = params; const parsedInput = createBountySubmissionInputSchema - .omit({ programId: true }) + .omit({ programId: true, bountyId: true }) .parse(await parseRequestBody(req)); const partner = await prisma.partner.findUniqueOrThrow({ @@ -26,6 +27,7 @@ export const POST = withReferralsEmbedToken( const submissionHandler = new BountySubmissionHandler({ ...parsedInput, + bountyId, programId: programEnrollment.programId, partner, }); diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx index 0e60298ccfb..4c115764bf8 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-fields.tsx @@ -325,25 +325,21 @@ export function EmbedSocialUrlField({ /> Posted from your account - {bounty.startsAt && ( -
  • + - - {`Posted after ${formatDate(bounty.startsAt, { month: "short", day: "numeric", year: "numeric" })}`} -
  • - )} + /> + {`Posted after ${formatDate(bounty.startsAt, { month: "short", day: "numeric", year: "numeric" })}`} +
    ); diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx index 2b19e1c6987..d3159fe67ef 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx @@ -171,21 +171,23 @@ export function EmbedBountySubmissionForm({ isDraft ? setIsDraftSaving(true) : setIsSubmitting(true); try { - const res = await fetch("/api/embed/referrals/submissions", { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, + const res = await fetch( + `/api/embed/referrals/bounties/${bounty.id}/submissions`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + files: completedFiles, + urls: submissionUrls, + description: description || undefined, + isDraft, + periodNumber, + }), }, - body: JSON.stringify({ - bountyId: bounty.id, - files: completedFiles, - urls: submissionUrls, - description: description || undefined, - isDraft, - periodNumber, - }), - }); + ); if (!res.ok) { const err = await res.json().catch(() => ({})); From 5abd92f332cb08253180b8e31ff3f61f8a29a6ab Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 17:10:24 +0530 Subject: [PATCH 15/27] Gate draft submission cron on partner eligibility and trim bounty selects. --- .../create-draft-submissions/route.ts | 147 +++++++++++------- .../add-edit-bounty/bounty-criteria.tsx | 7 +- .../[groupSlug]/settings/group-move-rules.tsx | 2 +- 3 files changed, 90 insertions(+), 66 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts index 9f398264e98..775996b0d77 100644 --- a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts @@ -1,13 +1,14 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { canPartnerSubmitBounty } from "@/lib/bounty/api/bounty-availability"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { prisma } from "@/lib/prisma"; import { workflowConditionSchema } from "@/lib/zod/schemas/workflows"; import { APP_DOMAIN_WITH_NGROK, log, toCentsNumber } from "@dub/utils"; -import { Prisma } from "@prisma/client"; +import { Prisma, ProgramEnrollmentStatus } from "@prisma/client"; import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; @@ -41,9 +42,22 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, - program: true, - workflow: true, + workflow: { + select: { + triggerConditions: true, + }, + }, + groups: { + select: { + groupId: true, + }, + }, + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, }, }); @@ -53,12 +67,14 @@ export async function POST(req: Request) { }); } - let diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); + if (bounty.startsAt) { + let diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); - if (diffMinutes >= 10) { - return logAndRespond( - `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, - ); + if (diffMinutes >= 10) { + return logAndRespond( + `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, + ); + } } if (bounty.type !== "performance") { @@ -75,16 +91,15 @@ export async function POST(req: Request) { return logAndRespond(`Bounty ${bountyId} has no workflow.`); } - // Find groupIds - const groupIds = bounty.groups.map(({ groupId }) => groupId); + const bountyGroupIds = bounty.groups.map(({ groupId }) => groupId); // Find program enrollments const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: bounty.programId, - ...(groupIds.length > 0 && { + ...(bountyGroupIds.length > 0 && { groupId: { - in: groupIds, + in: bountyGroupIds, }, }), ...(partnerIds && { @@ -93,12 +108,13 @@ export async function POST(req: Request) { }, }), status: { - in: ["approved", "invited"], + in: [ + ProgramEnrollmentStatus.approved, + ProgramEnrollmentStatus.invited, + ], }, }, - select: { - partnerId: true, - totalCommissions: true, + include: { links: { select: { clicks: true, @@ -136,54 +152,65 @@ export async function POST(req: Request) { .array(workflowConditionSchema) .parse(bounty.workflow.triggerConditions)[0]; - // Partners with their link metrics - const partners = programEnrollments.map((programEnrollment) => { - return { - id: programEnrollment.partnerId, + const bountySubmissionsToCreate: Prisma.BountySubmissionCreateManyInput[] = + []; + + for (const programEnrollment of programEnrollments) { + const performanceCount = { ...aggregatePartnerLinksStats(programEnrollment.links), totalCommissions: toCentsNumber(programEnrollment.totalCommissions), - }; - }); + }[condition.attribute]; - const bountySubmissionsToCreate: Prisma.BountySubmissionCreateManyInput[] = - partners - // only create submissions for partners that have at least 1 performanceCount - .filter((partner) => partner[condition.attribute] > 0) - .map((partner) => { - const performanceCount = partner[condition.attribute]; - - const conditionMet = evaluateWorkflowConditions({ - conditions: [condition], - attributes: { - [condition.attribute]: performanceCount, - }, - }); - - return { - id: createId({ prefix: "bnty_sub_" }), - programId: bounty.programId, - partnerId: partner.id, - bountyId: bounty.id, - performanceCount, - // If the condition is met, automatically submit the submission - ...(conditionMet && { - status: "submitted", - completedAt: new Date(), - }), - }; - }); - - console.table(bountySubmissionsToCreate); + if (!performanceCount || performanceCount <= 0) { + continue; + } + + const canSubmitBounty = canPartnerSubmitBounty({ + program: bounty.program, + bounty, + programEnrollment, + }); + + if (!canSubmitBounty) { + continue; + } + + const conditionMet = evaluateWorkflowConditions({ + conditions: [condition], + attributes: { + [condition.attribute]: performanceCount, + }, + }); + + bountySubmissionsToCreate.push({ + id: createId({ prefix: "bnty_sub_" }), + programId: bounty.programId, + partnerId: programEnrollment.partnerId, + bountyId: bounty.id, + performanceCount: Math.min(performanceCount, condition.value as number), + // If the condition is met, automatically submit the submission + ...(conditionMet && { + status: "submitted", + completedAt: new Date(), + }), + }); + } // Create bounty submissions - const createdBountySubmissions = await prisma.bountySubmission.createMany({ - data: bountySubmissionsToCreate, - skipDuplicates: true, - }); + if (bountySubmissionsToCreate.length > 0) { + console.table(bountySubmissionsToCreate); - console.log( - `Created ${createdBountySubmissions.count} bounty submissions for bounty ${bountyId}.`, - ); + const createdBountySubmissions = await prisma.bountySubmission.createMany( + { + data: bountySubmissionsToCreate, + skipDuplicates: true, + }, + ); + + console.log( + `Created ${createdBountySubmissions.count} bounty submissions for bounty ${bountyId}.`, + ); + } if (programEnrollments.length === MAX_PAGE_SIZE) { const response = await qstash.publishJSON({ @@ -201,7 +228,7 @@ export async function POST(req: Request) { } return logAndRespond( - `Finished creating submissions for ${createdBountySubmissions.count} partners for bounty ${bountyId}.`, + `Completed draft submission creation for bounty ${bountyId}`, ); } catch (error) { await log({ diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx index df8f7babe64..b3f41e70bde 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-criteria.tsx @@ -18,11 +18,8 @@ import { BountyLogic } from "./bounty-logic"; export function BountyCriteria() { const { watch } = useBountyFormContext(); - const [ - bountyTypeUI = "performance", - rewardAmount, - rewardType = "flat", - ] = watch(["bountyTypeUI", "rewardAmount", "rewardType"]); + const [bountyTypeUI = "performance", rewardAmount, rewardType = "flat"] = + watch(["bountyTypeUI", "rewardAmount", "rewardType"]); const showPerformanceContent = bountyTypeUI === "performance"; const showSubmissionContent = bountyTypeUI === "submission"; diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx index 22c95b6b0f0..a5fbffb2ea0 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx @@ -4,8 +4,8 @@ import { getPlanCapabilities } from "@/lib/plan-capabilities"; import useGroup from "@/lib/swr/use-group"; import useWorkspace from "@/lib/swr/use-workspace"; import { WorkflowCondition } from "@/lib/types"; -import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { useAdvancedUpsellModal } from "@/ui/partners/advanced-upsell-modal"; +import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; import { InlineBadgePopover, InlineBadgePopoverAmountInput, From 441a1c0d43f0cf6ea8f93b81ca7c52510e7917e7 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 17:32:57 +0530 Subject: [PATCH 16/27] Handle nullable startsAt for relative bounties and skip partner notify cron. --- .../cron/bounties/notify-partners/route.ts | 30 ++++++++++++------- .../execute-complete-bounty-workflow.ts | 1 + .../bounty/api/get-group-bounty-summaries.ts | 4 +-- .../api/trigger-draft-bounty-submissions.ts | 2 +- apps/web/lib/integrations/slack/transform.ts | 12 +++++++- 5 files changed, 34 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 8b93144b28c..8f1bd4c298a 100644 --- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts @@ -7,7 +7,7 @@ import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { sendBatchEmail } from "@dub/email"; import NewBountyAvailable from "@dub/email/templates/new-bounty-available"; import { APP_DOMAIN_WITH_NGROK, log } from "@dub/utils"; -import { NotificationEmailType } from "@prisma/client"; +import { BountyStartMode, NotificationEmailType } from "@prisma/client"; import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; @@ -69,28 +69,36 @@ export async function POST(req: Request) { }); } - const diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); - - if (diffMinutes >= 10) { + if (bounty.startMode === BountyStartMode.relative) { return logAndRespond( - `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, + `Bounty ${bountyId} is relative-start; partner notifications skipped.`, ); } - // Find groupIds - const groupIds = bounty.groups.map(({ groupId }) => groupId); + if (bounty.startsAt) { + const diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); + + if (diffMinutes >= 10) { + return logAndRespond( + `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, + ); + } + } + + const bountyGroupIds = bounty.groups.map(({ groupId }) => groupId); + console.log( `Bounty ${bountyId} is applicable to ${ - groupIds.length === 0 ? "all" : groupIds.length - } groups (groupIds: ${JSON.stringify(groupIds)})`, + bountyGroupIds.length === 0 ? "all" : bountyGroupIds.length + } groups (groupIds: ${JSON.stringify(bountyGroupIds)})`, ); const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: bounty.programId, - ...(groupIds.length > 0 && { + ...(bountyGroupIds.length > 0 && { groupId: { - in: groupIds, + in: bountyGroupIds, }, }), status: { diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts index f52e49ec860..49b73ed0ef4 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -121,6 +121,7 @@ export const executeCompleteBountyWorkflow = async ({ if ( bounty.performanceScope === "new" && customerFirstSaleAt && + bounty.startsAt && customerFirstSaleAt < bounty.startsAt ) { console.log( diff --git a/apps/web/lib/bounty/api/get-group-bounty-summaries.ts b/apps/web/lib/bounty/api/get-group-bounty-summaries.ts index 8acc655367f..4e29bdb6250 100644 --- a/apps/web/lib/bounty/api/get-group-bounty-summaries.ts +++ b/apps/web/lib/bounty/api/get-group-bounty-summaries.ts @@ -5,7 +5,7 @@ type BountyEligibilityCandidate = { id: string; name: string | null; type: BountyType; - startsAt: Date; + startsAt: Date | null; endsAt: Date | null; archivedAt: Date | null; groups: { groupId: string }[]; @@ -26,7 +26,7 @@ export function filterActiveGroupBounties( return false; } - if (bounty.startsAt > now) { + if (bounty.startsAt && bounty.startsAt > now) { return false; } diff --git a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts index 068653dd8ea..e37d8ee2779 100644 --- a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts +++ b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts @@ -97,7 +97,7 @@ function isEligiblePerformanceBounty(bounty: Bounty) { if (bounty.type !== "performance") return false; if (bounty.performanceScope === "new") return false; - if (bounty.startsAt > now) return false; + if (bounty.startsAt && bounty.startsAt > now) return false; if (bounty.endsAt && bounty.endsAt <= now) return false; return true; diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index c6dc3fa23af..f3288b5255e 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -1,4 +1,5 @@ import { isFirstConversion } from "@/lib/analytics/is-first-conversion"; +import { getProgramBountyMeta } from "@/lib/bounty/bounty-period"; import { getBountyRewardDescription } from "@/lib/bounty/rewards"; import { APP_DOMAIN, COUNTRIES, currencyFormatter, truncate } from "@dub/utils"; import { LinkWebhookEvent } from "dub/models/components"; @@ -451,6 +452,8 @@ const bountyTemplates = ({ type, startsAt, endsAt, + startMode, + endsAfterDays, } = data; const eventMessages = { @@ -464,6 +467,13 @@ const bountyTemplates = ({ submissionRequirements, }); + const { dateRangeLabel } = getProgramBountyMeta({ + startsAt, + endsAt, + startMode, + endsAfterDays, + }); + const hrefToBounty = `${APP_DOMAIN}/program/bounties/${id}`; return { @@ -497,7 +507,7 @@ const bountyTemplates = ({ }, { type: "mrkdwn", - text: `*Duration*\n${new Date(startsAt).toLocaleDateString()}${endsAt ? ` - ${new Date(endsAt).toLocaleDateString()}` : " (No end date)"}`, + text: `*Duration*\n${dateRangeLabel}`, }, ], }, From 93d013decf65476590c7537d2e275afdd80ec3f4 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 18:03:36 +0530 Subject: [PATCH 17/27] Use partner eligibility helpers in complete bounty workflows. --- .../execute-complete-bounty-workflow.ts | 71 +++++++++++-------- .../lib/api/workflows/execute-workflows.ts | 5 +- .../web/lib/bounty/api/bounty-availability.ts | 2 +- apps/web/lib/types.ts | 10 +++ 4 files changed, 54 insertions(+), 34 deletions(-) diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts index 49b73ed0ef4..1860343ce89 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -1,4 +1,8 @@ import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { + getEffectiveBountyPeriod, + isPartnerEligibleForBounty, +} from "@/lib/bounty/api/bounty-availability"; import { prisma } from "@/lib/prisma"; import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; @@ -37,8 +41,9 @@ export const executeCompleteBountyWorkflow = async ({ } const { bountyId } = action.data; - const { identity, metrics } = context; - const { partnerId, groupId, customerId, customerFirstSaleAt } = identity; + const { identity, metrics, programEnrollment } = context; + const { customerId, customerFirstSaleAt } = identity; + const { partnerId, groupId } = programEnrollment; if (!groupId) { console.error("Partner groupId not set in the context."); @@ -51,11 +56,24 @@ export const executeCompleteBountyWorkflow = async ({ id: bountyId, }, include: { - program: true, - groups: true, + program: { + select: { + id: true, + name: true, + slug: true, + supportEmail: true, + defaultGroupId: true, + }, + }, + groups: { + select: { + groupId: true, + }, + }, submissions: { - where: { - partnerId, + select: { + id: true, + status: true, }, }, }, @@ -77,30 +95,19 @@ export const executeCompleteBountyWorkflow = async ({ return; } - const now = new Date(); - - // Check if bounty is active - if ( - (bounty.startsAt && bounty.startsAt > now) || - (bounty.endsAt && bounty.endsAt < now) || - bounty.archivedAt - ) { - console.log(`Bounty ${bounty.id} is no longer active.`); - return; - } + const { submissions, program } = bounty; - const { groups, submissions } = bounty; - - // If the bounty is part of a group, check if the partner is in the group - if (groups.length > 0) { - const groupIds = groups.map(({ groupId }) => groupId); + const isEligible = isPartnerEligibleForBounty({ + program, + bounty, + programEnrollment, + }); - if (!groupIds.includes(groupId)) { - console.log( - `Partner ${partnerId} is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups. Partner's groupId: ${groupId}. Assigned groupIds: ${groupIds.join(", ")}.`, - ); - return; - } + if (!isEligible) { + console.log( + `Partner ${partnerId} is not eligible for bounty ${bounty.id}.`, + ); + return; } if (submissions.length > 0) { @@ -118,11 +125,15 @@ export const executeCompleteBountyWorkflow = async ({ } } + const { startsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + if ( bounty.performanceScope === "new" && customerFirstSaleAt && - bounty.startsAt && - customerFirstSaleAt < bounty.startsAt + customerFirstSaleAt < startsAt ) { console.log( `Bounty ${bounty.id} is for net-new revenue only and partner ${partnerId} referred customer ${customerId} before the bounty started, skipping...`, diff --git a/apps/web/lib/api/workflows/execute-workflows.ts b/apps/web/lib/api/workflows/execute-workflows.ts index aa209ab8d08..975478a02e7 100644 --- a/apps/web/lib/api/workflows/execute-workflows.ts +++ b/apps/web/lib/api/workflows/execute-workflows.ts @@ -127,9 +127,7 @@ export async function executeWorkflows({ programId, }, }, - select: { - partnerId: true, - groupId: true, + include: { links: { select: { clicks: true, @@ -177,6 +175,7 @@ export async function executeWorkflows({ aggregatePartnerLinksStats(programEnrollment.links); const workflowContext: WorkflowContext = { + programEnrollment, trigger, reason, identity: { diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index de10ac3a5fc..35edd048327 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -131,7 +131,7 @@ export function isPartnerEligibleForBounty({ if (bountyGroupIds.length > 0 && !bountyGroupIds.includes(partnerGroupId)) { console.log( - `Partner doesn't belong to any of the bounty's ${bounty.id} groups.`, + `Partner is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups. Partner's groupId: ${partnerGroupId}. Assigned groupIds: ${bountyGroupIds.join(", ")}.`, ); return false; } diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 3e34281ea53..2d0d225e982 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -23,6 +23,7 @@ import { PartnerRole, PayoutStatus, Prisma, + ProgramEnrollment, ProgramEnrollmentStatus, Project, SubmittedLead, @@ -864,6 +865,15 @@ export interface WorkflowContext { current?: PartnerMetrics; aggregated?: PartnerMetrics; }; + programEnrollment: Pick< + ProgramEnrollment, + | "groupId" + | "createdAt" + | "groupJoinedAt" + | "partnerId" + | "programId" + | "status" + >; } export type SubmittedLeadProps = z.infer; From 30d467cc74cddfcec19a738d4abc53a5b55c888c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 18:07:25 +0530 Subject: [PATCH 18/27] Update execute-complete-bounty-workflow.ts --- apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts index 1860343ce89..44b9e693c57 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -75,6 +75,9 @@ export const executeCompleteBountyWorkflow = async ({ id: true, status: true, }, + where: { + partnerId, + }, }, }, }); From 47fee24e637f8fe0856194bcdc070b7ee0fdbf4a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 18:14:55 +0530 Subject: [PATCH 19/27] Update route.ts --- .../api/cron/bounties/create-draft-submissions/route.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts index 775996b0d77..4122f6ae30b 100644 --- a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts @@ -162,6 +162,9 @@ export async function POST(req: Request) { }[condition.attribute]; if (!performanceCount || performanceCount <= 0) { + console.log( + `Partner ${programEnrollment.partnerId} has no performance count for bounty ${bountyId}.`, + ); continue; } @@ -172,6 +175,9 @@ export async function POST(req: Request) { }); if (!canSubmitBounty) { + console.log( + `Partner ${programEnrollment.partnerId} is not eligible to submit bounty ${bountyId}.`, + ); continue; } From 31c2f3b6eee40bc976d04950ab7083de4cf27780 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 18:25:36 +0530 Subject: [PATCH 20/27] Hide send-notification checkbox for relative bounty creation. --- .../confirm-create-bounty-modal.tsx | 92 ++++++++++--------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx index a140a4e8c28..4692f9f151f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx @@ -183,55 +183,57 @@ function ConfirmCreateBountyModal({
    - - ), - } - : undefined - } - > - - + + setSendNotificationEmails(Boolean(checked)) + } + disabled={!canSendEmailCampaigns} + className="data-[state=checked]:bg-black" + /> + + Send notification to{" "} + + {loading ? ( + + ) : ( + nFormatter(totalPartners, { full: true }) + )}{" "} + selected {pluralize("partner", totalPartners)} + + + + + )}
    From 8ce9960140b51b11b56d413f3ff5e7f962c6fa3d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 21 Jul 2026 21:46:45 +0530 Subject: [PATCH 21/27] Make workflow programEnrollment optional and replace invalid font-regular classes. --- .../add-edit-bounty/confirm-create-bounty-modal.tsx | 2 +- .../[slug]/(ee)/program/bounties/bounty-card.tsx | 10 +++++----- .../api/workflows/execute-complete-bounty-workflow.ts | 6 ++++++ apps/web/lib/types.ts | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx index 4692f9f151f..ac9fe469610 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx @@ -136,7 +136,7 @@ function ConfirmCreateBountyModal({
    )} -
    +
    {partnerAudienceLabel}
    diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx index 8c81af42892..8eafdfaf2eb 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx @@ -70,23 +70,23 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { {bounty.name} -
    +
    {dateRangeLabel}
    e.preventDefault()} /> -
    +
    {partnerAudienceLabel}
    -
    +
    {bounty.groups.length === 0 ? ( All groups @@ -103,7 +103,7 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { className="flex items-center gap-2" > - + {group.name}
    diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts index 44b9e693c57..2ad63c771b4 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -43,6 +43,12 @@ export const executeCompleteBountyWorkflow = async ({ const { bountyId } = action.data; const { identity, metrics, programEnrollment } = context; const { customerId, customerFirstSaleAt } = identity; + + if (!programEnrollment) { + console.error("Program enrollment not set in the context."); + return; + } + const { partnerId, groupId } = programEnrollment; if (!groupId) { diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 2d0d225e982..42d1adaf649 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -865,7 +865,7 @@ export interface WorkflowContext { current?: PartnerMetrics; aggregated?: PartnerMetrics; }; - programEnrollment: Pick< + programEnrollment?: Pick< ProgramEnrollment, | "groupId" | "createdAt" From 26dce41f270a176120a660fb149254970148fb3c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 22 Jul 2026 10:12:21 +0530 Subject: [PATCH 22/27] Remove groupJoinedAt field --- .../[bountyId]/sync-social-metrics/route.ts | 1 - .../bounties/sync-social-metrics/route.ts | 1 - .../actions/partners/accept-program-invite.ts | 1 - .../actions/partners/bulk-approve-partners.ts | 1 - .../lib/api/groups/move-partners-to-group.ts | 1 - .../partners/applications/approve-partner.ts | 1 - .../api/partners/create-and-enroll-partner.ts | 3 - .../web/lib/bounty/api/bounty-availability.ts | 21 ++- .../bounty/api/get-bounties-for-partner.ts | 11 +- .../api/get-bounty-submission-upload-url.ts | 7 +- apps/web/lib/firstpromoter/import-partners.ts | 1 - apps/web/lib/partnerstack/import-partners.ts | 1 - apps/web/lib/rewardful/import-partners.ts | 1 - apps/web/lib/tapfiliate/import-partners.ts | 1 - apps/web/lib/tolt/import-partners.ts | 1 - apps/web/lib/types.ts | 7 +- apps/web/prisma/schema/program.prisma | 1 - .../migrations/backfill-group-joined-at.ts | 126 ------------------ 18 files changed, 12 insertions(+), 175 deletions(-) delete mode 100644 apps/web/scripts/migrations/backfill-group-joined-at.ts diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts index 4090e76021a..956b6d41c90 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts @@ -56,7 +56,6 @@ export const POST = withWorkspace( partner: true, programEnrollment: { select: { - groupJoinedAt: true, createdAt: true, }, }, diff --git a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts index 31f8e274fdf..a99496993ee 100644 --- a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts @@ -78,7 +78,6 @@ export const POST = withCron(async ({ rawBody }) => { }, programEnrollment: { select: { - groupJoinedAt: true, createdAt: true, }, }, diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts index 516d5c2711c..447806269d4 100644 --- a/apps/web/lib/actions/partners/accept-program-invite.ts +++ b/apps/web/lib/actions/partners/accept-program-invite.ts @@ -34,7 +34,6 @@ export const acceptProgramInviteAction = authPartnerActionClient data: { status: "approved", createdAt: now, - groupJoinedAt: now, }, include: { links: true, diff --git a/apps/web/lib/actions/partners/bulk-approve-partners.ts b/apps/web/lib/actions/partners/bulk-approve-partners.ts index e5b5e12b741..0466c3562d9 100644 --- a/apps/web/lib/actions/partners/bulk-approve-partners.ts +++ b/apps/web/lib/actions/partners/bulk-approve-partners.ts @@ -62,7 +62,6 @@ export const bulkApprovePartnersAction = authActionClient data: { status: "approved", createdAt: now, - groupJoinedAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, diff --git a/apps/web/lib/api/groups/move-partners-to-group.ts b/apps/web/lib/api/groups/move-partners-to-group.ts index cfbc9090c18..bded179de36 100644 --- a/apps/web/lib/api/groups/move-partners-to-group.ts +++ b/apps/web/lib/api/groups/move-partners-to-group.ts @@ -82,7 +82,6 @@ export async function movePartnersToGroup({ }, data: { groupId: group.id, - groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/api/partners/applications/approve-partner.ts b/apps/web/lib/api/partners/applications/approve-partner.ts index eec659b2fb8..2509e3fc1dc 100644 --- a/apps/web/lib/api/partners/applications/approve-partner.ts +++ b/apps/web/lib/api/partners/applications/approve-partner.ts @@ -94,7 +94,6 @@ export async function approvePartner({ data: { status: "approved", createdAt: now, - groupJoinedAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, diff --git a/apps/web/lib/api/partners/create-and-enroll-partner.ts b/apps/web/lib/api/partners/create-and-enroll-partner.ts index 40325e38303..a30ad9585fd 100644 --- a/apps/web/lib/api/partners/create-and-enroll-partner.ts +++ b/apps/web/lib/api/partners/create-and-enroll-partner.ts @@ -158,9 +158,6 @@ export const createAndEnrollPartner = async ({ ...(enrolledAt && { createdAt: enrolledAt, }), - ...(status === "approved" && { - groupJoinedAt: enrolledAt ?? new Date(), - }), }, }, }; diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index 35edd048327..0fcc8e9c5c2 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -26,7 +26,7 @@ type PartnerBountyEligibilityInput = { }; programEnrollment: Pick< ProgramEnrollment, - "groupJoinedAt" | "createdAt" | "groupId" | "status" + "createdAt" | "groupId" | "status" >; }; @@ -95,18 +95,16 @@ export function getEffectiveBountyPeriod({ programEnrollment, bounty, }: { - programEnrollment: Pick; + programEnrollment: Pick; bounty: Pick; }) { - const { createdAt, groupJoinedAt } = programEnrollment; + const { createdAt } = programEnrollment; const { startsAt, endsAt, endsAfterDays, startMode } = bounty; // If startMode is absolute, use the startsAt (Assumed to be set). - // If startMode is relative, use the groupJoinedAt or createdAt. + // If startMode is relative, use the program enrollment createdAt. const bountyStartDate = - startMode === BountyStartMode.absolute - ? startsAt! - : groupJoinedAt || createdAt; + startMode === BountyStartMode.absolute ? startsAt! : createdAt; return { startsAt: bountyStartDate, @@ -136,14 +134,11 @@ export function isPartnerEligibleForBounty({ return false; } - // Relative bounties are for new partners only (joined on/after bounty creation) + // Relative bounties are for new partners only (enrolled on/after bounty creation) if (bounty.startMode === BountyStartMode.relative) { - const partnerJoinedAt = - programEnrollment.groupJoinedAt || programEnrollment.createdAt; - - if (partnerJoinedAt < bounty.createdAt) { + if (programEnrollment.createdAt < bounty.createdAt) { console.log( - `Partner joined before relative bounty ${bounty.id} was created.`, + `Partner enrolled before relative bounty ${bounty.id} was created.`, ); return false; } diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts index ecc65517f63..467244f8725 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -16,12 +16,7 @@ import { type GetBountiesForPartnerParams = Pick< ProgramEnrollment, - | "groupId" - | "partnerId" - | "totalCommissions" - | "groupJoinedAt" - | "createdAt" - | "status" + "groupId" | "partnerId" | "totalCommissions" | "createdAt" | "status" > & { links: PartnerLink[]; program: Pick; @@ -32,8 +27,7 @@ export async function getBountiesForPartner({ links, ...programEnrollment }: GetBountiesForPartnerParams) { - const { groupId, partnerId, totalCommissions, createdAt, groupJoinedAt } = - programEnrollment; + const { groupId, partnerId, totalCommissions, createdAt } = programEnrollment; const partnerGroupId = groupId || program.defaultGroupId; @@ -100,7 +94,6 @@ export async function getBountiesForPartner({ const { startsAt, endsAt } = getEffectiveBountyPeriod({ programEnrollment: { createdAt, - groupJoinedAt, }, bounty, }); diff --git a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts index be4749878b3..d5231a5caa5 100644 --- a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts +++ b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts @@ -17,12 +17,7 @@ type GetBountySubmissionUploadUrlParams = { contentLength: number; programEnrollment: Pick< ProgramEnrollment, - | "programId" - | "partnerId" - | "groupId" - | "status" - | "createdAt" - | "groupJoinedAt" + "programId" | "partnerId" | "groupId" | "status" | "createdAt" >; }; diff --git a/apps/web/lib/firstpromoter/import-partners.ts b/apps/web/lib/firstpromoter/import-partners.ts index 3693fb052ee..025071d3368 100644 --- a/apps/web/lib/firstpromoter/import-partners.ts +++ b/apps/web/lib/firstpromoter/import-partners.ts @@ -183,7 +183,6 @@ async function createPartnerAndLinks({ partnerId: partner.id, status: "approved", groupId: group.id, - groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts index 6a81d151fd4..a6f3eb65dee 100644 --- a/apps/web/lib/partnerstack/import-partners.ts +++ b/apps/web/lib/partnerstack/import-partners.ts @@ -182,7 +182,6 @@ async function createPartner({ partnerId, status: "approved", groupId: group.id, - groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/rewardful/import-partners.ts b/apps/web/lib/rewardful/import-partners.ts index 5871a0c7747..c982377c2a5 100644 --- a/apps/web/lib/rewardful/import-partners.ts +++ b/apps/web/lib/rewardful/import-partners.ts @@ -196,7 +196,6 @@ async function createPartnerAndLinks({ programId: program.id, partnerId: partner.id, status: "approved", - groupJoinedAt: new Date(), ...defaultGroupAttributes, }, update: { diff --git a/apps/web/lib/tapfiliate/import-partners.ts b/apps/web/lib/tapfiliate/import-partners.ts index 5b595cd7bc0..a4e1cce782e 100644 --- a/apps/web/lib/tapfiliate/import-partners.ts +++ b/apps/web/lib/tapfiliate/import-partners.ts @@ -226,7 +226,6 @@ async function createPartnerAndLinks({ partnerId: partner.id, status: "approved", groupId: group.id, - groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/tolt/import-partners.ts b/apps/web/lib/tolt/import-partners.ts index a97e8c3721a..8055d55fb33 100644 --- a/apps/web/lib/tolt/import-partners.ts +++ b/apps/web/lib/tolt/import-partners.ts @@ -160,7 +160,6 @@ async function createPartner({ programId: program.id, partnerId: partner.id, status: "approved", - groupJoinedAt: new Date(), ...defaultGroupAttributes, }, update: { diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 42d1adaf649..4e6ef170b9c 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -867,12 +867,7 @@ export interface WorkflowContext { }; programEnrollment?: Pick< ProgramEnrollment, - | "groupId" - | "createdAt" - | "groupJoinedAt" - | "partnerId" - | "programId" - | "status" + "groupId" | "createdAt" | "partnerId" | "programId" | "status" >; } diff --git a/apps/web/prisma/schema/program.prisma b/apps/web/prisma/schema/program.prisma index 5fff2bd9169..7d88029486e 100644 --- a/apps/web/prisma/schema/program.prisma +++ b/apps/web/prisma/schema/program.prisma @@ -143,7 +143,6 @@ model ProgramEnrollment { customerDataSharingEnabledAt DateTime? groupMoveDisabledAt DateTime? bannedAt DateTime? - groupJoinedAt DateTime? // date when the partner joined their current group bannedReason PartnerBannedReason? reapplicationTimeframe ReapplicationTimeframe @default(standard) riskMonitoringDisabledAt DateTime? diff --git a/apps/web/scripts/migrations/backfill-group-joined-at.ts b/apps/web/scripts/migrations/backfill-group-joined-at.ts deleted file mode 100644 index bf4e3091b5d..00000000000 --- a/apps/web/scripts/migrations/backfill-group-joined-at.ts +++ /dev/null @@ -1,126 +0,0 @@ -import "dotenv-flow/config"; - -import { prisma } from "@/lib/prisma"; - -// Backfill ProgramEnrollment.groupJoinedAt for enrollments that have a groupId -// but no groupJoinedAt yet. - -// Resolution order: -// 1. Latest partner.groupChanged activity log for that partner + program -// 2. Program application reviewedAt (approval/review time) -// 3. Enrollment createdAt -async function main() { - while (true) { - const programEnrollments = await prisma.programEnrollment.findMany({ - where: { - groupId: { - not: null, - }, - groupJoinedAt: null, - }, - select: { - id: true, - partnerId: true, - programId: true, - createdAt: true, - application: { - select: { - reviewedAt: true, - }, - }, - }, - take: 100, - orderBy: { - createdAt: "asc", - }, - }); - - if (programEnrollments.length === 0) { - console.log("No more program enrollments to backfill, skipping..."); - break; - } - - const partnerIds = programEnrollments.map(({ partnerId }) => partnerId); - const programIds = [ - ...new Set(programEnrollments.map(({ programId }) => programId)), - ]; - - const activityLogs = await prisma.activityLog.findMany({ - where: { - action: "partner.groupChanged", - resourceType: "partner", - resourceId: { - in: partnerIds, - }, - programId: { - in: programIds, - }, - }, - select: { - programId: true, - resourceId: true, - createdAt: true, - }, - orderBy: { - createdAt: "desc", - }, - }); - - const latestGroupChangeByEnrollment = new Map(); - - for (const log of activityLogs) { - const key = `${log.programId}:${log.resourceId}`; - if (!latestGroupChangeByEnrollment.has(key)) { - latestGroupChangeByEnrollment.set(key, log.createdAt); - } - } - - let fromActivity = 0; - let fromReviewedAt = 0; - let fromCreatedAt = 0; - - await Promise.all( - programEnrollments.map((enrollment) => { - const key = `${enrollment.programId}:${enrollment.partnerId}`; - const latestGroupChange = latestGroupChangeByEnrollment.get(key); - - let groupJoinedAt: Date; - - if (latestGroupChange) { - groupJoinedAt = latestGroupChange; - fromActivity++; - } else if (enrollment.application?.reviewedAt) { - groupJoinedAt = enrollment.application.reviewedAt; - fromReviewedAt++; - } else { - groupJoinedAt = enrollment.createdAt; - fromCreatedAt++; - } - - return prisma.programEnrollment.update({ - where: { - id: enrollment.id, - }, - data: { - groupJoinedAt, - }, - }); - }), - ); - - await new Promise((resolve) => setTimeout(resolve, 1000)); - - console.log( - `Backfilled ${programEnrollments.length} enrollments (activity: ${fromActivity}, reviewedAt: ${fromReviewedAt}, createdAt: ${fromCreatedAt})...`, - ); - } -} - -main() - .catch((error) => { - console.error(error); - process.exit(1); - }) - .finally(async () => { - await prisma.$disconnect(); - }); From e3146af3434b76dce579f37a40c4c7adf486e635 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 22 Jul 2026 10:17:56 +0530 Subject: [PATCH 23/27] Swap bounty custom start/end to inline calendar popover --- .../add-edit-bounty/bounty-duration.tsx | 246 ++++++++++-------- 1 file changed, 136 insertions(+), 110 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx index 78e814c401b..86ffdbec76c 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -10,13 +10,14 @@ import { } from "@/lib/bounty/bounty-period"; import { InlineBadgePopover, + InlineBadgePopoverContext, InlineBadgePopoverMenu, } from "@/ui/shared/inline-badge-popover"; -import { CalendarIcon, DatePicker } from "@dub/ui"; +import { Calendar, CalendarIcon } from "@dub/ui"; import { formatDate } from "@dub/utils"; import { BountyStartMode } from "@prisma/client"; import { addDays, addMonths, addWeeks } from "date-fns"; -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; type PresetOption = { value: T; label: string }; type BountyTimingInput = ReturnType; @@ -246,45 +247,87 @@ function parsePresetsFromValue( return isEditing ? parsePresetsForEdit(value) : parsePresets(value); } -function CustomDatePickerIcon({ - value, - onChange, +function mergeDateWithTime(date: Date, previous: Date | null | undefined) { + const merged = new Date(date); + + if (previous) { + merged.setHours( + previous.getHours(), + previous.getMinutes(), + previous.getSeconds(), + previous.getMilliseconds(), + ); + } + + return merged; +} + +function BountyDatePopoverContent({ + options, + selectedPreset, + customDate, + onSelectPreset, + onSelectDate, }: { - value: Date | null | undefined; - onChange: (date: Date | null) => void; + options: PresetOption[]; + selectedPreset: T | undefined; + customDate: Date | null | undefined; + onSelectPreset: (preset: T) => void; + onSelectDate: (date: Date) => void; }) { - return ( - { - if (!date) { - onChange(null); - return; - } - - const merged = new Date(date); + const { isOpen, setIsOpen } = useContext(InlineBadgePopoverContext); + const [showCustomCalendar, setShowCustomCalendar] = useState(false); - if (value) { - merged.setHours( - value.getHours(), - value.getMinutes(), - value.getSeconds(), - value.getMilliseconds(), - ); - } + useEffect(() => { + if (!isOpen) return; + if (selectedPreset === "custom") { + setShowCustomCalendar(true); + } + }, [isOpen, selectedPreset]); - onChange(merged); - }} - align="start" - showYearNavigation - trigger={() => ( + if (showCustomCalendar) { + return ( +
    - )} + { + if (!date) return; + onSelectDate(mergeDateWithTime(date, customDate)); + setIsOpen(false); + }} + showYearNavigation + className="p-2" + /> +
    + ); + } + + return ( + { + if (preset === "custom") { + setShowCustomCalendar(true); + onSelectPreset(preset); + return; + } + + setShowCustomCalendar(false); + onSelectPreset(preset); + }} + items={options.map((option) => ({ + value: option.value, + text: option.label, + ...(option.value === "custom" ? { preventClose: true } : {}), + }))} /> ); } @@ -397,95 +440,78 @@ export function BountyDuration({ Starts{" "} - - - ({ - value: option.value, - text: option.label, - }))} - selectedValue={startPreset} - onSelect={(preset) => { - setStartPreset(preset); - - if (preset === "custom") { - const nextCustomStartsAt = customStartsAt ?? value.startsAt; - setCustomStartsAt(nextCustomStartsAt); - applyTiming({ - nextStartPreset: "custom", - nextCustomStartsAt, - }); - return; - } - - applyTiming({ nextStartPreset: preset }); - }} - /> - - {startPreset === "custom" && ( - { - const nextCustomStartsAt = date ?? null; + + { + setStartPreset(preset); + + if (preset === "custom") { + const nextCustomStartsAt = customStartsAt ?? value.startsAt; setCustomStartsAt(nextCustomStartsAt); applyTiming({ nextStartPreset: "custom", nextCustomStartsAt, }); - }} - /> - )} - {" "} + return; + } + + applyTiming({ nextStartPreset: preset }); + }} + onSelectDate={(date) => { + setStartPreset("custom"); + setCustomStartsAt(date); + applyTiming({ + nextStartPreset: "custom", + nextCustomStartsAt: date, + }); + }} + /> + {" "} and ends{" "} - - - ({ - value: option.value, - text: option.label, - }))} - selectedValue={ - customEndsAfterDays != null ? undefined : endPreset + + { + if (endDateLocked && preset === "never") { + return; } - onSelect={(preset) => { - if (endDateLocked && preset === "never") { - return; - } - - setEndPreset(preset); - setCustomEndsAfterDays(null); - - if (preset === "custom") { - const nextCustomEndsAt = - customEndsAt ?? - value.endsAt ?? - addWeeks(value.startsAt, 2); - setCustomEndsAt(nextCustomEndsAt); - applyTiming({ - nextEndPreset: "custom", - nextCustomEndsAt, - }); - return; - } - - applyTiming({ nextEndPreset: preset }); - }} - /> - - {endPreset === "custom" && ( - { - const nextCustomEndsAt = date ?? null; + + setEndPreset(preset); + setCustomEndsAfterDays(null); + + if (preset === "custom") { + const nextCustomEndsAt = + customEndsAt ?? value.endsAt ?? addWeeks(value.startsAt, 2); setCustomEndsAt(nextCustomEndsAt); applyTiming({ nextEndPreset: "custom", nextCustomEndsAt, }); - }} - /> - )} - + return; + } + + applyTiming({ nextEndPreset: preset }); + }} + onSelectDate={(date) => { + setEndPreset("custom"); + setCustomEndsAfterDays(null); + setCustomEndsAt(date); + applyTiming({ + nextEndPreset: "custom", + nextCustomEndsAt: date, + }); + }} + /> + {endSuffix && {endSuffix}}
    From 9b5aa9e83c4b85fa09c59024601f86238bad767c Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 22 Jul 2026 11:26:03 +0530 Subject: [PATCH 24/27] Align bounty period checks with partner eligibility helper --- .../[bountyId]/sync-social-metrics/route.ts | 4 ++-- apps/web/app/(ee)/api/bounties/route.ts | 14 ++++++++++---- .../bounties/create-draft-submissions/route.ts | 8 ++++---- .../api/cron/bounties/sync-social-metrics/route.ts | 4 ++-- apps/web/lib/bounty/api/bounty-availability.ts | 4 ++-- apps/web/lib/bounty/bounty-period.ts | 2 +- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts index 956b6d41c90..5ecb517532b 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts @@ -5,7 +5,7 @@ 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 { isBountyExpired, isBountyStarted } from "@/lib/bounty/bounty-period"; +import { isBountyEnded, isBountyStarted } from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; @@ -123,7 +123,7 @@ export const POST = withWorkspace( }); } - if (isBountyExpired(endsAt)) { + if (isBountyEnded(endsAt)) { throw new DubApiError({ code: "bad_request", message: "Social metrics can't be synced after the bounty ends.", diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 596e18c4c77..61a2e79c637 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -10,6 +10,7 @@ import { bountyEligibilityIncludes, buildBountyEligibilityWhere, getEffectiveBountyPeriod, + isPartnerEligibleForBounty, } from "@/lib/bounty/api/bounty-availability"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; @@ -110,18 +111,23 @@ export const GET = withWorkspace( }; // Transform the bounties to the response schema - const now = new Date(); const data = bounties.flatMap((bounty) => { if (programEnrollment) { - const { startsAt, endsAt } = getEffectiveBountyPeriod({ - programEnrollment, + const isEligible = isPartnerEligibleForBounty({ + program: programEnrollment.program, bounty, + programEnrollment, }); - if (now < startsAt || (endsAt && now > endsAt)) { + if (!isEligible) { return []; } + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + bounty = { ...bounty, startsAt, diff --git a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts index 4122f6ae30b..fac6aa6efe3 100644 --- a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts @@ -1,7 +1,7 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; -import { canPartnerSubmitBounty } from "@/lib/bounty/api/bounty-availability"; +import { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-availability"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; @@ -168,15 +168,15 @@ export async function POST(req: Request) { continue; } - const canSubmitBounty = canPartnerSubmitBounty({ + const isEligible = isPartnerEligibleForBounty({ program: bounty.program, bounty, programEnrollment, }); - if (!canSubmitBounty) { + if (!isEligible) { console.log( - `Partner ${programEnrollment.partnerId} is not eligible to submit bounty ${bountyId}.`, + `Partner ${programEnrollment.partnerId} is not eligible for bounty ${bountyId}.`, ); continue; } diff --git a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts index a99496993ee..8f48189e909 100644 --- a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts @@ -1,6 +1,6 @@ import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability"; import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; -import { isBountyExpired } from "@/lib/bounty/bounty-period"; +import { isBountyEnded } from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; @@ -134,7 +134,7 @@ export const POST = withCron(async ({ rawBody }) => { bounty, }); - if (isBountyExpired(endsAt)) { + if (isBountyEnded(endsAt)) { continue; } diff --git a/apps/web/lib/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts index 0fcc8e9c5c2..a09a519b92b 100644 --- a/apps/web/lib/bounty/api/bounty-availability.ts +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -8,7 +8,7 @@ import { ProgramEnrollment, } from "@prisma/client"; import { addDays } from "date-fns"; -import { isBountyExpired, isBountyStarted } from "../bounty-period"; +import { isBountyEnded, isBountyStarted } from "../bounty-period"; type PartnerBountyEligibilityInput = { program: Pick; @@ -156,7 +156,7 @@ export function isPartnerEligibleForBounty({ return false; } - if (isBountyExpired(endsAt)) { + if (isBountyEnded(endsAt)) { console.log(`Bounty ${bounty.id} is expired.`); return false; } diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts index ec1eaa3559f..c6b859bbc36 100644 --- a/apps/web/lib/bounty/bounty-period.ts +++ b/apps/web/lib/bounty/bounty-period.ts @@ -95,7 +95,7 @@ export function isBountyStarted(startsAt: Date) { return startsAt <= new Date(); } -export function isBountyExpired(endsAt: Date | null) { +export function isBountyEnded(endsAt: Date | null) { return endsAt !== null && endsAt <= new Date(); } From 39ea8337c13418bc9135b1fa3bdf43b244dc2609 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 22 Jul 2026 12:39:49 +0530 Subject: [PATCH 25/27] Move unban partner side effects to a background job. --- .../bounties/sync-social-metrics/route.ts | 168 ++++++++++-------- 1 file changed, 90 insertions(+), 78 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts index 8f48189e909..f1fcced4d96 100644 --- a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts @@ -55,6 +55,14 @@ export const POST = withCron(async ({ rawBody }) => { ); } + const minCount = bountyInfo.socialMetrics?.minCount; + + if (!minCount) { + return logAndRespond( + `Bounty ${bountyId} has no minimum social metrics count. Skipping...`, + ); + } + const submissions = await prisma.bountySubmission.findMany({ where: { bountyId, @@ -100,33 +108,9 @@ export const POST = withCron(async ({ rawBody }) => { ); } - const newMetrics = await getSocialMetricsUpdates({ - bounty, - submissions, - }); - - const minCount = bountyInfo.socialMetrics?.minCount; - - if (!minCount) { - return logAndRespond( - `Bounty ${bountyId} has no minimum social metrics count. Skipping...`, - ); - } - - const submissionById = new Map(submissions.map((s) => [s.id, s])); - - const updates: Prisma.PrismaPromise[] = []; - const notifications: Pick[] = []; - - for (const { - id, - socialMetricCount, - socialMetricsLastSyncedAt, - } of newMetrics) { - const submission = submissionById.get(id); - - if (!submission || !submission.programEnrollment) { - continue; + const activeSubmissions = submissions.filter((submission) => { + if (!submission.programEnrollment) { + return false; } const { endsAt } = getEffectiveBountyPeriod({ @@ -134,66 +118,96 @@ export const POST = withCron(async ({ rawBody }) => { bounty, }); - if (isBountyEnded(endsAt)) { - continue; - } + return !isBountyEnded(endsAt); + }); + + let syncedCount = 0; + + if (activeSubmissions.length > 0) { + const newMetrics = await getSocialMetricsUpdates({ + bounty, + submissions: activeSubmissions, + }); - const hasMetCriteria = - socialMetricCount != null && socialMetricCount >= minCount; + const submissionById = new Map(activeSubmissions.map((s) => [s.id, s])); - const shouldTransitionToSubmitted = - submission.status === "draft" && hasMetCriteria; + const updates: Prisma.PrismaPromise[] = []; + const notifications: Pick[] = []; - const updateData: Prisma.BountySubmissionUpdateInput = { + for (const { + id, socialMetricCount, socialMetricsLastSyncedAt, - }; - - if (shouldTransitionToSubmitted) { - updateData.status = "submitted"; - updateData.completedAt = new Date(); + } of newMetrics) { + const submission = submissionById.get(id); - if (submission.partner?.email) { - notifications.push({ - email: submission.partner.email, - }); + if (!submission) { + continue; } - } - updates.push( - prisma.bountySubmission.update({ - where: { - id, - }, - data: updateData, - }), - ); - } + const hasMetCriteria = + socialMetricCount != null && socialMetricCount >= minCount; - await prisma.$transaction(updates); - - if (notifications.length > 0) { - await sendBatchEmail( - notifications.map(({ email }) => ({ - subject: "Bounty completed!", - to: email!, - variant: "notifications", - replyTo: bounty.program.supportEmail || "noreply", - react: BountyCompleted({ - email: email!, - bounty: { - name: bounty.name, - type: bounty.type, - }, - program: { - name: bounty.program.name, - slug: bounty.program.slug, + const shouldTransitionToSubmitted = + submission.status === "draft" && hasMetCriteria; + + const updateData: Prisma.BountySubmissionUpdateInput = { + socialMetricCount, + socialMetricsLastSyncedAt, + }; + + if (shouldTransitionToSubmitted) { + updateData.status = "submitted"; + updateData.completedAt = new Date(); + + if (submission.partner?.email) { + notifications.push({ + email: submission.partner.email, + }); + } + } + + updates.push( + prisma.bountySubmission.update({ + where: { + id, }, + data: updateData, }), - })), - ); + ); + } + + await prisma.$transaction(updates); + syncedCount = updates.length; + + if (notifications.length > 0) { + await sendBatchEmail( + notifications.map(({ email }) => ({ + subject: "Bounty completed!", + to: email!, + variant: "notifications", + replyTo: bounty.program.supportEmail || "noreply", + react: BountyCompleted({ + email: email!, + bounty: { + name: bounty.name, + type: bounty.type, + }, + program: { + name: bounty.program.name, + slug: bounty.program.slug, + }, + }), + })), + ); + } } + const summary = + activeSubmissions.length === 0 + ? `No active submissions found for bounty ${bountyId}.` + : `Synced ${syncedCount} submission(s) for bounty ${bountyId}.`; + if (submissions.length === SUBMISSION_BATCH_SIZE) { const startingAfter = submissions[submissions.length - 1].id; @@ -207,7 +221,7 @@ export const POST = withCron(async ({ rawBody }) => { }); return logAndRespond( - `Synced ${updates.length} submissions for bounty ${bountyId}. Queued next batch (startingAfter: ${startingAfter}).`, + `${summary} Queued next batch (startingAfter: ${startingAfter}).`, ); } @@ -220,7 +234,5 @@ export const POST = withCron(async ({ rawBody }) => { }, }); - return logAndRespond( - `Synced ${updates.length} submission(s) for bounty ${bountyId}.`, - ); + return logAndRespond(summary); }); From e50d41705fcff5238c241493290e12d5e5e6bcbe Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 22 Jul 2026 12:58:32 +0530 Subject: [PATCH 26/27] Update index.test.ts --- apps/web/tests/bounties/index.test.ts | 91 +++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/apps/web/tests/bounties/index.test.ts b/apps/web/tests/bounties/index.test.ts index a67b51e1a31..9bc16d1c309 100644 --- a/apps/web/tests/bounties/index.test.ts +++ b/apps/web/tests/bounties/index.test.ts @@ -587,3 +587,94 @@ describe.sequential( }); }, ); + +describe.sequential("/bounties - relative start mode", async () => { + const h = new IntegrationHarness(); + const { http } = await h.init(); + + const relativeSubmissionBase = { + name: "Relative Submission Bounty", + description: "starts when a partner joins", + type: "submission", + startMode: BountyStartMode.relative, + startsAt: null, + endsAt: null, + rewardAmount: 1000, + submissionRequirements: { image: { max: 4 } }, + groupIds: [E2E_PARTNER_GROUP.id], + }; + + test("POST /bounties - relative with endsAfterDays", async () => { + const { status, data: bounty } = await http.post({ + path: "/bounties", + body: { + ...relativeSubmissionBase, + endsAfterDays: 30, + }, + }); + + expect(status).toEqual(200); + expect(bounty).toMatchObject({ + startMode: BountyStartMode.relative, + startsAt: null, + endsAt: null, + endsAfterDays: 30, + }); + + const { status: patchStatus, data: updated } = await http.patch({ + path: `/bounties/${bounty.id}`, + body: { endsAfterDays: 180 }, + }); + + expect(patchStatus).toEqual(200); + expect(updated).toMatchObject({ + startMode: BountyStartMode.relative, + startsAt: null, + endsAfterDays: 180, + }); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + }); + + test("POST /bounties - relative with startsAt is rejected", async () => { + const { status, data } = await http.post({ + path: "/bounties", + body: { + ...relativeSubmissionBase, + startsAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + endsAfterDays: 30, + }, + }); + + expect(status).toEqual(400); + expect(data).toMatchObject({ + error: { + message: + "startsAt is not supported when the bounty starts when a partner joins. It must be null for relative bounties.", + code: "bad_request", + }, + }); + }); + + test("POST /bounties - both endsAt and endsAfterDays is rejected", async () => { + const { status, data } = await http.post({ + path: "/bounties", + body: { + ...relativeSubmissionBase, + endsAt: addDays(new Date(), 30).toISOString(), + endsAfterDays: 30, + }, + }); + + expect(status).toEqual(400); + expect(data).toMatchObject({ + error: { + message: + "Bounty cannot have both an end date (endsAt) and endsAfterDays.", + code: "bad_request", + }, + }); + }); +}); From 47735f44e2f274731e8e5052a3c382106da95d3f Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Fri, 24 Jul 2026 16:11:17 -0700 Subject: [PATCH 27/27] fix merge conflicts --- apps/web/lib/api/workflows/types.ts | 6 +++++- apps/web/lib/types.ts | 32 ----------------------------- 2 files changed, 5 insertions(+), 33 deletions(-) diff --git a/apps/web/lib/api/workflows/types.ts b/apps/web/lib/api/workflows/types.ts index 19874ba5434..d7f8055a46e 100644 --- a/apps/web/lib/api/workflows/types.ts +++ b/apps/web/lib/api/workflows/types.ts @@ -2,7 +2,7 @@ import { workflowActionSchema, workflowConditionSchema, } from "@/lib/zod/schemas/workflows"; -import { WorkflowTrigger } from "@prisma/client"; +import { ProgramEnrollment, WorkflowTrigger } from "@prisma/client"; import type * as z from "zod/v4"; export type WorkflowCondition = z.infer; @@ -33,6 +33,10 @@ export interface WorkflowContext { current?: PartnerMetrics; aggregated?: PartnerMetrics; }; + programEnrollment?: Pick< + ProgramEnrollment, + "groupId" | "createdAt" | "partnerId" | "programId" | "status" + >; } export type WorkflowType = "awardBounty" | "sendCampaign" | "moveGroup"; diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index e304455e7ef..8126bc8ef93 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -23,14 +23,12 @@ import { PartnerRole, PayoutStatus, Prisma, - ProgramEnrollment, ProgramEnrollmentStatus, Project, SubmittedLead, User, UtmTemplate, Webhook, - WorkflowTrigger, WorkspaceRole, } from "@prisma/client"; import * as z from "zod/v4"; @@ -803,36 +801,6 @@ export type CreateFraudEventInput = Pick< metadata?: Record | null; }; -interface WorkflowIdentity { - workspaceId: string; - programId: string; - partnerId: string; - groupId?: string; - customerId?: string; - customerFirstSaleAt?: Date; -} - -interface PartnerMetrics { - leads?: number; - conversions?: number; - saleAmount?: number; - commissions?: number; -} - -export interface WorkflowContext { - trigger: WorkflowTrigger; - reason?: "lead" | "sale" | "commission"; - identity: WorkflowIdentity; - metrics?: { - current?: PartnerMetrics; - aggregated?: PartnerMetrics; - }; - programEnrollment?: Pick< - ProgramEnrollment, - "groupId" | "createdAt" | "partnerId" | "programId" | "status" - >; -} - export type SubmittedLeadProps = z.infer; export type SubmittedLeadFormDataField = z.infer<