diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index a89c3934939..f2560bed599 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -2,9 +2,12 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details"; import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; @@ -18,7 +21,7 @@ import { updateBountySchema, } from "@/lib/zod/schemas/bounties"; import { arrayEqual, deepEqual } from "@dub/utils"; -import { PartnerGroup, Prisma } from "@prisma/client"; +import { PartnerGroup, PartnerTag, Prisma } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -54,6 +57,8 @@ export const PATCH = withWorkspace( description, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, @@ -62,28 +67,33 @@ export const PATCH = withWorkspace( submissionRequirements, performanceCondition, groupIds, + partnerTagIds, } = updateBountySchema.parse(await parseRequestBody(req)); - const bounty = await prisma.bounty.findUniqueOrThrow({ - where: { - id: bountyId, - programId, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, include: { - groups: true, workflow: true, _count: { select: { submissions: true, }, }, + ...bountyEligibilityIncludes, }, }); + const nextStartMode = + startMode !== undefined ? startMode : bounty.startMode; + validateBounty({ type: bounty.type, - startsAt, + startsAt: startsAt !== undefined ? startsAt : bounty.startsAt, endsAt: endsAt !== undefined ? endsAt : bounty.endsAt, + startMode: nextStartMode, + endsAfterDays: + endsAfterDays !== undefined ? endsAfterDays : bounty.endsAfterDays, submissionsOpenAt, submissionFrequency: submissionFrequency !== undefined @@ -113,17 +123,44 @@ export const PATCH = withWorkspace( // if groupIds is provided and is different from the current groupIds, update the groups let updatedPartnerGroups: PartnerGroup[] | undefined = undefined; - if ( - groupIds && - !arrayEqual( - bounty.groups.map((group) => group.groupId), - groupIds, - ) - ) { - updatedPartnerGroups = await throwIfInvalidGroupIds({ - programId, - groupIds, - }); + let shouldUpdatePartnerGroups = false; + + if (groupIds !== undefined) { + const currentGroupIds = bounty.groups.map((group) => group.groupId); + const newGroupIds = groupIds || []; + + if (!arrayEqual(currentGroupIds, newGroupIds)) { + if (newGroupIds.length > 0) { + updatedPartnerGroups = await throwIfInvalidGroupIds({ + programId, + groupIds: newGroupIds, + }); + } + + shouldUpdatePartnerGroups = true; + } + } + + // if partnerTagIds is provided and is different from the current partnerTagIds, update the partner tags + let updatedPartnerTags: PartnerTag[] | undefined = undefined; + let shouldUpdatePartnerTags = false; + + if (partnerTagIds !== undefined) { + const currentPartnerTagIds = bounty.partnerTags.map( + (tag) => tag.partnerTagId, + ); + const newPartnerTagIds = partnerTagIds || []; + + if (!arrayEqual(currentPartnerTagIds, newPartnerTagIds)) { + if (newPartnerTagIds.length > 0) { + updatedPartnerTags = await throwIfInvalidPartnerTagIds({ + programId, + partnerTagIds: newPartnerTagIds, + }); + } + + shouldUpdatePartnerTags = true; + } } // Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions @@ -179,6 +216,19 @@ export const PATCH = withWorkspace( }); } + // Relative bounties start when a partner joins, so startsAt is cleared. + // For absolute bounties, only update startsAt when explicitly provided. + let startsAtUpdate: { startsAt?: Date | null } = {}; + + if (nextStartMode === "relative") { + startsAtUpdate = { startsAt: null }; + } else if (startsAt !== undefined) { + startsAtUpdate = { startsAt: startsAt ?? new Date() }; + } else if (bounty.startsAt === null) { + // Switching relative -> absolute without a startsAt: default to now + startsAtUpdate = { startsAt: new Date() }; + } + const data = await prisma.$transaction(async (tx) => { const updatedBounty = await tx.bounty.update({ where: { @@ -187,8 +237,10 @@ export const PATCH = withWorkspace( data: { name: bountyName ?? undefined, description, - startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4) - endsAt, + ...startsAtUpdate, + ...(endsAt !== undefined && { endsAt }), + ...(startMode !== undefined && { startMode }), + ...(endsAfterDays !== undefined && { endsAfterDays }), submissionsOpenAt: bounty.type === "submission" ? submissionsOpenAt : null, ...(bounty.type === "submission" && @@ -204,18 +256,32 @@ export const PATCH = withWorkspace( submissionRequirements !== undefined && { submissionRequirements: submissionRequirements ?? Prisma.DbNull, }), - ...(updatedPartnerGroups && { + ...(shouldUpdatePartnerGroups && { groups: { deleteMany: {}, - create: updatedPartnerGroups.map((group) => ({ - groupId: group.id, - })), + ...(updatedPartnerGroups && + updatedPartnerGroups.length > 0 && { + create: updatedPartnerGroups.map((group) => ({ + groupId: group.id, + })), + }), + }, + }), + ...(shouldUpdatePartnerTags && { + partnerTags: { + deleteMany: {}, + ...(updatedPartnerTags && + updatedPartnerTags.length > 0 && { + create: updatedPartnerTags.map((tag) => ({ + partnerTagId: tag.id, + })), + }), }, }), }, include: { + ...bountyEligibilityIncludes, workflow: true, - groups: true, }, }); @@ -239,6 +305,9 @@ export const PATCH = withWorkspace( const updatedBounty = BountySchema.parse({ ...data, groups: data.groups.map(({ groupId }) => ({ id: groupId })), + partnerTags: data.partnerTags.map(({ partnerTagId }) => ({ + id: partnerTagId, + })), performanceCondition: data.workflow?.triggerConditions?.[0], }); @@ -281,19 +350,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, }, }); @@ -324,6 +391,9 @@ export const DELETE = withWorkspace( const deletedBounty = BountySchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), + partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({ + id: partnerTagId, + })), performanceCondition: bounty.workflow?.triggerConditions?.[0], }); 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..8430f7f4d89 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 @@ -4,6 +4,11 @@ import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; +import { + getEffectiveBountyPeriod, + isBountyExpired, + isBountyStarted, +} from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; @@ -52,6 +57,12 @@ export const POST = withWorkspace( urls: true, status: true, partner: true, + programEnrollment: { + select: { + groupJoinedAt: true, + createdAt: true, + }, + }, }, }, } @@ -67,64 +78,65 @@ export const POST = withWorkspace( }); } - const submission = submissionId ? bounty.submissions?.[0] : undefined; - - if (submissionId) { - if (!submission) { - throw new DubApiError({ - code: "not_found", - message: `Submission ${submissionId} not found.`, - }); - } + // Bounty-wide sync (no submissionId): run asynchronously via a background job + if (!submissionId) { + const response = await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, + method: "POST", + body: { + bountyId, + }, + }); - if (submission.status === "approved") { + if (!response.messageId) { throw new DubApiError({ code: "bad_request", - message: "Social metrics can't be synced for an approved submission.", + message: "Could not sync social metrics for this bounty now.", }); } + + return NextResponse.json({}); } - const now = new Date(); + // Single-submission sync + const submission = bounty.submissions?.[0]; - if (bounty.startsAt && bounty.startsAt > now) { + if (!submission || !submission.programEnrollment) { throw new DubApiError({ - code: "bad_request", - message: "Social metrics can only be synced after the bounty starts.", + code: "not_found", + message: `Submission ${submissionId} not found.`, }); } - if (bounty.endsAt && bounty.endsAt < now) { + if (submission.status === "approved") { throw new DubApiError({ code: "bad_request", - message: "Social metrics can't be synced after the bounty ends.", + message: "Social metrics can't be synced for an approved submission.", }); } - // Do the sync in a background job if no submissionId is provided - if (!submissionId) { - const response = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`, - method: "POST", - body: { - bountyId, - }, - }); + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment: submission.programEnrollment, + bounty, + }); - if (!response.messageId) { - throw new DubApiError({ - code: "bad_request", - message: "Could not sync social metrics for this bounty now.", - }); - } + if (!isBountyStarted(startsAt)) { + throw new DubApiError({ + code: "bad_request", + message: "Social metrics can only be synced after the bounty starts.", + }); + } - return NextResponse.json({}); + if (isBountyExpired(endsAt)) { + throw new DubApiError({ + code: "bad_request", + message: "Social metrics can't be synced after the bounty ends.", + }); } - // Otherwise, do the sync for the specific submission const toUpdate = await getSocialMetricsUpdates({ bounty, - submissions: bounty.submissions![0], + submissions: submission, }); if (toUpdate.length > 0) { @@ -135,7 +147,6 @@ export const POST = withWorkspace( } const { socialMetricCount, socialMetricsLastSyncedAt } = update; - const submission = bounty.submissions![0]; const updateData: Prisma.BountySubmissionUpdateInput = { socialMetricCount, diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 873593f51f1..78f322e802c 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -4,10 +4,16 @@ import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { + bountyEligibilityIncludes, + buildBountyEligibilityWhere, +} from "@/lib/bounty/api/bounty-eligibility"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; +import { getEffectiveBountyPeriod } from "@/lib/bounty/bounty-period"; import { qstash } from "@/lib/cron"; import { getPlanCapabilities } from "@/lib/plan-capabilities"; import { prisma } from "@/lib/prisma"; @@ -41,52 +47,44 @@ export const GET = withWorkspace( partnerId, programId, include: { - program: true, + program: { + select: { + defaultGroupId: true, + }, + }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }) : null; + const partnerGroupId = + programEnrollment?.groupId || programEnrollment?.program.defaultGroupId; + const partnerTagIds = + programEnrollment?.programPartnerTags.map( + ({ partnerTagId }) => partnerTagId, + ) || []; + const [bounties, allBountiesSubmissionsCount] = await Promise.all([ prisma.bounty.findMany({ where: { programId, // Filter only bounties the specified partner is eligible for ...(programEnrollment && { - AND: [ - // Filter out expired bounties - { - OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }], - }, - // Filter by partner's group eligibility - { - OR: [ - { - groups: { - none: {}, - }, - }, - { - groups: { - some: { - groupId: - programEnrollment.groupId || - programEnrollment.program.defaultGroupId, - }, - }, - }, - ], - }, - ], + ...buildBountyEligibilityWhere({ + groupId: partnerGroupId, + partnerTagIds, + }), }), }, include: { - groups: { - select: { - groupId: true, - }, - }, + ...bountyEligibilityIncludes, }, }), + includeSubmissionsCount ? prisma.bountySubmission.groupBy({ by: ["bountyId", "status"], @@ -127,14 +125,39 @@ 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), + const now = new Date(); + + const data = bounties.flatMap((bounty) => { + // Filter out bounties that are not in the effective bounty period + if (programEnrollment) { + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + if (now < startsAt || (endsAt && now > endsAt)) { + return []; + } + + bounty = { + ...bounty, + startsAt, + endsAt, + }; + } + + return [ + BountyListSchema.parse({ + ...bounty, + groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), + partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({ + id: partnerTagId, + })), + ...(allBountiesSubmissionsCount && { + submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id), + }), }), - }); + ]; }); return NextResponse.json(data); @@ -159,21 +182,25 @@ export const POST = withWorkspace( rewardDescription, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, submissionRequirements, groupIds, + partnerTagIds, performanceCondition, performanceScope, sendNotificationEmails, } = parsedBody; - // Use current date as default if startsAt is not provided - startsAt = startsAt || new Date(); - validateBounty(parsedBody); + // startsAt is only stored for absolute bounties (defaulting to now when + // omitted); relative bounties start when a partner joins, so it stays null. + startsAt = startMode === "absolute" ? startsAt || new Date() : null; + const { canUseBountySocialMetrics, canSendEmailCampaigns } = getPlanCapabilities(workspace.plan); @@ -184,10 +211,17 @@ export const POST = withWorkspace( }); } - const partnerGroups = await throwIfInvalidGroupIds({ - programId, - groupIds, - }); + const [partnerGroups, partnerTags] = await Promise.all([ + throwIfInvalidGroupIds({ + programId, + groupIds, + }), + + throwIfInvalidPartnerTagIds({ + programId, + partnerTagIds, + }), + ]); // Bounty name let bountyName = name; @@ -241,6 +275,8 @@ export const POST = withWorkspace( type, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt: type === "submission" ? submissionsOpenAt : null, submissionFrequency: type === "submission" ? submissionFrequency : null, @@ -261,10 +297,19 @@ export const POST = withWorkspace( }, }, }), + ...(partnerTags.length && { + partnerTags: { + createMany: { + data: partnerTags.map(({ id }) => ({ + partnerTagId: id, + })), + }, + }, + }), }, include: { workflow: true, - groups: true, + ...bountyEligibilityIncludes, }, }); }); @@ -272,11 +317,21 @@ export const POST = withWorkspace( const createdBounty = BountySchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), + partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({ + id: partnerTagId, + })), performanceCondition: bounty.workflow?.triggerConditions?.[0], }); const shouldScheduleDraftSubmissions = - bounty.type === "performance" && bounty.performanceScope === "lifetime"; + bounty.type === "performance" && + bounty.performanceScope === "lifetime" && + bounty.startMode === "absolute"; + + const shouldSchedulePartnerNotifications = + sendNotificationEmails && + canSendEmailCampaigns && + bounty.startMode === "absolute"; waitUntil( Promise.allSettled([ @@ -301,14 +356,14 @@ 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), + // startsAt is guaranteed to be set for absolute bounties + notBefore: Math.floor(bounty.startsAt!.getTime() / 1000), }), shouldScheduleDraftSubmissions && @@ -317,7 +372,8 @@ export const POST = withWorkspace( body: { bountyId: bounty.id, }, - notBefore: Math.floor(bounty.startsAt.getTime() / 1000), + // startsAt is guaranteed to be set for absolute bounties + notBefore: Math.floor(bounty.startsAt!.getTime() / 1000), }), ]), ); diff --git a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts index 9f398264e98..faae7f1beb9 100644 --- a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts +++ b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts @@ -1,6 +1,10 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { + bountyEligibilityIncludes, + canPartnerSubmitBounty, +} from "@/lib/bounty/api/bounty-eligibility"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; @@ -41,9 +45,9 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, program: true, workflow: true, + ...bountyEligibilityIncludes, }, }); @@ -53,12 +57,14 @@ export async function POST(req: Request) { }); } - let diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); + if (bounty.startsAt) { + let diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); - if (diffMinutes >= 10) { - return logAndRespond( - `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, - ); + if (diffMinutes >= 10) { + return logAndRespond( + `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, + ); + } } if (bounty.type !== "performance") { @@ -75,16 +81,18 @@ 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); + const bountyTagIds = bounty.partnerTags.map( + ({ partnerTagId }) => partnerTagId, + ); // 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 && { @@ -92,13 +100,20 @@ export async function POST(req: Request) { in: partnerIds, }, }), + ...(bountyTagIds.length > 0 && { + programPartnerTags: { + some: { + partnerTagId: { + in: bountyTagIds, + }, + }, + }, + }), status: { in: ["approved", "invited"], }, }, - select: { - partnerId: true, - totalCommissions: true, + include: { links: { select: { clicks: true, @@ -113,6 +128,11 @@ export async function POST(req: Request) { name: true, }, }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, orderBy: { createdAt: "asc", @@ -136,48 +156,53 @@ export async function POST(req: Request) { .array(workflowConditionSchema) .parse(bounty.workflow.triggerConditions)[0]; - // Partners with their link metrics - const partners = programEnrollments.map((programEnrollment) => { - return { - id: programEnrollment.partnerId, - ...aggregatePartnerLinksStats(programEnrollment.links), - totalCommissions: toCentsNumber(programEnrollment.totalCommissions), - }; - }); + const submissionsToCreate: Prisma.BountySubmissionCreateManyInput[] = []; - const bountySubmissionsToCreate: Prisma.BountySubmissionCreateManyInput[] = - partners - // only create submissions for partners that have at least 1 performanceCount - .filter((partner) => partner[condition.attribute] > 0) - .map((partner) => { - const performanceCount = partner[condition.attribute]; - - const conditionMet = evaluateWorkflowConditions({ - conditions: [condition], - attributes: { - [condition.attribute]: performanceCount, - }, - }); - - return { - id: createId({ prefix: "bnty_sub_" }), - programId: bounty.programId, - partnerId: partner.id, - bountyId: bounty.id, - performanceCount, - // If the condition is met, automatically submit the submission - ...(conditionMet && { - status: "submitted", - completedAt: new Date(), - }), - }; - }); - - console.table(bountySubmissionsToCreate); + for (const enrollment of programEnrollments) { + const performanceCount = { + ...aggregatePartnerLinksStats(enrollment.links), + totalCommissions: toCentsNumber(enrollment.totalCommissions), + }[condition.attribute]; + + if (!performanceCount || performanceCount <= 0) { + continue; + } + + const canSubmit = canPartnerSubmitBounty({ + programEnrollment: enrollment, + bounty, + }); + + if (!canSubmit) { + continue; + } + + const conditionMet = evaluateWorkflowConditions({ + conditions: [condition], + attributes: { + [condition.attribute]: performanceCount, + }, + }); + + submissionsToCreate.push({ + id: createId({ prefix: "bnty_sub_" }), + programId: bounty.programId, + partnerId: enrollment.partnerId, + bountyId: bounty.id, + performanceCount: Math.min(performanceCount, condition.value as number), + // If the condition is met, automatically submit the submission + ...(conditionMet && { + status: "submitted", + completedAt: new Date(), + }), + }); + } + + console.table(submissionsToCreate); // Create bounty submissions const createdBountySubmissions = await prisma.bountySubmission.createMany({ - data: bountySubmissionsToCreate, + data: submissionsToCreate, skipDuplicates: true, }); 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..b7cb22d6004 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 @@ -1,5 +1,6 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@/lib/prisma"; @@ -50,7 +51,6 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, program: { include: { emailDomains: { @@ -60,6 +60,7 @@ export async function POST(req: Request) { }, }, }, + ...bountyEligibilityIncludes, }, }); @@ -69,20 +70,25 @@ export async function POST(req: Request) { }); } - const diffMinutes = differenceInMinutes(bounty.startsAt, new Date()); - - if (diffMinutes >= 10) { + if (bounty.startMode === "relative") { return logAndRespond( - `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`, + `Bounty ${bountyId} has dynamic start date, skipping...`, ); } - // Find groupIds + 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 groupIds = bounty.groups.map(({ groupId }) => groupId); - console.log( - `Bounty ${bountyId} is applicable to ${ - groupIds.length === 0 ? "all" : groupIds.length - } groups (groupIds: ${JSON.stringify(groupIds)})`, + const partnerTagIds = bounty.partnerTags.map( + ({ partnerTagId }) => partnerTagId, ); const programEnrollments = await prisma.programEnrollment.findMany({ @@ -93,6 +99,15 @@ export async function POST(req: Request) { in: groupIds, }, }), + ...(partnerTagIds.length > 0 && { + programPartnerTags: { + some: { + partnerTagId: { + in: partnerTagIds, + }, + }, + }, + }), status: { in: ACTIVE_ENROLLMENT_STATUSES, }, 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..4b1c1d2394c 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,3 +1,4 @@ +import { buildActiveBountyPeriodWhere } from "@/lib/bounty/api/bounty-eligibility"; import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs"; import { withCron } from "@/lib/cron/with-cron"; import { prisma } from "@/lib/prisma"; @@ -9,28 +10,14 @@ 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, }, + ...buildActiveBountyPeriodWhere(), }, select: { id: true, diff --git a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts index 6a64fd63252..e7eb971e35e 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,8 @@ import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates"; +import { + getEffectiveBountyPeriod, + isBountyExpired, +} from "@/lib/bounty/bounty-period"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { qstash } from "@/lib/cron"; import { withCron } from "@/lib/cron/with-cron"; @@ -31,7 +35,13 @@ export const POST = withCron(async ({ rawBody }) => { id: bountyId, }, include: { - program: true, + program: { + select: { + name: true, + slug: true, + supportEmail: true, + }, + }, }, }); @@ -39,16 +49,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) { @@ -75,6 +75,12 @@ export const POST = withCron(async ({ rawBody }) => { email: true, }, }, + programEnrollment: { + select: { + groupJoinedAt: true, + createdAt: true, + }, + }, }, orderBy: { id: "asc", @@ -119,7 +125,16 @@ export const POST = withCron(async ({ rawBody }) => { } of newMetrics) { const submission = submissionById.get(id); - if (!submission) { + if (!submission || !submission.programEnrollment) { + continue; + } + + const { endsAt } = getEffectiveBountyPeriod({ + programEnrollment: submission.programEnrollment, + bounty, + }); + + if (isBountyExpired(endsAt)) { continue; } @@ -136,7 +151,7 @@ export const POST = withCron(async ({ rawBody }) => { if (shouldTransitionToSubmitted) { updateData.status = "submitted"; - updateData.completedAt = now; + updateData.completedAt = new Date(); if (submission.partner?.email) { notifications.push({ @@ -157,7 +172,7 @@ export const POST = withCron(async ({ rawBody }) => { await prisma.$transaction(updates); - if (notifications.length > 0 && bounty.program) { + if (notifications.length > 0) { await sendBatchEmail( notifications.map(({ email }) => ({ subject: "Bounty completed!", diff --git a/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts b/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts index ab48735c901..f2f1198c4f0 100644 --- a/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts +++ b/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts @@ -35,7 +35,9 @@ export const POST = withWorkspace(async ({ workspace, params }) => { const workflowConfig = parseWorkflowConfig(workflow); if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) { - await executeSendCampaignWorkflow({ workflow }); + await executeSendCampaignWorkflow({ + workflow, + }); } return NextResponse.json({ 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..15a1b29ed96 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,9 @@ import { DubApiError } from "@/lib/api/errors"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; +import { + bountyEligibilityIncludes, + throwIfPartnerCannotSubmitBounty, +} from "@/lib/bounty/api/bounty-eligibility"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth"; @@ -32,45 +36,14 @@ export const GET = withReferralsEmbedToken( bountyId, programId: programEnrollment.programId, include: { - groups: true, + ...bountyEligibilityIncludes, }, }); - 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.", - }); - } - - if (bounty.archivedAt) { - throw new DubApiError({ - code: "bad_request", - message: "This bounty is archived.", - }); - } + throwIfPartnerCannotSubmitBounty({ + programEnrollment, + bounty, + }); const bountyInfo = resolveBountyDetails(bounty); 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..7280c86365a 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 { + bountyEligibilityIncludes, + throwIfPartnerCannotViewBounty, +} from "@/lib/bounty/api/bounty-eligibility"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; +import { getEffectiveBountyPeriod } from "@/lib/bounty/bounty-period"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; -import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; @@ -17,21 +21,23 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { include: { program: true, links: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); - const bounty = await prisma.bounty.findUnique({ - where: { - id: bountyId, - programId: program.id, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId: program.id, include: { workflow: { select: { triggerConditions: true, }, }, - groups: true, submissions: { where: { partnerId: partner.id, @@ -47,41 +53,24 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }, }, + ...bountyEligibilityIncludes, }, }); - if (!bounty) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } - - if (bounty.startsAt > new Date()) { - 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 { groups, ...bountyWithoutGroups } = bounty; + throwIfPartnerCannotViewBounty({ + programEnrollment, + bounty, + defaultGroupId: program.defaultGroupId, + hasSubmission: bounty.submissions.length > 0, + }); return NextResponse.json( PartnerBountySchema.parse({ - ...bountyWithoutGroups, + ...bounty, + ...getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }), 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..e4897f9bd0e 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,10 @@ 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 { + bountyEligibilityIncludes, + throwIfPartnerCannotSubmitBounty, +} from "@/lib/bounty/api/bounty-eligibility"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { ratelimit } from "@/lib/upstash"; @@ -33,12 +37,26 @@ export const GET = withPartnerProfile( const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }); const bounty = await getBountyOrThrow({ bountyId, programId: programEnrollment.programId, + include: { + ...bountyEligibilityIncludes, + }, + }); + + throwIfPartnerCannotSubmitBounty({ + programEnrollment, + bounty, }); const bountyInfo = resolveBountyDetails(bounty); 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..03fadc40895 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,26 @@ 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, + }, + }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); 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/bounties/submissions-table.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx index 1f6ff5e0e5f..df8eeeba8a3 100644 --- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx +++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx @@ -1,5 +1,6 @@ "use client"; +import { isBountyExpired } from "@/lib/bounty/bounty-period"; import { BountySubmissionStatusBadges } from "@/lib/bounty/bounty-submission-status-badges"; import { type SubmissionPeriod, @@ -96,8 +97,9 @@ export function EmbedBountySubmissionsTable({ size: 98, cell: ({ row: { original } }) => { const { status, periodNumber } = original; - const isExpired = - bounty.endsAt !== null && new Date(bounty.endsAt) < new Date(); + const isExpired = isBountyExpired( + bounty.endsAt ? new Date(bounty.endsAt) : null, + ); const isActionable = status === "notSubmitted" || (status === "draft" && 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..57a29e99894 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 @@ -72,6 +72,11 @@ export const getReferralsEmbedData = async (token: string) => { saleReward: true, referralReward: true, discount: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx index e46df734904..86e69bb354c 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx @@ -1,5 +1,6 @@ "use client"; +import { isBountyExpired } from "@/lib/bounty/bounty-period"; import { BountySubmissionStatusBadges } from "@/lib/bounty/bounty-submission-status-badges"; import { type SubmissionPeriod, @@ -142,8 +143,9 @@ export function BountySubmissionsTable({ cell: ({ row: { original } }) => { const { status } = original; const bountyInfo = resolveBountyDetails(bounty); - const isExpired = - bounty.endsAt !== null && new Date(bounty.endsAt) < new Date(); + const isExpired = isBountyExpired( + bounty.endsAt ? new Date(bounty.endsAt) : null, + ); const isActionable = bountyInfo?.hasSocialMetrics ? status === "notSubmitted" : status === "notSubmitted" || status === "draft"; 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..36c3cbf2027 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 @@ -1,3 +1,4 @@ +import { isBountyExpired } from "@/lib/bounty/bounty-period"; import { PartnerBountyProps } from "@/lib/types"; import { PerformanceBountyProgress, @@ -169,8 +170,9 @@ export function BountyRewardsTable({ } export function BountyEndDate({ bounty }: { bounty: PartnerBountyProps }) { - const isExpired = - bounty.endsAt && new Date(bounty.endsAt) < new Date() ? true : false; + const isExpired = isBountyExpired( + bounty.endsAt ? new Date(bounty.endsAt) : null, + ); return (
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx index 7258e9378a7..b7da9ad2aa6 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { isBountyExpired } from "@/lib/bounty/bounty-period"; import { usePartnerProgramBounties } from "@/lib/swr/use-partner-program-bounties"; import { PageContent } from "@/ui/layout/page-content"; import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper"; @@ -29,9 +30,10 @@ export default function PartnerProgramBountiesPage() { const filteredBounties = useMemo(() => { if (!bounties) return []; - const now = new Date(); return bounties.filter((bounty) => { - const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= now; + const isExpired = isBountyExpired( + bounty.endsAt ? new Date(bounty.endsAt) : null, + ); if (activeTab === "active") { return !isExpired; diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx index e4fcc39fa5d..58751759138 100644 --- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx +++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx @@ -1,6 +1,6 @@ import { serializeReward } from "@/lib/api/partners/serialize-reward"; import { getSession } from "@/lib/auth"; -import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries"; +import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties"; import { prisma } from "@/lib/prisma"; import { programLanderSchema } from "@/lib/zod/schemas/program-lander"; import { PageContent } from "@/ui/layout/page-content"; @@ -71,7 +71,7 @@ export default async function ProgramInvitePage(props: { .filter((r) => r !== null) .map((r) => serializeReward(r as Reward)); - const bounties = await getGroupBountySummaries({ + const bounties = await getGroupBounties({ programId: program.id, groupId: group.id, }); 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..7524a59e9fd 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 @@ -6,6 +6,7 @@ import { useBountySubmissionsCount, } from "@/lib/swr/use-bounty-submissions-count"; import useGroups from "@/lib/swr/use-groups"; +import { usePartnerTags } from "@/lib/swr/use-partner-tags"; 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"; @@ -44,6 +45,7 @@ export function BountyInfo() { }); const { groups } = useGroups(); + const { partnerTags } = usePartnerTags(); const eligibleGroups = useMemo(() => { if (!groups || !bounty || bounty.groups.length === 0) { @@ -54,6 +56,16 @@ export function BountyInfo() { .filter((g): g is NonNullable => g !== undefined); }, [groups, bounty]); + const eligibleTags = useMemo(() => { + if (!partnerTags || !bounty || bounty.partnerTags.length === 0) { + return []; + } + + return bounty.partnerTags + .map((bountyTag) => partnerTags.find((t) => t.id === bountyTag.id)) + .filter((t): t is NonNullable => t !== undefined); + }, [partnerTags, bounty]); + if (loading) { return ; } @@ -81,7 +93,9 @@ export function BountyInfo() {
- {formatDate(bounty.startsAt, { month: "short" })} + {bounty.startsAt + ? formatDate(bounty.startsAt, { month: "short" }) + : "When a partner joins"} {" → "} {bounty.endsAt ? formatDate(bounty.endsAt, { month: "short" }) @@ -140,38 +154,74 @@ export function BountyInfo() {
{isOwner && ( -
+
- {bounty.groups.length === 0 ? ( - All groups - ) : eligibleGroups.length === 1 ? ( -
- - {eligibleGroups[0].name} -
- ) : eligibleGroups.length > 1 ? ( - - {eligibleGroups.map((group) => ( -
- - - {group.name} - -
- ))} - - } - > +
+ {bounty.groups.length === 0 ? ( + All groups + ) : eligibleGroups.length === 1 ? (
- - {eligibleGroups[0].name} +{eligibleGroups.length - 1} - + {eligibleGroups[0].name}
- - ) : null} + ) : eligibleGroups.length > 1 ? ( + + {eligibleGroups.map((group) => ( +
+ + + {group.name} + +
+ ))} + + } + > +
+ + + {eligibleGroups[0].name} +{eligibleGroups.length - 1} + +
+
+ ) : ( +
+ )} + + {bounty.partnerTags.length > 0 && ( + <> + · + {eligibleTags.length > 0 ? ( + eligibleTags.length === 1 ? ( + {eligibleTags[0].name} + ) : ( + + {eligibleTags.map((tag) => ( + + {tag.name} + + ))} + + } + > + + {eligibleTags[0].name} +{eligibleTags.length - 1} + + + ) + ) : ( +
+ )} + + )} +
)}
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..33c20353f7e 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 @@ -6,7 +6,6 @@ import { getPlanCapabilities } from "@/lib/plan-capabilities"; import useProgram from "@/lib/swr/use-program"; import useWorkspace from "@/lib/swr/use-workspace"; import { BountyProps } from "@/lib/types"; -import { GroupsMultiSelect } from "@/ui/partners/groups/groups-multi-select"; import { ProgramSheetAccordion, ProgramSheetAccordionContent, @@ -21,7 +20,6 @@ import { } from "@/ui/shared/inline-badge-popover"; import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter"; import { - AnimatedSizeContainer, Button, CalendarIcon, CardSelector, @@ -34,7 +32,6 @@ import { RichTextProvider, RichTextToolbar, Sheet, - SmartDateTimePicker, Switch, Tooltip, TooltipContent, @@ -45,6 +42,8 @@ import { BountySubmissionFrequency } from "@prisma/client"; import { Dispatch, SetStateAction, useState } from "react"; import { Controller, FormProvider } from "react-hook-form"; import { BountyCriteria } from "./bounty-criteria"; +import { BountyDuration } from "./bounty-duration"; +import { BountyEligibility } from "./bounty-eligibility"; import { useAddEditBountyForm } from "./use-add-edit-bounty-form"; interface BountySheetProps { @@ -78,11 +77,12 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { form, openAccordions, setOpenAccordions, - hasStartDate, - handleStartDateToggle, hasEndDate, - handleEndDateToggle, - handleEndDateChange, + startsAt, + endsAt, + startMode, + endsAfterDays, + handleTimingChange, allowedSubmissions, handleAllowedSubmissionsChange, maxAllowedSubmissions, @@ -246,126 +246,16 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
- -
- - -
- - {hasStartDate && ( -
- ( - - field.onChange(date ?? undefined) - } - placeholder='E.g. "2026-02-28", "Last Thursday", "2 hours ago"' - /> - )} - /> -
- )} -
- - {type === "performance" && ( - -
- - -
- - {hasEndDate && ( -
- ( - - handleEndDateChange(date ?? null) - } - placeholder='E.g. "2026-12-01", "Next Thursday", "After 10 days"' - /> - )} - /> -
- )} -
- )} - - {type === "submission" && ( - -
- - -
- - {hasEndDate && ( -
- ( - - handleEndDateChange(date ?? null) - } - placeholder='E.g. "2026-12-01", "Next Thursday", "After 10 days"' - /> - )} - /> -
- )} -
- )} + onChange={handleTimingChange} + /> {type === "submission" && ( <> @@ -503,23 +393,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { - - - Groups - - - ( - field.onChange(ids)} - /> - )} - /> - - +
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx new file mode 100644 index 00000000000..372381bc17c --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx @@ -0,0 +1,472 @@ +"use client"; + +import { + BOUNTY_DURATION_DAYS, + BOUNTY_DURATION_PRESETS, + DurationPreset, + EndPreset, + resolveBountyTiming, + StartPreset, +} from "@/lib/bounty/bounty-period"; +import { + InlineBadgePopover, + InlineBadgePopoverMenu, +} from "@/ui/shared/inline-badge-popover"; +import { CalendarIcon, DatePicker } from "@dub/ui"; +import { formatDate } from "@dub/utils"; +import { addDays, addMonths, addWeeks } from "date-fns"; +import { useEffect, useState } from "react"; + +type PresetOption = { value: T; label: string }; +type BountyTimingInput = ReturnType; +type ParsedPresets = { + startPreset: StartPreset; + endPreset: EndPreset; + customStartsAt: Date | null; + customEndsAt: Date | null; + customEndsAfterDays: number | null; +}; + +const DURATION_LABELS: Record = + { + twoWeeks: { start: "in 2 weeks", end: "2 weeks" }, + oneMonth: { start: "in 1 month", end: "1 month" }, + sixMonths: { start: "in 6 months", end: "6 months" }, + }; + +const START_OPTIONS = [ + { value: "today", label: "today" }, + ...BOUNTY_DURATION_PRESETS.map((p) => ({ + value: p, + label: DURATION_LABELS[p].start, + })), + { value: "onPartnerJoin", label: "when a new partner joins" }, + { value: "custom", label: "custom" }, +] satisfies PresetOption[]; + +const END_OPTIONS = [ + { value: "never", label: "never" }, + ...BOUNTY_DURATION_PRESETS.map((p) => ({ + value: p, + label: DURATION_LABELS[p].end, + })), + { value: "custom", label: "custom" }, +] satisfies PresetOption[]; + +const START_DURATION_DATES: Record Date> = { + twoWeeks: (now) => addWeeks(now, 2), + oneMonth: (now) => addMonths(now, 1), + sixMonths: (now) => addMonths(now, 6), +}; + +const DATE_TOLERANCE_MS = 60_000; + +function datesAreClose(a: Date, b: Date, toleranceMs = DATE_TOLERANCE_MS) { + return Math.abs(a.getTime() - b.getTime()) <= toleranceMs; +} + +function isSameCalendarDay(a: Date, b: Date) { + return ( + a.getFullYear() === b.getFullYear() && + a.getMonth() === b.getMonth() && + a.getDate() === b.getDate() + ); +} + +function findDurationPresetByDays(days: number): DurationPreset | null { + return ( + (Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][]).find( + ([, durationDays]) => durationDays === days, + )?.[0] ?? null + ); +} + +function getPresetLabel( + preset: T, + options: PresetOption[], + customDate?: Date | null, + fallback?: string, +) { + if (preset === "custom" && customDate) { + return formatDate(customDate, { month: "short" }); + } + + return options.find((option) => option.value === preset)?.label ?? fallback; +} + +function parsePresets(value: BountyTimingInput): ParsedPresets { + let startPreset: StartPreset; + let customStartsAt: Date | null; + + if (value.startMode === "relative") { + startPreset = "onPartnerJoin"; + customStartsAt = null; + } else { + const now = new Date(); + + if (isSameCalendarDay(value.startsAt, now)) { + startPreset = "today"; + customStartsAt = null; + } else { + const matchedStartPreset = BOUNTY_DURATION_PRESETS.find((preset) => + datesAreClose(value.startsAt, START_DURATION_DATES[preset](now)), + ); + + if (matchedStartPreset) { + startPreset = matchedStartPreset; + customStartsAt = null; + } else { + startPreset = "custom"; + customStartsAt = value.startsAt; + } + } + } + + let endPreset: EndPreset; + let customEndsAt: Date | null; + + if (value.endsAfterDays != null) { + const durationPreset = findDurationPresetByDays(value.endsAfterDays); + + if (durationPreset) { + return { + startPreset, + endPreset: durationPreset, + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + } + + if (!value.endsAt) { + endPreset = "never"; + customEndsAt = null; + } else if (value.startMode === "absolute") { + const matchedEndPreset = ( + Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][] + ).find(([, days]) => + datesAreClose(value.endsAt!, addDays(value.startsAt, days)), + )?.[0]; + + if (matchedEndPreset) { + endPreset = matchedEndPreset; + customEndsAt = null; + } else { + endPreset = "custom"; + customEndsAt = value.endsAt; + } + } else { + endPreset = "custom"; + customEndsAt = value.endsAt; + } + + return { + startPreset, + endPreset, + customStartsAt, + customEndsAt, + customEndsAfterDays: null, + }; +} + +function parsePresetsForEdit(value: BountyTimingInput): ParsedPresets { + if (value.startMode === "relative") { + const startPreset: StartPreset = "onPartnerJoin"; + const customStartsAt = null; + + if (value.endsAfterDays != null) { + const durationPreset = findDurationPresetByDays(value.endsAfterDays); + + if (durationPreset) { + return { + startPreset, + endPreset: durationPreset, + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + + return { + startPreset, + endPreset: "never", + customStartsAt, + customEndsAt: null, + customEndsAfterDays: value.endsAfterDays, + }; + } + + if (value.endsAt) { + return { + startPreset, + endPreset: "custom", + customStartsAt, + customEndsAt: value.endsAt, + customEndsAfterDays: null, + }; + } + + return { + startPreset, + endPreset: "never", + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + + const startPreset: StartPreset = "custom"; + const customStartsAt = value.startsAt; + + if (!value.endsAt) { + return { + startPreset, + endPreset: "never", + customStartsAt, + customEndsAt: null, + customEndsAfterDays: null, + }; + } + + return { + startPreset, + endPreset: "custom", + customStartsAt, + customEndsAt: value.endsAt, + customEndsAfterDays: null, + }; +} + +function parsePresetsFromValue( + value: BountyTimingInput, + isEditing: boolean, +): ParsedPresets { + return isEditing ? parsePresetsForEdit(value) : parsePresets(value); +} + +function CustomDatePickerIcon({ + value, + onChange, +}: { + value: Date | null | undefined; + onChange: (date: Date | null) => void; +}) { + return ( + { + if (!date) { + onChange(null); + return; + } + + const merged = new Date(date); + + if (value) { + merged.setHours( + value.getHours(), + value.getMinutes(), + value.getSeconds(), + value.getMilliseconds(), + ); + } + + onChange(merged); + }} + align="start" + showYearNavigation + trigger={() => ( + + )} + /> + ); +} + +interface BountyDurationProps { + value: BountyTimingInput; + onChange: (value: BountyTimingInput) => void; + isEditing?: boolean; +} + +export function BountyDuration({ + value, + onChange, + isEditing = false, +}: BountyDurationProps) { + const initialPresets = parsePresetsFromValue(value, isEditing); + + const [startPreset, setStartPreset] = useState( + initialPresets.startPreset, + ); + + const [endPreset, setEndPreset] = useState( + initialPresets.endPreset, + ); + + const [customStartsAt, setCustomStartsAt] = useState( + initialPresets.customStartsAt, + ); + + const [customEndsAt, setCustomEndsAt] = useState( + initialPresets.customEndsAt, + ); + + const [customEndsAfterDays, setCustomEndsAfterDays] = useState( + initialPresets.customEndsAfterDays, + ); + + useEffect(() => { + const presets = parsePresetsFromValue(value, isEditing); + setStartPreset(presets.startPreset); + setEndPreset(presets.endPreset); + setCustomStartsAt(presets.customStartsAt); + setCustomEndsAt(presets.customEndsAt); + setCustomEndsAfterDays(presets.customEndsAfterDays); + }, [ + isEditing, + value.startMode, + value.startsAt, + value.endsAt, + value.endsAfterDays, + ]); + + const applyTiming = ({ + nextStartPreset = startPreset, + nextEndPreset = endPreset, + nextCustomStartsAt = customStartsAt, + nextCustomEndsAt = customEndsAt, + }: { + nextStartPreset?: StartPreset; + nextEndPreset?: EndPreset; + nextCustomStartsAt?: Date | null; + nextCustomEndsAt?: Date | null; + } = {}) => { + onChange( + resolveBountyTiming({ + startPreset: nextStartPreset, + endPreset: nextEndPreset, + customStartsAt: nextCustomStartsAt, + customEndsAt: nextCustomEndsAt, + }), + ); + }; + + const startLabel = getPresetLabel( + startPreset, + START_OPTIONS, + customStartsAt ?? value.startsAt, + "today", + ); + + const endLabel = + customEndsAfterDays != null + ? `${customEndsAfterDays} days` + : getPresetLabel( + endPreset, + END_OPTIONS, + customEndsAt ?? value.endsAt, + "never", + ); + + const endSuffix = + customEndsAfterDays != null || + (endPreset !== "never" && endPreset !== "custom") + ? value.startMode === "relative" + ? "after joining" + : "from start date" + : null; + + return ( +
+
+ + + Starts{" "} + + + ({ + value: option.value, + text: option.label, + }))} + selectedValue={startPreset} + onSelect={(preset) => { + setStartPreset(preset); + + if (preset === "custom") { + setCustomStartsAt(customStartsAt ?? value.startsAt); + return; + } + + applyTiming({ nextStartPreset: preset }); + }} + /> + + {startPreset === "custom" && ( + { + const nextCustomStartsAt = date ?? null; + setCustomStartsAt(nextCustomStartsAt); + applyTiming({ + nextStartPreset: "custom", + nextCustomStartsAt, + }); + }} + /> + )} + {" "} + and ends{" "} + + + ({ + value: option.value, + text: option.label, + }))} + selectedValue={ + customEndsAfterDays != null ? undefined : endPreset + } + onSelect={(preset) => { + setEndPreset(preset); + setCustomEndsAfterDays(null); + + if (preset === "custom") { + setCustomEndsAt( + customEndsAt ?? + value.endsAt ?? + addWeeks(value.startsAt, 2), + ); + return; + } + + applyTiming({ nextEndPreset: preset }); + }} + /> + + {endPreset === "custom" && ( + { + const nextCustomEndsAt = date ?? null; + setCustomEndsAt(nextCustomEndsAt); + applyTiming({ + nextEndPreset: "custom", + nextCustomEndsAt, + }); + }} + /> + )} + + {endSuffix && {endSuffix}} + +
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx new file mode 100644 index 00000000000..0876e27bf7e --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx @@ -0,0 +1,407 @@ +"use client"; + +import useGroups from "@/lib/swr/use-groups"; +import { usePartnerTags } from "@/lib/swr/use-partner-tags"; +import usePartnersCount from "@/lib/swr/use-partners-count"; +import { GroupExtendedProps } from "@/lib/types"; +import { GROUPS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/groups"; +import { PARTNER_TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/partner-tags"; +import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; +import { + ProgramSheetAccordionContent, + ProgramSheetAccordionItem, + ProgramSheetAccordionTrigger, +} from "@/ui/partners/program-sheet-accordion"; +import { + AnimatedSizeContainer, + Check2, + LoadingSpinner, + Magnifier, + ScrollContainer, + Switch, + Users6, +} from "@dub/ui"; +import { cn, nFormatter } from "@dub/utils"; +import { Command } from "cmdk"; +import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Controller } from "react-hook-form"; +import { useDebounce } from "use-debounce"; +import { useBountyFormContext } from "./bounty-form-context"; + +export function BountyEligibility() { + const { control } = useBountyFormContext(); + + return ( + + Eligibility + +
+ ( + field.onChange(null)} + > + field.onChange(ids)} + /> + + )} + /> + + ( + field.onChange(null)} + > + field.onChange(ids)} + /> + + )} + /> +
+
+
+ ); +} + +function EligibilityToggle({ + title, + enabledDescription, + disabledDescription, + defaultEnabled, + onDisable, + children, +}: { + title: string; + enabledDescription: string; + disabledDescription: string; + defaultEnabled: boolean; + onDisable: () => void; + children: ReactNode; +}) { + const [enabled, setEnabled] = useState(defaultEnabled); + + return ( +
+
+ { + setEnabled(checked); + if (!checked) onDisable(); + }} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+ + {title} + + + {enabled ? enabledDescription : disabledDescription} + +
+
+ + +
+ {enabled &&
{children}
} +
+
+
+ ); +} + +interface EligibilitySelectProps { + selectedIds: string[] | null; + setSelectedIds: (ids: string[] | null) => void; +} + +function GroupsEligibilitySelect({ + selectedIds, + setSelectedIds, +}: EligibilitySelectProps) { + const [search, setSearch] = useState(""); + const [useAsync, setUseAsync] = useState(false); + const [debouncedSearch] = useDebounce(search, 500); + + const { groups } = useGroups({ + query: { + includeExpandedFields: true, + ...(useAsync ? { search: debouncedSearch } : undefined), + }, + }); + + const { groups: selectedGroups } = useGroups({ + query: { + groupIds: selectedIds ?? undefined, + includeExpandedFields: true, + }, + enabled: Boolean(selectedIds?.length), + }); + + // Determine if we should use async loading + useEffect(() => { + setUseAsync( + (prev) => + prev || Boolean(groups && groups.length >= GROUPS_MAX_PAGE_SIZE), + ); + }, [groups]); + + return ( + `${group.name}::${group.slug}`} + renderLeading={(group) => } + renderRight={(group) => ( + + {nFormatter(group.totalPartners, { full: true })} qualify + + )} + /> + ); +} + +function TagsEligibilitySelect({ + selectedIds, + setSelectedIds, +}: EligibilitySelectProps) { + const [search, setSearch] = useState(""); + const [useAsync, setUseAsync] = useState(false); + const [debouncedSearch] = useDebounce(search, 500); + + const { partnerTags } = usePartnerTags({ + query: { ...(useAsync ? { search: debouncedSearch } : undefined) }, + }); + + const { partnerTags: selectedTags } = usePartnerTags({ + query: { ids: selectedIds ?? undefined }, + enabled: Boolean(selectedIds?.length), + }); + + const { partnersCount } = usePartnersCount< + { partnerTagId: string; _count: number }[] + >({ + groupBy: "partnerTagId", + ignoreParams: true, + }); + + const tagCountMap = useMemo(() => { + const map = new Map(); + if (Array.isArray(partnersCount)) { + for (const { partnerTagId, _count } of partnersCount) { + map.set(partnerTagId, _count); + } + } + return map; + }, [partnersCount]); + + // Determine if we should use async loading + useEffect(() => { + setUseAsync( + (prev) => + prev || + Boolean( + partnerTags && partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, + ), + ); + }, [partnerTags]); + + return ( + ( +
+ + {nFormatter(tagCountMap.get(tag.id) ?? 0, { full: true })} +
+ )} + /> + ); +} + +interface BountyEligibilityMultiSelectProps< + T extends { id: string; name: string }, +> { + // The current (optionally search-filtered) list of items, or undefined while loading + items: T[] | undefined; + // The currently selected items, so they remain visible even when not in `items` + selectedItems: T[] | undefined; + selectedIds: string[] | null; + setSelectedIds: (ids: string[] | null) => void; + search: string; + setSearch: (search: string) => void; + // Whether items are searched server-side (search input shouldn't filter locally) + useAsync: boolean; + searchPlaceholder: string; + // Value used for local (cmdk) filtering — defaults to the item name + getItemValue?: (item: T) => string; + // Left-aligned visual rendered before the item name (e.g. a color circle) + renderLeading?: (item: T) => ReactNode; + // Right-aligned content rendered at the end of the row (e.g. a partner count) + renderRight?: (item: T) => ReactNode; +} + +function BountyEligibilityMultiSelect({ + items, + selectedItems, + selectedIds, + setSelectedIds, + search, + setSearch, + useAsync, + searchPlaceholder, + getItemValue, + renderLeading, + renderRight, +}: BountyEligibilityMultiSelectProps) { + const [shouldSort, setShouldSort] = useState(false); + const [sortedItems, setSortedItems] = useState(undefined); + + const sortItems = useCallback( + (items: T[], search: string) => { + return search === "" + ? [ + ...items.filter((i) => selectedIds?.includes(i.id)), + ...items.filter((i) => !selectedIds?.includes(i.id)), + ] + : items; + }, + [selectedIds], + ); + + // Actually sort the items when needed + useEffect(() => { + if (!shouldSort || !items || (selectedIds?.length && !selectedItems)) + return; + + setSortedItems( + sortItems( + [ + ...(selectedItems ?? []), + ...items.filter((i) => !selectedItems?.some((si) => si.id === i.id)), + ], + search, + ), + ); + setShouldSort(false); + }, [shouldSort, items, selectedIds, selectedItems, sortItems, search]); + + // Re-sort when the search-filtered items or selection changes + useEffect(() => setShouldSort(true), [items, selectedIds]); + + return ( + + + + + {sortedItems !== undefined ? ( + <> + {sortedItems.map((item) => { + const checked = Boolean(selectedIds?.includes(item.id)); + + return ( + + setSelectedIds( + selectedIds?.includes(item.id) + ? selectedIds.length === 1 + ? null // Revert to null if there will be no items selected + : selectedIds.filter((id) => id !== item.id) + : [...(selectedIds ?? []), item.id], + ) + } + className={cn( + "flex cursor-pointer select-none items-center gap-3 whitespace-nowrap rounded-md px-3 py-2.5 text-left text-sm text-neutral-700", + "data-[selected=true]:bg-neutral-100", + )} + > +
+ {checked && Checked} + +
+
+ {renderLeading?.(item)} + {item.name} +
+ {renderRight?.(item)} +
+ ); + })} + {!useAsync ? ( + + No matches + + ) : sortedItems.length === 0 ? ( +
+ No matches +
+ ) : null} + + ) : ( + // undefined data / explicit loading state + +
+ +
+
+ )} +
+
+
+ ); +} 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..600916786f4 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 @@ -33,6 +33,7 @@ type ConfirmCreateBountyModalProps = { | "rewardDescription" | "submissionRequirements" | "groups" + | "partnerTags" >; onConfirm: (data: { sendNotificationEmails: boolean }) => Promise; }; @@ -108,7 +109,9 @@ function ConfirmCreateBountyModal({
- {formatDate(bounty.startsAt, { month: "short" })} + {bounty.startsAt + ? formatDate(bounty.startsAt, { month: "short" }) + : "When a partner joins"} {bounty.endsAt && ( <> {" → "} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts index 013e8ecc8ff..951234a3ac4 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts @@ -2,6 +2,7 @@ import { isCurrencyAttribute } from "@/lib/api/workflows/utils"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; +import { resolveBountyTiming } from "@/lib/bounty/bounty-period"; import { BOUNTY_DESCRIPTION_MAX_LENGTH, BOUNTY_MAX_SUBMISSIONS, @@ -17,7 +18,15 @@ import { } from "@/lib/zod/schemas/bounties"; import { formatDate } from "@dub/utils"; import { BountySubmissionFrequency } from "@prisma/client"; -import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; +import { addDays } from "date-fns"; +import { + Dispatch, + SetStateAction, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; import { @@ -30,7 +39,7 @@ const ACCORDION_ITEMS = [ "bounty-type", "bounty-details", "bounty-criteria", - "groups", + "eligibility", ]; const DEFAULT_SOCIAL_METRICS_CRITERIA = { @@ -50,6 +59,38 @@ const resolveSocialMetricsCriteria = ( const isEmpty = (value: unknown) => value === undefined || value === null || value === ""; +function getEffectiveEndsAt({ + startsAt, + endsAt, + endsAfterDays, +}: { + startsAt: Date; + endsAt: Date | null; + endsAfterDays: number | null; +}) { + if (endsAt) { + return endsAt; + } + + if (endsAfterDays != null) { + return addDays(startsAt, endsAfterDays); + } + + return null; +} + +function getSubmissionWindowFromBounty(bounty?: BountyProps): number | null { + if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null; + + const days = Math.ceil( + (new Date(bounty.endsAt).getTime() - + new Date(bounty.submissionsOpenAt).getTime()) / + (1000 * 60 * 60 * 24), + ); + + return days >= 2 && days <= 14 ? days : null; +} + export function useAddEditBountyForm({ bounty, setIsOpen, @@ -60,8 +101,14 @@ export function useAddEditBountyForm({ const { id: workspaceId } = useWorkspace(); const { makeRequest, isSubmitting } = useApiMutation(); - const [hasStartDate, setHasStartDate] = useState(!!bounty?.startsAt); - const [hasEndDate, setHasEndDate] = useState(!!bounty?.endsAt); + const defaultTiming = resolveBountyTiming({ + startPreset: "today", + endPreset: "never", + }); + + const [hasEndDate, setHasEndDate] = useState( + !!bounty?.endsAt || !!bounty?.endsAfterDays, + ); const [openAccordions, setOpenAccordions] = useState(ACCORDION_ITEMS); const [allowedSubmissions, setAllowedSubmissions] = useState( bounty?.maxSubmissions ?? 1, @@ -71,16 +118,8 @@ export function useAddEditBountyForm({ bounty?.submissionFrequency ?? null, ); - const [submissionWindow, setSubmissionWindow] = useState( - () => { - if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null; - const days = Math.ceil( - (new Date(bounty.endsAt).getTime() - - new Date(bounty.submissionsOpenAt).getTime()) / - (1000 * 60 * 60 * 24), - ); - return days >= 2 && days <= 14 ? days : null; - }, + const [submissionWindow, setSubmissionWindow] = useState(() => + getSubmissionWindowFromBounty(bounty), ); const initialSubmissionRequirements = (() => { @@ -106,8 +145,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 @@ -126,6 +167,7 @@ export function useAddEditBountyForm({ : "performance", submissionRequirements: initialSubmissionRequirements, groupIds: bounty?.groups?.map(({ id }) => id) || null, + partnerTagIds: bounty?.partnerTags?.map(({ id }) => id) || null, performanceCondition: bounty?.performanceCondition ? { ...bounty.performanceCondition, @@ -154,6 +196,8 @@ export function useAddEditBountyForm({ const [ startsAt, endsAt, + startMode, + endsAfterDays, rewardAmount, rewardDescription, type, @@ -162,11 +206,14 @@ export function useAddEditBountyForm({ description, performanceCondition, groupIds, + partnerTagIds, rewardType, submissionRequirements, ] = watch([ "startsAt", "endsAt", + "startMode", + "endsAfterDays", "rewardAmount", "rewardDescription", "type", @@ -175,54 +222,64 @@ export function useAddEditBountyForm({ "description", "performanceCondition", "groupIds", + "partnerTagIds", "rewardType", "submissionRequirements", ]); - const handleStartDateToggle = (checked: boolean) => { - setHasStartDate(checked); - if (!checked) { - setValue("startsAt", null, { shouldDirty: true, shouldValidate: true }); - } - }; + const handleTimingChange = useCallback( + ({ + startMode: nextStartMode, + startsAt: nextStartsAt, + endsAt: nextEndsAt, + endsAfterDays: nextEndsAfterDays, + }: ReturnType) => { + setValue("startMode", nextStartMode, { + shouldDirty: true, + shouldValidate: true, + }); - const handleEndDateToggle = (checked: boolean) => { - setHasEndDate(checked); - if (!checked) { - setValue("endsAt", null, { shouldDirty: true, shouldValidate: true }); - setSubmissionWindow(null); - setValue("submissionsOpenAt", null, { shouldDirty: true }); - } - }; + setValue("startsAt", nextStartsAt, { + shouldDirty: true, + shouldValidate: true, + }); - const handleEndDateChange = (date: Date | null) => { - setValue("endsAt", date, { - shouldDirty: true, - shouldValidate: true, - }); - if (date && submissionWindow != null) { - const submissionsOpenAt = new Date(date); - submissionsOpenAt.setDate(submissionsOpenAt.getDate() - submissionWindow); - setValue("submissionsOpenAt", submissionsOpenAt, { + setValue("endsAt", nextEndsAt, { shouldDirty: true, shouldValidate: true, }); - } - }; - const getInitialSubmissionWindow = () => { - if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null; - const days = Math.ceil( - (new Date(bounty.endsAt).getTime() - - new Date(bounty.submissionsOpenAt).getTime()) / - (1000 * 60 * 60 * 24), - ); - return days >= 2 && days <= 14 ? days : null; - }; + setValue("endsAfterDays", nextEndsAfterDays, { + shouldDirty: true, + shouldValidate: true, + }); + + setHasEndDate( + Boolean(nextEndsAt) || + (nextStartMode === "relative" && Boolean(nextEndsAfterDays)), + ); + + if (!nextEndsAt) { + setSubmissionWindow(null); + setValue("submissionsOpenAt", null, { shouldDirty: true }); + } else if (submissionWindow != null) { + const submissionsOpenAt = new Date(nextEndsAt); + submissionsOpenAt.setDate( + submissionsOpenAt.getDate() - submissionWindow, + ); + + setValue("submissionsOpenAt", submissionsOpenAt, { + shouldDirty: true, + shouldValidate: true, + }); + } + }, + [setValue, submissionWindow], + ); const handleSubmissionWindowToggle = (checked: boolean) => { if (checked) { - const val = getInitialSubmissionWindow() ?? 2; + const val = getSubmissionWindowFromBounty(bounty) ?? 2; setSubmissionWindow(val); if (endsAt) { const submissionsOpenAt = new Date(endsAt); @@ -280,11 +337,21 @@ export function useAddEditBountyForm({ } }; + const effectiveEndsAt = useMemo( + () => + getEffectiveEndsAt({ + startsAt: startsAt ? new Date(startsAt) : new Date(), + endsAt: endsAt ? new Date(endsAt) : null, + endsAfterDays: endsAfterDays ?? null, + }), + [startsAt, endsAt, endsAfterDays], + ); + const maxAllowedSubmissions = useMemo(() => { - if (!submissionFrequency || !endsAt) return BOUNTY_MAX_SUBMISSIONS; + if (!submissionFrequency || !effectiveEndsAt) return BOUNTY_MAX_SUBMISSIONS; const start = startsAt ? new Date(startsAt) : new Date(); - const end = new Date(endsAt); + const end = effectiveEndsAt; let count = 0; for (let i = 0; i < BOUNTY_MAX_SUBMISSIONS; i++) { @@ -298,7 +365,7 @@ export function useAddEditBountyForm({ } return count; - }, [submissionFrequency, startsAt, endsAt]); + }, [submissionFrequency, startsAt, effectiveEndsAt]); useEffect(() => { if (allowedSubmissions > maxAllowedSubmissions) { @@ -384,17 +451,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."; } } @@ -510,6 +581,7 @@ export function useAddEditBountyForm({ bounty, startsAt, endsAt, + endsAfterDays, submissionWindow, rewardAmount, rewardDescription, @@ -535,6 +607,11 @@ export function useAddEditBountyForm({ ...data } = form.getValues(); + // Relative bounties start when a partner joins, so startsAt must be null + if (data.startMode === "relative") { + data.startsAt = null; + } + const rawRewardAmount = data.rewardAmount; const numAmount = typeof rawRewardAmount === "number" && !Number.isNaN(rawRewardAmount) @@ -622,12 +699,13 @@ export function useAddEditBountyForm({ : performanceCondition, }) : name || "New bounty", - startsAt: startsAt || new Date(), - endsAt: endsAt || null, + startsAt: startMode === "relative" ? null : startsAt || new Date(), + endsAt: effectiveEndsAt, rewardAmount: rewardAmount ? rewardAmount * 100 : null, rewardDescription: rewardDescription || null, submissionRequirements: submissionRequirements ?? null, groups: groupIds?.map((id) => ({ id })) || [], + partnerTags: partnerTagIds?.map((id) => ({ id })) || [], } : undefined, onConfirm: async ({ sendNotificationEmails }) => { @@ -645,10 +723,7 @@ export function useAddEditBountyForm({ return { form, - hasStartDate, - setHasStartDate, hasEndDate, - handleEndDateToggle, openAccordions, setOpenAccordions, type, @@ -660,8 +735,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..1fa659143b3 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,16 +1,25 @@ +import { BOUNTY_DURATION_DAYS } from "@/lib/bounty/bounty-period"; import useGroups from "@/lib/swr/use-groups"; +import { usePartnerTags } from "@/lib/swr/use-partner-tags"; 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 { formatDate, nFormatter, pluralize } from "@dub/utils"; +import { cn, formatDate, nFormatter, pluralize } from "@dub/utils"; import Link from "next/link"; import { useMemo } from "react"; +const tagPillClassName = + "bg-bg-inverted/5 text-content-default inline-flex min-h-6 items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-tight"; + export function BountyCard({ bounty }: { bounty: BountyListProps }) { const { slug: workspaceSlug, isOwner } = useWorkspace(); @@ -19,6 +28,7 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { }); const { groups } = useGroups(); + const { partnerTags } = usePartnerTags(); const eligibleGroups = useMemo(() => { if (!groups || bounty.groups.length === 0) { @@ -29,46 +39,50 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { .filter((g): g is NonNullable => g !== undefined); }, [groups, bounty.groups]); + const eligibleTags = useMemo(() => { + if (!partnerTags || bounty.partnerTags.length === 0) { + return []; + } + + return bounty.partnerTags + .map((bountyTag) => partnerTags.find((t) => t.id === bountyTag.id)) + .filter((t): t is NonNullable => t !== undefined); + }, [partnerTags, bounty.partnerTags]); + return ( -
+
-
-
- -
+
+
+
+ +
-
- {bounty.submissionsCountData && - bounty.submissionsCountData.submitted > 0 && ( - +
+ {bounty.submissionsCountData && + bounty.submissionsCountData.submitted > 0 && ( + + )} + {bounty.endsAt && new Date(bounty.endsAt) < new Date() && ( + )} - {bounty.endsAt && new Date(bounty.endsAt) < new Date() && ( - - )} +
-
+

{bounty.name}

- - {formatDate(bounty.startsAt, { month: "short" })} - {bounty.endsAt && ( - <> - {" → "} - {formatDate(bounty.endsAt, { month: "short" })} - - )} - + {getBountyPeriodLabel(bounty)}
-
- {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.startMode === "relative" + ? "New partners only" + : "All partners"} + +
+ +
+ +
+ {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}` + : ""} + +
+
) : ( +
+ )} + + {bounty.partnerTags.length > 0 && ( <> - - {nFormatter(bounty.submissionsCountData?.total ?? 0, { - full: true, - })} - {" "} - of{" "} - - {nFormatter(totalPartners, { full: true })} - {" "} - {pluralize("partner", totalPartners)}{" "} - {bounty.type === "performance" ? "completed" : "submitted"} + · + {eligibleTags.length > 0 ? ( + 1 + ? { + content: ( + + {eligibleTags.map((tag) => ( + + {tag.name} + + ))} + + ), + } + : undefined + } + > +
+ + {eligibleTags[0].name} + + {eligibleTags.length > 1 && ( + + +{eligibleTags.length - 1} + + )} +
+
+ ) : ( +
+ )} )}
- -
- - {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}` - : ""} - -
-
- ) : ( -
- )} -
+ +
); } +function BountySubmissionStatsFooter({ + bounty, + totalPartners, + loading, +}: { + bounty: BountyListProps; + totalPartners: number; + loading: boolean; +}) { + const submissionCount = bounty.submissionsCountData?.total ?? 0; + const actionLabel = bounty.type === "performance" ? "completed" : "submitted"; + + const progress = + totalPartners > 0 + ? Math.min(Math.max((submissionCount / totalPartners) * 100, 0), 100) + : 0; + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + return ( +
+ + {totalPartners === 0 ? ( + <> + 0 {actionLabel} + + ) : submissionCount === totalPartners ? ( + <> + All{" "} + + {nFormatter(totalPartners, { full: true })} + {" "} + {actionLabel} + + ) : ( + <> + + {nFormatter(submissionCount, { full: true })} + {" "} + of{" "} + + {nFormatter(totalPartners, { full: true })} + {" "} + {actionLabel} + + )} + +
+ ); +} + +function getBountyPeriodLabel(bounty: BountyListProps): string { + if (bounty.startMode === "relative") { + const { endsAfterDays } = bounty; + + if (endsAfterDays === BOUNTY_DURATION_DAYS.twoWeeks) { + return "2 weeks after joining"; + } + + if (endsAfterDays === BOUNTY_DURATION_DAYS.oneMonth) { + return "1 month after joining"; + } + + if (endsAfterDays === BOUNTY_DURATION_DAYS.sixMonths) { + return "6 months after joining"; + } + + if (endsAfterDays != null) { + return `${endsAfterDays} days after joining`; + } + + return "When a partner joins"; + } + + if (bounty.startsAt) { + let label = formatDate(bounty.startsAt, { month: "short" }); + + if (bounty.endsAt) { + label += ` → ${formatDate(bounty.endsAt, { month: "short" })}`; + } + + return label; + } + + return "When a partner joins"; +} + function SubmissionsCountBadge({ count }: { count: number }) { return (
@@ -171,6 +304,7 @@ function SubmissionsCountBadge({ count }: { count: number }) {
); } + function BountyEndedBadge({ endsAt }: { endsAt: Date }) { return (
@@ -181,23 +315,29 @@ function BountyEndedBadge({ endsAt }: { endsAt: Date }) { export function BountyCardSkeleton() { return ( -
-
+
+
-
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/lib/actions/partners/bulk-approve-partners.ts b/apps/web/lib/actions/partners/bulk-approve-partners.ts index 0466c3562d9..e5b5e12b741 100644 --- a/apps/web/lib/actions/partners/bulk-approve-partners.ts +++ b/apps/web/lib/actions/partners/bulk-approve-partners.ts @@ -62,6 +62,7 @@ export const bulkApprovePartnersAction = authActionClient data: { status: "approved", createdAt: now, + groupJoinedAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, diff --git a/apps/web/lib/actions/partners/bulk-invite-partners.ts b/apps/web/lib/actions/partners/bulk-invite-partners.ts index 496b531a3b8..e92ba33b800 100644 --- a/apps/web/lib/actions/partners/bulk-invite-partners.ts +++ b/apps/web/lib/actions/partners/bulk-invite-partners.ts @@ -131,6 +131,7 @@ export const bulkInvitePartnersAction = authActionClient partnerId: partner.id, status: "invited", groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts b/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts index 4d9f1287b8d..db6fd53fefd 100644 --- a/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts +++ b/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts @@ -3,6 +3,7 @@ import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { triggerDraftBountySubmissionCreation } from "@/lib/bounty/api/trigger-draft-bounty-submissions"; import { prisma } from "@/lib/prisma"; import { recordLink } from "@/lib/tinybird"; import { updatePartnerTagsSchema } from "@/lib/zod/schemas/partner-tags"; @@ -94,6 +95,15 @@ export const updateProgramPartnerTagsAction = authActionClient ]); }); + if (addTagIds.length > 0) { + waitUntil( + triggerDraftBountySubmissionCreation({ + programId, + partnerIds, + }), + ); + } + // Sync updated partner tags to Tinybird for analytics (top_partner_tags) waitUntil( (async () => { diff --git a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts index 05b1b83621b..897a803f534 100644 --- a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts +++ b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts @@ -23,7 +23,13 @@ export const uploadBountySubmissionFileAction = authPartnerActionClient const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }); const { signedUrl, destinationUrl } = await getBountySubmissionUploadUrl({ diff --git a/apps/web/lib/api/groups/get-group-or-throw.ts b/apps/web/lib/api/groups/get-group-or-throw.ts index a8c6a80a23e..fd2c6a22e1f 100644 --- a/apps/web/lib/api/groups/get-group-or-throw.ts +++ b/apps/web/lib/api/groups/get-group-or-throw.ts @@ -1,4 +1,4 @@ -import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries"; +import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties"; import { prisma } from "@/lib/prisma"; import { DubApiError } from "../errors"; @@ -56,7 +56,7 @@ export const getGroupOrThrow = async ({ return { ...group, ...(includeBounties && { - bounties: await getGroupBountySummaries({ + bounties: await getGroupBounties({ programId, groupId: group.id, }), diff --git a/apps/web/lib/api/groups/move-partners-to-group.ts b/apps/web/lib/api/groups/move-partners-to-group.ts index bded179de36..e7c76803fa0 100644 --- a/apps/web/lib/api/groups/move-partners-to-group.ts +++ b/apps/web/lib/api/groups/move-partners-to-group.ts @@ -82,6 +82,7 @@ export async function movePartnersToGroup({ }, data: { groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, @@ -118,15 +119,18 @@ export async function movePartnersToGroup({ }, programId, }, - select: { - id: true, - partnerId: true, + include: { partnerGroup: { select: { id: true, name: true, }, }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }, ); @@ -192,7 +196,7 @@ export async function movePartnersToGroup({ notifyPartnerGroupChange({ programId, groupId: group.id, - partnerIds, + programEnrollments: updatedProgramEnrollments, }), trackActivityLog(activityLogInputs), diff --git a/apps/web/lib/api/partners/applications/approve-partner.ts b/apps/web/lib/api/partners/applications/approve-partner.ts index 7065a944538..eec659b2fb8 100644 --- a/apps/web/lib/api/partners/applications/approve-partner.ts +++ b/apps/web/lib/api/partners/applications/approve-partner.ts @@ -79,6 +79,8 @@ export async function approvePartner({ groupId: finalGroupId, }); + const now = new Date(); + await prisma.$transaction(async (tx) => { throwIfPartnersLimitExceeded(program.workspace); @@ -91,7 +93,8 @@ export async function approvePartner({ }, data: { status: "approved", - createdAt: new Date(), + createdAt: now, + groupJoinedAt: now, groupId: group.id, clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, @@ -107,7 +110,7 @@ export async function approvePartner({ id: programEnrollment.applicationId, }, data: { - reviewedAt: new Date(), + reviewedAt: now, rejectionReason: null, rejectionNote: null, userId, diff --git a/apps/web/lib/api/partners/create-and-enroll-partner.ts b/apps/web/lib/api/partners/create-and-enroll-partner.ts index a30ad9585fd..40325e38303 100644 --- a/apps/web/lib/api/partners/create-and-enroll-partner.ts +++ b/apps/web/lib/api/partners/create-and-enroll-partner.ts @@ -158,6 +158,9 @@ export const createAndEnrollPartner = async ({ ...(enrolledAt && { createdAt: enrolledAt, }), + ...(status === "approved" && { + groupJoinedAt: enrolledAt ?? new Date(), + }), }, }, }; diff --git a/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts b/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts index 402abbc67c7..ba83a8d4eda 100644 --- a/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts +++ b/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts @@ -1,6 +1,7 @@ +import { BOUNTY_ICONS } from "@/lib/bounty/constants"; import { formatDiscountDescription } from "@/ui/partners/format-discount-description"; import { formatRewardDescription } from "@/ui/partners/format-reward-description"; -import { BountyType, EventType, Reward } from "@prisma/client"; +import { EventType, Reward } from "@prisma/client"; import { getGroupOrThrow } from "../groups/get-group-or-throw"; import { serializeReward } from "./serialize-reward"; @@ -11,23 +12,20 @@ const REWARD_ICONS: Record = { referral: "https://assets.dub.co/email-assets/icons/nodes-4.png", }; -const BOUNTY_ICONS: Record = { - submission: "https://assets.dub.co/email-assets/icons/heart.png", - performance: "https://assets.dub.co/email-assets/icons/trophy.png", -}; - export async function getGroupRewardsAndBounties({ programId, groupId, + includeBounties = true, }: { programId: string; groupId: string; + includeBounties?: boolean; }) { const group = await getGroupOrThrow({ programId, groupId, includeExpandedFields: true, - includeBounties: true, + includeBounties, }); return { diff --git a/apps/web/lib/api/partners/notify-partner-group-change.ts b/apps/web/lib/api/partners/notify-partner-group-change.ts index f202068025b..d0719fa4769 100644 --- a/apps/web/lib/api/partners/notify-partner-group-change.ts +++ b/apps/web/lib/api/partners/notify-partner-group-change.ts @@ -1,42 +1,86 @@ +import { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility"; +import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties"; +import { BOUNTY_ICONS } from "@/lib/bounty/constants"; import { queueBatchEmail } from "@/lib/email/queue-batch-email"; -import type PartnerGroupChanged from "@dub/email/templates/partner-group-changed"; +import PartnerGroupChanged from "@dub/email/templates/partner-group-changed"; +import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; import { getGroupRewardsAndBounties } from "./get-group-rewards-and-bounties"; import { getPartnerUsers } from "./get-partner-users"; interface NotifyPartnerGroupChangeParams { programId: string; groupId: string; - partnerIds: string[]; + programEnrollments: (Pick< + ProgramEnrollment, + "partnerId" | "groupId" | "createdAt" | "groupJoinedAt" | "status" + > & { + programPartnerTags: Pick[]; + })[]; } // Send email to partners when they are moved to a new group export async function notifyPartnerGroupChange({ programId, groupId, - partnerIds, + programEnrollments, }: NotifyPartnerGroupChangeParams) { - if (partnerIds.length === 0) { + if (programEnrollments.length === 0) { return; } + const partnerIds = programEnrollments.map(({ partnerId }) => partnerId); + const [ { rewards, - bounties, group: { program }, }, partnerUsers, + bounties, ] = await Promise.all([ getGroupRewardsAndBounties({ programId, groupId, + includeBounties: false, }), getPartnerUsers({ partnerIds, }), + + getGroupBounties({ + programId, + groupId, + }), ]); + // Filter eligible bounties for each partner + const partnerBounties = new Map< + string, + { + icon: string; + label: string; + }[] + >(); + + for (const programEnrollment of programEnrollments) { + const eligibleBounties = bounties.filter((bounty) => + isPartnerEligibleForBounty({ + programEnrollment, + bounty, + }), + ); + + if (eligibleBounties.length > 0) { + const formattedBounties = eligibleBounties.map((bounty) => ({ + icon: BOUNTY_ICONS[bounty.type], + label: bounty.name, + })); + + partnerBounties.set(programEnrollment.partnerId, formattedBounties); + } + } + await queueBatchEmail( partnerUsers.map(({ partner, user }) => ({ to: user.email!, @@ -55,7 +99,7 @@ export async function notifyPartnerGroupChange({ email: user.email!, }, rewards, - bounties, + bounties: partnerBounties.get(partner.id) ?? [], }, })), ); diff --git a/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts new file mode 100644 index 00000000000..e00775b3735 --- /dev/null +++ b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts @@ -0,0 +1,37 @@ +import { prisma } from "@/lib/prisma"; +import { PartnerTag } from "@prisma/client"; +import { DubApiError } from "../errors"; + +export async function throwIfInvalidPartnerTagIds({ + programId, + partnerTagIds, +}: { + programId: string; + partnerTagIds: string[] | null | undefined; +}) { + let partnerTags: PartnerTag[] = []; + + if (partnerTagIds && partnerTagIds.length) { + partnerTags = await prisma.partnerTag.findMany({ + where: { + programId, + id: { + in: partnerTagIds, + }, + }, + }); + + const invalidPartnerTagIds = partnerTagIds?.filter( + (partnerTagId) => !partnerTags?.some((tag) => tag.id === partnerTagId), + ); + + if (invalidPartnerTagIds?.length) { + throw new DubApiError({ + code: "unprocessable_entity", + message: `Invalid partner tag IDs detected: ${invalidPartnerTagIds.join(", ")}`, + }); + } + } + + return partnerTags; +} diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts index f52e49ec860..e77adaeb280 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -1,6 +1,13 @@ import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { + bountyEligibilityIncludes, + canPartnerSubmitBounty, +} from "@/lib/bounty/api/bounty-eligibility"; import { prisma } from "@/lib/prisma"; -import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; +import { + WorkflowConditionAttribute, + WorkflowContextExtended, +} from "@/lib/types"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { sendBatchEmail, sendEmail } from "@dub/email"; import BountyCompleted from "@dub/email/templates/bounty-completed"; @@ -28,7 +35,7 @@ export const executeCompleteBountyWorkflow = async ({ context, }: { workflow: Workflow; - context: WorkflowContext; + context: WorkflowContextExtended; }) => { const { condition, action } = parseWorkflowConfig(workflow); @@ -37,8 +44,9 @@ export const executeCompleteBountyWorkflow = async ({ } const { bountyId } = action.data; - const { identity, metrics } = context; - const { partnerId, groupId, customerId, customerFirstSaleAt } = identity; + const { identity, metrics, programEnrollment } = context; + const { customerId, customerFirstSaleAt } = identity; + const { programId, partnerId, groupId } = programEnrollment; if (!groupId) { console.error("Partner groupId not set in the context."); @@ -51,13 +59,24 @@ export const executeCompleteBountyWorkflow = async ({ id: bountyId, }, include: { - program: true, - groups: true, submissions: { where: { partnerId, }, + select: { + id: true, + status: true, + }, + }, + program: { + select: { + id: true, + name: true, + slug: true, + supportEmail: true, + }, }, + ...bountyEligibilityIncludes, }, }); @@ -77,34 +96,20 @@ export const executeCompleteBountyWorkflow = async ({ return; } - const now = new Date(); + const canSubmitBounty = canPartnerSubmitBounty({ + programEnrollment, + bounty, + }); - // 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.`); + if (!canSubmitBounty) { + console.log( + `Partner ${partnerId} is not eligible to submit bounty ${bounty.id}.`, + ); return; } - const { groups, submissions } = bounty; - - // If the bounty is part of a group, check if the partner is in the group - if (groups.length > 0) { - const groupIds = groups.map(({ groupId }) => groupId); - - 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 (submissions.length > 0) { - const submission = submissions[0]; + if (bounty.submissions.length > 0) { + const submission = bounty.submissions[0]; if (submission.status !== "draft") { const reason = terminalStatusReason[submission.status]; @@ -119,6 +124,7 @@ export const executeCompleteBountyWorkflow = async ({ } if ( + bounty.startsAt && bounty.performanceScope === "new" && customerFirstSaleAt && customerFirstSaleAt < bounty.startsAt @@ -156,7 +162,7 @@ export const executeCompleteBountyWorkflow = async ({ }, create: { id: createId({ prefix: "bnty_sub_" }), - programId: bounty.programId, + programId, partnerId, bountyId: bounty.id, periodNumber, diff --git a/apps/web/lib/api/workflows/execute-move-group-workflow.ts b/apps/web/lib/api/workflows/execute-move-group-workflow.ts index df90e3ef904..bada13f9a57 100644 --- a/apps/web/lib/api/workflows/execute-move-group-workflow.ts +++ b/apps/web/lib/api/workflows/execute-move-group-workflow.ts @@ -1,5 +1,8 @@ import { prisma } from "@/lib/prisma"; -import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; +import { + WorkflowConditionAttribute, + WorkflowContextExtended, +} from "@/lib/types"; import { redis } from "@/lib/upstash/redis"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { Workflow } from "@prisma/client"; @@ -12,7 +15,7 @@ export const executeMoveGroupWorkflow = async ({ context, }: { workflow: Workflow; - context: WorkflowContext; + context: WorkflowContextExtended; }) => { const { conditions, action } = parseWorkflowConfig(workflow); @@ -23,8 +26,10 @@ export const executeMoveGroupWorkflow = async ({ return; } - const { identity, metrics } = context; - const { workspaceId, programId, partnerId, groupId } = identity; + const { identity, metrics, programEnrollment } = context; + const { workspaceId } = identity; + const { programId, partnerId, groupId, groupMoveDisabledAt } = + programEnrollment; if (!groupId) { console.error("Partner groupId not set in the context. Skipping.."); @@ -34,20 +39,20 @@ export const executeMoveGroupWorkflow = async ({ const { groupId: newGroupId } = action.data; // Fetch program enrollment to get fresh groupId - const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({ - where: { - partnerId_programId: { - partnerId, - programId, + const programEnrollmentRefreshed = + await prisma.programEnrollment.findUniqueOrThrow({ + where: { + partnerId_programId: { + partnerId, + programId, + }, }, - }, - select: { - groupId: true, - groupMoveDisabledAt: true, - }, - }); + select: { + groupId: true, + }, + }); - if (programEnrollment.groupId === newGroupId) { + if (programEnrollmentRefreshed.groupId === newGroupId) { console.log( `Partner ${partnerId} is already in target group ${newGroupId}. Skipping..`, ); @@ -55,7 +60,7 @@ export const executeMoveGroupWorkflow = async ({ } // If the partner has group move rules disabled, skip the workflow - if (programEnrollment.groupMoveDisabledAt) { + if (groupMoveDisabledAt) { console.log( `Partner ${partnerId} has group move rules disabled. Skipping..`, ); diff --git a/apps/web/lib/api/workflows/execute-workflows.ts b/apps/web/lib/api/workflows/execute-workflows.ts index aa209ab8d08..cf4a831d071 100644 --- a/apps/web/lib/api/workflows/execute-workflows.ts +++ b/apps/web/lib/api/workflows/execute-workflows.ts @@ -1,6 +1,10 @@ import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { prisma } from "@/lib/prisma"; -import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; +import { + WorkflowConditionAttribute, + WorkflowContext, + WorkflowContextExtended, +} from "@/lib/types"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; import { Workflow } from "@prisma/client"; import { executeCompleteBountyWorkflow } from "./execute-complete-bounty-workflow"; @@ -11,7 +15,7 @@ import { parseWorkflowConfig } from "./parse-workflow-config"; interface WorkflowActionHandler { execute(params: { workflow: Workflow; - context: WorkflowContext; + context: WorkflowContextExtended; }): Promise; } @@ -128,8 +132,13 @@ export async function executeWorkflows({ }, }, select: { + programId: true, partnerId: true, groupId: true, + groupJoinedAt: true, + createdAt: true, + status: true, + groupMoveDisabledAt: true, links: { select: { clicks: true, @@ -139,6 +148,11 @@ export async function executeWorkflows({ saleAmount: true, }, }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }), @@ -176,13 +190,10 @@ export async function executeWorkflows({ const { totalLeads, totalSaleAmount, totalConversions } = aggregatePartnerLinksStats(programEnrollment.links); - const workflowContext: WorkflowContext = { + const workflowContext: WorkflowContextExtended = { trigger, reason, - identity: { - ...identity, - groupId: programEnrollment.groupId, - }, + identity, metrics: { ...metrics, aggregated: { @@ -192,6 +203,7 @@ export async function executeWorkflows({ commissions: totalCommissions._sum.earnings ?? 0, }, }, + programEnrollment, }; for (const { workflow, config } of filteredWorkflows) { diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts new file mode 100644 index 00000000000..249500f0741 --- /dev/null +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -0,0 +1,307 @@ +import { DubApiError } from "@/lib/api/errors"; +import { + Bounty, + BountyGroup, + BountyPartnerTag, + Prisma, + ProgramEnrollment, + ProgramPartnerTag, +} from "@prisma/client"; +import { + getEffectiveBountyPeriod, + isBountyExpired, + isBountyStarted, +} from "../bounty-period"; + +type PartnerBountyEligibilityParams = { + programEnrollment: Pick< + ProgramEnrollment, + "groupId" | "createdAt" | "groupJoinedAt" | "status" + > & { + programPartnerTags: Pick[]; + }; + bounty: Pick< + Bounty, + "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" | "id" + > & { + groups: Pick[]; + partnerTags: Pick[]; + }; + // Fallback for enrollments without an explicit group (program.defaultGroupId) + defaultGroupId?: string | null; +}; + +export function buildBountyEligibilityWhere({ + groupId, + partnerTagIds, +}: { + groupId: string | undefined; + partnerTagIds: string[]; +}): Prisma.BountyWhereInput { + return { + AND: [ + { + OR: [ + { + groups: { + none: {}, + }, + }, + ...(groupId + ? [ + { + groups: { + some: { + groupId, + }, + }, + }, + ] + : []), + ], + }, + { + OR: [ + { + partnerTags: { + none: {}, + }, + }, + ...(partnerTagIds.length > 0 + ? [ + { + partnerTags: { + some: { + partnerTagId: { + in: partnerTagIds, + }, + }, + }, + }, + ] + : []), + ], + }, + ], + }; +} + +// Relative bounties start when a partner joins (no startsAt filter). +// Absolute bounties must have started and not expired. +export function buildActiveBountyPeriodWhere(): Prisma.BountyWhereInput { + const now = new Date(); + + return { + archivedAt: null, + OR: [ + { + startMode: "relative", + }, + { + startMode: "absolute", + startsAt: { + lt: now, + }, + OR: [ + { + endsAt: null, + }, + { + endsAt: { + gt: now, + }, + }, + ], + }, + ], + }; +} + +export function isPartnerEligibleForBounty({ + programEnrollment, + bounty, + defaultGroupId, +}: PartnerBountyEligibilityParams): boolean { + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + + const partnerGroupId = programEnrollment.groupId || defaultGroupId; + const partnerTagIds = programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); + + // No restrictions + if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { + return true; + } + + // Group restrictions + const inGroup = + bountyGroupIds.length === 0 || + (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); + + // Tag restrictions + const hasTag = + bountyTagIds.length === 0 || + partnerTagIds.some((id) => bountyTagIds.includes(id)); + + return Boolean(inGroup && hasTag); +} + +export function canPartnerSubmitBounty({ + programEnrollment, + bounty, + defaultGroupId, +}: PartnerBountyEligibilityParams): boolean { + // Only approved partners can submit bounties + if (programEnrollment.status !== "approved") { + console.log( + `Partner enrollment status "${programEnrollment.status}" is not allowed to submit bounty ${bounty.id}.`, + ); + return false; + } + + const isEligible = isPartnerEligibleForBounty({ + programEnrollment, + bounty, + defaultGroupId, + }); + + if (!isEligible) { + console.log( + `Partner is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups or partner tags.`, + ); + return false; + } + + if (bounty.archivedAt) { + console.log(`Bounty ${bounty.id} is archived.`); + return false; + } + + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + if (!isBountyStarted(startsAt)) { + console.log(`Bounty ${bounty.id} has not started yet.`); + return false; + } + + if (isBountyExpired(endsAt)) { + console.log(`Bounty ${bounty.id} has ended.`); + return false; + } + + return true; +} + +// Throws 404 (not 400) to avoid revealing bounties the partner shouldn't see. +// A partner with a submission can always view the bounty, even if they are no +// longer eligible (e.g. moved out of an eligible group) or it was archived. +// Expired bounties stay viewable. +export function throwIfPartnerCannotViewBounty({ + programEnrollment, + bounty, + defaultGroupId, + hasSubmission, +}: PartnerBountyEligibilityParams & { hasSubmission: boolean }) { + if (hasSubmission) { + return; + } + + const notFoundError = new DubApiError({ + code: "not_found", + message: "Bounty not found.", + }); + + const isEligible = isPartnerEligibleForBounty({ + programEnrollment, + bounty, + defaultGroupId, + }); + + if (!isEligible) { + throw notFoundError; + } + + const { startsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + if (!isBountyStarted(startsAt)) { + throw notFoundError; + } + + if (bounty.archivedAt) { + throw notFoundError; + } +} + +export function throwIfPartnerCannotSubmitBounty({ + programEnrollment, + bounty, + defaultGroupId, +}: PartnerBountyEligibilityParams) { + // Only approved partners can submit bounties + if (programEnrollment.status !== "approved") { + throw new DubApiError({ + code: "bad_request", + message: "You are not allowed to submit a bounty for this program.", + }); + } + + const isEligible = isPartnerEligibleForBounty({ + programEnrollment, + bounty, + defaultGroupId, + }); + + if (!isEligible) { + throw new DubApiError({ + code: "bad_request", + message: "You are not eligible for this bounty.", + }); + } + + if (bounty.archivedAt) { + throw new DubApiError({ + code: "bad_request", + message: "This bounty is archived.", + }); + } + + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + if (!isBountyStarted(startsAt)) { + throw new DubApiError({ + code: "bad_request", + message: "This bounty has not started yet.", + }); + } + + if (isBountyExpired(endsAt)) { + throw new DubApiError({ + code: "bad_request", + message: "This bounty has ended.", + }); + } +} + +export const bountyEligibilityIncludes = { + groups: { + select: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, +} satisfies Prisma.BountyInclude; diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index e652689e869..ff3936f575f 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -25,7 +25,13 @@ import { import { waitUntil } from "@vercel/functions"; import { formatDistanceToNow, isBefore } from "date-fns"; import * as z from "zod/v4"; +import { getEffectiveBountyPeriod } from "../bounty-period"; import { SOCIAL_URL_HOST_TO_PLATFORM } from "../social-content"; +import { + bountyEligibilityIncludes, + throwIfPartnerCannotSubmitBounty, +} from "./bounty-eligibility"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; type CreateBountySubmissionParams = z.infer< typeof createBountySubmissionInputSchema @@ -35,8 +41,17 @@ type CreateBountySubmissionParams = z.infer< type BountyWithRelations = Prisma.BountyGetPayload<{ include: { - groups: true; submissions: true; + groups: { + select: { + groupId: true; + }; + }; + partnerTags: { + select: { + partnerTagId: true; + }; + }; }; }>; @@ -57,7 +72,13 @@ export class BountySubmissionHandler { private submissions: BountySubmission[]; private submissionData: Partial; private programEnrollment: Prisma.ProgramEnrollmentGetPayload<{ - include: {}; + include: { + programPartnerTags: { + select: { + partnerTagId: true; + }; + }; + }; }>; constructor(params: CreateBountySubmissionParams) { @@ -99,26 +120,41 @@ export class BountySubmissionHandler { getProgramEnrollmentOrThrow({ partnerId: this.partner.id, programId: this.programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }), - prisma.bounty.findUniqueOrThrow({ - where: { - id: this.bountyId, - }, + getBountyOrThrow({ + bountyId: this.bountyId, + programId: this.programId, include: { - groups: true, submissions: { where: { partnerId: this.partner.id, }, }, + ...bountyEligibilityIncludes, }, }), ]); + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment, + bounty, + }); + + this.bounty = { + ...bounty, + startsAt, + endsAt, + }; + this.programEnrollment = programEnrollment; - this.bounty = bounty; this.submissions = bounty.submissions; } @@ -156,7 +192,7 @@ export class BountySubmissionHandler { // Multi-submission WITH frequency — time-gated const currentPeriod = getCurrentPeriodNumber({ - startsAt: this.bounty.startsAt, + startsAt: this.bounty.startsAt!, endsAt: this.bounty.endsAt, submissionFrequency: this.bounty.submissionFrequency, maxSubmissions: this.bounty.maxSubmissions, @@ -186,7 +222,7 @@ export class BountySubmissionHandler { // Validate the period has started const periodStart = addFrequency({ - date: this.bounty.startsAt, + date: this.bounty.startsAt!, frequency: this.bounty.submissionFrequency, amount: periodNumber - 1, }); @@ -210,19 +246,10 @@ export class BountySubmissionHandler { // Validate the eligibility of the submission private validateEligibility() { - if (!["approved", "pending"].includes(this.programEnrollment.status)) { - 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.", - }); - } + throwIfPartnerCannotSubmitBounty({ + programEnrollment: this.programEnrollment, + bounty: this.bounty, + }); // Check existing submission for this period const existingSubmission = this.submissions.find( @@ -243,44 +270,8 @@ 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", diff --git a/apps/web/lib/bounty/api/get-bounties-by-groups.ts b/apps/web/lib/bounty/api/get-bounties-by-groups.ts deleted file mode 100644 index 68dec8dfce5..00000000000 --- a/apps/web/lib/bounty/api/get-bounties-by-groups.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import { Bounty } from "@prisma/client"; - -export async function getBountiesByGroups({ - programId, - groupIds, -}: { - programId: string; - groupIds: string[]; -}) { - const bounties = await prisma.bounty.findMany({ - where: { - programId, - AND: [ - { - OR: [ - { groups: { none: {} } }, - { groups: { some: { groupId: { in: groupIds } } } }, - ], - }, - ], - }, - include: { - groups: true, - }, - }); - - const bountiesByGroups: Record = {}; - - // Note: global bounties are not included here - for (const groupId of groupIds) { - bountiesByGroups[groupId] = bounties.filter((bounty) => - bounty.groups.some((g) => g.groupId === groupId), - ); - } - - return bountiesByGroups; -} 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..d937ed15550 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -1,45 +1,62 @@ -import { - aggregatePartnerLinksStats, - PartnerLink, -} from "@/lib/partners/aggregate-partner-links-stats"; +import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; -import { Program, ProgramEnrollment } from "@prisma/client"; +import { + Link, + Program, + ProgramEnrollment, + ProgramPartnerTag, +} from "@prisma/client"; import * as z from "zod/v4"; +import { getEffectiveBountyPeriod } from "../bounty-period"; +import { + buildActiveBountyPeriodWhere, + buildBountyEligibilityWhere, +} from "./bounty-eligibility"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, - "groupId" | "partnerId" | "totalCommissions" + "groupId" | "partnerId" | "totalCommissions" | "groupJoinedAt" | "createdAt" > & { - links: PartnerLink[]; + programPartnerTags: Pick[]; + links: Pick< + Link, + "clicks" | "leads" | "conversions" | "sales" | "saleAmount" + >[]; program: Pick; }; -export async function getBountiesForPartner( - params: GetBountiesForPartnerParams, -) { - const { groupId, partnerId, totalCommissions, program, links } = params; - - const now = new Date(); +export async function getBountiesForPartner({ + partnerId, + groupId, + totalCommissions, + createdAt, + groupJoinedAt, + program, + links, + programPartnerTags, +}: GetBountiesForPartnerParams) { + const partnerTagIds = programPartnerTags.map( + ({ partnerTagId }) => partnerTagId, + ); 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 OR: [ { - groups: { - none: {}, - }, + ...buildActiveBountyPeriodWhere(), + ...buildBountyEligibilityWhere({ + groupId: groupId || program.defaultGroupId, + partnerTagIds, + }), }, + // Bounties the partner has a submission on stay visible even if the + // partner is no longer eligible or the bounty was archived { - groups: { + submissions: { some: { - groupId: groupId || program.defaultGroupId, + partnerId, }, }, }, @@ -72,13 +89,28 @@ export async function getBountiesForPartner( const partnerLinkStats = aggregatePartnerLinksStats(links); return z.array(PartnerBountySchema).parse( - bounties.map((bounty) => ({ - ...bounty, - performanceCondition: bounty.workflow?.triggerConditions?.[0] || null, - partner: { - ...partnerLinkStats, - totalCommissions, - }, - })), + bounties.map((bounty) => { + const performanceCondition = + bounty.workflow?.triggerConditions?.[0] || null; + + const { startsAt, endsAt } = getEffectiveBountyPeriod({ + programEnrollment: { + createdAt, + groupJoinedAt, + }, + bounty, + }); + + return { + ...bounty, + startsAt, + endsAt, + performanceCondition, + partner: { + ...partnerLinkStats, + totalCommissions, + }, + }; + }), ); } diff --git a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts index a6432aee6d7..ca51a35f4c7 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,23 +1,36 @@ import { DubApiError } from "@/lib/api/errors"; -import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { ratelimit } from "@/lib/upstash"; import { submissionRequirementsSchema } from "@/lib/zod/schemas/bounties"; import { nanoid, R2_URL } from "@dub/utils"; -import { ProgramEnrollment } from "@prisma/client"; +import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; +import { + bountyEligibilityIncludes, + throwIfPartnerCannotSubmitBounty, +} from "./bounty-eligibility"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; const MAX_ATTEMPTS = 25; const CACHE_KEY_PREFIX = "bounty:submission:file:upload"; +type ProgramEnrollmentWithPartnerTags = Pick< + ProgramEnrollment, + | "programId" + | "partnerId" + | "groupId" + | "createdAt" + | "groupJoinedAt" + | "status" +> & { + programPartnerTags: Pick[]; +}; + type GetBountySubmissionUploadUrlParams = { bountyId: string; fileName: string; contentType: string; contentLength: number; - programEnrollment: Pick< - ProgramEnrollment, - "programId" | "partnerId" | "groupId" - >; + programEnrollment: ProgramEnrollmentWithPartnerTags; }; const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024; @@ -74,68 +87,18 @@ 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, - groups: { - select: { - groupId: true, - }, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, + include: { + ...bountyEligibilityIncludes, }, }); - 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) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is no longer available.", - }); - } - - if (bounty.archivedAt) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is archived.", - }); - } + throwIfPartnerCannotSubmitBounty({ + programEnrollment, + bounty, + }); if (bounty.type === "performance") { throw new DubApiError({ 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..b22c93b2261 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, @@ -36,7 +38,19 @@ export const getBountyWithDetails = async ({ WHERE bountyId = b.id ), JSON_ARRAY() - ) AS \`groups\` + ) AS \`groups\`, + + -- Bounty partner tags + COALESCE( + ( + SELECT JSON_ARRAYAGG( + JSON_OBJECT('id', partnerTagId) + ) + FROM BountyPartnerTag + WHERE bountyId = b.id + ), + JSON_ARRAY() + ) AS \`partnerTags\` FROM Bounty b LEFT JOIN Workflow wf ON wf.id = b.workflowId @@ -63,6 +77,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, @@ -73,5 +89,6 @@ export const getBountyWithDetails = async ({ performanceScope, performanceCondition, groups: bounty.groups.filter((group) => group !== null) ?? [], + partnerTags: bounty.partnerTags.filter((tag) => tag !== null) ?? [], }; }; diff --git a/apps/web/lib/bounty/api/get-group-bounties.ts b/apps/web/lib/bounty/api/get-group-bounties.ts new file mode 100644 index 00000000000..8b8a28a4995 --- /dev/null +++ b/apps/web/lib/bounty/api/get-group-bounties.ts @@ -0,0 +1,50 @@ +import { prisma } from "@/lib/prisma"; +import { + buildActiveBountyPeriodWhere, + buildBountyEligibilityWhere, +} from "./bounty-eligibility"; + +// Get active bounties for a given group +export async function getGroupBounties({ + programId, + groupId, +}: { + programId: string; + groupId: string; +}) { + const bounties = await prisma.bounty.findMany({ + where: { + programId, + ...buildActiveBountyPeriodWhere(), + ...buildBountyEligibilityWhere({ + groupId, + partnerTagIds: [], // No partner context + }), + }, + select: { + id: true, + name: true, + type: true, + startsAt: true, + endsAt: true, + endsAfterDays: true, + startMode: true, + archivedAt: true, + groups: { + select: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, + }, + orderBy: { + startsAt: "asc", + }, + }); + + return bounties; +} diff --git a/apps/web/lib/bounty/api/get-group-bounty-summaries.ts b/apps/web/lib/bounty/api/get-group-bounty-summaries.ts deleted file mode 100644 index 8acc655367f..00000000000 --- a/apps/web/lib/bounty/api/get-group-bounty-summaries.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import { BountyType } from "@prisma/client"; - -type BountyEligibilityCandidate = { - id: string; - name: string | null; - type: BountyType; - startsAt: Date; - endsAt: Date | null; - archivedAt: Date | null; - groups: { groupId: string }[]; -}; - -export function filterActiveGroupBounties( - bounties: BountyEligibilityCandidate[], - { - groupId, - now = new Date(), - }: { - groupId: string; - now?: Date; - }, -) { - return bounties.filter((bounty) => { - if (bounty.archivedAt) { - return false; - } - - if (bounty.startsAt > now) { - return false; - } - - if (bounty.endsAt && bounty.endsAt <= now) { - return false; - } - - return ( - bounty.groups.length === 0 || - bounty.groups.some((group) => group.groupId === groupId) - ); - }); -} - -export async function getGroupBountySummaries({ - programId, - groupId, - now = new Date(), -}: { - programId: string; - groupId: string; - now?: Date; -}) { - const bounties = await prisma.bounty.findMany({ - where: { - programId, - }, - select: { - id: true, - name: true, - type: true, - startsAt: true, - endsAt: true, - archivedAt: true, - groups: { - select: { - groupId: true, - }, - }, - }, - orderBy: { - startsAt: "asc", - }, - }); - - return filterActiveGroupBounties(bounties, { groupId, now }).map( - (bounty) => ({ - id: bounty.id, - name: bounty.name || "Untitled bounty", - type: bounty.type, - }), - ); -} diff --git a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts index 068653dd8ea..90dfdcd0a17 100644 --- a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts +++ b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts @@ -1,8 +1,10 @@ import { qstash } from "@/lib/cron"; import { prisma } from "@/lib/prisma"; import { APP_DOMAIN_WITH_NGROK } from "@dub/utils"; -import { Bounty } from "@prisma/client"; -import { getBountiesByGroups } from "./get-bounties-by-groups"; +import { + bountyEligibilityIncludes, + canPartnerSubmitBounty, +} from "./bounty-eligibility"; // Trigger the creation of draft submissions for performance bounties that uses lifetime stats for the given partners export async function triggerDraftBountySubmissionCreation({ @@ -12,93 +14,87 @@ export async function triggerDraftBountySubmissionCreation({ programId: string; partnerIds: string[]; }) { - const programEnrollments = await prisma.programEnrollment.findMany({ - where: { - partnerId: { - in: partnerIds, + const [program, programEnrollments] = await Promise.all([ + prisma.program.findUnique({ + where: { + id: programId, }, - programId, - }, - select: { - partnerId: true, - groupId: true, - }, - }); + select: { + defaultGroupId: true, + }, + }), - if (programEnrollments.length === 0) { + prisma.programEnrollment.findMany({ + where: { + partnerId: { + in: partnerIds, + }, + programId, + }, + select: { + partnerId: true, + groupId: true, + createdAt: true, + status: true, + groupJoinedAt: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, + }), + ]); + + if (!program || programEnrollments.length === 0) { return; } - const groupIds = [ - ...new Set( - programEnrollments - .map(({ groupId }) => groupId) - .filter((id): id is string => id !== null), - ), - ]; - - const bountiesByGroups = await getBountiesByGroups({ - programId, - groupIds, + const bounties = await prisma.bounty.findMany({ + where: { + programId, + type: "performance", + performanceScope: "lifetime", + archivedAt: null, + }, + include: { + ...bountyEligibilityIncludes, + }, }); - const partnersByGroup = programEnrollments.reduce( - (acc, enrollment) => { - if (enrollment.groupId) { - acc[enrollment.groupId] = [ - ...(acc[enrollment.groupId] || []), - enrollment.partnerId, - ]; - } - return acc; + console.log( + `Found ${bounties.length} eligible performance bounties for program ${programId}.`, + { + bounties, }, - {} as Record, ); - for (const groupId in bountiesByGroups) { - const eligibleBounties = bountiesByGroups[groupId].filter((bounty) => - isEligiblePerformanceBounty(bounty), - ); - - if (eligibleBounties.length === 0) { - console.log( - `No eligible bounties found for the group ${groupId}. Either there are no performance bounties, or there are no lifetime stats.`, - ); - continue; - } - - const groupPartnerIds = partnersByGroup[groupId] || []; - - if (groupPartnerIds.length === 0) { - console.log(`No partners found for the group ${groupId}.`); - continue; - } - - console.log( - `Found ${eligibleBounties.length} eligible bounties for the group ${groupId}.`, - ); - - await Promise.allSettled( - eligibleBounties.map((bounty) => - qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`, - body: { - bountyId: bounty.id, - partnerIds: groupPartnerIds, - }, - }), - ), - ); + if (bounties.length === 0) { + return; } -} -function isEligiblePerformanceBounty(bounty: Bounty) { - const now = new Date(); + await Promise.allSettled( + bounties.map(async (bounty) => { + const eligiblePartnerIds = programEnrollments + .filter((programEnrollment) => + canPartnerSubmitBounty({ + programEnrollment, + bounty, + }), + ) + .map(({ partnerId }) => partnerId); - if (bounty.type !== "performance") return false; - if (bounty.performanceScope === "new") return false; - if (bounty.startsAt > now) return false; - if (bounty.endsAt && bounty.endsAt <= now) return false; + if (eligiblePartnerIds.length === 0) { + return; + } - return true; + await qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`, + body: { + bountyId: bounty.id, + partnerIds: eligiblePartnerIds, + }, + }); + }), + ); } diff --git a/apps/web/lib/bounty/api/validate-bounty.ts b/apps/web/lib/bounty/api/validate-bounty.ts index 19c28f21cec..72112f028f1 100644 --- a/apps/web/lib/bounty/api/validate-bounty.ts +++ b/apps/web/lib/bounty/api/validate-bounty.ts @@ -5,6 +5,8 @@ export function validateBounty({ type, startsAt, endsAt, + startMode, + endsAfterDays, submissionsOpenAt, submissionFrequency, maxSubmissions, @@ -12,9 +14,40 @@ export function validateBounty({ rewardDescription, performanceScope, }: Partial) { - startsAt = startsAt || new Date(); + startMode = startMode ?? "absolute"; - if (endsAt && endsAt < startsAt) { + // startsAt is required when startMode is absolute and must be null when + // startMode is relative (relative bounties start when a partner joins). + if (startMode === "relative") { + if (startsAt != null) { + throw new DubApiError({ + message: + "startsAt is not supported when the bounty starts when a partner joins. It must be null for relative bounties.", + code: "bad_request", + }); + } + } else { + // Default to now when an absolute bounty doesn't specify a start date + startsAt = startsAt || new Date(); + } + + if (endsAt && endsAfterDays) { + throw new DubApiError({ + message: + "Bounty cannot have both an end date (endsAt) and endsAfterDays.", + code: "bad_request", + }); + } + + if (startMode === "absolute" && endsAfterDays) { + throw new DubApiError({ + message: + "endsAfterDays is only supported when the bounty starts when a partner joins.", + code: "bad_request", + }); + } + + if (endsAt && startsAt && endsAt < startsAt) { throw new DubApiError({ message: "Bounty end date (endsAt) must be on or after start date (startsAt).", @@ -31,7 +64,7 @@ export function validateBounty({ }); } - if (submissionsOpenAt < startsAt) { + if (startsAt && submissionsOpenAt < startsAt) { throw new DubApiError({ message: "Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).", @@ -72,7 +105,6 @@ export function validateBounty({ }); } - // submission bounty checks if (type === "submission") { if (submissionFrequency && maxSubmissions == null) { throw new DubApiError({ diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts new file mode 100644 index 00000000000..b398c86c752 --- /dev/null +++ b/apps/web/lib/bounty/bounty-period.ts @@ -0,0 +1,114 @@ +import { Bounty, BountyStartMode, ProgramEnrollment } 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, +}; + +export type StartPreset = "today" | DurationPreset | "onPartnerJoin" | "custom"; +export type EndPreset = "never" | DurationPreset | "custom"; + +export function resolveBountyTiming({ + startPreset, + endPreset, + customStartsAt, + customEndsAt, +}: { + startPreset: StartPreset; + endPreset: EndPreset; + customStartsAt?: Date | null; + customEndsAt?: Date | null; +}) { + const now = new Date(); + + let startMode: BountyStartMode = "absolute"; + let startsAt = now; + + switch (startPreset) { + case "today": + startsAt = now; + break; + case "twoWeeks": + startsAt = addWeeks(now, 2); + break; + case "oneMonth": + startsAt = addMonths(now, 1); + break; + case "sixMonths": + startsAt = addMonths(now, 6); + break; + case "onPartnerJoin": + startMode = "relative"; + startsAt = now; + break; + case "custom": + startsAt = customStartsAt ?? now; + break; + } + + let endsAt: Date | null = null; + let endsAfterDays: number | null = null; + + switch (endPreset) { + case "never": + break; + case "twoWeeks": + case "oneMonth": + case "sixMonths": + if (startMode === "absolute") { + endsAt = addDays(startsAt, BOUNTY_DURATION_DAYS[endPreset]); + } else { + endsAfterDays = BOUNTY_DURATION_DAYS[endPreset]; + } + break; + case "custom": + endsAt = customEndsAt ?? null; + break; + } + + return { + startMode, + startsAt, + endsAt, + endsAfterDays, + }; +} + +export function getEffectiveBountyPeriod({ + programEnrollment, + bounty, +}: { + programEnrollment: Pick; + bounty: Pick; +}) { + const { createdAt, groupJoinedAt } = programEnrollment; + const { startsAt, endsAt, endsAfterDays, startMode } = bounty; + + // If startMode is absolute, use the startsAt (Assumed to be set). + // If startMode is relative, use the groupJoinedAt or createdAt. + const bountyStartDate = + startMode === "absolute" ? startsAt! : groupJoinedAt || createdAt; + + return { + startsAt: bountyStartDate, + endsAt: endsAfterDays ? addDays(bountyStartDate, endsAfterDays) : endsAt, + }; +} + +export function isBountyStarted(startsAt: Date) { + return startsAt <= new Date(); +} + +export function isBountyExpired(endsAt: Date | null) { + return endsAt !== null && endsAt <= new Date(); +} diff --git a/apps/web/lib/bounty/constants.ts b/apps/web/lib/bounty/constants.ts index e859234fc36..07ae14e6c97 100644 --- a/apps/web/lib/bounty/constants.ts +++ b/apps/web/lib/bounty/constants.ts @@ -1,4 +1,4 @@ -import { BountySubmissionFrequency } from "@prisma/client"; +import { BountySubmissionFrequency, BountyType } from "@prisma/client"; export const BOUNTY_DESCRIPTION_MAX_LENGTH = 5000; @@ -29,3 +29,8 @@ export const SUBMISSION_FREQUENCY_OPTIONS = [ { label: "Once a week", value: BountySubmissionFrequency.week }, { label: "Once a month", value: BountySubmissionFrequency.month }, ] as const; + +export const BOUNTY_ICONS: Record = { + submission: "https://assets.dub.co/email-assets/icons/heart.png", + performance: "https://assets.dub.co/email-assets/icons/trophy.png", +}; diff --git a/apps/web/lib/bounty/periods.ts b/apps/web/lib/bounty/periods.ts index 82a78997e72..e2b72b396f4 100644 --- a/apps/web/lib/bounty/periods.ts +++ b/apps/web/lib/bounty/periods.ts @@ -1,5 +1,6 @@ import { BountySubmissionFrequency } from "@prisma/client"; import { addDays, addMonths, addWeeks } from "date-fns"; +import { isBountyStarted } from "./bounty-period"; export type SubmissionPeriodStatus = | "notSubmitted" @@ -83,7 +84,7 @@ export function getCurrentPeriodNumber({ const now = new Date(); const start = new Date(startsAt); - if (now < start) { + if (!isBountyStarted(start)) { return null; } @@ -157,7 +158,7 @@ export function getSubmissionPeriods< if (submission) { status = submission.status as SubmissionPeriodStatus; - } else if (now < start) { + } else if (!isBountyStarted(start)) { status = "notOpen"; } else { status = "notSubmitted"; @@ -188,7 +189,7 @@ export function getSubmissionPeriods< if (submissionForPeriod) { status = submissionForPeriod.status as SubmissionPeriodStatus; - } else if (now < start) { + } else if (!isBountyStarted(start)) { status = "notOpen"; } else { status = "notSubmitted"; @@ -235,7 +236,7 @@ export function getSubmissionPeriods< if (submissionForPeriod) { status = submissionForPeriod.status as SubmissionPeriodStatus; - } else if (now < startDate) { + } else if (!isBountyStarted(startDate)) { status = "notOpen"; } else { status = "notSubmitted"; diff --git a/apps/web/lib/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts index ac142be36b2..001370bc955 100644 --- a/apps/web/lib/embed/referrals/auth.ts +++ b/apps/web/lib/embed/referrals/auth.ts @@ -4,7 +4,12 @@ import { prisma } from "@/lib/prisma"; import { PartnerGroupProps } from "@/lib/types"; import { ratelimit } from "@/lib/upstash"; import { getSearchParams } from "@dub/utils"; -import { Link, Program, ProgramEnrollment } from "@prisma/client"; +import { + Link, + Program, + ProgramEnrollment, + ProgramPartnerTag, +} from "@prisma/client"; import { headers } from "next/headers"; import { referralsEmbedToken } from "./token-class"; @@ -23,7 +28,9 @@ interface WithReferralsEmbedTokenHandler { params: Record; searchParams: Record; program: Program; - programEnrollment: ProgramEnrollment; + programEnrollment: ProgramEnrollment & { + programPartnerTags: Pick[]; + }; group: PartnerGroupProps; links: Link[]; embedToken: string; @@ -105,6 +112,11 @@ export const withReferralsEmbedToken = ( }, program: true, partnerGroup: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); diff --git a/apps/web/lib/fetchers/get-network-program.ts b/apps/web/lib/fetchers/get-network-program.ts index 9c20a836cae..67e61747dbb 100644 --- a/apps/web/lib/fetchers/get-network-program.ts +++ b/apps/web/lib/fetchers/get-network-program.ts @@ -1,4 +1,4 @@ -import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries"; +import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties"; import { prisma } from "@/lib/prisma"; import { cache } from "react"; import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups"; @@ -37,7 +37,7 @@ export const getNetworkProgram = cache(async ({ slug }: { slug: string }) => { const defaultGroup = program.groups[0]; const bounties = defaultGroup - ? await getGroupBountySummaries({ + ? await getGroupBounties({ programId: program.id, groupId: defaultGroup.id, }) diff --git a/apps/web/lib/fetchers/get-program.ts b/apps/web/lib/fetchers/get-program.ts index 7f5ef47e31a..de83c847ac7 100644 --- a/apps/web/lib/fetchers/get-program.ts +++ b/apps/web/lib/fetchers/get-program.ts @@ -1,4 +1,4 @@ -import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries"; +import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties"; import { prisma } from "@/lib/prisma"; import { Program, Reward } from "@prisma/client"; import { cache } from "react"; @@ -57,7 +57,7 @@ export const getProgram = cache( const group = groups[0]; - const bounties = await getGroupBountySummaries({ + const bounties = await getGroupBounties({ programId: program.id, groupId: group.id, }); diff --git a/apps/web/lib/firstpromoter/import-partners.ts b/apps/web/lib/firstpromoter/import-partners.ts index 025071d3368..3693fb052ee 100644 --- a/apps/web/lib/firstpromoter/import-partners.ts +++ b/apps/web/lib/firstpromoter/import-partners.ts @@ -183,6 +183,7 @@ async function createPartnerAndLinks({ partnerId: partner.id, status: "approved", groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts index c6dc3fa23af..781c7988083 100644 --- a/apps/web/lib/integrations/slack/transform.ts +++ b/apps/web/lib/integrations/slack/transform.ts @@ -449,6 +449,7 @@ const bountyTemplates = ({ rewardDescription, submissionRequirements, type, + startMode, startsAt, endsAt, } = data; @@ -497,7 +498,11 @@ const bountyTemplates = ({ }, { type: "mrkdwn", - text: `*Duration*\n${new Date(startsAt).toLocaleDateString()}${endsAt ? ` - ${new Date(endsAt).toLocaleDateString()}` : " (No end date)"}`, + text: `*Duration*\n${ + startMode === "relative" || !startsAt + ? "Starts when partner joins" + : `${startsAt.toLocaleDateString()}${endsAt ? ` - ${endsAt.toLocaleDateString()}` : " (No end date)"}` + }`, }, ], }, diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts index a6f3eb65dee..6a81d151fd4 100644 --- a/apps/web/lib/partnerstack/import-partners.ts +++ b/apps/web/lib/partnerstack/import-partners.ts @@ -182,6 +182,7 @@ async function createPartner({ partnerId, status: "approved", groupId: group.id, + groupJoinedAt: new Date(), clickRewardId: group.clickRewardId, leadRewardId: group.leadRewardId, saleRewardId: group.saleRewardId, diff --git a/apps/web/lib/rewardful/import-partners.ts b/apps/web/lib/rewardful/import-partners.ts index c982377c2a5..5871a0c7747 100644 --- a/apps/web/lib/rewardful/import-partners.ts +++ b/apps/web/lib/rewardful/import-partners.ts @@ -196,6 +196,7 @@ async function createPartnerAndLinks({ programId: program.id, partnerId: partner.id, status: "approved", + groupJoinedAt: new Date(), ...defaultGroupAttributes, }, update: { diff --git a/apps/web/lib/swr/use-partner-program-bounties.ts b/apps/web/lib/swr/use-partner-program-bounties.ts index 631b57bc85f..8fb02b1489b 100644 --- a/apps/web/lib/swr/use-partner-program-bounties.ts +++ b/apps/web/lib/swr/use-partner-program-bounties.ts @@ -1,3 +1,4 @@ +import { isBountyExpired } from "@/lib/bounty/bounty-period"; import { fetcher } from "@dub/utils"; import { useParams } from "next/navigation"; import { useMemo } from "react"; @@ -30,7 +31,10 @@ export function usePartnerProgramBounties({ if (!bounties) return { active: 0, expired: 0 }; return bounties.reduce( (counts, bounty) => { - const isExpired = bounty.endsAt && new Date(bounty.endsAt) < new Date(); + const isExpired = isBountyExpired( + bounty.endsAt ? new Date(bounty.endsAt) : null, + ); + counts[isExpired ? "expired" : "active"]++; return counts; }, diff --git a/apps/web/lib/tolt/import-partners.ts b/apps/web/lib/tolt/import-partners.ts index 8055d55fb33..a97e8c3721a 100644 --- a/apps/web/lib/tolt/import-partners.ts +++ b/apps/web/lib/tolt/import-partners.ts @@ -160,6 +160,7 @@ async function createPartner({ programId: program.id, partnerId: partner.id, status: "approved", + groupJoinedAt: new Date(), ...defaultGroupAttributes, }, update: { diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 3e34281ea53..fce63572572 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -23,7 +23,9 @@ import { PartnerRole, PayoutStatus, Prisma, + ProgramEnrollment, ProgramEnrollmentStatus, + ProgramPartnerTag, Project, SubmittedLead, User, @@ -844,7 +846,6 @@ interface WorkflowIdentity { workspaceId: string; programId: string; partnerId: string; - groupId?: string; customerId?: string; customerFirstSaleAt?: Date; } @@ -866,6 +867,21 @@ export interface WorkflowContext { }; } +export interface WorkflowContextExtended extends WorkflowContext { + programEnrollment: Pick< + ProgramEnrollment, + | "groupJoinedAt" + | "createdAt" + | "groupId" + | "partnerId" + | "programId" + | "status" + | "groupMoveDisabledAt" + > & { + programPartnerTags: Pick[]; + }; +} + export type SubmittedLeadProps = z.infer; export type SubmittedLeadFormDataField = z.infer< diff --git a/apps/web/lib/webhook/sample-events/bounty-created.json b/apps/web/lib/webhook/sample-events/bounty-created.json index eb772cfae8a..63d2ac0f591 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, @@ -20,5 +22,10 @@ { "id": "grp_1K2E25381GVMG7HHM057TB92F" } + ], + "partnerTags": [ + { + "id": "ptg_1K2E25381GVMG7HHM057TB92F" + } ] } diff --git a/apps/web/lib/webhook/sample-events/bounty-updated.json b/apps/web/lib/webhook/sample-events/bounty-updated.json index 0d5605ac897..7b89744f8a4 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, @@ -20,5 +22,10 @@ { "id": "grp_1K2E25381GVMG7HHM057TB92F" } + ], + "partnerTags": [ + { + "id": "ptg_1K2E25381GVMG7HHM057TB92F" + } ] } diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index 12ea84a8b4d..7d315f75626 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, @@ -21,6 +22,7 @@ import * as z from "zod/v4"; import { CommissionSchema } from "./commissions"; import { GroupSchema } from "./groups"; import { booleanQuerySchema, getPaginationQuerySchema } from "./misc"; +import { PartnerTagSchema } from "./partner-tags"; import { EnrolledPartnerSchema } from "./partners"; import { UserSchema } from "./users"; import { nullableCountSchema, parseDateSchema } from "./utils"; @@ -100,8 +102,10 @@ export const createBountySchema = z.object({ ) .nullish(), type: z.enum(BountyType), + startMode: z.enum(BountyStartMode), startsAt: parseDateSchema.nullish(), endsAt: parseDateSchema.nullish(), + endsAfterDays: z.number().int().positive().nullish(), submissionsOpenAt: parseDateSchema.nullish(), submissionFrequency: z.enum(BountySubmissionFrequency).nullish(), maxSubmissions: z @@ -123,6 +127,7 @@ export const createBountySchema = z.object({ .nullish(), submissionRequirements: submissionRequirementsSchema.nullish(), groupIds: z.array(z.string()).nullable(), + partnerTagIds: z.array(z.string()).nullable(), performanceCondition: bountyPerformanceConditionSchema.nullish(), performanceScope: z.enum(BountyPerformanceScope).nullish(), sendNotificationEmails: z.boolean().optional(), @@ -148,8 +153,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(), @@ -162,6 +169,7 @@ export const BountySchema = z.object({ submissionRequirements: submissionRequirementsSchema.nullable().default(null), socialMetricsLastSyncedAt: z.date().nullable().optional(), groups: z.array(GroupSchema.pick({ id: true })), + partnerTags: z.array(PartnerTagSchema.pick({ id: true })), }); export const getBountiesQuerySchema = z.object({ diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts index 303279daf98..7a8e6f17ce1 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -164,8 +164,10 @@ export const partnerBountySubmissionSchema = BountySubmissionSchema.extend({ export const PartnerBountySchema = BountySchema.omit({ groups: true, + partnerTags: true, socialMetricsLastSyncedAt: true, }).extend({ + startsAt: z.date(), // Always resolved to the partner's effective start date (never null) submissions: z.array(partnerBountySubmissionSchema), performanceCondition: bountyPerformanceConditionSchema .nullable() diff --git a/apps/web/prisma/schema/bounty.prisma b/apps/web/prisma/schema/bounty.prisma index 321f46b2c35..a2e198a1b74 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) @@ -52,6 +59,7 @@ model Bounty { submissions BountySubmission[] groups BountyGroup[] + partnerTags BountyPartnerTag[] program Program @relation(fields: [programId], references: [id], onDelete: Cascade) workflow Workflow? @relation(fields: [workflowId], references: [id], onDelete: Cascade) emails NotificationEmail[] @@ -68,7 +76,19 @@ model BountyGroup { partnerGroup PartnerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) @@unique([bountyId, groupId]) - @@index([groupId]) + @@index(groupId) +} + +model BountyPartnerTag { + id String @id @default(cuid()) + bountyId String + partnerTagId String + + bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade) + partnerTag PartnerTag @relation(fields: [partnerTagId], references: [id], onDelete: Cascade) + + @@unique([bountyId, partnerTagId]) + @@index(partnerTagId) } model BountySubmission { diff --git a/apps/web/prisma/schema/program.prisma b/apps/web/prisma/schema/program.prisma index 7d88029486e..85bbc4c1864 100644 --- a/apps/web/prisma/schema/program.prisma +++ b/apps/web/prisma/schema/program.prisma @@ -140,6 +140,7 @@ model ProgramEnrollment { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + groupJoinedAt DateTime? // date when the partner joined their current group customerDataSharingEnabledAt DateTime? groupMoveDisabledAt DateTime? bannedAt DateTime? diff --git a/apps/web/prisma/schema/tag.prisma b/apps/web/prisma/schema/tag.prisma index 67b7f05a859..ae34982d58c 100644 --- a/apps/web/prisma/schema/tag.prisma +++ b/apps/web/prisma/schema/tag.prisma @@ -34,6 +34,7 @@ model PartnerTag { program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) programPartnerTags ProgramPartnerTag[] + bounties BountyPartnerTag[] @@unique([programId, name]) @@index(programId) diff --git a/apps/web/scripts/migrations/backfill-group-joined-at.ts b/apps/web/scripts/migrations/backfill-group-joined-at.ts new file mode 100644 index 00000000000..a6024f05d07 --- /dev/null +++ b/apps/web/scripts/migrations/backfill-group-joined-at.ts @@ -0,0 +1,54 @@ +import { prisma } from "@/lib/prisma"; +import "dotenv-flow/config"; + +async function main() { + while (true) { + const programEnrollments = await prisma.programEnrollment.findMany({ + where: { + groupId: { + not: null, + }, + groupJoinedAt: null, + }, + select: { + id: true, + createdAt: true, + }, + take: 100, + orderBy: { + createdAt: "asc", + }, + }); + + if (programEnrollments.length === 0) { + console.log("No more program enrollments to backfill, skipping..."); + break; + } + + await Promise.all( + programEnrollments.map(({ id, createdAt }) => + prisma.programEnrollment.update({ + where: { + id, + }, + data: { + groupJoinedAt: createdAt, + }, + }), + ), + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + console.log(`Backfilled ${programEnrollments.length} enrollments...`); + } +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/web/tests/bounties/index.test.ts b/apps/web/tests/bounties/index.test.ts index c17a1b339df..5c66185de21 100644 --- a/apps/web/tests/bounties/index.test.ts +++ b/apps/web/tests/bounties/index.test.ts @@ -106,6 +106,31 @@ describe.sequential("/bounties/**", async () => { submissionBountyId = bounty.id; }); + test("POST /bounties - relative start with endsAfterDays", async () => { + const { status, data: bounty } = await http.post({ + path: "/bounties", + body: { + ...submissionBounty, + groupIds: [E2E_PARTNER_GROUP.id], + startMode: "relative", + endsAfterDays: 14, + endsAt: null, + }, + }); + + expect(status).toEqual(200); + expect(bounty).toMatchObject({ + id: expect.any(String), + startMode: "relative", + endsAfterDays: 14, + endsAt: null, + }); + + onTestFinished(async () => { + await h.deleteBounty(bounty.id); + }); + }); + test("POST /bounties - submission based with rewardDescription", async () => { const { status, data: bounty } = await http.post({ path: "/bounties", diff --git a/apps/web/tests/misc/filter-active-group-bounties.test.ts b/apps/web/tests/misc/filter-active-group-bounties.test.ts deleted file mode 100644 index 2bd238df98b..00000000000 --- a/apps/web/tests/misc/filter-active-group-bounties.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { filterActiveGroupBounties } from "@/lib/bounty/api/get-group-bounty-summaries"; -import { BountyType } from "@prisma/client"; -import { describe, expect, it } from "vitest"; - -const GROUP_ID = "grp_test_123"; -const OTHER_GROUP_ID = "grp_other_456"; - -const NOW = new Date("2025-06-01T12:00:00.000Z"); - -function makeCandidate( - overrides: Partial<{ - id: string; - name: string | null; - type: BountyType; - startsAt: Date; - endsAt: Date | null; - archivedAt: Date | null; - groups: { groupId: string }[]; - }> = {}, -) { - return { - id: "bnty_1", - name: "Test Bounty", - type: "submission" as BountyType, - startsAt: new Date("2025-01-01T00:00:00.000Z"), - endsAt: null, - archivedAt: null, - groups: [], - ...overrides, - }; -} - -describe("filterActiveGroupBounties", () => { - it("excludes archived bounties", () => { - const bounty = makeCandidate({ - archivedAt: new Date("2025-05-01T00:00:00.000Z"), - }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(0); - }); - - it("excludes bounties that have not started yet (startsAt > now)", () => { - const bounty = makeCandidate({ - startsAt: new Date("2025-06-02T00:00:00.000Z"), - }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(0); - }); - - it("includes bounties that start exactly at now (startsAt === now, boundary is exclusive >)", () => { - const bounty = makeCandidate({ startsAt: NOW }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(1); - }); - - it("excludes bounties whose endsAt is exactly now (endsAt === now, exclusive boundary)", () => { - const bounty = makeCandidate({ endsAt: NOW }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(0); - }); - - it("excludes bounties that expired before now (endsAt < now)", () => { - const bounty = makeCandidate({ - endsAt: new Date("2025-05-31T00:00:00.000Z"), - }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(0); - }); - - it("includes bounties with no endsAt (never expires)", () => { - const bounty = makeCandidate({ endsAt: null }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(1); - }); - - it("includes global bounties (groups is empty) regardless of groupId", () => { - const bounty = makeCandidate({ groups: [] }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(1); - }); - - it("includes group-scoped bounties when groups contains the matching groupId", () => { - const bounty = makeCandidate({ groups: [{ groupId: GROUP_ID }] }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(1); - }); - - it("excludes group-scoped bounties when groups contains only a different groupId", () => { - const bounty = makeCandidate({ groups: [{ groupId: OTHER_GROUP_ID }] }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(0); - }); - - it("includes group-scoped bounties when groups contains the matching groupId alongside others", () => { - const bounty = makeCandidate({ - groups: [{ groupId: OTHER_GROUP_ID }, { groupId: GROUP_ID }], - }); - const result = filterActiveGroupBounties([bounty], { - groupId: GROUP_ID, - now: NOW, - }); - expect(result).toHaveLength(1); - }); - - it("filters multiple bounties correctly in a mixed set", () => { - const bounties = [ - makeCandidate({ - id: "bnty_1", - archivedAt: new Date("2025-01-01T00:00:00.000Z"), - }), - makeCandidate({ - id: "bnty_2", - startsAt: new Date("2025-07-01T00:00:00.000Z"), - }), - makeCandidate({ id: "bnty_3", endsAt: NOW }), - makeCandidate({ id: "bnty_4", groups: [] }), - makeCandidate({ id: "bnty_5", groups: [{ groupId: GROUP_ID }] }), - makeCandidate({ id: "bnty_6", groups: [{ groupId: OTHER_GROUP_ID }] }), - ]; - - const result = filterActiveGroupBounties(bounties, { - groupId: GROUP_ID, - now: NOW, - }); - expect(result.map((b) => b.id)).toEqual(["bnty_4", "bnty_5"]); - }); -}); diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts index 39864ea0728..cc11206e3d8 100644 --- a/apps/web/tests/webhooks/index.test.ts +++ b/apps/web/tests/webhooks/index.test.ts @@ -57,6 +57,15 @@ 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)), + submissionsOpenAt: z + .string() + .nullable() + .transform((str) => (str ? new Date(str) : null)), + socialMetricsLastSyncedAt: z + .string() + .nullable() + .optional() + .transform((str) => (str ? new Date(str) : null)), }); const payoutWebhookEventSchemaExtended = payoutWebhookEventSchema.extend({