diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
index a89c3934939..f2560bed599 100644
--- a/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
+++ b/apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
@@ -2,9 +2,12 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
+import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
+import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility";
import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name";
+import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { getBountyWithDetails } from "@/lib/bounty/api/get-bounty-with-details";
import { PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES } from "@/lib/bounty/api/performance-bounty-scope-attributes";
import { validateBounty } from "@/lib/bounty/api/validate-bounty";
@@ -18,7 +21,7 @@ import {
updateBountySchema,
} from "@/lib/zod/schemas/bounties";
import { arrayEqual, deepEqual } from "@dub/utils";
-import { PartnerGroup, Prisma } from "@prisma/client";
+import { PartnerGroup, PartnerTag, Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
@@ -54,6 +57,8 @@ export const PATCH = withWorkspace(
description,
startsAt,
endsAt,
+ startMode,
+ endsAfterDays,
submissionsOpenAt,
submissionFrequency,
maxSubmissions,
@@ -62,28 +67,33 @@ export const PATCH = withWorkspace(
submissionRequirements,
performanceCondition,
groupIds,
+ partnerTagIds,
} = updateBountySchema.parse(await parseRequestBody(req));
- const bounty = await prisma.bounty.findUniqueOrThrow({
- where: {
- id: bountyId,
- programId,
- },
+ const bounty = await getBountyOrThrow({
+ bountyId,
+ programId,
include: {
- groups: true,
workflow: true,
_count: {
select: {
submissions: true,
},
},
+ ...bountyEligibilityIncludes,
},
});
+ const nextStartMode =
+ startMode !== undefined ? startMode : bounty.startMode;
+
validateBounty({
type: bounty.type,
- startsAt,
+ startsAt: startsAt !== undefined ? startsAt : bounty.startsAt,
endsAt: endsAt !== undefined ? endsAt : bounty.endsAt,
+ startMode: nextStartMode,
+ endsAfterDays:
+ endsAfterDays !== undefined ? endsAfterDays : bounty.endsAfterDays,
submissionsOpenAt,
submissionFrequency:
submissionFrequency !== undefined
@@ -113,17 +123,44 @@ export const PATCH = withWorkspace(
// if groupIds is provided and is different from the current groupIds, update the groups
let updatedPartnerGroups: PartnerGroup[] | undefined = undefined;
- if (
- groupIds &&
- !arrayEqual(
- bounty.groups.map((group) => group.groupId),
- groupIds,
- )
- ) {
- updatedPartnerGroups = await throwIfInvalidGroupIds({
- programId,
- groupIds,
- });
+ let shouldUpdatePartnerGroups = false;
+
+ if (groupIds !== undefined) {
+ const currentGroupIds = bounty.groups.map((group) => group.groupId);
+ const newGroupIds = groupIds || [];
+
+ if (!arrayEqual(currentGroupIds, newGroupIds)) {
+ if (newGroupIds.length > 0) {
+ updatedPartnerGroups = await throwIfInvalidGroupIds({
+ programId,
+ groupIds: newGroupIds,
+ });
+ }
+
+ shouldUpdatePartnerGroups = true;
+ }
+ }
+
+ // if partnerTagIds is provided and is different from the current partnerTagIds, update the partner tags
+ let updatedPartnerTags: PartnerTag[] | undefined = undefined;
+ let shouldUpdatePartnerTags = false;
+
+ if (partnerTagIds !== undefined) {
+ const currentPartnerTagIds = bounty.partnerTags.map(
+ (tag) => tag.partnerTagId,
+ );
+ const newPartnerTagIds = partnerTagIds || [];
+
+ if (!arrayEqual(currentPartnerTagIds, newPartnerTagIds)) {
+ if (newPartnerTagIds.length > 0) {
+ updatedPartnerTags = await throwIfInvalidPartnerTagIds({
+ programId,
+ partnerTagIds: newPartnerTagIds,
+ });
+ }
+
+ shouldUpdatePartnerTags = true;
+ }
}
// Prevent updates if `performanceCondition.attribute` differs from the current value if there are existing submissions
@@ -179,6 +216,19 @@ export const PATCH = withWorkspace(
});
}
+ // Relative bounties start when a partner joins, so startsAt is cleared.
+ // For absolute bounties, only update startsAt when explicitly provided.
+ let startsAtUpdate: { startsAt?: Date | null } = {};
+
+ if (nextStartMode === "relative") {
+ startsAtUpdate = { startsAt: null };
+ } else if (startsAt !== undefined) {
+ startsAtUpdate = { startsAt: startsAt ?? new Date() };
+ } else if (bounty.startsAt === null) {
+ // Switching relative -> absolute without a startsAt: default to now
+ startsAtUpdate = { startsAt: new Date() };
+ }
+
const data = await prisma.$transaction(async (tx) => {
const updatedBounty = await tx.bounty.update({
where: {
@@ -187,8 +237,10 @@ export const PATCH = withWorkspace(
data: {
name: bountyName ?? undefined,
description,
- startsAt: startsAt!, // Can remove the ! when we're on a newer TS version (currently 5.4.4)
- endsAt,
+ ...startsAtUpdate,
+ ...(endsAt !== undefined && { endsAt }),
+ ...(startMode !== undefined && { startMode }),
+ ...(endsAfterDays !== undefined && { endsAfterDays }),
submissionsOpenAt:
bounty.type === "submission" ? submissionsOpenAt : null,
...(bounty.type === "submission" &&
@@ -204,18 +256,32 @@ export const PATCH = withWorkspace(
submissionRequirements !== undefined && {
submissionRequirements: submissionRequirements ?? Prisma.DbNull,
}),
- ...(updatedPartnerGroups && {
+ ...(shouldUpdatePartnerGroups && {
groups: {
deleteMany: {},
- create: updatedPartnerGroups.map((group) => ({
- groupId: group.id,
- })),
+ ...(updatedPartnerGroups &&
+ updatedPartnerGroups.length > 0 && {
+ create: updatedPartnerGroups.map((group) => ({
+ groupId: group.id,
+ })),
+ }),
+ },
+ }),
+ ...(shouldUpdatePartnerTags && {
+ partnerTags: {
+ deleteMany: {},
+ ...(updatedPartnerTags &&
+ updatedPartnerTags.length > 0 && {
+ create: updatedPartnerTags.map((tag) => ({
+ partnerTagId: tag.id,
+ })),
+ }),
},
}),
},
include: {
+ ...bountyEligibilityIncludes,
workflow: true,
- groups: true,
},
});
@@ -239,6 +305,9 @@ export const PATCH = withWorkspace(
const updatedBounty = BountySchema.parse({
...data,
groups: data.groups.map(({ groupId }) => ({ id: groupId })),
+ partnerTags: data.partnerTags.map(({ partnerTagId }) => ({
+ id: partnerTagId,
+ })),
performanceCondition: data.workflow?.triggerConditions?.[0],
});
@@ -281,19 +350,17 @@ export const DELETE = withWorkspace(
const { bountyId } = params;
const programId = getDefaultProgramIdOrThrow(workspace);
- const bounty = await prisma.bounty.findUniqueOrThrow({
- where: {
- id: bountyId,
- programId,
- },
+ const bounty = await getBountyOrThrow({
+ bountyId,
+ programId,
include: {
- groups: true,
workflow: true,
_count: {
select: {
submissions: true,
},
},
+ ...bountyEligibilityIncludes,
},
});
@@ -324,6 +391,9 @@ export const DELETE = withWorkspace(
const deletedBounty = BountySchema.parse({
...bounty,
groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
+ partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({
+ id: partnerTagId,
+ })),
performanceCondition: bounty.workflow?.triggerConditions?.[0],
});
diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
index f6ba6d42236..34faf5a188c 100644
--- a/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
+++ b/apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
@@ -17,9 +17,6 @@ export const GET = withWorkspace(
await getBountyOrThrow({
bountyId,
programId,
- include: {
- groups: true,
- },
});
const {
diff --git a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts
index 26bec94e943..8430f7f4d89 100644
--- a/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts
+++ b/apps/web/app/(ee)/api/bounties/[bountyId]/sync-social-metrics/route.ts
@@ -4,6 +4,11 @@ import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates";
+import {
+ getEffectiveBountyPeriod,
+ isBountyExpired,
+ isBountyStarted,
+} from "@/lib/bounty/bounty-period";
import { resolveBountyDetails } from "@/lib/bounty/utils";
import { qstash } from "@/lib/cron";
import { prisma } from "@/lib/prisma";
@@ -52,6 +57,12 @@ export const POST = withWorkspace(
urls: true,
status: true,
partner: true,
+ programEnrollment: {
+ select: {
+ groupJoinedAt: true,
+ createdAt: true,
+ },
+ },
},
},
}
@@ -67,64 +78,65 @@ export const POST = withWorkspace(
});
}
- const submission = submissionId ? bounty.submissions?.[0] : undefined;
-
- if (submissionId) {
- if (!submission) {
- throw new DubApiError({
- code: "not_found",
- message: `Submission ${submissionId} not found.`,
- });
- }
+ // Bounty-wide sync (no submissionId): run asynchronously via a background job
+ if (!submissionId) {
+ const response = await qstash.publishJSON({
+ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,
+ method: "POST",
+ body: {
+ bountyId,
+ },
+ });
- if (submission.status === "approved") {
+ if (!response.messageId) {
throw new DubApiError({
code: "bad_request",
- message: "Social metrics can't be synced for an approved submission.",
+ message: "Could not sync social metrics for this bounty now.",
});
}
+
+ return NextResponse.json({});
}
- const now = new Date();
+ // Single-submission sync
+ const submission = bounty.submissions?.[0];
- if (bounty.startsAt && bounty.startsAt > now) {
+ if (!submission || !submission.programEnrollment) {
throw new DubApiError({
- code: "bad_request",
- message: "Social metrics can only be synced after the bounty starts.",
+ code: "not_found",
+ message: `Submission ${submissionId} not found.`,
});
}
- if (bounty.endsAt && bounty.endsAt < now) {
+ if (submission.status === "approved") {
throw new DubApiError({
code: "bad_request",
- message: "Social metrics can't be synced after the bounty ends.",
+ message: "Social metrics can't be synced for an approved submission.",
});
}
- // Do the sync in a background job if no submissionId is provided
- if (!submissionId) {
- const response = await qstash.publishJSON({
- url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/sync-social-metrics`,
- method: "POST",
- body: {
- bountyId,
- },
- });
+ const { startsAt, endsAt } = getEffectiveBountyPeriod({
+ programEnrollment: submission.programEnrollment,
+ bounty,
+ });
- if (!response.messageId) {
- throw new DubApiError({
- code: "bad_request",
- message: "Could not sync social metrics for this bounty now.",
- });
- }
+ if (!isBountyStarted(startsAt)) {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "Social metrics can only be synced after the bounty starts.",
+ });
+ }
- return NextResponse.json({});
+ if (isBountyExpired(endsAt)) {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "Social metrics can't be synced after the bounty ends.",
+ });
}
- // Otherwise, do the sync for the specific submission
const toUpdate = await getSocialMetricsUpdates({
bounty,
- submissions: bounty.submissions![0],
+ submissions: submission,
});
if (toUpdate.length > 0) {
@@ -135,7 +147,6 @@ export const POST = withWorkspace(
}
const { socialMetricCount, socialMetricsLastSyncedAt } = update;
- const submission = bounty.submissions![0];
const updateData: Prisma.BountySubmissionUpdateInput = {
socialMetricCount,
diff --git a/apps/web/app/(ee)/api/bounties/route.ts b/apps/web/app/(ee)/api/bounties/route.ts
index 873593f51f1..78f322e802c 100644
--- a/apps/web/app/(ee)/api/bounties/route.ts
+++ b/apps/web/app/(ee)/api/bounties/route.ts
@@ -4,10 +4,16 @@ import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
+import { throwIfInvalidPartnerTagIds } from "@/lib/api/tags/throw-if-invalid-partner-tag-ids";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
+import {
+ bountyEligibilityIncludes,
+ buildBountyEligibilityWhere,
+} from "@/lib/bounty/api/bounty-eligibility";
import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name";
import { validateBounty } from "@/lib/bounty/api/validate-bounty";
+import { getEffectiveBountyPeriod } from "@/lib/bounty/bounty-period";
import { qstash } from "@/lib/cron";
import { getPlanCapabilities } from "@/lib/plan-capabilities";
import { prisma } from "@/lib/prisma";
@@ -41,52 +47,44 @@ export const GET = withWorkspace(
partnerId,
programId,
include: {
- program: true,
+ program: {
+ select: {
+ defaultGroupId: true,
+ },
+ },
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
})
: null;
+ const partnerGroupId =
+ programEnrollment?.groupId || programEnrollment?.program.defaultGroupId;
+ const partnerTagIds =
+ programEnrollment?.programPartnerTags.map(
+ ({ partnerTagId }) => partnerTagId,
+ ) || [];
+
const [bounties, allBountiesSubmissionsCount] = await Promise.all([
prisma.bounty.findMany({
where: {
programId,
// Filter only bounties the specified partner is eligible for
...(programEnrollment && {
- AND: [
- // Filter out expired bounties
- {
- OR: [{ endsAt: null }, { endsAt: { gt: new Date() } }],
- },
- // Filter by partner's group eligibility
- {
- OR: [
- {
- groups: {
- none: {},
- },
- },
- {
- groups: {
- some: {
- groupId:
- programEnrollment.groupId ||
- programEnrollment.program.defaultGroupId,
- },
- },
- },
- ],
- },
- ],
+ ...buildBountyEligibilityWhere({
+ groupId: partnerGroupId,
+ partnerTagIds,
+ }),
}),
},
include: {
- groups: {
- select: {
- groupId: true,
- },
- },
+ ...bountyEligibilityIncludes,
},
}),
+
includeSubmissionsCount
? prisma.bountySubmission.groupBy({
by: ["bountyId", "status"],
@@ -127,14 +125,39 @@ export const GET = withWorkspace(
};
};
- const data = bounties.map((bounty) => {
- return BountyListSchema.parse({
- ...bounty,
- groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
- ...(allBountiesSubmissionsCount && {
- submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id),
+ const now = new Date();
+
+ const data = bounties.flatMap((bounty) => {
+ // Filter out bounties that are not in the effective bounty period
+ if (programEnrollment) {
+ const { startsAt, endsAt } = getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+ });
+
+ if (now < startsAt || (endsAt && now > endsAt)) {
+ return [];
+ }
+
+ bounty = {
+ ...bounty,
+ startsAt,
+ endsAt,
+ };
+ }
+
+ return [
+ BountyListSchema.parse({
+ ...bounty,
+ groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
+ partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({
+ id: partnerTagId,
+ })),
+ ...(allBountiesSubmissionsCount && {
+ submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id),
+ }),
}),
- });
+ ];
});
return NextResponse.json(data);
@@ -159,21 +182,25 @@ export const POST = withWorkspace(
rewardDescription,
startsAt,
endsAt,
+ startMode,
+ endsAfterDays,
submissionsOpenAt,
submissionFrequency,
maxSubmissions,
submissionRequirements,
groupIds,
+ partnerTagIds,
performanceCondition,
performanceScope,
sendNotificationEmails,
} = parsedBody;
- // Use current date as default if startsAt is not provided
- startsAt = startsAt || new Date();
-
validateBounty(parsedBody);
+ // startsAt is only stored for absolute bounties (defaulting to now when
+ // omitted); relative bounties start when a partner joins, so it stays null.
+ startsAt = startMode === "absolute" ? startsAt || new Date() : null;
+
const { canUseBountySocialMetrics, canSendEmailCampaigns } =
getPlanCapabilities(workspace.plan);
@@ -184,10 +211,17 @@ export const POST = withWorkspace(
});
}
- const partnerGroups = await throwIfInvalidGroupIds({
- programId,
- groupIds,
- });
+ const [partnerGroups, partnerTags] = await Promise.all([
+ throwIfInvalidGroupIds({
+ programId,
+ groupIds,
+ }),
+
+ throwIfInvalidPartnerTagIds({
+ programId,
+ partnerTagIds,
+ }),
+ ]);
// Bounty name
let bountyName = name;
@@ -241,6 +275,8 @@ export const POST = withWorkspace(
type,
startsAt,
endsAt,
+ startMode,
+ endsAfterDays,
submissionsOpenAt: type === "submission" ? submissionsOpenAt : null,
submissionFrequency:
type === "submission" ? submissionFrequency : null,
@@ -261,10 +297,19 @@ export const POST = withWorkspace(
},
},
}),
+ ...(partnerTags.length && {
+ partnerTags: {
+ createMany: {
+ data: partnerTags.map(({ id }) => ({
+ partnerTagId: id,
+ })),
+ },
+ },
+ }),
},
include: {
workflow: true,
- groups: true,
+ ...bountyEligibilityIncludes,
},
});
});
@@ -272,11 +317,21 @@ export const POST = withWorkspace(
const createdBounty = BountySchema.parse({
...bounty,
groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
+ partnerTags: bounty.partnerTags.map(({ partnerTagId }) => ({
+ id: partnerTagId,
+ })),
performanceCondition: bounty.workflow?.triggerConditions?.[0],
});
const shouldScheduleDraftSubmissions =
- bounty.type === "performance" && bounty.performanceScope === "lifetime";
+ bounty.type === "performance" &&
+ bounty.performanceScope === "lifetime" &&
+ bounty.startMode === "absolute";
+
+ const shouldSchedulePartnerNotifications =
+ sendNotificationEmails &&
+ canSendEmailCampaigns &&
+ bounty.startMode === "absolute";
waitUntil(
Promise.allSettled([
@@ -301,14 +356,14 @@ export const POST = withWorkspace(
data: createdBounty,
}),
- sendNotificationEmails &&
- canSendEmailCampaigns &&
+ shouldSchedulePartnerNotifications &&
qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/notify-partners`,
body: {
bountyId: bounty.id,
},
- notBefore: Math.floor(bounty.startsAt.getTime() / 1000),
+ // startsAt is guaranteed to be set for absolute bounties
+ notBefore: Math.floor(bounty.startsAt!.getTime() / 1000),
}),
shouldScheduleDraftSubmissions &&
@@ -317,7 +372,8 @@ export const POST = withWorkspace(
body: {
bountyId: bounty.id,
},
- notBefore: Math.floor(bounty.startsAt.getTime() / 1000),
+ // startsAt is guaranteed to be set for absolute bounties
+ notBefore: Math.floor(bounty.startsAt!.getTime() / 1000),
}),
]),
);
diff --git a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts
index 9f398264e98..faae7f1beb9 100644
--- a/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts
+++ b/apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts
@@ -1,6 +1,10 @@
import { createId } from "@/lib/api/create-id";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions";
+import {
+ bountyEligibilityIncludes,
+ canPartnerSubmitBounty,
+} from "@/lib/bounty/api/bounty-eligibility";
import { qstash } from "@/lib/cron";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats";
@@ -41,9 +45,9 @@ export async function POST(req: Request) {
id: bountyId,
},
include: {
- groups: true,
program: true,
workflow: true,
+ ...bountyEligibilityIncludes,
},
});
@@ -53,12 +57,14 @@ export async function POST(req: Request) {
});
}
- let diffMinutes = differenceInMinutes(bounty.startsAt, new Date());
+ if (bounty.startsAt) {
+ let diffMinutes = differenceInMinutes(bounty.startsAt, new Date());
- if (diffMinutes >= 10) {
- return logAndRespond(
- `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,
- );
+ if (diffMinutes >= 10) {
+ return logAndRespond(
+ `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,
+ );
+ }
}
if (bounty.type !== "performance") {
@@ -75,16 +81,18 @@ export async function POST(req: Request) {
return logAndRespond(`Bounty ${bountyId} has no workflow.`);
}
- // Find groupIds
- const groupIds = bounty.groups.map(({ groupId }) => groupId);
+ const bountyGroupIds = bounty.groups.map(({ groupId }) => groupId);
+ const bountyTagIds = bounty.partnerTags.map(
+ ({ partnerTagId }) => partnerTagId,
+ );
// Find program enrollments
const programEnrollments = await prisma.programEnrollment.findMany({
where: {
programId: bounty.programId,
- ...(groupIds.length > 0 && {
+ ...(bountyGroupIds.length > 0 && {
groupId: {
- in: groupIds,
+ in: bountyGroupIds,
},
}),
...(partnerIds && {
@@ -92,13 +100,20 @@ export async function POST(req: Request) {
in: partnerIds,
},
}),
+ ...(bountyTagIds.length > 0 && {
+ programPartnerTags: {
+ some: {
+ partnerTagId: {
+ in: bountyTagIds,
+ },
+ },
+ },
+ }),
status: {
in: ["approved", "invited"],
},
},
- select: {
- partnerId: true,
- totalCommissions: true,
+ include: {
links: {
select: {
clicks: true,
@@ -113,6 +128,11 @@ export async function POST(req: Request) {
name: true,
},
},
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
orderBy: {
createdAt: "asc",
@@ -136,48 +156,53 @@ export async function POST(req: Request) {
.array(workflowConditionSchema)
.parse(bounty.workflow.triggerConditions)[0];
- // Partners with their link metrics
- const partners = programEnrollments.map((programEnrollment) => {
- return {
- id: programEnrollment.partnerId,
- ...aggregatePartnerLinksStats(programEnrollment.links),
- totalCommissions: toCentsNumber(programEnrollment.totalCommissions),
- };
- });
+ const submissionsToCreate: Prisma.BountySubmissionCreateManyInput[] = [];
- const bountySubmissionsToCreate: Prisma.BountySubmissionCreateManyInput[] =
- partners
- // only create submissions for partners that have at least 1 performanceCount
- .filter((partner) => partner[condition.attribute] > 0)
- .map((partner) => {
- const performanceCount = partner[condition.attribute];
-
- const conditionMet = evaluateWorkflowConditions({
- conditions: [condition],
- attributes: {
- [condition.attribute]: performanceCount,
- },
- });
-
- return {
- id: createId({ prefix: "bnty_sub_" }),
- programId: bounty.programId,
- partnerId: partner.id,
- bountyId: bounty.id,
- performanceCount,
- // If the condition is met, automatically submit the submission
- ...(conditionMet && {
- status: "submitted",
- completedAt: new Date(),
- }),
- };
- });
-
- console.table(bountySubmissionsToCreate);
+ for (const enrollment of programEnrollments) {
+ const performanceCount = {
+ ...aggregatePartnerLinksStats(enrollment.links),
+ totalCommissions: toCentsNumber(enrollment.totalCommissions),
+ }[condition.attribute];
+
+ if (!performanceCount || performanceCount <= 0) {
+ continue;
+ }
+
+ const canSubmit = canPartnerSubmitBounty({
+ programEnrollment: enrollment,
+ bounty,
+ });
+
+ if (!canSubmit) {
+ continue;
+ }
+
+ const conditionMet = evaluateWorkflowConditions({
+ conditions: [condition],
+ attributes: {
+ [condition.attribute]: performanceCount,
+ },
+ });
+
+ submissionsToCreate.push({
+ id: createId({ prefix: "bnty_sub_" }),
+ programId: bounty.programId,
+ partnerId: enrollment.partnerId,
+ bountyId: bounty.id,
+ performanceCount: Math.min(performanceCount, condition.value as number),
+ // If the condition is met, automatically submit the submission
+ ...(conditionMet && {
+ status: "submitted",
+ completedAt: new Date(),
+ }),
+ });
+ }
+
+ console.table(submissionsToCreate);
// Create bounty submissions
const createdBountySubmissions = await prisma.bountySubmission.createMany({
- data: bountySubmissionsToCreate,
+ data: submissionsToCreate,
skipDuplicates: true,
});
diff --git a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
index 8b93144b28c..b7cb22d6004 100644
--- a/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
+++ b/apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
@@ -1,5 +1,6 @@
import { createId } from "@/lib/api/create-id";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
+import { bountyEligibilityIncludes } from "@/lib/bounty/api/bounty-eligibility";
import { qstash } from "@/lib/cron";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { prisma } from "@/lib/prisma";
@@ -50,7 +51,6 @@ export async function POST(req: Request) {
id: bountyId,
},
include: {
- groups: true,
program: {
include: {
emailDomains: {
@@ -60,6 +60,7 @@ export async function POST(req: Request) {
},
},
},
+ ...bountyEligibilityIncludes,
},
});
@@ -69,20 +70,25 @@ export async function POST(req: Request) {
});
}
- const diffMinutes = differenceInMinutes(bounty.startsAt, new Date());
-
- if (diffMinutes >= 10) {
+ if (bounty.startMode === "relative") {
return logAndRespond(
- `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,
+ `Bounty ${bountyId} has dynamic start date, skipping...`,
);
}
- // Find groupIds
+ if (bounty.startsAt) {
+ const diffMinutes = differenceInMinutes(bounty.startsAt, new Date());
+
+ if (diffMinutes >= 10) {
+ return logAndRespond(
+ `Bounty ${bountyId} not started yet, it will start at ${bounty.startsAt.toISOString()}`,
+ );
+ }
+ }
+
const groupIds = bounty.groups.map(({ groupId }) => groupId);
- console.log(
- `Bounty ${bountyId} is applicable to ${
- groupIds.length === 0 ? "all" : groupIds.length
- } groups (groupIds: ${JSON.stringify(groupIds)})`,
+ const partnerTagIds = bounty.partnerTags.map(
+ ({ partnerTagId }) => partnerTagId,
);
const programEnrollments = await prisma.programEnrollment.findMany({
@@ -93,6 +99,15 @@ export async function POST(req: Request) {
in: groupIds,
},
}),
+ ...(partnerTagIds.length > 0 && {
+ programPartnerTags: {
+ some: {
+ partnerTagId: {
+ in: partnerTagIds,
+ },
+ },
+ },
+ }),
status: {
in: ACTIVE_ENROLLMENT_STATUSES,
},
diff --git a/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts
index 5ca507dcf2c..4b1c1d2394c 100644
--- a/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts
+++ b/apps/web/app/(ee)/api/cron/bounties/queue-sync-social-metrics/route.ts
@@ -1,3 +1,4 @@
+import { buildActiveBountyPeriodWhere } from "@/lib/bounty/api/bounty-eligibility";
import { enqueueBatchJobs } from "@/lib/cron/enqueue-batch-jobs";
import { withCron } from "@/lib/cron/with-cron";
import { prisma } from "@/lib/prisma";
@@ -9,28 +10,14 @@ export const dynamic = "force-dynamic";
// GET /api/cron/bounties/queue-sync-social-metrics - queue social metrics sync for bounties
export const GET = withCron(async () => {
- const now = new Date();
-
const bounties = await prisma.bounty.findMany({
where: {
type: "submission",
- startsAt: {
- lte: now,
- },
- OR: [
- {
- endsAt: null,
- },
- {
- endsAt: {
- gt: now,
- },
- },
- ],
submissionRequirements: {
path: "$.socialMetrics",
not: Prisma.JsonNull,
},
+ ...buildActiveBountyPeriodWhere(),
},
select: {
id: true,
diff --git a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts
index 6a64fd63252..e7eb971e35e 100644
--- a/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts
+++ b/apps/web/app/(ee)/api/cron/bounties/sync-social-metrics/route.ts
@@ -1,4 +1,8 @@
import { getSocialMetricsUpdates } from "@/lib/bounty/api/get-social-metrics-updates";
+import {
+ getEffectiveBountyPeriod,
+ isBountyExpired,
+} from "@/lib/bounty/bounty-period";
import { resolveBountyDetails } from "@/lib/bounty/utils";
import { qstash } from "@/lib/cron";
import { withCron } from "@/lib/cron/with-cron";
@@ -31,7 +35,13 @@ export const POST = withCron(async ({ rawBody }) => {
id: bountyId,
},
include: {
- program: true,
+ program: {
+ select: {
+ name: true,
+ slug: true,
+ supportEmail: true,
+ },
+ },
},
});
@@ -39,16 +49,6 @@ export const POST = withCron(async ({ rawBody }) => {
return logAndRespond(`Bounty ${bountyId} not found. Skipping...`);
}
- const now = new Date();
-
- if (bounty.startsAt && bounty.startsAt > now) {
- return logAndRespond(`Bounty ${bountyId} has not started yet. Skipping...`);
- }
-
- if (bounty.endsAt && bounty.endsAt < now) {
- return logAndRespond(`Bounty ${bountyId} has ended. Skipping...`);
- }
-
const bountyInfo = resolveBountyDetails(bounty);
if (!bountyInfo?.hasSocialMetrics) {
@@ -75,6 +75,12 @@ export const POST = withCron(async ({ rawBody }) => {
email: true,
},
},
+ programEnrollment: {
+ select: {
+ groupJoinedAt: true,
+ createdAt: true,
+ },
+ },
},
orderBy: {
id: "asc",
@@ -119,7 +125,16 @@ export const POST = withCron(async ({ rawBody }) => {
} of newMetrics) {
const submission = submissionById.get(id);
- if (!submission) {
+ if (!submission || !submission.programEnrollment) {
+ continue;
+ }
+
+ const { endsAt } = getEffectiveBountyPeriod({
+ programEnrollment: submission.programEnrollment,
+ bounty,
+ });
+
+ if (isBountyExpired(endsAt)) {
continue;
}
@@ -136,7 +151,7 @@ export const POST = withCron(async ({ rawBody }) => {
if (shouldTransitionToSubmitted) {
updateData.status = "submitted";
- updateData.completedAt = now;
+ updateData.completedAt = new Date();
if (submission.partner?.email) {
notifications.push({
@@ -157,7 +172,7 @@ export const POST = withCron(async ({ rawBody }) => {
await prisma.$transaction(updates);
- if (notifications.length > 0 && bounty.program) {
+ if (notifications.length > 0) {
await sendBatchEmail(
notifications.map(({ email }) => ({
subject: "Bounty completed!",
diff --git a/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts b/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts
index ab48735c901..f2f1198c4f0 100644
--- a/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts
+++ b/apps/web/app/(ee)/api/e2e/trigger-workflow/[workflowId]/route.ts
@@ -35,7 +35,9 @@ export const POST = withWorkspace(async ({ workspace, params }) => {
const workflowConfig = parseWorkflowConfig(workflow);
if (workflowConfig.action.type === WORKFLOW_ACTION_TYPES.SendCampaign) {
- await executeSendCampaignWorkflow({ workflow });
+ await executeSendCampaignWorkflow({
+ workflow,
+ });
}
return NextResponse.json({
diff --git a/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts
index 0a1e80f6a4f..15a1b29ed96 100644
--- a/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts
+++ b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/social-content-stats/route.ts
@@ -1,5 +1,9 @@
import { DubApiError } from "@/lib/api/errors";
import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content";
+import {
+ bountyEligibilityIncludes,
+ throwIfPartnerCannotSubmitBounty,
+} from "@/lib/bounty/api/bounty-eligibility";
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { resolveBountyDetails } from "@/lib/bounty/utils";
import { withReferralsEmbedToken } from "@/lib/embed/referrals/auth";
@@ -32,45 +36,14 @@ export const GET = withReferralsEmbedToken(
bountyId,
programId: programEnrollment.programId,
include: {
- groups: true,
+ ...bountyEligibilityIncludes,
},
});
- if (bounty.groups.length > 0) {
- const isInGroup = bounty.groups.some(
- ({ groupId }) => groupId === programEnrollment.groupId,
- );
-
- if (!isInGroup) {
- throw new DubApiError({
- code: "forbidden",
- message: "You are not allowed to access this bounty.",
- });
- }
- }
-
- const now = new Date();
-
- if (bounty.startsAt && bounty.startsAt > now) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is not yet available.",
- });
- }
-
- if (bounty.endsAt && bounty.endsAt < now) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is no longer available.",
- });
- }
-
- if (bounty.archivedAt) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is archived.",
- });
- }
+ throwIfPartnerCannotSubmitBounty({
+ programEnrollment,
+ bounty,
+ });
const bountyInfo = resolveBountyDetails(bounty);
diff --git a/apps/web/app/(ee)/api/embed/referrals/submissions/route.ts b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/submissions/route.ts
similarity index 80%
rename from apps/web/app/(ee)/api/embed/referrals/submissions/route.ts
rename to apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/submissions/route.ts
index b2edc073633..5504f3704b1 100644
--- a/apps/web/app/(ee)/api/embed/referrals/submissions/route.ts
+++ b/apps/web/app/(ee)/api/embed/referrals/bounties/[bountyId]/submissions/route.ts
@@ -5,11 +5,12 @@ import { prisma } from "@/lib/prisma";
import { createBountySubmissionInputSchema } from "@/lib/zod/schemas/bounties";
import { NextResponse } from "next/server";
-// POST /api/embed/referrals/submissions – submit a bounty via embed token
+// POST /api/embed/referrals/bounties/[bountyId]/submissions – submit a bounty via embed token
export const POST = withReferralsEmbedToken(
- async ({ req, programEnrollment }) => {
+ async ({ req, programEnrollment, params }) => {
+ const { bountyId } = params;
const parsedInput = createBountySubmissionInputSchema
- .omit({ programId: true })
+ .omit({ programId: true, bountyId: true })
.parse(await parseRequestBody(req));
const partner = await prisma.partner.findUniqueOrThrow({
@@ -26,6 +27,7 @@ export const POST = withReferralsEmbedToken(
const submissionHandler = new BountySubmissionHandler({
...parsedInput,
+ bountyId,
programId: programEnrollment.programId,
partner,
});
diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts
index 5ff1989bd6c..7280c86365a 100644
--- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts
+++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/route.ts
@@ -1,8 +1,12 @@
-import { DubApiError } from "@/lib/api/errors";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { withPartnerProfile } from "@/lib/auth/partner";
+import {
+ bountyEligibilityIncludes,
+ throwIfPartnerCannotViewBounty,
+} from "@/lib/bounty/api/bounty-eligibility";
+import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
+import { getEffectiveBountyPeriod } from "@/lib/bounty/bounty-period";
import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats";
-import { prisma } from "@/lib/prisma";
import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile";
import { NextResponse } from "next/server";
@@ -17,21 +21,23 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
include: {
program: true,
links: true,
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
});
- const bounty = await prisma.bounty.findUnique({
- where: {
- id: bountyId,
- programId: program.id,
- },
+ const bounty = await getBountyOrThrow({
+ bountyId,
+ programId: program.id,
include: {
workflow: {
select: {
triggerConditions: true,
},
},
- groups: true,
submissions: {
where: {
partnerId: partner.id,
@@ -47,41 +53,24 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
},
},
},
+ ...bountyEligibilityIncludes,
},
});
- if (!bounty) {
- throw new DubApiError({
- code: "not_found",
- message: "Bounty not found.",
- });
- }
-
- if (bounty.startsAt > new Date()) {
- throw new DubApiError({
- code: "not_found",
- message: "Bounty not found.",
- });
- }
-
- const partnerGroupId = programEnrollment.groupId || program.defaultGroupId;
- const bountyGroupIds = bounty.groups.map((g) => g.groupId);
- const partnerCanSeeBounty =
- bountyGroupIds.length === 0 ||
- (partnerGroupId && bountyGroupIds.includes(partnerGroupId));
-
- if (!partnerCanSeeBounty) {
- throw new DubApiError({
- code: "not_found",
- message: "Bounty not found.",
- });
- }
-
- const { groups, ...bountyWithoutGroups } = bounty;
+ throwIfPartnerCannotViewBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId: program.defaultGroupId,
+ hasSubmission: bounty.submissions.length > 0,
+ });
return NextResponse.json(
PartnerBountySchema.parse({
- ...bountyWithoutGroups,
+ ...bounty,
+ ...getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+ }),
performanceCondition: bounty.workflow?.triggerConditions?.[0] || null,
partner: {
...aggregatePartnerLinksStats(links),
diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts
index 124e659a1f3..e4897f9bd0e 100644
--- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts
+++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/[bountyId]/social-content-stats/route.ts
@@ -2,6 +2,10 @@ import { DubApiError } from "@/lib/api/errors";
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
import { getSocialContent } from "@/lib/api/scrape-creators/get-social-content";
import { withPartnerProfile } from "@/lib/auth/partner";
+import {
+ bountyEligibilityIncludes,
+ throwIfPartnerCannotSubmitBounty,
+} from "@/lib/bounty/api/bounty-eligibility";
import { getBountyOrThrow } from "@/lib/bounty/api/get-bounty-or-throw";
import { resolveBountyDetails } from "@/lib/bounty/utils";
import { ratelimit } from "@/lib/upstash";
@@ -33,12 +37,26 @@ export const GET = withPartnerProfile(
const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId,
- include: {},
+ include: {
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
+ },
});
const bounty = await getBountyOrThrow({
bountyId,
programId: programEnrollment.programId,
+ include: {
+ ...bountyEligibilityIncludes,
+ },
+ });
+
+ throwIfPartnerCannotSubmitBounty({
+ programEnrollment,
+ bounty,
});
const bountyInfo = resolveBountyDetails(bounty);
diff --git a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts
index c9622f3ed00..03fadc40895 100644
--- a/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts
+++ b/apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts
@@ -9,8 +9,26 @@ export const GET = withPartnerProfile(async ({ partner, params }) => {
partnerId: partner.id,
programId: params.programId,
include: {
- program: true,
- links: true,
+ program: {
+ select: {
+ id: true,
+ defaultGroupId: true,
+ },
+ },
+ links: {
+ select: {
+ clicks: true,
+ leads: true,
+ conversions: true,
+ sales: true,
+ saleAmount: true,
+ },
+ },
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
});
diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx
index 2b19e1c6987..d3159fe67ef 100644
--- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx
+++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submission-form.tsx
@@ -171,21 +171,23 @@ export function EmbedBountySubmissionForm({
isDraft ? setIsDraftSaving(true) : setIsSubmitting(true);
try {
- const res = await fetch("/api/embed/referrals/submissions", {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${token}`,
+ const res = await fetch(
+ `/api/embed/referrals/bounties/${bounty.id}/submissions`,
+ {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ files: completedFiles,
+ urls: submissionUrls,
+ description: description || undefined,
+ isDraft,
+ periodNumber,
+ }),
},
- body: JSON.stringify({
- bountyId: bounty.id,
- files: completedFiles,
- urls: submissionUrls,
- description: description || undefined,
- isDraft,
- periodNumber,
- }),
- });
+ );
if (!res.ok) {
const err = await res.json().catch(() => ({}));
diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx
index 1f6ff5e0e5f..df8eeeba8a3 100644
--- a/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx
+++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/bounties/submissions-table.tsx
@@ -1,5 +1,6 @@
"use client";
+import { isBountyExpired } from "@/lib/bounty/bounty-period";
import { BountySubmissionStatusBadges } from "@/lib/bounty/bounty-submission-status-badges";
import {
type SubmissionPeriod,
@@ -96,8 +97,9 @@ export function EmbedBountySubmissionsTable({
size: 98,
cell: ({ row: { original } }) => {
const { status, periodNumber } = original;
- const isExpired =
- bounty.endsAt !== null && new Date(bounty.endsAt) < new Date();
+ const isExpired = isBountyExpired(
+ bounty.endsAt ? new Date(bounty.endsAt) : null,
+ );
const isActionable =
status === "notSubmitted" ||
(status === "draft" &&
diff --git a/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts b/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts
index d5502ff8e24..57a29e99894 100644
--- a/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts
+++ b/apps/web/app/(ee)/app.dub.co/embed/referrals/get-referrals-embed-data.ts
@@ -72,6 +72,11 @@ export const getReferralsEmbedData = async (token: string) => {
saleReward: true,
referralReward: true,
discount: true,
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
});
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx
index e46df734904..86e69bb354c 100644
--- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx
+++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/[bountyId]/bounty-submissions-table.tsx
@@ -1,5 +1,6 @@
"use client";
+import { isBountyExpired } from "@/lib/bounty/bounty-period";
import { BountySubmissionStatusBadges } from "@/lib/bounty/bounty-submission-status-badges";
import {
type SubmissionPeriod,
@@ -142,8 +143,9 @@ export function BountySubmissionsTable({
cell: ({ row: { original } }) => {
const { status } = original;
const bountyInfo = resolveBountyDetails(bounty);
- const isExpired =
- bounty.endsAt !== null && new Date(bounty.endsAt) < new Date();
+ const isExpired = isBountyExpired(
+ bounty.endsAt ? new Date(bounty.endsAt) : null,
+ );
const isActionable = bountyInfo?.hasSocialMetrics
? status === "notSubmitted"
: status === "notSubmitted" || status === "draft";
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx
index 17ddba859c4..36c3cbf2027 100644
--- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx
+++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/bounty-card.tsx
@@ -1,3 +1,4 @@
+import { isBountyExpired } from "@/lib/bounty/bounty-period";
import { PartnerBountyProps } from "@/lib/types";
import {
PerformanceBountyProgress,
@@ -169,8 +170,9 @@ export function BountyRewardsTable({
}
export function BountyEndDate({ bounty }: { bounty: PartnerBountyProps }) {
- const isExpired =
- bounty.endsAt && new Date(bounty.endsAt) < new Date() ? true : false;
+ const isExpired = isBountyExpired(
+ bounty.endsAt ? new Date(bounty.endsAt) : null,
+ );
return (
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx
index 7258e9378a7..b7da9ad2aa6 100644
--- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx
+++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page.tsx
@@ -1,5 +1,6 @@
"use client";
+import { isBountyExpired } from "@/lib/bounty/bounty-period";
import { usePartnerProgramBounties } from "@/lib/swr/use-partner-program-bounties";
import { PageContent } from "@/ui/layout/page-content";
import { PageWidthWrapper } from "@/ui/layout/page-width-wrapper";
@@ -29,9 +30,10 @@ export default function PartnerProgramBountiesPage() {
const filteredBounties = useMemo(() => {
if (!bounties) return [];
- const now = new Date();
return bounties.filter((bounty) => {
- const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= now;
+ const isExpired = isBountyExpired(
+ bounty.endsAt ? new Date(bounty.endsAt) : null,
+ );
if (activeTab === "active") {
return !isExpired;
diff --git a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx
index e4fcc39fa5d..58751759138 100644
--- a/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx
+++ b/apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/invite/page.tsx
@@ -1,6 +1,6 @@
import { serializeReward } from "@/lib/api/partners/serialize-reward";
import { getSession } from "@/lib/auth";
-import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries";
+import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties";
import { prisma } from "@/lib/prisma";
import { programLanderSchema } from "@/lib/zod/schemas/program-lander";
import { PageContent } from "@/ui/layout/page-content";
@@ -71,7 +71,7 @@ export default async function ProgramInvitePage(props: {
.filter((r) => r !== null)
.map((r) => serializeReward(r as Reward));
- const bounties = await getGroupBountySummaries({
+ const bounties = await getGroupBounties({
programId: program.id,
groupId: group.id,
});
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx
index b334839fcf6..7524a59e9fd 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx
@@ -6,6 +6,7 @@ import {
useBountySubmissionsCount,
} from "@/lib/swr/use-bounty-submissions-count";
import useGroups from "@/lib/swr/use-groups";
+import { usePartnerTags } from "@/lib/swr/use-partner-tags";
import { usePartnersCountByGroupIds } from "@/lib/swr/use-partners-count-by-groupids";
import useWorkspace from "@/lib/swr/use-workspace";
import { BountyRewardDescription } from "@/ui/partners/bounties/bounty-reward-description";
@@ -44,6 +45,7 @@ export function BountyInfo() {
});
const { groups } = useGroups();
+ const { partnerTags } = usePartnerTags();
const eligibleGroups = useMemo(() => {
if (!groups || !bounty || bounty.groups.length === 0) {
@@ -54,6 +56,16 @@ export function BountyInfo() {
.filter((g): g is NonNullable
=> g !== undefined);
}, [groups, bounty]);
+ const eligibleTags = useMemo(() => {
+ if (!partnerTags || !bounty || bounty.partnerTags.length === 0) {
+ return [];
+ }
+
+ return bounty.partnerTags
+ .map((bountyTag) => partnerTags.find((t) => t.id === bountyTag.id))
+ .filter((t): t is NonNullable => t !== undefined);
+ }, [partnerTags, bounty]);
+
if (loading) {
return ;
}
@@ -81,7 +93,9 @@ export function BountyInfo() {
- {formatDate(bounty.startsAt, { month: "short" })}
+ {bounty.startsAt
+ ? formatDate(bounty.startsAt, { month: "short" })
+ : "When a partner joins"}
{" → "}
{bounty.endsAt
? formatDate(bounty.endsAt, { month: "short" })
@@ -140,38 +154,74 @@ export function BountyInfo() {
{isOwner && (
-
+
- {bounty.groups.length === 0 ? (
-
All groups
- ) : eligibleGroups.length === 1 ? (
-
-
- {eligibleGroups[0].name}
-
- ) : eligibleGroups.length > 1 ? (
-
- {eligibleGroups.map((group) => (
-
-
-
- {group.name}
-
-
- ))}
-
- }
- >
+
+ {bounty.groups.length === 0 ? (
+
All groups
+ ) : eligibleGroups.length === 1 ? (
-
- {eligibleGroups[0].name} +{eligibleGroups.length - 1}
-
+ {eligibleGroups[0].name}
-
- ) : null}
+ ) : eligibleGroups.length > 1 ? (
+
+ {eligibleGroups.map((group) => (
+
+
+
+ {group.name}
+
+
+ ))}
+
+ }
+ >
+
+
+
+ {eligibleGroups[0].name} +{eligibleGroups.length - 1}
+
+
+
+ ) : (
+
+ )}
+
+ {bounty.partnerTags.length > 0 && (
+ <>
+
·
+ {eligibleTags.length > 0 ? (
+ eligibleTags.length === 1 ? (
+
{eligibleTags[0].name}
+ ) : (
+
+ {eligibleTags.map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+ }
+ >
+
+ {eligibleTags[0].name} +{eligibleTags.length - 1}
+
+
+ )
+ ) : (
+
+ )}
+ >
+ )}
+
)}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx
index c5499163d7a..33c20353f7e 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/add-edit-bounty-sheet.tsx
@@ -6,7 +6,6 @@ import { getPlanCapabilities } from "@/lib/plan-capabilities";
import useProgram from "@/lib/swr/use-program";
import useWorkspace from "@/lib/swr/use-workspace";
import { BountyProps } from "@/lib/types";
-import { GroupsMultiSelect } from "@/ui/partners/groups/groups-multi-select";
import {
ProgramSheetAccordion,
ProgramSheetAccordionContent,
@@ -21,7 +20,6 @@ import {
} from "@/ui/shared/inline-badge-popover";
import { MaxCharactersCounter } from "@/ui/shared/max-characters-counter";
import {
- AnimatedSizeContainer,
Button,
CalendarIcon,
CardSelector,
@@ -34,7 +32,6 @@ import {
RichTextProvider,
RichTextToolbar,
Sheet,
- SmartDateTimePicker,
Switch,
Tooltip,
TooltipContent,
@@ -45,6 +42,8 @@ import { BountySubmissionFrequency } from "@prisma/client";
import { Dispatch, SetStateAction, useState } from "react";
import { Controller, FormProvider } from "react-hook-form";
import { BountyCriteria } from "./bounty-criteria";
+import { BountyDuration } from "./bounty-duration";
+import { BountyEligibility } from "./bounty-eligibility";
import { useAddEditBountyForm } from "./use-add-edit-bounty-form";
interface BountySheetProps {
@@ -78,11 +77,12 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
form,
openAccordions,
setOpenAccordions,
- hasStartDate,
- handleStartDateToggle,
hasEndDate,
- handleEndDateToggle,
- handleEndDateChange,
+ startsAt,
+ endsAt,
+ startMode,
+ endsAfterDays,
+ handleTimingChange,
allowedSubmissions,
handleAllowedSubmissionsChange,
maxAllowedSubmissions,
@@ -246,126 +246,16 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
-
-
-
-
-
-
- {hasStartDate && (
-
- (
-
- field.onChange(date ?? undefined)
- }
- placeholder='E.g. "2026-02-28", "Last Thursday", "2 hours ago"'
- />
- )}
- />
-
- )}
-
-
- {type === "performance" && (
-
-
-
-
-
-
- {hasEndDate && (
-
- (
-
- handleEndDateChange(date ?? null)
- }
- placeholder='E.g. "2026-12-01", "Next Thursday", "After 10 days"'
- />
- )}
- />
-
- )}
-
- )}
-
- {type === "submission" && (
-
-
-
-
-
-
- {hasEndDate && (
-
- (
-
- handleEndDateChange(date ?? null)
- }
- placeholder='E.g. "2026-12-01", "Next Thursday", "After 10 days"'
- />
- )}
- />
-
- )}
-
- )}
+ onChange={handleTimingChange}
+ />
{type === "submission" && (
<>
@@ -503,23 +393,7 @@ function BountySheetContent({ setIsOpen, bounty }: BountySheetProps) {
-
-
- Groups
-
-
- (
- field.onChange(ids)}
- />
- )}
- />
-
-
+
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx
new file mode 100644
index 00000000000..372381bc17c
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-duration.tsx
@@ -0,0 +1,472 @@
+"use client";
+
+import {
+ BOUNTY_DURATION_DAYS,
+ BOUNTY_DURATION_PRESETS,
+ DurationPreset,
+ EndPreset,
+ resolveBountyTiming,
+ StartPreset,
+} from "@/lib/bounty/bounty-period";
+import {
+ InlineBadgePopover,
+ InlineBadgePopoverMenu,
+} from "@/ui/shared/inline-badge-popover";
+import { CalendarIcon, DatePicker } from "@dub/ui";
+import { formatDate } from "@dub/utils";
+import { addDays, addMonths, addWeeks } from "date-fns";
+import { useEffect, useState } from "react";
+
+type PresetOption = { value: T; label: string };
+type BountyTimingInput = ReturnType;
+type ParsedPresets = {
+ startPreset: StartPreset;
+ endPreset: EndPreset;
+ customStartsAt: Date | null;
+ customEndsAt: Date | null;
+ customEndsAfterDays: number | null;
+};
+
+const DURATION_LABELS: Record =
+ {
+ twoWeeks: { start: "in 2 weeks", end: "2 weeks" },
+ oneMonth: { start: "in 1 month", end: "1 month" },
+ sixMonths: { start: "in 6 months", end: "6 months" },
+ };
+
+const START_OPTIONS = [
+ { value: "today", label: "today" },
+ ...BOUNTY_DURATION_PRESETS.map((p) => ({
+ value: p,
+ label: DURATION_LABELS[p].start,
+ })),
+ { value: "onPartnerJoin", label: "when a new partner joins" },
+ { value: "custom", label: "custom" },
+] satisfies PresetOption[];
+
+const END_OPTIONS = [
+ { value: "never", label: "never" },
+ ...BOUNTY_DURATION_PRESETS.map((p) => ({
+ value: p,
+ label: DURATION_LABELS[p].end,
+ })),
+ { value: "custom", label: "custom" },
+] satisfies PresetOption[];
+
+const START_DURATION_DATES: Record Date> = {
+ twoWeeks: (now) => addWeeks(now, 2),
+ oneMonth: (now) => addMonths(now, 1),
+ sixMonths: (now) => addMonths(now, 6),
+};
+
+const DATE_TOLERANCE_MS = 60_000;
+
+function datesAreClose(a: Date, b: Date, toleranceMs = DATE_TOLERANCE_MS) {
+ return Math.abs(a.getTime() - b.getTime()) <= toleranceMs;
+}
+
+function isSameCalendarDay(a: Date, b: Date) {
+ return (
+ a.getFullYear() === b.getFullYear() &&
+ a.getMonth() === b.getMonth() &&
+ a.getDate() === b.getDate()
+ );
+}
+
+function findDurationPresetByDays(days: number): DurationPreset | null {
+ return (
+ (Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][]).find(
+ ([, durationDays]) => durationDays === days,
+ )?.[0] ?? null
+ );
+}
+
+function getPresetLabel(
+ preset: T,
+ options: PresetOption[],
+ customDate?: Date | null,
+ fallback?: string,
+) {
+ if (preset === "custom" && customDate) {
+ return formatDate(customDate, { month: "short" });
+ }
+
+ return options.find((option) => option.value === preset)?.label ?? fallback;
+}
+
+function parsePresets(value: BountyTimingInput): ParsedPresets {
+ let startPreset: StartPreset;
+ let customStartsAt: Date | null;
+
+ if (value.startMode === "relative") {
+ startPreset = "onPartnerJoin";
+ customStartsAt = null;
+ } else {
+ const now = new Date();
+
+ if (isSameCalendarDay(value.startsAt, now)) {
+ startPreset = "today";
+ customStartsAt = null;
+ } else {
+ const matchedStartPreset = BOUNTY_DURATION_PRESETS.find((preset) =>
+ datesAreClose(value.startsAt, START_DURATION_DATES[preset](now)),
+ );
+
+ if (matchedStartPreset) {
+ startPreset = matchedStartPreset;
+ customStartsAt = null;
+ } else {
+ startPreset = "custom";
+ customStartsAt = value.startsAt;
+ }
+ }
+ }
+
+ let endPreset: EndPreset;
+ let customEndsAt: Date | null;
+
+ if (value.endsAfterDays != null) {
+ const durationPreset = findDurationPresetByDays(value.endsAfterDays);
+
+ if (durationPreset) {
+ return {
+ startPreset,
+ endPreset: durationPreset,
+ customStartsAt,
+ customEndsAt: null,
+ customEndsAfterDays: null,
+ };
+ }
+ }
+
+ if (!value.endsAt) {
+ endPreset = "never";
+ customEndsAt = null;
+ } else if (value.startMode === "absolute") {
+ const matchedEndPreset = (
+ Object.entries(BOUNTY_DURATION_DAYS) as [DurationPreset, number][]
+ ).find(([, days]) =>
+ datesAreClose(value.endsAt!, addDays(value.startsAt, days)),
+ )?.[0];
+
+ if (matchedEndPreset) {
+ endPreset = matchedEndPreset;
+ customEndsAt = null;
+ } else {
+ endPreset = "custom";
+ customEndsAt = value.endsAt;
+ }
+ } else {
+ endPreset = "custom";
+ customEndsAt = value.endsAt;
+ }
+
+ return {
+ startPreset,
+ endPreset,
+ customStartsAt,
+ customEndsAt,
+ customEndsAfterDays: null,
+ };
+}
+
+function parsePresetsForEdit(value: BountyTimingInput): ParsedPresets {
+ if (value.startMode === "relative") {
+ const startPreset: StartPreset = "onPartnerJoin";
+ const customStartsAt = null;
+
+ if (value.endsAfterDays != null) {
+ const durationPreset = findDurationPresetByDays(value.endsAfterDays);
+
+ if (durationPreset) {
+ return {
+ startPreset,
+ endPreset: durationPreset,
+ customStartsAt,
+ customEndsAt: null,
+ customEndsAfterDays: null,
+ };
+ }
+
+ return {
+ startPreset,
+ endPreset: "never",
+ customStartsAt,
+ customEndsAt: null,
+ customEndsAfterDays: value.endsAfterDays,
+ };
+ }
+
+ if (value.endsAt) {
+ return {
+ startPreset,
+ endPreset: "custom",
+ customStartsAt,
+ customEndsAt: value.endsAt,
+ customEndsAfterDays: null,
+ };
+ }
+
+ return {
+ startPreset,
+ endPreset: "never",
+ customStartsAt,
+ customEndsAt: null,
+ customEndsAfterDays: null,
+ };
+ }
+
+ const startPreset: StartPreset = "custom";
+ const customStartsAt = value.startsAt;
+
+ if (!value.endsAt) {
+ return {
+ startPreset,
+ endPreset: "never",
+ customStartsAt,
+ customEndsAt: null,
+ customEndsAfterDays: null,
+ };
+ }
+
+ return {
+ startPreset,
+ endPreset: "custom",
+ customStartsAt,
+ customEndsAt: value.endsAt,
+ customEndsAfterDays: null,
+ };
+}
+
+function parsePresetsFromValue(
+ value: BountyTimingInput,
+ isEditing: boolean,
+): ParsedPresets {
+ return isEditing ? parsePresetsForEdit(value) : parsePresets(value);
+}
+
+function CustomDatePickerIcon({
+ value,
+ onChange,
+}: {
+ value: Date | null | undefined;
+ onChange: (date: Date | null) => void;
+}) {
+ return (
+ {
+ if (!date) {
+ onChange(null);
+ return;
+ }
+
+ const merged = new Date(date);
+
+ if (value) {
+ merged.setHours(
+ value.getHours(),
+ value.getMinutes(),
+ value.getSeconds(),
+ value.getMilliseconds(),
+ );
+ }
+
+ onChange(merged);
+ }}
+ align="start"
+ showYearNavigation
+ trigger={() => (
+
+ )}
+ />
+ );
+}
+
+interface BountyDurationProps {
+ value: BountyTimingInput;
+ onChange: (value: BountyTimingInput) => void;
+ isEditing?: boolean;
+}
+
+export function BountyDuration({
+ value,
+ onChange,
+ isEditing = false,
+}: BountyDurationProps) {
+ const initialPresets = parsePresetsFromValue(value, isEditing);
+
+ const [startPreset, setStartPreset] = useState(
+ initialPresets.startPreset,
+ );
+
+ const [endPreset, setEndPreset] = useState(
+ initialPresets.endPreset,
+ );
+
+ const [customStartsAt, setCustomStartsAt] = useState(
+ initialPresets.customStartsAt,
+ );
+
+ const [customEndsAt, setCustomEndsAt] = useState(
+ initialPresets.customEndsAt,
+ );
+
+ const [customEndsAfterDays, setCustomEndsAfterDays] = useState(
+ initialPresets.customEndsAfterDays,
+ );
+
+ useEffect(() => {
+ const presets = parsePresetsFromValue(value, isEditing);
+ setStartPreset(presets.startPreset);
+ setEndPreset(presets.endPreset);
+ setCustomStartsAt(presets.customStartsAt);
+ setCustomEndsAt(presets.customEndsAt);
+ setCustomEndsAfterDays(presets.customEndsAfterDays);
+ }, [
+ isEditing,
+ value.startMode,
+ value.startsAt,
+ value.endsAt,
+ value.endsAfterDays,
+ ]);
+
+ const applyTiming = ({
+ nextStartPreset = startPreset,
+ nextEndPreset = endPreset,
+ nextCustomStartsAt = customStartsAt,
+ nextCustomEndsAt = customEndsAt,
+ }: {
+ nextStartPreset?: StartPreset;
+ nextEndPreset?: EndPreset;
+ nextCustomStartsAt?: Date | null;
+ nextCustomEndsAt?: Date | null;
+ } = {}) => {
+ onChange(
+ resolveBountyTiming({
+ startPreset: nextStartPreset,
+ endPreset: nextEndPreset,
+ customStartsAt: nextCustomStartsAt,
+ customEndsAt: nextCustomEndsAt,
+ }),
+ );
+ };
+
+ const startLabel = getPresetLabel(
+ startPreset,
+ START_OPTIONS,
+ customStartsAt ?? value.startsAt,
+ "today",
+ );
+
+ const endLabel =
+ customEndsAfterDays != null
+ ? `${customEndsAfterDays} days`
+ : getPresetLabel(
+ endPreset,
+ END_OPTIONS,
+ customEndsAt ?? value.endsAt,
+ "never",
+ );
+
+ const endSuffix =
+ customEndsAfterDays != null ||
+ (endPreset !== "never" && endPreset !== "custom")
+ ? value.startMode === "relative"
+ ? "after joining"
+ : "from start date"
+ : null;
+
+ return (
+
+
+
+
+ Starts{" "}
+
+
+ ({
+ value: option.value,
+ text: option.label,
+ }))}
+ selectedValue={startPreset}
+ onSelect={(preset) => {
+ setStartPreset(preset);
+
+ if (preset === "custom") {
+ setCustomStartsAt(customStartsAt ?? value.startsAt);
+ return;
+ }
+
+ applyTiming({ nextStartPreset: preset });
+ }}
+ />
+
+ {startPreset === "custom" && (
+ {
+ const nextCustomStartsAt = date ?? null;
+ setCustomStartsAt(nextCustomStartsAt);
+ applyTiming({
+ nextStartPreset: "custom",
+ nextCustomStartsAt,
+ });
+ }}
+ />
+ )}
+ {" "}
+ and ends{" "}
+
+
+ ({
+ value: option.value,
+ text: option.label,
+ }))}
+ selectedValue={
+ customEndsAfterDays != null ? undefined : endPreset
+ }
+ onSelect={(preset) => {
+ setEndPreset(preset);
+ setCustomEndsAfterDays(null);
+
+ if (preset === "custom") {
+ setCustomEndsAt(
+ customEndsAt ??
+ value.endsAt ??
+ addWeeks(value.startsAt, 2),
+ );
+ return;
+ }
+
+ applyTiming({ nextEndPreset: preset });
+ }}
+ />
+
+ {endPreset === "custom" && (
+ {
+ const nextCustomEndsAt = date ?? null;
+ setCustomEndsAt(nextCustomEndsAt);
+ applyTiming({
+ nextEndPreset: "custom",
+ nextCustomEndsAt,
+ });
+ }}
+ />
+ )}
+
+ {endSuffix && {endSuffix}}
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx
new file mode 100644
index 00000000000..0876e27bf7e
--- /dev/null
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/bounty-eligibility.tsx
@@ -0,0 +1,407 @@
+"use client";
+
+import useGroups from "@/lib/swr/use-groups";
+import { usePartnerTags } from "@/lib/swr/use-partner-tags";
+import usePartnersCount from "@/lib/swr/use-partners-count";
+import { GroupExtendedProps } from "@/lib/types";
+import { GROUPS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/groups";
+import { PARTNER_TAGS_MAX_PAGE_SIZE } from "@/lib/zod/schemas/partner-tags";
+import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle";
+import {
+ ProgramSheetAccordionContent,
+ ProgramSheetAccordionItem,
+ ProgramSheetAccordionTrigger,
+} from "@/ui/partners/program-sheet-accordion";
+import {
+ AnimatedSizeContainer,
+ Check2,
+ LoadingSpinner,
+ Magnifier,
+ ScrollContainer,
+ Switch,
+ Users6,
+} from "@dub/ui";
+import { cn, nFormatter } from "@dub/utils";
+import { Command } from "cmdk";
+import { ReactNode, useCallback, useEffect, useMemo, useState } from "react";
+import { Controller } from "react-hook-form";
+import { useDebounce } from "use-debounce";
+import { useBountyFormContext } from "./bounty-form-context";
+
+export function BountyEligibility() {
+ const { control } = useBountyFormContext();
+
+ return (
+
+ Eligibility
+
+
+ (
+ field.onChange(null)}
+ >
+ field.onChange(ids)}
+ />
+
+ )}
+ />
+
+ (
+ field.onChange(null)}
+ >
+ field.onChange(ids)}
+ />
+
+ )}
+ />
+
+
+
+ );
+}
+
+function EligibilityToggle({
+ title,
+ enabledDescription,
+ disabledDescription,
+ defaultEnabled,
+ onDisable,
+ children,
+}: {
+ title: string;
+ enabledDescription: string;
+ disabledDescription: string;
+ defaultEnabled: boolean;
+ onDisable: () => void;
+ children: ReactNode;
+}) {
+ const [enabled, setEnabled] = useState(defaultEnabled);
+
+ return (
+
+
+
{
+ setEnabled(checked);
+ if (!checked) onDisable();
+ }}
+ trackDimensions="w-8 h-4"
+ thumbDimensions="w-3 h-3"
+ thumbTranslate="translate-x-4"
+ />
+
+
+ {title}
+
+
+ {enabled ? enabledDescription : disabledDescription}
+
+
+
+
+
+
+ {enabled &&
{children}
}
+
+
+
+ );
+}
+
+interface EligibilitySelectProps {
+ selectedIds: string[] | null;
+ setSelectedIds: (ids: string[] | null) => void;
+}
+
+function GroupsEligibilitySelect({
+ selectedIds,
+ setSelectedIds,
+}: EligibilitySelectProps) {
+ const [search, setSearch] = useState("");
+ const [useAsync, setUseAsync] = useState(false);
+ const [debouncedSearch] = useDebounce(search, 500);
+
+ const { groups } = useGroups({
+ query: {
+ includeExpandedFields: true,
+ ...(useAsync ? { search: debouncedSearch } : undefined),
+ },
+ });
+
+ const { groups: selectedGroups } = useGroups({
+ query: {
+ groupIds: selectedIds ?? undefined,
+ includeExpandedFields: true,
+ },
+ enabled: Boolean(selectedIds?.length),
+ });
+
+ // Determine if we should use async loading
+ useEffect(() => {
+ setUseAsync(
+ (prev) =>
+ prev || Boolean(groups && groups.length >= GROUPS_MAX_PAGE_SIZE),
+ );
+ }, [groups]);
+
+ return (
+ `${group.name}::${group.slug}`}
+ renderLeading={(group) => }
+ renderRight={(group) => (
+
+ {nFormatter(group.totalPartners, { full: true })} qualify
+
+ )}
+ />
+ );
+}
+
+function TagsEligibilitySelect({
+ selectedIds,
+ setSelectedIds,
+}: EligibilitySelectProps) {
+ const [search, setSearch] = useState("");
+ const [useAsync, setUseAsync] = useState(false);
+ const [debouncedSearch] = useDebounce(search, 500);
+
+ const { partnerTags } = usePartnerTags({
+ query: { ...(useAsync ? { search: debouncedSearch } : undefined) },
+ });
+
+ const { partnerTags: selectedTags } = usePartnerTags({
+ query: { ids: selectedIds ?? undefined },
+ enabled: Boolean(selectedIds?.length),
+ });
+
+ const { partnersCount } = usePartnersCount<
+ { partnerTagId: string; _count: number }[]
+ >({
+ groupBy: "partnerTagId",
+ ignoreParams: true,
+ });
+
+ const tagCountMap = useMemo(() => {
+ const map = new Map();
+ if (Array.isArray(partnersCount)) {
+ for (const { partnerTagId, _count } of partnersCount) {
+ map.set(partnerTagId, _count);
+ }
+ }
+ return map;
+ }, [partnersCount]);
+
+ // Determine if we should use async loading
+ useEffect(() => {
+ setUseAsync(
+ (prev) =>
+ prev ||
+ Boolean(
+ partnerTags && partnerTags.length >= PARTNER_TAGS_MAX_PAGE_SIZE,
+ ),
+ );
+ }, [partnerTags]);
+
+ return (
+ (
+
+
+ {nFormatter(tagCountMap.get(tag.id) ?? 0, { full: true })}
+
+ )}
+ />
+ );
+}
+
+interface BountyEligibilityMultiSelectProps<
+ T extends { id: string; name: string },
+> {
+ // The current (optionally search-filtered) list of items, or undefined while loading
+ items: T[] | undefined;
+ // The currently selected items, so they remain visible even when not in `items`
+ selectedItems: T[] | undefined;
+ selectedIds: string[] | null;
+ setSelectedIds: (ids: string[] | null) => void;
+ search: string;
+ setSearch: (search: string) => void;
+ // Whether items are searched server-side (search input shouldn't filter locally)
+ useAsync: boolean;
+ searchPlaceholder: string;
+ // Value used for local (cmdk) filtering — defaults to the item name
+ getItemValue?: (item: T) => string;
+ // Left-aligned visual rendered before the item name (e.g. a color circle)
+ renderLeading?: (item: T) => ReactNode;
+ // Right-aligned content rendered at the end of the row (e.g. a partner count)
+ renderRight?: (item: T) => ReactNode;
+}
+
+function BountyEligibilityMultiSelect({
+ items,
+ selectedItems,
+ selectedIds,
+ setSelectedIds,
+ search,
+ setSearch,
+ useAsync,
+ searchPlaceholder,
+ getItemValue,
+ renderLeading,
+ renderRight,
+}: BountyEligibilityMultiSelectProps) {
+ const [shouldSort, setShouldSort] = useState(false);
+ const [sortedItems, setSortedItems] = useState(undefined);
+
+ const sortItems = useCallback(
+ (items: T[], search: string) => {
+ return search === ""
+ ? [
+ ...items.filter((i) => selectedIds?.includes(i.id)),
+ ...items.filter((i) => !selectedIds?.includes(i.id)),
+ ]
+ : items;
+ },
+ [selectedIds],
+ );
+
+ // Actually sort the items when needed
+ useEffect(() => {
+ if (!shouldSort || !items || (selectedIds?.length && !selectedItems))
+ return;
+
+ setSortedItems(
+ sortItems(
+ [
+ ...(selectedItems ?? []),
+ ...items.filter((i) => !selectedItems?.some((si) => si.id === i.id)),
+ ],
+ search,
+ ),
+ );
+ setShouldSort(false);
+ }, [shouldSort, items, selectedIds, selectedItems, sortItems, search]);
+
+ // Re-sort when the search-filtered items or selection changes
+ useEffect(() => setShouldSort(true), [items, selectedIds]);
+
+ return (
+
+
+
+
+ {sortedItems !== undefined ? (
+ <>
+ {sortedItems.map((item) => {
+ const checked = Boolean(selectedIds?.includes(item.id));
+
+ return (
+
+ setSelectedIds(
+ selectedIds?.includes(item.id)
+ ? selectedIds.length === 1
+ ? null // Revert to null if there will be no items selected
+ : selectedIds.filter((id) => id !== item.id)
+ : [...(selectedIds ?? []), item.id],
+ )
+ }
+ className={cn(
+ "flex cursor-pointer select-none items-center gap-3 whitespace-nowrap rounded-md px-3 py-2.5 text-left text-sm text-neutral-700",
+ "data-[selected=true]:bg-neutral-100",
+ )}
+ >
+
+ {checked && Checked}
+
+
+
+ {renderLeading?.(item)}
+ {item.name}
+
+ {renderRight?.(item)}
+
+ );
+ })}
+ {!useAsync ? (
+
+ No matches
+
+ ) : sortedItems.length === 0 ? (
+
+ No matches
+
+ ) : null}
+ >
+ ) : (
+ // undefined data / explicit loading state
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx
index 5b425d1f7c5..600916786f4 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/confirm-create-bounty-modal.tsx
@@ -33,6 +33,7 @@ type ConfirmCreateBountyModalProps = {
| "rewardDescription"
| "submissionRequirements"
| "groups"
+ | "partnerTags"
>;
onConfirm: (data: { sendNotificationEmails: boolean }) => Promise;
};
@@ -108,7 +109,9 @@ function ConfirmCreateBountyModal({
- {formatDate(bounty.startsAt, { month: "short" })}
+ {bounty.startsAt
+ ? formatDate(bounty.startsAt, { month: "short" })
+ : "When a partner joins"}
{bounty.endsAt && (
<>
{" → "}
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts
index 013e8ecc8ff..951234a3ac4 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty/use-add-edit-bounty-form.ts
@@ -2,6 +2,7 @@
import { isCurrencyAttribute } from "@/lib/api/workflows/utils";
import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name";
+import { resolveBountyTiming } from "@/lib/bounty/bounty-period";
import {
BOUNTY_DESCRIPTION_MAX_LENGTH,
BOUNTY_MAX_SUBMISSIONS,
@@ -17,7 +18,15 @@ import {
} from "@/lib/zod/schemas/bounties";
import { formatDate } from "@dub/utils";
import { BountySubmissionFrequency } from "@prisma/client";
-import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
+import { addDays } from "date-fns";
+import {
+ Dispatch,
+ SetStateAction,
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+} from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
@@ -30,7 +39,7 @@ const ACCORDION_ITEMS = [
"bounty-type",
"bounty-details",
"bounty-criteria",
- "groups",
+ "eligibility",
];
const DEFAULT_SOCIAL_METRICS_CRITERIA = {
@@ -50,6 +59,38 @@ const resolveSocialMetricsCriteria = (
const isEmpty = (value: unknown) =>
value === undefined || value === null || value === "";
+function getEffectiveEndsAt({
+ startsAt,
+ endsAt,
+ endsAfterDays,
+}: {
+ startsAt: Date;
+ endsAt: Date | null;
+ endsAfterDays: number | null;
+}) {
+ if (endsAt) {
+ return endsAt;
+ }
+
+ if (endsAfterDays != null) {
+ return addDays(startsAt, endsAfterDays);
+ }
+
+ return null;
+}
+
+function getSubmissionWindowFromBounty(bounty?: BountyProps): number | null {
+ if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null;
+
+ const days = Math.ceil(
+ (new Date(bounty.endsAt).getTime() -
+ new Date(bounty.submissionsOpenAt).getTime()) /
+ (1000 * 60 * 60 * 24),
+ );
+
+ return days >= 2 && days <= 14 ? days : null;
+}
+
export function useAddEditBountyForm({
bounty,
setIsOpen,
@@ -60,8 +101,14 @@ export function useAddEditBountyForm({
const { id: workspaceId } = useWorkspace();
const { makeRequest, isSubmitting } = useApiMutation();
- const [hasStartDate, setHasStartDate] = useState(!!bounty?.startsAt);
- const [hasEndDate, setHasEndDate] = useState(!!bounty?.endsAt);
+ const defaultTiming = resolveBountyTiming({
+ startPreset: "today",
+ endPreset: "never",
+ });
+
+ const [hasEndDate, setHasEndDate] = useState(
+ !!bounty?.endsAt || !!bounty?.endsAfterDays,
+ );
const [openAccordions, setOpenAccordions] = useState(ACCORDION_ITEMS);
const [allowedSubmissions, setAllowedSubmissions] = useState(
bounty?.maxSubmissions ?? 1,
@@ -71,16 +118,8 @@ export function useAddEditBountyForm({
bounty?.submissionFrequency ?? null,
);
- const [submissionWindow, setSubmissionWindow] = useState(
- () => {
- if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null;
- const days = Math.ceil(
- (new Date(bounty.endsAt).getTime() -
- new Date(bounty.submissionsOpenAt).getTime()) /
- (1000 * 60 * 60 * 24),
- );
- return days >= 2 && days <= 14 ? days : null;
- },
+ const [submissionWindow, setSubmissionWindow] = useState(() =>
+ getSubmissionWindowFromBounty(bounty),
);
const initialSubmissionRequirements = (() => {
@@ -106,8 +145,10 @@ export function useAddEditBountyForm({
defaultValues: {
name: bounty?.name || undefined,
description: bounty?.description || undefined,
- startsAt: bounty?.startsAt || undefined,
- endsAt: bounty?.endsAt || undefined,
+ startsAt: bounty?.startsAt || defaultTiming.startsAt,
+ endsAt: bounty?.endsAt ?? defaultTiming.endsAt,
+ startMode: bounty?.startMode ?? defaultTiming.startMode,
+ endsAfterDays: bounty?.endsAfterDays ?? defaultTiming.endsAfterDays,
submissionsOpenAt: bounty?.submissionsOpenAt || undefined,
rewardAmount: bounty?.rewardAmount
? bounty.rewardAmount / 100
@@ -126,6 +167,7 @@ export function useAddEditBountyForm({
: "performance",
submissionRequirements: initialSubmissionRequirements,
groupIds: bounty?.groups?.map(({ id }) => id) || null,
+ partnerTagIds: bounty?.partnerTags?.map(({ id }) => id) || null,
performanceCondition: bounty?.performanceCondition
? {
...bounty.performanceCondition,
@@ -154,6 +196,8 @@ export function useAddEditBountyForm({
const [
startsAt,
endsAt,
+ startMode,
+ endsAfterDays,
rewardAmount,
rewardDescription,
type,
@@ -162,11 +206,14 @@ export function useAddEditBountyForm({
description,
performanceCondition,
groupIds,
+ partnerTagIds,
rewardType,
submissionRequirements,
] = watch([
"startsAt",
"endsAt",
+ "startMode",
+ "endsAfterDays",
"rewardAmount",
"rewardDescription",
"type",
@@ -175,54 +222,64 @@ export function useAddEditBountyForm({
"description",
"performanceCondition",
"groupIds",
+ "partnerTagIds",
"rewardType",
"submissionRequirements",
]);
- const handleStartDateToggle = (checked: boolean) => {
- setHasStartDate(checked);
- if (!checked) {
- setValue("startsAt", null, { shouldDirty: true, shouldValidate: true });
- }
- };
+ const handleTimingChange = useCallback(
+ ({
+ startMode: nextStartMode,
+ startsAt: nextStartsAt,
+ endsAt: nextEndsAt,
+ endsAfterDays: nextEndsAfterDays,
+ }: ReturnType) => {
+ setValue("startMode", nextStartMode, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
- const handleEndDateToggle = (checked: boolean) => {
- setHasEndDate(checked);
- if (!checked) {
- setValue("endsAt", null, { shouldDirty: true, shouldValidate: true });
- setSubmissionWindow(null);
- setValue("submissionsOpenAt", null, { shouldDirty: true });
- }
- };
+ setValue("startsAt", nextStartsAt, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
- const handleEndDateChange = (date: Date | null) => {
- setValue("endsAt", date, {
- shouldDirty: true,
- shouldValidate: true,
- });
- if (date && submissionWindow != null) {
- const submissionsOpenAt = new Date(date);
- submissionsOpenAt.setDate(submissionsOpenAt.getDate() - submissionWindow);
- setValue("submissionsOpenAt", submissionsOpenAt, {
+ setValue("endsAt", nextEndsAt, {
shouldDirty: true,
shouldValidate: true,
});
- }
- };
- const getInitialSubmissionWindow = () => {
- if (!bounty?.submissionsOpenAt || !bounty?.endsAt) return null;
- const days = Math.ceil(
- (new Date(bounty.endsAt).getTime() -
- new Date(bounty.submissionsOpenAt).getTime()) /
- (1000 * 60 * 60 * 24),
- );
- return days >= 2 && days <= 14 ? days : null;
- };
+ setValue("endsAfterDays", nextEndsAfterDays, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+
+ setHasEndDate(
+ Boolean(nextEndsAt) ||
+ (nextStartMode === "relative" && Boolean(nextEndsAfterDays)),
+ );
+
+ if (!nextEndsAt) {
+ setSubmissionWindow(null);
+ setValue("submissionsOpenAt", null, { shouldDirty: true });
+ } else if (submissionWindow != null) {
+ const submissionsOpenAt = new Date(nextEndsAt);
+ submissionsOpenAt.setDate(
+ submissionsOpenAt.getDate() - submissionWindow,
+ );
+
+ setValue("submissionsOpenAt", submissionsOpenAt, {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
+ }
+ },
+ [setValue, submissionWindow],
+ );
const handleSubmissionWindowToggle = (checked: boolean) => {
if (checked) {
- const val = getInitialSubmissionWindow() ?? 2;
+ const val = getSubmissionWindowFromBounty(bounty) ?? 2;
setSubmissionWindow(val);
if (endsAt) {
const submissionsOpenAt = new Date(endsAt);
@@ -280,11 +337,21 @@ export function useAddEditBountyForm({
}
};
+ const effectiveEndsAt = useMemo(
+ () =>
+ getEffectiveEndsAt({
+ startsAt: startsAt ? new Date(startsAt) : new Date(),
+ endsAt: endsAt ? new Date(endsAt) : null,
+ endsAfterDays: endsAfterDays ?? null,
+ }),
+ [startsAt, endsAt, endsAfterDays],
+ );
+
const maxAllowedSubmissions = useMemo(() => {
- if (!submissionFrequency || !endsAt) return BOUNTY_MAX_SUBMISSIONS;
+ if (!submissionFrequency || !effectiveEndsAt) return BOUNTY_MAX_SUBMISSIONS;
const start = startsAt ? new Date(startsAt) : new Date();
- const end = new Date(endsAt);
+ const end = effectiveEndsAt;
let count = 0;
for (let i = 0; i < BOUNTY_MAX_SUBMISSIONS; i++) {
@@ -298,7 +365,7 @@ export function useAddEditBountyForm({
}
return count;
- }, [submissionFrequency, startsAt, endsAt]);
+ }, [submissionFrequency, startsAt, effectiveEndsAt]);
useEffect(() => {
if (allowedSubmissions > maxAllowedSubmissions) {
@@ -384,17 +451,21 @@ export function useAddEditBountyForm({
const effectiveStartDate = startsAt ? new Date(startsAt) : now;
- if (endsAt) {
- const endDate = new Date(endsAt);
+ const effectiveEndDate = endsAfterDays
+ ? addDays(effectiveStartDate, endsAfterDays)
+ : endsAt
+ ? new Date(endsAt)
+ : null;
- if (endDate <= effectiveStartDate) {
+ if (effectiveEndDate) {
+ if (effectiveEndDate <= effectiveStartDate) {
return `Please choose an end date that is after the start date (${formatDate(effectiveStartDate)}).`;
}
const minEndDate = new Date(
effectiveStartDate.getTime() + 60 * 60 * 1000,
);
- if (endDate < minEndDate) {
+ if (effectiveEndDate < minEndDate) {
return "End date must be at least 1 hour after the start date.";
}
}
@@ -510,6 +581,7 @@ export function useAddEditBountyForm({
bounty,
startsAt,
endsAt,
+ endsAfterDays,
submissionWindow,
rewardAmount,
rewardDescription,
@@ -535,6 +607,11 @@ export function useAddEditBountyForm({
...data
} = form.getValues();
+ // Relative bounties start when a partner joins, so startsAt must be null
+ if (data.startMode === "relative") {
+ data.startsAt = null;
+ }
+
const rawRewardAmount = data.rewardAmount;
const numAmount =
typeof rawRewardAmount === "number" && !Number.isNaN(rawRewardAmount)
@@ -622,12 +699,13 @@ export function useAddEditBountyForm({
: performanceCondition,
})
: name || "New bounty",
- startsAt: startsAt || new Date(),
- endsAt: endsAt || null,
+ startsAt: startMode === "relative" ? null : startsAt || new Date(),
+ endsAt: effectiveEndsAt,
rewardAmount: rewardAmount ? rewardAmount * 100 : null,
rewardDescription: rewardDescription || null,
submissionRequirements: submissionRequirements ?? null,
groups: groupIds?.map((id) => ({ id })) || [],
+ partnerTags: partnerTagIds?.map((id) => ({ id })) || [],
}
: undefined,
onConfirm: async ({ sendNotificationEmails }) => {
@@ -645,10 +723,7 @@ export function useAddEditBountyForm({
return {
form,
- hasStartDate,
- setHasStartDate,
hasEndDate,
- handleEndDateToggle,
openAccordions,
setOpenAccordions,
type,
@@ -660,8 +735,11 @@ export function useAddEditBountyForm({
watch,
errors,
isDirty,
- handleStartDateToggle,
- handleEndDateChange,
+ startsAt,
+ endsAt,
+ startMode,
+ endsAfterDays,
+ handleTimingChange,
allowedSubmissions,
handleAllowedSubmissionsChange,
maxAllowedSubmissions,
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
index 4b09f0f695a..1fa659143b3 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
@@ -1,16 +1,25 @@
+import { BOUNTY_DURATION_DAYS } from "@/lib/bounty/bounty-period";
import useGroups from "@/lib/swr/use-groups";
+import { usePartnerTags } from "@/lib/swr/use-partner-tags";
import { usePartnersCountByGroupIds } from "@/lib/swr/use-partners-count-by-groupids";
import useWorkspace from "@/lib/swr/use-workspace";
import { BountyListProps } from "@/lib/types";
+import {
+ BountyProgressBarRow,
+ EmphasisNumber,
+} from "@/ui/partners/bounties/bounty-progress-bar-row";
import { BountyRewardDescription } from "@/ui/partners/bounties/bounty-reward-description";
import { BountyThumbnailImage } from "@/ui/partners/bounties/bounty-thumbnail-image";
import { GroupColorCircle } from "@/ui/partners/groups/group-color-circle";
import { DynamicTooltipWrapper, ScrollableTooltipContent } from "@dub/ui";
import { Calendar6, Users, Users6 } from "@dub/ui/icons";
-import { formatDate, nFormatter, pluralize } from "@dub/utils";
+import { cn, formatDate, nFormatter, pluralize } from "@dub/utils";
import Link from "next/link";
import { useMemo } from "react";
+const tagPillClassName =
+ "bg-bg-inverted/5 text-content-default inline-flex min-h-6 items-center rounded-md px-2 py-0.5 text-xs font-semibold leading-tight";
+
export function BountyCard({ bounty }: { bounty: BountyListProps }) {
const { slug: workspaceSlug, isOwner } = useWorkspace();
@@ -19,6 +28,7 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) {
});
const { groups } = useGroups();
+ const { partnerTags } = usePartnerTags();
const eligibleGroups = useMemo(() => {
if (!groups || bounty.groups.length === 0) {
@@ -29,46 +39,50 @@ export function BountyCard({ bounty }: { bounty: BountyListProps }) {
.filter((g): g is NonNullable => g !== undefined);
}, [groups, bounty.groups]);
+ const eligibleTags = useMemo(() => {
+ if (!partnerTags || bounty.partnerTags.length === 0) {
+ return [];
+ }
+
+ return bounty.partnerTags
+ .map((bountyTag) => partnerTags.find((t) => t.id === bountyTag.id))
+ .filter((t): t is NonNullable => t !== undefined);
+ }, [partnerTags, bounty.partnerTags]);
+
return (
-
+
-
-
-
-
+
+
+
+
+
-
- {bounty.submissionsCountData &&
- bounty.submissionsCountData.submitted > 0 && (
-
+
+ {bounty.submissionsCountData &&
+ bounty.submissionsCountData.submitted > 0 && (
+
+ )}
+ {bounty.endsAt && new Date(bounty.endsAt) < new Date() && (
+
)}
- {bounty.endsAt && new Date(bounty.endsAt) < new Date() && (
-
- )}
+
-
+
{bounty.name}
-
- {formatDate(bounty.startsAt, { month: "short" })}
- {bounty.endsAt && (
- <>
- {" → "}
- {formatDate(bounty.endsAt, { month: "short" })}
- >
- )}
-
+ {getBountyPeriodLabel(bounty)}
-
- {loading ? (
-
- ) : totalPartners === 0 ? (
- <>
- 0{" "}
- {pluralize("partner", 0)}{" "}
- {bounty.type === "performance" ? "completed" : "submitted"}
- >
- ) : bounty.submissionsCountData?.total === totalPartners ? (
- <>
- All{" "}
-
- {nFormatter(totalPartners, { full: true })}
- {" "}
- {pluralize("partner", totalPartners)}{" "}
- {bounty.type === "performance" ? "completed" : "submitted"}
- >
+
+ {bounty.startMode === "relative"
+ ? "New partners only"
+ : "All partners"}
+
+
+
+
+
+
+ {bounty.groups.length === 0 ? (
+
All groups
+ ) : eligibleGroups.length > 0 ? (
+
1
+ ? {
+ content: (
+
+ {eligibleGroups.map((group) => (
+
+
+
+ {group.name}
+
+
+ ))}
+
+ ),
+ }
+ : undefined
+ }
+ >
+
+
+
+ {eligibleGroups[0].name}{" "}
+ {eligibleGroups.length > 1
+ ? `+${eligibleGroups.length - 1}`
+ : ""}
+
+
+
) : (
+
+ )}
+
+ {bounty.partnerTags.length > 0 && (
<>
-
- {nFormatter(bounty.submissionsCountData?.total ?? 0, {
- full: true,
- })}
- {" "}
- of{" "}
-
- {nFormatter(totalPartners, { full: true })}
- {" "}
- {pluralize("partner", totalPartners)}{" "}
- {bounty.type === "performance" ? "completed" : "submitted"}
+
·
+ {eligibleTags.length > 0 ? (
+
1
+ ? {
+ content: (
+
+ {eligibleTags.map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+ ),
+ }
+ : undefined
+ }
+ >
+
+
+ {eligibleTags[0].name}
+
+ {eligibleTags.length > 1 && (
+
+ +{eligibleTags.length - 1}
+
+ )}
+
+
+ ) : (
+
+ )}
>
)}
-
-
-
- {bounty.groups.length === 0 ? (
-
All groups
- ) : eligibleGroups.length > 0 ? (
-
1
- ? {
- content: (
-
- {eligibleGroups.map((group) => (
-
-
-
- {group.name}
-
-
- ))}
-
- ),
- }
- : undefined
- }
- >
-
-
-
- {eligibleGroups[0].name}{" "}
- {eligibleGroups.length > 1
- ? `+${eligibleGroups.length - 1}`
- : ""}
-
-
-
- ) : (
-
- )}
-
+
+
);
}
+function BountySubmissionStatsFooter({
+ bounty,
+ totalPartners,
+ loading,
+}: {
+ bounty: BountyListProps;
+ totalPartners: number;
+ loading: boolean;
+}) {
+ const submissionCount = bounty.submissionsCountData?.total ?? 0;
+ const actionLabel = bounty.type === "performance" ? "completed" : "submitted";
+
+ const progress =
+ totalPartners > 0
+ ? Math.min(Math.max((submissionCount / totalPartners) * 100, 0), 100)
+ : 0;
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {totalPartners === 0 ? (
+ <>
+ 0 {actionLabel}
+ >
+ ) : submissionCount === totalPartners ? (
+ <>
+ All{" "}
+
+ {nFormatter(totalPartners, { full: true })}
+ {" "}
+ {actionLabel}
+ >
+ ) : (
+ <>
+
+ {nFormatter(submissionCount, { full: true })}
+ {" "}
+ of{" "}
+
+ {nFormatter(totalPartners, { full: true })}
+ {" "}
+ {actionLabel}
+ >
+ )}
+
+
+ );
+}
+
+function getBountyPeriodLabel(bounty: BountyListProps): string {
+ if (bounty.startMode === "relative") {
+ const { endsAfterDays } = bounty;
+
+ if (endsAfterDays === BOUNTY_DURATION_DAYS.twoWeeks) {
+ return "2 weeks after joining";
+ }
+
+ if (endsAfterDays === BOUNTY_DURATION_DAYS.oneMonth) {
+ return "1 month after joining";
+ }
+
+ if (endsAfterDays === BOUNTY_DURATION_DAYS.sixMonths) {
+ return "6 months after joining";
+ }
+
+ if (endsAfterDays != null) {
+ return `${endsAfterDays} days after joining`;
+ }
+
+ return "When a partner joins";
+ }
+
+ if (bounty.startsAt) {
+ let label = formatDate(bounty.startsAt, { month: "short" });
+
+ if (bounty.endsAt) {
+ label += ` → ${formatDate(bounty.endsAt, { month: "short" })}`;
+ }
+
+ return label;
+ }
+
+ return "When a partner joins";
+}
+
function SubmissionsCountBadge({ count }: { count: number }) {
return (
@@ -171,6 +304,7 @@ function SubmissionsCountBadge({ count }: { count: number }) {
);
}
+
function BountyEndedBadge({ endsAt }: { endsAt: Date }) {
return (
@@ -181,23 +315,29 @@ function BountyEndedBadge({ endsAt }: { endsAt: Date }) {
export function BountyCardSkeleton() {
return (
-
-
+
+
diff --git a/apps/web/lib/actions/partners/bulk-approve-partners.ts b/apps/web/lib/actions/partners/bulk-approve-partners.ts
index 0466c3562d9..e5b5e12b741 100644
--- a/apps/web/lib/actions/partners/bulk-approve-partners.ts
+++ b/apps/web/lib/actions/partners/bulk-approve-partners.ts
@@ -62,6 +62,7 @@ export const bulkApprovePartnersAction = authActionClient
data: {
status: "approved",
createdAt: now,
+ groupJoinedAt: now,
groupId: group.id,
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
diff --git a/apps/web/lib/actions/partners/bulk-invite-partners.ts b/apps/web/lib/actions/partners/bulk-invite-partners.ts
index 496b531a3b8..e92ba33b800 100644
--- a/apps/web/lib/actions/partners/bulk-invite-partners.ts
+++ b/apps/web/lib/actions/partners/bulk-invite-partners.ts
@@ -131,6 +131,7 @@ export const bulkInvitePartnersAction = authActionClient
partnerId: partner.id,
status: "invited",
groupId: group.id,
+ groupJoinedAt: new Date(),
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
saleRewardId: group.saleRewardId,
diff --git a/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts b/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts
index 4d9f1287b8d..db6fd53fefd 100644
--- a/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts
+++ b/apps/web/lib/actions/partners/tags/update-program-partner-tags.ts
@@ -3,6 +3,7 @@
import { includeProgramEnrollment } from "@/lib/api/links/include-program-enrollment";
import { includeTags } from "@/lib/api/links/include-tags";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
+import { triggerDraftBountySubmissionCreation } from "@/lib/bounty/api/trigger-draft-bounty-submissions";
import { prisma } from "@/lib/prisma";
import { recordLink } from "@/lib/tinybird";
import { updatePartnerTagsSchema } from "@/lib/zod/schemas/partner-tags";
@@ -94,6 +95,15 @@ export const updateProgramPartnerTagsAction = authActionClient
]);
});
+ if (addTagIds.length > 0) {
+ waitUntil(
+ triggerDraftBountySubmissionCreation({
+ programId,
+ partnerIds,
+ }),
+ );
+ }
+
// Sync updated partner tags to Tinybird for analytics (top_partner_tags)
waitUntil(
(async () => {
diff --git a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts
index 05b1b83621b..897a803f534 100644
--- a/apps/web/lib/actions/partners/upload-bounty-submission-file.ts
+++ b/apps/web/lib/actions/partners/upload-bounty-submission-file.ts
@@ -23,7 +23,13 @@ export const uploadBountySubmissionFileAction = authPartnerActionClient
const programEnrollment = await getProgramEnrollmentOrThrow({
partnerId: partner.id,
programId,
- include: {},
+ include: {
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
+ },
});
const { signedUrl, destinationUrl } = await getBountySubmissionUploadUrl({
diff --git a/apps/web/lib/api/groups/get-group-or-throw.ts b/apps/web/lib/api/groups/get-group-or-throw.ts
index a8c6a80a23e..fd2c6a22e1f 100644
--- a/apps/web/lib/api/groups/get-group-or-throw.ts
+++ b/apps/web/lib/api/groups/get-group-or-throw.ts
@@ -1,4 +1,4 @@
-import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries";
+import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties";
import { prisma } from "@/lib/prisma";
import { DubApiError } from "../errors";
@@ -56,7 +56,7 @@ export const getGroupOrThrow = async ({
return {
...group,
...(includeBounties && {
- bounties: await getGroupBountySummaries({
+ bounties: await getGroupBounties({
programId,
groupId: group.id,
}),
diff --git a/apps/web/lib/api/groups/move-partners-to-group.ts b/apps/web/lib/api/groups/move-partners-to-group.ts
index bded179de36..e7c76803fa0 100644
--- a/apps/web/lib/api/groups/move-partners-to-group.ts
+++ b/apps/web/lib/api/groups/move-partners-to-group.ts
@@ -82,6 +82,7 @@ export async function movePartnersToGroup({
},
data: {
groupId: group.id,
+ groupJoinedAt: new Date(),
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
saleRewardId: group.saleRewardId,
@@ -118,15 +119,18 @@ export async function movePartnersToGroup({
},
programId,
},
- select: {
- id: true,
- partnerId: true,
+ include: {
partnerGroup: {
select: {
id: true,
name: true,
},
},
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
},
);
@@ -192,7 +196,7 @@ export async function movePartnersToGroup({
notifyPartnerGroupChange({
programId,
groupId: group.id,
- partnerIds,
+ programEnrollments: updatedProgramEnrollments,
}),
trackActivityLog(activityLogInputs),
diff --git a/apps/web/lib/api/partners/applications/approve-partner.ts b/apps/web/lib/api/partners/applications/approve-partner.ts
index 7065a944538..eec659b2fb8 100644
--- a/apps/web/lib/api/partners/applications/approve-partner.ts
+++ b/apps/web/lib/api/partners/applications/approve-partner.ts
@@ -79,6 +79,8 @@ export async function approvePartner({
groupId: finalGroupId,
});
+ const now = new Date();
+
await prisma.$transaction(async (tx) => {
throwIfPartnersLimitExceeded(program.workspace);
@@ -91,7 +93,8 @@ export async function approvePartner({
},
data: {
status: "approved",
- createdAt: new Date(),
+ createdAt: now,
+ groupJoinedAt: now,
groupId: group.id,
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
@@ -107,7 +110,7 @@ export async function approvePartner({
id: programEnrollment.applicationId,
},
data: {
- reviewedAt: new Date(),
+ reviewedAt: now,
rejectionReason: null,
rejectionNote: null,
userId,
diff --git a/apps/web/lib/api/partners/create-and-enroll-partner.ts b/apps/web/lib/api/partners/create-and-enroll-partner.ts
index a30ad9585fd..40325e38303 100644
--- a/apps/web/lib/api/partners/create-and-enroll-partner.ts
+++ b/apps/web/lib/api/partners/create-and-enroll-partner.ts
@@ -158,6 +158,9 @@ export const createAndEnrollPartner = async ({
...(enrolledAt && {
createdAt: enrolledAt,
}),
+ ...(status === "approved" && {
+ groupJoinedAt: enrolledAt ?? new Date(),
+ }),
},
},
};
diff --git a/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts b/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts
index 402abbc67c7..ba83a8d4eda 100644
--- a/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts
+++ b/apps/web/lib/api/partners/get-group-rewards-and-bounties.ts
@@ -1,6 +1,7 @@
+import { BOUNTY_ICONS } from "@/lib/bounty/constants";
import { formatDiscountDescription } from "@/ui/partners/format-discount-description";
import { formatRewardDescription } from "@/ui/partners/format-reward-description";
-import { BountyType, EventType, Reward } from "@prisma/client";
+import { EventType, Reward } from "@prisma/client";
import { getGroupOrThrow } from "../groups/get-group-or-throw";
import { serializeReward } from "./serialize-reward";
@@ -11,23 +12,20 @@ const REWARD_ICONS: Record
= {
referral: "https://assets.dub.co/email-assets/icons/nodes-4.png",
};
-const BOUNTY_ICONS: Record = {
- submission: "https://assets.dub.co/email-assets/icons/heart.png",
- performance: "https://assets.dub.co/email-assets/icons/trophy.png",
-};
-
export async function getGroupRewardsAndBounties({
programId,
groupId,
+ includeBounties = true,
}: {
programId: string;
groupId: string;
+ includeBounties?: boolean;
}) {
const group = await getGroupOrThrow({
programId,
groupId,
includeExpandedFields: true,
- includeBounties: true,
+ includeBounties,
});
return {
diff --git a/apps/web/lib/api/partners/notify-partner-group-change.ts b/apps/web/lib/api/partners/notify-partner-group-change.ts
index f202068025b..d0719fa4769 100644
--- a/apps/web/lib/api/partners/notify-partner-group-change.ts
+++ b/apps/web/lib/api/partners/notify-partner-group-change.ts
@@ -1,42 +1,86 @@
+import { isPartnerEligibleForBounty } from "@/lib/bounty/api/bounty-eligibility";
+import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties";
+import { BOUNTY_ICONS } from "@/lib/bounty/constants";
import { queueBatchEmail } from "@/lib/email/queue-batch-email";
-import type PartnerGroupChanged from "@dub/email/templates/partner-group-changed";
+import PartnerGroupChanged from "@dub/email/templates/partner-group-changed";
+import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client";
import { getGroupRewardsAndBounties } from "./get-group-rewards-and-bounties";
import { getPartnerUsers } from "./get-partner-users";
interface NotifyPartnerGroupChangeParams {
programId: string;
groupId: string;
- partnerIds: string[];
+ programEnrollments: (Pick<
+ ProgramEnrollment,
+ "partnerId" | "groupId" | "createdAt" | "groupJoinedAt" | "status"
+ > & {
+ programPartnerTags: Pick[];
+ })[];
}
// Send email to partners when they are moved to a new group
export async function notifyPartnerGroupChange({
programId,
groupId,
- partnerIds,
+ programEnrollments,
}: NotifyPartnerGroupChangeParams) {
- if (partnerIds.length === 0) {
+ if (programEnrollments.length === 0) {
return;
}
+ const partnerIds = programEnrollments.map(({ partnerId }) => partnerId);
+
const [
{
rewards,
- bounties,
group: { program },
},
partnerUsers,
+ bounties,
] = await Promise.all([
getGroupRewardsAndBounties({
programId,
groupId,
+ includeBounties: false,
}),
getPartnerUsers({
partnerIds,
}),
+
+ getGroupBounties({
+ programId,
+ groupId,
+ }),
]);
+ // Filter eligible bounties for each partner
+ const partnerBounties = new Map<
+ string,
+ {
+ icon: string;
+ label: string;
+ }[]
+ >();
+
+ for (const programEnrollment of programEnrollments) {
+ const eligibleBounties = bounties.filter((bounty) =>
+ isPartnerEligibleForBounty({
+ programEnrollment,
+ bounty,
+ }),
+ );
+
+ if (eligibleBounties.length > 0) {
+ const formattedBounties = eligibleBounties.map((bounty) => ({
+ icon: BOUNTY_ICONS[bounty.type],
+ label: bounty.name,
+ }));
+
+ partnerBounties.set(programEnrollment.partnerId, formattedBounties);
+ }
+ }
+
await queueBatchEmail(
partnerUsers.map(({ partner, user }) => ({
to: user.email!,
@@ -55,7 +99,7 @@ export async function notifyPartnerGroupChange({
email: user.email!,
},
rewards,
- bounties,
+ bounties: partnerBounties.get(partner.id) ?? [],
},
})),
);
diff --git a/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts
new file mode 100644
index 00000000000..e00775b3735
--- /dev/null
+++ b/apps/web/lib/api/tags/throw-if-invalid-partner-tag-ids.ts
@@ -0,0 +1,37 @@
+import { prisma } from "@/lib/prisma";
+import { PartnerTag } from "@prisma/client";
+import { DubApiError } from "../errors";
+
+export async function throwIfInvalidPartnerTagIds({
+ programId,
+ partnerTagIds,
+}: {
+ programId: string;
+ partnerTagIds: string[] | null | undefined;
+}) {
+ let partnerTags: PartnerTag[] = [];
+
+ if (partnerTagIds && partnerTagIds.length) {
+ partnerTags = await prisma.partnerTag.findMany({
+ where: {
+ programId,
+ id: {
+ in: partnerTagIds,
+ },
+ },
+ });
+
+ const invalidPartnerTagIds = partnerTagIds?.filter(
+ (partnerTagId) => !partnerTags?.some((tag) => tag.id === partnerTagId),
+ );
+
+ if (invalidPartnerTagIds?.length) {
+ throw new DubApiError({
+ code: "unprocessable_entity",
+ message: `Invalid partner tag IDs detected: ${invalidPartnerTagIds.join(", ")}`,
+ });
+ }
+ }
+
+ return partnerTags;
+}
diff --git a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts
index f52e49ec860..e77adaeb280 100644
--- a/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts
+++ b/apps/web/lib/api/workflows/execute-complete-bounty-workflow.ts
@@ -1,6 +1,13 @@
import { evaluateWorkflowConditions } from "@/lib/api/workflows/evaluate-workflow-conditions";
+import {
+ bountyEligibilityIncludes,
+ canPartnerSubmitBounty,
+} from "@/lib/bounty/api/bounty-eligibility";
import { prisma } from "@/lib/prisma";
-import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types";
+import {
+ WorkflowConditionAttribute,
+ WorkflowContextExtended,
+} from "@/lib/types";
import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows";
import { sendBatchEmail, sendEmail } from "@dub/email";
import BountyCompleted from "@dub/email/templates/bounty-completed";
@@ -28,7 +35,7 @@ export const executeCompleteBountyWorkflow = async ({
context,
}: {
workflow: Workflow;
- context: WorkflowContext;
+ context: WorkflowContextExtended;
}) => {
const { condition, action } = parseWorkflowConfig(workflow);
@@ -37,8 +44,9 @@ export const executeCompleteBountyWorkflow = async ({
}
const { bountyId } = action.data;
- const { identity, metrics } = context;
- const { partnerId, groupId, customerId, customerFirstSaleAt } = identity;
+ const { identity, metrics, programEnrollment } = context;
+ const { customerId, customerFirstSaleAt } = identity;
+ const { programId, partnerId, groupId } = programEnrollment;
if (!groupId) {
console.error("Partner groupId not set in the context.");
@@ -51,13 +59,24 @@ export const executeCompleteBountyWorkflow = async ({
id: bountyId,
},
include: {
- program: true,
- groups: true,
submissions: {
where: {
partnerId,
},
+ select: {
+ id: true,
+ status: true,
+ },
+ },
+ program: {
+ select: {
+ id: true,
+ name: true,
+ slug: true,
+ supportEmail: true,
+ },
},
+ ...bountyEligibilityIncludes,
},
});
@@ -77,34 +96,20 @@ export const executeCompleteBountyWorkflow = async ({
return;
}
- const now = new Date();
+ const canSubmitBounty = canPartnerSubmitBounty({
+ programEnrollment,
+ bounty,
+ });
- // Check if bounty is active
- if (
- (bounty.startsAt && bounty.startsAt > now) ||
- (bounty.endsAt && bounty.endsAt < now) ||
- bounty.archivedAt
- ) {
- console.log(`Bounty ${bounty.id} is no longer active.`);
+ if (!canSubmitBounty) {
+ console.log(
+ `Partner ${partnerId} is not eligible to submit bounty ${bounty.id}.`,
+ );
return;
}
- const { groups, submissions } = bounty;
-
- // If the bounty is part of a group, check if the partner is in the group
- if (groups.length > 0) {
- const groupIds = groups.map(({ groupId }) => groupId);
-
- if (!groupIds.includes(groupId)) {
- console.log(
- `Partner ${partnerId} is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups. Partner's groupId: ${groupId}. Assigned groupIds: ${groupIds.join(", ")}.`,
- );
- return;
- }
- }
-
- if (submissions.length > 0) {
- const submission = submissions[0];
+ if (bounty.submissions.length > 0) {
+ const submission = bounty.submissions[0];
if (submission.status !== "draft") {
const reason = terminalStatusReason[submission.status];
@@ -119,6 +124,7 @@ export const executeCompleteBountyWorkflow = async ({
}
if (
+ bounty.startsAt &&
bounty.performanceScope === "new" &&
customerFirstSaleAt &&
customerFirstSaleAt < bounty.startsAt
@@ -156,7 +162,7 @@ export const executeCompleteBountyWorkflow = async ({
},
create: {
id: createId({ prefix: "bnty_sub_" }),
- programId: bounty.programId,
+ programId,
partnerId,
bountyId: bounty.id,
periodNumber,
diff --git a/apps/web/lib/api/workflows/execute-move-group-workflow.ts b/apps/web/lib/api/workflows/execute-move-group-workflow.ts
index df90e3ef904..bada13f9a57 100644
--- a/apps/web/lib/api/workflows/execute-move-group-workflow.ts
+++ b/apps/web/lib/api/workflows/execute-move-group-workflow.ts
@@ -1,5 +1,8 @@
import { prisma } from "@/lib/prisma";
-import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types";
+import {
+ WorkflowConditionAttribute,
+ WorkflowContextExtended,
+} from "@/lib/types";
import { redis } from "@/lib/upstash/redis";
import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows";
import { Workflow } from "@prisma/client";
@@ -12,7 +15,7 @@ export const executeMoveGroupWorkflow = async ({
context,
}: {
workflow: Workflow;
- context: WorkflowContext;
+ context: WorkflowContextExtended;
}) => {
const { conditions, action } = parseWorkflowConfig(workflow);
@@ -23,8 +26,10 @@ export const executeMoveGroupWorkflow = async ({
return;
}
- const { identity, metrics } = context;
- const { workspaceId, programId, partnerId, groupId } = identity;
+ const { identity, metrics, programEnrollment } = context;
+ const { workspaceId } = identity;
+ const { programId, partnerId, groupId, groupMoveDisabledAt } =
+ programEnrollment;
if (!groupId) {
console.error("Partner groupId not set in the context. Skipping..");
@@ -34,20 +39,20 @@ export const executeMoveGroupWorkflow = async ({
const { groupId: newGroupId } = action.data;
// Fetch program enrollment to get fresh groupId
- const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({
- where: {
- partnerId_programId: {
- partnerId,
- programId,
+ const programEnrollmentRefreshed =
+ await prisma.programEnrollment.findUniqueOrThrow({
+ where: {
+ partnerId_programId: {
+ partnerId,
+ programId,
+ },
},
- },
- select: {
- groupId: true,
- groupMoveDisabledAt: true,
- },
- });
+ select: {
+ groupId: true,
+ },
+ });
- if (programEnrollment.groupId === newGroupId) {
+ if (programEnrollmentRefreshed.groupId === newGroupId) {
console.log(
`Partner ${partnerId} is already in target group ${newGroupId}. Skipping..`,
);
@@ -55,7 +60,7 @@ export const executeMoveGroupWorkflow = async ({
}
// If the partner has group move rules disabled, skip the workflow
- if (programEnrollment.groupMoveDisabledAt) {
+ if (groupMoveDisabledAt) {
console.log(
`Partner ${partnerId} has group move rules disabled. Skipping..`,
);
diff --git a/apps/web/lib/api/workflows/execute-workflows.ts b/apps/web/lib/api/workflows/execute-workflows.ts
index aa209ab8d08..cf4a831d071 100644
--- a/apps/web/lib/api/workflows/execute-workflows.ts
+++ b/apps/web/lib/api/workflows/execute-workflows.ts
@@ -1,6 +1,10 @@
import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats";
import { prisma } from "@/lib/prisma";
-import { WorkflowConditionAttribute, WorkflowContext } from "@/lib/types";
+import {
+ WorkflowConditionAttribute,
+ WorkflowContext,
+ WorkflowContextExtended,
+} from "@/lib/types";
import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows";
import { Workflow } from "@prisma/client";
import { executeCompleteBountyWorkflow } from "./execute-complete-bounty-workflow";
@@ -11,7 +15,7 @@ import { parseWorkflowConfig } from "./parse-workflow-config";
interface WorkflowActionHandler {
execute(params: {
workflow: Workflow;
- context: WorkflowContext;
+ context: WorkflowContextExtended;
}): Promise;
}
@@ -128,8 +132,13 @@ export async function executeWorkflows({
},
},
select: {
+ programId: true,
partnerId: true,
groupId: true,
+ groupJoinedAt: true,
+ createdAt: true,
+ status: true,
+ groupMoveDisabledAt: true,
links: {
select: {
clicks: true,
@@ -139,6 +148,11 @@ export async function executeWorkflows({
saleAmount: true,
},
},
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
}),
@@ -176,13 +190,10 @@ export async function executeWorkflows({
const { totalLeads, totalSaleAmount, totalConversions } =
aggregatePartnerLinksStats(programEnrollment.links);
- const workflowContext: WorkflowContext = {
+ const workflowContext: WorkflowContextExtended = {
trigger,
reason,
- identity: {
- ...identity,
- groupId: programEnrollment.groupId,
- },
+ identity,
metrics: {
...metrics,
aggregated: {
@@ -192,6 +203,7 @@ export async function executeWorkflows({
commissions: totalCommissions._sum.earnings ?? 0,
},
},
+ programEnrollment,
};
for (const { workflow, config } of filteredWorkflows) {
diff --git a/apps/web/lib/bounty/api/bounty-eligibility.ts b/apps/web/lib/bounty/api/bounty-eligibility.ts
new file mode 100644
index 00000000000..249500f0741
--- /dev/null
+++ b/apps/web/lib/bounty/api/bounty-eligibility.ts
@@ -0,0 +1,307 @@
+import { DubApiError } from "@/lib/api/errors";
+import {
+ Bounty,
+ BountyGroup,
+ BountyPartnerTag,
+ Prisma,
+ ProgramEnrollment,
+ ProgramPartnerTag,
+} from "@prisma/client";
+import {
+ getEffectiveBountyPeriod,
+ isBountyExpired,
+ isBountyStarted,
+} from "../bounty-period";
+
+type PartnerBountyEligibilityParams = {
+ programEnrollment: Pick<
+ ProgramEnrollment,
+ "groupId" | "createdAt" | "groupJoinedAt" | "status"
+ > & {
+ programPartnerTags: Pick[];
+ };
+ bounty: Pick<
+ Bounty,
+ "startsAt" | "endsAt" | "endsAfterDays" | "startMode" | "archivedAt" | "id"
+ > & {
+ groups: Pick[];
+ partnerTags: Pick[];
+ };
+ // Fallback for enrollments without an explicit group (program.defaultGroupId)
+ defaultGroupId?: string | null;
+};
+
+export function buildBountyEligibilityWhere({
+ groupId,
+ partnerTagIds,
+}: {
+ groupId: string | undefined;
+ partnerTagIds: string[];
+}): Prisma.BountyWhereInput {
+ return {
+ AND: [
+ {
+ OR: [
+ {
+ groups: {
+ none: {},
+ },
+ },
+ ...(groupId
+ ? [
+ {
+ groups: {
+ some: {
+ groupId,
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ {
+ OR: [
+ {
+ partnerTags: {
+ none: {},
+ },
+ },
+ ...(partnerTagIds.length > 0
+ ? [
+ {
+ partnerTags: {
+ some: {
+ partnerTagId: {
+ in: partnerTagIds,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ ],
+ };
+}
+
+// Relative bounties start when a partner joins (no startsAt filter).
+// Absolute bounties must have started and not expired.
+export function buildActiveBountyPeriodWhere(): Prisma.BountyWhereInput {
+ const now = new Date();
+
+ return {
+ archivedAt: null,
+ OR: [
+ {
+ startMode: "relative",
+ },
+ {
+ startMode: "absolute",
+ startsAt: {
+ lt: now,
+ },
+ OR: [
+ {
+ endsAt: null,
+ },
+ {
+ endsAt: {
+ gt: now,
+ },
+ },
+ ],
+ },
+ ],
+ };
+}
+
+export function isPartnerEligibleForBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+}: PartnerBountyEligibilityParams): boolean {
+ const bountyGroupIds = bounty.groups.map((g) => g.groupId);
+ const bountyTagIds = bounty.partnerTags.map((t) => t.partnerTagId);
+
+ const partnerGroupId = programEnrollment.groupId || defaultGroupId;
+ const partnerTagIds = programEnrollment.programPartnerTags.map(
+ (t) => t.partnerTagId,
+ );
+
+ // No restrictions
+ if (bountyGroupIds.length === 0 && bountyTagIds.length === 0) {
+ return true;
+ }
+
+ // Group restrictions
+ const inGroup =
+ bountyGroupIds.length === 0 ||
+ (partnerGroupId && bountyGroupIds.includes(partnerGroupId));
+
+ // Tag restrictions
+ const hasTag =
+ bountyTagIds.length === 0 ||
+ partnerTagIds.some((id) => bountyTagIds.includes(id));
+
+ return Boolean(inGroup && hasTag);
+}
+
+export function canPartnerSubmitBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+}: PartnerBountyEligibilityParams): boolean {
+ // Only approved partners can submit bounties
+ if (programEnrollment.status !== "approved") {
+ console.log(
+ `Partner enrollment status "${programEnrollment.status}" is not allowed to submit bounty ${bounty.id}.`,
+ );
+ return false;
+ }
+
+ const isEligible = isPartnerEligibleForBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+ });
+
+ if (!isEligible) {
+ console.log(
+ `Partner is not eligible for bounty ${bounty.id} because they are not in any of the assigned groups or partner tags.`,
+ );
+ return false;
+ }
+
+ if (bounty.archivedAt) {
+ console.log(`Bounty ${bounty.id} is archived.`);
+ return false;
+ }
+
+ const { startsAt, endsAt } = getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+ });
+
+ if (!isBountyStarted(startsAt)) {
+ console.log(`Bounty ${bounty.id} has not started yet.`);
+ return false;
+ }
+
+ if (isBountyExpired(endsAt)) {
+ console.log(`Bounty ${bounty.id} has ended.`);
+ return false;
+ }
+
+ return true;
+}
+
+// Throws 404 (not 400) to avoid revealing bounties the partner shouldn't see.
+// A partner with a submission can always view the bounty, even if they are no
+// longer eligible (e.g. moved out of an eligible group) or it was archived.
+// Expired bounties stay viewable.
+export function throwIfPartnerCannotViewBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+ hasSubmission,
+}: PartnerBountyEligibilityParams & { hasSubmission: boolean }) {
+ if (hasSubmission) {
+ return;
+ }
+
+ const notFoundError = new DubApiError({
+ code: "not_found",
+ message: "Bounty not found.",
+ });
+
+ const isEligible = isPartnerEligibleForBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+ });
+
+ if (!isEligible) {
+ throw notFoundError;
+ }
+
+ const { startsAt } = getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+ });
+
+ if (!isBountyStarted(startsAt)) {
+ throw notFoundError;
+ }
+
+ if (bounty.archivedAt) {
+ throw notFoundError;
+ }
+}
+
+export function throwIfPartnerCannotSubmitBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+}: PartnerBountyEligibilityParams) {
+ // Only approved partners can submit bounties
+ if (programEnrollment.status !== "approved") {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "You are not allowed to submit a bounty for this program.",
+ });
+ }
+
+ const isEligible = isPartnerEligibleForBounty({
+ programEnrollment,
+ bounty,
+ defaultGroupId,
+ });
+
+ if (!isEligible) {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "You are not eligible for this bounty.",
+ });
+ }
+
+ if (bounty.archivedAt) {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "This bounty is archived.",
+ });
+ }
+
+ const { startsAt, endsAt } = getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+ });
+
+ if (!isBountyStarted(startsAt)) {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "This bounty has not started yet.",
+ });
+ }
+
+ if (isBountyExpired(endsAt)) {
+ throw new DubApiError({
+ code: "bad_request",
+ message: "This bounty has ended.",
+ });
+ }
+}
+
+export const bountyEligibilityIncludes = {
+ groups: {
+ select: {
+ groupId: true,
+ },
+ },
+ partnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
+} satisfies Prisma.BountyInclude;
diff --git a/apps/web/lib/bounty/api/create-bounty-submission.ts b/apps/web/lib/bounty/api/create-bounty-submission.ts
index e652689e869..ff3936f575f 100644
--- a/apps/web/lib/bounty/api/create-bounty-submission.ts
+++ b/apps/web/lib/bounty/api/create-bounty-submission.ts
@@ -25,7 +25,13 @@ import {
import { waitUntil } from "@vercel/functions";
import { formatDistanceToNow, isBefore } from "date-fns";
import * as z from "zod/v4";
+import { getEffectiveBountyPeriod } from "../bounty-period";
import { SOCIAL_URL_HOST_TO_PLATFORM } from "../social-content";
+import {
+ bountyEligibilityIncludes,
+ throwIfPartnerCannotSubmitBounty,
+} from "./bounty-eligibility";
+import { getBountyOrThrow } from "./get-bounty-or-throw";
type CreateBountySubmissionParams = z.infer<
typeof createBountySubmissionInputSchema
@@ -35,8 +41,17 @@ type CreateBountySubmissionParams = z.infer<
type BountyWithRelations = Prisma.BountyGetPayload<{
include: {
- groups: true;
submissions: true;
+ groups: {
+ select: {
+ groupId: true;
+ };
+ };
+ partnerTags: {
+ select: {
+ partnerTagId: true;
+ };
+ };
};
}>;
@@ -57,7 +72,13 @@ export class BountySubmissionHandler {
private submissions: BountySubmission[];
private submissionData: Partial;
private programEnrollment: Prisma.ProgramEnrollmentGetPayload<{
- include: {};
+ include: {
+ programPartnerTags: {
+ select: {
+ partnerTagId: true;
+ };
+ };
+ };
}>;
constructor(params: CreateBountySubmissionParams) {
@@ -99,26 +120,41 @@ export class BountySubmissionHandler {
getProgramEnrollmentOrThrow({
partnerId: this.partner.id,
programId: this.programId,
- include: {},
+ include: {
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
+ },
}),
- prisma.bounty.findUniqueOrThrow({
- where: {
- id: this.bountyId,
- },
+ getBountyOrThrow({
+ bountyId: this.bountyId,
+ programId: this.programId,
include: {
- groups: true,
submissions: {
where: {
partnerId: this.partner.id,
},
},
+ ...bountyEligibilityIncludes,
},
}),
]);
+ const { startsAt, endsAt } = getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+ });
+
+ this.bounty = {
+ ...bounty,
+ startsAt,
+ endsAt,
+ };
+
this.programEnrollment = programEnrollment;
- this.bounty = bounty;
this.submissions = bounty.submissions;
}
@@ -156,7 +192,7 @@ export class BountySubmissionHandler {
// Multi-submission WITH frequency — time-gated
const currentPeriod = getCurrentPeriodNumber({
- startsAt: this.bounty.startsAt,
+ startsAt: this.bounty.startsAt!,
endsAt: this.bounty.endsAt,
submissionFrequency: this.bounty.submissionFrequency,
maxSubmissions: this.bounty.maxSubmissions,
@@ -186,7 +222,7 @@ export class BountySubmissionHandler {
// Validate the period has started
const periodStart = addFrequency({
- date: this.bounty.startsAt,
+ date: this.bounty.startsAt!,
frequency: this.bounty.submissionFrequency,
amount: periodNumber - 1,
});
@@ -210,19 +246,10 @@ export class BountySubmissionHandler {
// Validate the eligibility of the submission
private validateEligibility() {
- if (!["approved", "pending"].includes(this.programEnrollment.status)) {
- throw new DubApiError({
- code: "forbidden",
- message: "You are not allowed to submit a bounty for this program.",
- });
- }
-
- if (this.bounty.programId !== this.programId) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is not for this program.",
- });
- }
+ throwIfPartnerCannotSubmitBounty({
+ programEnrollment: this.programEnrollment,
+ bounty: this.bounty,
+ });
// Check existing submission for this period
const existingSubmission = this.submissions.find(
@@ -243,44 +270,8 @@ export class BountySubmissionHandler {
}
}
- // Check group membership
- if (this.bounty.groups.length > 0) {
- const isInGroup = this.bounty.groups.find(
- ({ groupId }) => groupId === this.programEnrollment.groupId,
- );
-
- if (!isInGroup) {
- throw new DubApiError({
- code: "forbidden",
- message: "You are not allowed to submit this bounty.",
- });
- }
- }
-
- // Validate bounty dates and status
const now = new Date();
- if (this.bounty.startsAt && this.bounty.startsAt > now) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is not yet available.",
- });
- }
-
- if (this.bounty.endsAt && this.bounty.endsAt < now) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is no longer available.",
- });
- }
-
- if (this.bounty.archivedAt) {
- throw new DubApiError({
- code: "bad_request",
- message: "This bounty is archived.",
- });
- }
-
if (this.bounty.type === "performance") {
throw new DubApiError({
code: "forbidden",
diff --git a/apps/web/lib/bounty/api/get-bounties-by-groups.ts b/apps/web/lib/bounty/api/get-bounties-by-groups.ts
deleted file mode 100644
index 68dec8dfce5..00000000000
--- a/apps/web/lib/bounty/api/get-bounties-by-groups.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { prisma } from "@/lib/prisma";
-import { Bounty } from "@prisma/client";
-
-export async function getBountiesByGroups({
- programId,
- groupIds,
-}: {
- programId: string;
- groupIds: string[];
-}) {
- const bounties = await prisma.bounty.findMany({
- where: {
- programId,
- AND: [
- {
- OR: [
- { groups: { none: {} } },
- { groups: { some: { groupId: { in: groupIds } } } },
- ],
- },
- ],
- },
- include: {
- groups: true,
- },
- });
-
- const bountiesByGroups: Record = {};
-
- // Note: global bounties are not included here
- for (const groupId of groupIds) {
- bountiesByGroups[groupId] = bounties.filter((bounty) =>
- bounty.groups.some((g) => g.groupId === groupId),
- );
- }
-
- return bountiesByGroups;
-}
diff --git a/apps/web/lib/bounty/api/get-bounties-for-partner.ts b/apps/web/lib/bounty/api/get-bounties-for-partner.ts
index 75c31aa5600..d937ed15550 100644
--- a/apps/web/lib/bounty/api/get-bounties-for-partner.ts
+++ b/apps/web/lib/bounty/api/get-bounties-for-partner.ts
@@ -1,45 +1,62 @@
-import {
- aggregatePartnerLinksStats,
- PartnerLink,
-} from "@/lib/partners/aggregate-partner-links-stats";
+import { aggregatePartnerLinksStats } from "@/lib/partners/aggregate-partner-links-stats";
import { prisma } from "@/lib/prisma";
import { PartnerBountySchema } from "@/lib/zod/schemas/partner-profile";
-import { Program, ProgramEnrollment } from "@prisma/client";
+import {
+ Link,
+ Program,
+ ProgramEnrollment,
+ ProgramPartnerTag,
+} from "@prisma/client";
import * as z from "zod/v4";
+import { getEffectiveBountyPeriod } from "../bounty-period";
+import {
+ buildActiveBountyPeriodWhere,
+ buildBountyEligibilityWhere,
+} from "./bounty-eligibility";
type GetBountiesForPartnerParams = Pick<
ProgramEnrollment,
- "groupId" | "partnerId" | "totalCommissions"
+ "groupId" | "partnerId" | "totalCommissions" | "groupJoinedAt" | "createdAt"
> & {
- links: PartnerLink[];
+ programPartnerTags: Pick[];
+ links: Pick<
+ Link,
+ "clicks" | "leads" | "conversions" | "sales" | "saleAmount"
+ >[];
program: Pick;
};
-export async function getBountiesForPartner(
- params: GetBountiesForPartnerParams,
-) {
- const { groupId, partnerId, totalCommissions, program, links } = params;
-
- const now = new Date();
+export async function getBountiesForPartner({
+ partnerId,
+ groupId,
+ totalCommissions,
+ createdAt,
+ groupJoinedAt,
+ program,
+ links,
+ programPartnerTags,
+}: GetBountiesForPartnerParams) {
+ const partnerTagIds = programPartnerTags.map(
+ ({ partnerTagId }) => partnerTagId,
+ );
const bounties = await prisma.bounty.findMany({
where: {
programId: program.id,
- startsAt: {
- lte: now,
- },
- // If bounty has no groups, it's available to all partners
- // If bounty has groups, only partners in those groups can see it
OR: [
{
- groups: {
- none: {},
- },
+ ...buildActiveBountyPeriodWhere(),
+ ...buildBountyEligibilityWhere({
+ groupId: groupId || program.defaultGroupId,
+ partnerTagIds,
+ }),
},
+ // Bounties the partner has a submission on stay visible even if the
+ // partner is no longer eligible or the bounty was archived
{
- groups: {
+ submissions: {
some: {
- groupId: groupId || program.defaultGroupId,
+ partnerId,
},
},
},
@@ -72,13 +89,28 @@ export async function getBountiesForPartner(
const partnerLinkStats = aggregatePartnerLinksStats(links);
return z.array(PartnerBountySchema).parse(
- bounties.map((bounty) => ({
- ...bounty,
- performanceCondition: bounty.workflow?.triggerConditions?.[0] || null,
- partner: {
- ...partnerLinkStats,
- totalCommissions,
- },
- })),
+ bounties.map((bounty) => {
+ const performanceCondition =
+ bounty.workflow?.triggerConditions?.[0] || null;
+
+ const { startsAt, endsAt } = getEffectiveBountyPeriod({
+ programEnrollment: {
+ createdAt,
+ groupJoinedAt,
+ },
+ bounty,
+ });
+
+ return {
+ ...bounty,
+ startsAt,
+ endsAt,
+ performanceCondition,
+ partner: {
+ ...partnerLinkStats,
+ totalCommissions,
+ },
+ };
+ }),
);
}
diff --git a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts
index a6432aee6d7..ca51a35f4c7 100644
--- a/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts
+++ b/apps/web/lib/bounty/api/get-bounty-submission-upload-url.ts
@@ -1,23 +1,36 @@
import { DubApiError } from "@/lib/api/errors";
-import { prisma } from "@/lib/prisma";
import { storage } from "@/lib/storage";
import { ratelimit } from "@/lib/upstash";
import { submissionRequirementsSchema } from "@/lib/zod/schemas/bounties";
import { nanoid, R2_URL } from "@dub/utils";
-import { ProgramEnrollment } from "@prisma/client";
+import { ProgramEnrollment, ProgramPartnerTag } from "@prisma/client";
+import {
+ bountyEligibilityIncludes,
+ throwIfPartnerCannotSubmitBounty,
+} from "./bounty-eligibility";
+import { getBountyOrThrow } from "./get-bounty-or-throw";
const MAX_ATTEMPTS = 25;
const CACHE_KEY_PREFIX = "bounty:submission:file:upload";
+type ProgramEnrollmentWithPartnerTags = Pick<
+ ProgramEnrollment,
+ | "programId"
+ | "partnerId"
+ | "groupId"
+ | "createdAt"
+ | "groupJoinedAt"
+ | "status"
+> & {
+ programPartnerTags: Pick[];
+};
+
type GetBountySubmissionUploadUrlParams = {
bountyId: string;
fileName: string;
contentType: string;
contentLength: number;
- programEnrollment: Pick<
- ProgramEnrollment,
- "programId" | "partnerId" | "groupId"
- >;
+ programEnrollment: ProgramEnrollmentWithPartnerTags;
};
const MAX_UPLOAD_SIZE_BYTES = 5 * 1024 * 1024;
@@ -74,68 +87,18 @@ export async function getBountySubmissionUploadUrl({
});
}
- const bounty = await prisma.bounty.findUniqueOrThrow({
- where: {
- id: bountyId,
- },
- select: {
- programId: true,
- type: true,
- startsAt: true,
- endsAt: true,
- archivedAt: true,
- submissionRequirements: true,
- groups: {
- select: {
- groupId: true,
- },
- },
+ const bounty = await getBountyOrThrow({
+ bountyId,
+ programId,
+ include: {
+ ...bountyEligibilityIncludes,
},
});
- if (bounty.programId !== programId) {
- throw new DubApiError({
- code: "forbidden",
- message: "This bounty is not for this program.",
- });
- }
-
- if (bounty.groups.length > 0) {
- const isInGroup = bounty.groups.find(
- ({ groupId }) => groupId === programEnrollment.groupId,
- );
-
- if (!isInGroup) {
- throw new DubApiError({
- code: "forbidden",
- message: "You are not allowed to submit this bounty.",
- });
- }
- }
-
- // Validate the bounty dates
- const now = new Date();
-
- if (bounty.startsAt && bounty.startsAt > now) {
- throw new DubApiError({
- code: "forbidden",
- message: "This bounty is not yet available.",
- });
- }
-
- if (bounty.endsAt && bounty.endsAt < now) {
- throw new DubApiError({
- code: "forbidden",
- message: "This bounty is no longer available.",
- });
- }
-
- if (bounty.archivedAt) {
- throw new DubApiError({
- code: "forbidden",
- message: "This bounty is archived.",
- });
- }
+ throwIfPartnerCannotSubmitBounty({
+ programEnrollment,
+ bounty,
+ });
if (bounty.type === "performance") {
throw new DubApiError({
diff --git a/apps/web/lib/bounty/api/get-bounty-with-details.ts b/apps/web/lib/bounty/api/get-bounty-with-details.ts
index a3740ef9825..b22c93b2261 100644
--- a/apps/web/lib/bounty/api/get-bounty-with-details.ts
+++ b/apps/web/lib/bounty/api/get-bounty-with-details.ts
@@ -16,6 +16,8 @@ export const getBountyWithDetails = async ({
b.type,
b.startsAt,
b.endsAt,
+ b.startMode,
+ b.endsAfterDays,
b.submissionsOpenAt,
b.submissionFrequency,
b.maxSubmissions,
@@ -36,7 +38,19 @@ export const getBountyWithDetails = async ({
WHERE bountyId = b.id
),
JSON_ARRAY()
- ) AS \`groups\`
+ ) AS \`groups\`,
+
+ -- Bounty partner tags
+ COALESCE(
+ (
+ SELECT JSON_ARRAYAGG(
+ JSON_OBJECT('id', partnerTagId)
+ )
+ FROM BountyPartnerTag
+ WHERE bountyId = b.id
+ ),
+ JSON_ARRAY()
+ ) AS \`partnerTags\`
FROM Bounty b
LEFT JOIN Workflow wf ON wf.id = b.workflowId
@@ -63,6 +77,8 @@ export const getBountyWithDetails = async ({
type: bounty.type,
startsAt: bounty.startsAt,
endsAt: bounty.endsAt,
+ startMode: bounty.startMode,
+ endsAfterDays: bounty.endsAfterDays,
submissionsOpenAt: bounty.submissionsOpenAt,
submissionFrequency: bounty.submissionFrequency,
maxSubmissions: bounty.maxSubmissions,
@@ -73,5 +89,6 @@ export const getBountyWithDetails = async ({
performanceScope,
performanceCondition,
groups: bounty.groups.filter((group) => group !== null) ?? [],
+ partnerTags: bounty.partnerTags.filter((tag) => tag !== null) ?? [],
};
};
diff --git a/apps/web/lib/bounty/api/get-group-bounties.ts b/apps/web/lib/bounty/api/get-group-bounties.ts
new file mode 100644
index 00000000000..8b8a28a4995
--- /dev/null
+++ b/apps/web/lib/bounty/api/get-group-bounties.ts
@@ -0,0 +1,50 @@
+import { prisma } from "@/lib/prisma";
+import {
+ buildActiveBountyPeriodWhere,
+ buildBountyEligibilityWhere,
+} from "./bounty-eligibility";
+
+// Get active bounties for a given group
+export async function getGroupBounties({
+ programId,
+ groupId,
+}: {
+ programId: string;
+ groupId: string;
+}) {
+ const bounties = await prisma.bounty.findMany({
+ where: {
+ programId,
+ ...buildActiveBountyPeriodWhere(),
+ ...buildBountyEligibilityWhere({
+ groupId,
+ partnerTagIds: [], // No partner context
+ }),
+ },
+ select: {
+ id: true,
+ name: true,
+ type: true,
+ startsAt: true,
+ endsAt: true,
+ endsAfterDays: true,
+ startMode: true,
+ archivedAt: true,
+ groups: {
+ select: {
+ groupId: true,
+ },
+ },
+ partnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
+ },
+ orderBy: {
+ startsAt: "asc",
+ },
+ });
+
+ return bounties;
+}
diff --git a/apps/web/lib/bounty/api/get-group-bounty-summaries.ts b/apps/web/lib/bounty/api/get-group-bounty-summaries.ts
deleted file mode 100644
index 8acc655367f..00000000000
--- a/apps/web/lib/bounty/api/get-group-bounty-summaries.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import { prisma } from "@/lib/prisma";
-import { BountyType } from "@prisma/client";
-
-type BountyEligibilityCandidate = {
- id: string;
- name: string | null;
- type: BountyType;
- startsAt: Date;
- endsAt: Date | null;
- archivedAt: Date | null;
- groups: { groupId: string }[];
-};
-
-export function filterActiveGroupBounties(
- bounties: BountyEligibilityCandidate[],
- {
- groupId,
- now = new Date(),
- }: {
- groupId: string;
- now?: Date;
- },
-) {
- return bounties.filter((bounty) => {
- if (bounty.archivedAt) {
- return false;
- }
-
- if (bounty.startsAt > now) {
- return false;
- }
-
- if (bounty.endsAt && bounty.endsAt <= now) {
- return false;
- }
-
- return (
- bounty.groups.length === 0 ||
- bounty.groups.some((group) => group.groupId === groupId)
- );
- });
-}
-
-export async function getGroupBountySummaries({
- programId,
- groupId,
- now = new Date(),
-}: {
- programId: string;
- groupId: string;
- now?: Date;
-}) {
- const bounties = await prisma.bounty.findMany({
- where: {
- programId,
- },
- select: {
- id: true,
- name: true,
- type: true,
- startsAt: true,
- endsAt: true,
- archivedAt: true,
- groups: {
- select: {
- groupId: true,
- },
- },
- },
- orderBy: {
- startsAt: "asc",
- },
- });
-
- return filterActiveGroupBounties(bounties, { groupId, now }).map(
- (bounty) => ({
- id: bounty.id,
- name: bounty.name || "Untitled bounty",
- type: bounty.type,
- }),
- );
-}
diff --git a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts
index 068653dd8ea..90dfdcd0a17 100644
--- a/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts
+++ b/apps/web/lib/bounty/api/trigger-draft-bounty-submissions.ts
@@ -1,8 +1,10 @@
import { qstash } from "@/lib/cron";
import { prisma } from "@/lib/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
-import { Bounty } from "@prisma/client";
-import { getBountiesByGroups } from "./get-bounties-by-groups";
+import {
+ bountyEligibilityIncludes,
+ canPartnerSubmitBounty,
+} from "./bounty-eligibility";
// Trigger the creation of draft submissions for performance bounties that uses lifetime stats for the given partners
export async function triggerDraftBountySubmissionCreation({
@@ -12,93 +14,87 @@ export async function triggerDraftBountySubmissionCreation({
programId: string;
partnerIds: string[];
}) {
- const programEnrollments = await prisma.programEnrollment.findMany({
- where: {
- partnerId: {
- in: partnerIds,
+ const [program, programEnrollments] = await Promise.all([
+ prisma.program.findUnique({
+ where: {
+ id: programId,
},
- programId,
- },
- select: {
- partnerId: true,
- groupId: true,
- },
- });
+ select: {
+ defaultGroupId: true,
+ },
+ }),
- if (programEnrollments.length === 0) {
+ prisma.programEnrollment.findMany({
+ where: {
+ partnerId: {
+ in: partnerIds,
+ },
+ programId,
+ },
+ select: {
+ partnerId: true,
+ groupId: true,
+ createdAt: true,
+ status: true,
+ groupJoinedAt: true,
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
+ },
+ }),
+ ]);
+
+ if (!program || programEnrollments.length === 0) {
return;
}
- const groupIds = [
- ...new Set(
- programEnrollments
- .map(({ groupId }) => groupId)
- .filter((id): id is string => id !== null),
- ),
- ];
-
- const bountiesByGroups = await getBountiesByGroups({
- programId,
- groupIds,
+ const bounties = await prisma.bounty.findMany({
+ where: {
+ programId,
+ type: "performance",
+ performanceScope: "lifetime",
+ archivedAt: null,
+ },
+ include: {
+ ...bountyEligibilityIncludes,
+ },
});
- const partnersByGroup = programEnrollments.reduce(
- (acc, enrollment) => {
- if (enrollment.groupId) {
- acc[enrollment.groupId] = [
- ...(acc[enrollment.groupId] || []),
- enrollment.partnerId,
- ];
- }
- return acc;
+ console.log(
+ `Found ${bounties.length} eligible performance bounties for program ${programId}.`,
+ {
+ bounties,
},
- {} as Record,
);
- for (const groupId in bountiesByGroups) {
- const eligibleBounties = bountiesByGroups[groupId].filter((bounty) =>
- isEligiblePerformanceBounty(bounty),
- );
-
- if (eligibleBounties.length === 0) {
- console.log(
- `No eligible bounties found for the group ${groupId}. Either there are no performance bounties, or there are no lifetime stats.`,
- );
- continue;
- }
-
- const groupPartnerIds = partnersByGroup[groupId] || [];
-
- if (groupPartnerIds.length === 0) {
- console.log(`No partners found for the group ${groupId}.`);
- continue;
- }
-
- console.log(
- `Found ${eligibleBounties.length} eligible bounties for the group ${groupId}.`,
- );
-
- await Promise.allSettled(
- eligibleBounties.map((bounty) =>
- qstash.publishJSON({
- url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`,
- body: {
- bountyId: bounty.id,
- partnerIds: groupPartnerIds,
- },
- }),
- ),
- );
+ if (bounties.length === 0) {
+ return;
}
-}
-function isEligiblePerformanceBounty(bounty: Bounty) {
- const now = new Date();
+ await Promise.allSettled(
+ bounties.map(async (bounty) => {
+ const eligiblePartnerIds = programEnrollments
+ .filter((programEnrollment) =>
+ canPartnerSubmitBounty({
+ programEnrollment,
+ bounty,
+ }),
+ )
+ .map(({ partnerId }) => partnerId);
- if (bounty.type !== "performance") return false;
- if (bounty.performanceScope === "new") return false;
- if (bounty.startsAt > now) return false;
- if (bounty.endsAt && bounty.endsAt <= now) return false;
+ if (eligiblePartnerIds.length === 0) {
+ return;
+ }
- return true;
+ await qstash.publishJSON({
+ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/bounties/create-draft-submissions`,
+ body: {
+ bountyId: bounty.id,
+ partnerIds: eligiblePartnerIds,
+ },
+ });
+ }),
+ );
}
diff --git a/apps/web/lib/bounty/api/validate-bounty.ts b/apps/web/lib/bounty/api/validate-bounty.ts
index 19c28f21cec..72112f028f1 100644
--- a/apps/web/lib/bounty/api/validate-bounty.ts
+++ b/apps/web/lib/bounty/api/validate-bounty.ts
@@ -5,6 +5,8 @@ export function validateBounty({
type,
startsAt,
endsAt,
+ startMode,
+ endsAfterDays,
submissionsOpenAt,
submissionFrequency,
maxSubmissions,
@@ -12,9 +14,40 @@ export function validateBounty({
rewardDescription,
performanceScope,
}: Partial) {
- startsAt = startsAt || new Date();
+ startMode = startMode ?? "absolute";
- if (endsAt && endsAt < startsAt) {
+ // startsAt is required when startMode is absolute and must be null when
+ // startMode is relative (relative bounties start when a partner joins).
+ if (startMode === "relative") {
+ if (startsAt != null) {
+ throw new DubApiError({
+ message:
+ "startsAt is not supported when the bounty starts when a partner joins. It must be null for relative bounties.",
+ code: "bad_request",
+ });
+ }
+ } else {
+ // Default to now when an absolute bounty doesn't specify a start date
+ startsAt = startsAt || new Date();
+ }
+
+ if (endsAt && endsAfterDays) {
+ throw new DubApiError({
+ message:
+ "Bounty cannot have both an end date (endsAt) and endsAfterDays.",
+ code: "bad_request",
+ });
+ }
+
+ if (startMode === "absolute" && endsAfterDays) {
+ throw new DubApiError({
+ message:
+ "endsAfterDays is only supported when the bounty starts when a partner joins.",
+ code: "bad_request",
+ });
+ }
+
+ if (endsAt && startsAt && endsAt < startsAt) {
throw new DubApiError({
message:
"Bounty end date (endsAt) must be on or after start date (startsAt).",
@@ -31,7 +64,7 @@ export function validateBounty({
});
}
- if (submissionsOpenAt < startsAt) {
+ if (startsAt && submissionsOpenAt < startsAt) {
throw new DubApiError({
message:
"Bounty submissions open date (submissionsOpenAt) must be on or after start date (startsAt).",
@@ -72,7 +105,6 @@ export function validateBounty({
});
}
- // submission bounty checks
if (type === "submission") {
if (submissionFrequency && maxSubmissions == null) {
throw new DubApiError({
diff --git a/apps/web/lib/bounty/bounty-period.ts b/apps/web/lib/bounty/bounty-period.ts
new file mode 100644
index 00000000000..b398c86c752
--- /dev/null
+++ b/apps/web/lib/bounty/bounty-period.ts
@@ -0,0 +1,114 @@
+import { Bounty, BountyStartMode, ProgramEnrollment } from "@prisma/client";
+import { addDays, addMonths, addWeeks } from "date-fns";
+
+export const BOUNTY_DURATION_PRESETS = [
+ "twoWeeks",
+ "oneMonth",
+ "sixMonths",
+] as const;
+
+export type DurationPreset = (typeof BOUNTY_DURATION_PRESETS)[number];
+
+export const BOUNTY_DURATION_DAYS: Record = {
+ twoWeeks: 14,
+ oneMonth: 30,
+ sixMonths: 180,
+};
+
+export type StartPreset = "today" | DurationPreset | "onPartnerJoin" | "custom";
+export type EndPreset = "never" | DurationPreset | "custom";
+
+export function resolveBountyTiming({
+ startPreset,
+ endPreset,
+ customStartsAt,
+ customEndsAt,
+}: {
+ startPreset: StartPreset;
+ endPreset: EndPreset;
+ customStartsAt?: Date | null;
+ customEndsAt?: Date | null;
+}) {
+ const now = new Date();
+
+ let startMode: BountyStartMode = "absolute";
+ let startsAt = now;
+
+ switch (startPreset) {
+ case "today":
+ startsAt = now;
+ break;
+ case "twoWeeks":
+ startsAt = addWeeks(now, 2);
+ break;
+ case "oneMonth":
+ startsAt = addMonths(now, 1);
+ break;
+ case "sixMonths":
+ startsAt = addMonths(now, 6);
+ break;
+ case "onPartnerJoin":
+ startMode = "relative";
+ startsAt = now;
+ break;
+ case "custom":
+ startsAt = customStartsAt ?? now;
+ break;
+ }
+
+ let endsAt: Date | null = null;
+ let endsAfterDays: number | null = null;
+
+ switch (endPreset) {
+ case "never":
+ break;
+ case "twoWeeks":
+ case "oneMonth":
+ case "sixMonths":
+ if (startMode === "absolute") {
+ endsAt = addDays(startsAt, BOUNTY_DURATION_DAYS[endPreset]);
+ } else {
+ endsAfterDays = BOUNTY_DURATION_DAYS[endPreset];
+ }
+ break;
+ case "custom":
+ endsAt = customEndsAt ?? null;
+ break;
+ }
+
+ return {
+ startMode,
+ startsAt,
+ endsAt,
+ endsAfterDays,
+ };
+}
+
+export function getEffectiveBountyPeriod({
+ programEnrollment,
+ bounty,
+}: {
+ programEnrollment: Pick;
+ bounty: Pick;
+}) {
+ const { createdAt, groupJoinedAt } = programEnrollment;
+ const { startsAt, endsAt, endsAfterDays, startMode } = bounty;
+
+ // If startMode is absolute, use the startsAt (Assumed to be set).
+ // If startMode is relative, use the groupJoinedAt or createdAt.
+ const bountyStartDate =
+ startMode === "absolute" ? startsAt! : groupJoinedAt || createdAt;
+
+ return {
+ startsAt: bountyStartDate,
+ endsAt: endsAfterDays ? addDays(bountyStartDate, endsAfterDays) : endsAt,
+ };
+}
+
+export function isBountyStarted(startsAt: Date) {
+ return startsAt <= new Date();
+}
+
+export function isBountyExpired(endsAt: Date | null) {
+ return endsAt !== null && endsAt <= new Date();
+}
diff --git a/apps/web/lib/bounty/constants.ts b/apps/web/lib/bounty/constants.ts
index e859234fc36..07ae14e6c97 100644
--- a/apps/web/lib/bounty/constants.ts
+++ b/apps/web/lib/bounty/constants.ts
@@ -1,4 +1,4 @@
-import { BountySubmissionFrequency } from "@prisma/client";
+import { BountySubmissionFrequency, BountyType } from "@prisma/client";
export const BOUNTY_DESCRIPTION_MAX_LENGTH = 5000;
@@ -29,3 +29,8 @@ export const SUBMISSION_FREQUENCY_OPTIONS = [
{ label: "Once a week", value: BountySubmissionFrequency.week },
{ label: "Once a month", value: BountySubmissionFrequency.month },
] as const;
+
+export const BOUNTY_ICONS: Record = {
+ submission: "https://assets.dub.co/email-assets/icons/heart.png",
+ performance: "https://assets.dub.co/email-assets/icons/trophy.png",
+};
diff --git a/apps/web/lib/bounty/periods.ts b/apps/web/lib/bounty/periods.ts
index 82a78997e72..e2b72b396f4 100644
--- a/apps/web/lib/bounty/periods.ts
+++ b/apps/web/lib/bounty/periods.ts
@@ -1,5 +1,6 @@
import { BountySubmissionFrequency } from "@prisma/client";
import { addDays, addMonths, addWeeks } from "date-fns";
+import { isBountyStarted } from "./bounty-period";
export type SubmissionPeriodStatus =
| "notSubmitted"
@@ -83,7 +84,7 @@ export function getCurrentPeriodNumber({
const now = new Date();
const start = new Date(startsAt);
- if (now < start) {
+ if (!isBountyStarted(start)) {
return null;
}
@@ -157,7 +158,7 @@ export function getSubmissionPeriods<
if (submission) {
status = submission.status as SubmissionPeriodStatus;
- } else if (now < start) {
+ } else if (!isBountyStarted(start)) {
status = "notOpen";
} else {
status = "notSubmitted";
@@ -188,7 +189,7 @@ export function getSubmissionPeriods<
if (submissionForPeriod) {
status = submissionForPeriod.status as SubmissionPeriodStatus;
- } else if (now < start) {
+ } else if (!isBountyStarted(start)) {
status = "notOpen";
} else {
status = "notSubmitted";
@@ -235,7 +236,7 @@ export function getSubmissionPeriods<
if (submissionForPeriod) {
status = submissionForPeriod.status as SubmissionPeriodStatus;
- } else if (now < startDate) {
+ } else if (!isBountyStarted(startDate)) {
status = "notOpen";
} else {
status = "notSubmitted";
diff --git a/apps/web/lib/embed/referrals/auth.ts b/apps/web/lib/embed/referrals/auth.ts
index ac142be36b2..001370bc955 100644
--- a/apps/web/lib/embed/referrals/auth.ts
+++ b/apps/web/lib/embed/referrals/auth.ts
@@ -4,7 +4,12 @@ import { prisma } from "@/lib/prisma";
import { PartnerGroupProps } from "@/lib/types";
import { ratelimit } from "@/lib/upstash";
import { getSearchParams } from "@dub/utils";
-import { Link, Program, ProgramEnrollment } from "@prisma/client";
+import {
+ Link,
+ Program,
+ ProgramEnrollment,
+ ProgramPartnerTag,
+} from "@prisma/client";
import { headers } from "next/headers";
import { referralsEmbedToken } from "./token-class";
@@ -23,7 +28,9 @@ interface WithReferralsEmbedTokenHandler {
params: Record;
searchParams: Record;
program: Program;
- programEnrollment: ProgramEnrollment;
+ programEnrollment: ProgramEnrollment & {
+ programPartnerTags: Pick[];
+ };
group: PartnerGroupProps;
links: Link[];
embedToken: string;
@@ -105,6 +112,11 @@ export const withReferralsEmbedToken = (
},
program: true,
partnerGroup: true,
+ programPartnerTags: {
+ select: {
+ partnerTagId: true,
+ },
+ },
},
});
diff --git a/apps/web/lib/fetchers/get-network-program.ts b/apps/web/lib/fetchers/get-network-program.ts
index 9c20a836cae..67e61747dbb 100644
--- a/apps/web/lib/fetchers/get-network-program.ts
+++ b/apps/web/lib/fetchers/get-network-program.ts
@@ -1,4 +1,4 @@
-import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries";
+import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties";
import { prisma } from "@/lib/prisma";
import { cache } from "react";
import { DEFAULT_PARTNER_GROUP } from "../zod/schemas/groups";
@@ -37,7 +37,7 @@ export const getNetworkProgram = cache(async ({ slug }: { slug: string }) => {
const defaultGroup = program.groups[0];
const bounties = defaultGroup
- ? await getGroupBountySummaries({
+ ? await getGroupBounties({
programId: program.id,
groupId: defaultGroup.id,
})
diff --git a/apps/web/lib/fetchers/get-program.ts b/apps/web/lib/fetchers/get-program.ts
index 7f5ef47e31a..de83c847ac7 100644
--- a/apps/web/lib/fetchers/get-program.ts
+++ b/apps/web/lib/fetchers/get-program.ts
@@ -1,4 +1,4 @@
-import { getGroupBountySummaries } from "@/lib/bounty/api/get-group-bounty-summaries";
+import { getGroupBounties } from "@/lib/bounty/api/get-group-bounties";
import { prisma } from "@/lib/prisma";
import { Program, Reward } from "@prisma/client";
import { cache } from "react";
@@ -57,7 +57,7 @@ export const getProgram = cache(
const group = groups[0];
- const bounties = await getGroupBountySummaries({
+ const bounties = await getGroupBounties({
programId: program.id,
groupId: group.id,
});
diff --git a/apps/web/lib/firstpromoter/import-partners.ts b/apps/web/lib/firstpromoter/import-partners.ts
index 025071d3368..3693fb052ee 100644
--- a/apps/web/lib/firstpromoter/import-partners.ts
+++ b/apps/web/lib/firstpromoter/import-partners.ts
@@ -183,6 +183,7 @@ async function createPartnerAndLinks({
partnerId: partner.id,
status: "approved",
groupId: group.id,
+ groupJoinedAt: new Date(),
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
saleRewardId: group.saleRewardId,
diff --git a/apps/web/lib/integrations/slack/transform.ts b/apps/web/lib/integrations/slack/transform.ts
index c6dc3fa23af..781c7988083 100644
--- a/apps/web/lib/integrations/slack/transform.ts
+++ b/apps/web/lib/integrations/slack/transform.ts
@@ -449,6 +449,7 @@ const bountyTemplates = ({
rewardDescription,
submissionRequirements,
type,
+ startMode,
startsAt,
endsAt,
} = data;
@@ -497,7 +498,11 @@ const bountyTemplates = ({
},
{
type: "mrkdwn",
- text: `*Duration*\n${new Date(startsAt).toLocaleDateString()}${endsAt ? ` - ${new Date(endsAt).toLocaleDateString()}` : " (No end date)"}`,
+ text: `*Duration*\n${
+ startMode === "relative" || !startsAt
+ ? "Starts when partner joins"
+ : `${startsAt.toLocaleDateString()}${endsAt ? ` - ${endsAt.toLocaleDateString()}` : " (No end date)"}`
+ }`,
},
],
},
diff --git a/apps/web/lib/partnerstack/import-partners.ts b/apps/web/lib/partnerstack/import-partners.ts
index a6f3eb65dee..6a81d151fd4 100644
--- a/apps/web/lib/partnerstack/import-partners.ts
+++ b/apps/web/lib/partnerstack/import-partners.ts
@@ -182,6 +182,7 @@ async function createPartner({
partnerId,
status: "approved",
groupId: group.id,
+ groupJoinedAt: new Date(),
clickRewardId: group.clickRewardId,
leadRewardId: group.leadRewardId,
saleRewardId: group.saleRewardId,
diff --git a/apps/web/lib/rewardful/import-partners.ts b/apps/web/lib/rewardful/import-partners.ts
index c982377c2a5..5871a0c7747 100644
--- a/apps/web/lib/rewardful/import-partners.ts
+++ b/apps/web/lib/rewardful/import-partners.ts
@@ -196,6 +196,7 @@ async function createPartnerAndLinks({
programId: program.id,
partnerId: partner.id,
status: "approved",
+ groupJoinedAt: new Date(),
...defaultGroupAttributes,
},
update: {
diff --git a/apps/web/lib/swr/use-partner-program-bounties.ts b/apps/web/lib/swr/use-partner-program-bounties.ts
index 631b57bc85f..8fb02b1489b 100644
--- a/apps/web/lib/swr/use-partner-program-bounties.ts
+++ b/apps/web/lib/swr/use-partner-program-bounties.ts
@@ -1,3 +1,4 @@
+import { isBountyExpired } from "@/lib/bounty/bounty-period";
import { fetcher } from "@dub/utils";
import { useParams } from "next/navigation";
import { useMemo } from "react";
@@ -30,7 +31,10 @@ export function usePartnerProgramBounties({
if (!bounties) return { active: 0, expired: 0 };
return bounties.reduce(
(counts, bounty) => {
- const isExpired = bounty.endsAt && new Date(bounty.endsAt) < new Date();
+ const isExpired = isBountyExpired(
+ bounty.endsAt ? new Date(bounty.endsAt) : null,
+ );
+
counts[isExpired ? "expired" : "active"]++;
return counts;
},
diff --git a/apps/web/lib/tolt/import-partners.ts b/apps/web/lib/tolt/import-partners.ts
index 8055d55fb33..a97e8c3721a 100644
--- a/apps/web/lib/tolt/import-partners.ts
+++ b/apps/web/lib/tolt/import-partners.ts
@@ -160,6 +160,7 @@ async function createPartner({
programId: program.id,
partnerId: partner.id,
status: "approved",
+ groupJoinedAt: new Date(),
...defaultGroupAttributes,
},
update: {
diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts
index 3e34281ea53..fce63572572 100644
--- a/apps/web/lib/types.ts
+++ b/apps/web/lib/types.ts
@@ -23,7 +23,9 @@ import {
PartnerRole,
PayoutStatus,
Prisma,
+ ProgramEnrollment,
ProgramEnrollmentStatus,
+ ProgramPartnerTag,
Project,
SubmittedLead,
User,
@@ -844,7 +846,6 @@ interface WorkflowIdentity {
workspaceId: string;
programId: string;
partnerId: string;
- groupId?: string;
customerId?: string;
customerFirstSaleAt?: Date;
}
@@ -866,6 +867,21 @@ export interface WorkflowContext {
};
}
+export interface WorkflowContextExtended extends WorkflowContext {
+ programEnrollment: Pick<
+ ProgramEnrollment,
+ | "groupJoinedAt"
+ | "createdAt"
+ | "groupId"
+ | "partnerId"
+ | "programId"
+ | "status"
+ | "groupMoveDisabledAt"
+ > & {
+ programPartnerTags: Pick[];
+ };
+}
+
export type SubmittedLeadProps = z.infer;
export type SubmittedLeadFormDataField = z.infer<
diff --git a/apps/web/lib/webhook/sample-events/bounty-created.json b/apps/web/lib/webhook/sample-events/bounty-created.json
index eb772cfae8a..63d2ac0f591 100644
--- a/apps/web/lib/webhook/sample-events/bounty-created.json
+++ b/apps/web/lib/webhook/sample-events/bounty-created.json
@@ -5,6 +5,8 @@
"type": "submission",
"startsAt": "2025-08-01T17:34:00.000Z",
"endsAt": "2025-09-01T17:34:00.000Z",
+ "startMode": "absolute",
+ "endsAfterDays": null,
"submissionsOpenAt": null,
"submissionFrequency": null,
"maxSubmissions": 1,
@@ -20,5 +22,10 @@
{
"id": "grp_1K2E25381GVMG7HHM057TB92F"
}
+ ],
+ "partnerTags": [
+ {
+ "id": "ptg_1K2E25381GVMG7HHM057TB92F"
+ }
]
}
diff --git a/apps/web/lib/webhook/sample-events/bounty-updated.json b/apps/web/lib/webhook/sample-events/bounty-updated.json
index 0d5605ac897..7b89744f8a4 100644
--- a/apps/web/lib/webhook/sample-events/bounty-updated.json
+++ b/apps/web/lib/webhook/sample-events/bounty-updated.json
@@ -5,6 +5,8 @@
"type": "submission",
"startsAt": "2025-08-01T17:34:00.000Z",
"endsAt": "2025-09-01T17:34:00.000Z",
+ "startMode": "absolute",
+ "endsAfterDays": null,
"submissionsOpenAt": null,
"submissionFrequency": null,
"maxSubmissions": 1,
@@ -20,5 +22,10 @@
{
"id": "grp_1K2E25381GVMG7HHM057TB92F"
}
+ ],
+ "partnerTags": [
+ {
+ "id": "ptg_1K2E25381GVMG7HHM057TB92F"
+ }
]
}
diff --git a/apps/web/lib/zod/schemas/bounties.ts b/apps/web/lib/zod/schemas/bounties.ts
index 12ea84a8b4d..7d315f75626 100644
--- a/apps/web/lib/zod/schemas/bounties.ts
+++ b/apps/web/lib/zod/schemas/bounties.ts
@@ -12,6 +12,7 @@ import {
} from "@/lib/bounty/social-content";
import {
BountyPerformanceScope,
+ BountyStartMode,
BountySubmissionFrequency,
BountySubmissionRejectionReason,
BountySubmissionStatus,
@@ -21,6 +22,7 @@ import * as z from "zod/v4";
import { CommissionSchema } from "./commissions";
import { GroupSchema } from "./groups";
import { booleanQuerySchema, getPaginationQuerySchema } from "./misc";
+import { PartnerTagSchema } from "./partner-tags";
import { EnrolledPartnerSchema } from "./partners";
import { UserSchema } from "./users";
import { nullableCountSchema, parseDateSchema } from "./utils";
@@ -100,8 +102,10 @@ export const createBountySchema = z.object({
)
.nullish(),
type: z.enum(BountyType),
+ startMode: z.enum(BountyStartMode),
startsAt: parseDateSchema.nullish(),
endsAt: parseDateSchema.nullish(),
+ endsAfterDays: z.number().int().positive().nullish(),
submissionsOpenAt: parseDateSchema.nullish(),
submissionFrequency: z.enum(BountySubmissionFrequency).nullish(),
maxSubmissions: z
@@ -123,6 +127,7 @@ export const createBountySchema = z.object({
.nullish(),
submissionRequirements: submissionRequirementsSchema.nullish(),
groupIds: z.array(z.string()).nullable(),
+ partnerTagIds: z.array(z.string()).nullable(),
performanceCondition: bountyPerformanceConditionSchema.nullish(),
performanceScope: z.enum(BountyPerformanceScope).nullish(),
sendNotificationEmails: z.boolean().optional(),
@@ -148,8 +153,10 @@ export const BountySchema = z.object({
name: z.string().nullable(),
description: z.string().nullable(),
type: z.enum(BountyType),
- startsAt: z.date(),
+ startsAt: z.date().nullable(),
endsAt: z.date().nullable(),
+ startMode: z.enum(BountyStartMode),
+ endsAfterDays: z.number().nullable(),
submissionsOpenAt: z.date().nullable(),
submissionFrequency: z.enum(BountySubmissionFrequency).nullable(),
maxSubmissions: z.number(),
@@ -162,6 +169,7 @@ export const BountySchema = z.object({
submissionRequirements: submissionRequirementsSchema.nullable().default(null),
socialMetricsLastSyncedAt: z.date().nullable().optional(),
groups: z.array(GroupSchema.pick({ id: true })),
+ partnerTags: z.array(PartnerTagSchema.pick({ id: true })),
});
export const getBountiesQuerySchema = z.object({
diff --git a/apps/web/lib/zod/schemas/partner-profile.ts b/apps/web/lib/zod/schemas/partner-profile.ts
index 303279daf98..7a8e6f17ce1 100644
--- a/apps/web/lib/zod/schemas/partner-profile.ts
+++ b/apps/web/lib/zod/schemas/partner-profile.ts
@@ -164,8 +164,10 @@ export const partnerBountySubmissionSchema = BountySubmissionSchema.extend({
export const PartnerBountySchema = BountySchema.omit({
groups: true,
+ partnerTags: true,
socialMetricsLastSyncedAt: true,
}).extend({
+ startsAt: z.date(), // Always resolved to the partner's effective start date (never null)
submissions: z.array(partnerBountySubmissionSchema),
performanceCondition: bountyPerformanceConditionSchema
.nullable()
diff --git a/apps/web/prisma/schema/bounty.prisma b/apps/web/prisma/schema/bounty.prisma
index 321f46b2c35..a2e198a1b74 100644
--- a/apps/web/prisma/schema/bounty.prisma
+++ b/apps/web/prisma/schema/bounty.prisma
@@ -29,6 +29,11 @@ enum BountySubmissionFrequency {
month
}
+enum BountyStartMode {
+ absolute
+ relative
+}
+
model Bounty {
id String @id
programId String
@@ -36,8 +41,10 @@ model Bounty {
name String
description String? @db.Text
type BountyType
- startsAt DateTime
+ startsAt DateTime? // Set only when startMode is absolute
endsAt DateTime?
+ startMode BountyStartMode @default(absolute)
+ endsAfterDays Int? // Set only when startMode is relative
submissionsOpenAt DateTime?
submissionFrequency BountySubmissionFrequency?
maxSubmissions Int @default(1)
@@ -52,6 +59,7 @@ model Bounty {
submissions BountySubmission[]
groups BountyGroup[]
+ partnerTags BountyPartnerTag[]
program Program @relation(fields: [programId], references: [id], onDelete: Cascade)
workflow Workflow? @relation(fields: [workflowId], references: [id], onDelete: Cascade)
emails NotificationEmail[]
@@ -68,7 +76,19 @@ model BountyGroup {
partnerGroup PartnerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([bountyId, groupId])
- @@index([groupId])
+ @@index(groupId)
+}
+
+model BountyPartnerTag {
+ id String @id @default(cuid())
+ bountyId String
+ partnerTagId String
+
+ bounty Bounty @relation(fields: [bountyId], references: [id], onDelete: Cascade)
+ partnerTag PartnerTag @relation(fields: [partnerTagId], references: [id], onDelete: Cascade)
+
+ @@unique([bountyId, partnerTagId])
+ @@index(partnerTagId)
}
model BountySubmission {
diff --git a/apps/web/prisma/schema/program.prisma b/apps/web/prisma/schema/program.prisma
index 7d88029486e..85bbc4c1864 100644
--- a/apps/web/prisma/schema/program.prisma
+++ b/apps/web/prisma/schema/program.prisma
@@ -140,6 +140,7 @@ model ProgramEnrollment {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+ groupJoinedAt DateTime? // date when the partner joined their current group
customerDataSharingEnabledAt DateTime?
groupMoveDisabledAt DateTime?
bannedAt DateTime?
diff --git a/apps/web/prisma/schema/tag.prisma b/apps/web/prisma/schema/tag.prisma
index 67b7f05a859..ae34982d58c 100644
--- a/apps/web/prisma/schema/tag.prisma
+++ b/apps/web/prisma/schema/tag.prisma
@@ -34,6 +34,7 @@ model PartnerTag {
program Program? @relation(fields: [programId], references: [id], onDelete: Cascade)
programPartnerTags ProgramPartnerTag[]
+ bounties BountyPartnerTag[]
@@unique([programId, name])
@@index(programId)
diff --git a/apps/web/scripts/migrations/backfill-group-joined-at.ts b/apps/web/scripts/migrations/backfill-group-joined-at.ts
new file mode 100644
index 00000000000..a6024f05d07
--- /dev/null
+++ b/apps/web/scripts/migrations/backfill-group-joined-at.ts
@@ -0,0 +1,54 @@
+import { prisma } from "@/lib/prisma";
+import "dotenv-flow/config";
+
+async function main() {
+ while (true) {
+ const programEnrollments = await prisma.programEnrollment.findMany({
+ where: {
+ groupId: {
+ not: null,
+ },
+ groupJoinedAt: null,
+ },
+ select: {
+ id: true,
+ createdAt: true,
+ },
+ take: 100,
+ orderBy: {
+ createdAt: "asc",
+ },
+ });
+
+ if (programEnrollments.length === 0) {
+ console.log("No more program enrollments to backfill, skipping...");
+ break;
+ }
+
+ await Promise.all(
+ programEnrollments.map(({ id, createdAt }) =>
+ prisma.programEnrollment.update({
+ where: {
+ id,
+ },
+ data: {
+ groupJoinedAt: createdAt,
+ },
+ }),
+ ),
+ );
+
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+
+ console.log(`Backfilled ${programEnrollments.length} enrollments...`);
+ }
+}
+
+main()
+ .catch((error) => {
+ console.error(error);
+ process.exit(1);
+ })
+ .finally(async () => {
+ await prisma.$disconnect();
+ });
diff --git a/apps/web/tests/bounties/index.test.ts b/apps/web/tests/bounties/index.test.ts
index c17a1b339df..5c66185de21 100644
--- a/apps/web/tests/bounties/index.test.ts
+++ b/apps/web/tests/bounties/index.test.ts
@@ -106,6 +106,31 @@ describe.sequential("/bounties/**", async () => {
submissionBountyId = bounty.id;
});
+ test("POST /bounties - relative start with endsAfterDays", async () => {
+ const { status, data: bounty } = await http.post({
+ path: "/bounties",
+ body: {
+ ...submissionBounty,
+ groupIds: [E2E_PARTNER_GROUP.id],
+ startMode: "relative",
+ endsAfterDays: 14,
+ endsAt: null,
+ },
+ });
+
+ expect(status).toEqual(200);
+ expect(bounty).toMatchObject({
+ id: expect.any(String),
+ startMode: "relative",
+ endsAfterDays: 14,
+ endsAt: null,
+ });
+
+ onTestFinished(async () => {
+ await h.deleteBounty(bounty.id);
+ });
+ });
+
test("POST /bounties - submission based with rewardDescription", async () => {
const { status, data: bounty } = await http.post({
path: "/bounties",
diff --git a/apps/web/tests/misc/filter-active-group-bounties.test.ts b/apps/web/tests/misc/filter-active-group-bounties.test.ts
deleted file mode 100644
index 2bd238df98b..00000000000
--- a/apps/web/tests/misc/filter-active-group-bounties.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { filterActiveGroupBounties } from "@/lib/bounty/api/get-group-bounty-summaries";
-import { BountyType } from "@prisma/client";
-import { describe, expect, it } from "vitest";
-
-const GROUP_ID = "grp_test_123";
-const OTHER_GROUP_ID = "grp_other_456";
-
-const NOW = new Date("2025-06-01T12:00:00.000Z");
-
-function makeCandidate(
- overrides: Partial<{
- id: string;
- name: string | null;
- type: BountyType;
- startsAt: Date;
- endsAt: Date | null;
- archivedAt: Date | null;
- groups: { groupId: string }[];
- }> = {},
-) {
- return {
- id: "bnty_1",
- name: "Test Bounty",
- type: "submission" as BountyType,
- startsAt: new Date("2025-01-01T00:00:00.000Z"),
- endsAt: null,
- archivedAt: null,
- groups: [],
- ...overrides,
- };
-}
-
-describe("filterActiveGroupBounties", () => {
- it("excludes archived bounties", () => {
- const bounty = makeCandidate({
- archivedAt: new Date("2025-05-01T00:00:00.000Z"),
- });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(0);
- });
-
- it("excludes bounties that have not started yet (startsAt > now)", () => {
- const bounty = makeCandidate({
- startsAt: new Date("2025-06-02T00:00:00.000Z"),
- });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(0);
- });
-
- it("includes bounties that start exactly at now (startsAt === now, boundary is exclusive >)", () => {
- const bounty = makeCandidate({ startsAt: NOW });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(1);
- });
-
- it("excludes bounties whose endsAt is exactly now (endsAt === now, exclusive boundary)", () => {
- const bounty = makeCandidate({ endsAt: NOW });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(0);
- });
-
- it("excludes bounties that expired before now (endsAt < now)", () => {
- const bounty = makeCandidate({
- endsAt: new Date("2025-05-31T00:00:00.000Z"),
- });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(0);
- });
-
- it("includes bounties with no endsAt (never expires)", () => {
- const bounty = makeCandidate({ endsAt: null });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(1);
- });
-
- it("includes global bounties (groups is empty) regardless of groupId", () => {
- const bounty = makeCandidate({ groups: [] });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(1);
- });
-
- it("includes group-scoped bounties when groups contains the matching groupId", () => {
- const bounty = makeCandidate({ groups: [{ groupId: GROUP_ID }] });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(1);
- });
-
- it("excludes group-scoped bounties when groups contains only a different groupId", () => {
- const bounty = makeCandidate({ groups: [{ groupId: OTHER_GROUP_ID }] });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(0);
- });
-
- it("includes group-scoped bounties when groups contains the matching groupId alongside others", () => {
- const bounty = makeCandidate({
- groups: [{ groupId: OTHER_GROUP_ID }, { groupId: GROUP_ID }],
- });
- const result = filterActiveGroupBounties([bounty], {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result).toHaveLength(1);
- });
-
- it("filters multiple bounties correctly in a mixed set", () => {
- const bounties = [
- makeCandidate({
- id: "bnty_1",
- archivedAt: new Date("2025-01-01T00:00:00.000Z"),
- }),
- makeCandidate({
- id: "bnty_2",
- startsAt: new Date("2025-07-01T00:00:00.000Z"),
- }),
- makeCandidate({ id: "bnty_3", endsAt: NOW }),
- makeCandidate({ id: "bnty_4", groups: [] }),
- makeCandidate({ id: "bnty_5", groups: [{ groupId: GROUP_ID }] }),
- makeCandidate({ id: "bnty_6", groups: [{ groupId: OTHER_GROUP_ID }] }),
- ];
-
- const result = filterActiveGroupBounties(bounties, {
- groupId: GROUP_ID,
- now: NOW,
- });
- expect(result.map((b) => b.id)).toEqual(["bnty_4", "bnty_5"]);
- });
-});
diff --git a/apps/web/tests/webhooks/index.test.ts b/apps/web/tests/webhooks/index.test.ts
index 39864ea0728..cc11206e3d8 100644
--- a/apps/web/tests/webhooks/index.test.ts
+++ b/apps/web/tests/webhooks/index.test.ts
@@ -57,6 +57,15 @@ const commissionWebhookEventSchemaExtended = CommissionWebhookSchema.extend({
const bountyWebhookEventSchemaExtended = BountySchema.extend({
startsAt: z.string().transform((str) => new Date(str)),
endsAt: z.string().transform((str) => (str ? new Date(str) : null)),
+ submissionsOpenAt: z
+ .string()
+ .nullable()
+ .transform((str) => (str ? new Date(str) : null)),
+ socialMetricsLastSyncedAt: z
+ .string()
+ .nullable()
+ .optional()
+ .transform((str) => (str ? new Date(str) : null)),
});
const payoutWebhookEventSchemaExtended = payoutWebhookEventSchema.extend({