diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 918d6fc54bb..f83efe5f5bc 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -6,7 +6,9 @@ import { parseRequestBody } from "@/lib/api/utils"; import { WorkflowCondition } from "@/lib/api/workflows/types"; import { validateWorkflowConditions } from "@/lib/api/workflows/validate-workflow-conditions"; import { withWorkspace } from "@/lib/auth"; +import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-availability"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details"; import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes"; import { shouldUpsertDraftSubmissionsOnReopen } from "@/lib/bounty/api/upsert-draft-bounty-submissions"; @@ -21,7 +23,7 @@ import { updateBountySchema, } from "@/lib/zod/schemas/bounties"; import { APP_DOMAIN_WITH_NGROK, 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"; @@ -57,6 +59,8 @@ export const PATCH = withWorkspace( description, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, @@ -67,26 +71,57 @@ export const PATCH = withWorkspace( groupIds, } = updateBountySchema.parse(await parseRequestBody(req)); - const bounty = await prisma.bounty.findUniqueOrThrow({ - where: { - id: bountyId, - programId, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, include: { - groups: true, workflow: true, _count: { select: { submissions: true, }, }, + ...bountyEligibilityIncludes, }, }); + const nextStartMode = + startMode !== undefined ? startMode : bounty.startMode; + + // Absolute end dates are cleared when switching to relative (unless the + // client explicitly sends endsAt) or when setting endsAfterDays. + let endsAtUpdate: { endsAt?: Date | null } = {}; + + if (endsAt !== undefined) { + endsAtUpdate = { endsAt }; + } else if (endsAfterDays != null) { + endsAtUpdate = { endsAt: null }; + } else if ( + nextStartMode === BountyStartMode.relative && + bounty.endsAt != null + ) { + endsAtUpdate = { endsAt: null }; + } + validateBounty({ type: bounty.type, - startsAt, - endsAt: endsAt !== undefined ? endsAt : bounty.endsAt, + // Relative bounties never store startsAt; coerce so mode switches don't + // fail validation against a leftover absolute startsAt. + startsAt: + nextStartMode === BountyStartMode.relative + ? null + : startsAt !== undefined + ? startsAt + : bounty.startsAt, + endsAt: + endsAtUpdate.endsAt !== undefined ? endsAtUpdate.endsAt : bounty.endsAt, + startMode: nextStartMode, + endsAfterDays: + endsAfterDays !== undefined + ? endsAfterDays + : nextStartMode === BountyStartMode.absolute + ? null + : bounty.endsAfterDays, submissionsOpenAt, submissionFrequency: submissionFrequency !== undefined @@ -123,17 +158,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 @@ -189,6 +229,19 @@ export const PATCH = withWorkspace( }); } + // Relative bounties start when a partner joins, so startsAt is cleared. + // For absolute bounties, only update startsAt when explicitly provided. + let startsAtUpdate: { startsAt?: Date | null } = {}; + + if (nextStartMode === BountyStartMode.relative) { + startsAtUpdate = { startsAt: null }; + } else if (startsAt !== undefined) { + startsAtUpdate = { startsAt: startsAt ?? new Date() }; + } else if (bounty.startsAt === null) { + // Switching relative -> absolute without a startsAt: default to now + startsAtUpdate = { startsAt: new Date() }; + } + const data = await prisma.$transaction(async (tx) => { const updatedBounty = await tx.bounty.update({ where: { @@ -197,8 +250,15 @@ export const PATCH = withWorkspace( data: { name: bountyName ?? undefined, description, - startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4) - endsAt, + ...startsAtUpdate, + ...endsAtUpdate, + ...(startMode !== undefined && { startMode }), + ...(endsAfterDays !== undefined + ? { endsAfterDays } + : nextStartMode === BountyStartMode.absolute && + bounty.endsAfterDays != null + ? { endsAfterDays: null } + : {}), submissionsOpenAt: bounty.type === "submission" ? submissionsOpenAt : null, ...(bounty.type === "submission" && @@ -214,18 +274,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, }, }); @@ -290,7 +353,9 @@ export const PATCH = withWorkspace( body: { bountyId: bounty.id, }, - notBefore: Math.floor(data.startsAt.getTime() / 1000), + ...(data.startsAt && { + notBefore: Math.floor(data.startsAt.getTime() / 1000), + }), }), ]), ); @@ -309,19 +374,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/[bountyId]/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts index 26bec94e943..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 @@ -2,8 +2,10 @@ import { DubApiError } from "@/lib/api/errors"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; +import { isBountyEnded, isBountyStarted } from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; @@ -52,6 +54,11 @@ export const POST = withWorkspace( urls: true, status: true, partner: true, + programEnrollment: { + select: { + createdAt: true, + }, + }, }, }, } @@ -67,58 +74,60 @@ export const POST = withWorkspace( }); } - const submission = submissionId ? bounty.submissions?.[0] : undefined; - - if (submissionId) { - if (!submission) { - throw new DubApiError({ - code: "not_found", - message: `Submission ${submissionId} not found.`, - }); - } + // Bounty-wide sync (no submissionId): run asynchronously via a background job + if (!submissionId) { + const response = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, + method: "POST", + body: { + bountyId, + }, + }); - if (submission.status === "approved") { + if (!response.messageId) { throw new DubApiError({ code: "bad_request", - message: "Social metrics can't be synced for an approved submission.", + message: "Could not sync social metrics for this bounty now.", }); } + + return NextResponse.json({}); } - const now = new Date(); + // Single-submission sync + const submission = bounty.submissions?.[0]; - if (bounty.startsAt && bounty.startsAt > now) { + if (!submission || !submission.programEnrollment) { throw new DubApiError({ - code: "bad_request", - message: "Social metrics can only be synced after the bounty starts.", + code: "not_found", + message: `Submission ${submissionId} not found.`, }); } - if (bounty.endsAt && bounty.endsAt < now) { + if (submission.status === "approved") { throw new DubApiError({ code: "bad_request", - message: "Social metrics can't be synced after the bounty ends.", + message: "Social metrics can't be synced for an approved submission.", }); } - // Do the sync in a background job if no submissionId is provided - if (!submissionId) { - const response = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, - method: "POST", - body: { - bountyId, - }, - }); + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment: submission.programEnrollment, + bounty, + }); - if (!response.messageId) { - throw new DubApiError({ - code: "bad_request", - message: "Could not sync social metrics for this bounty now.", - }); - } + if (!isBountyStarted(startsAt)) { + throw new DubApiError({ + code: "bad_request", + message: "Social metrics can only be synced after the bounty starts.", + }); + } - return NextResponse.json({}); + if (isBountyEnded(endsAt)) { + throw new DubApiError({ + code: "bad_request", + message: "Social metrics can't be synced after the bounty ends.", + }); } // Otherwise, do the sync for the specific submission diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index eb42e0a8a02..aa8ae62f83b 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -8,6 +8,12 @@ import { parseRequestBody } from "@/lib/api/utils"; import { WorkflowAction } from "@/lib/api/workflows/types"; import { validateWorkflowConditions } from "@/lib/api/workflows/validate-workflow-conditions"; import { withWorkspace } from "@/lib/auth"; +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"; import { qstash } from "@/lib/cron"; @@ -25,7 +31,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"; @@ -42,52 +48,29 @@ 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, - }, - }, - }, - ], - }, - ], - }), + ...(programEnrollment && buildBountyEligibilityWhere(partnerGroupId)), }, include: { - groups: { - select: { - groupId: true, - }, - }, + ...bountyEligibilityIncludes, }, }), + includeSubmissionsCount ? prisma.bountySubmission.groupBy({ by: ["bountyId", "status"], @@ -128,14 +111,40 @@ 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 data = bounties.flatMap((bounty) => { + if (programEnrollment) { + const isEligible = isPartnerEligibleForBounty({ + program: programEnrollment.program, + bounty, + programEnrollment, + }); + + if (!isEligible) { + return []; + } + + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + bounty = { + ...bounty, + startsAt, + endsAt, + }; + } + + return [ + BountyListSchema.parse({ + ...bounty, + ...(allBountiesSubmissionsCount && { + submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id), + }), + groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), }), - }); + ]; }); return NextResponse.json(data); @@ -168,11 +177,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); if (type === "performance" && performanceCondition) { @@ -214,6 +222,11 @@ 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 === BountyStartMode.relative ? null : startsAt || new Date(); + const bounty = await prisma.$transaction(async (tx) => { let workflow: Workflow | null = null; const bountyId = createId({ prefix: "bnty_" }); @@ -256,6 +269,8 @@ export const POST = withWorkspace( rewardAmount, rewardDescription, performanceScope: type === "performance" ? performanceScope : null, + startMode, + endsAfterDays, ...(submissionRequirements && type === "submission" && { submissionRequirements, @@ -272,7 +287,7 @@ export const POST = withWorkspace( }, include: { workflow: true, - groups: true, + ...bountyEligibilityIncludes, }, }); }); @@ -284,7 +299,14 @@ export const POST = withWorkspace( }); const shouldScheduleDraftSubmissions = - bounty.type === "performance" && bounty.performanceScope === "lifetime"; + bounty.type === "performance" && + bounty.performanceScope === "lifetime" && + bounty.startMode !== BountyStartMode.relative; + + const shouldSchedulePartnerNotifications = + sendNotificationEmails && + canSendEmailCampaigns && + bounty.startMode !== BountyStartMode.relative; waitUntil( Promise.allSettled([ @@ -309,14 +331,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 && @@ -325,7 +348,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/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/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..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 @@ -1,4 +1,6 @@ +import { getEffectiveBountyPeriod } from "@/lib/bounty/api/bounty-availability"; import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; +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"; @@ -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) { @@ -57,12 +55,23 @@ 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, status: { // We only want to process submissions that are not rejected or approved. - notIn: ["rejected", "approved"], + notIn: [ + BountySubmissionStatus.rejected, + BountySubmissionStatus.approved, + ], }, }, select: { @@ -75,6 +84,11 @@ export const POST = withCron(async ({ rawBody }) => { email: true, }, }, + programEnrollment: { + select: { + createdAt: true, + }, + }, }, orderBy: { id: "asc", @@ -94,91 +108,106 @@ export const POST = withCron(async ({ rawBody }) => { ); } - const newMetrics = await getSocialMetricsUpdates({ - bounty, - submissions, + const activeSubmissions = submissions.filter((submission) => { + if (!submission.programEnrollment) { + return false; + } + + const { endsAt } = getEffectiveBountyPeriod({ + programEnrollment: submission.programEnrollment, + bounty, + }); + + return !isBountyEnded(endsAt); }); - const minCount = bountyInfo.socialMetrics?.minCount; + let syncedCount = 0; - if (!minCount) { - return logAndRespond( - `Bounty ${bountyId} has no minimum social metrics count. Skipping...`, - ); - } + if (activeSubmissions.length > 0) { + const newMetrics = await getSocialMetricsUpdates({ + bounty, + submissions: activeSubmissions, + }); - const submissionById = new Map(submissions.map((s) => [s.id, s])); + const submissionById = new Map(activeSubmissions.map((s) => [s.id, s])); - const updates: Prisma.PrismaPromise[] = []; - const notifications: Pick[] = []; + const updates: Prisma.PrismaPromise[] = []; + const notifications: Pick[] = []; - for (const { - id, - socialMetricCount, - socialMetricsLastSyncedAt, - } of newMetrics) { - const submission = submissionById.get(id); + for (const { + id, + socialMetricCount, + socialMetricsLastSyncedAt, + } of newMetrics) { + const submission = submissionById.get(id); - if (!submission) { - continue; - } + if (!submission) { + continue; + } - const hasMetCriteria = - socialMetricCount != null && socialMetricCount >= minCount; + const hasMetCriteria = + socialMetricCount != null && socialMetricCount >= minCount; - const shouldTransitionToSubmitted = - submission.status === "draft" && hasMetCriteria; + const shouldTransitionToSubmitted = + submission.status === "draft" && hasMetCriteria; - const updateData: Prisma.BountySubmissionUpdateInput = { - socialMetricCount, - socialMetricsLastSyncedAt, - }; + const updateData: Prisma.BountySubmissionUpdateInput = { + socialMetricCount, + socialMetricsLastSyncedAt, + }; - if (shouldTransitionToSubmitted) { - updateData.status = "submitted"; - updateData.completedAt = now; + if (shouldTransitionToSubmitted) { + updateData.status = "submitted"; + updateData.completedAt = new Date(); - if (submission.partner?.email) { - notifications.push({ - email: submission.partner.email, - }); + if (submission.partner?.email) { + notifications.push({ + email: submission.partner.email, + }); + } } - } - - updates.push( - prisma.bountySubmission.update({ - where: { - id, - }, - data: updateData, - }), - ); - } - await prisma.$transaction(updates); - - if (notifications.length > 0 && bounty.program) { - 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, + 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; @@ -192,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}).`, ); } @@ -205,7 +234,5 @@ export const POST = withCron(async ({ rawBody }) => { }, }); - return logAndRespond( - `Synced ${updates.length} submission(s) for bounty ${bountyId}.`, - ); + return logAndRespond(summary); }); diff --git a/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts index 376771ba8ed..21dea4b9e50 100644 --- a/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/upsert-draft-submissions/route.ts @@ -43,9 +43,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, + }, + }, }, }); @@ -55,12 +68,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") { @@ -77,16 +92,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 && { @@ -98,9 +112,7 @@ export async function POST(req: Request) { in: COMMISSION_ELIGIBLE_ENROLLMENT_STATUSES, }, }, - select: { - partnerId: true, - totalCommissions: true, + include: { links: { select: { clicks: true, 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)/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..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 @@ -1,8 +1,12 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { + canPartnerSeeBounty, + getEffectiveBountyPeriod, +} from "@/lib/bounty/api/bounty-availability"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; 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"; @@ -15,16 +19,27 @@ 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, + }, + }, }, }); - const bounty = await prisma.bounty.findUnique({ - where: { - id: bountyId, - programId: program.id, - }, + const bounty = await getBountyOrThrow({ + programId: program.id, + bountyId, include: { workflow: { select: { @@ -50,38 +65,31 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }); - if (!bounty) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } + const canSeeBounty = canPartnerSeeBounty({ + program, + bounty, + programEnrollment, + }); - if (bounty.startsAt > new Date()) { + if (!canSeeBounty) { 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 (!partnerCanSeeBounty) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); const { groups, ...bountyWithoutGroups } = bounty; return NextResponse.json( PartnerBountySchema.parse({ ...bountyWithoutGroups, + startsAt, + endsAt, performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, partner: { ...aggregatePartnerLinksStats(links), 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..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 @@ -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"; @@ -33,12 +34,31 @@ export const GET = withPartnerProfile( const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, - include: {}, + include: { + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + }, }); const bounty = await getBountyOrThrow({ bountyId, programId: programEnrollment.programId, + include: { + groups: { + select: { + groupId: true, + }, + }, + submissions: { + where: { + partnerId: partner.id, + }, + }, + }, }); const bountyInfo = resolveBountyDetails(bounty); @@ -50,6 +70,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/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(() => ({})); 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/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page-client.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page-client.tsx deleted file mode 100644 index 87b8487f325..00000000000 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page-client.tsx +++ /dev/null @@ -1,142 +0,0 @@ -"use client"; - -import usePartnerBounty from "@/lib/swr/use-partner-bounty"; -import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; -import { BountyDescription } from "@/ui/partners/bounties/bounty-description"; -import { - PerformanceBountyProgress, - SubmissionBountyProgress, -} from "@/ui/partners/bounties/bounty-performance"; -import { BountyRewardCriteria } from "@/ui/partners/bounties/bounty-reward-criteria"; -import { BountySubmissionRequirements } from "@/ui/partners/bounties/bounty-submission-requirements"; -import { ChevronRight, Trophy } from "@dub/ui"; -import { cn, truncate } from "@dub/utils"; -import Link from "next/link"; -import { redirect, useParams } from "next/navigation"; -import { - BountyRewardsTable, - PartnerBountyCard, - PartnerBountyCardSkeleton, -} from "../bounty-card"; -import { BountySubmissionsTable } from "./bounty-submissions-table"; - -export function PartnerBountyPageClient() { - const { programSlug } = useParams<{ programSlug: string }>(); - const { bounty, isLoading } = usePartnerBounty(); - - if (!bounty && !isLoading) { - redirect(`/programs/${programSlug}/bounties`); - } - - return ( - -
    -
    - {isLoading ? ( - - ) : bounty ? ( -
    - - - -
    - ) : null} -
    - -
    - {isLoading ? ( - - ) : bounty ? ( - <> -
    -

    - Progress -

    -
    - {bounty.type === "performance" ? ( - - ) : ( - - )} -
    -
    - - {bounty.type === "performance" ? null : ( - // ( - // - // ) - - )} - -
    - - - -
    - - ) : null} -
    -
    -
    - ); -} - -function BountyDetailsProgressSkeleton() { - return ( -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - ); -} - -export function PartnerBountyPageHeader() { - const { programSlug } = useParams<{ programSlug: string }>(); - const { bounty } = usePartnerBounty(); - - return ( -
    - - - - -
    - {bounty ? ( - truncate(bounty.name, 70) - ) : ( -
    - )} -
    -
    - ); -} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page.tsx index 8cf6cab23a0..d9e4c5ee475 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/page.tsx @@ -1,13 +1,145 @@ +"use client"; + +import usePartnerBounty from "@/lib/swr/use-partner-bounty"; import { PageContent } from "@/ui/layout/page-content"; +import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; +import { BountyDescription } from "@/ui/partners/bounties/bounty-description"; +import { + PerformanceBountyProgress, + SubmissionBountyProgress, +} from "@/ui/partners/bounties/bounty-performance"; +import { BountyRewardCriteria } from "@/ui/partners/bounties/bounty-reward-criteria"; +import { BountySubmissionRequirements } from "@/ui/partners/bounties/bounty-submission-requirements"; +import { ChevronRight, Trophy } from "@dub/ui"; +import { cn, truncate } from "@dub/utils"; +import Link from "next/link"; +import { redirect, useParams } from "next/navigation"; import { - PartnerBountyPageClient, - PartnerBountyPageHeader, -} from "./page-client"; + BountyRewardsTable, + PartnerBountyCard, + PartnerBountyCardSkeleton, +} from "../bounty-card"; +import { BountySubmissionsTable } from "./bounty-submissions-table"; export default function PartnerBountyPage() { + const { programSlug } = useParams<{ programSlug: string }>(); + const { bounty, isLoading } = usePartnerBounty(); + + if (!bounty && !isLoading) { + redirect(`/programs/${programSlug}/bounties`); + } + return ( }> - + +
    +
    + {isLoading ? ( + + ) : bounty ? ( +
    + + + +
    + ) : null} +
    + +
    + {isLoading ? ( + + ) : bounty ? ( + <> +
    +

    + Progress +

    +
    + {bounty.type === "performance" ? ( + + ) : ( + + )} +
    +
    + + {bounty.type === "performance" ? null : ( + // ( + // + // ) + + )} + +
    + + + +
    + + ) : null} +
    +
    +
    ); } + +function BountyDetailsProgressSkeleton() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} + +function PartnerBountyPageHeader() { + const { programSlug } = useParams<{ programSlug: string }>(); + const { bounty } = usePartnerBounty(); + + return ( +
    + + + + +
    + {bounty ? ( + truncate(bounty.name, 70) + ) : ( +
    + )} +
    +
    + ); +} diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx index 17ddba859c4..47a7b9df886 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx @@ -39,7 +39,11 @@ export function PartnerBountyCard({
    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..06ba2863ee2 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 { Calendar6, Users6 } from "@dub/ui/icons"; 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,9 +35,11 @@ export function BountyInfo() { return null; } + const { dateRangeLabel } = getProgramBountyMeta(bounty); + return (
    -
    +
    @@ -80,65 +55,11 @@ 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) - - )} -
    -
    - {isOwner && (
    @@ -186,7 +107,7 @@ export function BountyInfo() { function BountyInfoSkeleton() { return (
    -
    +
    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..d142276ccf4 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,17 +33,17 @@ import { RichTextProvider, RichTextToolbar, Sheet, - SmartDateTimePicker, Switch, Tooltip, TooltipContent, useRouterStuff, } from "@dub/ui"; import { cn } from "@dub/utils"; -import { BountySubmissionFrequency } from "@prisma/client"; -import { Dispatch, SetStateAction, useState } from "react"; +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"; +import { BountyDuration } from "./bounty-duration"; import { useAddEditBountyForm } from "./use-add-edit-bounty-form"; interface BountySheetProps { @@ -78,11 +77,11 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { form, openAccordions, setOpenAccordions, - hasStartDate, - handleStartDateToggle, - hasEndDate, - handleEndDateToggle, - handleEndDateChange, + startsAt, + endsAt, + startMode, + endsAfterDays, + handleTimingChange, allowedSubmissions, handleAllowedSubmissionsChange, maxAllowedSubmissions, @@ -110,6 +109,16 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { const showBountySocialMetricsUpsell = bountyTypeUI === "socialMetrics" && !canUseBountySocialMetrics; + const bountyTimingValue = useMemo( + () => ({ + startMode: startMode ?? BountyStartMode.absolute, + startsAt: startsAt ? new Date(startsAt) : new Date(), + endsAt: endsAt ? new Date(endsAt) : null, + endsAfterDays: endsAfterDays ?? null, + }), + [startMode, startsAt, endsAt, endsAfterDays], + ); + return (
    @@ -212,7 +221,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { features={["bold", "italic", "links"]} markdown placeholder="Provide any bounty requirements to the partner" - editorClassName="block max-h-48 overflow-auto scrollbar-hide w-full resize-none border-none p-3 text-base sm:text-sm" + editorClassName="block max-h-48 overflow-auto scrollbar-hide w-full resize-none border-none px-3 py-1 text-base sm:text-sm" initialValue={field.value} onChange={(editor: any) => field.onChange(editor.getMarkdown() || null) @@ -246,126 +255,11 @@ 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"' - /> - )} - /> -
    - )} -
    - )} + {type === "submission" && ( <> @@ -386,7 +280,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
    1 ? "Decrease allowed submissions to 1 to use submission window." @@ -396,7 +290,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
    1) && + (!endsAt || allowedSubmissions > 1) && "opacity-30", )} > @@ -406,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/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..41f56b6dc79 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -0,0 +1,570 @@ +"use client"; + +import { + BOUNTY_DURATION_DAYS, + BOUNTY_DURATION_PRESETS, + DurationPreset, + EndPreset, + resolveBountyTiming, + StartPreset, +} from "@/lib/bounty/bounty-period"; +import { + InlineBadgePopoverContext, + InlineBadgePopoverMenu, +} from "@/ui/shared/inline-badge-popover"; +import { + AnimatedSizeContainer, + CalendarIcon, + ChevronLeft, + DatePicker, + DatePickerContext, + Label, +} from "@dub/ui"; +import { cn, formatDate } from "@dub/utils"; +import { BountyStartMode } from "@prisma/client"; +import { addDays, addMonths, addWeeks } from "date-fns"; +import { ReactNode, useContext, 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; + +const BADGE_TRIGGER_CLASSNAME = + "mx-0.5 inline-block rounded px-1.5 text-left text-sm font-semibold transition-colors bg-blue-50 text-blue-700 hover:bg-blue-100 data-[state=open]:bg-blue-100"; + +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 === BountyStartMode.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 === BountyStartMode.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 === BountyStartMode.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 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 BountyDatePickerContent({ + calendar, + options, + selectedPreset, + onSelectPreset, +}: { + calendar: ReactNode; + options: PresetOption[]; + selectedPreset: T | undefined; + onSelectPreset: (preset: T) => void; +}) { + const { isOpen, setIsOpen } = useContext(DatePickerContext); + const [showCustomCalendar, setShowCustomCalendar] = useState( + selectedPreset === "custom", + ); + + useEffect(() => { + if (!isOpen) return; + if (selectedPreset === "custom") { + setShowCustomCalendar(true); + } + }, [isOpen, selectedPreset]); + + return ( + + + {showCustomCalendar ? ( +
    + + {calendar} +
    + ) : ( +
    + { + 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 } : {}), + }))} + /> +
    + )} +
    +
    + ); +} + +function BountyDatePicker({ + label, + options, + selectedPreset, + customDate, + onSelectPreset, + onSelectDate, +}: { + label: string; + options: PresetOption[]; + selectedPreset: T | undefined; + customDate: Date | null | undefined; + onSelectPreset: (preset: T) => void; + onSelectDate: (date: Date) => void; +}) { + return ( + { + if (!date) return; + onSelectDate(mergeDateWithTime(date, customDate)); + }} + trigger={({ open }) => ( + + )} + renderContent={({ calendar }) => ( + + )} + /> + ); +} + +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, + ); + + 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); + setEndPreset(presets.endPreset); + setCustomStartsAt(presets.customStartsAt); + setCustomEndsAt(presets.customEndsAt); + setCustomEndsAfterDays(presets.customEndsAfterDays); + }, [ + isEditing, + value.startMode, + value.startsAt?.getTime(), + value.endsAt?.getTime(), + 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 === BountyStartMode.relative + ? "after joining" + : "from start date" + : null; + + return ( +
    + +
    + + + Starts{" "} + { + 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{" "} + { + 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 }); + }} + onSelectDate={(date) => { + setEndPreset("custom"); + setCustomEndsAfterDays(null); + setCustomEndsAt(date); + applyTiming({ + nextEndPreset: "custom", + nextCustomEndsAt: date, + }); + }} + /> + {endSuffix && {endSuffix}} + +
    +
    + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx index 1e73ca4e697..7c16729f27f 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-logic.tsx @@ -12,6 +12,7 @@ import { } from "@/ui/shared/inline-badge-popover"; import { Trophy } from "@dub/ui/icons"; import { cn, currencyFormatter, nFormatter } from "@dub/utils"; +import { BountyStartMode } from "@prisma/client"; import { Controller } from "react-hook-form"; import { BountyAmountInput } from "./bounty-amount-input"; import { useBountyFormContext } from "./bounty-form-context"; @@ -24,11 +25,14 @@ const PERFORMANCE_SCOPE_DESCRIPTIONS = { export function BountyLogic({ className }: { className?: string }) { const { control, watch } = useBountyFormContext(); - const [attribute, value] = watch([ + const [attribute, value, startMode] = watch([ "performanceCondition.attribute", "performanceCondition.value", + "startMode", ]); + const isRelative = startMode === BountyStartMode.relative; + return (
    @@ -56,9 +60,12 @@ export function BountyLogic({ className }: { className?: string }) { description: PERFORMANCE_SCOPE_DESCRIPTIONS.new, }, { - text: "lifetime", + text: isRelative + ? "lifetime (not available)" + : "lifetime", value: "lifetime", - description: PERFORMANCE_SCOPE_DESCRIPTIONS.lifetime, + description: `${PERFORMANCE_SCOPE_DESCRIPTIONS.lifetime}${isRelative ? " (not available for relative start dates)" : ""}`, + disabled: isRelative, }, ]} /> 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..996641ef3ce 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"; @@ -18,8 +19,9 @@ import { TooltipContent, } from "@dub/ui"; import { Users6 } from "@dub/ui/icons"; -import { formatDate, nFormatter, pluralize } from "@dub/utils"; +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 = { @@ -29,6 +31,8 @@ type ConfirmCreateBountyModalProps = { | "name" | "startsAt" | "endsAt" + | "startMode" + | "endsAfterDays" | "rewardAmount" | "rewardDescription" | "submissionRequirements" @@ -46,40 +50,53 @@ 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); } }; - return bounty ? ( + const { dateRangeLabel } = getProgramBountyMeta(bounty); + + return ( - - {formatDate(bounty.startsAt, { month: "short" })} - {bounty.endsAt && ( - <> - {" → "} - {formatDate(bounty.endsAt, { month: "short" })} - - )} - + {dateRangeLabel}
    {!isOwner && ( @@ -169,55 +178,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)} + + + + + )}
    @@ -236,7 +247,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 d5d21b169c5..b4c9dd73e9f 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 @@ -3,6 +3,7 @@ import { awardBountyConditionSchema } from "@/lib/api/workflows/award-bounty/schema"; 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, @@ -14,8 +15,16 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { BountyProps } from "@/lib/types"; import { bountySocialContentRequirementsSchema } from "@/lib/zod/schemas/bounties"; import { formatDate } from "@dub/utils"; -import { BountySubmissionFrequency } from "@prisma/client"; -import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; +import { BountyStartMode, BountySubmissionFrequency } from "@prisma/client"; +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"; @@ -45,6 +54,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, @@ -55,8 +96,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, @@ -66,16 +113,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 = (() => { @@ -101,8 +140,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 @@ -151,6 +192,8 @@ export function useAddEditBountyForm({ const [ startsAt, endsAt, + startMode, + endsAfterDays, rewardAmount, rewardDescription, type, @@ -164,6 +207,8 @@ export function useAddEditBountyForm({ ] = watch([ "startsAt", "endsAt", + "startMode", + "endsAfterDays", "rewardAmount", "rewardDescription", "type", @@ -176,50 +221,67 @@ 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, + }); + + if (nextStartMode === BountyStartMode.relative) { + setValue("performanceScope", "new", { + shouldDirty: true, + shouldValidate: true, + }); + } + + setHasEndDate( + Boolean(nextEndsAt) || + (nextStartMode === BountyStartMode.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); @@ -277,11 +339,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++) { @@ -295,7 +367,7 @@ export function useAddEditBountyForm({ } return count; - }, [submissionFrequency, startsAt, endsAt]); + }, [submissionFrequency, startsAt, effectiveEndsAt]); useEffect(() => { if (allowedSubmissions > maxAllowedSubmissions) { @@ -381,17 +453,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."; } } @@ -512,6 +588,7 @@ export function useAddEditBountyForm({ bounty, startsAt, endsAt, + endsAfterDays, submissionWindow, rewardAmount, rewardDescription, @@ -537,6 +614,11 @@ export function useAddEditBountyForm({ ...data } = form.getValues(); + // Relative bounties start when a partner joins, so startsAt must be null + if (data.startMode === BountyStartMode.relative) { + data.startsAt = null; + } + const rawRewardAmount = data.rewardAmount; const numAmount = typeof rawRewardAmount === "number" && !Number.isNaN(rawRewardAmount) @@ -626,8 +708,18 @@ export function useAddEditBountyForm({ : performanceCondition, }) : name || "New bounty", - startsAt: startsAt || new Date(), - endsAt: endsAt || null, + startsAt: + startMode === BountyStartMode.relative + ? null + : startsAt || new Date(), + endsAt: + startMode === BountyStartMode.relative + ? endsAfterDays != null + ? null + : endsAt ?? null + : effectiveEndsAt, + startMode: startMode ?? BountyStartMode.absolute, + endsAfterDays: endsAfterDays ?? null, rewardAmount: rewardAmount ? rewardAmount * 100 : null, rewardDescription: rewardDescription || null, submissionRequirements: submissionRequirements ?? null, @@ -649,10 +741,7 @@ export function useAddEditBountyForm({ return { form, - hasStartDate, - setHasStartDate, hasEndDate, - handleEndDateToggle, openAccordions, setOpenAccordions, type, @@ -664,8 +753,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..10df67a7c5c 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,18 +1,23 @@ +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"; import { DynamicTooltipWrapper, ScrollableTooltipContent } from "@dub/ui"; -import { Calendar6, Users, Users6 } from "@dub/ui/icons"; +import { Calendar6, Users6 } from "@dub/ui/icons"; import { formatDate, nFormatter, pluralize } from "@dub/utils"; 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 } = getProgramBountyMeta(bounty); + const eligibleGroups = useMemo(() => { if (!groups || bounty.groups.length === 0) { return []; @@ -29,134 +36,114 @@ 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} -

    +
    +

    + {bounty.name} +

    -
    - - - {formatDate(bounty.startsAt, { month: "short" })} - {bounty.endsAt && ( - <> - {" → "} - {formatDate(bounty.endsAt, { month: "short" })} - - )} - -
    +
    + + {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"} - +
    + + {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 +168,10 @@ function BountyEndedBadge({ endsAt }: { endsAt: Date }) { export function BountyCardSkeleton() { return ( -
    -
    +
    +
    -
    +
    @@ -192,7 +179,7 @@ export function BountyCardSkeleton() {
    -
    +
    @@ -200,6 +187,12 @@ export function BountyCardSkeleton() {
    +
    +
    +
    +
    +
    +
    ); } diff --git a/apps/web/lib/actions/partners/accept-program-invite.ts b/apps/web/lib/actions/partners/accept-program-invite.ts index 7036dacd291..447806269d4 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,7 @@ export const acceptProgramInviteAction = authPartnerActionClient }, data: { status: "approved", - createdAt: new Date(), + createdAt: now, }, include: { links: true, diff --git a/apps/web/lib/api/partners/applications/approve-partner.ts b/apps/web/lib/api/partners/applications/approve-partner.ts index 7065a944538..2509e3fc1dc 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,7 @@ export async function approvePartner({ }, data: { status: "approved", - createdAt: new Date(), + createdAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, @@ -107,7 +109,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/workflows/award-bounty/execute.ts b/apps/web/lib/api/workflows/award-bounty/execute.ts index 26c83d3e37a..f9b1476eba3 100644 --- a/apps/web/lib/api/workflows/award-bounty/execute.ts +++ b/apps/web/lib/api/workflows/award-bounty/execute.ts @@ -1,5 +1,9 @@ import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; import { WorkflowContext } from "@/lib/api/workflows/types"; +import { + getEffectiveBountyPeriod, + isPartnerEligibleForBounty, +} from "@/lib/bounty/api/bounty-availability"; import { prisma } from "@/lib/prisma"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { sendBatchEmail, sendEmail } from "@dub/email"; @@ -38,8 +42,15 @@ export const executeAwardBountyWorkflow = async ({ } const { bountyId } = action.data; - const { identity, metrics } = context; - const { partnerId, groupId, customerId, customerFirstSaleAt } = identity; + 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) { console.error("Partner groupId not set in the context."); @@ -52,9 +63,25 @@ export const executeAwardBountyWorkflow = 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: { + select: { + id: true, + status: true, + }, where: { partnerId, }, @@ -78,30 +105,19 @@ export const executeAwardBountyWorkflow = 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 { groups, submissions } = bounty; + const { submissions, program } = 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) { @@ -119,10 +135,15 @@ export const executeAwardBountyWorkflow = async ({ } } + const { startsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + if ( bounty.performanceScope === "new" && customerFirstSaleAt && - 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 cde8578600e..a1da1513ed6 100644 --- a/apps/web/lib/api/workflows/execute-workflows.ts +++ b/apps/web/lib/api/workflows/execute-workflows.ts @@ -132,9 +132,7 @@ export async function executeWorkflows({ programId, }, }, - select: { - partnerId: true, - groupId: true, + include: { links: { select: { clicks: true, @@ -182,6 +180,7 @@ export async function executeWorkflows({ aggregatePartnerLinksStats(programEnrollment.links); const workflowContext: WorkflowContext = { + programEnrollment, trigger, reason, identity: { 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/bounty/api/bounty-availability.ts b/apps/web/lib/bounty/api/bounty-availability.ts new file mode 100644 index 00000000000..fdc9c12056f --- /dev/null +++ b/apps/web/lib/bounty/api/bounty-availability.ts @@ -0,0 +1,208 @@ +import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; +import { + Bounty, + BountyGroup, + BountyStartMode, + BountySubmission, + Prisma, + Program, + ProgramEnrollment, +} from "@prisma/client"; +import { addDays } from "date-fns"; +import { isBountyEnded, isBountyStarted } from "../bounty-period"; + +type PartnerBountyEligibilityInput = { + program: Pick; + bounty: Pick< + Bounty, + | "id" + | "startsAt" + | "endsAt" + | "endsAfterDays" + | "startMode" + | "archivedAt" + | "createdAt" + > & { + groups: Pick[]; + }; + programEnrollment: Pick< + ProgramEnrollment, + "createdAt" | "groupId" | "status" + >; +}; + +export function buildBountyEligibilityWhere( + groupId: string | undefined, +): Prisma.BountyWhereInput { + return { + OR: [ + { + groups: { + none: {}, + }, + }, + ...(groupId + ? [ + { + groups: { + some: { + groupId, + }, + }, + }, + ] + : []), + ], + }; +} + +export function buildBountyActivePeriodWhere(): Prisma.BountyWhereInput { + const now = new Date(); + + return { + OR: [ + { + startMode: BountyStartMode.relative, + }, + { + startMode: BountyStartMode.absolute, + startsAt: { + lte: now, + }, + OR: [ + { + endsAt: null, + }, + { + endsAt: { + gte: now, + }, + }, + ], + }, + ], + }; +} + +export const bountyEligibilityIncludes = { + groups: { + select: { + groupId: true, + }, + }, +} satisfies Prisma.BountyInclude; + +export function getEffectiveBountyPeriod({ + programEnrollment, + bounty, +}: { + programEnrollment: Pick; + bounty: Pick; +}) { + 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 program enrollment createdAt. + const bountyStartDate = + startMode === BountyStartMode.absolute ? startsAt! : createdAt; + + return { + startsAt: bountyStartDate, + endsAt: endsAfterDays ? addDays(bountyStartDate, endsAfterDays) : endsAt, + }; +} + +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; + } + + // 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)) { + console.log( + `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; + } + + // Relative bounties are for new partners only (enrolled on/after bounty creation) + if (bounty.startMode === BountyStartMode.relative) { + if (programEnrollment.createdAt < bounty.createdAt) { + console.log( + `Partner enrolled before relative bounty ${bounty.id} was created.`, + ); + 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)) { + console.log(`Bounty ${bounty.id} is not started.`); + return false; + } + + if (isBountyEnded(endsAt)) { + console.log(`Bounty ${bounty.id} is expired.`); + return false; + } + + return true; +} + +export const canPartnerSeeBounty = ({ + program, + bounty, + programEnrollment, +}: PartnerBountyEligibilityInput & { + bounty: PartnerBountyEligibilityInput["bounty"] & { + submissions: Pick[]; + }; +}): boolean => { + if (bounty.archivedAt) { + return false; + } + + // Bounties the partner has a submission on stay visible + if (bounty.submissions.length > 0) { + return true; + } + + return isPartnerEligibleForBounty({ + program, + bounty, + programEnrollment, + }); +}; + +export const canPartnerSubmitBounty = ({ + program, + bounty, + programEnrollment, +}: PartnerBountyEligibilityInput): boolean => { + // Only approved/archived partners can submit bounties + if (!ACTIVE_ENROLLMENT_STATUSES.includes(programEnrollment.status)) { + return false; + } + + return isPartnerEligibleForBounty({ + program, + bounty, + programEnrollment, + }); +}; diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index 30fdd8b2ffa..68915130e8d 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"; @@ -11,7 +16,6 @@ import { createBountySubmissionInputSchema, submissionRequirementsSchema, } from "@/lib/zod/schemas/bounties"; -import { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; import { sendBatchEmail, sendEmail } from "@dub/email"; import NewBountySubmission from "@dub/email/templates/bounty-new-submission"; import BountySubmitted from "@dub/email/templates/bounty-submitted"; @@ -58,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) { @@ -100,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: { @@ -156,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, }); @@ -187,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, }); @@ -211,17 +233,17 @@ export class BountySubmissionHandler { // Validate the eligibility of the submission private validateEligibility() { - if (!ACTIVE_ENROLLMENT_STATUSES.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.", + message: + "You are not allowed to submit this bounty. Please contact the program if you think this is an error.", }); } @@ -244,44 +266,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", @@ -289,6 +273,8 @@ export class BountySubmissionHandler { }); } + const now = new Date(); + if ( !this.isDraft && this.bounty.submissionsOpenAt && @@ -523,16 +509,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 = { 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..467244f8725 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -6,46 +6,53 @@ 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 { + bountyEligibilityIncludes, + buildBountyActivePeriodWhere, + buildBountyEligibilityWhere, + canPartnerSeeBounty, + getEffectiveBountyPeriod, +} from "./bounty-availability"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, - "groupId" | "partnerId" | "totalCommissions" + "groupId" | "partnerId" | "totalCommissions" | "createdAt" | "status" > & { links: PartnerLink[]; program: Pick; }; -export async function getBountiesForPartner( - params: GetBountiesForPartnerParams, -) { - const { groupId, partnerId, totalCommissions, program, links } = params; +export async function getBountiesForPartner({ + program, + links, + ...programEnrollment +}: GetBountiesForPartnerParams) { + const { groupId, partnerId, totalCommissions, createdAt } = programEnrollment; - 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: [ { - groups: { - none: {}, - }, - }, - { - groups: { + submissions: { some: { - groupId: groupId || program.defaultGroupId, + partnerId, }, }, }, + { + AND: [ + buildBountyEligibilityWhere(partnerGroupId), + buildBountyActivePeriodWhere(), + ], + }, ], }, include: { + ...bountyEligibilityIncludes, workflow: { select: { triggerConditions: true, @@ -71,14 +78,36 @@ 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) => ({ - ...bounty, - performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, - partner: { - ...partnerLinkStats, - totalCommissions, - }, - })), + visibleBounties.map((bounty) => { + const performanceCondition = + bounty.workflow?.triggerConditions?.[0] || null; + + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment: { + createdAt, + }, + bounty, + }); + + return { + ...bounty, + startsAt, + endsAt, + performanceCondition, + partner: { + ...partnerLinkStats, + totalCommissions, + }, + }; + }), ); } 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 1e29f523f54..be9bc0b74cb 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,11 +1,12 @@ 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 { ACTIVE_ENROLLMENT_STATUSES } from "@/lib/zod/schemas/partners"; 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"; @@ -17,7 +18,7 @@ type GetBountySubmissionUploadUrlParams = { contentLength: number; programEnrollment: Pick< ProgramEnrollment, - "programId" | "partnerId" | "groupId" | "status" + "programId" | "partnerId" | "groupId" | "status" | "createdAt" >; }; @@ -82,73 +83,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.", }); } 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/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 f93700e6136..b8e143b7878 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/bounty/api/upsert-draft-bounty-submissions.ts b/apps/web/lib/bounty/api/upsert-draft-bounty-submissions.ts index 1d35f80e853..7c5c05968d7 100644 --- a/apps/web/lib/bounty/api/upsert-draft-bounty-submissions.ts +++ b/apps/web/lib/bounty/api/upsert-draft-bounty-submissions.ts @@ -1,7 +1,7 @@ import { createId } from "@/lib/api/create-id"; import { awardBountyConditionSchema } from "@/lib/api/workflows/award-bounty/schema"; import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; -import { BountyPerformanceScope, BountyType, Prisma } from "@prisma/client"; +import { Bounty, BountyPerformanceScope, Prisma } from "@prisma/client"; import * as z from "zod/v4"; type AwardBountyCondition = z.infer; @@ -34,20 +34,17 @@ export function shouldUpsertDraftSubmissionsOnReopen({ endsAt, archivedAt, now = new Date(), -}: { - type: BountyType; +}: Pick & { performanceScope: BountyPerformanceScope | null; previousEndsAt: Date | null; - startsAt: Date; - endsAt: Date | null; - archivedAt: Date | null; now?: Date; }): boolean { if (type !== "performance") return false; if (performanceScope !== "lifetime") return false; const wasExpired = previousEndsAt != null && previousEndsAt < now; - const stillExpired = endsAt != null && endsAt < now && startsAt <= now; + const stillExpired = + endsAt != null && endsAt < now && startsAt != null && startsAt <= now; const nowOrSoonActive = !archivedAt && !stillExpired; return wasExpired && nowOrSoonActive; diff --git a/apps/web/lib/bounty/api/validate-bounty.ts b/apps/web/lib/bounty/api/validate-bounty.ts index 19c28f21cec..ff2315695e8 100644 --- a/apps/web/lib/bounty/api/validate-bounty.ts +++ b/apps/web/lib/bounty/api/validate-bounty.ts @@ -1,10 +1,13 @@ import { DubApiError } from "@/lib/api/errors"; import { CreateBountyInput } from "@/lib/types"; +import { BountyStartMode } from "@prisma/client"; export function validateBounty({ type, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, @@ -12,12 +15,42 @@ export function validateBounty({ rewardDescription, performanceScope, }: Partial) { - startsAt = startsAt || new Date(); + startMode = startMode ?? BountyStartMode.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 === BountyStartMode.relative) { + if (startsAt != null) { + throw new DubApiError({ + message: + "`startsAt` is not supported when the `startMode` is `relative`.", + 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: "Bounties cannot have both `endsAt` and `endsAfterDays`.", + code: "bad_request", + }); + } + + if (startMode === BountyStartMode.absolute && endsAfterDays) { + throw new DubApiError({ + message: + "`endsAfterDays` is only supported when the `startMode` is `relative`.", + code: "bad_request", + }); + } + + if (endsAt && startsAt && endsAt < startsAt) { throw new DubApiError({ message: - "Bounty end date (endsAt) must be on or after start date (startsAt).", + "The bounty's end date (`endsAt`) must be on or after the start date (`startsAt`).", code: "bad_request", }); } @@ -25,24 +58,21 @@ export function validateBounty({ if (submissionsOpenAt) { if (!endsAt) { throw new DubApiError({ - message: - "An end date is required to determine when the submission window opens.", + message: "`endsAt` is required when `submissionsOpenAt` is set.", code: "bad_request", }); } - if (submissionsOpenAt < startsAt) { + if (startsAt && submissionsOpenAt < startsAt) { throw new DubApiError({ - message: - "Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).", + message: "`submissionsOpenAt` must be on or after `startsAt`.", code: "bad_request", }); } if (submissionsOpenAt > endsAt) { throw new DubApiError({ - message: - "Bounty submissions open date (submissionsOpenAt) must be on or before end date (endsAt).", + message: "`submissionsOpenAt` must be on or before `endsAt`.", code: "bad_request", }); } @@ -52,7 +82,7 @@ export function validateBounty({ if (type === "performance") { throw new DubApiError({ code: "bad_request", - message: "Reward amount is required for performance bounties.", + message: "`rewardAmount` is required for `performance` bounties.", }); } @@ -60,7 +90,7 @@ export function validateBounty({ throw new DubApiError({ code: "bad_request", message: - "For submission bounties, either reward amount or reward description is required.", + "For `submission` bounties, either `rewardAmount` or `rewardDescription` is required.", }); } } @@ -68,7 +98,18 @@ export function validateBounty({ if (!performanceScope && type === "performance") { throw new DubApiError({ code: "bad_request", - message: "performanceScope must be set for performance bounties.", + message: "`performanceScope` must be set for `performance` bounties.", + }); + } + + if ( + startMode === BountyStartMode.relative && + performanceScope === "lifetime" + ) { + throw new DubApiError({ + code: "bad_request", + message: + "`lifetime` performance scope is not supported when the `startMode` is `relative`.", }); } @@ -77,14 +118,15 @@ export function validateBounty({ if (submissionFrequency && maxSubmissions == null) { throw new DubApiError({ code: "bad_request", - message: "maxSubmissions is required when submissionFrequency is set.", + message: + "`maxSubmissions` is required when `submissionFrequency` is set.", }); } - if (submissionFrequency && !endsAt) { + if (submissionFrequency && !endsAt && !endsAfterDays) { throw new DubApiError({ code: "bad_request", - message: "An end date is required when submissionFrequency is set.", + message: "`endsAt` is required when `submissionFrequency` is set.", }); } } diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts new file mode 100644 index 00000000000..1c71b80a981 --- /dev/null +++ b/apps/web/lib/bounty/bounty-period.ts @@ -0,0 +1,137 @@ +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 = 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 = BountyStartMode.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 === BountyStartMode.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 isBountyEnded(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 === BountyStartMode.relative || !startsAt; + + let dateRangeLabel: string; + + if (isRelative) { + if (endsAfterDays != null) { + 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"; + } + } else { + const startLabel = formatDate(startsAt, { month: "short" }); + dateRangeLabel = endsAt + ? `${startLabel} → ${formatDate(endsAt, { month: "short" })}` + : startLabel; + } + + return { + dateRangeLabel, + }; +} 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}`, }, ], }, 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/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index 19e9128c859..e3f0b32b145 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, @@ -84,7 +85,10 @@ export const createBountySchema = z.object({ .string() .trim() .max(100, "Name must be less than 100 characters") - .nullish(), + .nullish() + .describe( + "The name of the bounty. E.g.: `June Product Launch Promo`. Only applicable for `submission` bounties.", + ), description: z .string() .trim() @@ -92,34 +96,118 @@ export const createBountySchema = z.object({ BOUNTY_DESCRIPTION_MAX_LENGTH, `Description must be less than ${BOUNTY_DESCRIPTION_MAX_LENGTH} characters`, ) - .nullish(), - type: z.enum(BountyType), - startsAt: parseDateSchema.nullish(), - endsAt: parseDateSchema.nullish(), - submissionsOpenAt: parseDateSchema.nullish(), - submissionFrequency: z.enum(BountySubmissionFrequency).nullish(), + .nullish() + .describe( + "The description of the bounty. Use this field to outline the rules and requirements for the bounty.", + ), + type: z + .enum(BountyType) + .describe( + [ + "The type of bounty.", + "`performance`: Bounties that are awarded based on partner performance (leads, conversions, revenue, commissions).", + "`submission`: Bounties that are awarded based on partner submissions (can be an arbitrary submission or a social content submission).", + ].join("\n"), + ), + startMode: z + .enum(BountyStartMode) + .optional() + .describe( + [ + "How the bounty's start date is determined.", + "`absolute`: Bounty starts at a specific date and time.", + "`relative`: Bounty starts when a partner joins the program.", + ].join("\n"), + ), + startsAt: parseDateSchema + .nullish() + .describe( + "The date and time the bounty starts. Only applicable when `startMode` is absolute.", + ), + endsAt: parseDateSchema + .nullish() + .describe( + "The date and time the bounty ends. Only applicable when `startMode` is absolute.", + ), + endsAfterDays: z + .number() + .int() + .positive() + .nullish() + .describe( + "How long after a partner joins the program is the bounty open for. Only applicable when `startMode` is relative.", + ), + submissionsOpenAt: parseDateSchema + .nullish() + .describe( + "The date and time after which partners can finalize their bounty submissions. Only applicable for `submission` bounties.", + ), maxSubmissions: z .number() .int() - .min(2, "Total submissions allowed must be at least 2") + .min(2, "If `maxSubmissions` is set, it must be at least 2") .max(BOUNTY_MAX_SUBMISSIONS) - .nullish(), + .nullish() + .describe( + "The maximum number of submissions a partner can enter. Only applicable for `submission` bounties.", + ), + submissionFrequency: z + .enum(BountySubmissionFrequency) + .nullish() + .describe( + [ + "How often partners can submit their bounty submissions.", + "`daily`: Partners can submit their bounty submissions once per day.", + "`weekly`: Partners can submit their bounty submissions once per week.", + "`monthly`: Partners can submit their bounty submissions once per month.", + "Only applicable for bounties that have `maxSubmissions` set.", + ].join("\n"), + ), rewardAmount: z .number() .positive() .min(1, "Reward amount must be greater than 1") - .nullable(), + .nullable() + .describe("The reward amount for the bounty in USD cents."), rewardDescription: z .string() .trim() .max(100, "Reward description must be less than 100 characters") .transform((v) => (v === "" ? null : v)) - .nullish(), - submissionRequirements: submissionRequirementsSchema.nullish(), - groupIds: z.array(z.string()).nullable(), - performanceCondition: awardBountyConditionSchema.nullish(), - performanceScope: z.enum(BountyPerformanceScope).nullish(), - sendNotificationEmails: z.boolean().optional(), + .nullish() + .describe( + "The reward description for `submission` bounties if a custom reward amount is set", + ), + performanceCondition: awardBountyConditionSchema + .nullish() + .describe( + "The condition that must be met for a `performance` bounty to be awarded. Only applicable for `performance` bounties.", + ), + performanceScope: z + .enum(BountyPerformanceScope) + .nullish() + .describe( + [ + "The scpoe of the performance criteria:", + "`lifetime`: Bounty takes into account all-time performance data", + "`new`: Bounty only counts performance data after the bounty starts", + ].join("\n"), + ), + submissionRequirements: submissionRequirementsSchema + .nullish() + .describe( + "The requirements that must be met for a bounty submission to be finalized. Only applicable for `submission` bounties.", + ), + groupIds: z + .array(z.string()) + .nullable() + .describe("The IDs of the partner groups that this bounty is available to"), + sendNotificationEmails: z + .boolean() + .optional() + .describe( + "Whether to send notification emails to partners when the bounty is created.", + ), }); export const updateBountySchema = createBountySchema @@ -142,8 +230,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 baa1d344f7f..f55fa8030d9 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -163,6 +163,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: awardBountyConditionSchema.nullable().default(null), partner: z.object({ 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/tests/bounties/index.test.ts b/apps/web/tests/bounties/index.test.ts index c17a1b339df..c0213ae482f 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"; @@ -368,7 +368,7 @@ describe.sequential( expect(data).toMatchObject({ error: { message: - "maxSubmissions is required when submissionFrequency is set.", + "`maxSubmissions` is required when `submissionFrequency` is set.", }, }); }); @@ -387,11 +387,40 @@ describe.sequential( expect(status).toEqual(400); expect(data).toMatchObject({ error: { - message: "An end date is required when submissionFrequency is set.", + message: "`endsAt` is required when `submissionFrequency` is set.", }, }); }); + test("POST /bounties - submissionFrequency with relative endsAfterDays is accepted", async () => { + const { status, data: bounty } = await http.post({ + path: "/bounties", + body: { + ...base, + startMode: BountyStartMode.relative, + startsAt: null, + endsAt: null, + endsAfterDays: 30, + maxSubmissions: 4, + submissionFrequency: "week", + }, + }); + + expect(status).toEqual(200); + expect(bounty).toMatchObject({ + startMode: BountyStartMode.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(); @@ -403,8 +432,7 @@ describe.sequential( expect(status).toEqual(400); expect(data).toMatchObject({ error: { - message: - "An end date is required to determine when the submission window opens.", + message: "`endsAt` is required when `submissionsOpenAt` is set.", }, }); }); @@ -420,8 +448,7 @@ describe.sequential( expect(status).toEqual(400); expect(data).toMatchObject({ error: { - message: - "Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).", + message: "`submissionsOpenAt` must be on or after `startsAt`.", }, }); }); @@ -437,8 +464,7 @@ describe.sequential( expect(status).toEqual(400); expect(data).toMatchObject({ error: { - message: - "Bounty submissions open date (submissionsOpenAt) must be on or before end date (endsAt).", + message: "`submissionsOpenAt` must be on or before `endsAt`.", }, }); }); @@ -512,7 +538,7 @@ describe.sequential( expect(status).toEqual(400); expect(data).toMatchObject({ error: { - message: "An end date is required when submissionFrequency is set.", + message: "`endsAt` is required when `submissionFrequency` is set.", }, }); }); @@ -528,8 +554,7 @@ describe.sequential( expect(status).toEqual(400); expect(data).toMatchObject({ error: { - message: - "An end date is required to determine when the submission window opens.", + message: "`endsAt` is required when `submissionsOpenAt` is set.", }, }); }); @@ -558,3 +583,93 @@ 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 `startMode` is `relative`.", + 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: "Bounties cannot have both `endsAt` and `endsAfterDays`.", + code: "bad_request", + }, + }); + }); +}); 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({ diff --git a/apps/web/ui/partners/rewards/rewards-logic.tsx b/apps/web/ui/partners/rewards/rewards-logic.tsx index 8ac3137c29a..c3b31ca4fc4 100644 --- a/apps/web/ui/partners/rewards/rewards-logic.tsx +++ b/apps/web/ui/partners/rewards/rewards-logic.tsx @@ -697,7 +697,6 @@ function ConditionLogic({ {displayValue ?? placeholder} )} - showYearNavigation /> ) : ( = { value: T; onSelect?: () => void; preventClose?: boolean; + disabled?: boolean; }; export function InlineBadgePopoverMenu({ @@ -206,11 +207,14 @@ export function InlineBadgePopoverMenu({ value, onSelect: itemOnSelect, preventClose, + disabled, }) => ( { + if (disabled) return; itemOnSelect?.(); onSelect?.(value); !isMultiSelect && !preventClose && setIsOpen(false); @@ -218,6 +222,8 @@ export function InlineBadgePopoverMenu({ className={cn( "flex cursor-pointer justify-between rounded-md px-1.5 py-1 transition-colors duration-150 data-[selected=true]:bg-neutral-100", description ? "items-start gap-2 py-1.5" : "items-center", + disabled && + "cursor-not-allowed opacity-50 data-[selected=true]:bg-transparent", )} >
    void; /** Custom trigger element. Receives displayValue, placeholder, open, and disabled. Must return a single React element (e.g.
    + renderContent ? ( + renderContent({ calendar }) + ) : ( +
    {calendar}
    + ) } > {customTrigger ? (