diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts index d1b15d57297..31b0d1917a8 100644 --- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts +++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts @@ -2,9 +2,12 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log"; import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; +import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details"; import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; @@ -18,7 +21,7 @@ import { updateBountySchema, } from "@/lib/zod/schemas/bounties"; import { arrayEqual, deepEqual } from "@dub/utils"; -import { PartnerGroup, Prisma } from "@prisma/client"; +import { PartnerGroup, PartnerTag, Prisma } from "@prisma/client"; import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; @@ -69,21 +72,20 @@ export const PATCH = withWorkspace( submissionRequirements, performanceCondition, groupIds, + partnerTagIds, } = updateBountySchema.parse(await parseRequestBody(req)); - const bounty = await prisma.bounty.findUniqueOrThrow({ - where: { - id: bountyId, - programId, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, include: { - groups: true, workflow: true, _count: { select: { submissions: true, }, }, + ...bountyEligibilityIncludes, }, }); @@ -120,17 +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; + 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 @@ -211,18 +240,33 @@ export const PATCH = withWorkspace( submissionRequirements !== undefined && { submissionRequirements: submissionRequirements ?? Prisma.DbNull, }), - ...(updatedPartnerGroups && { + ...(shouldUpdatePartnerGroups && { groups: { deleteMany: {}, - create: updatedPartnerGroups.map((group) => ({ - groupId: group.id, - })), + ...(updatedPartnerGroups && + updatedPartnerGroups.length > 0 && { + create: updatedPartnerGroups.map((group) => ({ + groupId: group.id, + })), + }), + }, + }), + ...(shouldUpdatePartnerTags && { + partnerTags: { + deleteMany: {}, + ...(updatedPartnerTags && + updatedPartnerTags.length > 0 && { + create: updatedPartnerTags.map((tag) => ({ + partnerTagId: tag.id, + })), + }), }, }), }, include: { - workflow: true, groups: true, + partnerTags: true, + workflow: true, }, }); @@ -246,6 +290,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], }); @@ -295,19 +342,17 @@ export const DELETE = withWorkspace( const { bountyId } = params; const programId = getDefaultProgramIdOrThrow(workspace); - const bounty = await prisma.bounty.findUniqueOrThrow({ - where: { - id: bountyId, - programId, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, include: { - groups: true, workflow: true, _count: { select: { submissions: true, }, }, + ...bountyEligibilityIncludes, }, }); @@ -338,6 +383,9 @@ export const DELETE = withWorkspace( const deletedBounty = BountySchema.parse({ ...bounty, groups: bounty.groups.map(({ groupId }) => ({ id: groupId })), + partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({ + id: partnerTagId, + })), performanceCondition: bounty.workflow?.triggerConditions?.[0], }); diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts index 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 c72b5fc6097..38872d6c1de 100644 --- a/apps/web/app/(ee)/api/bounties/route.ts +++ b/apps/web/app/(ee)/api/bounties/route.ts @@ -4,8 +4,13 @@ import { DubApiError } from "@/lib/api/errors"; import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; +import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids"; import { parseRequestBody } from "@/lib/api/utils"; import { withWorkspace } from "@/lib/auth"; +import { + bountyEligibilityIncludes, + buildBountyEligibilityWhere, +} from "@/lib/bounty/api/bounty-eligibility"; import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name"; import { validateBounty } from "@/lib/bounty/api/validate-bounty"; import { qstash } from "@/lib/cron"; @@ -42,10 +47,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: { @@ -57,36 +74,20 @@ 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, + }), }, ], }), }, include: { - groups: { - select: { - groupId: true, - }, - }, + ...bountyEligibilityIncludes, }, }), + includeSubmissionsCount ? prisma.bountySubmission.groupBy({ by: ["bountyId", "status"], @@ -131,6 +132,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 +175,7 @@ export const POST = withWorkspace( maxSubmissions, submissionRequirements, groupIds, + partnerTagIds, performanceCondition, performanceScope, sendNotificationEmails, @@ -191,10 +196,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 +280,19 @@ export const POST = withWorkspace( }, }, }), + ...(partnerTags.length && { + partnerTags: { + createMany: { + data: partnerTags.map(({ id }) => ({ + partnerTagId: id, + })), + }, + }, + }), }, include: { workflow: true, - groups: true, + ...bountyEligibilityIncludes, }, }); }); @@ -279,6 +300,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/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts index 9f398264e98..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 @@ -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"; @@ -41,9 +42,9 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, program: true, workflow: true, + ...bountyEligibilityIncludes, }, }); @@ -75,8 +76,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({ @@ -92,6 +95,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 8b93144b28c..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 @@ -1,5 +1,6 @@ import { createId } from "@/lib/api/create-id"; import { handleAndReturnErrorResponse } from "@/lib/api/errors"; +import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility"; import { qstash } from "@/lib/cron"; import { verifyQstashSignature } from "@/lib/cron/verify-qstash"; import { prisma } from "@/lib/prisma"; @@ -50,7 +51,6 @@ export async function POST(req: Request) { id: bountyId, }, include: { - groups: true, program: { include: { emailDomains: { @@ -60,6 +60,7 @@ export async function POST(req: Request) { }, }, }, + ...bountyEligibilityIncludes, }, }); @@ -77,12 +78,9 @@ 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, ); const programEnrollments = await prisma.programEnrollment.findMany({ @@ -93,6 +91,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..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 @@ -1,5 +1,9 @@ import { DubApiError } from "@/lib/api/errors"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; +import { + bountyEligibilityIncludes, + throwIfPartnerNotEligibleForBounty, +} 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,22 +36,22 @@ 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, - ); + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); - if (!isInGroup) { - throw new DubApiError({ - code: "forbidden", - message: "You are not allowed to access this bounty.", - }); - } - } + throwIfPartnerNotEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: programEnrollment.groupId, + partnerTagIds, + }); const now = new Date(); 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..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 @@ -1,8 +1,12 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { + bountyEligibilityIncludes, + 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"; -import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; import { NextResponse } from "next/server"; @@ -17,21 +21,23 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { include: { program: true, links: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); - const bounty = await prisma.bounty.findUnique({ - where: { - id: bountyId, - programId: program.id, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId: program.id, include: { workflow: { select: { triggerConditions: true, }, }, - groups: true, submissions: { where: { partnerId: partner.id, @@ -47,35 +53,29 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { }, }, }, + ...bountyEligibilityIncludes, }, }); - if (!bounty) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } - if (bounty.startsAt > new Date()) { throw new DubApiError({ code: "not_found", - message: "Bounty not found.", + message: "Bounty has not started yet.", }); } - 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); - if (!partnerCanSeeBounty) { - throw new DubApiError({ - code: "not_found", - message: "Bounty not found.", - }); - } + throwIfPartnerNotEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: programEnrollment.groupId, + partnerTagIds, + }); const { groups, ...bountyWithoutGroups } = 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 124e659a1f3..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 @@ -2,6 +2,10 @@ import { DubApiError } from "@/lib/api/errors"; import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw"; import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content"; import { withPartnerProfile } from "@/lib/auth/partner"; +import { + bountyEligibilityIncludes, + throwIfPartnerNotEligibleForBounty, +} from "@/lib/bounty/api/bounty-eligibility"; import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw"; import { resolveBountyDetails } from "@/lib/bounty/utils"; import { ratelimit } from "@/lib/upstash"; @@ -33,12 +37,34 @@ export const GET = withPartnerProfile( const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }); const bounty = await getBountyOrThrow({ bountyId, programId: programEnrollment.programId, + include: { + ...bountyEligibilityIncludes, + }, + }); + + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = programEnrollment.programPartnerTags.map( + (t) => t.partnerTagId, + ); + + throwIfPartnerNotEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: programEnrollment.groupId, + partnerTagIds, }); const bountyInfo = resolveBountyDetails(bounty); diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts index c9622f3ed00..03fadc40895 100644 --- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts +++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts @@ -9,8 +9,26 @@ export const GET = withPartnerProfile(async ({ partner, params }) => { partnerId: partner.id, programId: params.programId, include: { - program: true, - links: true, + program: { + select: { + id: true, + defaultGroupId: true, + }, + }, + links: { + select: { + clicks: true, + leads: true, + conversions: true, + sales: true, + saleAmount: true, + }, + }, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/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/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/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 55dd321b9d0..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, @@ -45,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 { @@ -505,23 +505,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) { - - - Groups - - - ( - field.onChange(ids)} - /> - )} - /> - - +
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx new file mode 100644 index 00000000000..0876e27bf7e --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx @@ -0,0 +1,407 @@ +"use client"; + +import useGroups from "@/lib/swr/use-groups"; +import { usePartnerTags } from "@/lib/swr/use-partner-tags"; +import usePartnersCount from "@/lib/swr/use-partners-count"; +import { GroupExtendedProps } from "@/lib/types"; +import { GROUPS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/groups"; +import { PARTNER_TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/partner-tags"; +import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle"; +import { + ProgramSheetAccordionContent, + ProgramSheetAccordionItem, + ProgramSheetAccordionTrigger, +} from "@/ui/partners/program-sheet-accordion"; +import { + AnimatedSizeContainer, + Check2, + LoadingSpinner, + Magnifier, + ScrollContainer, + Switch, + Users6, +} from "@dub/ui"; +import { cn, nFormatter } from "@dub/utils"; +import { Command } from "cmdk"; +import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { Controller } from "react-hook-form"; +import { useDebounce } from "use-debounce"; +import { useBountyFormContext } from "./bounty-form-context"; + +export function BountyEligibility() { + const { control } = useBountyFormContext(); + + return ( + + Eligibility + +
+ ( + field.onChange(null)} + > + field.onChange(ids)} + /> + + )} + /> + + ( + field.onChange(null)} + > + field.onChange(ids)} + /> + + )} + /> +
+
+
+ ); +} + +function EligibilityToggle({ + title, + enabledDescription, + disabledDescription, + defaultEnabled, + onDisable, + children, +}: { + title: string; + enabledDescription: string; + disabledDescription: string; + defaultEnabled: boolean; + onDisable: () => void; + children: ReactNode; +}) { + const [enabled, setEnabled] = useState(defaultEnabled); + + return ( +
+
+ { + setEnabled(checked); + if (!checked) onDisable(); + }} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> +
+ + {title} + + + {enabled ? enabledDescription : disabledDescription} + +
+
+ + +
+ {enabled &&
{children}
} +
+
+
+ ); +} + +interface EligibilitySelectProps { + selectedIds: string[] | null; + setSelectedIds: (ids: string[] | null) => void; +} + +function GroupsEligibilitySelect({ + selectedIds, + setSelectedIds, +}: EligibilitySelectProps) { + const [search, setSearch] = useState(""); + const [useAsync, setUseAsync] = useState(false); + const [debouncedSearch] = useDebounce(search, 500); + + const { groups } = useGroups({ + query: { + includeExpandedFields: true, + ...(useAsync ? { search: debouncedSearch } : undefined), + }, + }); + + const { groups: selectedGroups } = useGroups({ + query: { + groupIds: selectedIds ?? undefined, + includeExpandedFields: true, + }, + enabled: Boolean(selectedIds?.length), + }); + + // Determine if we should use async loading + useEffect(() => { + setUseAsync( + (prev) => + prev || Boolean(groups && groups.length >= GROUPS_MAX_PAGE_SIZE), + ); + }, [groups]); + + return ( + `${group.name}::${group.slug}`} + renderLeading={(group) => } + renderRight={(group) => ( + + {nFormatter(group.totalPartners, { full: true })} qualify + + )} + /> + ); +} + +function TagsEligibilitySelect({ + selectedIds, + setSelectedIds, +}: EligibilitySelectProps) { + const [search, setSearch] = useState(""); + const [useAsync, setUseAsync] = useState(false); + const [debouncedSearch] = useDebounce(search, 500); + + const { partnerTags } = usePartnerTags({ + query: { ...(useAsync ? { search: debouncedSearch } : undefined) }, + }); + + const { partnerTags: selectedTags } = usePartnerTags({ + query: { ids: selectedIds ?? undefined }, + enabled: Boolean(selectedIds?.length), + }); + + const { partnersCount } = usePartnersCount< + { partnerTagId: string; _count: number }[] + >({ + groupBy: "partnerTagId", + ignoreParams: true, + }); + + const tagCountMap = useMemo(() => { + const map = new Map(); + if (Array.isArray(partnersCount)) { + for (const { partnerTagId, _count } of partnersCount) { + map.set(partnerTagId, _count); + } + } + return map; + }, [partnersCount]); + + // Determine if we should use async loading + useEffect(() => { + setUseAsync( + (prev) => + prev || + Boolean( + partnerTags && partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE, + ), + ); + }, [partnerTags]); + + return ( + ( +
+ + {nFormatter(tagCountMap.get(tag.id) ?? 0, { full: true })} +
+ )} + /> + ); +} + +interface BountyEligibilityMultiSelectProps< + T extends { id: string; name: string }, +> { + // The current (optionally search-filtered) list of items, or undefined while loading + items: T[] | undefined; + // The currently selected items, so they remain visible even when not in `items` + selectedItems: T[] | undefined; + selectedIds: string[] | null; + setSelectedIds: (ids: string[] | null) => void; + search: string; + setSearch: (search: string) => void; + // Whether items are searched server-side (search input shouldn't filter locally) + useAsync: boolean; + searchPlaceholder: string; + // Value used for local (cmdk) filtering — defaults to the item name + getItemValue?: (item: T) => string; + // Left-aligned visual rendered before the item name (e.g. a color circle) + renderLeading?: (item: T) => ReactNode; + // Right-aligned content rendered at the end of the row (e.g. a partner count) + renderRight?: (item: T) => ReactNode; +} + +function BountyEligibilityMultiSelect({ + items, + selectedItems, + selectedIds, + setSelectedIds, + search, + setSearch, + useAsync, + searchPlaceholder, + getItemValue, + renderLeading, + renderRight, +}: BountyEligibilityMultiSelectProps) { + const [shouldSort, setShouldSort] = useState(false); + const [sortedItems, setSortedItems] = useState(undefined); + + const sortItems = useCallback( + (items: T[], search: string) => { + return search === "" + ? [ + ...items.filter((i) => selectedIds?.includes(i.id)), + ...items.filter((i) => !selectedIds?.includes(i.id)), + ] + : items; + }, + [selectedIds], + ); + + // Actually sort the items when needed + useEffect(() => { + if (!shouldSort || !items || (selectedIds?.length && !selectedItems)) + return; + + setSortedItems( + sortItems( + [ + ...(selectedItems ?? []), + ...items.filter((i) => !selectedItems?.some((si) => si.id === i.id)), + ], + search, + ), + ); + setShouldSort(false); + }, [shouldSort, items, selectedIds, selectedItems, sortItems, search]); + + // Re-sort when the search-filtered items or selection changes + useEffect(() => setShouldSort(true), [items, selectedIds]); + + return ( + + + + + {sortedItems !== undefined ? ( + <> + {sortedItems.map((item) => { + const checked = Boolean(selectedIds?.includes(item.id)); + + return ( + + setSelectedIds( + selectedIds?.includes(item.id) + ? selectedIds.length === 1 + ? null // Revert to null if there will be no items selected + : selectedIds.filter((id) => id !== item.id) + : [...(selectedIds ?? []), item.id], + ) + } + className={cn( + "flex cursor-pointer select-none items-center gap-3 whitespace-nowrap rounded-md px-3 py-2.5 text-left text-sm text-neutral-700", + "data-[selected=true]:bg-neutral-100", + )} + > +
+ {checked && Checked} + +
+
+ {renderLeading?.(item)} + {item.name} +
+ {renderRight?.(item)} +
+ ); + })} + {!useAsync ? ( + + No matches + + ) : sortedItems.length === 0 ? ( +
+ No matches +
+ ) : null} + + ) : ( + // undefined data / explicit loading state + +
+ +
+
+ )} +
+
+
+ ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx index 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 7c246bacc1a..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,7 +24,7 @@ const ACCORDION_ITEMS = [ "bounty-type", "bounty-details", "bounty-criteria", - "groups", + "eligibility", ]; const isEmpty = (value: unknown) => @@ -96,6 +96,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 +138,7 @@ export function useAddEditBountyForm({ description, performanceCondition, groupIds, + partnerTagIds, rewardType, submissionRequirements, ] = watch([ @@ -149,6 +151,7 @@ export function useAddEditBountyForm({ "description", "performanceCondition", "groupIds", + "partnerTagIds", "rewardType", "submissionRequirements", ]); @@ -536,6 +539,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/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} + + )} +
+ + ) : ( +
+ )} + + )} +
diff --git a/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts b/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts index 4d9f1287b8d..db6fd53fefd 100644 --- a/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts +++ b/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts @@ -3,6 +3,7 @@ import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment"; import { includeTags } from "@/lib/api/links/include-tags"; import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw"; +import { triggerDraftBountySubmissionCreation } from "@/lib/bounty/api/trigger-draft-bounty-submissions"; import { prisma } from "@/lib/prisma"; import { recordLink } from "@/lib/tinybird"; import { updatePartnerTagsSchema } from "@/lib/zod/schemas/partner-tags"; @@ -94,6 +95,15 @@ export const updateProgramPartnerTagsAction = authActionClient ]); }); + if (addTagIds.length > 0) { + waitUntil( + triggerDraftBountySubmissionCreation({ + programId, + partnerIds, + }), + ); + } + // Sync updated partner tags to Tinybird for analytics (top_partner_tags) waitUntil( (async () => { diff --git a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts index 05b1b83621b..897a803f534 100644 --- a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts +++ b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts @@ -23,7 +23,13 @@ export const uploadBountySubmissionFileAction = authPartnerActionClient const programEnrollment = await getProgramEnrollmentOrThrow({ partnerId: partner.id, programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }); const { signedUrl, destinationUrl } = await getBountySubmissionUploadUrl({ diff --git a/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts new file mode 100644 index 00000000000..e00775b3735 --- /dev/null +++ b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts @@ -0,0 +1,37 @@ +import { prisma } from "@/lib/prisma"; +import { PartnerTag } from "@prisma/client"; +import { DubApiError } from "../errors"; + +export async function throwIfInvalidPartnerTagIds({ + programId, + partnerTagIds, +}: { + programId: string; + partnerTagIds: string[] | null | undefined; +}) { + let partnerTags: PartnerTag[] = []; + + if (partnerTagIds && partnerTagIds.length) { + partnerTags = await prisma.partnerTag.findMany({ + where: { + programId, + id: { + in: partnerTagIds, + }, + }, + }); + + const invalidPartnerTagIds = partnerTagIds?.filter( + (partnerTagId) => !partnerTags?.some((tag) => tag.id === partnerTagId), + ); + + if (invalidPartnerTagIds?.length) { + throw new DubApiError({ + code: "unprocessable_entity", + message: `Invalid partner tag IDs detected: ${invalidPartnerTagIds.join(", ")}`, + }); + } + } + + return partnerTags; +} diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts index f52e49ec860..78fb285db23 100644 --- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts +++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts @@ -1,4 +1,8 @@ import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions"; +import { + 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"; @@ -38,7 +42,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,13 +56,24 @@ export const executeCompleteBountyWorkflow = async ({ id: bountyId, }, include: { - program: true, - groups: true, submissions: { where: { partnerId, }, + select: { + id: true, + status: true, + }, + }, + program: { + select: { + id: true, + name: true, + slug: true, + supportEmail: true, + }, }, + ...bountyEligibilityIncludes, }, }); @@ -89,18 +105,29 @@ export const executeCompleteBountyWorkflow = async ({ return; } - const { groups, submissions } = bounty; + const { 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 new file mode 100644 index 00000000000..c8633968ffb --- /dev/null +++ b/apps/web/lib/bounty/api/bounty-eligibility.ts @@ -0,0 +1,125 @@ +import { DubApiError } from "@/lib/api/errors"; +import { Prisma } from "@prisma/client"; + +export function buildBountyEligibilityWhere({ + groupId, + partnerTagIds, +}: { + groupId: string | undefined; + partnerTagIds: string[]; +}): Prisma.BountyWhereInput { + return { + AND: [ + { + OR: [ + { + groups: { + none: {}, + }, + }, + ...(groupId + ? [ + { + groups: { + some: { + groupId, + }, + }, + }, + ] + : []), + ], + }, + { + OR: [ + { + partnerTags: { + none: {}, + }, + }, + ...(partnerTagIds.length > 0 + ? [ + { + partnerTags: { + some: { + partnerTagId: { + in: partnerTagIds, + }, + }, + }, + }, + ] + : []), + ], + }, + ], + }; +} + +export function isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId, + partnerTagIds = [], +}: { + bountyGroupIds: string[]; + bountyTagIds: string[]; + partnerGroupId: string | null; + partnerTagIds: string[] | undefined; +}): boolean { + // No restrictions + if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) { + return true; + } + + // Group restrictions + const inGroup = + bountyGroupIds.length === 0 || + (partnerGroupId && bountyGroupIds.includes(partnerGroupId)); + + // Tag restrictions + const hasTag = + bountyTagIds.length === 0 || + partnerTagIds.some((id) => bountyTagIds.includes(id)); + + return Boolean(inGroup && hasTag); +} + +export function 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: { + groupId: true, + }, + }, + partnerTags: { + select: { + partnerTagId: true, + }, + }, +} satisfies Prisma.BountyInclude; diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts index e652689e869..203296f157a 100644 --- a/apps/web/lib/bounty/api/create-bounty-submission.ts +++ b/apps/web/lib/bounty/api/create-bounty-submission.ts @@ -26,6 +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 { + bountyEligibilityIncludes, + throwIfPartnerNotEligibleForBounty, +} from "./bounty-eligibility"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; type CreateBountySubmissionParams = z.infer< typeof createBountySubmissionInputSchema @@ -35,8 +40,17 @@ type CreateBountySubmissionParams = z.infer< type BountyWithRelations = Prisma.BountyGetPayload<{ include: { - groups: true; submissions: true; + groups: { + select: { + groupId: true; + }; + }; + partnerTags: { + select: { + partnerTagId: true; + }; + }; }; }>; @@ -57,7 +71,13 @@ export class BountySubmissionHandler { private submissions: BountySubmission[]; private submissionData: Partial; private programEnrollment: Prisma.ProgramEnrollmentGetPayload<{ - include: {}; + include: { + programPartnerTags: { + select: { + partnerTagId: true; + }; + }; + }; }>; constructor(params: CreateBountySubmissionParams) { @@ -99,20 +119,25 @@ export class BountySubmissionHandler { getProgramEnrollmentOrThrow({ partnerId: this.partner.id, programId: this.programId, - include: {}, + include: { + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, + }, }), - prisma.bounty.findUniqueOrThrow({ - where: { - id: this.bountyId, - }, + getBountyOrThrow({ + bountyId: this.bountyId, + programId: this.programId, include: { - groups: true, submissions: { where: { partnerId: this.partner.id, }, }, + ...bountyEligibilityIncludes, }, }), ]); @@ -243,19 +268,18 @@ 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.", - }); - } - } + throwIfPartnerNotEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: this.programEnrollment.groupId, + partnerTagIds, + }); // Validate bounty dates and status const now = new Date(); diff --git a/apps/web/lib/bounty/api/get-bounties-by-groups.ts b/apps/web/lib/bounty/api/get-bounties-by-groups.ts deleted file mode 100644 index 68dec8dfce5..00000000000 --- a/apps/web/lib/bounty/api/get-bounties-by-groups.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import { Bounty } from "@prisma/client"; - -export async function getBountiesByGroups({ - programId, - groupIds, -}: { - programId: string; - groupIds: string[]; -}) { - const bounties = await prisma.bounty.findMany({ - where: { - programId, - AND: [ - { - OR: [ - { groups: { none: {} } }, - { groups: { some: { groupId: { in: groupIds } } } }, - ], - }, - ], - }, - include: { - groups: true, - }, - }); - - const bountiesByGroups: Record = {}; - - // Note: global bounties are not included here - for (const groupId of groupIds) { - bountiesByGroups[groupId] = bounties.filter((bounty) => - bounty.groups.some((g) => g.groupId === groupId), - ); - } - - return bountiesByGroups; -} diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts index 75c31aa5600..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,26 +1,39 @@ -import { - aggregatePartnerLinksStats, - PartnerLink, -} from "@/lib/partners/aggregate-partner-links-stats"; +import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats"; import { prisma } from "@/lib/prisma"; import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile"; -import { Program, ProgramEnrollment } from "@prisma/client"; +import { + Link, + Program, + ProgramEnrollment, + ProgramPartnerTag, +} from "@prisma/client"; import * as z from "zod/v4"; +import { buildBountyEligibilityWhere } from "./bounty-eligibility"; type GetBountiesForPartnerParams = Pick< ProgramEnrollment, "groupId" | "partnerId" | "totalCommissions" > & { - links: PartnerLink[]; + programPartnerTags: Pick[]; + links: Pick< + Link, + "clicks" | "leads" | "conversions" | "sales" | "saleAmount" + >[]; program: Pick; }; -export async function getBountiesForPartner( - params: GetBountiesForPartnerParams, -) { - const { groupId, partnerId, totalCommissions, program, links } = params; - +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,22 +41,10 @@ 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: {}, - }, - }, - { - groups: { - some: { - groupId: groupId || program.defaultGroupId, - }, - }, - }, - ], + ...buildBountyEligibilityWhere({ + groupId: groupId || program.defaultGroupId, + partnerTagIds, + }), }, include: { workflow: { 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..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 @@ -1,23 +1,31 @@ import { DubApiError } from "@/lib/api/errors"; -import { prisma } from "@/lib/prisma"; import { storage } from "@/lib/storage"; import { ratelimit } from "@/lib/upstash"; import { submissionRequirementsSchema } from "@/lib/zod/schemas/bounties"; import { nanoid, R2_URL } from "@dub/utils"; -import { ProgramEnrollment } from "@prisma/client"; +import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client"; +import { + bountyEligibilityIncludes, + throwIfPartnerNotEligibleForBounty, +} from "./bounty-eligibility"; +import { getBountyOrThrow } from "./get-bounty-or-throw"; const MAX_ATTEMPTS = 25; const CACHE_KEY_PREFIX = "bounty:submission:file:upload"; +type ProgramEnrollmentWithPartnerTags = Pick< + ProgramEnrollment, + "programId" | "partnerId" | "groupId" +> & { + 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 +43,8 @@ export async function getBountySubmissionUploadUrl({ contentLength, programEnrollment, }: GetBountySubmissionUploadUrlParams) { - const { programId, partnerId } = programEnrollment; + const { programId, partnerId, groupId, programPartnerTags } = + programEnrollment; if (!fileName.trim()) { throw new DubApiError({ @@ -74,44 +83,24 @@ export async function getBountySubmissionUploadUrl({ }); } - const bounty = await prisma.bounty.findUniqueOrThrow({ - where: { - id: bountyId, - }, - select: { - programId: true, - type: true, - startsAt: true, - endsAt: true, - archivedAt: true, - submissionRequirements: true, - groups: { - select: { - groupId: true, - }, - }, + const bounty = await getBountyOrThrow({ + bountyId, + programId, + include: { + ...bountyEligibilityIncludes, }, }); - if (bounty.programId !== programId) { - throw new DubApiError({ - code: "forbidden", - message: "This bounty is not for this program.", - }); - } + const bountyGroupIds = bounty.groups.map((g) => g.groupId); + const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId); + const partnerTagIds = programPartnerTags.map((t) => t.partnerTagId); - 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.", - }); - } - } + throwIfPartnerNotEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: groupId, + partnerTagIds, + }); // Validate the bounty dates const now = new Date(); 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) ?? [], }; }; 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..5ee735da2f8 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,108 @@ 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), - ), - ]; - - const bountiesByGroups = await getBountiesByGroups({ - programId, - groupIds, - }); - - const partnersByGroup = programEnrollments.reduce( - (acc, enrollment) => { - if (enrollment.groupId) { - acc[enrollment.groupId] = [ - ...(acc[enrollment.groupId] || []), - enrollment.partnerId, - ]; - } - return acc; + console.log( + `Found ${eligibleBounties.length} eligible performance bounties for program ${programId}.`, + { + eligibleBounties, }, - {} 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.`, + await Promise.allSettled( + eligibleBounties.map(async (bounty) => { + const bountyGroupIds = bounty.groups.map(({ groupId }) => groupId); + const bountyTagIds = bounty.partnerTags.map( + ({ partnerTagId }) => partnerTagId, ); - 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, - }, - }), - ), - ); - } -} + const eligiblePartnerIds = programEnrollments + .filter((enrollment) => + isPartnerEligibleForBounty({ + bountyGroupIds, + bountyTagIds, + partnerGroupId: enrollment.groupId, + partnerTagIds: enrollment.programPartnerTags.map( + ({ partnerTagId }) => partnerTagId, + ), + }), + ) + .map(({ partnerId }) => partnerId); -function isEligiblePerformanceBounty(bounty: Bounty) { - const now = new Date(); + if (eligiblePartnerIds.length === 0) { + console.log( + `No eligible partners found for bounty ${bounty.id} in program ${programId}.`, + ); + return; + } - 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; + console.log( + `Found ${eligiblePartnerIds.length} eligible partners for bounty ${bounty.id}.`, + ); - 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/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts index ac142be36b2..001370bc955 100644 --- a/apps/web/lib/embed/referrals/auth.ts +++ b/apps/web/lib/embed/referrals/auth.ts @@ -4,7 +4,12 @@ import { prisma } from "@/lib/prisma"; import { PartnerGroupProps } from "@/lib/types"; import { ratelimit } from "@/lib/upstash"; import { getSearchParams } from "@dub/utils"; -import { Link, Program, ProgramEnrollment } from "@prisma/client"; +import { + Link, + Program, + ProgramEnrollment, + ProgramPartnerTag, +} from "@prisma/client"; import { headers } from "next/headers"; import { referralsEmbedToken } from "./token-class"; @@ -23,7 +28,9 @@ interface WithReferralsEmbedTokenHandler { params: Record; searchParams: Record; program: Program; - programEnrollment: ProgramEnrollment; + programEnrollment: ProgramEnrollment & { + programPartnerTags: Pick[]; + }; group: PartnerGroupProps; links: Link[]; embedToken: string; @@ -105,6 +112,11 @@ export const withReferralsEmbedToken = ( }, program: true, partnerGroup: true, + programPartnerTags: { + select: { + partnerTagId: true, + }, + }, }, }); diff --git a/apps/web/lib/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; } 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" + } ] } diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts index 12ea84a8b4d..5b1dddddb6a 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 608d98a1faf..2e63f95b09c 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/prisma/schema/bounty.prisma b/apps/web/prisma/schema/bounty.prisma index 321f46b2c35..6b2eaafb886 100644 --- a/apps/web/prisma/schema/bounty.prisma +++ b/apps/web/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/apps/web/prisma/schema/tag.prisma b/apps/web/prisma/schema/tag.prisma index 67b7f05a859..ae34982d58c 100644 --- a/apps/web/prisma/schema/tag.prisma +++ b/apps/web/prisma/schema/tag.prisma @@ -34,6 +34,7 @@ model PartnerTag { program Program? @relation(fields: [programId], references: [id], onDelete: Cascade) programPartnerTags ProgramPartnerTag[] + bounties BountyPartnerTag[] @@unique([programId, name]) @@index(programId)