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"];