Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/e2e-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
env:
DATABASE_URL: "postgresql://postgres:secret@localhost:5432/postgres"
NEXTAUTH_URL: http://localhost:3000/api/auth
# A production build sets NODE_ENV=production, which would otherwise turn
# off the passwordless provider that dev enables implicitly.
EMAIL_AUTH_ENABLED: "true"
GITHUB_ID: ${{ secrets.E2E_GITHUB_ID }}
GITHUB_SECRET: ${{ secrets.E2E_GITHUB_SECRET }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
Expand Down Expand Up @@ -91,6 +94,12 @@ jobs:
npm run db:migrate
npm run db:seed

# Playwright serves this build (see webServer in playwright.config.ts).
# Tests used to run against `next dev`, where the first hit on a route
# blocked on an on-demand compile for longer than the assertion timeouts.
- name: Build app
run: npm run build:e2e

- name: Run Playwright tests
id: playwright-tests
run: npx playwright test
Expand Down
50 changes: 16 additions & 34 deletions app/(admin)/admin/moderation/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,39 +40,21 @@ const reasonLabels: Record<ReportReason, string> = {
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 (
<Link href={href} target="_blank" className="secondary-button">
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Preview
</Link>
);
};
// Read the submission before deciding on it. The preview is an admin-side,
// read-only render (see app/(admin)/admin/moderation/preview/[postId]) rather
// than the public URL: an unapproved post has no public URL yet, and the public
// reader would put vote/bookmark/comment controls on a post that may be about
// to be rejected. Keyed by id, so it is available for every queued post.
const PreviewLink = ({ postId }: { postId: string }) => (
<Link
href={`/admin/moderation/preview/${postId}`}
target="_blank"
className="secondary-button"
>
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Preview
</Link>
);

// 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".
Expand Down Expand Up @@ -310,7 +292,7 @@ const ModerationQueue = () => {
)}
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<PreviewLink post={post} />
<PreviewLink postId={post.id} />
<button
className="primary-button"
disabled={isModerating}
Expand Down
160 changes: 160 additions & 0 deletions app/(admin)/admin/moderation/preview/[postId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeftIcon } from "@heroicons/react/24/outline";
import { and, eq } from "drizzle-orm";
import z from "zod";
import { db } from "@/server/db";
import { posts, user, post_tags, tag } from "@/server/db/schema";
import { PostBody, renderPostBody } from "@/components/ContentDetail/PostBody";
import { getCamelCaseFromLower } from "@/utils/utils";
import { safeExternalHref } from "@/utils/url";
import { moderationPreviewFilter } from "@/server/lib/postVisibility";

export const metadata = {
title: "Preview - Codú Admin",
description: "Read a submission before approving or declining it",
robots: { index: false, follow: false },
};

type Props = { params: Promise<{ postId: string }> };

// Read-only preview of a submission, for deciding whether it belongs on the
// site. It deliberately lives inside `(admin)` rather than exposing unpublished
// posts on the public reader routes: moderators need to READ a post, not vote,
// bookmark or comment on one that may be about to be rejected — and admins
// should still see the public site exactly as readers do.
//
// The body renders through the same `PostBody` the reader uses, so what a
// moderator approves is what readers will get.
//
// Admin-role gate is enforced in app/(admin)/layout.tsx.
export default async function Page({ params }: Props) {
const { postId } = await params;

// posts.id is a uuid column, so a mistyped or truncated id would make
// Postgres throw a cast error (a 500) before the not-found check below.
if (!z.string().uuid().safeParse(postId).success) notFound();

const [rows, tags] = await Promise.all([
db
.select({
id: posts.id,
title: posts.title,
body: posts.body,
excerpt: posts.excerpt,
type: posts.type,
status: posts.status,
slug: posts.slug,
externalUrl: posts.externalUrl,
coverImage: posts.coverImage,
readingTime: posts.readingTime,
createdAt: posts.createdAt,
moderationNote: posts.moderationNote,
authorName: user.name,
authorUsername: user.username,
})
.from(posts)
.leftJoin(user, eq(posts.authorId, user.id))
// Submitted work only. A moderator has business reading anything that
// entered the pipeline; a private draft is not that.
.where(and(eq(posts.id, postId), moderationPreviewFilter()))
.limit(1),
db
.select({ title: tag.title, slug: tag.slug })
.from(post_tags)
.innerJoin(tag, eq(post_tags.tagId, tag.id))
.where(eq(post_tags.postId, postId)),
]);

const record = rows[0];
if (!record) notFound();

const renderedBody = renderPostBody(record.body);
const externalHref = safeExternalHref(record.externalUrl);

return (
<div className="mx-auto max-w-3xl px-0 py-4 sm:px-4 sm:py-8">
<div className="mb-6 flex items-center gap-4">
<Link
href="/admin/moderation"
className="rounded-lg p-2 text-muted transition-colors hover:bg-elevated hover:text-fg"
>
<ArrowLeftIcon className="h-5 w-5" />
</Link>
<div className="min-w-0">
<p className="eyebrow">
<span className="slash">{"// "}</span>preview
</p>
<h1 className="mt-1 font-display text-2xl font-extrabold tracking-tight text-fg">
{record.title || "Untitled"}
</h1>
<p className="mt-1 font-mono text-xs text-faint">
{record.type} · {record.status} · @
{record.authorUsername ?? "unknown"}
{record.readingTime ? ` · ${record.readingTime} min read` : ""}
</p>
</div>
</div>

{record.moderationNote && (
<p className="mb-6 rounded-lg border border-hairline bg-inset p-3 text-sm text-muted">
<span className="font-medium text-fg">Flagged:</span>{" "}
{record.moderationNote}
</p>
)}

{record.excerpt && (
<p className="mb-6 text-base text-muted">{record.excerpt}</p>
)}

{/* A link submission is judged on both halves: the member's own framing
above, and the destination. rel/noreferrer keep the admin surface out
of the referrer of a page that is under review precisely because it
may be hostile. */}
{record.type === "link" &&
(externalHref ? (
<p className="mb-6 break-all font-mono text-sm">
<span className="text-faint">{"// destination "}</span>
<a
href={externalHref}
target="_blank"
rel="noopener noreferrer nofollow"
className="text-accent underline"
>
{externalHref}
</a>
</p>
) : (
<p className="mb-6 font-mono text-sm text-danger">
{"// destination missing or not a http(s) URL: "}
{record.externalUrl ?? "none"}
</p>
))}

{tags.length > 0 && (
<div className="mb-6 flex flex-wrap gap-2">
{tags.map((t) => (
<span
key={t.title}
className="rounded-sm border border-hairline px-2.5 py-0.5 font-mono text-xs text-muted"
>
{getCamelCaseFromLower(t.title)}
</span>
))}
</div>
)}

{record.body ? (
<article className="prose max-w-none dark:prose-invert">
<PostBody {...renderedBody} />
</article>
) : (
<p className="font-mono text-sm text-faint">{"// no body submitted"}</p>
)}

<p className="mt-8 font-mono text-xs text-faint">
{"// read-only — approve or decline from the queue"}
</p>
</div>
);
}
17 changes: 3 additions & 14 deletions app/(app)/[username]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ async function getUserPostUncached(
username: string,
postSlug: string,
viewerId?: string | null,
viewerIsAdmin = false,
) {
// Case-insensitive handle resolution (GitHub-style), matching the profile page.
const userRecord = await db.query.user.findFirst({
Expand All @@ -42,7 +41,7 @@ async function getUserPostUncached(

if (!userRecord) return null;

const visibilityFilter = postVisibilityFilter({ viewerId, viewerIsAdmin });
const visibilityFilter = postVisibilityFilter({ viewerId });

const postResults = await db
.select({
Expand Down Expand Up @@ -363,12 +362,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {

// 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,
session?.user?.role === "ADMIN",
);
const userPost = await getUserPost(username, slug, session?.user?.id);
if (userPost) {
// Discussions/questions canonicalize to /d/{slug}; redirect before metadata.
if (isDiscussionKind(userPost.type)) {
Expand Down Expand Up @@ -525,12 +519,7 @@ const UnifiedPostPage = async (props: Props) => {

const host = (await headers()).get("host") || "";

const userPost = await getUserPost(
username,
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const userPost = await getUserPost(username, slug, session?.user?.id);

if (userPost) {
// Discussions/questions live under /d/{slug} — redirect before rendering.
Expand Down
15 changes: 3 additions & 12 deletions app/(app)/d/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ type Props = { params: Promise<{ slug: string }> };
async function getDiscussionPostUncached(
slug: string,
viewerId?: string | null,
viewerIsAdmin = false,
): Promise<ReaderPost | null> {
const urlId = parseUrlId(slug);
if (!urlId) return null;
Expand Down Expand Up @@ -68,7 +67,7 @@ async function getDiscussionPostUncached(
and(
idMatch,
inArray(posts.type, ["discussion", "question"]),
postVisibilityFilter({ viewerId, viewerIsAdmin }),
postVisibilityFilter({ viewerId }),
),
)
.limit(1);
Expand Down Expand Up @@ -147,11 +146,7 @@ export async function generateMetadata(props: Props): Promise<Metadata> {
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,
session?.user?.role === "ADMIN",
);
const post = await getDiscussionPost(slug, session?.user?.id);

if (!post) {
return { title: "Discussion Not Found" };
Expand Down Expand Up @@ -201,11 +196,7 @@ const DiscussionPage = async (props: Props) => {
const { slug } = await props.params;
const session = await getServerAuthSession();

const post = await getDiscussionPost(
slug,
session?.user?.id,
session?.user?.role === "ADMIN",
);
const post = await getDiscussionPost(slug, session?.user?.id);

if (!post) return notFound();

Expand Down
Loading
Loading