diff --git a/app/lib/payload.ts b/app/lib/payload.ts index aab61c5..6fa5ef8 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -1192,6 +1192,46 @@ export async function getSeoSettings(): Promise { }; } +// Powers app/r/[code]/route.ts — a static short link (e.g. printed on a QR +// code) that redirects to a `targetPath` editable in Payload at any time, +// so the QR code itself never needs reprinting. `cache: "no-store"` +// (unlike this file's other public-catalog fetches) since a stale hit here +// would send a visitor to a since-changed target, and the PATCH below needs +// the just-fetched id/clickCount, not a 60s-old ISR snapshot. +type PayloadRedirect = { id: number; targetPath: string; clickCount: number }; + +// PATCH failure only logs — click tracking is informational, never worth +// stranding a visitor on a broken link over. +export async function resolveAndTrackRedirect(code: string): Promise { + const params = new URLSearchParams({ + "where[tenant.slug][equals]": TENANT_SLUG, + "where[code][equals]": code, + "where[active][equals]": "true", + limit: "1", + }); + + const res = await fetch(`${PAYLOAD_URL}/api/redirects?${params}`, { cache: "no-store" }); + if (!res.ok) { + console.error(`resolveAndTrackRedirect: Payload returned ${res.status} ${res.statusText}`); + return null; + } + + const data: { docs?: PayloadRedirect[] } = await res.json(); + const doc = data.docs?.[0]; + if (!doc) return null; + + fetch(`${PAYLOAD_URL}/api/redirects/${doc.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + "x-order-service-secret": process.env.ORDER_SERVICE_SECRET || "", + }, + body: JSON.stringify({ clickCount: doc.clickCount + 1, lastClickedAt: new Date().toISOString() }), + }).catch((err) => console.error("resolveAndTrackRedirect: click-tracking PATCH failed", err)); + + return doc.targetPath; +} + export type TrackingCode = { id: number; provider: "google-analytics" | "facebook-pixel" | "google-tag-manager" | "google-maps" | "other"; diff --git a/app/r/[code]/route.ts b/app/r/[code]/route.ts new file mode 100644 index 0000000..c0118d5 --- /dev/null +++ b/app/r/[code]/route.ts @@ -0,0 +1,21 @@ +import { redirect, notFound } from "next/navigation"; +import { NextRequest } from "next/server"; +import { resolveAndTrackRedirect } from "../../lib/payload"; + +// A static short link (e.g. printed on a QR code) that always resolves to +// whatever `targetPath` is currently set on the matching Redirects document +// in Payload — the QR code itself never needs reprinting when the target +// changes. `redirect()` (not permanentRedirect()) issues a 307, deliberately +// non-cacheable client-side since the target can change at any time. +export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) { + const { code } = await params; + const targetPath = await resolveAndTrackRedirect(code); + + // Defense in depth — Redirects.targetPath is already validated in Payload + // to start with "/", same open-redirect guard as api/preview/route.ts. + if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) { + notFound(); + } + + redirect(targetPath); +}