From 4d09857e685abfca36e6ff4f87bf63fec689a4c2 Mon Sep 17 00:00:00 2001 From: NiallJoeMaher Date: Wed, 12 Aug 2026 07:48:40 +0100 Subject: [PATCH] fix(admin): let moderators preview a post before approving it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "in review" queue showed a title, author and excerpt but gave no way to read the post, so there was nothing to base an Approve/Decline on. Each queued item now has a Preview link that opens where the post actually renders: /d/{slug} for discussions and questions, the destination itself for shared links, and /{username}/{slug} for everything the site renders. For that last case the reader resolvers now grant admins the bypass authors already had for their own in_review and rejected posts, so the preview is the article exactly as readers would eventually see it, "Awaiting review" banner and all. That visibility rule was about to be a third copy of the same published-or-owner check, so it moves to server/lib/postVisibility.ts with unit tests covering the anonymous, author and admin branches โ€” the author predicate is the only thing stopping one member reading another's drafts, so it is worth pinning down. --- app/(admin)/admin/moderation/_client.tsx | 42 +++++++++++++++- app/(app)/[username]/[slug]/page.tsx | 35 ++++++------- app/(app)/d/[slug]/page.tsx | 33 ++++++------- components/ContentDetail/PostReader.tsx | 5 +- server/api/router/admin.ts | 7 +++ server/lib/postVisibility.test.ts | 62 ++++++++++++++++++++++++ server/lib/postVisibility.ts | 38 +++++++++++++++ 7 files changed, 181 insertions(+), 41 deletions(-) create mode 100644 server/lib/postVisibility.test.ts create mode 100644 server/lib/postVisibility.ts diff --git a/app/(admin)/admin/moderation/_client.tsx b/app/(admin)/admin/moderation/_client.tsx index a4c1fe307..b6a2592ab 100644 --- a/app/(admin)/admin/moderation/_client.tsx +++ b/app/(admin)/admin/moderation/_client.tsx @@ -40,6 +40,40 @@ const reasonLabels: Record = { const chipBase = "rounded-full px-2 py-0.5 font-mono text-xs uppercase tracking-label"; +type PreviewablePost = { + type: string | null; + slug: string | null; + externalUrl: string | null; + authorUsername: string | null; +}; + +// Where to send a moderator to actually read the thing they're judging. +// Discussions and questions live under /d/; a shared link IS its destination, +// so it points off-site; everything else renders at /{username}/{slug}, where +// the reader grants admins the same bypass the author has โ€” so an in_review +// post previews exactly as readers would eventually see it. +function postPreviewHref(post: PreviewablePost): string | null { + if (post.type === "link") return post.externalUrl; + if (!post.slug) return null; + if (post.type === "discussion" || post.type === "question") { + return `/d/${post.slug}`; + } + if (!post.authorUsername) return null; + return `/${post.authorUsername}/${post.slug}`; +} + +const PreviewLink = ({ post }: { post: PreviewablePost }) => { + const href = postPreviewHref(post); + if (!href) return null; + + return ( + + + Preview + + ); +}; + // datetime-local is in the moderator's LOCAL time, so shift the `min` boundary // by the tz offset before slicing to "YYYY-MM-DDTHH:mm". function localDateTimeMin(): string { @@ -263,6 +297,11 @@ const ModerationQueue = () => { @{post.authorUsername ?? "unknown"} ยท{" "} {getRelativeTime(post.createdAt!)}

+ {post.excerpt && ( +

+ {post.excerpt} +

+ )} {post.moderationNote && (

Reason:{" "} @@ -270,7 +309,8 @@ const ModerationQueue = () => {

)} -
+
+