Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shopify #1841

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
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",
},
);
17 changes: 17 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,17 @@
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
Loading