Skip to content

Commit

Permalink
Merge branch 'main' into optimize-analytics
Browse files Browse the repository at this point in the history
  • Loading branch information
steven-tey authored Mar 3, 2025
2 parents c032855 + 563b020 commit d245bba
Show file tree
Hide file tree
Showing 16 changed files with 388 additions and 52 deletions.
6 changes: 4 additions & 2 deletions apps/web/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
analyticsPathParamsSchema,
analyticsQuerySchema,
} from "@/lib/zod/schemas/analytics";
import { Link } from "@dub/prisma/client";
import { Folder, Link } from "@dub/prisma/client";
import { NextResponse } from "next/server";

// GET /api/analytics – get analytics
Expand Down Expand Up @@ -64,8 +64,9 @@ export const GET = withWorkspace(

const folderIdToVerify = link?.folderId || folderId;

let selectedFolder: Pick<Folder, "id" | "type"> | null = null;
if (folderIdToVerify) {
await verifyFolderAccess({
selectedFolder = await verifyFolderAccess({
workspace,
userId: session.user.id,
folderId: folderIdToVerify,
Expand Down Expand Up @@ -106,6 +107,7 @@ export const GET = withWorkspace(
isDeprecatedClicksEndpoint,
dataAvailableFrom: workspace.createdAt,
folderIds,
isMegaFolder: selectedFolder?.type === "mega",
});

return NextResponse.json(response);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/app/api/events/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { throwIfClicksUsageExceeded } from "@/lib/api/links/usage-checks";
import { withWorkspace } from "@/lib/auth";
import { verifyFolderAccess } from "@/lib/folder/permissions";
import { eventsQuerySchema } from "@/lib/zod/schemas/analytics";
import { Link } from "@dub/prisma/client";
import { Folder, Link } from "@dub/prisma/client";
import { NextResponse } from "next/server";

export const GET = withWorkspace(
Expand Down Expand Up @@ -46,8 +46,9 @@ export const GET = withWorkspace(

const folderIdToVerify = link?.folderId || folderId;

let selectedFolder: Pick<Folder, "id" | "type"> | null = null;
if (folderIdToVerify) {
await verifyFolderAccess({
selectedFolder = await verifyFolderAccess({
workspace,
userId: session.user.id,
folderId: folderIdToVerify,
Expand Down Expand Up @@ -78,6 +79,7 @@ export const GET = withWorkspace(
workspaceId: workspace.id,
folderIds,
folderId: folderId || "",
isMegaFolder: selectedFolder?.type === "mega",
});

return NextResponse.json(response);
Expand Down
113 changes: 113 additions & 0 deletions apps/web/app/api/partners/sales/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { DubApiError } from "@/lib/api/errors";
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
import { calculateSaleEarnings } from "@/lib/api/sales/calculate-sale-earnings";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth/workspace";
import { determinePartnerReward } from "@/lib/partners/determine-partner-reward";
import { updatePartnerSaleSchema } from "@/lib/zod/schemas/partners";
import { ProgramSaleSchema } from "@/lib/zod/schemas/program-sales";
import { prisma } from "@dub/prisma";
import { NextResponse } from "next/server";

// PATCH /api/partners/sales - update a sale
export const PATCH = withWorkspace(
async ({ req, workspace }) => {
const { programId, invoiceId, amount } = updatePartnerSaleSchema.parse(
await parseRequestBody(req),
);

const program = await getProgramOrThrow({
workspaceId: workspace.id,
programId,
});

const sale = await prisma.commission.findUnique({
where: {
programId_invoiceId: {
programId: program.id,
invoiceId,
},
},
include: {
partner: true,
},
});

if (!sale) {
throw new DubApiError({
code: "not_found",
message: `Sale with invoice ID ${invoiceId} not found for program ${programId}.`,
});
}

if (sale.status === "paid") {
throw new DubApiError({
code: "bad_request",
message: `Cannot update amount: Sale with invoice ID ${invoiceId} has already been paid.`,
});
}

const { partner } = sale;

const reward = await determinePartnerReward({
event: "sale",
partnerId: partner.id,
programId: program.id,
});

if (!reward) {
throw new DubApiError({
code: "not_found",
message: `No reward found for partner ${partner.id} in program ${program.id}.`,
});
}

// Recalculate the earnings based on the new amount
const earnings = calculateSaleEarnings({
reward,
sale: {
amount,
quantity: sale.quantity,
},
});

const updatedSale = await prisma.commission.update({
where: {
id: sale.id,
},
data: {
amount,
earnings,
},
});

// If the sale has already been paid, we need to update the payout
if (sale.status === "processed" && sale.payoutId) {
const earningsDifference = earnings - sale.earnings;

await prisma.payout.update({
where: {
id: sale.payoutId,
},
data: {
amount: {
...(earningsDifference < 0
? { decrement: Math.abs(earningsDifference) }
: { increment: earningsDifference }),
},
},
});
}

return NextResponse.json(ProgramSaleSchema.parse(updatedSale));
},
{
requiredPlan: [
"business",
"business extra",
"business max",
"business plus",
"enterprise",
],
},
);
2 changes: 1 addition & 1 deletion apps/web/app/app.dub.co/(dashboard)/[slug]/page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ function ImportOption({
content={
<TooltipContent
title="Your workspace has exceeded its monthly links limit. We're still collecting data on your existing links, but you need to upgrade to add more links."
cta={`Upgrade to ${nextPlan.name}`}
cta={nextPlan ? `Upgrade to ${nextPlan.name}` : "Contact support"}
href={`/${slug}/upgrade`}
/>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useEditRoleModal } from "@/ui/modals/edit-role-modal";
import { useInviteCodeModal } from "@/ui/modals/invite-code-modal";
import { useInviteTeammateModal } from "@/ui/modals/invite-teammate-modal";
import { useRemoveTeammateModal } from "@/ui/modals/remove-teammate-modal";
import { AnimatedEmptyState } from "@/ui/shared/animated-empty-state";
import {
CheckCircleFill,
Link as LinkIcon,
Expand All @@ -21,6 +22,7 @@ import {
IconMenu,
Popover,
useCopyToClipboard,
UserCheck,
} from "@dub/ui";
import { capitalize, cn, timeAgo } from "@dub/utils";
import { UserMinus } from "lucide-react";
Expand Down Expand Up @@ -108,16 +110,17 @@ export default function WorkspacePeopleClient() {
<UserCard key={user.id} user={user} currentTab={currentTab} />
))
) : (
<div className="flex flex-col items-center justify-center py-10">
<img
src="https://assets.dub.co/misc/video-park.svg"
alt="No invitations sent"
width={300}
height={300}
className="pointer-events-none -my-8"
/>
<p className="text-sm text-neutral-500">No invitations sent</p>
</div>
<AnimatedEmptyState
title="No invitations sent"
description="No teammates have been added to this workspace yet."
cardContent={() => (
<>
<UserCheck className="size-4 text-neutral-700" />
<div className="h-2.5 w-24 min-w-0 rounded-sm bg-neutral-200" />
</>
)}
className="border-none"
/>
)
) : (
Array.from({ length: 5 }).map((_, i) => <UserPlaceholder key={i} />)
Expand Down
36 changes: 33 additions & 3 deletions apps/web/lib/actions/partners/approve-partner.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"use server";

import { determinePartnerReward } from "@/lib/partners/determine-partner-reward";
import { ProgramRewardDescription } from "@/ui/partners/program-reward-description";
import { sendEmail } from "@dub/email";
import { PartnerApplicationApproved } from "@dub/email/templates/partner-application-approved";
import { prisma } from "@dub/prisma";
import { waitUntil } from "@vercel/functions";
import { getLinkOrThrow } from "../../api/links/get-link-or-throw";
Expand Down Expand Up @@ -33,11 +37,11 @@ export const approvePartnerAction = authActionClient
}),
]);

if (link.programId) {
if (link.partnerId) {
throw new Error("Link is already associated with another partner.");
}

const [programEnrollment, updatedLink] = await Promise.all([
const [programEnrollment, updatedLink, reward] = await Promise.all([
prisma.programEnrollment.update({
where: {
partnerId_programId: {
Expand Down Expand Up @@ -71,14 +75,40 @@ export const approvePartnerAction = authActionClient
},
},
}),

determinePartnerReward({
programId,
partnerId,
event: "sale",
}),
]);

const partner = programEnrollment.partner;

waitUntil(
Promise.allSettled([
recordLink(updatedLink),
// TODO: [partners] Notify partner of approval?

sendEmail({
subject: `Your application to join ${program.name} partner program has been approved!`,
email: partner.email!,
react: PartnerApplicationApproved({
program: {
name: program.name,
logo: program.logo,
slug: program.slug,
},
partner: {
name: partner.name,
email: partner.email!,
payoutsEnabled: partner.payoutsEnabled,
},
rewardDescription: ProgramRewardDescription({
reward,
}),
}),
}),

// TODO: send partner.created webhook
]),
);
Expand Down
1 change: 0 additions & 1 deletion apps/web/lib/analytics/get-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export const getAnalytics = async (params: AnalyticsFilters) => {
region,
country,
timezone = "UTC",
isDemo,
isDeprecatedClicksEndpoint = false,
dataAvailableFrom,
} = params;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/lib/analytics/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type AnalyticsFilters = z.infer<typeof analyticsQuerySchema> & {
isDemo?: boolean;
isDeprecatedClicksEndpoint?: boolean;
folderIds?: string[];
isMegaFolder?: boolean;
};

export type EventsFilters = z.infer<typeof eventsQuerySchema> & {
Expand All @@ -44,6 +45,7 @@ export type EventsFilters = z.infer<typeof eventsQuerySchema> & {
isDemo?: boolean;
customerId?: string;
folderIds?: string[];
isMegaFolder?: boolean;
};

const partnerAnalyticsSchema = analyticsQuerySchema
Expand Down
4 changes: 4 additions & 0 deletions apps/web/lib/openapi/partners/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ZodOpenApiPathsObject } from "zod-openapi";
import { createPartner } from "./create-partner";
import { createPartnerLink } from "./create-partner-link";
import { retrievePartnerAnalytics } from "./retrieve-analytics";
import { updatePartnerSale } from "./update-partner-sale";
import { upsertPartnerLink } from "./upsert-partner-link";

export const partnersPaths: ZodOpenApiPathsObject = {
Expand All @@ -17,4 +18,7 @@ export const partnersPaths: ZodOpenApiPathsObject = {
"/partners/analytics": {
get: retrievePartnerAnalytics,
},
"/partners/sales": {
patch: updatePartnerSale,
},
};
32 changes: 32 additions & 0 deletions apps/web/lib/openapi/partners/update-partner-sale.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { ZodOpenApiOperationObject } from "zod-openapi";
import { updatePartnerSaleSchema } from "../../zod/schemas/partners";
import { ProgramSaleSchema } from "../../zod/schemas/program-sales";
import { openApiErrorResponses } from "../responses";

export const updatePartnerSale: ZodOpenApiOperationObject = {
operationId: "updatePartnerSale",
"x-speakeasy-name-override": "updateSale",
summary: "Update a sale for a partner.",
description:
"Update an existing sale amount. This is useful for handling refunds (partial or full) or fraudulent sales.",
requestBody: {
content: {
"application/json": {
schema: updatePartnerSaleSchema,
},
},
},
responses: {
"200": {
description: "The updated sale.",
content: {
"application/json": {
schema: ProgramSaleSchema,
},
},
},
...openApiErrorResponses,
},
tags: ["Partners"],
security: [{ token: [] }],
};
4 changes: 2 additions & 2 deletions apps/web/lib/webhook/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { linkEventSchema } from "../zod/schemas/links";
import { EnrolledPartnerSchema } from "../zod/schemas/partners";
import { WEBHOOK_TRIGGERS } from "./constants";

const saleSchema = z.object({
const webhookSaleSchema = z.object({
amount: z.number(),
currency: z.string(),
paymentProcessor: z.string(),
Expand All @@ -29,7 +29,7 @@ export const saleWebhookEventSchema = z.object({
customer: CustomerSchema,
click: clickEventSchema,
link: linkEventSchema,
sale: saleSchema,
sale: webhookSaleSchema,
});

// Schema of the payload sent to the webhook endpoint by Dub
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/zod/schemas/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ export const analyticsFilterTB = z
.transform((v) => (Array.isArray(v) ? v : v.split(",")))
.optional()
.describe("The folder IDs to retrieve analytics for."),
isMegaFolder: z.boolean().optional(),
})
.merge(
analyticsQuerySchema.pick({
Expand Down
Loading

0 comments on commit d245bba

Please sign in to comment.