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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user