dde53319e5
- New custom server.js (replaces react-router-serve): ISO timestamps on all console.* output and on access logs, and skip successful /healthz polls so real traffic stays visible. - New ProcessedWebhook table + dedupe helper keyed on X-Shopify-Webhook-Id; stops Shopify retries from triggering a second invoice email when the original delivery exceeded the 5s ack timeout. - orders/create + orders/fulfilled now respond 200 immediately and run the PDF/email work in the background so we stay under that timeout. - pickLanguage(): non-German locales (it, fr, es, ...) now default to English instead of falling back to German. Empty/unknown still maps to 'de' so the per-shop defaultLanguage chain keeps working. - Tests for pickLanguage and dedupe via node --test + tsx.
68 lines
2.2 KiB
TypeScript
68 lines
2.2 KiB
TypeScript
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<unknown>;
|
|
};
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<boolean> {
|
|
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;
|
|
}
|
|
}
|