yap-blog/src/db/schema.ts
matt bb3ab4561d Add moderated threaded comments
Visitors comment with just a name and email; a checkbox controls
whether the email is shown publicly (default private — only the admin
sees it). Every comment lands as pending and is invisible until
approved on the new /admin/comments page (approve / unapprove /
delete, with a pending-count badge in the admin nav and a dashboard
stat). Replies nest under their parent; a reply is only accepted on an
approved comment of the same post, and replies stay hidden while their
parent is unapproved so threads never render out of context. A hidden
honeypot field silently drops naive bots. Comment bodies are plain
text, rendered escaped.

The backup format gains a comments section (export version 2; v1 files
still import) with parent links remapped through file-local ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 21:08:08 -04:00

195 lines
6.7 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
check,
index,
integer,
pgEnum,
pgTable,
primaryKey,
text,
timestamp,
} 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",
"solarized-light",
"dracula",
"nord",
"gruvbox-dark",
"mono",
"mono-dark",
"catppuccin-mocha",
"catppuccin-latte",
"tokyo-night",
"one-dark",
"rose-pine",
"everforest-dark",
"monokai",
"github-light",
]);
export const fontEnum = pgEnum("font", [
"geist",
"inter",
"lora",
"merriweather",
"jetbrains-mono",
"source-serif",
"eb-garamond",
"playfair-display",
"open-sans",
"work-sans",
"atkinson-hyperlegible",
"space-grotesk",
]);
export const users = pgTable("users", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const sessions = pgTable("sessions", {
// sha-256 hex digest of the bearer token; the raw token never touches the DB.
id: text("id").primaryKey(),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
export const posts = pgTable(
"posts",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
body: text("body").notNull().default(""),
authorName: text("author_name").notNull(),
featuredImageUrl: text("featured_image_url"),
featuredImageAlt: text("featured_image_alt"),
status: contentStatusEnum("status").notNull().default("draft"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
publishedAt: timestamp("published_at", { withTimezone: true }),
},
(t) => [index("posts_status_published_at_idx").on(t.status, t.publishedAt)],
);
export const tags = pgTable("tags", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
name: text("name").notNull().unique(),
slug: text("slug").notNull().unique(),
});
export const postTags = pgTable(
"post_tags",
{
postId: integer("post_id")
.notNull()
.references(() => posts.id, { onDelete: "cascade" }),
tagId: integer("tag_id")
.notNull()
.references(() => tags.id, { onDelete: "cascade" }),
},
(t) => [
primaryKey({ columns: [t.postId, t.tagId] }),
index("post_tags_tag_id_idx").on(t.tagId),
],
);
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(),
slug: text("slug").notNull().unique(),
body: text("body").notNull().default(""),
status: contentStatusEnum("status").notNull().default("draft"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
});
export const settings = pgTable(
"settings",
{
// Single-row table; the row always has id = 1 (enforced below).
id: integer("id").primaryKey(),
siteTitle: text("site_title").notNull().default("My Blog"),
headerText: text("header_text").notNull().default(""),
footerText: text("footer_text").notNull().default(""),
postsPerPage: integer("posts_per_page").notNull().default(10),
excerptWords: integer("excerpt_words").notNull().default(40),
homeMode: homeModeEnum("home_mode").notNull().default("posts"),
homeTagId: integer("home_tag_id").references(() => tags.id, { onDelete: "set null" }),
homePageId: integer("home_page_id").references(() => pages.id, { onDelete: "set null" }),
theme: themeEnum("theme").notNull().default("solarized-dark"),
font: fontEnum("font").notNull().default("geist"),
},
(t) => [
check("settings_single_row_check", sql`${t.id} = 1`),
check("settings_posts_per_page_check", sql`${t.postsPerPage} BETWEEN 1 AND 50`),
check("settings_excerpt_words_check", sql`${t.excerptWords} BETWEEN 5 AND 200`),
],
);
export const navItems = pgTable(
"nav_items",
{
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
label: text("label").notNull(),
// A nav item points at exactly one target: an external/internal URL...
url: text("url"),
// ...or a static page (removed automatically when the page is deleted).
pageId: integer("page_id").references(() => pages.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
},
(t) => [check("nav_items_target_check", sql`(${t.url} IS NULL) <> (${t.pageId} IS NULL)`)],
);
export type User = typeof users.$inferSelect;
export type Session = typeof sessions.$inferSelect;
export type Post = typeof posts.$inferSelect;
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"];