2 Commits

Author SHA1 Message Date
Marco 33f3adb92c Document /r and /sticker short-link redirects in README
Neither route was mentioned anywhere despite predating this session (/r)
or being added this session (/sticker) — every other route/collection
this size gets its own section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 13:21:48 +00:00
Marco 31e4f907f2 Make Redirects.urlPrefix actually gate which route a code resolves under
resolveAndTrackRedirect() now filters on urlPrefix in addition to code,
with each route.ts passing its own literal prefix — previously the field
was admin-display-only, so a code marked "/sticker" in Payload silently
kept resolving under /r/<code> too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G5mssdCBir9kyXTmqBjV3h
2026-08-28 13:15:41 +00:00
4 changed files with 51 additions and 13 deletions
+30
View File
@@ -1903,6 +1903,36 @@ one; a second `google-maps` row would collide on that name. To actually
use it: `<GoogleMapsEmbed src="<Google Maps embed URL>" title="..." />`
anywhere.
## Short links & QR redirects
`app/r/[code]/route.ts` and `app/sticker/[code]/route.ts` are static
short-link routes (e.g. printed on a flyer or a physical sticker's QR
code) that resolve a `code` against Payload's `redirects` collection and
307-redirect (`redirect()`, deliberately not `permanentRedirect()` — the
target can change at any time and this must never be client-cached) to
that doc's `targetPath`. Both routes call the same
`resolveAndTrackRedirect(code, urlPrefix)` in `app/lib/payload.ts`, which
also fire-and-forget PATCHes `clickCount`/`lastClickedAt` on every hit
(tracking failure only logs — never worth stranding a visitor over).
- **`/r/[code]`** is the default scheme for all new short links —
`einfach-produktiv.com/r/aktion-sommer`.
- **`/sticker/[code]`** exists only because a batch of physical stickers
was already printed as `einfach-produktiv.com/sticker/<code>` (`echt`,
`geheimnis`, `fokus`) before `/r/[code]` existed and can't be
reprinted — same collection, same lookup, just a second fixed prefix.
- The Redirects doc's `urlPrefix` field (`/r` or `/sticker`) isn't just an
admin label — `resolveAndTrackRedirect` filters on it too, so a code is
only reachable under whichever prefix its doc is actually set to.
Changing `urlPrefix` in Payload immediately changes which route serves
that code.
- Defense in depth: `targetPath` must start with `/` and not `//` (open-
redirect guard, same check `app/api/preview/route.ts` does), enforced
both in Payload's own field validation and again in each route.ts before
calling `redirect()`.
- A deactivated (`active: false`) doc — or no matching doc at all —
renders a plain 404 (`notFound()`), not a redirect to some fallback.
## Product image gallery
`ProductGallery.tsx` — main image + thumbnail strip, swappable on click OR
+16 -9
View File
@@ -1251,20 +1251,27 @@ 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.
// Powers app/r/[code]/route.ts and app/sticker/[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> {
// `urlPrefix` filters the match to whichever fixed frontend route is
// actually calling this — Redirects.urlPrefix is otherwise just an admin
// label, not something either route enforced, so a code edited to
// urlPrefix "/sticker" in Payload would silently keep resolving under
// /r/<code> too without this. Each call site passes its own literal
// prefix (see route.ts files), so this actually makes the field mean
// something rather than only decorate the admin list.
export async function resolveAndTrackRedirect(code: string, urlPrefix: "/r" | "/sticker"): Promise<string | null> {
const params = new URLSearchParams({
"where[tenant.slug][equals]": TENANT_SLUG,
"where[code][equals]": code,
"where[urlPrefix][equals]": urlPrefix,
"where[active][equals]": "true",
limit: "1",
});
+1 -1
View File
@@ -9,7 +9,7 @@ import { resolveAndTrackRedirect } from "../../lib/payload";
// 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);
const targetPath = await resolveAndTrackRedirect(code, "/r");
// Defense in depth — Redirects.targetPath is already validated in Payload
// to start with "/", same open-redirect guard as api/preview/route.ts.
+4 -3
View File
@@ -5,11 +5,12 @@ import { resolveAndTrackRedirect } from "../../lib/payload";
// Legacy sticker QR codes already printed as https://einfach-produktiv.com/sticker/<code>
// before the /r/[code] short-link scheme existed — can't be reprinted, so
// this mirrors app/r/[code]/route.ts's exact lookup (same Redirects
// collection, matched by `code`) under the fixed /sticker prefix instead.
// Keeps a single admin-editable/toggleable source of truth for both.
// collection, matched by `code` + urlPrefix "/sticker") under the fixed
// /sticker prefix instead. Keeps a single admin-editable/toggleable source
// of truth for both.
export async function GET(_request: NextRequest, { params }: { params: Promise<{ code: string }> }) {
const { code } = await params;
const targetPath = await resolveAndTrackRedirect(code);
const targetPath = await resolveAndTrackRedirect(code, "/sticker");
if (!targetPath || !targetPath.startsWith("/") || targetPath.startsWith("//")) {
notFound();