Skip to content

Commit

Permalink
Merge pull request #1841 from dubinc/shopify
Browse files Browse the repository at this point in the history
Shopify
  • Loading branch information
steven-tey authored Jan 2, 2025
2 parents ec418ad + d90b61c commit db8f80a
Show file tree
Hide file tree
Showing 15 changed files with 794 additions and 1 deletion.
60 changes: 60 additions & 0 deletions apps/web/app/api/cron/shopify/order-paid/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { DubApiError, handleAndReturnErrorResponse } from "@/lib/api/errors";
import { verifyQstashSignature } from "@/lib/cron/verify-qstash";
import { processOrder } from "@/lib/integrations/shopify/process-order";
import { redis } from "@/lib/upstash";
import { z } from "zod";

export const dynamic = "force-dynamic";

const schema = z.object({
workspaceId: z.string(),
checkoutToken: z.string(),
});

// POST /api/cron/shopify/order-paid
export async function POST(req: Request) {
try {
const body = await req.json();
await verifyQstashSignature(req, body);

const { workspaceId, checkoutToken } = schema.parse(body);

// Find Shopify order
const event = await redis.hget(
`shopify:checkout:${checkoutToken}`,
"order",
);

if (!event) {
return new Response(
`[Shopify] Order with checkout token ${checkoutToken} not found. Skipping...`,
);
}

const clickId = await redis.hget<string>(
`shopify:checkout:${checkoutToken}`,
"clickId",
);

// Wait for the click event to come from Shopify pixel
if (!clickId) {
throw new DubApiError({
code: "bad_request",
message:
"[Shopify] Click event not found. Waiting for Shopify pixel event...",
});
}

await processOrder({
event,
workspaceId,
clickId,
});

await redis.del(`shopify:checkout:${checkoutToken}`);

return new Response("[Shopify] Order event processed successfully.");
} catch (error) {
return handleAndReturnErrorResponse(error);
}
}
2 changes: 1 addition & 1 deletion apps/web/app/api/cron/year-in-review/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export const dynamic = "force-dynamic";
const BATCH_SIZE = 100;

// POST /api/cron/year-in-review
export async function POST(req: Request) {
export async function POST() {
try {
if (process.env.VERCEL === "1") {
return new Response("Not available in production.");
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/api/oauth/userinfo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export async function GET(req: NextRequest) {
},
project: {
select: {
id: true,
name: true,
slug: true,
logo: true,
Expand All @@ -52,6 +53,7 @@ export async function GET(req: NextRequest) {
name: user.name,
image: user.image,
workspace: {
id: `ws_${tokenRecord.project.id}`,
slug: tokenRecord.project.slug,
name: tokenRecord.project.name,
logo: tokenRecord.project.logo,
Expand Down
91 changes: 91 additions & 0 deletions apps/web/app/api/shopify/integration/callback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { DubApiError } from "@/lib/api/errors";
import { parseRequestBody } from "@/lib/api/utils";
import { withWorkspace } from "@/lib/auth";
import { installIntegration } from "@/lib/integrations/install";
import { prisma } from "@dub/prisma";
import { SHOPIFY_INTEGRATION_ID } from "@dub/utils";
import { waitUntil } from "@vercel/functions";
import { NextResponse } from "next/server";
import { z } from "zod";

const updateWorkspaceSchema = z.object({
shopifyStoreId: z.string().nullable(),
});

// PATCH /api/shopify/integration/callback – update a shopify store id
export const PATCH = withWorkspace(
async ({ req, workspace, session }) => {
const body = await parseRequestBody(req);
const { shopifyStoreId } = updateWorkspaceSchema.parse(body);

try {
const response = await prisma.project.update({
where: {
id: workspace.id,
},
data: {
shopifyStoreId,
},
select: {
shopifyStoreId: true,
},
});

waitUntil(
(async () => {
const installation = await prisma.installedIntegration.findUnique({
where: {
userId_integrationId_projectId: {
userId: session.user.id,
projectId: workspace.id,
integrationId: SHOPIFY_INTEGRATION_ID,
},
},
select: {
id: true,
},
});

// Install the integration if it doesn't exist
if (!installation) {
await installIntegration({
userId: session.user.id,
workspaceId: workspace.id,
integrationId: SHOPIFY_INTEGRATION_ID,
credentials: {
shopifyStoreId,
},
});
}

// Uninstall the integration if the shopify store id is null
if (installation && shopifyStoreId === null) {
await prisma.installedIntegration.delete({
where: {
id: installation.id,
},
});
}
})(),
);

return NextResponse.json(response);
} catch (error) {
if (error.code === "P2002") {
throw new DubApiError({
code: "conflict",
message: `The shopify store "${shopifyStoreId}" is already in use.`,
});
}

throw new DubApiError({
code: "internal_server_error",
message: error.message,
});
}
},
{
requiredPermissions: ["workspaces.write"],
requiredAddOn: "conversion",
},
);
16 changes: 16 additions & 0 deletions apps/web/app/api/shopify/integration/webhook/app-uninstalled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { redis } from "@/lib/upstash";
import { prisma } from "@dub/prisma";

export async function appUninstalled({ shopDomain }: { shopDomain: string }) {
await Promise.all([
prisma.project.update({
where: {
shopifyStoreId: shopDomain,
},
data: {
shopifyStoreId: null,
},
}),
redis.del(`shopify:shop:${shopDomain}`),
]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { waitUntil } from "@vercel/functions";
import { sendEmail } from "emails";
import { z } from "zod";

const schema = z.object({
shop_domain: z.string(),
orders_requested: z.array(z.number()),
customer: z.object({
id: z.number(),
email: z.string(),
phone: z.string(),
}),
});

export async function customersDataRequest({ event }: { event: any }) {
const {
customer,
shop_domain: shopDomain,
orders_requested: ordersRequested,
} = schema.parse(event);

waitUntil(
sendEmail({
email: "[email protected]",
from: "Steven Tey <[email protected]>",
subject: "[Shopify] - Customer Data Request received",
text: `Customer Data Request received for shop: ${shopDomain}.
Customer ID: ${customer.id},
Email: ${customer.email},
Phone: ${customer.phone},
Orders Requested: ${ordersRequested.join(", ")}`,
}),
);
}
34 changes: 34 additions & 0 deletions apps/web/app/api/shopify/integration/webhook/customers-redact.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { waitUntil } from "@vercel/functions";
import { sendEmail } from "emails";
import { z } from "zod";

const schema = z.object({
shop_domain: z.string(),
orders_to_redact: z.array(z.number()),
customer: z.object({
id: z.number(),
email: z.string(),
phone: z.string(),
}),
});

export async function customersRedact({ event }: { event: any }) {
const {
customer,
shop_domain: shopDomain,
orders_to_redact: ordersToRedact,
} = schema.parse(event);

waitUntil(
sendEmail({
email: "[email protected]",
from: "Steven Tey <[email protected]>",
subject: "[Shopify] - Customer Redacted request received",
text: `Customer Redacted request received for shop: ${shopDomain}.
Customer ID: ${customer.id},
Email: ${customer.email},
Phone: ${customer.phone},
Orders to Redact: ${ordersToRedact.join(", ")}`,
}),
);
}
73 changes: 73 additions & 0 deletions apps/web/app/api/shopify/integration/webhook/order-paid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { qstash } from "@/lib/cron";
import { processOrder } from "@/lib/integrations/shopify/process-order";
import { orderSchema } from "@/lib/integrations/shopify/schema";
import { redis } from "@/lib/upstash";
import { prisma } from "@dub/prisma";
import { APP_DOMAIN_WITH_NGROK } from "@dub/utils";

export async function orderPaid({
event,
workspaceId,
}: {
event: any;
workspaceId: string;
}) {
const {
customer: { id: externalId },
checkout_token: checkoutToken,
} = orderSchema.parse(event);

const customer = await prisma.customer.findUnique({
where: {
projectId_externalId: {
projectId: workspaceId,
externalId: externalId.toString(),
},
},
});

// customer is found, process the order right away
if (customer) {
await processOrder({
event,
workspaceId,
customerId: customer.id,
});

return;
}

// Check the cache to see the pixel event for this checkout token exist before publishing the event to the queue
const clickId = await redis.hget<string>(
`shopify:checkout:${checkoutToken}`,
"clickId",
);

// clickId is found, process the order for the new customer
if (clickId) {
await processOrder({
event,
workspaceId,
clickId,
});

await redis.del(`shopify:checkout:${checkoutToken}`);

return;
}

// Wait for the pixel event to come in so that we can decide if the order is from a Dub link or not
await redis.hset(`shopify:checkout:${checkoutToken}`, {
order: event,
});

await qstash.publishJSON({
url: `${APP_DOMAIN_WITH_NGROK}/api/cron/shopify/order-paid`,
body: {
checkoutToken,
workspaceId,
},
retries: 5,
delay: 3,
});
}
Loading

0 comments on commit db8f80a

Please sign in to comment.