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
4 changes: 4 additions & 0 deletions apps/web/app/(ee)/api/cron/import/rewardful/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { importAffiliateCoupons } from "@/lib/rewardful/import-affiliate-coupons";
import { importCampaigns } from "@/lib/rewardful/import-campaigns";
import { importCommissions } from "@/lib/rewardful/import-commissions";
import { importCustomers } from "@/lib/rewardful/import-customers";
Expand All @@ -23,6 +24,9 @@ export async function POST(req: Request) {
case "import-partners":
await importPartners(payload);
break;
case "import-affiliate-coupons":
await importAffiliateCoupons(payload);
break;
case "import-customers":
await importCustomers(payload);
break;
Expand Down
13 changes: 13 additions & 0 deletions apps/web/lib/rewardful/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DubApiError } from "@/lib/api/errors";
import {
RewardfulAffiliate,
RewardfulCoupon,
RewardfulCampaign,
RewardfulCommission,
RewardfulReferral,
Expand Down Expand Up @@ -93,4 +94,16 @@ export class RewardfulApi {

return data;
}

async listAffiliateCoupons({ page = 1 }: { page?: number }) {
const searchParams = new URLSearchParams();
searchParams.append("page", page.toString());
searchParams.append("limit", PAGE_LIMIT.toString());

const { data } = await this.fetch<{ data: RewardfulCoupon[] }>(
`${this.baseUrl}/affiliate_coupons?${searchParams.toString()}`,
);

return data;
}
}
122 changes: 122 additions & 0 deletions apps/web/lib/rewardful/import-affiliate-coupons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { prisma } from "@dub/prisma";
import { bulkCreateLinks } from "../api/links";
import { redis } from "../upstash";
import { RewardfulApi } from "./api";
import { MAX_BATCHES, rewardfulImporter } from "./importer";
import { RewardfulImportPayload } from "./types";

export async function importAffiliateCoupons(payload: RewardfulImportPayload) {
const { programId, userId, page = 1 } = payload;

const program = await prisma.program.findUniqueOrThrow({
where: {
id: programId,
},
select: {
id: true,
workspaceId: true,
domain: true,
url: true,
defaultFolderId: true,
},
});

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

const rewardfulApi = new RewardfulApi({ token });

let currentPage = page;
let hasMore = true;
let processedBatches = 0;

while (hasMore && processedBatches < MAX_BATCHES) {
const affiliateCoupons = await rewardfulApi.listAffiliateCoupons({
page: currentPage,
});

if (affiliateCoupons.length === 0) {
hasMore = false;
break;
}

const affiliateIds = affiliateCoupons.map(
(affiliateCoupon) => affiliateCoupon.affiliate_id,
);

const results = await redis.hmget<Record<string, string>>(
`rewardful:affiliates:${program.id}`,
...affiliateIds,
);

const filteredPartners = Object.fromEntries(
Object.entries(results ?? {}).filter(
([_, value]) => value !== null && value !== undefined,
),
);

const affiliateIdToCouponsMap = affiliateCoupons.reduce(
(acc, coupon) => {
if (!acc[coupon.affiliate_id]) {
acc[coupon.affiliate_id] = [];
}

acc[coupon.affiliate_id].push(coupon);
return acc;
},
{} as Record<string, typeof affiliateCoupons>,
);

if (Object.keys(filteredPartners).length > 0) {
const linksToCreate: any[] = [];

for (const [affiliateId, partnerId] of Object.entries(filteredPartners)) {
const coupons = affiliateIdToCouponsMap[affiliateId];

if (!coupons) {
continue;
}

const activeCoupons = affiliateCoupons.filter(
(coupon) => !coupon.archived,
);

if (activeCoupons.length === 0) {
continue;
}

linksToCreate.push(
...activeCoupons.map((coupon) => ({
domain: program.domain,
key: coupon.token,
url: program.url,
trackConversion: true,
programId,
partnerId,
folderId: program.defaultFolderId,
userId,
projectId: program.workspaceId,
})),
);
}

await bulkCreateLinks({
links: linksToCreate,
});
}

currentPage++;
processedBatches++;
}

if (!hasMore) {
await redis.del(`rewardful:affiliates:${program.id}`);
}

const action = hasMore ? "import-affiliate-coupons" : "import-customers";

await rewardfulImporter.queue({
...payload,
action,
page: hasMore ? currentPage : undefined,
});
}
2 changes: 2 additions & 0 deletions apps/web/lib/rewardful/import-campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export async function importCampaigns(payload: RewardfulImportPayload) {
new Date(),
createdGroup.createdAt,
);

console.log(
`This group was created ${createdSecondsAgo} seconds ago (most likely ${createdSecondsAgo < 10 ? "created" : "upserted"})`,
);
Expand Down Expand Up @@ -90,6 +91,7 @@ export async function importCampaigns(payload: RewardfulImportPayload) {
: commission_percent,
},
});

console.log(
`Since group was newly created, also created reward ${createdReward.id} with amount ${createdReward.amount} and type ${createdReward.type}`,
);
Expand Down
4 changes: 3 additions & 1 deletion apps/web/lib/rewardful/import-commissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ async function createCommission({
entity_id: commission.id,
} as const;

if (commission.campaign.id && !campaignIds.includes(commission.campaign.id)) {
const campaignId = commission.sale.affiliate.campaign?.id;

if (campaignId && !campaignIds.includes(campaignId)) {
console.log(
`Affiliate ${commission?.sale?.affiliate?.email} for commission ${commission.id}) not in campaignIds (${campaignIds.join(", ")}) (they're in ${commission.campaign.id}). Skipping...`,
);
Expand Down
15 changes: 11 additions & 4 deletions apps/web/lib/rewardful/import-customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,21 +99,28 @@ async function createCustomer({
entity_id: referralId,
} as const;

if (!referral.link) {
if (!referral.link && !referral.coupon) {
await logImportError({
...commonImportLogInputs,
code: "LINK_NOT_FOUND",
message: `Link not found for referral ${referralId} (could've been a coupon-based referral).`,
message: `Link or coupon not found for referral ${referralId}.`,
});

return;
}

const shortLinkToken = referral.link?.token || referral.coupon?.token;

if (!shortLinkToken) {
console.error(`Short link token not found for referral ${referralId}.`);
return;
}
Comment on lines +112 to +117
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use structured error logging for consistency.

While the validation logic is sound, the error handling here uses console.error instead of logImportError, which is inconsistent with the error-handling pattern used elsewhere in this function (e.g., lines 103-107, 129-133, 142-146).

Apply this diff to maintain consistent error logging:

   const shortLinkToken = referral.link?.token || referral.coupon?.token;
 
   if (!shortLinkToken) {
-    console.error(`Short link token not found for referral ${referralId}.`);
+    await logImportError({
+      ...commonImportLogInputs,
+      code: "LINK_TOKEN_NOT_FOUND",
+      message: `Link token not found for referral ${referralId}.`,
+    });
     return;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const shortLinkToken = referral.link?.token || referral.coupon?.token;
if (!shortLinkToken) {
console.error(`Short link token not found for referral ${referralId}.`);
return;
}
const shortLinkToken = referral.link?.token || referral.coupon?.token;
if (!shortLinkToken) {
await logImportError({
...commonImportLogInputs,
code: "LINK_TOKEN_NOT_FOUND",
message: `Link token not found for referral ${referralId}.`,
});
return;
}
🤖 Prompt for AI Agents
In apps/web/lib/rewardful/import-customers.ts around lines 112 to 117, replace
the console.error usage with the module's structured logger to match the rest of
the function: call logImportError(referralId, `Short link token not found for
referral ${referralId}.`) (or the existing logImportError signature used
elsewhere) and then return, so the error is recorded consistently with other
failures in this file.


const link = await prisma.link.findUnique({
where: {
domain_key: {
domain: program.domain!,
key: referral.link.token,
key: shortLinkToken,
},
},
});
Expand All @@ -122,7 +129,7 @@ async function createCustomer({
await logImportError({
...commonImportLogInputs,
code: "LINK_NOT_FOUND",
message: `Link not found for referral ${referralId} (token: ${referral.link.token}).`,
message: `Link not found for referral ${referralId} (token: ${shortLinkToken}).`,
});

return;
Expand Down
27 changes: 25 additions & 2 deletions apps/web/lib/rewardful/import-partners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { nanoid } from "@dub/utils";
import { createId } from "../api/create-id";
import { bulkCreateLinks } from "../api/links";
import { logImportError } from "../tinybird/log-import-error";
import { redis } from "../upstash";
import { RewardfulApi } from "./api";
import { MAX_BATCHES, rewardfulImporter } from "./importer";
import { RewardfulAffiliate, RewardfulImportPayload } from "./types";
Expand Down Expand Up @@ -72,10 +73,11 @@ export async function importPartners(payload: RewardfulImportPayload) {
}

if (activeAffiliates.length > 0) {
await Promise.all(
const partners = await Promise.all(
activeAffiliates.map((affiliate) => {
const groupId = campaignIdToGroupMap[affiliate.campaign.id];
const group = program.groups.find((group) => group.id === groupId);

if (!group) {
console.error(
`Group not found for campaign ${affiliate.campaign.id}`,
Expand All @@ -102,6 +104,22 @@ export async function importPartners(payload: RewardfulImportPayload) {
});
}),
);

const filteredPartners = partners.filter(
(p): p is NonNullable<typeof p> => p !== undefined,
);

if (filteredPartners.length > 0) {
await redis.hset(
`rewardful:affiliates:${program.id}`,
Object.fromEntries(
filteredPartners.map((p) => [
p.rewardfulAffiliateId,
p.dubPartnerId,
]),
),
);
}
}

if (notImportedAffiliates.length > 0) {
Expand All @@ -119,7 +137,7 @@ export async function importPartners(payload: RewardfulImportPayload) {
processedBatches++;
}

const action = hasMore ? "import-partners" : "import-customers";
const action = hasMore ? "import-partners" : "import-affiliate-coupons";

await rewardfulImporter.queue({
...payload,
Expand Down Expand Up @@ -206,4 +224,9 @@ async function createPartnerAndLinks({
partnerGroupDefaultLinkId: idx === 0 ? partnerGroupDefaultLinkId : null,
})),
});

return {
rewardfulAffiliateId: affiliate.id,
dubPartnerId: partner.id,
};
}
1 change: 1 addition & 0 deletions apps/web/lib/rewardful/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from "zod";
export const rewardfulImportSteps = z.enum([
"import-campaigns",
"import-partners",
"import-affiliate-coupons",
"import-customers",
"import-commissions",
]);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/lib/rewardful/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface RewardfulAffiliate {
export interface RewardfulReferral {
id: string;
link?: RewardfulLink; // could be null for coupon-based referrals
coupon?: RewardfulCoupon; // could be null for link-based referrals
customer: RewardfulCustomer;
affiliate: RewardfulAffiliate;
created_at: string;
Expand Down Expand Up @@ -100,6 +101,15 @@ export interface RewardfulCommission {
sale: RewardfulCommissionSale;
}

export interface RewardfulCoupon {
id: string;
external_id: string;
archived: boolean;
archived_at: string;
token: string;
affiliate_id: string;
}

export type RewardfulImportPayload = z.infer<
typeof rewardfulImportPayloadSchema
>;