Skip to content

Commit cb1aa86

Browse files
authored
Merge pull request dubinc#3223 from dubinc/aggregate-manual-commissions
Aggregate manual commissions when they're created
2 parents 0f9499d + 6451c59 commit cb1aa86

3 files changed

Lines changed: 225 additions & 1 deletion

File tree

apps/web/app/(ee)/api/cron/payouts/aggregate-due-commissions/route.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@ import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
55
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
66
import { prisma } from "@dub/prisma";
77
import { APP_DOMAIN_WITH_NGROK, chunk, log } from "@dub/utils";
8+
import { z } from "zod";
89
import { logAndRespond } from "../../utils";
910

1011
export const dynamic = "force-dynamic";
1112

1213
const BATCH_SIZE = 1000;
1314

15+
const schema = z.object({
16+
programId: z.string().optional().describe("Optional program ID to filter by"),
17+
});
18+
1419
// This cron job aggregates due commissions (pending commissions that are past the partner group's holding period) into payouts.
1520
// Runs once every hour (0 * * * *) + calls itself recursively to look through all pending commissions available.
1621
async function handler(req: Request) {
1722
try {
23+
let programId: string | undefined = undefined;
24+
1825
if (req.method === "GET") {
1926
await verifyVercelSignature(req);
2027
} else if (req.method === "POST") {
@@ -23,10 +30,13 @@ async function handler(req: Request) {
2330
req,
2431
rawBody,
2532
});
33+
34+
({ programId } = schema.parse(JSON.parse(rawBody)));
2635
}
2736

2837
const partnerGroupsByHoldingPeriod = await prisma.partnerGroup.groupBy({
2938
by: ["holdingPeriodDays"],
39+
...(programId ? { where: { programId } } : {}),
3040
_count: {
3141
id: true,
3242
},
@@ -44,6 +54,7 @@ async function handler(req: Request) {
4454
const partnerGroups = await prisma.partnerGroup.findMany({
4555
where: {
4656
holdingPeriodDays,
57+
...(programId ? { programId } : {}),
4758
},
4859
select: {
4960
id: true,

apps/web/lib/actions/partners/create-manual-commission.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { syncPartnerLinksStats } from "@/lib/api/partners/sync-partner-links-sta
77
import { getDefaultProgramIdOrThrow } from "@/lib/api/programs/get-default-program-id-or-throw";
88
import { getProgramEnrollmentOrThrow } from "@/lib/api/programs/get-program-enrollment-or-throw";
99
import { executeWorkflows } from "@/lib/api/workflows/execute-workflows";
10+
import { qstash } from "@/lib/cron";
1011
import {
1112
createPartnerCommission,
1213
CreatePartnerCommissionProps,
@@ -23,7 +24,7 @@ import { leadEventSchemaTB } from "@/lib/zod/schemas/leads";
2324
import { saleEventSchemaTB } from "@/lib/zod/schemas/sales";
2425
import { prisma } from "@dub/prisma";
2526
import { WorkflowTrigger } from "@dub/prisma/client";
26-
import { nanoid } from "@dub/utils";
27+
import { APP_DOMAIN_WITH_NGROK, nanoid, prettyPrint } from "@dub/utils";
2728
import { COUNTRIES_TO_CONTINENTS } from "@dub/utils/src";
2829
import { waitUntil } from "@vercel/functions";
2930
import { z } from "zod";
@@ -100,6 +101,8 @@ export const createManualCommissionAction = authActionClient
100101
description,
101102
});
102103

104+
waitUntil(triggerAggregateDueCommissionsCronJob(programId));
105+
103106
return;
104107
}
105108

@@ -578,6 +581,20 @@ export const createManualCommissionAction = authActionClient
578581
}),
579582
]);
580583
}
584+
585+
await triggerAggregateDueCommissionsCronJob(programId);
581586
})(),
582587
);
583588
});
589+
590+
async function triggerAggregateDueCommissionsCronJob(programId: string) {
591+
const qstashResponse = await qstash.publishJSON({
592+
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/payouts/aggregate-due-commissions`,
593+
body: {
594+
programId,
595+
},
596+
});
597+
console.log(
598+
`Triggered aggregate due commissions cron job for program ${programId}: ${prettyPrint(qstashResponse)}`,
599+
);
600+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { anthropic } from "@ai-sdk/anthropic";
2+
import { prisma } from "@dub/prisma";
3+
import { Category } from "@dub/prisma/client";
4+
import FireCrawlApp from "@mendable/firecrawl-js";
5+
import { generateObject } from "ai";
6+
import "dotenv-flow/config";
7+
import { z } from "zod";
8+
9+
const CategoryEnum = z.nativeEnum(Category);
10+
11+
// AI response schema
12+
const categorizationSchema = z.object({
13+
categories: z.array(CategoryEnum).min(1).max(3),
14+
reasoning: z.string(),
15+
});
16+
17+
// Result interface
18+
interface ProgramResult {
19+
programId: string;
20+
programName: string;
21+
categories: Category[];
22+
url?: string;
23+
error?: string;
24+
}
25+
26+
if (!process.env.FIRECRAWL_API_KEY)
27+
throw new Error("FIRECRAWL_API_KEY is not set");
28+
29+
// Initialize FireCrawl
30+
const firecrawl = new FireCrawlApp({
31+
apiKey: process.env.FIRECRAWL_API_KEY,
32+
});
33+
34+
// Function to scrape website content
35+
async function scrapeWebsite(url: string) {
36+
try {
37+
const scrapeResult = await firecrawl.scrapeUrl(url, {
38+
formats: ["markdown"],
39+
onlyMainContent: true,
40+
parsePDF: false,
41+
maxAge: 14400000, // 4 hours cache
42+
excludeTags: ["img"],
43+
});
44+
45+
if (!scrapeResult.success) {
46+
throw new Error(scrapeResult.error || "Failed to scrape");
47+
}
48+
49+
return {
50+
content: scrapeResult.markdown || "",
51+
title: scrapeResult.metadata?.title || "",
52+
description: scrapeResult.metadata?.description || "",
53+
};
54+
} catch (error) {
55+
console.error(`Error scraping ${url}:`, error);
56+
return null;
57+
}
58+
}
59+
60+
// Function to categorize content using AI
61+
async function categorizeProgram(
62+
programName: string,
63+
url: string,
64+
content: string,
65+
title: string,
66+
description: string,
67+
) {
68+
try {
69+
const prompt = `Analyze this website and categorize it into 1-3 most relevant categories.
70+
71+
IMPORTANT: You must select categories from this EXACT list (case-sensitive):
72+
- Artificial_Intelligence
73+
- Development
74+
- Design
75+
- Productivity
76+
- Finance
77+
- Marketing
78+
- Ecommerce
79+
- Security
80+
- Education
81+
- Health
82+
- Consumer
83+
84+
Category descriptions:
85+
- Artificial_Intelligence: AI/ML tools, chatbots, automation, machine learning platforms
86+
- Development: Code tools, APIs, developer platforms, programming resources
87+
- Design: Design tools, UI/UX, creative software, graphics
88+
- Productivity: Task management, collaboration, workflow tools, organization
89+
- Finance: Financial services, payments, accounting, investment, banking
90+
- Marketing: Marketing tools, analytics, advertising, social media, SEO
91+
- Ecommerce: Online stores, commerce platforms, marketplaces, retail
92+
- Security: Cybersecurity, privacy, protection tools, data security
93+
- Education: Learning platforms, courses, educational content, training
94+
- Health: Healthcare, fitness, wellness apps, medical services
95+
- Consumer: General consumer products/services that don't fit other categories
96+
97+
CRITICAL: Only use the exact category names listed above. DO NOT create new categories or modify existing ones. Do not select "Entrepreneurship" or "Business" as a category.
98+
99+
Website information:
100+
Name: ${programName}
101+
Website URL: ${url}
102+
Page Title: ${title}
103+
Meta Description: ${description}
104+
Website Content Preview: ${content.slice(0, 300)}...`;
105+
106+
const { object } = await generateObject({
107+
model: anthropic("claude-sonnet-4-20250514"),
108+
schema: categorizationSchema,
109+
prompt,
110+
});
111+
112+
return object.categories;
113+
} catch (error) {
114+
// console.error(`Error categorizing ${programName}:`, error);
115+
116+
// If it's a validation error (invalid enum values), return empty array
117+
if (
118+
error?.name === "AI_NoObjectGeneratedError" ||
119+
error?.cause?.name === "AI_TypeValidationError"
120+
) {
121+
console.log(
122+
` Invalid categories returned for ${programName}, skipping categorization`,
123+
);
124+
return []; // Return empty array for invalid categories
125+
}
126+
127+
return []; // Return empty array for any other errors too
128+
}
129+
}
130+
131+
// Main processing function
132+
async function main() {
133+
const program = await prisma.program.findUniqueOrThrow({
134+
where: {
135+
slug: "",
136+
url: {
137+
not: null,
138+
},
139+
},
140+
select: {
141+
id: true,
142+
name: true,
143+
url: true,
144+
},
145+
});
146+
147+
// Scrape website
148+
console.log(`Scraping: ${program.url}...`);
149+
150+
const scraped = await scrapeWebsite(program.url!); // already filtered above
151+
152+
if (!scraped) {
153+
throw new Error("Failed to scrape website");
154+
}
155+
156+
console.log(`Description: ${scraped.description}`);
157+
158+
// Categorize with AI
159+
console.log(`Analyzing content...`);
160+
const categories = await categorizeProgram(
161+
program.name,
162+
program.url!, // already filtered above
163+
scraped.content,
164+
scraped.title,
165+
scraped.description,
166+
);
167+
168+
console.log(
169+
`Categories: ${categories.length > 0 ? categories.join(", ") : "None (invalid/failed categorization)"}`,
170+
);
171+
172+
const res = await prisma.program.update({
173+
where: { id: program.id },
174+
data: {
175+
addedToMarketplaceAt: new Date(),
176+
description: scraped.description,
177+
categories: {
178+
deleteMany: {},
179+
create: categories.map((category) => ({ category })),
180+
},
181+
},
182+
});
183+
184+
console.log(
185+
`Added ${res.name} to the marketplace with description: ${res.description}`,
186+
);
187+
}
188+
189+
main()
190+
.catch((error) => {
191+
console.error("Script failed:", error);
192+
process.exit(1);
193+
})
194+
.finally(async () => {
195+
await prisma.$disconnect();
196+
});

0 commit comments

Comments
 (0)