diff --git a/app/(admin)/admin/moderation/_client.tsx b/app/(admin)/admin/moderation/_client.tsx
index a4c1fe30..b6a2592a 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 = () => {
)}
-
+
+
{
// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
- const userPost = await getUserPost(username, slug, session?.user?.id);
+ const userPost = await getUserPost(
+ username,
+ slug,
+ session?.user?.id,
+ session?.user?.role === "ADMIN",
+ );
if (userPost) {
// Discussions/questions canonicalize to /d/{slug}; redirect before metadata.
if (isDiscussionKind(userPost.type)) {
@@ -533,7 +525,12 @@ const UnifiedPostPage = async (props: Props) => {
const host = (await headers()).get("host") || "";
- const userPost = await getUserPost(username, slug, session?.user?.id);
+ const userPost = await getUserPost(
+ username,
+ slug,
+ session?.user?.id,
+ session?.user?.role === "ADMIN",
+ );
if (userPost) {
// Discussions/questions live under /d/{slug} — redirect before rendering.
diff --git a/app/(app)/d/[slug]/page.tsx b/app/(app)/d/[slug]/page.tsx
index a7bddbc3..cbb5f55e 100644
--- a/app/(app)/d/[slug]/page.tsx
+++ b/app/(app)/d/[slug]/page.tsx
@@ -7,11 +7,12 @@ import { ogPostImage } from "@/lib/og/url";
import { getServerAuthSession } from "@/server/auth";
import { db } from "@/server/db";
import { posts, user, post_tags, tag, comments } from "@/server/db/schema";
-import { eq, and, lte, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
+import { eq, and, inArray, or, isNull, asc, type SQL } from "drizzle-orm";
import PostReader, {
type ReaderPost,
} from "@/components/ContentDetail/PostReader";
import { parseUrlId, canonicalMismatch } from "@/server/lib/content-url";
+import { postVisibilityFilter } from "@/server/lib/postVisibility";
import { JsonLd } from "@/components/JsonLd";
import {
getDiscussionForumPostingSchema,
@@ -26,6 +27,7 @@ type Props = { params: Promise<{ slug: string }> };
async function getDiscussionPostUncached(
slug: string,
viewerId?: string | null,
+ viewerIsAdmin = false,
): Promise {
const urlId = parseUrlId(slug);
if (!urlId) return null;
@@ -37,13 +39,6 @@ async function getDiscussionPostUncached(
? eq(posts.urlId, urlId)
: or(eq(posts.urlId, urlId), eq(posts.slug, slug))!;
- const publicFilter = and(
- eq(posts.status, "published"),
- lte(posts.publishedAt, new Date().toISOString()),
- );
-
- // Owner bypass: the author may view their own in_review/rejected discussion;
- // everyone else only sees published.
const [row] = await db
.select({
id: posts.id,
@@ -73,15 +68,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
- viewerId
- ? or(
- publicFilter,
- and(
- eq(posts.authorId, viewerId),
- inArray(posts.status, ["in_review", "rejected"]),
- ),
- )
- : publicFilter,
+ postVisibilityFilter({ viewerId, viewerIsAdmin }),
),
)
.limit(1);
@@ -160,7 +147,11 @@ export async function generateMetadata(props: Props): Promise {
const { slug } = await props.params;
// Same viewerId as the page body so the cache()d resolver runs once per request.
const session = await getServerAuthSession();
- const post = await getDiscussionPost(slug, session?.user?.id);
+ const post = await getDiscussionPost(
+ slug,
+ session?.user?.id,
+ session?.user?.role === "ADMIN",
+ );
if (!post) {
return { title: "Discussion Not Found" };
@@ -210,7 +201,11 @@ const DiscussionPage = async (props: Props) => {
const { slug } = await props.params;
const session = await getServerAuthSession();
- const post = await getDiscussionPost(slug, session?.user?.id);
+ const post = await getDiscussionPost(
+ slug,
+ session?.user?.id,
+ session?.user?.role === "ADMIN",
+ );
if (!post) return notFound();
diff --git a/components/ContentDetail/PostReader.tsx b/components/ContentDetail/PostReader.tsx
index 036ce6c4..8b123783 100644
--- a/components/ContentDetail/PostReader.tsx
+++ b/components/ContentDetail/PostReader.tsx
@@ -120,8 +120,9 @@ const PostReader = async ({
commentsDisabledLabel = "post",
emitArticleSchema = true,
}: PostReaderProps) => {
- // Only reachable by the author (the resolver only returns non-published posts
- // when viewerId matches the author's id).
+ // Only reachable by the author or an admin (the resolvers only return
+ // non-published posts when viewerId matches the author's id, or the viewer is
+ // an admin previewing from the moderation queue).
const isAwaitingReview = post.status === "in_review";
const isRejected = post.status === "rejected";
const bodyContent = post.body ?? "";
diff --git a/server/api/router/admin.ts b/server/api/router/admin.ts
index 71b6fa31..5431f0f3 100644
--- a/server/api/router/admin.ts
+++ b/server/api/router/admin.ts
@@ -205,12 +205,19 @@ export const adminRouter = createTRPCRouter({
// Auto-moderation queue: posts awaiting human review (status `in_review`).
// `moderationNote` surfaces WHY a post was flagged (auto-mod reason, etc.).
+ // `excerpt` gives a moderator a first impression in the queue itself, while
+ // `type` and `externalUrl` decide where its Preview link points: /d/{slug}
+ // for discussions and questions, the linked page for shared links, and
+ // /{user}/{slug} for everything the site renders itself.
listInReview: adminOnlyProcedure.query(async ({ ctx }) => {
const rows = await ctx.db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
+ type: posts.type,
+ excerpt: posts.excerpt,
+ externalUrl: posts.externalUrl,
authorId: posts.authorId,
authorUsername: user.username,
authorName: user.name,
diff --git a/server/lib/postVisibility.test.ts b/server/lib/postVisibility.test.ts
new file mode 100644
index 00000000..fb699d22
--- /dev/null
+++ b/server/lib/postVisibility.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import { PgDialect } from "drizzle-orm/pg-core";
+import { postVisibilityFilter } from "./postVisibility";
+
+const dialect = new PgDialect();
+
+// Statuses and ids are bound as parameters, so the interesting assertions are
+// about which values a viewer's filter binds, not the SQL text.
+const render = (viewer: Parameters[0]) => {
+ const { sql, params } = dialect.sqlToQuery(postVisibilityFilter(viewer));
+ return {
+ sql,
+ params,
+ scopesToAuthor: sql.includes('"author_id" = '),
+ allowsUnpublished:
+ params.includes("in_review") && params.includes("rejected"),
+ };
+};
+
+describe("postVisibilityFilter", () => {
+ it("shows an anonymous viewer only live posts", () => {
+ const filter = render({});
+
+ expect(filter.params).toContain("published");
+ expect(filter.sql).toContain('"published_at" <= ');
+ expect(filter.allowsUnpublished).toBe(false);
+ });
+
+ it("lets a signed-in viewer see unpublished posts only when they wrote them", () => {
+ const filter = render({ viewerId: "viewer-1" });
+
+ expect(filter.allowsUnpublished).toBe(true);
+ // The author predicate is what stops one member reading another's drafts.
+ expect(filter.scopesToAuthor).toBe(true);
+ expect(filter.params).toContain("viewer-1");
+ });
+
+ it("lets an admin see unpublished posts by any author", () => {
+ const filter = render({ viewerId: "admin-1", viewerIsAdmin: true });
+
+ expect(filter.allowsUnpublished).toBe(true);
+ expect(filter.scopesToAuthor).toBe(false);
+ });
+
+ it("does not grant the admin bypass on a plain signed-in session", () => {
+ const admin = render({ viewerId: "admin-1", viewerIsAdmin: true });
+ const member = render({ viewerId: "admin-1" });
+
+ expect(member.sql).not.toEqual(admin.sql);
+ expect(member.scopesToAuthor).toBe(true);
+ });
+
+ it("never exposes drafts, whoever is looking", () => {
+ for (const viewer of [
+ {},
+ { viewerId: "viewer-1" },
+ { viewerId: "admin-1", viewerIsAdmin: true },
+ ]) {
+ expect(render(viewer).params).not.toContain("draft");
+ }
+ });
+});
diff --git a/server/lib/postVisibility.ts b/server/lib/postVisibility.ts
new file mode 100644
index 00000000..55b4d72f
--- /dev/null
+++ b/server/lib/postVisibility.ts
@@ -0,0 +1,38 @@
+import { and, eq, inArray, lte, or, type SQL } from "drizzle-orm";
+import { posts } from "@/server/db/schema";
+
+/**
+ * Who is allowed to see a post that is not live yet.
+ *
+ * Every reader resolver applies the same rule, so it lives here rather than
+ * being restated per route: a post is visible when it is published and its
+ * publish time has passed, OR it is awaiting/failed review and the viewer is
+ * either its author or an admin. Admins get the author's view so the moderation
+ * queue can link straight to a full preview of a post it is asking them to
+ * approve.
+ *
+ * Callers that already pin an author in their WHERE (the /{username}/{slug}
+ * resolvers) still get the right answer: the extra authorId predicate here is
+ * simply redundant with theirs.
+ */
+export function postVisibilityFilter(viewer: {
+ viewerId?: string | null;
+ viewerIsAdmin?: boolean;
+}): SQL {
+ const live = and(
+ eq(posts.status, "published"),
+ lte(posts.publishedAt, new Date().toISOString()),
+ )!;
+
+ const notLiveYet = inArray(posts.status, ["in_review", "rejected"]);
+
+ if (viewer.viewerIsAdmin) {
+ return or(live, notLiveYet)!;
+ }
+
+ if (viewer.viewerId) {
+ return or(live, and(notLiveYet, eq(posts.authorId, viewer.viewerId)))!;
+ }
+
+ return live;
+}