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
159 changes: 102 additions & 57 deletions apps/web/app/(ee)/api/cron/aggregate-clicks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,47 @@ import { createId } from "@/lib/api/create-id";
import { handleAndReturnErrorResponse } from "@/lib/api/errors";
import { serializeReward } from "@/lib/api/partners/serialize-reward";
import { syncTotalCommissions } from "@/lib/api/partners/sync-total-commissions";
import { qstash } from "@/lib/cron";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { verifyVercelSignature } from "@/lib/cron/verify-vercel";
import { getRewardAmount } from "@/lib/partners/get-reward-amount";
import { analyticsResponse } from "@/lib/zod/schemas/analytics-response";
import { prisma } from "@dub/prisma";
import { CommissionType, Prisma } from "@dub/prisma/client";
import { log } from "@dub/utils";
import { NextResponse } from "next/server";
import { APP_DOMAIN_WITH_NGROK, nFormatter } from "@dub/utils";
import { z } from "zod";
import { logAndRespond } from "../utils";

export const dynamic = "force-dynamic";

const BATCH_SIZE = 200;

const schema = z.object({
startingAfter: z.string().optional(),
batchNumber: z.number().optional().default(1),
});

// This route is used aggregate clicks events on daily basis for Program links and add to the Commission table
// Runs every day at 00:00 (0 0 * * *)
// GET /api/cron/aggregate-clicks
export async function GET(req: Request) {
async function handler(req: Request) {
try {
await verifyVercelSignature(req);
let { startingAfter, batchNumber } = schema.parse({
startingAfter: undefined,
batchNumber: 1,
});

if (req.method === "GET") {
await verifyVercelSignature(req);
} else if (req.method === "POST") {
const rawBody = await req.text();
await verifyQstashSignature({
req,
rawBody,
});

({ startingAfter, batchNumber } = schema.parse(JSON.parse(rawBody)));
}

const now = new Date();

Expand All @@ -33,84 +57,83 @@ export async function GET(req: Request) {
end.setDate(end.getDate() - 1);
end.setHours(23, 59, 59, 999);

const clickRewardsWithEnrollments = await prisma.reward.findMany({
const linksWithClickRewards = await prisma.link.findMany({
where: {
event: "click",
programEnrollment: {
clickRewardId: {
not: null,
},
},
clicks: {
gt: 0,
},
lastClicked: {
gte: start, // links that were clicked on after the start date
},
},
include: {
clickEnrollments: {
include: {
links: {
where: {
clicks: {
gt: 0,
},
lastClicked: {
gte: start, // links that were clicked on after the start date
},
},
},
select: {
id: true,
programId: true,
partnerId: true,
programEnrollment: {
select: {
clickReward: true,
},
},
},
take: BATCH_SIZE,
skip: startingAfter ? 1 : 0,
...(startingAfter && {
cursor: {
id: startingAfter,
},
}),
orderBy: {
id: "asc",
},
});

if (!clickRewardsWithEnrollments.length) {
return NextResponse.json({
message: "No programs with click rewards found. Skipping...",
});
}
const endMessage = `Finished aggregating clicks for ${batchNumber} batches (total ${nFormatter(batchNumber * (BATCH_SIZE - 1) + linksWithClickRewards.length, { full: true })} links)`;

// getting a list of links with clicks
const linkWithClicks = clickRewardsWithEnrollments.flatMap(
({ clickEnrollments }) => clickEnrollments.flatMap(({ links }) => links),
);

if (linkWithClicks.length === 0) {
return NextResponse.json({
message: "No links with clicks found. Skipping...",
});
if (linksWithClickRewards.length === 0) {
return logAndRespond(endMessage);
}

console.time("getAnalytics");
// getting the actual click count for the links for the previous day
const linkClickStats: z.infer<(typeof analyticsResponse)["top_links"]>[] =
await getAnalytics({
event: "clicks",
groupBy: "top_links",
linkIds: linkWithClicks.map(({ id }) => id),
linkIds: linksWithClickRewards.map(({ id }) => id),
start,
end,
});
console.timeEnd("getAnalytics");

// This should never happen, but just in case
if (linkClickStats.length === 0) {
await log({
message:
"Failed to get link click stats from Tinybird. Needs investigation.",
type: "errors",
mention: true,
});
throw new Error("Failed to get link click stats from Tinybird.");
return logAndRespond(endMessage);
}

// creating a map of link id to reward (for easy lookup)
const linkRewardMap = new Map(
clickRewardsWithEnrollments.flatMap(({ clickEnrollments, ...reward }) =>
clickEnrollments.flatMap(({ links }) =>
links.map(({ id }) => [id, reward]),
),
),
);
// creating a map of link id to clicks count (for easy lookup)
const linkClicksMap = new Map(
linkClickStats.map(({ id, clicks }) => [id, clicks]),
);

// creating a list of commissions to create
const commissionsToCreate = linkWithClicks
.map(({ id, programId, partnerId }) => {
const commissionsToCreate = linksWithClickRewards
.map(({ id, programId, partnerId, programEnrollment }) => {
if (!programId || !partnerId || !programEnrollment?.clickReward) {
return null;
}

const linkClicks = linkClicksMap.get(id) ?? 0;
const reward = linkRewardMap.get(id);
if (!programId || !partnerId || linkClicks === 0 || !reward) {
const earnings =
getRewardAmount(serializeReward(programEnrollment.clickReward)) *
linkClicks;

if (linkClicks === 0 || earnings === 0) {
return null;
}

Expand All @@ -122,14 +145,16 @@ export async function GET(req: Request) {
quantity: linkClicks,
type: CommissionType.click,
amount: 0,
earnings: getRewardAmount(serializeReward(reward)) * linkClicks,
earnings,
};
})
.filter((c) => c !== null) as Prisma.CommissionCreateManyInput[];
.filter(
(c): c is NonNullable<typeof c> => c !== null,
) satisfies Prisma.CommissionCreateManyInput[];

console.table(commissionsToCreate);

// // Create commissions
// Create commissions
await prisma.commission.createMany({
data: commissionsToCreate,
});
Expand All @@ -145,8 +170,28 @@ export async function GET(req: Request) {
`Synced total commissions count for ${commissionsToCreate.length} partners`,
);

return NextResponse.json("OK");
if (linksWithClickRewards.length === BATCH_SIZE) {
const nextStartingAfter =
linksWithClickRewards[linksWithClickRewards.length - 1].id;

await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/aggregate-clicks`,
method: "POST",
body: {
startingAfter: nextStartingAfter,
batchNumber: batchNumber + 1,
},
});

return logAndRespond(
`Enqueued next batch (batch #${batchNumber + 1} for aggregate clicks cron (startingAfter: ${nextStartingAfter}).`,
);
}

return logAndRespond(endMessage);
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}

export { handler as GET, handler as POST };
72 changes: 72 additions & 0 deletions apps/web/scripts/perplexity/partners-updated-countries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { partnerProfileChangeHistoryLogSchema } from "@/lib/zod/schemas/partner-profile";
import { prisma } from "@dub/prisma";
import "dotenv-flow/config";
import * as fs from "fs";
import * as Papa from "papaparse";

const programId = "prog_xxx";

async function main() {
const partners = await prisma.partner.findMany({
where: {
programs: {
some: {
programId,
status: "approved",
},
},
country: {
not: "US",
},
changeHistoryLog: {
path: "$[*].from",
array_contains: "US",
},
commissions: {
some: {
programId,
status: {
in: ["pending", "processed"],
},
},
},
},
include: {
commissions: {
where: {
programId,
status: {
in: ["pending", "processed"],
},
},
},
},
});

const finalPartners = partners.map((p) => {
const changeHistoryLog = partnerProfileChangeHistoryLogSchema.parse(
p.changeHistoryLog,
);
const finalCountryChange = changeHistoryLog
.filter((ch) => ch.field === "country") // filter by country field
.sort((a, b) => b.changedAt.getTime() - a.changedAt.getTime())[0]; // sort by changedAt descending
return {
id: p.id,
email: p.email,
pendingCommissions: p.commissions.reduce(
(acc, commission) => acc + commission.earnings,
0,
),
changedFrom: finalCountryChange?.from,
changedTo: finalCountryChange?.to,
currentCountry: p.country,
changedAt: finalCountryChange?.changedAt,
};
});

console.table(finalPartners);

fs.writeFileSync("output.csv", Papa.unparse(finalPartners));
}

main();