Add internal redirect short links (/r/<code>)

A static short link — e.g. printed on a QR code — that resolves to a
Payload-editable internal target path, so the link itself never needs
reprinting when the underlying content moves. Tracks a click count.
This commit is contained in:
Marco
2026-08-25 12:17:08 +00:00
parent a085a75dea
commit 988f259371
2 changed files with 61 additions and 0 deletions
+40
View File
@@ -1192,6 +1192,46 @@ export async function getSeoSettings(): Promise<SeoSettings> {
};
}
// 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<string | null> {
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";
+21
View File
@@ -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);
}