Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ef7f0df
Allow targeting campaign recipients by partner tags.
devkiran Jul 27, 2026
f323dd8
Add campaign audience panel for groups and partner tags.
devkiran Jul 27, 2026
dc4eb6f
Fix campaign confirmation counts to honor partner tag filters.
devkiran Jul 27, 2026
14fb8eb
Seed partner notification preferences in local partner seed scripts.
devkiran Jul 27, 2026
a3cc035
Update campaign-audience-panel.tsx
devkiran Jul 27, 2026
39cb6ef
Format
devkiran Jul 27, 2026
1ca93b1
Update campaign-audience-panel.tsx
devkiran Jul 27, 2026
f7a29b7
Add partner tag eligibility to bounty UI and share audience panel.
devkiran Jul 27, 2026
b9b142b
Extract bounty eligibility summary and filter by partner tags.
devkiran Jul 27, 2026
5790645
Persist partner tags on bounty create and update APIs.
devkiran Jul 27, 2026
6dd3721
Accept programPartnerTags on bounty eligibility helpers and wire tag …
devkiran Jul 27, 2026
70ac637
Merge branch 'dynamic-bounty-start-date' into limt-bounties-campaigns…
devkiran Jul 28, 2026
ce5e253
Merge branch 'dynamic-bounty-start-date' into limt-bounties-campaigns…
devkiran Jul 28, 2026
d7511a0
Select only needed campaign relation fields in API queries.
devkiran Jul 28, 2026
69ca7f6
Add pluck helper for extracting IDs from campaign relations.
devkiran Jul 28, 2026
a5bbe96
Update route.ts
devkiran Jul 28, 2026
8759793
some cleanup
devkiran Jul 28, 2026
87362e6
Share eligibility includes and pluck group/tag IDs across bounties an…
devkiran Jul 28, 2026
eb276ec
Document bounty eligibility where semantics and use pluck for partner…
devkiran Jul 28, 2026
b952085
Split audience eligibility into shared group and tag selects.
devkiran Jul 28, 2026
3d458c2
Log workflow conditions in local dev and return mock batch email IDs.
devkiran Jul 28, 2026
ac88559
Preserve relative bounty startMode and endsAt on unrelated PATCH upda…
devkiran Jul 28, 2026
9c9ad72
Tighten spacing around the audience limit select list.
devkiran Jul 28, 2026
46c2f7b
Merge remote-tracking branch 'origin/dynamic-bounty-start-date' into …
devkiran Jul 29, 2026
8e80d14
Format
devkiran Jul 29, 2026
5d0e19c
Support multiple trigger conditions on campaign workflows
devkiran Jul 29, 2026
6f1d0e3
Drive workflows from events and attribute data requirements
devkiran Jul 29, 2026
e29505e
Wire campaign UI and send execution to multiple trigger conditions
devkiran Jul 29, 2026
68b9454
Share workflow condition checks and tighten campaign logic validation
devkiran Jul 29, 2026
2d15839
Fix build
devkiran Jul 29, 2026
9d89f28
Align workflow parsing with multi-condition configs and drop trigger …
devkiran Jul 29, 2026
f3bd7b5
Make partnerJoined exclusive and share send-campaign workflow context…
devkiran Jul 29, 2026
42f8e19
Restore two-tab Groups/Tags selectors on campaign recipients.
devkiran Jul 29, 2026
44d298e
Update update-program-partner-tags.ts
devkiran Jul 29, 2026
24c5bb3
Merge branch 'dynamic-bounty-start-date' into limt-bounties-campaigns…
devkiran Jul 30, 2026
75a8a0d
Merge branch 'limt-bounties-campaigns-by-tag' of https://github.com/d…
devkiran Jul 30, 2026
8a66898
Harden workflow conditions, scheduled detection, and audience null ha…
devkiran Jul 30, 2026
a208711
Fix bounty font classes and drop redundant await in campaign resolve.
devkiran Jul 30, 2026
8a407c5
Fetch selected campaign recipients by ID and soft-fail QStash schedul…
devkiran Jul 30, 2026
dfddb68
Use shared eligibility helpers for draft bounty submission triggers.
devkiran Jul 30, 2026
9289ffe
Hide empty partner tag labels and cover campaign partner tag API cases.
devkiran Jul 30, 2026
675c537
Require exactly one award bounty condition and rename lte label to at…
devkiran Jul 30, 2026
f7f1077
Merge branch 'dynamic-bounty-start-date' into limt-bounties-campaigns…
devkiran Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
import { throwIfInvalidPartnerTagIds } from "@/lib/api/partner-tags/throw-if-invalid-partner-tag-ids";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { WorkflowCondition } from "@/lib/api/workflows/types";
Expand All @@ -11,6 +12,7 @@ import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-perform
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 { transformBounty } from "@/lib/bounty/api/transform-bounty";
import { shouldUpsertDraftSubmissionsOnReopen } from "@/lib/bounty/api/upsert-draft-bounty-submissions";
import { validateBounty } from "@/lib/bounty/api/validate-bounty";
import { qstash } from "@/lib/cron";
Expand All @@ -22,7 +24,12 @@ import {
submissionRequirementsSchema,
updateBountySchema,
} from "@/lib/zod/schemas/bounties";
import { APP_DOMAIN_WITH_NGROK, arrayEqual, deepEqual } from "@dub/utils";
import {
APP_DOMAIN_WITH_NGROK,
arrayEqual,
deepEqual,
pluck,
} from "@dub/utils";
import { BountyStartMode, PartnerGroup, Prisma } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
Expand Down Expand Up @@ -69,6 +76,7 @@ export const PATCH = withWorkspace(
submissionRequirements,
performanceCondition,
groupIds,
partnerTagIds,
} = updateBountySchema.parse(await parseRequestBody(req));

const bounty = await getBountyOrThrow({
Expand All @@ -90,13 +98,16 @@ export const PATCH = withWorkspace(

// Absolute end dates are cleared when switching to relative (unless the
// client explicitly sends endsAt) or when setting endsAfterDays.
// Do not clear a relative bounty's fixed endsAt on unrelated PATCHes —
// relative + calendar endsAt is a supported shape (custom end).
let endsAtUpdate: { endsAt?: Date | null } = {};

if (endsAt !== undefined) {
endsAtUpdate = { endsAt };
} else if (endsAfterDays != null) {
endsAtUpdate = { endsAt: null };
} else if (
bounty.startMode === BountyStartMode.absolute &&
nextStartMode === BountyStartMode.relative &&
bounty.endsAt != null
) {
Expand Down Expand Up @@ -159,9 +170,11 @@ export const PATCH = withWorkspace(
// if groupIds is provided and is different from the current groupIds, update the groups
let updatedPartnerGroups: PartnerGroup[] | undefined = undefined;
let shouldUpdatePartnerGroups = false;
let updatedPartnerTags: { id: string }[] | undefined = undefined;
let shouldUpdatePartnerTags = false;

if (groupIds !== undefined) {
const currentGroupIds = bounty.groups.map((group) => group.groupId);
const currentGroupIds = pluck(bounty.groups, "groupId");
const newGroupIds = groupIds || [];

if (!arrayEqual(currentGroupIds, newGroupIds)) {
Expand All @@ -176,6 +189,22 @@ export const PATCH = withWorkspace(
}
}

if (partnerTagIds !== undefined) {
const currentPartnerTagIds = pluck(bounty.partnerTags, "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
if (performanceCondition && bounty.workflow) {
const submissionCount = bounty._count.submissions;
Expand Down Expand Up @@ -285,6 +314,17 @@ export const PATCH = withWorkspace(
}),
},
}),
...(shouldUpdatePartnerTags && {
partnerTags: {
deleteMany: {},
...(updatedPartnerTags &&
updatedPartnerTags.length > 0 && {
create: updatedPartnerTags.map((tag) => ({
partnerTagId: tag.id,
})),
}),
},
}),
},
include: {
workflow: true,
Expand All @@ -309,11 +349,7 @@ export const PATCH = withWorkspace(
};
});

const updatedBounty = BountySchema.parse({
...data,
groups: data.groups.map(({ groupId }) => ({ id: groupId })),
performanceCondition: data.workflow?.triggerConditions?.[0],
});
const updatedBounty = BountySchema.parse(transformBounty(data));

const shouldUpsertDraftSubmissions = shouldUpsertDraftSubmissionsOnReopen({
type: bounty.type,
Expand Down Expand Up @@ -412,11 +448,7 @@ export const DELETE = withWorkspace(
}
});

const deletedBounty = BountySchema.parse({
...bounty,
groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
performanceCondition: bounty.workflow?.triggerConditions?.[0],
});
const deletedBounty = BountySchema.parse(transformBounty(bounty));

waitUntil(
recordAuditLog({
Expand Down
48 changes: 34 additions & 14 deletions apps/web/app/(ee)/api/bounties/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { recordAuditLog } from "@/lib/api/audit-logs/record-audit-log";
import { createId } from "@/lib/api/create-id";
import { DubApiError } from "@/lib/api/errors";
import { throwIfInvalidGroupIds } from "@/lib/api/groups/throw-if-invalid-group-ids";
import { throwIfInvalidPartnerTagIds } from "@/lib/api/partner-tags/throw-if-invalid-partner-tag-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 { parseRequestBody } from "@/lib/api/utils";
Expand All @@ -15,6 +16,7 @@ import {
isPartnerEligibleForBounty,
} from "@/lib/bounty/api/bounty-availability";
import { generatePerformanceBountyName } from "@/lib/bounty/api/generate-performance-bounty-name";
import { transformBounty } from "@/lib/bounty/api/transform-bounty";
import { validateBounty } from "@/lib/bounty/api/validate-bounty";
import { qstash } from "@/lib/cron";
import { getPlanCapabilities } from "@/lib/plan-capabilities";
Expand All @@ -26,11 +28,8 @@ import {
createBountySchema,
getBountiesQuerySchema,
} from "@/lib/zod/schemas/bounties";
import {
WORKFLOW_ACTION_TYPES,
WORKFLOW_ATTRIBUTE_TRIGGER,
} from "@/lib/zod/schemas/workflows";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";
import { WORKFLOW_ACTION_TYPES } from "@/lib/zod/schemas/workflows";
import { APP_DOMAIN_WITH_NGROK, pluck } from "@dub/utils";
import { BountyStartMode, Workflow } from "@prisma/client";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
Expand All @@ -53,18 +52,30 @@ export const GET = withWorkspace(
defaultGroupId: true,
},
},
programPartnerTags: {
select: {
partnerTagId: true,
},
},
},
})
: null;

const partnerGroupId =
programEnrollment?.groupId || programEnrollment?.program.defaultGroupId;
const partnerTagIds = programEnrollment
? pluck(programEnrollment.programPartnerTags, "partnerTagId")
: [];

const [bounties, allBountiesSubmissionsCount] = await Promise.all([
prisma.bounty.findMany({
where: {
programId,
...(programEnrollment && buildBountyEligibilityWhere(partnerGroupId)),
...(programEnrollment &&
buildBountyEligibilityWhere({
groupId: partnerGroupId,
partnerTagIds,
})),
},
include: {
...bountyEligibilityIncludes,
Expand Down Expand Up @@ -138,11 +149,10 @@ export const GET = withWorkspace(

return [
BountyListSchema.parse({
...bounty,
...transformBounty(bounty),
...(allBountiesSubmissionsCount && {
submissionsCountData: aggregateSubmissionsCountForBounty(bounty.id),
}),
groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
}),
];
});
Expand Down Expand Up @@ -174,6 +184,7 @@ export const POST = withWorkspace(
maxSubmissions,
submissionRequirements,
groupIds,
partnerTagIds,
performanceCondition,
performanceScope,
sendNotificationEmails,
Expand Down Expand Up @@ -205,6 +216,11 @@ export const POST = withWorkspace(
groupIds,
});

const partnerTags = await throwIfInvalidPartnerTagIds({
programId,
partnerTagIds,
});

// Bounty name
let bountyName = name;

Expand Down Expand Up @@ -244,7 +260,6 @@ export const POST = withWorkspace(
data: {
id: createId({ prefix: "wf_" }),
programId,
trigger: WORKFLOW_ATTRIBUTE_TRIGGER[performanceCondition.attribute],
triggerConditions: [performanceCondition],
actions: [action],
},
Expand Down Expand Up @@ -284,6 +299,15 @@ export const POST = withWorkspace(
},
},
}),
...(partnerTags.length && {
partnerTags: {
createMany: {
data: partnerTags.map(({ id }) => ({
partnerTagId: id,
})),
},
},
}),
},
include: {
workflow: true,
Expand All @@ -292,11 +316,7 @@ export const POST = withWorkspace(
});
});

const createdBounty = BountySchema.parse({
...bounty,
groups: bounty.groups.map(({ groupId }) => ({ id: groupId })),
performanceCondition: bounty.workflow?.triggerConditions?.[0],
});
const createdBounty = BountySchema.parse(transformBounty(bounty));

const shouldScheduleDraftSubmissions =
bounty.type === "performance" &&
Expand Down
27 changes: 22 additions & 5 deletions apps/web/app/(ee)/api/campaigns/[campaignId]/duplicate/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DEFAULT_CAMPAIGN_BODY } from "@/lib/api/campaigns/constants";
import { getCampaignOrThrow } from "@/lib/api/campaigns/get-campaign-or-throw";
import { campaignEligibilityIncludes } from "@/lib/api/campaigns/transform-campaign";
import { createId } from "@/lib/api/create-id";
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
import { parseWorkflowConfig } from "@/lib/api/workflows/parse-workflow-config";
Expand All @@ -17,24 +18,33 @@ export const POST = withWorkspace(
const campaign = await getCampaignOrThrow({
programId,
campaignId,
includeWorkflow: true,
includeGroups: true,
include: {
...campaignEligibilityIncludes,
workflow: {
select: {
id: true,
actions: true,
triggerConditions: true,
},
},
},
});

const duplicatedCampaign = await prisma.$transaction(async (tx) => {
let workflowId: string | null = null;
const campaignId = createId({ prefix: "cmp_" });

if (campaign.workflow) {
const { action, condition } = parseWorkflowConfig(campaign.workflow);
const { action, conditions: triggerConditions } = parseWorkflowConfig(
campaign.workflow,
);

const newWorkflow = await tx.workflow.create({
data: {
id: createId({ prefix: "wf_" }),
programId,
name: campaign.name,
trigger: campaign.workflow.trigger,
triggerConditions: [condition],
triggerConditions,
actions: [
{
...action,
Expand Down Expand Up @@ -70,6 +80,13 @@ export const POST = withWorkspace(
})),
},
},
partnerTags: {
createMany: {
data: campaign.partnerTags.map(({ partnerTagId }) => ({
partnerTagId,
})),
},
},
},
});
});
Expand Down
Loading
Loading