Skip to content

Commit

Permalink
Create partners.upsertLink method
Browse files Browse the repository at this point in the history
  • Loading branch information
steven-tey committed Feb 5, 2025
1 parent 047fc72 commit 9788111
Show file tree
Hide file tree
Showing 5 changed files with 254 additions and 3 deletions.
12 changes: 11 additions & 1 deletion apps/web/app/api/links/upsert/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import {
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { NewLinkProps } from "@/lib/types";
import { createLinkBodySchema } from "@/lib/zod/schemas/links";
import { sendWorkspaceWebhook } from "@/lib/webhook/publish";
import { createLinkBodySchema, linkEventSchema } from "@/lib/zod/schemas/links";
import { prisma } from "@dub/prisma";
import { deepEqual } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

// PUT /api/links/upsert – update or create a link
Expand Down Expand Up @@ -113,6 +115,14 @@ export const PUT = withWorkspace(
updatedLink: processedLink,
});

waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace,
data: linkEventSchema.parse(response),
}),
);

return NextResponse.json(response, {
headers,
});
Expand Down
205 changes: 205 additions & 0 deletions apps/web/app/api/partners/links/upsert/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { DubApiError, ErrorCodes } from "@/lib/api/errors";
import {
createLink,
processLink,
transformLink,
updateLink,
} from "@/lib/api/links";
import { includeTags } from "@/lib/api/links/include-tags";
import { getProgramOrThrow } from "@/lib/api/programs/get-program-or-throw";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { NewLinkProps } from "@/lib/types";
import { sendWorkspaceWebhook } from "@/lib/webhook/publish";
import { linkEventSchema } from "@/lib/zod/schemas/links";
import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners";
import { prisma } from "@dub/prisma";
import { deepEqual, getApexDomain } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";

// PUT /api/partners/links/upsert – update or create a partner link
export const PUT = withWorkspace(
async ({ req, headers, workspace, session }) => {
const { programId, partnerId, tenantId, url, key, linkProps } =
createPartnerLinkSchema.parse(await parseRequestBody(req));

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

if (!program.domain || !program.url) {
throw new DubApiError({
code: "bad_request",
message:
"You need to set a domain and url for this program before creating a partner.",
});
}

if (getApexDomain(url) !== getApexDomain(program.url)) {
throw new DubApiError({
code: "bad_request",
message: `The provided URL domain (${getApexDomain(url)}) does not match the program's domain (${getApexDomain(program.url)}).`,
});
}

if (!partnerId && !tenantId) {
throw new DubApiError({
code: "bad_request",
message: "You must provide a partnerId or tenantId.",
});
}

const partner = await prisma.programEnrollment.findUnique({
where: partnerId
? { partnerId_programId: { partnerId, programId } }
: { tenantId_programId: { tenantId: tenantId!, programId } },
});

if (!partner) {
throw new DubApiError({
code: "not_found",
message: "Partner not found.",
});
}

const link = await prisma.link.findFirst({
where: {
projectId: workspace.id,
programId,
partnerId,
url,
},
include: includeTags,
});

if (link) {
// proceed with /api/links/[linkId] PATCH logic
const updatedLink = {
// original link
...link,
// coerce types
expiresAt:
link.expiresAt instanceof Date
? link.expiresAt.toISOString()
: link.expiresAt,
geo: link.geo as NewLinkProps["geo"],
// merge in new props
...linkProps,
// set default fields
domain: program.domain,
...(key && { key }),
url,
programId: program.id,
tenantId: partner.tenantId,
partnerId: partner.partnerId,
trackConversion: true,
};

// if link and updatedLink are identical, return the link
if (deepEqual(link, updatedLink)) {
return NextResponse.json(transformLink(link), {
headers,
});
}

// if domain and key are the same, we don't need to check if the key exists
const skipKeyChecks =
link.domain === updatedLink.domain &&
link.key.toLowerCase() === updatedLink.key?.toLowerCase();

// if externalId is the same, we don't need to check if it exists
const skipExternalIdChecks =
link.externalId?.toLowerCase() ===
updatedLink.externalId?.toLowerCase();

const {
link: processedLink,
error,
code,
} = await processLink({
payload: updatedLink,
workspace,
skipKeyChecks,
skipExternalIdChecks,
});

if (error) {
throw new DubApiError({
code: code as ErrorCodes,
message: error,
});
}

try {
const response = await updateLink({
oldLink: {
domain: link.domain,
key: link.key,
image: link.image,
},
updatedLink: processedLink,
});

waitUntil(
sendWorkspaceWebhook({
trigger: "link.updated",
workspace,
data: linkEventSchema.parse(response),
}),
);

return NextResponse.json(response, {
headers,
});
} catch (error) {
throw new DubApiError({
code: "unprocessable_entity",
message: error.message,
});
}
} else {
// proceed with /api/partners/links POST logic
const { link, error, code } = await processLink({
payload: {
...linkProps,
domain: program.domain,
key: key || undefined,
url,
programId: program.id,
tenantId: partner.tenantId,
partnerId: partner.partnerId,
trackConversion: true,
},
workspace,
userId: session.user.id,
skipProgramChecks: true, // skip this cause we've already validated the program above
});

if (error != null) {
throw new DubApiError({
code: code as ErrorCodes,
message: error,
});
}

const partnerLink = await createLink(link);

waitUntil(
sendWorkspaceWebhook({
trigger: "link.created",
workspace,
data: linkEventSchema.parse(partnerLink),
}),
);

return NextResponse.json(partnerLink, {
headers,
});
}
},
{
requiredPermissions: ["links.write"],
},
);
4 changes: 2 additions & 2 deletions apps/web/lib/openapi/partners/create-partner-link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const createPartnerLink: ZodOpenApiOperationObject = {
"x-speakeasy-name-override": "createLink",
summary: "Create a link for a partner",
description:
"Create a new link for a partner that is enrolled in your program",
"Create a new link for a partner that is enrolled in your program.",
requestBody: {
content: {
"application/json": {
Expand All @@ -18,7 +18,7 @@ export const createPartnerLink: ZodOpenApiOperationObject = {
},
responses: {
"201": {
description: "The created partner",
description: "The created partner link",
content: {
"application/json": {
schema: LinkSchema,
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
@@ -1,6 +1,7 @@
import { ZodOpenApiPathsObject } from "zod-openapi";
import { createPartner } from "./create-partner";
import { createPartnerLink } from "./create-partner-link";
import { upsertPartnerLink } from "./upsert-partner-link";

export const partnersPaths: ZodOpenApiPathsObject = {
"/partners": {
Expand All @@ -9,4 +10,7 @@ export const partnersPaths: ZodOpenApiPathsObject = {
"/partners/links": {
post: createPartnerLink,
},
"/partners/links/upsert": {
put: upsertPartnerLink,
},
};
32 changes: 32 additions & 0 deletions apps/web/lib/openapi/partners/upsert-partner-link.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { openApiErrorResponses } from "@/lib/openapi/responses";
import { LinkSchema } from "@/lib/zod/schemas/links";
import { createPartnerLinkSchema } from "@/lib/zod/schemas/partners";
import { ZodOpenApiOperationObject } from "zod-openapi";

export const upsertPartnerLink: ZodOpenApiOperationObject = {
operationId: "upsertPartnerLink",
"x-speakeasy-name-override": "upsertLink",
summary: "Upsert a link for a partner",
description:
"Upsert a link for a partner that is enrolled in your program. If a link with the same URL already exists, return it (or update it if there are any changes). Otherwise, a new link will be created.",
requestBody: {
content: {
"application/json": {
schema: createPartnerLinkSchema,
},
},
},
responses: {
"200": {
description: "The upserted partner link",
content: {
"application/json": {
schema: LinkSchema,
},
},
},
...openApiErrorResponses,
},
tags: ["Partners"],
security: [{ token: [] }],
};

0 comments on commit 9788111

Please sign in to comment.