Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions apps/web/app/(ee)/api/cron/import/rewardful/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { importCampaign } from "@/lib/rewardful/import-campaign";
import { importCampaigns } from "@/lib/rewardful/import-campaigns";
import { importCommissions } from "@/lib/rewardful/import-commissions";
import { importCustomers } from "@/lib/rewardful/import-customers";
import { importPartners } from "@/lib/rewardful/import-partners";
Expand All @@ -17,8 +17,8 @@ export async function POST(req: Request) {
const payload = rewardfulImportPayloadSchema.parse(JSON.parse(rawBody));

switch (payload.action) {
case "import-campaign":
await importCampaign(payload);
case "import-campaigns":
await importCampaigns(payload);
break;
case "import-partners":
await importPartners(payload);
Expand Down
10 changes: 6 additions & 4 deletions apps/web/lib/actions/partners/start-rewardful-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,16 @@ import { authActionClient } from "../safe-action";

const schema = z.object({
workspaceId: z.string(),
campaignId: z.string().describe("Rewardful campaign ID to import."),
campaignIds: z
.array(z.string())
.describe("Rewardful campaign IDs to import."),
});
Comment on lines +12 to 15
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Harden action input: enforce at least one campaign ID

startRewardfulImportAction used to require a campaign via parsedInput.campaignId. With the array form, we should keep that invariant server-side by adding .min(1) to the schema. That way any misuse (tests, future callers, or UI regressions) is caught before queuing an import job.

campaignIds: z
  .array(z.string())
  .min(1, "Select at least one campaign")
  .describe("Rewardful campaign IDs to import."),
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/start-rewardful-import.ts around lines 12 to
15, the campaignIds schema currently allows an empty array; update the Zod
schema to require at least one campaign by adding .min(1, "Select at least one
campaign") to the array chain so the server enforces that callers provide one or
more campaign IDs and validation fails early.


export const startRewardfulImportAction = authActionClient
.schema(schema)
.action(async ({ parsedInput, ctx }) => {
const { workspace, user } = ctx;
const { campaignId } = parsedInput;
const { campaignIds } = parsedInput;

const programId = getDefaultProgramIdOrThrow(workspace);

Expand All @@ -37,7 +39,7 @@ export const startRewardfulImportAction = authActionClient
importId: createId({ prefix: "import_" }),
userId: user.id,
programId,
campaignId,
action: "import-campaign",
campaignIds,
action: "import-campaigns",
});
});
16 changes: 2 additions & 14 deletions apps/web/lib/rewardful/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,6 @@ export class RewardfulApi {
return data as T;
}

async retrieveCampaign(campaignId: string) {
return this.fetch<RewardfulCampaign>(
`${this.baseUrl}/campaigns/${campaignId}`,
);
}

async listCampaigns() {
const { data } = await this.fetch<{ data: RewardfulCampaign[] }>(
`${this.baseUrl}/campaigns`,
Expand All @@ -57,16 +51,10 @@ export class RewardfulApi {
return data;
}

async listPartners({
campaignId,
page = 1,
}: {
campaignId: string;
page?: number;
}) {
async listPartners({ page = 1 }: { page?: number }) {
const searchParams = new URLSearchParams();
searchParams.append("expand[]", "campaign");
searchParams.append("expand[]", "links");
searchParams.append("campaign_id", campaignId);
searchParams.append("page", page.toString());
searchParams.append("limit", PAGE_LIMIT.toString());

Expand Down
114 changes: 0 additions & 114 deletions apps/web/lib/rewardful/import-campaign.ts

This file was deleted.

118 changes: 118 additions & 0 deletions apps/web/lib/rewardful/import-campaigns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { RESOURCE_COLORS } from "@/ui/colors";
import { prisma } from "@dub/prisma";
import { EventType, RewardStructure } from "@dub/prisma/client";
import { randomValue } from "@dub/utils";
import { differenceInSeconds } from "date-fns";
import { createId } from "../api/create-id";
import { RewardfulApi } from "./api";
import { rewardfulImporter } from "./importer";
import { RewardfulImportPayload } from "./types";

export async function importCampaigns(payload: RewardfulImportPayload) {
const { programId, campaignIds } = payload;

const { workspaceId } = await prisma.program.findUniqueOrThrow({
where: {
id: programId,
},
});

const { token } = await rewardfulImporter.getCredentials(workspaceId);

const rewardfulApi = new RewardfulApi({ token });

const campaigns = await rewardfulApi.listCampaigns();
const campaignsToImport = campaigns.filter((campaign) =>
campaignIds.includes(campaign.id),
);

for (const campaign of campaignsToImport) {
const {
id: campaignId,
commission_amount_cents,
minimum_payout_cents,
commission_percent,
max_commission_period_months,
days_until_commissions_are_due,
reward_type,
} = campaign;

const groupSlug = `rewardful-${campaignId}`;
const createdGroup = await prisma.partnerGroup.upsert({
where: {
programId_slug: {
programId,
slug: groupSlug,
},
},
update: {},
create: {
id: createId({ prefix: "grp_" }),
programId,
name: `(Rewardful) ${campaign.name}`,
slug: groupSlug,
color: randomValue(RESOURCE_COLORS),
},
});

console.log(
`Upserted group ${createdGroup.name} (${createdGroup.id}) matching Rewardful campaign ${campaign.name} (${campaignId}).`,
);

const createdSecondsAgo = differenceInSeconds(
new Date(),
createdGroup.createdAt,
);
console.log(
`This group was created ${createdSecondsAgo} seconds ago (most likely ${createdSecondsAgo < 10 ? "created" : "upserted"})`,
);

if (!createdGroup.saleRewardId) {
const createdReward = await prisma.reward.create({
data: {
id: createId({ prefix: "rw_" }),
programId,
// connect the reward to the group
salePartnerGroup: {
connect: {
id: createdGroup.id,
},
},
event: EventType.sale,
maxDuration: max_commission_period_months,
type:
reward_type === "amount"
? RewardStructure.flat
: RewardStructure.percentage,
amount:
reward_type === "amount"
? commission_amount_cents
: commission_percent,
},
});
console.log(
`Since group was newly created, also created reward ${createdReward.id} with amount ${createdReward.amount} and type ${createdReward.type}`,
);
}
Comment on lines +70 to +96
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify null handling for reward amounts.

Lines 88-90 assign amount based on reward_type, but don't explicitly handle cases where commission_amount_cents or commission_percent might be null or undefined. While Prisma may enforce non-null constraints, it's safer to validate or provide defaults.

Run the following script to check the Rewardful API response schema and Prisma schema:


🏁 Script executed:

#!/bin/bash
# Description: Verify nullability of commission fields in types and schema

# Check Rewardful type definitions
rg -nP --type=ts -A5 'interface.*RewardfulCampaign' 

# Check if these fields are nullable
rg -nP --type=ts 'commission_amount_cents|commission_percent' 

# Check Prisma schema for Reward.amount nullability
fd -e prisma -x cat {} | grep -A10 "model Reward"

Length of output: 1824


Add runtime checks for commission values
Ensure commission_amount_cents and commission_percent are not null/undefined (Prisma amount is non-nullable) by adding validation or defaulting to 0 before prisma.reward.create.


if (campaign.default) {
await prisma.program.update({
where: {
id: programId,
},
data: {
minPayoutAmount: minimum_payout_cents,
holdingPeriodDays: days_until_commissions_are_due,
},
});
console.log(
`Updated program ${programId} with min payout amount ${minimum_payout_cents} and holding period days ${days_until_commissions_are_due}`,
);
}
}

return await rewardfulImporter.queue({
...payload,
action: "import-partners",
});
}
12 changes: 6 additions & 6 deletions apps/web/lib/rewardful/import-commissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const toDubStatus: Record<RewardfulCommission["state"], CommissionStatus> = {
};

export async function importCommissions(payload: RewardfulImportPayload) {
const { importId, programId, userId, campaignId, page = 1 } = payload;
const { importId, programId, userId, campaignIds, page = 1 } = payload;

const program = await prisma.program.findUniqueOrThrow({
where: {
Expand Down Expand Up @@ -76,7 +76,7 @@ export async function importCommissions(payload: RewardfulImportPayload) {
createCommission({
commission,
program,
campaignId,
campaignIds,
fxRates,
importId,
customersData,
Expand Down Expand Up @@ -134,15 +134,15 @@ export async function importCommissions(payload: RewardfulImportPayload) {
async function createCommission({
commission,
program,
campaignId,
campaignIds,
fxRates,
importId,
customersData,
customerLeadEvents,
}: {
commission: RewardfulCommission;
program: Program;
campaignId: string;
campaignIds: string[];
fxRates: Record<string, string> | null;
importId: string;
customersData: (Customer & { link: Link | null })[];
Expand All @@ -156,9 +156,9 @@ async function createCommission({
entity_id: commission.id,
} as const;

if (commission.campaign.id !== campaignId) {
if (commission.campaign.id && !campaignIds.includes(commission.campaign.id)) {
console.log(
`Affiliate ${commission?.sale?.affiliate?.email} for commission ${commission.id}) not in campaign ${campaignId} (they're in ${commission.campaign.id}). Skipping...`,
`Affiliate ${commission?.sale?.affiliate?.email} for commission ${commission.id}) not in campaignIds (${campaignIds.join(", ")}) (they're in ${commission.campaign.id}). Skipping...`,
);

return;
Expand Down
Loading