import db from "../../db.server"; /** * Minimal shape of the Prisma client surface we use — declared inline so * the helper can be unit-tested with a tiny stub instead of pulling in a * real database. */ export interface DedupeDeps { db: { processedWebhook: { create: (args: { data: { webhookId: string; topic: string; shopDomain: string }; }) => Promise; }; }; } /** * Returns `true` when this Shopify webhook delivery has already been * processed and the caller should short-circuit without doing the work. * * Shopify retries webhook deliveries when it doesn't receive a 200 within * its (~5s) timeout window. Without dedupe this caused us to email an * invoice twice for the same order: the first slow delivery completed its * work but Shopify timed out and re-sent the webhook, which then ran the * automation a second time. * * We key on the `X-Shopify-Webhook-Id` header — Shopify guarantees the same * value for retries of the same delivery, but a new value for genuinely * new events. The insert is the lock: a unique-constraint violation * (Prisma error code `P2002`) means another delivery already claimed this * id. */ export async function isDuplicateWebhook( request: Request, shop: string, topic: string, deps: DedupeDeps = { db }, ): Promise { const webhookId = request.headers.get("x-shopify-webhook-id"); if (!webhookId) { // Defensive: in unit tests / non-Shopify callers there is no id. // Don't dedupe — that would silently drop legitimate calls. return false; } try { await deps.db.processedWebhook.create({ data: { webhookId, topic, shopDomain: shop }, }); return false; } catch (err) { // Duck-typed P2002 check so callers can stub the db without pulling // in the real `Prisma` namespace. if ((err as { code?: string } | null)?.code === "P2002") { console.log( `dedupe: skipping duplicate ${topic} delivery for ${shop} (webhookId=${webhookId})`, ); return true; } // Don't fail the webhook on a logging-table issue; just process it. console.warn( `dedupe: failed to record webhook ${webhookId} (${topic}/${shop}):`, err, ); return false; } }