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!)}