Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
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
>;
44 changes: 11 additions & 33 deletions apps/web/scripts/migrations/migrate-sales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,44 +4,22 @@ import { EventType } from "@dub/prisma/client";
import "dotenv-flow/config";

async function main() {
// @ts-ignore (old sales table)
const sales = await prisma.sale.findMany({
select: {
id: true,
programId: true,
partnerId: true,
linkId: true,
payoutId: true,
invoiceId: true,
customerId: true,
eventId: true,
amount: true,
earnings: true,
currency: true,
status: true,
createdAt: true,
updatedAt: true,
const program = await prisma.program.findUniqueOrThrow({
where: {
id: "prog_1K2J9DRWPPJ2F1RX53N92TSGA",
},
take: 1000,
skip: 2500,
});

if (!sales.length) {
console.log("No sales found.");
return;
}

await prisma.commission.createMany({
data: sales.map((sale) => ({
...sale,
id: createId({ prefix: "cm_" }),
type: EventType.sale,
quantity: 1,
})),
skipDuplicates: true,
const link = await prisma.link.findUnique({
where: {
domain_key: {
domain: program.domain!,
key: "BESTPIC",
},
},
});

console.log(`Migrated ${sales.length} sales.`);
console.log(link)
}
Comment on lines 6 to 45
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 | 🔴 Critical

🧩 Analysis chain

Debug code should not be committed to the repository.

This migration script has been replaced with what appears to be test or debug code that:

  • Uses hardcoded program ID and link key
  • Only fetches and logs a single link
  • Contains no actual migration logic

This should either be reverted to its original state, removed from the PR, or explained if there's a specific reason for this change.

Please clarify the intent of this change. If this was meant for local testing, it should not be included in the PR.


I'll investigate the migration script to verify the review comment. Let me gather information about the file's history and context.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check git diff for the migration file to see what changed
git diff HEAD -- apps/web/scripts/migrations/migrate-sales.ts || true

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Check git log to see recent changes to this file
git log --oneline -n 20 -- apps/web/scripts/migrations/migrate-sales.ts || true

Length of output: 106


🏁 Script executed:

#!/bin/bash
# Find other migration scripts to compare pattern
fd -t f 'migrate-.*\.ts$' apps/web/scripts/migrations/ || fd -t f '.*migration.*\.ts$' apps/web/scripts/

Length of output: 759


🏁 Script executed:

#!/bin/bash
# Show the complete current content of the migration file
cat apps/web/scripts/migrations/migrate-sales.ts || echo "File not found"

Length of output: 507


🏁 Script executed:

#!/bin/bash
# Check for any test or commented-out code patterns in the file
grep -n "test\|TODO\|FIXME\|debug\|temporary" apps/web/scripts/migrations/migrate-sales.ts -i || echo "No markers found"

Length of output: 16


I need to compare with other migration files to understand the expected pattern and clarify the file's status in this PR.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check git status to see if file is new, modified, or untracked in current branch
git status -- apps/web/scripts/migrations/migrate-sales.ts

Length of output: 63


🏁 Script executed:

#!/bin/bash
# Examine 2 other migration files to understand expected pattern
echo "=== migrate-rewards.ts ===" && head -50 apps/web/scripts/migrations/migrate-rewards.ts && echo -e "\n=== migrate-discounts.ts ===" && head -50 apps/web/scripts/migrations/migrate-discounts.ts

Length of output: 2296


🏁 Script executed:

#!/bin/bash
# Check if migrate-sales.ts exists in HEAD (to verify if it's new or modified)
git show HEAD:apps/web/scripts/migrations/migrate-sales.ts 2>/dev/null | head -40 || echo "File does not exist in HEAD"

Length of output: 507


I need to understand the commit history context better to determine if this is a new issue in this PR or a pre-existing problem.


---

🏁 Script executed:

```shell
#!/bin/bash
# Show the commit that introduced/modified this file
git log -p --follow -n 5 -- apps/web/scripts/migrations/migrate-sales.ts | head -100

Length of output: 59


🏁 Script executed:

#!/bin/bash
# Check what branch we're on and compare to main
git log --oneline -n 1 && echo "---" && git log --oneline origin/main -n 1 2>/dev/null || echo "Cannot access origin/main"

Length of output: 114


🏁 Script executed:

#!/bin/bash
# Show commits in current branch that aren't in main
git log --oneline origin/main..HEAD -- apps/web/scripts/migrations/migrate-sales.ts 2>/dev/null || echo "Cannot compare or no changes in this file since main"

Length of output: 106


Remove or replace debug code in migration script.

This file is part of the current PR branch and contains incomplete code. Unlike other migration scripts in the repository (e.g., migrate-rewards.ts, migrate-discounts.ts), this file:

  • Lacks explanatory comments documenting the migration purpose
  • Contains hardcoded IDs instead of querying dynamically
  • Only fetches and logs data with no transformation or migration operations
  • Performs no actual data updates, deletes, or transformations

Replace with actual migration logic or remove the file if no migration is needed.

🤖 Prompt for AI Agents
In apps/web/scripts/migrations/migrate-sales.ts around lines 6 to 23, the script
contains leftover debug code: hardcoded program ID, a single read of a link, and
a console.log with no migration, comments, or dynamic querying; either implement
the intended migration or remove the file. To fix, replace the debug block with
a properly documented migration function (add a descriptive header comment
explaining purpose and safe rollback notes), query programs/links dynamically
(avoid hardcoded IDs—use criteria or iterate over applicable rows), perform the
necessary update/delete/transform operations using prisma transactions, handle
and log errors, and include a dry-run or confirmation step; if no migration is
required, delete this file from the branch to avoid shipping incomplete code.


main();
Loading