Merge feature/comments: moderated threaded comments

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
matt 2026-07-04 21:08:16 -04:00
commit c0cf38671a
16 changed files with 2013 additions and 14 deletions

18
drizzle/0004_comments.sql Normal file
View file

@ -0,0 +1,18 @@
CREATE TYPE "public"."comment_status" AS ENUM('pending', 'approved');--> statement-breakpoint
CREATE TABLE "comments" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "comments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"post_id" integer NOT NULL,
"parent_id" integer,
"author_name" text NOT NULL,
"author_email" text NOT NULL,
"email_public" boolean DEFAULT false NOT NULL,
"body" text NOT NULL,
"status" "comment_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_post_id_status_idx" ON "comments" USING btree ("post_id","status");--> statement-breakpoint
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "comments_status_idx" ON "comments" USING btree ("status");

View file

@ -0,0 +1,895 @@
{
"id": "b1253e7e-39b6-4405-b99a-427b27e0266f",
"prevId": "ad3939eb-f52e-4a81-8732-6bcede794ad2",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.comments": {
"name": "comments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "comments_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"author_email": {
"name": "author_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_public": {
"name": "email_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "comment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"comments_post_id_status_idx": {
"name": "comments_post_id_status_idx",
"columns": [
{
"expression": "post_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"comments_parent_id_idx": {
"name": "comments_parent_id_idx",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"comments_status_idx": {
"name": "comments_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"comments_post_id_posts_id_fk": {
"name": "comments_post_id_posts_id_fk",
"tableFrom": "comments",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"comments_parent_id_comments_id_fk": {
"name": "comments_parent_id_comments_id_fk",
"tableFrom": "comments",
"tableTo": "comments",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.nav_items": {
"name": "nav_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "nav_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"page_id": {
"name": "page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"nav_items_page_id_pages_id_fk": {
"name": "nav_items_page_id_pages_id_fk",
"tableFrom": "nav_items",
"tableTo": "pages",
"columnsFrom": [
"page_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"nav_items_target_check": {
"name": "nav_items_target_check",
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
}
},
"isRLSEnabled": false
},
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "pages_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pages_slug_unique": {
"name": "pages_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.post_tags": {
"name": "post_tags",
"schema": "",
"columns": {
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"post_tags_tag_id_idx": {
"name": "post_tags_tag_id_idx",
"columns": [
{
"expression": "tag_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"name": "post_tags_post_id_tag_id_pk",
"columns": [
"post_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "posts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"featured_image_url": {
"name": "featured_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"featured_image_alt": {
"name": "featured_image_alt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"site_title": {
"name": "site_title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'My Blog'"
},
"header_text": {
"name": "header_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"posts_per_page": {
"name": "posts_per_page",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"excerpt_words": {
"name": "excerpt_words",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 40
},
"home_mode": {
"name": "home_mode",
"type": "home_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'posts'"
},
"home_tag_id": {
"name": "home_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"home_page_id": {
"name": "home_page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "theme",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'solarized-dark'"
},
"font": {
"name": "font",
"type": "font",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'geist'"
}
},
"indexes": {},
"foreignKeys": {
"settings_home_tag_id_tags_id_fk": {
"name": "settings_home_tag_id_tags_id_fk",
"tableFrom": "settings",
"tableTo": "tags",
"columnsFrom": [
"home_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"settings_home_page_id_pages_id_fk": {
"name": "settings_home_page_id_pages_id_fk",
"tableFrom": "settings",
"tableTo": "pages",
"columnsFrom": [
"home_page_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"settings_single_row_check": {
"name": "settings_single_row_check",
"value": "\"settings\".\"id\" = 1"
},
"settings_posts_per_page_check": {
"name": "settings_posts_per_page_check",
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
},
"settings_excerpt_words_check": {
"name": "settings_excerpt_words_check",
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
}
},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tags_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"tags_slug_unique": {
"name": "tags_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.comment_status": {
"name": "comment_status",
"schema": "public",
"values": [
"pending",
"approved"
]
},
"public.content_status": {
"name": "content_status",
"schema": "public",
"values": [
"draft",
"published"
]
},
"public.font": {
"name": "font",
"schema": "public",
"values": [
"geist",
"inter",
"lora",
"merriweather",
"jetbrains-mono",
"source-serif",
"eb-garamond",
"playfair-display",
"open-sans",
"work-sans",
"atkinson-hyperlegible",
"space-grotesk"
]
},
"public.home_mode": {
"name": "home_mode",
"schema": "public",
"values": [
"posts",
"tag",
"page"
]
},
"public.theme": {
"name": "theme",
"schema": "public",
"values": [
"solarized-dark",
"solarized-light",
"dracula",
"nord",
"gruvbox-dark",
"mono",
"mono-dark",
"catppuccin-mocha",
"catppuccin-latte",
"tokyo-night",
"one-dark",
"rose-pine",
"everforest-dark",
"monokai",
"github-light"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -29,6 +29,13 @@
"when": 1783037839786,
"tag": "0003_more-themes-fonts",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1783213112664,
"tag": "0004_comments",
"breakpoints": true
}
]
}

78
src/actions/comments.ts Normal file
View file

@ -0,0 +1,78 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import {
createComment,
deleteComment,
setCommentStatus,
} from "@/lib/services/comments";
import { commentFormSchema } from "@/lib/validation";
/**
* The one unauthenticated mutation in the app. Safe because the result is
* always a pending comment nothing shows publicly until an admin approves
* it and the service re-checks that the post is published and the parent
* comment is approved.
*/
export async function submitCommentAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
// Honeypot: real visitors never see this field; bots that fill it get a
// fake success so they don't learn to skip it.
const honeypot = formData.get("website");
if (typeof honeypot === "string" && honeypot !== "") {
return { status: "success" };
}
const parsed = commentFormSchema.safeParse({
postId: formData.get("postId"),
parentId: formData.get("parentId"),
authorName: formData.get("authorName"),
authorEmail: formData.get("authorEmail"),
emailPublic: formData.get("emailPublic"),
body: formData.get("body"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
const data = parsed.data;
try {
const result = await createComment({
postId: data.postId,
parentId: data.parentId,
authorName: data.authorName,
authorEmail: data.authorEmail,
emailPublic: data.emailPublic,
body: data.body,
});
if (!result.ok) return { formError: result.error };
} catch (error) {
console.error("submitCommentAction failed", error);
return { formError: "Something went wrong while posting. Please try again." };
}
// Pending comments are invisible publicly, so nothing to revalidate here.
return { status: "success" };
}
export async function setCommentStatusAction(
id: number,
status: "pending" | "approved",
): Promise<void> {
await requireAdmin();
const commentId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["pending", "approved"]).parse(status);
await setCommentStatus(commentId, nextStatus);
revalidatePath("/", "layout");
}
export async function deleteCommentAction(id: number): Promise<void> {
await requireAdmin();
const commentId = z.number().int().positive().parse(id);
await deleteComment(commentId);
revalidatePath("/", "layout");
}

View file

@ -1,7 +1,10 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { submitCommentAction } from "@/actions/comments";
import { CommentsSection } from "@/components/public/CommentsSection";
import { PostArticle } from "@/components/public/PostArticle";
import { generateExcerpt } from "@/lib/excerpt";
import { listApprovedComments } from "@/lib/services/comments";
import { getPublishedPostBySlug } from "@/lib/services/posts";
type Props = { params: Promise<{ slug: string }> };
@ -18,5 +21,11 @@ export default async function PostPage({ params }: Props) {
// Draft posts are filtered inside the query — they 404 like unknown slugs.
const post = await getPublishedPostBySlug(slug);
if (!post) notFound();
return <PostArticle post={post} />;
const comments = await listApprovedComments(post.id);
return (
<>
<PostArticle post={post} />
<CommentsSection postId={post.id} comments={comments} action={submitCommentAction} />
</>
);
}

View file

@ -0,0 +1,112 @@
import type { Metadata } from "next";
import Link from "next/link";
import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Button } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments";
export const metadata: Metadata = { title: "Comments" };
function CommentCard({ comment }: { comment: AdminComment }) {
return (
<li className="rounded-lg border border-edge bg-surface p-4">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="font-semibold text-ink-strong">{comment.authorName}</span>
<span className="text-xs text-ink-muted">
{comment.authorEmail}{" "}
<span
className={
comment.emailPublic
? "rounded border border-warning/40 px-1 text-warning"
: "rounded border border-edge px-1"
}
>
{comment.emailPublic ? "email shown publicly" : "email private"}
</span>
</span>
<span className="text-xs text-ink-muted">{formatDate(comment.createdAt)}</span>
</div>
<p className="mt-1 text-xs text-ink-muted">
On{" "}
<Link
href={`/posts/${comment.postSlug}`}
className="text-link hover:underline"
>
{comment.postTitle}
</Link>
{comment.parentAuthorName && <> · replying to {comment.parentAuthorName}</>}
</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-ink">
{comment.body}
</p>
<div className="mt-3 flex items-center gap-2">
{comment.status === "pending" ? (
<form action={setCommentStatusAction.bind(null, comment.id, "approved")}>
<Button type="submit" className="px-3 py-1.5 text-xs">Approve</Button>
</form>
) : (
<form action={setCommentStatusAction.bind(null, comment.id, "pending")}>
<Button type="submit" variant="secondary" className="px-3 py-1.5 text-xs">
Unapprove
</Button>
</form>
)}
<form action={deleteCommentAction.bind(null, comment.id)}>
<ConfirmButton
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
className="px-3 py-1.5 text-xs"
>
Delete
</ConfirmButton>
</form>
</div>
</li>
);
}
export default async function AdminCommentsPage() {
await requireAdmin();
const all = await listCommentsForAdmin();
const pending = all.filter((c) => c.status === "pending");
const approved = all.filter((c) => c.status === "approved");
return (
<div className="max-w-3xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Comments</h1>
<section aria-labelledby="pending-heading">
<h2 id="pending-heading" className="text-lg font-semibold text-ink-strong">
Awaiting approval {pending.length > 0 && `(${pending.length})`}
</h2>
{pending.length === 0 ? (
<p className="mt-3 rounded-lg border border-dashed border-edge-strong px-4 py-8 text-center text-sm text-ink-muted">
Nothing waiting all caught up.
</p>
) : (
<ul className="mt-3 space-y-3">
{pending.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
))}
</ul>
)}
</section>
<section aria-labelledby="approved-heading" className="mt-10">
<h2 id="approved-heading" className="text-lg font-semibold text-ink-strong">
Approved {approved.length > 0 && `(${approved.length})`}
</h2>
{approved.length === 0 ? (
<p className="mt-3 text-sm text-ink-muted">No approved comments yet.</p>
) : (
<ul className="mt-3 space-y-3">
{approved.map((comment) => (
<CommentCard key={comment.id} comment={comment} />
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -1,6 +1,7 @@
import Link from "next/link";
import { logoutAction } from "@/actions/auth";
import { requireAdmin } from "@/lib/auth/dal";
import { countCommentsByStatus } from "@/lib/services/comments";
import { getSettings } from "@/lib/services/settings";
const navLinkClasses =
@ -13,7 +14,10 @@ const navLinkClasses =
*/
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await requireAdmin();
const settings = await getSettings();
const [settings, commentCounts] = await Promise.all([
getSettings(),
countCommentsByStatus(),
]);
return (
<>
@ -32,6 +36,16 @@ export default async function AdminLayout({ children }: { children: React.ReactN
<li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
<li>
<Link href="/admin/comments" className={navLinkClasses}>
Comments
{commentCounts.pending > 0 && (
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
{commentCounts.pending}
</span>
)}
</Link>
</li>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
</ul>
</nav>

View file

@ -4,6 +4,7 @@ import { StatusBadge } from "@/components/admin/StatusBadge";
import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { countCommentsByStatus } from "@/lib/services/comments";
import { listAllPages } from "@/lib/services/pages";
import { countPostsByStatus, listAllPosts } from "@/lib/services/posts";
import { listAllTags } from "@/lib/services/tags";
@ -12,11 +13,12 @@ export const metadata: Metadata = { title: "Dashboard" };
export default async function AdminDashboard() {
await requireAdmin();
const [postCounts, allPosts, allPages, allTags] = await Promise.all([
const [postCounts, allPosts, allPages, allTags, commentCounts] = await Promise.all([
countPostsByStatus(),
listAllPosts(),
listAllPages(),
listAllTags(),
countCommentsByStatus(),
]);
const recentPosts = allPosts.slice(0, 5);
@ -25,6 +27,7 @@ export default async function AdminDashboard() {
{ label: "Draft posts", value: postCounts.draft },
{ label: "Pages", value: allPages.length },
{ label: "Tags", value: allTags.length },
{ label: "Pending comments", value: commentCounts.pending, href: "/admin/comments" },
];
return (
@ -37,10 +40,18 @@ export default async function AdminDashboard() {
</div>
</div>
<dl className="mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4">
<dl className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border border-edge bg-surface p-5">
<dt className="text-sm text-ink-muted">{stat.label}</dt>
<dt className="text-sm text-ink-muted">
{stat.href ? (
<Link href={stat.href} className="transition-colors hover:text-link">
{stat.label}
</Link>
) : (
stat.label
)}
</dt>
<dd className="mt-1 text-3xl font-semibold text-ink-bright">{stat.value}</dd>
</div>
))}

View file

@ -0,0 +1,240 @@
"use client";
import { useActionState, useId, useState } from "react";
import { Button, ErrorText, HelpText, Input, Label, Textarea } from "@/components/ui";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import { formatDate, isoDate } from "@/lib/format";
import type { PublicComment } from "@/lib/services/comments";
type CommentAction = (prev: FormState, formData: FormData) => Promise<FormState>;
function countComments(list: PublicComment[]): number {
return list.reduce((sum, c) => sum + 1 + countComments(c.replies), 0);
}
function CommentForm({
postId,
parentId,
action,
onCancel,
}: {
postId: number;
parentId: number | null;
action: CommentAction;
onCancel?: () => void;
}) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const err = (field: string) => firstFieldError(state, field);
if (state.status === "success") {
return (
<p
role="status"
className="rounded-md border border-success/40 bg-success/10 px-4 py-3 text-sm text-success"
>
Thanks! Your comment is awaiting moderation and will appear once approved.
</p>
);
}
return (
<form action={formAction} className="space-y-4">
{state.formError && (
<p
role="alert"
className="rounded-md border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger"
>
{state.formError}
</p>
)}
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="parentId" value={parentId ?? ""} />
{/* Honeypot — hidden from people, tempting to bots. */}
<div className="hidden" aria-hidden="true">
<label htmlFor={`${ids}-website`}>Website</label>
<input id={`${ids}-website`} name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor={`${ids}-name`}>Name</Label>
<Input
id={`${ids}-name`}
name="authorName"
required
maxLength={120}
autoComplete="name"
aria-invalid={err("authorName") ? true : undefined}
/>
<ErrorText>{err("authorName")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-email`}>Email</Label>
<Input
id={`${ids}-email`}
name="authorEmail"
type="email"
required
maxLength={254}
autoComplete="email"
aria-invalid={err("authorEmail") ? true : undefined}
/>
<ErrorText>{err("authorEmail")}</ErrorText>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink">
<input type="checkbox" name="emailPublic" className="mt-0.5 size-4 accent-(--link)" />
<span>
Show my email publicly
<span className="block text-xs text-ink-muted">
Leave unchecked to keep your email visible to the site owner only.
</span>
</span>
</label>
<div>
<Label htmlFor={`${ids}-body`}>Comment</Label>
<Textarea
id={`${ids}-body`}
name="body"
required
rows={parentId === null ? 5 : 3}
maxLength={5000}
aria-invalid={err("body") ? true : undefined}
/>
<ErrorText>{err("body")}</ErrorText>
</div>
<div className="flex items-center gap-3">
<Button type="submit">{parentId === null ? "Post comment" : "Post reply"}</Button>
{onCancel && (
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
<HelpText>Comments are reviewed before they appear.</HelpText>
</div>
</form>
);
}
function CommentItem({
comment,
postId,
action,
replyTo,
setReplyTo,
depth,
}: {
comment: PublicComment;
postId: number;
action: CommentAction;
replyTo: number | null;
setReplyTo: (id: number | null) => void;
depth: number;
}) {
return (
<li>
<article className="rounded-lg border border-edge bg-surface p-4">
<header className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="font-semibold text-ink-strong">{comment.authorName}</span>
{comment.authorEmail && (
<a
href={`mailto:${comment.authorEmail}`}
className="text-xs text-link hover:underline"
>
{comment.authorEmail}
</a>
)}
<time dateTime={isoDate(comment.createdAt)} className="text-xs text-ink-muted">
{formatDate(comment.createdAt)}
</time>
</header>
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">
{comment.body}
</p>
<footer className="mt-2">
<button
type="button"
className="text-xs font-medium text-link hover:underline"
onClick={() => setReplyTo(replyTo === comment.id ? null : comment.id)}
>
{replyTo === comment.id ? "Close reply form" : "Reply"}
</button>
</footer>
</article>
{replyTo === comment.id && (
<div className="mt-3 border-l-2 border-edge-strong pl-4">
<CommentForm
postId={postId}
parentId={comment.id}
action={action}
onCancel={() => setReplyTo(null)}
/>
</div>
)}
{comment.replies.length > 0 && (
// Cap the visual indent so deep threads stay readable on phones.
<ul className={`mt-3 space-y-3 ${depth < 4 ? "border-l-2 border-edge pl-4 sm:pl-6" : ""}`}>
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
postId={postId}
action={action}
replyTo={replyTo}
setReplyTo={setReplyTo}
depth={depth + 1}
/>
))}
</ul>
)}
</li>
);
}
export function CommentsSection({
postId,
comments,
action,
}: {
postId: number;
comments: PublicComment[];
action: CommentAction;
}) {
const [replyTo, setReplyTo] = useState<number | null>(null);
const total = countComments(comments);
const ids = useId();
return (
<section aria-labelledby={`${ids}-comments`} className="mx-auto mt-12 max-w-[46rem] border-t border-edge pt-8">
<h2 id={`${ids}-comments`} className="text-xl font-bold tracking-tight text-ink-bright">
{total === 0 ? "Comments" : total === 1 ? "1 comment" : `${total} comments`}
</h2>
{total === 0 ? (
<p className="mt-4 text-sm text-ink-muted">No comments yet. Start the conversation!</p>
) : (
<ul className="mt-6 space-y-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
postId={postId}
action={action}
replyTo={replyTo}
setReplyTo={setReplyTo}
depth={0}
/>
))}
</ul>
)}
<div className="mt-10">
<h3 className="mb-4 text-lg font-semibold text-ink-strong">Leave a comment</h3>
<CommentForm postId={postId} parentId={null} action={action} />
</div>
</section>
);
}

View file

@ -1,5 +1,7 @@
import { sql } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
check,
index,
integer,
@ -11,6 +13,7 @@ import {
} from "drizzle-orm/pg-core";
export const contentStatusEnum = pgEnum("content_status", ["draft", "published"]);
export const commentStatusEnum = pgEnum("comment_status", ["pending", "approved"]);
export const homeModeEnum = pgEnum("home_mode", ["posts", "tag", "page"]);
export const themeEnum = pgEnum("theme", [
"solarized-dark",
@ -101,6 +104,34 @@ export const postTags = pgTable(
],
);
export const comments = pgTable(
"comments",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
// Threading: replies point at their parent; deleting a comment removes
// its whole subtree via the cascade.
parentId: integer("parent_id").references((): AnyPgColumn => comments.id, {
onDelete: "cascade",
}),
authorName: text("author_name").notNull(),
// Always required (spam accountability); shown publicly only when the
// commenter opted in via emailPublic.
authorEmail: text("author_email").notNull(),
emailPublic: boolean("email_public").notNull().default(false),
body: text("body").notNull(),
status: commentStatusEnum("status").notNull().default("pending"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
index("comments_post_id_status_idx").on(t.postId, t.status),
index("comments_parent_id_idx").on(t.parentId),
index("comments_status_idx").on(t.status),
],
);
export const pages = pgTable("pages", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
@ -155,7 +186,9 @@ export type Tag = typeof tags.$inferSelect;
export type Page = typeof pages.$inferSelect;
export type Settings = typeof settings.$inferSelect;
export type NavItem = typeof navItems.$inferSelect;
export type Comment = typeof comments.$inferSelect;
export type ContentStatus = Post["status"];
export type CommentStatus = Comment["status"];
export type HomeMode = Settings["homeMode"];
export type Theme = Settings["theme"];
export type Font = Settings["font"];

View file

@ -0,0 +1,171 @@
import { and, asc, count, desc, eq } from "drizzle-orm";
import { alias } from "drizzle-orm/pg-core";
import { db } from "@/db";
import { type Comment, type CommentStatus, comments, posts } from "@/db/schema";
export type CommentInput = {
postId: number;
/** null for a top-level comment, otherwise the comment being replied to. */
parentId: number | null;
authorName: string;
authorEmail: string;
emailPublic: boolean;
body: string;
};
export type CreateCommentResult =
| { ok: true; comment: Comment }
| { ok: false; error: string };
/**
* Public submission path: verifies the target post is published and the
* parent (if any) is an approved comment on the same post, then stores the
* comment as pending. Nothing a visitor submits is visible until approved.
*/
export async function createComment(input: CommentInput): Promise<CreateCommentResult> {
const [post] = await db
.select({ id: posts.id })
.from(posts)
.where(and(eq(posts.id, input.postId), eq(posts.status, "published")))
.limit(1);
if (!post) return { ok: false, error: "This post does not accept comments." };
if (input.parentId !== null) {
const [parent] = await db
.select({ id: comments.id })
.from(comments)
.where(
and(
eq(comments.id, input.parentId),
eq(comments.postId, input.postId),
eq(comments.status, "approved"),
),
)
.limit(1);
if (!parent) {
return { ok: false, error: "The comment you are replying to no longer exists." };
}
}
const [comment] = await db
.insert(comments)
.values({
postId: input.postId,
parentId: input.parentId,
authorName: input.authorName,
authorEmail: input.authorEmail,
emailPublic: input.emailPublic,
body: input.body,
status: "pending",
})
.returning();
return { ok: true, comment };
}
/** What the public site sees: private emails are never in the payload. */
export type PublicComment = {
id: number;
authorName: string;
/** Present only when the commenter chose to show it. */
authorEmail: string | null;
body: string;
createdAt: Date;
replies: PublicComment[];
};
/**
* Approved comments for a post as a tree, oldest first at every level.
* A reply whose parent is still pending stays hidden until the parent is
* approved a thread never renders out of context.
*/
export async function listApprovedComments(postId: number): Promise<PublicComment[]> {
const rows = await db
.select()
.from(comments)
.where(and(eq(comments.postId, postId), eq(comments.status, "approved")))
.orderBy(asc(comments.createdAt), asc(comments.id));
const nodes = new Map<number, PublicComment>();
for (const row of rows) {
nodes.set(row.id, {
id: row.id,
authorName: row.authorName,
authorEmail: row.emailPublic ? row.authorEmail : null,
body: row.body,
createdAt: row.createdAt,
replies: [],
});
}
const roots: PublicComment[] = [];
for (const row of rows) {
const node = nodes.get(row.id)!;
const parent = row.parentId !== null ? nodes.get(row.parentId) : undefined;
if (row.parentId === null) {
roots.push(node);
} else if (parent) {
parent.replies.push(node);
}
// else: parent not approved (or deleted mid-query) — hide the reply.
}
return roots;
}
export type AdminComment = Comment & {
postTitle: string;
postSlug: string;
/** Author of the parent comment, when this is a reply. */
parentAuthorName: string | null;
};
/** Admin moderation list: every comment with its post, newest first. */
export async function listCommentsForAdmin(): Promise<AdminComment[]> {
const parent = alias(comments, "parent");
const rows = await db
.select({
comment: comments,
postTitle: posts.title,
postSlug: posts.slug,
parentAuthorName: parent.authorName,
})
.from(comments)
.innerJoin(posts, eq(posts.id, comments.postId))
.leftJoin(parent, eq(parent.id, comments.parentId))
.orderBy(desc(comments.createdAt), desc(comments.id));
return rows.map((r) => ({
...r.comment,
postTitle: r.postTitle,
postSlug: r.postSlug,
parentAuthorName: r.parentAuthorName ?? null,
}));
}
export async function setCommentStatus(
id: number,
status: CommentStatus,
): Promise<Comment | null> {
const [comment] = await db
.update(comments)
.set({ status })
.where(eq(comments.id, id))
.returning();
return comment ?? null;
}
/** Deletes the comment and, via FK cascade, every reply beneath it. */
export async function deleteComment(id: number): Promise<void> {
await db.delete(comments).where(eq(comments.id, id));
}
export async function countCommentsByStatus(): Promise<{
pending: number;
approved: number;
}> {
const rows = await db
.select({ status: comments.status, value: count() })
.from(comments)
.groupBy(comments.status);
const result = { pending: 0, approved: 0 };
for (const row of rows) result[row.status] = row.value;
return result;
}

View file

@ -1,7 +1,17 @@
import { asc, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { fontEnum, navItems, pages, postTags, posts, settings, tags, themeEnum } from "@/db/schema";
import {
comments,
fontEnum,
navItems,
pages,
postTags,
posts,
settings,
tags,
themeEnum,
} from "@/db/schema";
import { sanitizeHtml } from "@/lib/html";
import { isValidLinkUrl } from "@/lib/validation";
import { getSettings } from "./settings";
@ -12,7 +22,9 @@ import { getSettings } from "./settings";
* columns are regenerated on import).
*/
export const SITE_EXPORT_FORMAT = "yap-blog-export";
export const SITE_EXPORT_VERSION = 1;
// v1: posts/pages/tags/nav/settings. v2 adds comments. v1 files still
// import fine (they simply carry no comments).
export const SITE_EXPORT_VERSION = 2;
const slugValue = z.string().trim().min(1).max(120);
const statusValue = z.enum(["draft", "published"]);
@ -22,7 +34,7 @@ const timestampValue = z.coerce.date();
export const siteExportSchema = z
.object({
format: z.literal(SITE_EXPORT_FORMAT),
version: z.literal(SITE_EXPORT_VERSION),
version: z.union([z.literal(1), z.literal(2)]),
exportedAt: timestampValue,
settings: z.object({
siteTitle: z.string().trim().min(1).max(120),
@ -77,6 +89,23 @@ export const siteExportSchema = z
}),
)
.max(50_000),
comments: z
.array(
z.object({
/** Id local to this file (the exporter's DB id); remapped on import. */
id: z.number().int().positive(),
postSlug: slugValue,
parentId: z.number().int().positive().nullable(),
authorName: z.string().trim().min(1).max(120),
authorEmail: z.string().trim().min(1).max(254),
emailPublic: z.boolean(),
body: z.string().min(1).max(5_000),
status: z.enum(["pending", "approved"]),
createdAt: timestampValue,
}),
)
.max(100_000)
.default([]),
})
.superRefine((data, ctx) => {
const duplicate = (values: string[]): string | undefined => {
@ -150,6 +179,55 @@ export const siteExportSchema = z
});
}
}
const postSlugs = new Set(data.posts.map((p) => p.slug));
const commentById = new Map(data.comments.map((c) => [c.id, c]));
if (commentById.size !== data.comments.length) {
ctx.addIssue({ code: "custom", message: "Duplicate comment ids in the export." });
return; // parent-chain checks below assume unique ids
}
for (const comment of data.comments) {
if (!postSlugs.has(comment.postSlug)) {
ctx.addIssue({
code: "custom",
message: `Comment ${comment.id} references unknown post “${comment.postSlug}”.`,
});
continue;
}
if (comment.parentId === null) continue;
const parent = commentById.get(comment.parentId);
if (!parent) {
ctx.addIssue({
code: "custom",
message: `Comment ${comment.id} replies to unknown comment ${comment.parentId}.`,
});
} else if (parent.postSlug !== comment.postSlug) {
ctx.addIssue({
code: "custom",
message: `Comment ${comment.id} replies to a comment on a different post.`,
});
}
}
// Reject parent cycles (impossible via the app, possible in a crafted
// file) — the importer's parents-first insertion would never terminate.
const commentDepthCache = new Map<number, boolean>();
for (const comment of data.comments) {
const seen = new Set<number>();
let current: typeof comment | undefined = comment;
while (current && current.parentId !== null) {
if (commentDepthCache.get(current.id)) break; // known-good chain
if (seen.has(current.id)) {
ctx.addIssue({
code: "custom",
message: `Comment ${comment.id} is part of a reply cycle.`,
});
return;
}
seen.add(current.id);
current = commentById.get(current.parentId);
}
for (const id of seen) commentDepthCache.set(id, true);
}
});
export type SiteExport = z.infer<typeof siteExportSchema>;
@ -176,7 +254,8 @@ export function parseSiteExportJson(
/** Snapshot of everything the admin can edit: settings, nav, tags, pages, posts. */
export async function buildSiteExport(): Promise<SiteExport> {
const [settingsRow, navRows, tagRows, pageRows, postRows, postTagRows] = await Promise.all([
const [settingsRow, navRows, tagRows, pageRows, postRows, postTagRows, commentRows] =
await Promise.all([
getSettings(),
db
.select({ item: navItems, pageSlug: pages.slug })
@ -191,6 +270,11 @@ export async function buildSiteExport(): Promise<SiteExport> {
.from(postTags)
.innerJoin(tags, eq(tags.id, postTags.tagId))
.orderBy(asc(tags.slug)),
db
.select({ comment: comments, postSlug: posts.slug })
.from(comments)
.innerJoin(posts, eq(posts.id, comments.postId))
.orderBy(asc(comments.id)),
]);
const tagSlugsByPost = new Map<number, string[]>();
@ -243,6 +327,17 @@ export async function buildSiteExport(): Promise<SiteExport> {
publishedAt: p.publishedAt,
tagSlugs: tagSlugsByPost.get(p.id) ?? [],
})),
comments: commentRows.map(({ comment, postSlug }) => ({
id: comment.id,
postSlug,
parentId: comment.parentId,
authorName: comment.authorName,
authorEmail: comment.authorEmail,
emailPublic: comment.emailPublic,
body: comment.body,
status: comment.status,
createdAt: comment.createdAt,
})),
};
}
@ -260,8 +355,9 @@ function chunk<T>(items: T[], size: number): T[][] {
*/
export async function importSiteExport(data: SiteExport): Promise<void> {
await db.transaction(async (tx) => {
// Deleting posts/pages/tags cascades post_tags and page nav items away;
// the old settings row's home references become NULL via ON DELETE SET NULL.
// Deleting posts/pages/tags cascades post_tags, comments, and page nav
// items away; the old settings row's home references become NULL via
// ON DELETE SET NULL.
await tx.delete(navItems);
await tx.delete(posts);
await tx.delete(pages);
@ -296,6 +392,7 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
for (const row of inserted) pageIdBySlug.set(row.slug, row.id);
}
const postIdBySlug = new Map<string, number>();
const links: Array<{ postId: number; tagId: number }> = [];
for (const batch of chunk(data.posts, 1000)) {
const inserted = await tx
@ -315,9 +412,9 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
})),
)
.returning({ id: posts.id, slug: posts.slug });
const idBySlug = new Map(inserted.map((row) => [row.slug, row.id]));
for (const row of inserted) postIdBySlug.set(row.slug, row.id);
for (const post of batch) {
const postId = idBySlug.get(post.slug);
const postId = postIdBySlug.get(post.slug);
if (postId === undefined) continue;
for (const tagSlug of post.tagSlugs) {
const tagId = tagIdBySlug.get(tagSlug);
@ -329,6 +426,43 @@ export async function importSiteExport(data: SiteExport): Promise<void> {
await tx.insert(postTags).values(batch);
}
// Comments insert parents-first so replies can point at fresh ids;
// the schema validation above guarantees the parent graph is acyclic,
// so every pass makes progress. Comment bodies are plain text (rendered
// escaped), so no HTML sanitizing is needed.
const commentIdByLocal = new Map<number, number>();
let pendingComments = data.comments;
while (pendingComments.length > 0) {
const ready = pendingComments.filter(
(c) => c.parentId === null || commentIdByLocal.has(c.parentId),
);
if (ready.length === 0) {
// Unreachable after schema validation; guards the loop all the same.
throw new Error("Comment import stalled on an unresolvable parent reference.");
}
for (const batch of chunk(ready, 1000)) {
const inserted = await tx
.insert(comments)
.values(
batch.map((c) => ({
postId: postIdBySlug.get(c.postSlug)!,
parentId: c.parentId === null ? null : commentIdByLocal.get(c.parentId)!,
authorName: c.authorName,
authorEmail: c.authorEmail,
emailPublic: c.emailPublic,
body: c.body,
status: c.status,
createdAt: c.createdAt,
})),
)
// Postgres returns multi-row INSERT ... RETURNING rows in values
// order, which is what lets us zip local ids to new ids here.
.returning({ id: comments.id });
batch.forEach((c, i) => commentIdByLocal.set(c.id, inserted[i].id));
}
pendingComments = pendingComments.filter((c) => !commentIdByLocal.has(c.id));
}
if (data.navItems.length > 0) {
await tx.insert(navItems).values(
data.navItems.map((item, index) => ({

View file

@ -54,6 +54,29 @@ export const pageFormSchema = z.object({
});
export type PageFormData = z.infer<typeof pageFormSchema>;
export const commentFormSchema = z.object({
postId: z.coerce.number().int().positive(),
parentId: z.preprocess(
(v) => (v === "" || v === null || v === undefined ? null : v),
z.coerce.number().int().positive().nullable(),
),
authorName: z.string().trim().min(1, "Name is required.").max(120, "Name is too long."),
authorEmail: z
.string()
.trim()
.min(1, "Email is required.")
.max(254, "Email is too long.")
.pipe(z.email("Enter a valid email address.")),
// Checkbox: present ("on") when ticked, absent otherwise.
emailPublic: z.preprocess((v) => v === "on" || v === "true" || v === true, z.boolean()),
body: z
.string()
.trim()
.min(1, "Comment cannot be empty.")
.max(5000, "Comments are limited to 5000 characters."),
});
export type CommentFormData = z.infer<typeof commentFormSchema>;
export const loginFormSchema = z.object({
username: z.string().trim().min(1, "Username is required.").max(120),
password: z.string().min(1, "Password is required.").max(200),

View file

@ -4,6 +4,6 @@ import { db } from "@/db";
/** Wipes every table between tests; identities restart at 1. */
export async function resetDb(): Promise<void> {
await db.execute(
sql`TRUNCATE users, sessions, posts, tags, post_tags, pages, nav_items, settings RESTART IDENTITY CASCADE`,
sql`TRUNCATE users, sessions, posts, tags, post_tags, pages, nav_items, settings, comments RESTART IDENTITY CASCADE`,
);
}

View file

@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
type CommentInput,
countCommentsByStatus,
createComment,
deleteComment,
listApprovedComments,
listCommentsForAdmin,
setCommentStatus,
} from "@/lib/services/comments";
import { createPost, type PostInput } from "@/lib/services/posts";
import { resetDb } from "../helpers/db";
const postInput = (overrides: Partial<PostInput> = {}): PostInput => ({
title: `Post ${Math.random().toString(36).slice(2, 8)}`,
slug: "",
body: "body",
authorName: "Tester",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
tagIds: [],
newTagNames: [],
...overrides,
});
const commentInput = (
postId: number,
overrides: Partial<CommentInput> = {},
): CommentInput => ({
postId,
parentId: null,
authorName: "Alice",
authorEmail: "alice@example.com",
emailPublic: false,
body: "Nice post!",
...overrides,
});
beforeEach(resetDb);
describe("comment moderation", () => {
it("stores new comments as pending and hides them publicly", async () => {
const post = await createPost(postInput());
const result = await createComment(commentInput(post.id));
expect(result.ok).toBe(true);
expect(await listApprovedComments(post.id)).toEqual([]);
const counts = await countCommentsByStatus();
expect(counts).toEqual({ pending: 1, approved: 0 });
});
it("shows comments publicly only after approval", async () => {
const post = await createPost(postInput());
const created = await createComment(commentInput(post.id));
if (!created.ok) throw new Error("expected ok");
await setCommentStatus(created.comment.id, "approved");
const visible = await listApprovedComments(post.id);
expect(visible).toHaveLength(1);
expect(visible[0].authorName).toBe("Alice");
});
it("rejects comments on drafts and unknown posts", async () => {
const draft = await createPost(postInput({ status: "draft" }));
expect((await createComment(commentInput(draft.id))).ok).toBe(false);
expect((await createComment(commentInput(999_999))).ok).toBe(false);
});
it("hides private emails and shows public ones", async () => {
const post = await createPost(postInput());
const priv = await createComment(commentInput(post.id));
const pub = await createComment(
commentInput(post.id, { authorName: "Bob", authorEmail: "bob@example.com", emailPublic: true }),
);
if (!priv.ok || !pub.ok) throw new Error("expected ok");
await setCommentStatus(priv.comment.id, "approved");
await setCommentStatus(pub.comment.id, "approved");
const visible = await listApprovedComments(post.id);
expect(visible.find((c) => c.authorName === "Alice")?.authorEmail).toBeNull();
expect(visible.find((c) => c.authorName === "Bob")?.authorEmail).toBe("bob@example.com");
// The admin list always carries the address.
const admin = await listCommentsForAdmin();
expect(admin.map((c) => c.authorEmail).sort()).toEqual([
"alice@example.com",
"bob@example.com",
]);
});
});
describe("threading", () => {
it("only allows replies to approved comments on the same post", async () => {
const post = await createPost(postInput());
const other = await createPost(postInput());
const parent = await createComment(commentInput(post.id));
if (!parent.ok) throw new Error("expected ok");
// Parent still pending → no replies.
const early = await createComment(
commentInput(post.id, { parentId: parent.comment.id, body: "too early" }),
);
expect(early.ok).toBe(false);
await setCommentStatus(parent.comment.id, "approved");
const reply = await createComment(
commentInput(post.id, { parentId: parent.comment.id, authorName: "Bob", body: "A reply" }),
);
expect(reply.ok).toBe(true);
// Approved parent, but wrong post.
const crossPost = await createComment(
commentInput(other.id, { parentId: parent.comment.id }),
);
expect(crossPost.ok).toBe(false);
});
it("builds a nested tree and hides replies under pending parents", async () => {
const post = await createPost(postInput());
const a = await createComment(commentInput(post.id, { body: "root A" }));
if (!a.ok) throw new Error();
await setCommentStatus(a.comment.id, "approved");
const b = await createComment(
commentInput(post.id, { parentId: a.comment.id, authorName: "Bob", body: "reply B" }),
);
if (!b.ok) throw new Error();
await setCommentStatus(b.comment.id, "approved");
const c = await createComment(
commentInput(post.id, { parentId: b.comment.id, authorName: "Cleo", body: "reply C" }),
);
if (!c.ok) throw new Error();
// C not yet approved → depth-2 tree shows A > B only.
let tree = await listApprovedComments(post.id);
expect(tree).toHaveLength(1);
expect(tree[0].replies).toHaveLength(1);
expect(tree[0].replies[0].replies).toHaveLength(0);
// Approve C → full chain visible.
await setCommentStatus(c.comment.id, "approved");
tree = await listApprovedComments(post.id);
expect(tree[0].replies[0].replies[0].body).toBe("reply C");
// Unapprove B → C disappears with it (thread context preserved).
await setCommentStatus(b.comment.id, "pending");
tree = await listApprovedComments(post.id);
expect(tree[0].replies).toHaveLength(0);
});
it("deleting a comment removes its whole subtree", async () => {
const post = await createPost(postInput());
const a = await createComment(commentInput(post.id));
if (!a.ok) throw new Error();
await setCommentStatus(a.comment.id, "approved");
const b = await createComment(commentInput(post.id, { parentId: a.comment.id }));
if (!b.ok) throw new Error();
await deleteComment(a.comment.id);
const counts = await countCommentsByStatus();
expect(counts).toEqual({ pending: 0, approved: 0 });
});
});

View file

@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it } from "vitest";
import { db } from "@/db";
import { navItems, posts, tags } from "@/db/schema";
import {
createComment,
listApprovedComments,
setCommentStatus,
} from "@/lib/services/comments";
import {
buildSiteExport,
importSiteExport,
@ -96,6 +101,59 @@ describe("export/import round trip", () => {
expect(nav[1].url).toBe("https://example.com");
});
it("round-trips threaded comments with remapped ids and statuses", async () => {
const post = await createPost(input({ title: "Discussed" }));
const parent = await createComment({
postId: post.id,
parentId: null,
authorName: "Alice",
authorEmail: "alice@example.com",
emailPublic: true,
body: "First!",
});
if (!parent.ok) throw new Error();
await setCommentStatus(parent.comment.id, "approved");
const reply = await createComment({
postId: post.id,
parentId: parent.comment.id,
authorName: "Bob",
authorEmail: "bob@example.com",
emailPublic: false,
body: "Replying to Alice",
});
if (!reply.ok) throw new Error();
await setCommentStatus(reply.comment.id, "approved");
await createComment({
postId: post.id,
parentId: parent.comment.id,
authorName: "Spammer",
authorEmail: "spam@example.com",
emailPublic: false,
body: "pending reply",
});
const snapshot = await exportViaJson();
expect(snapshot.comments).toHaveLength(3);
await importSiteExport(snapshot);
const restored = await getPublishedPostBySlug(post.slug);
const tree = await listApprovedComments(restored!.id);
expect(tree).toHaveLength(1);
expect(tree[0].authorEmail).toBe("alice@example.com"); // opted-in email survives
expect(tree[0].replies).toHaveLength(1); // pending reply stays hidden
expect(tree[0].replies[0].body).toBe("Replying to Alice");
expect(tree[0].replies[0].authorEmail).toBeNull(); // private stays private
});
it("accepts version-1 exports (no comments field)", async () => {
const snapshot = await exportViaJson();
const v1 = { ...JSON.parse(JSON.stringify(snapshot)), version: 1 };
delete v1.comments;
const parsed = parseSiteExportJson(JSON.stringify(v1));
expect(parsed).not.toHaveProperty("error");
if ("data" in parsed) expect(parsed.data.comments).toEqual([]);
});
it("keeps draft posts and unpublished state intact", async () => {
await createPost(input({ title: "Draft", status: "draft" }));
const snapshot = await exportViaJson();
@ -163,6 +221,39 @@ describe("parseSiteExportJson", () => {
expect(result).toHaveProperty("error");
});
it("rejects reply cycles in a crafted file", async () => {
const snapshot = await exportViaJson();
snapshot.posts.push({
title: "P",
slug: "p",
body: "",
authorName: "A",
featuredImageUrl: null,
featuredImageAlt: null,
status: "published",
createdAt: new Date(),
updatedAt: new Date(),
publishedAt: new Date(),
tagSlugs: [],
});
const commentBase = {
postSlug: "p",
authorName: "X",
authorEmail: "x@example.com",
emailPublic: false,
body: "hi",
status: "approved" as const,
createdAt: new Date(),
};
snapshot.comments = [
{ id: 1, parentId: 2, ...commentBase },
{ id: 2, parentId: 1, ...commentBase },
];
const result = parseSiteExportJson(JSON.stringify(snapshot));
expect(result).toHaveProperty("error");
if ("error" in result) expect(result.error).toContain("cycle");
});
it("rejects a home tag that is not part of the export", async () => {
const snapshot = await exportViaJson();
snapshot.settings.homeMode = "tag";