From 9eae7e489c1608e82e438b11dfd7b75fe0889140 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 16 Jun 2026 22:08:21 +0530 Subject: [PATCH 01/19] Limit bounty eligibility to selected partner tags --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 38 ++- apps/web/app/(ee)/api/bounties/route.ts | 41 ++- .../add-edit-bounty/add-edit-bounty-sheet.tsx | 23 ++ .../use-add-edit-bounty-form.ts | 5 + .../tags/throw-if-invalid-partner-tag-ids.ts | 37 +++ .../api/is-partner-eligible-for-bounty.ts | 28 ++ apps/web/lib/zod/schemas/bounties.ts | 3 + apps/web/lib/zod/schemas/partner-profile.ts | 1 + .../tags/partner-tags-multi-select.tsx | 246 ++++++++++++++++++ packages/prisma/schema/bounty.prisma | 15 +- packages/prisma/schema/tag.prisma | 1 + 11 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts create mode 100644 apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts create mode 100644 apps/web/ui/partners/tags/partner-tags-multi-select.tsx diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 99b517d511b..ea39edc266f 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -2,6 +2,7 @@ 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 { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; @@ -17,7 +18,7 @@ import { updateBountySchema, } from "@/lib/zod/schemas/bounties"; import { prisma } from "@dub/prisma"; -import { PartnerGroup, Prisma } from "@dub/prisma/client"; +import { PartnerGroup, PartnerTag, Prisma } from "@dub/prisma/client"; import { arrayEqual, deepEqual } from "@dub/utils"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -69,6 +70,7 @@ export const PATCH = withWorkspace( submissionRequirements, performanceCondition, groupIds, + partnerTagIds, } = updateBountySchema.parse(await parseRequestBody(req)); const bounty = await prisma.bounty.findUniqueOrThrow({ @@ -78,6 +80,7 @@ export const PATCH = withWorkspace( }, include: { groups: true, + partnerTags: true, workflow: true, _count: { select: { @@ -133,6 +136,21 @@ export const PATCH = withWorkspace( }); } + // if partnerTagIds is provided and is different from the current partnerTagIds, update the partner tags + let updatedPartnerTags: PartnerTag[] | undefined = undefined; + if ( + partnerTagIds && + !arrayEqual( + bounty.partnerTags.map((tag) => tag.partnerTagId), + partnerTagIds, + ) + ) { + updatedPartnerTags = await throwIfInvalidPartnerTagIds({ + programId, + partnerTagIds, + }); + } + // Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions if (performanceCondition && bounty.workflow) { const submissionCount = bounty._count.submissions; @@ -219,10 +237,19 @@ export const PATCH = withWorkspace( })), }, }), + ...(updatedPartnerTags && { + partnerTags: { + deleteMany: {}, + create: updatedPartnerTags.map((tag) => ({ + partnerTagId: tag.id, + })), + }, + }), }, include: { - workflow: true, groups: true, + partnerTags: true, + workflow: true, }, }); @@ -246,6 +273,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], }); @@ -302,6 +332,7 @@ export const DELETE = withWorkspace( }, include: { groups: true, + partnerTags: true, workflow: true, _count: { select: { @@ -338,6 +369,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/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 208cacb85b7..cd2f9339faa 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -4,6 +4,7 @@ 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 { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; @@ -85,8 +86,14 @@ export const GET = withWorkspace( groupId: true, }, }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, }, }), + includeSubmissionsCount ? prisma.bountySubmission.groupBy({ by: ["bountyId", "status"], @@ -131,6 +138,9 @@ export const GET = withWorkspace( return BountyListSchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), + partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({ + id: partnerTagId, + })), ...(allBountiesSubmissionsCount && { submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id), }), @@ -171,6 +181,7 @@ export const POST = withWorkspace( maxSubmissions, submissionRequirements, groupIds, + partnerTagIds, performanceCondition, performanceScope, sendNotificationEmails, @@ -191,10 +202,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; @@ -268,10 +286,20 @@ export const POST = withWorkspace( }, }, }), + ...(partnerTags.length && { + partnerTags: { + createMany: { + data: partnerTags.map(({ id }) => ({ + partnerTagId: id, + })), + }, + }, + }), }, include: { - workflow: true, groups: true, + partnerTags: true, + workflow: true, }, }); }); @@ -279,6 +307,9 @@ 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], }); 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 f5d57b62330..f984f68d055 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 @@ -14,6 +14,7 @@ import { ProgramSheetAccordionTrigger, } from "@/ui/partners/program-sheet-accordion"; import { RewardIconSquare } from "@/ui/partners/rewards/reward-icon-square"; +import { PartnerTagsMultiSelect } from "@/ui/partners/tags/partner-tags-multi-select"; import { X } from "@/ui/shared/icons"; import { InlineBadgePopover, @@ -522,6 +523,28 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { /> + + + + Tags + + +

+ Partners in the selected groups or with the selected tags + are eligible for this bounty. +

+ ( + field.onChange(ids)} + /> + )} + /> +
+
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 36520a28fc8..b90acccdf29 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 @@ -25,6 +25,7 @@ const ACCORDION_ITEMS = [ "bounty-details", "bounty-criteria", "groups", + "partnerTags", ]; const isEmpty = (value: unknown) => @@ -96,6 +97,7 @@ export function useAddEditBountyForm({ type: bounty?.type || "performance", submissionRequirements: initialSubmissionRequirements, groupIds: bounty?.groups?.map(({ id }) => id) || null, + partnerTagIds: bounty?.partnerTags?.map(({ id }) => id) || null, performanceCondition: bounty?.performanceCondition ? { ...bounty.performanceCondition, @@ -137,6 +139,7 @@ export function useAddEditBountyForm({ description, performanceCondition, groupIds, + partnerTagIds, rewardType, submissionRequirements, ] = watch([ @@ -149,6 +152,7 @@ export function useAddEditBountyForm({ "description", "performanceCondition", "groupIds", + "partnerTagIds", "rewardType", "submissionRequirements", ]); @@ -536,6 +540,7 @@ export function useAddEditBountyForm({ rewardDescription: rewardDescription || null, submissionRequirements: submissionRequirements ?? null, groups: groupIds?.map((id) => ({ id })) || [], + partnerTags: partnerTagIds?.map((id) => ({ id })) || [], } : undefined, onConfirm: async ({ sendNotificationEmails }) => { 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..abd1db764db --- /dev/null +++ b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts @@ -0,0 +1,37 @@ +import { prisma } from "@dub/prisma"; +import { PartnerTag } from "@dub/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/bounty/api/is-partner-eligible-for-bounty.ts b/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts new file mode 100644 index 00000000000..478e7378e29 --- /dev/null +++ b/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts @@ -0,0 +1,28 @@ +// Determines whether a partner is eligible for a bounty. +// A bounty can be restricted by partner groups and/or partner tags. The rule is OR: +// - if the bounty has no restrictions at all, every partner is eligible (global) +// - otherwise the partner is eligible if they're in one of the bounty's groups +// OR they have one of the bounty's tags +export function isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId, + partnerTagIds, +}: { + bountyGroupIds: string[]; + bountyTagIds: string[]; + partnerGroupId: string | null; + partnerTagIds: string[]; +}): boolean { + // No restrictions → available to all partners + if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { + return true; + } + + const inGroup = + partnerGroupId != null && bountyGroupIds.includes(partnerGroupId); + + const hasTag = bountyTagIds.some((id) => partnerTagIds.includes(id)); + + return inGroup || hasTag; +} diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index dd5e8b759e1..c45c37c5868 100644 --- a/apps/web/lib/zod/schemas/bounties.ts +++ b/apps/web/lib/zod/schemas/bounties.ts @@ -21,6 +21,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"; @@ -123,6 +124,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(), @@ -162,6 +164,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 f1e9b7b0706..0918e49f5c1 100644 --- a/apps/web/lib/zod/schemas/partner-profile.ts +++ b/apps/web/lib/zod/schemas/partner-profile.ts @@ -163,6 +163,7 @@ export const partnerBountySubmissionSchema = BountySubmissionSchema.extend({ export const PartnerBountySchema = BountySchema.omit({ groups: true, + partnerTags: true, socialMetricsLastSyncedAt: true, }).extend({ submissions: z.array(partnerBountySubmissionSchema), diff --git a/apps/web/ui/partners/tags/partner-tags-multi-select.tsx b/apps/web/ui/partners/tags/partner-tags-multi-select.tsx new file mode 100644 index 00000000000..d00b0412e0d --- /dev/null +++ b/apps/web/ui/partners/tags/partner-tags-multi-select.tsx @@ -0,0 +1,246 @@ +import { usePartnerTags } from "@/lib/swr/use-partner-tags"; +import { usePartnerTagsCount } from "@/lib/swr/use-partner-tags-count"; +import { PartnerTagProps } from "@/lib/types"; +import { PARTNER_TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/partner-tags"; +import { + AnimatedSizeContainer, + Check2, + LoadingSpinner, + Magnifier, + ScrollContainer, + Tag, + ToggleGroup, +} from "@dub/ui"; +import { cn } from "@dub/utils"; +import { Command } from "cmdk"; +import { useCallback, useEffect, useState } from "react"; +import { useDebounce } from "use-debounce"; + +interface PartnerTagsMultiSelectProps { + selectedTagIds: string[] | null; + setSelectedTagIds: (tagIds: string[] | null) => void; + className?: string; +} + +export function PartnerTagsMultiSelect({ + selectedTagIds, + setSelectedTagIds, + className, +}: PartnerTagsMultiSelectProps) { + const [selectedMode, setSelectedMode] = useState<"all" | "select">( + selectedTagIds?.length ? "select" : "all", + ); + + const [search, setSearch] = useState(""); + const [useAsync, setUseAsync] = useState(false); + const [debouncedSearch] = useDebounce(search, 500); + + const [shouldSortTags, setShouldSortTags] = useState(false); + const [sortedTags, setSortedTags] = useState( + undefined, + ); + + const { partnerTagsCount } = usePartnerTagsCount(); + + const { partnerTags } = usePartnerTags({ + query: { ...(useAsync ? { search: debouncedSearch } : undefined) }, + }); + + const { partnerTags: selectedTags } = usePartnerTags({ + query: { ids: selectedTagIds ?? undefined }, + enabled: Boolean(selectedTagIds?.length), + }); + + // Determine if we should use async loading + useEffect( + () => + setUseAsync( + Boolean( + partnerTags && + !useAsync && + partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, + ), + ), + [partnerTags, useAsync], + ); + + const sortTags = useCallback( + (tags: PartnerTagProps[], search: string) => { + return search === "" + ? [ + ...tags.filter((t) => selectedTagIds?.includes(t.id)), + ...tags.filter((t) => !selectedTagIds?.includes(t.id)), + ] + : tags; + }, + [selectedTagIds], + ); + + // Actually sort the tags when needed + useEffect(() => { + if ( + !shouldSortTags || + !partnerTags || + (selectedTagIds?.length && !selectedTags) + ) + return; + + setSortedTags( + sortTags( + [ + ...(selectedTags ?? []), + ...partnerTags.filter( + (t) => !selectedTags?.some((st) => st.id === t.id), + ), + ], + search, + ), + ); + setShouldSortTags(false); + }, [ + shouldSortTags, + partnerTags, + selectedTagIds, + selectedTags, + sortTags, + search, + ]); + + // Sort when the search-filtered tags change + useEffect(() => setShouldSortTags(true), [partnerTags]); + + return ( +
+ { + setSelectedMode(value as "all" | "select"); + if (value === "all") setSelectedTagIds(null); + }} + /> + +
+ +
+ {selectedMode === "all" ? ( +
+
+ + {partnerTagsCount === undefined ? ( +
+ ) : ( + partnerTagsCount + )} +
+ + Tags selected + +
+ ) : ( + + + + + {sortedTags !== undefined ? ( + <> + {sortedTags.map((tag) => { + const checked = Boolean( + selectedTagIds?.includes(tag.id), + ); + + return ( + + setSelectedTagIds( + selectedTagIds?.includes(tag.id) + ? selectedTagIds.length === 1 + ? null // Revert to null if there will be no tags selected + : selectedTagIds.filter( + (id) => id !== tag.id, + ) + : [...(selectedTagIds ?? []), tag.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 + )} + +
+
+ + + {tag.name} + +
+
+ ); + })} + {!useAsync ? ( + + No matches + + ) : sortedTags.length === 0 ? ( +
+ No matches +
+ ) : null} + + ) : ( + // undefined data / explicit loading state + +
+ +
+
+ )} +
+
+
+ )} +
+ +
+
+ ); +} diff --git a/packages/prisma/schema/bounty.prisma b/packages/prisma/schema/bounty.prisma index 321f46b2c35..6b2eaafb886 100644 --- a/packages/prisma/schema/bounty.prisma +++ b/packages/prisma/schema/bounty.prisma @@ -52,6 +52,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 +69,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/packages/prisma/schema/tag.prisma b/packages/prisma/schema/tag.prisma index 67b7f05a859..ae34982d58c 100644 --- a/packages/prisma/schema/tag.prisma +++ b/packages/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) From d1a47dd8eaa5290692d4efde1d0d23df768656c8 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 11:36:39 +0530 Subject: [PATCH 02/19] Filter partner bounties by partner tag eligibility --- .../programs/[programId]/bounties/route.ts | 5 +++ .../referrals/get-referrals-embed-data.ts | 5 +++ .../tags/throw-if-invalid-partner-tag-ids.ts | 4 +- .../bounty/api/get-bounties-for-partner.ts | 43 ++++++++++++++----- .../api/is-partner-eligible-for-bounty.ts | 2 + 5 files changed, 46 insertions(+), 13 deletions(-) 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..7b3884ce695 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 @@ -11,6 +11,11 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { include: { program: true, links: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); 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 f8229f17f57..3cba2b6b727 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 @@ -71,6 +71,11 @@ export const getReferralsEmbedData = async (token: string) => { saleReward: true, referralReward: true, discount: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); 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 index abd1db764db..e00775b3735 100644 --- 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 @@ -1,5 +1,5 @@ -import { prisma } from "@dub/prisma"; -import { PartnerTag } from "@dub/prisma/client"; +import { prisma } from "@/lib/prisma"; +import { PartnerTag } from "@prisma/client"; import { DubApiError } from "../errors"; export async function throwIfInvalidPartnerTagIds({ 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..b8c977202af 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -4,23 +4,30 @@ import { } 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 { Program, ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; import * as z from "zod/v4"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, "groupId" | "partnerId" | "totalCommissions" > & { + programPartnerTags: Pick[]; links: PartnerLink[]; program: Pick; }; -export async function getBountiesForPartner( - params: GetBountiesForPartnerParams, -) { - const { groupId, partnerId, totalCommissions, program, links } = params; - +export async function getBountiesForPartner({ + groupId, + programPartnerTags, + partnerId, + totalCommissions, + program, + links, +}: GetBountiesForPartnerParams) { const now = new Date(); + const partnerTagIds = programPartnerTags.map( + ({ partnerTagId }) => partnerTagId, + ); const bounties = await prisma.bounty.findMany({ where: { @@ -28,13 +35,16 @@ export async function getBountiesForPartner( 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: {}, - }, + AND: [ + { + groups: { none: {} }, + }, + { + partnerTags: { none: {} }, + }, + ], }, { groups: { @@ -43,6 +53,13 @@ export async function getBountiesForPartner( }, }, }, + { + partnerTags: { + some: { + partnerTagId: { in: partnerTagIds }, + }, + }, + }, ], }, include: { @@ -82,3 +99,7 @@ export async function getBountiesForPartner( })), ); } + +function buildBountyEligibilityWhere() { + // +} diff --git a/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts b/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts index 478e7378e29..896283071cb 100644 --- a/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts +++ b/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts @@ -26,3 +26,5 @@ export function isPartnerEligibleForBounty({ return inGroup || hasTag; } + +// Note: not using From 2609375417006eee28728e1e2b7227fcf65988cc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 11:58:10 +0530 Subject: [PATCH 03/19] Consolidate bounty eligibility logic into shared module --- apps/web/app/(ee)/api/bounties/route.ts | 34 +++++----- .../add-edit-bounty/add-edit-bounty-sheet.tsx | 43 +----------- apps/web/lib/bounty/api/bounty-eligibility.ts | 67 +++++++++++++++++++ .../bounty/api/get-bounties-for-partner.ts | 35 ++-------- .../api/is-partner-eligible-for-bounty.ts | 30 --------- 5 files changed, 91 insertions(+), 118 deletions(-) create mode 100644 apps/web/lib/bounty/api/bounty-eligibility.ts delete mode 100644 apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index af516984f30..38a373e04e4 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -7,6 +7,7 @@ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enro import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { 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 { qstash } from "@/lib/cron"; @@ -43,10 +44,22 @@ export const GET = withWorkspace( programId, include: { program: 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: { @@ -58,24 +71,11 @@ export const GET = withWorkspace( { 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, + }), }, ], }), 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 7036ec10c3c..d235141b2e5 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, CreateBountyInput } from "@/lib/types"; -import { GroupsMultiSelect } from "@/ui/partners/groups/groups-multi-select"; import { ProgramSheetAccordion, ProgramSheetAccordionContent, @@ -14,7 +13,6 @@ import { ProgramSheetAccordionTrigger, } from "@/ui/partners/program-sheet-accordion"; import { RewardIconSquare } from "@/ui/partners/rewards/reward-icon-square"; -import { PartnerTagsMultiSelect } from "@/ui/partners/tags/partner-tags-multi-select"; import { X } from "@/ui/shared/icons"; import { InlineBadgePopover, @@ -46,6 +44,7 @@ import { BountySubmissionFrequency } from "@prisma/client"; import { Dispatch, SetStateAction, useState } from "react"; import { Controller, FormProvider } from "react-hook-form"; import { BountyCriteria } from "./bounty-criteria"; +import { BountyEligibility } from "./bounty-eligibility"; import { useAddEditBountyForm } from "./use-add-edit-bounty-form"; interface BountySheetProps { @@ -506,45 +505,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { - - - Groups - - - ( - field.onChange(ids)} - /> - )} - /> - - - - - - Tags - - -

- Partners in the selected groups or with the selected tags - are eligible for this bounty. -

- ( - field.onChange(ids)} - /> - )} - /> -
-
+
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..27ca92af0d6 --- /dev/null +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -0,0 +1,67 @@ +// Note: not using +export function isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId, + partnerTagIds, +}: { + bountyGroupIds: string[]; + bountyTagIds: string[]; + partnerGroupId: string | null; + partnerTagIds: string[]; +}): boolean { + // No restrictions → available to all partners + if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { + return true; + } + + const inGroup = + partnerGroupId != null && bountyGroupIds.includes(partnerGroupId); + + const hasTag = bountyTagIds.some((id) => partnerTagIds.includes(id)); + + return inGroup || hasTag; +} + +export function buildBountyEligibilityWhere({ + groupId, + partnerTagIds, +}: { + groupId: string | undefined; + partnerTagIds: string[]; +}) { + return { + OR: [ + { + AND: [ + { + groups: { + none: {}, + }, + }, + { + partnerTags: { + none: {}, + }, + }, + ], + }, + { + groups: { + some: { + groupId, + }, + }, + }, + { + partnerTags: { + some: { + partnerTagId: { + in: partnerTagIds, + }, + }, + }, + }, + ], + }; +} 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 b8c977202af..ea3b221a81b 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -6,6 +6,7 @@ import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { Program, ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; import * as z from "zod/v4"; +import { buildBountyEligibilityWhere } from "./bounty-eligibility"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, @@ -35,32 +36,10 @@ export async function getBountiesForPartner({ startsAt: { lte: now, }, - OR: [ - { - AND: [ - { - groups: { none: {} }, - }, - { - partnerTags: { none: {} }, - }, - ], - }, - { - groups: { - some: { - groupId: groupId || program.defaultGroupId, - }, - }, - }, - { - partnerTags: { - some: { - partnerTagId: { in: partnerTagIds }, - }, - }, - }, - ], + ...buildBountyEligibilityWhere({ + groupId: groupId || program.defaultGroupId, + partnerTagIds, + }), }, include: { workflow: { @@ -99,7 +78,3 @@ export async function getBountiesForPartner({ })), ); } - -function buildBountyEligibilityWhere() { - // -} diff --git a/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts b/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts deleted file mode 100644 index 896283071cb..00000000000 --- a/apps/web/lib/bounty/api/is-partner-eligible-for-bounty.ts +++ /dev/null @@ -1,30 +0,0 @@ -// Determines whether a partner is eligible for a bounty. -// A bounty can be restricted by partner groups and/or partner tags. The rule is OR: -// - if the bounty has no restrictions at all, every partner is eligible (global) -// - otherwise the partner is eligible if they're in one of the bounty's groups -// OR they have one of the bounty's tags -export function isPartnerEligibleForBounty({ - bountyGroupIds, - bountyTagIds, - partnerGroupId, - partnerTagIds, -}: { - bountyGroupIds: string[]; - bountyTagIds: string[]; - partnerGroupId: string | null; - partnerTagIds: string[]; -}): boolean { - // No restrictions → available to all partners - if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { - return true; - } - - const inGroup = - partnerGroupId != null && bountyGroupIds.includes(partnerGroupId); - - const hasTag = bountyTagIds.some((id) => partnerTagIds.includes(id)); - - return inGroup || hasTag; -} - -// Note: not using From f027ea8f0a242ee5d33eb936ef402b2dff318b1e Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 12:44:19 +0530 Subject: [PATCH 04/19] Enforce partner tag eligibility on partner bounty endpoints --- .../[programId]/bounties/[bountyId]/route.ts | 34 +++++- .../[bountyId]/social-content-stats/route.ts | 41 ++++++- apps/web/lib/bounty/api/bounty-eligibility.ts | 104 ++++++++++-------- .../lib/bounty/api/get-bounty-with-details.ts | 15 ++- 4 files changed, 143 insertions(+), 51 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts index 5ff1989bd6c..ec79d2efb8f 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,6 +1,7 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; @@ -17,6 +18,11 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { include: { program: true, links: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); @@ -26,12 +32,21 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { programId: program.id, }, include: { + groups: { + select: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, workflow: { select: { triggerConditions: true, }, }, - groups: true, submissions: { where: { partnerId: partner.id, @@ -64,13 +79,20 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }); } - const partnerGroupId = programEnrollment.groupId || program.defaultGroupId; + const partnerTagIds = programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); const bountyGroupIds = bounty.groups.map((g) => g.groupId); - const partnerCanSeeBounty = - bountyGroupIds.length === 0 || - (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: programEnrollment.groupId, + partnerTagIds, + }); - if (!partnerCanSeeBounty) { + if (!isEligible) { throw new DubApiError({ code: "not_found", message: "Bounty not found.", 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..90ae7c6a282 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts @@ -2,6 +2,7 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { isPartnerEligibleForBounty } 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,14 +34,52 @@ 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: { + groups: { + select: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }); + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); + + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: programEnrollment.groupId, + partnerTagIds, + }); + + if (!isEligible) { + throw new DubApiError({ + code: "not_found", + message: "Bounty not found.", + }); + } + const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.socialMetrics) { diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts index 27ca92af0d6..a34e3406ad0 100644 --- a/apps/web/lib/bounty/api/bounty-eligibility.ts +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -1,28 +1,3 @@ -// Note: not using -export function isPartnerEligibleForBounty({ - bountyGroupIds, - bountyTagIds, - partnerGroupId, - partnerTagIds, -}: { - bountyGroupIds: string[]; - bountyTagIds: string[]; - partnerGroupId: string | null; - partnerTagIds: string[]; -}): boolean { - // No restrictions → available to all partners - if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { - return true; - } - - const inGroup = - partnerGroupId != null && bountyGroupIds.includes(partnerGroupId); - - const hasTag = bountyTagIds.some((id) => partnerTagIds.includes(id)); - - return inGroup || hasTag; -} - export function buildBountyEligibilityWhere({ groupId, partnerTagIds, @@ -31,37 +6,80 @@ export function buildBountyEligibilityWhere({ partnerTagIds: string[]; }) { return { - OR: [ + AND: [ { - AND: [ + OR: [ { groups: { none: {}, }, }, + ...(groupId + ? [ + { + groups: { + some: { + groupId, + }, + }, + }, + ] + : []), + ], + }, + { + OR: [ { partnerTags: { none: {}, }, }, + ...(partnerTagIds.length > 0 + ? [ + { + partnerTags: { + some: { + partnerTagId: { + in: partnerTagIds, + }, + }, + }, + }, + ] + : []), ], }, - { - groups: { - some: { - groupId, - }, - }, - }, - { - partnerTags: { - some: { - partnerTagId: { - in: partnerTagIds, - }, - }, - }, - }, ], }; } + +export function isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId, + partnerTagIds, +}: { + bountyGroupIds: string[]; + bountyTagIds: string[]; + partnerGroupId: string | null; + partnerTagIds: string[]; +}) { + // 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.length > 0 && + bountyTagIds.some((id) => partnerTagIds.includes(id)); + + return inGroup && hasTag; +} 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..00667a0439e 100644 --- a/apps/web/lib/bounty/api/get-bounty-with-details.ts +++ b/apps/web/lib/bounty/api/get-bounty-with-details.ts @@ -36,7 +36,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 @@ -73,5 +85,6 @@ export const getBountyWithDetails = async ({ performanceScope, performanceCondition, groups: bounty.groups.filter((group) => group !== null) ?? [], + partnerTags: bounty.partnerTags.filter((tag) => tag !== null) ?? [], }; }; From 10e25c6d0aaddcd3bf38af9d57e087b59aa6a2ee Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 13:49:36 +0530 Subject: [PATCH 05/19] Enforce bounty eligibility on submission and upload flows --- .../partners/upload-bounty-submission-file.ts | 8 ++- .../bounty/api/create-bounty-submission.ts | 67 ++++++++++++++----- .../api/get-bounty-submission-upload-url.ts | 50 +++++++++----- apps/web/lib/embed/referrals/auth.ts | 16 ++++- 4 files changed, 106 insertions(+), 35 deletions(-) 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/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index e652689e869..4b3ea9fa389 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -26,6 +26,7 @@ import { waitUntil } from "@vercel/functions"; import { formatDistanceToNow, isBefore } from "date-fns"; import * as z from "zod/v4"; import { SOCIAL_URL_HOST_TO_PLATFORM } from "../social-content"; +import { isPartnerEligibleForBounty } from "./bounty-eligibility"; type CreateBountySubmissionParams = z.infer< typeof createBountySubmissionInputSchema @@ -35,8 +36,17 @@ type CreateBountySubmissionParams = z.infer< type BountyWithRelations = Prisma.BountyGetPayload<{ include: { - groups: true; submissions: true; + groups: { + select: { + groupId: true; + }; + }; + partnerTags: { + select: { + partnerTagId: true; + }; + }; }; }>; @@ -57,7 +67,13 @@ export class BountySubmissionHandler { private submissions: BountySubmission[]; private submissionData: Partial; private programEnrollment: Prisma.ProgramEnrollmentGetPayload<{ - include: {}; + include: { + programPartnerTags: { + select: { + partnerTagId: true; + }; + }; + }; }>; constructor(params: CreateBountySubmissionParams) { @@ -99,7 +115,13 @@ export class BountySubmissionHandler { getProgramEnrollmentOrThrow({ partnerId: this.partner.id, programId: this.programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }), prisma.bounty.findUniqueOrThrow({ @@ -107,12 +129,21 @@ export class BountySubmissionHandler { id: this.bountyId, }, include: { - groups: true, submissions: { where: { partnerId: this.partner.id, }, }, + groups: { + select: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, }, }), ]); @@ -243,18 +274,24 @@ export class BountySubmissionHandler { } } - // Check group membership - if (this.bounty.groups.length > 0) { - const isInGroup = this.bounty.groups.find( - ({ groupId }) => groupId === this.programEnrollment.groupId, - ); + const bountyGroupIds = this.bounty.groups.map((g) => g.groupId); + const bountyTagIds = this.bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = this.programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); - if (!isInGroup) { - throw new DubApiError({ - code: "forbidden", - message: "You are not allowed to submit this bounty.", - }); - } + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: this.programEnrollment.groupId, + partnerTagIds, + }); + + if (!isEligible) { + throw new DubApiError({ + code: "forbidden", + message: "You are not allowed to submit this bounty.", + }); } // Validate bounty dates and status 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..5e8431265c9 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 @@ -4,20 +4,25 @@ 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 { BountyPartnerTag, ProgramEnrollment } from "@prisma/client"; +import { isPartnerEligibleForBounty } from "./bounty-eligibility"; const MAX_ATTEMPTS = 25; const CACHE_KEY_PREFIX = "bounty:submission:file:upload"; +type ProgramEnrollmentWithPartnerTags = Pick< + ProgramEnrollment, + "programId" | "partnerId" | "groupId" +> & { + 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; @@ -35,7 +40,8 @@ export async function getBountySubmissionUploadUrl({ contentLength, programEnrollment, }: GetBountySubmissionUploadUrlParams) { - const { programId, partnerId } = programEnrollment; + const { programId, partnerId, groupId, programPartnerTags } = + programEnrollment; if (!fileName.trim()) { throw new DubApiError({ @@ -90,6 +96,11 @@ export async function getBountySubmissionUploadUrl({ groupId: true, }, }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); @@ -100,17 +111,22 @@ export async function getBountySubmissionUploadUrl({ }); } - 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.", - }); - } + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = programPartnerTags.map((t) => t.partnerTagId); + + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: groupId, + partnerTagIds, + }); + + if (!isEligible) { + throw new DubApiError({ + code: "forbidden", + message: "You are not allowed to submit this bounty.", + }); } // Validate the bounty dates diff --git a/apps/web/lib/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts index ac142be36b2..c4943a3ec7e 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 { + BountyPartnerTag, + Link, + Program, + ProgramEnrollment, +} 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, + }, + }, }, }); From 15bdd7e1292de868443c37105ccd7c7782fdaa88 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 14:10:34 +0530 Subject: [PATCH 06/19] Enforce partner tag eligibility in workflows and fix eligibility logic --- .../[programId]/bounties/[bountyId]/route.ts | 6 +- .../[bountyId]/social-content-stats/route.ts | 4 +- .../programs/[programId]/bounties/route.ts | 17 +++++- .../execute-complete-bounty-workflow.ts | 57 +++++++++++++++---- .../lib/api/workflows/execute-workflows.ts | 8 +++ apps/web/lib/bounty/api/bounty-eligibility.ts | 14 ++--- .../bounty/api/create-bounty-submission.ts | 2 +- .../bounty/api/get-bounties-for-partner.ts | 17 ++++-- .../api/get-bounty-submission-upload-url.ts | 2 +- apps/web/lib/types.ts | 1 + 10 files changed, 93 insertions(+), 35 deletions(-) diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts index ec79d2efb8f..d0644af3171 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 @@ -75,7 +75,7 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { if (bounty.startsAt > new Date()) { throw new DubApiError({ code: "not_found", - message: "Bounty not found.", + message: "Bounty has not started yet.", }); } @@ -94,8 +94,8 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { if (!isEligible) { throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", + code: "forbidden", + message: "You are not eligible for this bounty.", }); } 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 90ae7c6a282..71ba2107afb 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 @@ -75,8 +75,8 @@ export const GET = withPartnerProfile( if (!isEligible) { throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", + code: "forbidden", + message: "You are not eligible for this 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 7b3884ce695..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,21 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { partnerId: partner.id, programId: params.programId, include: { - program: true, - links: true, + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + links: { + select: { + clicks: true, + leads: true, + conversions: true, + sales: true, + saleAmount: true, + }, + }, programPartnerTags: { select: { partnerTagId: true, 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..1f92c6cb538 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -1,4 +1,5 @@ import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility"; import { prisma } from "@/lib/prisma"; import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; @@ -38,7 +39,8 @@ export const executeCompleteBountyWorkflow = async ({ const { bountyId } = action.data; const { identity, metrics } = context; - const { partnerId, groupId, customerId, customerFirstSaleAt } = identity; + const { partnerId, groupId, partnerTagIds, customerId, customerFirstSaleAt } = + identity; if (!groupId) { console.error("Partner groupId not set in the context."); @@ -51,12 +53,32 @@ 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, + }, + }, + groups: { + select: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, }, }, }); @@ -91,16 +113,27 @@ export const executeCompleteBountyWorkflow = async ({ const { groups, submissions } = bounty; - // If the bounty is part of a group, check if the partner is in the group - if (groups.length > 0) { - const groupIds = groups.map(({ groupId }) => groupId); + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); - 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; - } + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: groupId, + partnerTagIds, + }); + + if (!isEligible) { + console.log( + `Partner ${partnerId} is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups or partner tags.`, + { + bountyGroupIds, + bountyTagIds, + partnerGroupId: groupId, + partnerTagIds, + }, + ); + return; } if (submissions.length > 0) { diff --git a/apps/web/lib/api/workflows/execute-workflows.ts b/apps/web/lib/api/workflows/execute-workflows.ts index aa209ab8d08..2d25df97dcd 100644 --- a/apps/web/lib/api/workflows/execute-workflows.ts +++ b/apps/web/lib/api/workflows/execute-workflows.ts @@ -139,6 +139,11 @@ export async function executeWorkflows({ saleAmount: true, }, }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }), @@ -182,6 +187,9 @@ export async function executeWorkflows({ identity: { ...identity, groupId: programEnrollment.groupId, + partnerTagIds: programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ), }, metrics: { ...metrics, diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts index a34e3406ad0..a1473913994 100644 --- a/apps/web/lib/bounty/api/bounty-eligibility.ts +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -57,12 +57,12 @@ export function isPartnerEligibleForBounty({ bountyGroupIds, bountyTagIds, partnerGroupId, - partnerTagIds, + partnerTagIds = [], }: { bountyGroupIds: string[]; bountyTagIds: string[]; partnerGroupId: string | null; - partnerTagIds: string[]; + partnerTagIds: string[] | undefined; }) { // No restrictions if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { @@ -71,15 +71,13 @@ export function isPartnerEligibleForBounty({ // Group restrictions const inGroup = - bountyGroupIds.length > 0 && - partnerGroupId && - bountyGroupIds.includes(partnerGroupId); + bountyGroupIds.length === 0 || + (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); // Tag restrictions const hasTag = - bountyTagIds.length > 0 && - partnerTagIds.length > 0 && - bountyTagIds.some((id) => partnerTagIds.includes(id)); + bountyTagIds.length === 0 || + partnerTagIds.some((id) => bountyTagIds.includes(id)); return inGroup && hasTag; } diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index 4b3ea9fa389..68126694711 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -290,7 +290,7 @@ export class BountySubmissionHandler { if (!isEligible) { throw new DubApiError({ code: "forbidden", - message: "You are not allowed to submit this bounty.", + message: "You are not eligible for this bounty.", }); } 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 ea3b221a81b..60cbb00101e 100644 --- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts +++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts @@ -1,10 +1,12 @@ -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, ProgramPartnerTag } from "@prisma/client"; +import { + Link, + Program, + ProgramEnrollment, + ProgramPartnerTag, +} from "@prisma/client"; import * as z from "zod/v4"; import { buildBountyEligibilityWhere } from "./bounty-eligibility"; @@ -13,7 +15,10 @@ type GetBountiesForPartnerParams = Pick< "groupId" | "partnerId" | "totalCommissions" > & { programPartnerTags: Pick[]; - links: PartnerLink[]; + links: Pick< + Link, + "clicks" | "leads" | "conversions" | "sales" | "saleAmount" + >[]; program: Pick; }; 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 5e8431265c9..a98e12e0f93 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 @@ -125,7 +125,7 @@ export async function getBountySubmissionUploadUrl({ if (!isEligible) { throw new DubApiError({ code: "forbidden", - message: "You are not allowed to submit this bounty.", + message: "You are not eligible for this bounty.", }); } diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 1d22a6f4d03..a74afcd11c1 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -846,6 +846,7 @@ interface WorkflowIdentity { programId: string; partnerId: string; groupId?: string; + partnerTagIds?: string[]; customerId?: string; customerFirstSaleAt?: Date; } From 038f88c156dfaebe89243b50d96c21c2cd72803a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 14:29:19 +0530 Subject: [PATCH 07/19] Centralize bounty eligibility includes and standardize bounty lookups --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 24 ++++++-------- apps/web/app/(ee)/api/bounties/route.ts | 16 +++------ .../create-draft-submissions/route.ts | 5 ++- .../cron/bounties/notify-partners/route.ts | 6 +++- .../[programId]/bounties/[bountyId]/route.ts | 33 +++++-------------- .../execute-complete-bounty-workflow.ts | 16 +++------ apps/web/lib/bounty/api/bounty-eligibility.ts | 15 +++++++++ .../bounty/api/create-bounty-submission.ts | 24 +++++--------- .../api/get-bounty-submission-upload-url.ts | 33 ++++++------------- 9 files changed, 72 insertions(+), 100 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 7438951f043..6d7f85e4c22 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -5,7 +5,9 @@ import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-progr 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"; @@ -73,20 +75,17 @@ export const PATCH = withWorkspace( 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, - partnerTags: true, workflow: true, _count: { select: { submissions: true, }, }, + ...bountyEligibilityIncludes, }, }); @@ -325,20 +324,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, - partnerTags: true, workflow: true, _count: { select: { submissions: true, }, }, + ...bountyEligibilityIncludes, }, }); diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 38a373e04e4..766c2a4ec45 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -7,7 +7,10 @@ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enro import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; -import { buildBountyEligibilityWhere } from "@/lib/bounty/api/bounty-eligibility"; +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 { qstash } from "@/lib/cron"; @@ -81,16 +84,7 @@ export const GET = withWorkspace( }), }, include: { - groups: { - select: { - groupId: true, - }, - }, - partnerTags: { - select: { - partnerTagId: true, - }, - }, + ...bountyEligibilityIncludes, }, }), 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..a85a4027b33 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,7 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { bountyEligibilityIncludes } 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"; @@ -12,6 +13,8 @@ import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; +// TODO: Fix this + export const dynamic = "force-dynamic"; const schema = z.object({ @@ -41,9 +44,9 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, program: true, workflow: true, + ...bountyEligibilityIncludes, }, }); 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..62e50071ab7 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"; @@ -12,6 +13,9 @@ import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; +// TODO: +// Fix this + export const dynamic = "force-dynamic"; const schema = z.object({ @@ -50,7 +54,6 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, program: { include: { emailDomains: { @@ -60,6 +63,7 @@ export async function POST(req: Request) { }, }, }, + ...bountyEligibilityIncludes, }, }); 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 d0644af3171..10a00d18cd9 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,9 +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 { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility"; +import { + bountyEligibilityIncludes, + isPartnerEligibleForBounty, +} from "@/lib/bounty/api/bounty-eligibility"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; -import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; @@ -26,22 +29,10 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }); - const bounty = await prisma.bounty.findUnique({ - where: { - id: bountyId, - programId: program.id, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, include: { - groups: { - select: { - groupId: true, - }, - }, - partnerTags: { - select: { - partnerTagId: true, - }, - }, workflow: { select: { triggerConditions: true, @@ -62,16 +53,10 @@ 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", 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 1f92c6cb538..8d2ad94b46d 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -1,5 +1,8 @@ import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; -import { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility"; +import { + bountyEligibilityIncludes, + isPartnerEligibleForBounty, +} from "@/lib/bounty/api/bounty-eligibility"; import { prisma } from "@/lib/prisma"; import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types"; import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows"; @@ -70,16 +73,7 @@ export const executeCompleteBountyWorkflow = async ({ supportEmail: true, }, }, - groups: { - select: { - groupId: true, - }, - }, - partnerTags: { - select: { - partnerTagId: true, - }, - }, + ...bountyEligibilityIncludes, }, }); diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts index a1473913994..832f1a8bfde 100644 --- a/apps/web/lib/bounty/api/bounty-eligibility.ts +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -1,3 +1,5 @@ +import { Prisma } from "@prisma/client"; + export function buildBountyEligibilityWhere({ groupId, partnerTagIds, @@ -81,3 +83,16 @@ export function isPartnerEligibleForBounty({ return inGroup && hasTag; } + +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 68126694711..c610d8f60c4 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -26,7 +26,11 @@ import { waitUntil } from "@vercel/functions"; import { formatDistanceToNow, isBefore } from "date-fns"; import * as z from "zod/v4"; import { SOCIAL_URL_HOST_TO_PLATFORM } from "../social-content"; -import { isPartnerEligibleForBounty } from "./bounty-eligibility"; +import { + bountyEligibilityIncludes, + isPartnerEligibleForBounty, +} from "./bounty-eligibility"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; type CreateBountySubmissionParams = z.infer< typeof createBountySubmissionInputSchema @@ -124,26 +128,16 @@ export class BountySubmissionHandler { }, }), - prisma.bounty.findUniqueOrThrow({ - where: { - id: this.bountyId, - }, + getBountyOrThrow({ + bountyId: this.bountyId, + programId: this.programId, include: { submissions: { where: { partnerId: this.partner.id, }, }, - groups: { - select: { - groupId: true, - }, - }, - partnerTags: { - select: { - partnerTagId: true, - }, - }, + ...bountyEligibilityIncludes, }, }), ]); 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 a98e12e0f93..60694e938c8 100644 --- a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts +++ b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts @@ -1,11 +1,14 @@ 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 { BountyPartnerTag, ProgramEnrollment } from "@prisma/client"; -import { isPartnerEligibleForBounty } from "./bounty-eligibility"; +import { + bountyEligibilityIncludes, + isPartnerEligibleForBounty, +} from "./bounty-eligibility"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; const MAX_ATTEMPTS = 25; const CACHE_KEY_PREFIX = "bounty:submission:file:upload"; @@ -80,27 +83,11 @@ 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, - }, - }, - partnerTags: { - select: { - partnerTagId: true, - }, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, + include: { + ...bountyEligibilityIncludes, }, }); From 7e0c44fc195d05fbaa32bbc3dead7c6b14cd810b Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 15:00:02 +0530 Subject: [PATCH 08/19] Apply partner tag eligibility to draft submissions and bounty APIs --- .../bounties/[bountyId]/submissions/route.ts | 3 - apps/web/app/(ee)/api/bounties/route.ts | 3 +- .../create-draft-submissions/route.ts | 13 +- .../cron/bounties/notify-partners/route.ts | 21 ++- .../[bountyId]/social-content-stats/route.ts | 35 ++-- .../[bountyId]/social-content-stats/route.ts | 16 +- .../lib/bounty/api/get-bounties-by-groups.ts | 38 ----- .../api/trigger-draft-bounty-submissions.ts | 160 ++++++++++-------- .../webhook/sample-events/bounty-created.json | 5 + .../webhook/sample-events/bounty-updated.json | 5 + 10 files changed, 152 insertions(+), 147 deletions(-) delete mode 100644 apps/web/lib/bounty/api/get-bounties-by-groups.ts 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 168bf4dd373..dbad72d5708 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts @@ -17,9 +17,6 @@ export const GET = withWorkspace( await getBountyOrThrow({ bountyId, programId, - include: { - groups: true, - }, }); const { diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts index 766c2a4ec45..38872d6c1de 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -291,9 +291,8 @@ export const POST = withWorkspace( }), }, include: { - groups: true, - partnerTags: true, workflow: true, + ...bountyEligibilityIncludes, }, }); }); 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 a85a4027b33..cd78fde8543 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 @@ -78,8 +78,10 @@ export async function POST(req: Request) { return logAndRespond(`Bounty ${bountyId} has no workflow.`); } - // Find groupIds const groupIds = bounty.groups.map(({ groupId }) => groupId); + const partnerTagIds = bounty.partnerTags.map( + ({ partnerTagId }) => partnerTagId, + ); // Find program enrollments const programEnrollments = await prisma.programEnrollment.findMany({ @@ -95,6 +97,15 @@ export async function POST(req: Request) { in: partnerIds, }, }), + ...(partnerTagIds.length > 0 && { + programPartnerTags: { + some: { + partnerTagId: { + in: partnerTagIds, + }, + }, + }, + }), status: { in: ["approved", "invited"], }, 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 62e50071ab7..d9c51c74aaf 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 @@ -81,14 +81,16 @@ export async function POST(req: Request) { ); } - // Find groupIds 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, ); + console.log(`Bounty ${bountyId} eligibility:`, { + groupIds, + partnerTagIds, + }); + const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: bounty.programId, @@ -97,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/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..15dbf7167b5 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, + isPartnerEligibleForBounty, +} 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,21 +36,28 @@ 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 bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); + + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: programEnrollment.groupId, + partnerTagIds, + }); + + if (!isEligible) { + throw new DubApiError({ + code: "forbidden", + message: "You are not eligible for this bounty.", + }); } const now = new Date(); 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 71ba2107afb..f2166a0ab87 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,7 +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 { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility"; +import { + bountyEligibilityIncludes, + isPartnerEligibleForBounty, +} 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"; @@ -47,16 +50,7 @@ export const GET = withPartnerProfile( bountyId, programId: programEnrollment.programId, include: { - groups: { - select: { - groupId: true, - }, - }, - partnerTags: { - select: { - partnerTagId: true, - }, - }, + ...bountyEligibilityIncludes, }, }); 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/trigger-draft-bounty-submissions.ts b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts index 068653dd8ea..8c10736d150 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, + isPartnerEligibleForBounty, +} 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,101 @@ 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, + }, + select: { + defaultGroupId: true, }, + }), + + prisma.programEnrollment.findMany({ + where: { + partnerId: { + in: partnerIds, + }, + programId, + }, + select: { + partnerId: true, + groupId: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, + }), + ]); + + if (!program || programEnrollments.length === 0) { + return; + } + + const now = new Date(); + + const eligibleBounties = await prisma.bounty.findMany({ + where: { programId, + type: "performance", + performanceScope: "lifetime", + startsAt: { + lte: now, + }, + OR: [{ endsAt: null }, { endsAt: { gt: now } }], }, - select: { - partnerId: true, - groupId: true, + include: { + ...bountyEligibilityIncludes, }, }); - if (programEnrollments.length === 0) { + if (eligibleBounties.length === 0) { + console.log( + `No eligible performance bounties found for program ${programId}.`, + ); return; } - const groupIds = [ - ...new Set( - programEnrollments - .map(({ groupId }) => groupId) - .filter((id): id is string => id !== null), - ), - ]; + await Promise.allSettled( + eligibleBounties.map(async (bounty) => { + const bountyGroupIds = bounty.groups.map(({ groupId }) => groupId); + const bountyTagIds = bounty.partnerTags.map( + ({ partnerTagId }) => partnerTagId, + ); - const bountiesByGroups = await getBountiesByGroups({ - programId, - groupIds, - }); + const eligiblePartnerIds = programEnrollments + .filter((enrollment) => + isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: enrollment.groupId ?? program.defaultGroupId, + partnerTagIds: enrollment.programPartnerTags.map( + ({ partnerTagId }) => partnerTagId, + ), + }), + ) + .map(({ partnerId }) => partnerId); - const partnersByGroup = programEnrollments.reduce( - (acc, enrollment) => { - if (enrollment.groupId) { - acc[enrollment.groupId] = [ - ...(acc[enrollment.groupId] || []), - enrollment.partnerId, - ]; + if (eligiblePartnerIds.length === 0) { + console.log( + `No eligible partners found for bounty ${bounty.id} in program ${programId}.`, + ); + return; } - return acc; - }, - {} 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.`, + `Found ${eligiblePartnerIds.length} eligible partners for bounty ${bounty.id}.`, ); - 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, - }, - }), - ), - ); - } -} - -function isEligiblePerformanceBounty(bounty: Bounty) { - const now = new Date(); - - 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; - - 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/webhook/sample-events/bounty-created.json b/apps/web/lib/webhook/sample-events/bounty-created.json index eb772cfae8a..d35cca28e62 100644 --- a/apps/web/lib/webhook/sample-events/bounty-created.json +++ b/apps/web/lib/webhook/sample-events/bounty-created.json @@ -20,5 +20,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..5a10fbe34a9 100644 --- a/apps/web/lib/webhook/sample-events/bounty-updated.json +++ b/apps/web/lib/webhook/sample-events/bounty-updated.json @@ -20,5 +20,10 @@ { "id": "grp_1K2E25381GVMG7HHM057TB92F" } + ], + "partnerTags": [ + { + "id": "ptg_1K2E25381GVMG7HHM057TB92F" + } ] } From c3a85524ce317845de7b9abe9b5867a46d2fbfe0 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 15:04:45 +0530 Subject: [PATCH 09/19] Trigger draft bounty submissions when partner tags are added --- .../cron/bounties/create-draft-submissions/route.ts | 2 -- .../partners/tags/update-program-partner-tags.ts | 10 ++++++++++ .../lib/bounty/api/trigger-draft-bounty-submissions.ts | 7 +++++++ 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts index cd78fde8543..76deee08182 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 @@ -13,8 +13,6 @@ import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; -// TODO: Fix this - export const dynamic = "force-dynamic"; const schema = z.object({ 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/bounty/api/trigger-draft-bounty-submissions.ts b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts index 8c10736d150..b9fc596dd98 100644 --- a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts +++ b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts @@ -71,6 +71,13 @@ export async function triggerDraftBountySubmissionCreation({ return; } + console.log( + `Found ${eligibleBounties.length} eligible performance bounties for program ${programId}.`, + { + eligibleBounties, + }, + ); + await Promise.allSettled( eligibleBounties.map(async (bounty) => { const bountyGroupIds = bounty.groups.map(({ groupId }) => groupId); From f68639db027e0ae0a84dcefcdad5f37eabe0f566 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 15:21:04 +0530 Subject: [PATCH 10/19] Add bounty eligibility UI for groups and partner tags --- .../add-edit-bounty/bounty-eligibility.tsx | 410 ++++++++++++++++++ .../confirm-create-bounty-modal.tsx | 1 + .../use-add-edit-bounty-form.ts | 3 +- 3 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx 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..b59dd6605f8 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx @@ -0,0 +1,410 @@ +"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( + Boolean(groups && !useAsync && groups.length >= GROUPS_MAX_PAGE_SIZE), + ), + [groups, useAsync], + ); + + 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( + Boolean( + partnerTags && + !useAsync && + partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, + ), + ), + [partnerTags, useAsync], + ); + + 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 change + useEffect(() => setShouldSort(true), [items]); + + 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 9d3d28251b9..07e295a4ab2 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; }; 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 c3a98685b69..652030a250c 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 @@ -24,8 +24,7 @@ const ACCORDION_ITEMS = [ "bounty-type", "bounty-details", "bounty-criteria", - "groups", - "partnerTags", + "eligibility", ]; const isEmpty = (value: unknown) => From abc40dc9bbf7a4d7c3d0d1eea13799f9e7be486a Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 15:29:52 +0530 Subject: [PATCH 11/19] Update route.ts --- .../programs/[programId]/bounties/[bountyId]/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 10a00d18cd9..6eae51cbad6 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 @@ -31,7 +31,7 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { const bounty = await getBountyOrThrow({ bountyId, - programId, + programId: program.id, include: { workflow: { select: { From f435665330cd100a123f166669a127ef53b72fca Mon Sep 17 00:00:00 2001 From: Kiran K Date: Mon, 22 Jun 2026 15:47:04 +0530 Subject: [PATCH 12/19] Fix bounty group and partner tag removal on update Treat null eligibility selections as empty so PATCH clears existing restrictions, and refresh the eligibility picker when selections change. --- .../app/(ee)/api/bounties/[bountyId]/route.ts | 78 ++++++++++++------- .../add-edit-bounty/bounty-eligibility.tsx | 4 +- 2 files changed, 50 insertions(+), 32 deletions(-) diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index 6d7f85e4c22..31b0d1917a8 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -122,32 +122,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; - if ( - partnerTagIds && - !arrayEqual( - bounty.partnerTags.map((tag) => tag.partnerTagId), - partnerTagIds, - ) - ) { - updatedPartnerTags = await throwIfInvalidPartnerTagIds({ - programId, - partnerTagIds, - }); + 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 @@ -228,20 +240,26 @@ 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, + })), + }), }, }), - ...(updatedPartnerTags && { + ...(shouldUpdatePartnerTags && { partnerTags: { deleteMany: {}, - create: updatedPartnerTags.map((tag) => ({ - partnerTagId: tag.id, - })), + ...(updatedPartnerTags && + updatedPartnerTags.length > 0 && { + create: updatedPartnerTags.map((tag) => ({ + partnerTagId: tag.id, + })), + }), }, }), }, 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 index b59dd6605f8..118d75c9aeb 100644 --- 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 @@ -324,8 +324,8 @@ function BountyEligibilityMultiSelect({ setShouldSort(false); }, [shouldSort, items, selectedIds, selectedItems, sortItems, search]); - // Re-sort when the search-filtered items change - useEffect(() => setShouldSort(true), [items]); + // Re-sort when the search-filtered items or selection changes + useEffect(() => setShouldSort(true), [items, selectedIds]); return ( From 07835fa61f313ef125fd35b1f610774568a95ad5 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 23 Jun 2026 11:10:36 +0530 Subject: [PATCH 13/19] address CR comments --- .../add-edit-bounty/bounty-eligibility.tsx | 29 +-- apps/web/lib/bounty/api/bounty-eligibility.ts | 6 +- .../api/get-bounty-submission-upload-url.ts | 4 +- apps/web/lib/embed/referrals/auth.ts | 4 +- .../tags/partner-tags-multi-select.tsx | 246 ------------------ 5 files changed, 20 insertions(+), 269 deletions(-) delete mode 100644 apps/web/ui/partners/tags/partner-tags-multi-select.tsx 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 index 118d75c9aeb..0876e27bf7e 100644 --- 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 @@ -161,13 +161,12 @@ function GroupsEligibilitySelect({ }); // Determine if we should use async loading - useEffect( - () => - setUseAsync( - Boolean(groups && !useAsync && groups.length >= GROUPS_MAX_PAGE_SIZE), - ), - [groups, useAsync], - ); + useEffect(() => { + setUseAsync( + (prev) => + prev || Boolean(groups && groups.length >= GROUPS_MAX_PAGE_SIZE), + ); + }, [groups]); return ( - setUseAsync( + useEffect(() => { + setUseAsync( + (prev) => + prev || Boolean( - partnerTags && - !useAsync && - partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, + partnerTags && partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, ), - ), - [partnerTags, useAsync], - ); + ); + }, [partnerTags]); return ( bountyTagIds.includes(id)); - return inGroup && hasTag; + return Boolean(inGroup && hasTag); } export const bountyEligibilityIncludes = { 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 60694e938c8..2f63c8d586d 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 @@ -3,7 +3,7 @@ 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 { BountyPartnerTag, ProgramEnrollment } from "@prisma/client"; +import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; import { bountyEligibilityIncludes, isPartnerEligibleForBounty, @@ -17,7 +17,7 @@ type ProgramEnrollmentWithPartnerTags = Pick< ProgramEnrollment, "programId" | "partnerId" | "groupId" > & { - programPartnerTags: Pick[]; + programPartnerTags: Pick[]; }; type GetBountySubmissionUploadUrlParams = { diff --git a/apps/web/lib/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts index c4943a3ec7e..001370bc955 100644 --- a/apps/web/lib/embed/referrals/auth.ts +++ b/apps/web/lib/embed/referrals/auth.ts @@ -5,10 +5,10 @@ import { PartnerGroupProps } from "@/lib/types"; import { ratelimit } from "@/lib/upstash"; import { getSearchParams } from "@dub/utils"; import { - BountyPartnerTag, Link, Program, ProgramEnrollment, + ProgramPartnerTag, } from "@prisma/client"; import { headers } from "next/headers"; import { referralsEmbedToken } from "./token-class"; @@ -29,7 +29,7 @@ interface WithReferralsEmbedTokenHandler { searchParams: Record; program: Program; programEnrollment: ProgramEnrollment & { - programPartnerTags: Pick[]; + programPartnerTags: Pick[]; }; group: PartnerGroupProps; links: Link[]; diff --git a/apps/web/ui/partners/tags/partner-tags-multi-select.tsx b/apps/web/ui/partners/tags/partner-tags-multi-select.tsx deleted file mode 100644 index d00b0412e0d..00000000000 --- a/apps/web/ui/partners/tags/partner-tags-multi-select.tsx +++ /dev/null @@ -1,246 +0,0 @@ -import { usePartnerTags } from "@/lib/swr/use-partner-tags"; -import { usePartnerTagsCount } from "@/lib/swr/use-partner-tags-count"; -import { PartnerTagProps } from "@/lib/types"; -import { PARTNER_TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/partner-tags"; -import { - AnimatedSizeContainer, - Check2, - LoadingSpinner, - Magnifier, - ScrollContainer, - Tag, - ToggleGroup, -} from "@dub/ui"; -import { cn } from "@dub/utils"; -import { Command } from "cmdk"; -import { useCallback, useEffect, useState } from "react"; -import { useDebounce } from "use-debounce"; - -interface PartnerTagsMultiSelectProps { - selectedTagIds: string[] | null; - setSelectedTagIds: (tagIds: string[] | null) => void; - className?: string; -} - -export function PartnerTagsMultiSelect({ - selectedTagIds, - setSelectedTagIds, - className, -}: PartnerTagsMultiSelectProps) { - const [selectedMode, setSelectedMode] = useState<"all" | "select">( - selectedTagIds?.length ? "select" : "all", - ); - - const [search, setSearch] = useState(""); - const [useAsync, setUseAsync] = useState(false); - const [debouncedSearch] = useDebounce(search, 500); - - const [shouldSortTags, setShouldSortTags] = useState(false); - const [sortedTags, setSortedTags] = useState( - undefined, - ); - - const { partnerTagsCount } = usePartnerTagsCount(); - - const { partnerTags } = usePartnerTags({ - query: { ...(useAsync ? { search: debouncedSearch } : undefined) }, - }); - - const { partnerTags: selectedTags } = usePartnerTags({ - query: { ids: selectedTagIds ?? undefined }, - enabled: Boolean(selectedTagIds?.length), - }); - - // Determine if we should use async loading - useEffect( - () => - setUseAsync( - Boolean( - partnerTags && - !useAsync && - partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, - ), - ), - [partnerTags, useAsync], - ); - - const sortTags = useCallback( - (tags: PartnerTagProps[], search: string) => { - return search === "" - ? [ - ...tags.filter((t) => selectedTagIds?.includes(t.id)), - ...tags.filter((t) => !selectedTagIds?.includes(t.id)), - ] - : tags; - }, - [selectedTagIds], - ); - - // Actually sort the tags when needed - useEffect(() => { - if ( - !shouldSortTags || - !partnerTags || - (selectedTagIds?.length && !selectedTags) - ) - return; - - setSortedTags( - sortTags( - [ - ...(selectedTags ?? []), - ...partnerTags.filter( - (t) => !selectedTags?.some((st) => st.id === t.id), - ), - ], - search, - ), - ); - setShouldSortTags(false); - }, [ - shouldSortTags, - partnerTags, - selectedTagIds, - selectedTags, - sortTags, - search, - ]); - - // Sort when the search-filtered tags change - useEffect(() => setShouldSortTags(true), [partnerTags]); - - return ( -
- { - setSelectedMode(value as "all" | "select"); - if (value === "all") setSelectedTagIds(null); - }} - /> - -
- -
- {selectedMode === "all" ? ( -
-
- - {partnerTagsCount === undefined ? ( -
- ) : ( - partnerTagsCount - )} -
- - Tags selected - -
- ) : ( - - - - - {sortedTags !== undefined ? ( - <> - {sortedTags.map((tag) => { - const checked = Boolean( - selectedTagIds?.includes(tag.id), - ); - - return ( - - setSelectedTagIds( - selectedTagIds?.includes(tag.id) - ? selectedTagIds.length === 1 - ? null // Revert to null if there will be no tags selected - : selectedTagIds.filter( - (id) => id !== tag.id, - ) - : [...(selectedTagIds ?? []), tag.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 - )} - -
-
- - - {tag.name} - -
-
- ); - })} - {!useAsync ? ( - - No matches - - ) : sortedTags.length === 0 ? ( -
- No matches -
- ) : null} - - ) : ( - // undefined data / explicit loading state - -
- -
-
- )} -
-
-
- )} -
- -
-
- ); -} From d07b97a155066d4449418535ee0d522a6222cae6 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 23 Jun 2026 11:13:26 +0530 Subject: [PATCH 14/19] Update route.ts --- apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index d9c51c74aaf..8008a7f095d 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 @@ -13,9 +13,6 @@ import { differenceInMinutes } from "date-fns"; import * as z from "zod/v4"; import { logAndRespond } from "../../utils"; -// TODO: -// Fix this - export const dynamic = "force-dynamic"; const schema = z.object({ From 556ed95d06278a88019eba450d365327b530a52f Mon Sep 17 00:00:00 2001 From: Kiran K Date: Tue, 23 Jun 2026 12:31:19 +0530 Subject: [PATCH 15/19] Update execute-complete-bounty-workflow.ts --- apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8d2ad94b46d..78fb285db23 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -105,7 +105,7 @@ export const executeCompleteBountyWorkflow = async ({ return; } - const { groups, submissions } = bounty; + const { submissions } = bounty; const bountyGroupIds = bounty.groups.map((g) => g.groupId); const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); From f02f4221e0a0b3d74017ee0832a5527ba3a08bcc Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 24 Jun 2026 12:37:44 +0530 Subject: [PATCH 16/19] code cleanup --- .../app/(ee)/api/cron/bounties/notify-partners/route.ts | 5 ----- .../web/lib/bounty/api/get-bounty-submission-upload-url.ts | 7 ------- 2 files changed, 12 deletions(-) diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts index 8008a7f095d..829d48214d3 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 @@ -83,11 +83,6 @@ export async function POST(req: Request) { ({ partnerTagId }) => partnerTagId, ); - console.log(`Bounty ${bountyId} eligibility:`, { - groupIds, - partnerTagIds, - }); - const programEnrollments = await prisma.programEnrollment.findMany({ where: { programId: bounty.programId, 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 2f63c8d586d..0994c3908d5 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 @@ -91,13 +91,6 @@ export async function getBountySubmissionUploadUrl({ }, }); - if (bounty.programId !== programId) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is not for this program.", - }); - } - const bountyGroupIds = bounty.groups.map((g) => g.groupId); const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); const partnerTagIds = programPartnerTags.map((t) => t.partnerTagId); From fc8c125ee8d5d8b8ac8d0e31e749a9c530bbf750 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 24 Jun 2026 12:46:06 +0530 Subject: [PATCH 17/19] add throwIfPartnerNotEligibleForBounty --- .../[bountyId]/social-content-stats/route.ts | 11 ++------ .../[programId]/bounties/[bountyId]/route.ts | 11 ++------ .../[bountyId]/social-content-stats/route.ts | 11 ++------ apps/web/lib/bounty/api/bounty-eligibility.ts | 27 +++++++++++++++++++ .../bounty/api/create-bounty-submission.ts | 11 ++------ .../api/get-bounty-submission-upload-url.ts | 11 ++------ 6 files changed, 37 insertions(+), 45 deletions(-) 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 15dbf7167b5..20c389372ad 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 @@ -2,7 +2,7 @@ import { DubApiError } from "@/lib/api/errors"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; import { bountyEligibilityIncludes, - isPartnerEligibleForBounty, + throwIfPartnerNotEligibleForBounty, } from "@/lib/bounty/api/bounty-eligibility"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; @@ -46,20 +46,13 @@ export const GET = withReferralsEmbedToken( (t) => t.partnerTagId, ); - const isEligible = isPartnerEligibleForBounty({ + throwIfPartnerNotEligibleForBounty({ bountyGroupIds, bountyTagIds, partnerGroupId: programEnrollment.groupId, partnerTagIds, }); - if (!isEligible) { - throw new DubApiError({ - code: "forbidden", - message: "You are not eligible for this bounty.", - }); - } - const now = new Date(); if (bounty.startsAt && bounty.startsAt > now) { 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 6eae51cbad6..0355d7432c0 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 @@ -3,7 +3,7 @@ import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enro import { withPartnerProfile } from "@/lib/auth/partner"; import { bountyEligibilityIncludes, - isPartnerEligibleForBounty, + throwIfPartnerNotEligibleForBounty, } from "@/lib/bounty/api/bounty-eligibility"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; @@ -70,20 +70,13 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { const bountyGroupIds = bounty.groups.map((g) => g.groupId); const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); - const isEligible = isPartnerEligibleForBounty({ + throwIfPartnerNotEligibleForBounty({ bountyGroupIds, bountyTagIds, partnerGroupId: programEnrollment.groupId, partnerTagIds, }); - if (!isEligible) { - throw new DubApiError({ - code: "forbidden", - message: "You are not eligible for this bounty.", - }); - } - const { groups, ...bountyWithoutGroups } = bounty; return NextResponse.json( diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts index f2166a0ab87..893ceb5676d 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 @@ -4,7 +4,7 @@ import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; import { withPartnerProfile } from "@/lib/auth/partner"; import { bountyEligibilityIncludes, - isPartnerEligibleForBounty, + throwIfPartnerNotEligibleForBounty, } from "@/lib/bounty/api/bounty-eligibility"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; @@ -60,20 +60,13 @@ export const GET = withPartnerProfile( (t) => t.partnerTagId, ); - const isEligible = isPartnerEligibleForBounty({ + throwIfPartnerNotEligibleForBounty({ bountyGroupIds, bountyTagIds, partnerGroupId: programEnrollment.groupId, partnerTagIds, }); - if (!isEligible) { - throw new DubApiError({ - code: "forbidden", - message: "You are not eligible for this bounty.", - }); - } - const bountyInfo = resolveBountyDetails(bounty); if (!bountyInfo?.socialMetrics) { diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts index 0001a8fc0b7..c8633968ffb 100644 --- a/apps/web/lib/bounty/api/bounty-eligibility.ts +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -1,3 +1,4 @@ +import { DubApiError } from "@/lib/api/errors"; import { Prisma } from "@prisma/client"; export function buildBountyEligibilityWhere({ @@ -84,6 +85,32 @@ export function isPartnerEligibleForBounty({ return Boolean(inGroup && hasTag); } +export function throwIfPartnerNotEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId, + partnerTagIds = [], +}: { + bountyGroupIds: string[]; + bountyTagIds: string[]; + partnerGroupId: string | null; + partnerTagIds: string[] | undefined; +}) { + const isEligible = isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId, + partnerTagIds, + }); + + if (!isEligible) { + throw new DubApiError({ + code: "forbidden", + message: "You are not eligible for this bounty.", + }); + } +} + export const bountyEligibilityIncludes = { groups: { select: { diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index c610d8f60c4..203296f157a 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -28,7 +28,7 @@ import * as z from "zod/v4"; import { SOCIAL_URL_HOST_TO_PLATFORM } from "../social-content"; import { bountyEligibilityIncludes, - isPartnerEligibleForBounty, + throwIfPartnerNotEligibleForBounty, } from "./bounty-eligibility"; import { getBountyOrThrow } from "./get-bounty-or-throw"; @@ -274,20 +274,13 @@ export class BountySubmissionHandler { (t) => t.partnerTagId, ); - const isEligible = isPartnerEligibleForBounty({ + throwIfPartnerNotEligibleForBounty({ bountyGroupIds, bountyTagIds, partnerGroupId: this.programEnrollment.groupId, partnerTagIds, }); - if (!isEligible) { - throw new DubApiError({ - code: "forbidden", - message: "You are not eligible for this bounty.", - }); - } - // Validate bounty dates and status const now = new Date(); 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 0994c3908d5..ad0b3eb6599 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 @@ -6,7 +6,7 @@ import { nanoid, R2_URL } from "@dub/utils"; import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; import { bountyEligibilityIncludes, - isPartnerEligibleForBounty, + throwIfPartnerNotEligibleForBounty, } from "./bounty-eligibility"; import { getBountyOrThrow } from "./get-bounty-or-throw"; @@ -95,20 +95,13 @@ export async function getBountySubmissionUploadUrl({ const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); const partnerTagIds = programPartnerTags.map((t) => t.partnerTagId); - const isEligible = isPartnerEligibleForBounty({ + throwIfPartnerNotEligibleForBounty({ bountyGroupIds, bountyTagIds, partnerGroupId: groupId, partnerTagIds, }); - if (!isEligible) { - throw new DubApiError({ - code: "forbidden", - message: "You are not eligible for this bounty.", - }); - } - // Validate the bounty dates const now = new Date(); From 6e93fb98084ab0cc3d2186e950cbc1ad1a590438 Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 24 Jun 2026 12:52:19 +0530 Subject: [PATCH 18/19] Update trigger-draft-bounty-submissions.ts --- apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b9fc596dd98..5ee735da2f8 100644 --- a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts +++ b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts @@ -90,7 +90,7 @@ export async function triggerDraftBountySubmissionCreation({ isPartnerEligibleForBounty({ bountyGroupIds, bountyTagIds, - partnerGroupId: enrollment.groupId ?? program.defaultGroupId, + partnerGroupId: enrollment.groupId, partnerTagIds: enrollment.programPartnerTags.map( ({ partnerTagId }) => partnerTagId, ), From 6c080e5ddc8ec3c0fa988eade509cf8b13ab5f0d Mon Sep 17 00:00:00 2001 From: Kiran K Date: Wed, 24 Jun 2026 13:22:24 +0530 Subject: [PATCH 19/19] Show partner tags on bounty card and detail page --- .../bounties/[bountyId]/bounty-info.tsx | 104 +++++++++---- .../(ee)/program/bounties/bounty-card.tsx | 143 +++++++++++++----- 2 files changed, 178 insertions(+), 69 deletions(-) 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..c740eadab67 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 ; } @@ -140,38 +152,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/bounty-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx index 4b09f0f695a..3b6db72efff 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,4 +1,5 @@ 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"; @@ -7,10 +8,13 @@ import { BountyThumbnailImage } from "@/ui/partners/bounties/bounty-thumbnail-im 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 +23,7 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) { }); const { groups } = useGroups(); + const { partnerTags } = usePartnerTags(); const eligibleGroups = useMemo(() => { if (!groups || bounty.groups.length === 0) { @@ -29,6 +34,16 @@ 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.groups.length === 0 ? ( - All groups - ) : eligibleGroups.length > 0 ? ( - 1 - ? { - content: ( - - {eligibleGroups.map((group) => ( -
- - - {group.name} - -
- ))} -
- ), +
+ +
+ {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 && ( + <> + · + {eligibleTags.length > 0 ? ( + 1 + ? { + content: ( + + {eligibleTags.map((tag) => ( + + {tag.name} + + ))} + + ), + } + : undefined } - : undefined - } - > -
- - - {eligibleGroups[0].name}{" "} - {eligibleGroups.length > 1 - ? `+${eligibleGroups.length - 1}` - : ""} - -
-
- ) : ( -
- )} + > +
+ + {eligibleTags[0].name} + + {eligibleTags.length > 1 && ( + + +{eligibleTags.length - 1} + + )} +
+ + ) : ( +
+ )} + + )} +