Better Changelog β Feature Specification & Data Model
Version: 1.0
Date: 2026-02-04
Author: Generated from product vision, competitive analysis, and codebase audit
Status: Draft β awaiting founder review
Table of Contents
- Executive Summary
- Feature Specification
- Data Model
- API Specification
- UI/UX Requirements
- Migration Plan
1. Executive Summary
What This Is
Better Changelog is a change audit system for regulated industries, not just another changelog widget. The core differentiator is a 5-axis taxonomy that makes every change findable by what changed, why, the impact, where in the product, and when. This serves compliance officers, auditors, and clients in finance, healthcare, and government who need answers like βwhat methodology changes happened in Q3?β β not just βwhatβs new today.β
The Gap Between Vision and Current Codebase
The existing codebase is a solid multi-tenant changelog SaaS (13 tables, auth, CRUD, feeds), but itβs built as a general announcement tool rather than the audit-focused product Adam envisions. The key missing pieces:
| Requirement | Current State |
|---|---|
| 5-axis taxonomy (What/Why/Impact/Where/When) | Only 2 axes: updateType (hardcoded) + impactLevel (hardcoded) |
| Customer-configurable categories | All categories are hardcoded TypeScript enums |
| βWhyβ axis (change reason) | Completely missing |
| Entry versioning / audit trail | Edits overwrite; no history preserved |
| Full-text search | ILIKE queries only (no proper FTS) |
| Combined multi-axis filtering | Partial β type + date + search, but no reason/impact/module combos |
| Axis-based subscriptions | Subscriber table exists, but can only filter by audience/platform |
Design Principles
- Findability over freshness β The UI should make it trivially easy to find a specific past change, not just browse the latest.
- Configurable taxonomy β Every organization defines their own βWhatβ and βWhyβ categories. No hardcoded enums.
- Immutable audit trail β Published entries can be edited, but every version is preserved forever.
- Multi-tag per axis β A single entry can belong to multiple categories on each axis (e.g., both βDataβ and βMethodologyβ).
- Progressive disclosure β Simple for casual readers, powerful for investigators.
2. Feature Specification
2.1 Taxonomy & Categorization
F-TAX-01: Configurable βWhatβ Categories
Priority: P0 (MVP) | Status: Missing
Description: Organizations define their own change type categories for the βWhatβ axis. Adamβs org uses Data/Methodology/Technology, but a healthcare company might use Clinical/Regulatory/Infrastructure. Categories have a label, slug, optional color, optional icon, and sort order. Stored as a tenant-scoped table, not a TypeScript enum.
Current state: updateType is a hardcoded enum (new_feature, improvement, fix, etc.) in the schema. This must be replaced with a foreign key to an org-configurable taxonomy_values table.
F-TAX-02: Configurable βWhyβ Categories
Priority: P0 (MVP) | Status: Missing
Description: Organizations define their own change reason categories for the βWhyβ axis. Default presets: Bug fix, New feature, Scheduled refresh, Regulatory requirement, Client request, Deprecation, Security update. Each reason has a label, slug, optional color/icon.
F-TAX-03: Impact Level Axis
Priority: P0 (MVP) | Status: Partial
Description: Three-tier impact classification: Breaking, Non-breaking, Informational. Currently exists as impactLevel with values major/minor/patch β needs renaming to match the product language and should also be configurable per org (with sensible defaults).
Current state: impactLevel field exists on changelog_entries with hardcoded enum values. The values major/minor/patch are semver-oriented, not compliance-oriented. Needs migration to breaking/non_breaking/informational or (better) a configurable taxonomy axis.
F-TAX-04: Configurable βWhereβ (Modules/Components)
Priority: P0 (MVP) | Status: Partial
Description: Organizations define the modules/areas of their product. Entries are tagged with one or more modules. This is the βWhereβ axis.
Current state: The product_areas table exists and is tenant-scoped with slug, label, description, icon, color, sortOrder. The entry_product_areas junction table exists. This is largely correct β rename to βmodulesβ in the UI for clarity, and ensure the table structure aligns with the unified taxonomy system.
F-TAX-05: Multi-Tag Per Axis
Priority: P0 (MVP) | Status: Partial
Description: Every entry can have multiple values per axis. A single release might touch both Data and Methodology (What), be both a Bug fix and a Regulatory requirement (Why), and affect both the Scoring Module and the API (Where).
Current state: Multi-tagging exists for audiences, platforms, product areas, and free-text tags via junction tables. Needs extension to the What and Why axes.
F-TAX-06: Unified Taxonomy System
Priority: P0 (MVP) | Status: Missing
Description: Rather than having separate tables per axis (categories, reasons, modules), implement a unified taxonomy system with a taxonomy_axes table (defining the 5 axes) and a taxonomy_values table (the configurable options within each axis). This makes the system extensible β orgs could add custom axes in the future.
Axes:
whatβ Change category (required, multi-select)whyβ Change reason (required, multi-select)impactβ Impact level (required, single-select)whereβ Module/component (optional, multi-select)whenβ Timestamp (implicit viapublished_at)
F-TAX-07: Default Taxonomy Presets
Priority: P1 | Status: Missing
Description: When a new organization is created, seed their taxonomy with sensible defaults. Provide multiple preset βprofilesβ (e.g., βSaaS Productβ, βData Providerβ, βHealthcareβ, βFinancial Servicesβ) that pre-populate the What/Why/Where categories.
F-TAX-08: Free-Form Tags
Priority: P1 | Status: Exists
Description: In addition to the structured taxonomy, entries support arbitrary free-text tags for ad-hoc categorization.
Current state: entry_tags junction table exists. Working.
2.2 Entry Management
F-ENT-01: Create Changelog Entry
Priority: P0 (MVP) | Status: Exists
Description: Dashboard form to create a new entry with title, slug (auto-generated), optional emoji, summary, markdown body, taxonomy tags across all 5 axes, author, status (draft/scheduled/published/archived), SEO fields.
Current state: Full entry form exists at /dashboard/entries/new. Needs updating to use configurable taxonomy selectors instead of hardcoded dropdowns.
F-ENT-02: Rich Text Editor
Priority: P0 (MVP) | Status: Partial
Description: Markdown editor for body content with live preview. Support for images, code blocks, tables, links. Optional WYSIWYG/rich editor toggle for non-technical PMs.
Current state: Plain textarea for markdown body. No live preview, no WYSIWYG option. Markdown renders correctly on the public page via ReactMarkdown + remark-gfm.
MVP scope: Add a split-pane markdown editor with preview. WYSIWYG is P2.
F-ENT-03: Entry Versioning (Audit Trail)
Priority: P0 (MVP) | Status: Missing
Description: Every edit to a published entry creates a new version. The full version history is preserved and viewable. Each version records: who edited, when, what changed (diff), and the complete snapshot of the entry at that point.
Implementation: On every UPDATE to a published entry, first copy the current state to an entry_versions table, then apply the update. The public page always shows the latest version. A βVersion Historyβ link shows the timeline of changes.
Why this is P0: This is THE differentiator for regulated industries. Without an immutable audit trail, the product is just another changelog tool.
F-ENT-04: Entry Scheduling
Priority: P1 | Status: Partial
Description: Set a future date/time for an entry to auto-publish.
Current state: scheduled_for field exists in the schema. The entry form has status options including βscheduled.β However, there is no background job or cron to actually publish entries when their scheduled time arrives.
F-ENT-05: Entry Archiving
Priority: P1 | Status: Partial
Description: Archive entries to remove them from the public changelog without deleting them.
Current state: βarchivedβ is a valid status, and the dashboard has an βArchivedβ tab. But thereβs no dedicated archive/unarchive action β just changing the status dropdown.
F-ENT-06: Bulk Operations
Priority: P2 | Status: Missing
Description: Select multiple entries in the dashboard and perform bulk operations: publish, archive, delete, tag.
F-ENT-07: Entry Templates
Priority: P2 | Status: Missing
Description: Pre-defined entry templates for common change types (e.g., βData Refreshβ, βSecurity Patchβ, βBreaking API Changeβ) with pre-filled taxonomy tags and body structure.
2.3 Search & Discovery (THE Differentiator)
F-SRC-01: Full-Text Search
Priority: P0 (MVP) | Status: Partial
Description: Full-text search across entry titles, summaries, and bodies using PostgreSQLβs built-in tsvector/tsquery with proper ranking. Search results should highlight matching terms.
Current state: Search uses ILIKE '%term%' which is slow and doesnβt rank results. No search index exists.
Implementation: Add a search_vector column of type tsvector to changelog_entries, populated via trigger on insert/update. Create a GIN index. Query with ts_rank for relevance ordering.
F-SRC-02: Multi-Axis Filtering
Priority: P0 (MVP) | Status: Partial
Description: Filter entries by any combination of:
- What (change category) β multi-select
- Why (change reason) β multi-select
- Impact β single-select (breaking/non-breaking/informational)
- Where (module) β multi-select
- When (date range) β start date to end date picker
- Free-text search β combined with above
Example query: βShow me all Methodology changes that were Regulatory requirements affecting the Scoring Module in Q3 2025β
Current state: Filtering exists for update type (chips), search (text), and period (7d/30d/90d/all). Missing: reason filter, impact filter, module filter, date range picker, combined multi-axis queries.
F-SRC-03: Saved Filters / Views
Priority: P2 | Status: Missing
Description: Users can save filter combinations as named views for quick access. e.g., βQ3 Methodology Changesβ or βAll Breaking Changes This Year.β
F-SRC-04: Filter via URL Query Params
Priority: P0 (MVP) | Status: Exists
Description: All filter state is reflected in URL query parameters so filtered views can be bookmarked and shared.
Current state: Working β ?type=fix&q=search&period=30d pattern exists.
F-SRC-05: Sort Options
Priority: P1 | Status: Partial
Description: Sort results by: date (newest/oldest), impact level, change category.
Current state: Public changelog sorts by publishedAt descending only. Dashboard supports sort by title/status/publishedAt/updatedAt/createdAt.
F-SRC-06: Pagination
Priority: P1 | Status: Missing
Description: Both public changelog and dashboard entry list support pagination (cursor-based or offset) with configurable page size.
Current state: Hard limit of 50 entries with no pagination controls.
2.4 Public Changelog
F-PUB-01: Public Changelog Page
Priority: P0 (MVP) | Status: Exists (broken)
Description: The customer-facing changelog page with tenant branding, entries grouped chronologically, filter bar, search, and RSS link.
Current state: The page exists at /changelog but returns 404 on the deployed site due to tenant resolution failure (no βdemoβ tenant, no DEFAULT_TENANT_SLUG env var).
F-PUB-02: Entry Detail Page
Priority: P0 (MVP) | Status: Exists
Description: Individual entry page with full markdown rendering, metadata (type, impact, author, date, tags, platforms), structured data (JSON-LD), and back-link.
Current state: Working at /changelog/[slug]. Needs: version history link (for audit trail), βWhyβ taxonomy badges, module badges.
F-PUB-03: Visual Change Type Differentiation
Priority: P0 (MVP) | Status: Partial
Description: At a glance, users can distinguish change types visually via colored badges, icons, or sidebar indicators. Each βWhatβ category and βImpactβ level has a distinct visual treatment.
Current state: Update type badges exist with emoji + colored variants. Needs extension to configurable categories (colors/icons stored in taxonomy_values).
F-PUB-04: Custom Branding
Priority: P1 | Status: Partial
Description: Organization logo, brand colors, custom CSS for the public changelog.
Current state: logo_url and brand_colors fields exist in the tenants table. Brand colors are stored as JSON but not applied in the UI. No custom CSS support.
F-PUB-05: Custom Domain
Priority: P1 | Status: Partial
Description: Organizations can serve their changelog on a custom domain (e.g., changelog.acme.com).
Current state: custom_domain field and tenant resolution by domain exist. But no UI for configuring domains, no DNS verification flow, no SSL provisioning.
F-PUB-06: RSS/Atom/JSON Feeds
Priority: P0 (MVP) | Status: Exists (broken)
Description: Three feed formats for syndication. Feeds should support filtering (e.g., RSS feed of only βDataβ changes).
Current state: All three feed routes exist and generate valid feeds. Broken by tenant resolution issue. No per-category feed filtering.
F-PUB-07: Embeddable Widget
Priority: P2 | Status: Missing
Description: JavaScript snippet that customers embed in their own app to show a notification bell / changelog sidebar.
F-PUB-08: Subscribe Button + Email
Priority: P1 | Status: Missing
Description: Visitors can subscribe to the changelog via email. Subscribe button exists in UI but does nothing. Needs: subscription form, email confirmation flow, email delivery (Resend/SendGrid), unsubscribe link.
Current state: subscribers table exists with preferences. No subscribe form, no email integration, no confirmation flow.
2.5 Dashboard
F-DSH-01: Entry List with Status Tabs
Priority: P0 (MVP) | Status: Exists
Description: Dashboard homepage showing all entries with tabs for All/Draft/Scheduled/Published/Archived, search, and action buttons.
Current state: Working.
F-DSH-02: Taxonomy Management UI
Priority: P0 (MVP) | Status: Missing
Description: Settings page where org admins configure their taxonomy:
- Define βWhatβ categories (add/edit/reorder/deactivate)
- Define βWhyβ reasons (add/edit/reorder/deactivate)
- Define βImpactβ levels (typically fixed at 3, but allow label customization)
- Define βWhereβ modules (add/edit/reorder/deactivate)
Each value can have: label, slug, color, icon, description, sort order, active/inactive toggle.
F-DSH-03: Settings Page
Priority: P1 | Status: Missing
Description: Organization settings: name, slug, logo, brand colors, default timezone, custom domain configuration, subscription settings (enable/disable, default frequency), feed settings.
Current state: Dashboard sidebar links to /dashboard/settings but no page exists.
F-DSH-04: Team Management
Priority: P1 | Status: Missing
Description: Invite team members, assign roles (owner/admin/editor/viewer), remove members.
Current state: tenant_members table exists with roles and invite tracking fields. No UI for team management.
F-DSH-05: Entry Analytics
Priority: P2 | Status: Missing
Description: View counts, engagement metrics per entry. Top viewed entries, trending categories, activity timeline.
Current state: analytics_events table exists. Nothing writes to it.
F-DSH-06: Mobile-Responsive Dashboard
Priority: P1 | Status: Missing
Description: Dashboard sidebar collapses on mobile with hamburger menu.
Current state: Fixed 256px sidebar with no mobile collapse.
F-DSH-07: Version History Viewer
Priority: P0 (MVP) | Status: Missing
Description: For any entry, view the complete edit history: who changed what, when, with diff highlighting. Ability to view any historical version.
2.6 API
F-API-01: REST API for Entries
Priority: P0 (MVP) | Status: Missing
Description: Programmatic CRUD on entries. Authenticated via API key. Supports all taxonomy tags. Enables CI/CD integration β engineers can push changelog entries from their deployment pipeline.
Current state: No API routes exist. Entry creation is via Next.js Server Actions only.
F-API-02: API Key Management
Priority: P0 (MVP) | Status: Partial
Description: Generate, view, revoke API keys with scoped permissions.
Current state: api_keys table exists with key_prefix, key_hash, scopes, expires_at, revoked_at. No UI for key management, no API authentication middleware.
F-API-03: Webhook Delivery
Priority: P1 | Status: Missing
Description: When entries are published or updated, fire webhooks to configured URLs. Supports events: entry.published, entry.updated, entry.archived.
Current state: webhooks table exists. No webhook delivery code.
F-API-04: Public Read API
Priority: P0 (MVP) | Status: Missing
Description: Unauthenticated API to read published entries with filtering. Powers the embeddable widget and allows clients to programmatically query the changelog.
2.7 Notifications & Subscriptions
F-NOT-01: Email Subscriptions
Priority: P1 | Status: Missing
Description: Visitors subscribe with email. Double opt-in (confirmation email). Configurable frequency: realtime, daily digest, weekly digest.
Current state: subscribers table has frequency preference field. No email integration.
F-NOT-02: Axis-Based Subscription Filters
Priority: P1 | Status: Missing
Description: Subscribers can filter what they receive: βOnly notify me about Methodology changesβ or βOnly breaking changes to the Scoring Module.β Subscription preferences map to taxonomy axes.
Current state: Subscriber preferences support audiences and platforms arrays. Needs extension to all 5 taxonomy axes.
F-NOT-03: Webhook Notifications
Priority: P1 | Status: Missing
Description: See F-API-03.
F-NOT-04: RSS Feed Filtering
Priority: P2 | Status: Missing
Description: Per-category RSS feeds. e.g., /feed.rss?what=methodology returns only methodology changes.
2.8 Future / Post-MVP
F-FUT-01: AI Generation from Git Commits
Priority: P2 (post-MVP) | Status: Missing
Description: Connect a GitHub/GitLab repo. On release/tag, automatically generate a changelog entry draft from commit messages and PR descriptions using an LLM. Auto-categorize across taxonomy axes.
F-FUT-02: CLI Tool
Priority: P2 | Status: Missing
Description: better-changelog push --what data --why "scheduled refresh" --impact non-breaking --where scoring-module --title "Q3 Data Refresh"
F-FUT-03: GitHub Action
Priority: P2 | Status: Missing
Description: GitHub Action that auto-creates changelog entries on release.
F-FUT-04: Embeddable Widget
Priority: P2 | Status: Missing
Description: JS widget for in-app notification bell.
F-FUT-05: Multi-Language Support
Priority: P2 | Status: Missing
Description: Entries in multiple languages.
Feature Priority Matrix
| Priority | Feature ID | Feature | Status |
|---|---|---|---|
| P0 | F-TAX-01 | Configurable βWhatβ categories | Missing |
| P0 | F-TAX-02 | Configurable βWhyβ categories | Missing |
| P0 | F-TAX-03 | Impact level axis | Partial (needs migration) |
| P0 | F-TAX-04 | Configurable βWhereβ modules | Partial (exists, needs alignment) |
| P0 | F-TAX-05 | Multi-tag per axis | Partial |
| P0 | F-TAX-06 | Unified taxonomy system | Missing |
| P0 | F-ENT-01 | Create entry (with taxonomy) | Exists (needs update) |
| P0 | F-ENT-03 | Entry versioning / audit trail | Missing |
| P0 | F-SRC-01 | Full-text search | Partial (ILIKE only) |
| P0 | F-SRC-02 | Multi-axis filtering | Partial |
| P0 | F-SRC-04 | Filter via URL params | Exists |
| P0 | F-PUB-01 | Public changelog page | Exists (broken) |
| P0 | F-PUB-02 | Entry detail page | Exists |
| P0 | F-PUB-03 | Visual type differentiation | Partial |
| P0 | F-PUB-06 | RSS/Atom/JSON feeds | Exists (broken) |
| P0 | F-API-01 | REST API for entries | Missing |
| P0 | F-API-02 | API key management | Partial |
| P0 | F-API-04 | Public read API | Missing |
| P0 | F-DSH-01 | Dashboard entry list | Exists |
| P0 | F-DSH-02 | Taxonomy management UI | Missing |
| P0 | F-DSH-07 | Version history viewer | Missing |
| P1 | F-TAX-07 | Default taxonomy presets | Missing |
| P1 | F-TAX-08 | Free-form tags | Exists |
| P1 | F-ENT-02 | Rich text editor | Partial |
| P1 | F-ENT-04 | Entry scheduling (auto-publish) | Partial |
| P1 | F-ENT-05 | Entry archiving | Partial |
| P1 | F-SRC-05 | Sort options | Partial |
| P1 | F-SRC-06 | Pagination | Missing |
| P1 | F-PUB-04 | Custom branding | Partial |
| P1 | F-PUB-05 | Custom domain | Partial |
| P1 | F-PUB-08 | Subscribe + email | Missing |
| P1 | F-API-03 | Webhook delivery | Missing |
| P1 | F-NOT-01 | Email subscriptions | Missing |
| P1 | F-NOT-02 | Axis-based subscription filters | Missing |
| P1 | F-DSH-03 | Settings page | Missing |
| P1 | F-DSH-04 | Team management | Missing |
| P1 | F-DSH-06 | Mobile dashboard | Missing |
| P2 | F-ENT-06 | Bulk operations | Missing |
| P2 | F-ENT-07 | Entry templates | Missing |
| P2 | F-SRC-03 | Saved filters / views | Missing |
| P2 | F-PUB-07 | Embeddable widget | Missing |
| P2 | F-NOT-04 | RSS feed filtering | Missing |
| P2 | F-DSH-05 | Entry analytics | Missing |
| P2 | F-FUT-01 | AI from Git commits | Missing |
| P2 | F-FUT-02 | CLI tool | Missing |
| P2 | F-FUT-03 | GitHub Action | Missing |
3. Data Model
3.1 Schema Overview
The data model is organized around these entity groups:
Organizations (tenants)
βββ Users & Members
βββ Taxonomy (axes + values)
βββ Changelog Entries
β βββ Entry β Taxonomy Values (junction)
β βββ Entry Versions (audit trail)
β βββ Entry Tags (free-form)
βββ Subscribers
βββ Webhooks
βββ API Keys
βββ Analytics Events
3.2 Full Schema (Drizzle ORM TypeScript)
Below is the complete target schema. Tables are annotated with [EXISTS], [MODIFY], or [NEW].
import {
pgTable,
uuid,
varchar,
text,
timestamp,
boolean,
integer,
jsonb,
decimal,
primaryKey,
index,
uniqueIndex,
serial,
} from "drizzle-orm/pg-core";
import { relations, sql } from "drizzle-orm";
// =============================================================================
// TENANTS [EXISTS β minor modifications]
// =============================================================================
export const tenants = pgTable(
"tenants",
{
id: uuid("id").primaryKey().defaultRandom(),
slug: varchar("slug", { length: 63 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
// Domain
customDomain: varchar("custom_domain", { length: 255 }).unique(),
customDomainVerified: boolean("custom_domain_verified").default(false),
// Branding
logoUrl: text("logo_url"),
faviconUrl: text("favicon_url"), // [NEW]
brandColors: jsonb("brand_colors").$type<{
primary?: string;
secondary?: string;
accent?: string;
background?: string; // [NEW]
}>().default({}),
customCss: text("custom_css"), // [NEW]
// Settings
settings: jsonb("settings").$type<{
showEmoji?: boolean;
allowSubscriptions?: boolean;
defaultLocale?: string;
defaultTimezone?: string; // [NEW]
showAuthor?: boolean; // [NEW]
showVersionHistory?: boolean; // [NEW]
requireApproval?: boolean; // [NEW] entries need admin approval
taxonomyPreset?: string; // [NEW] which preset was used
}>().default({}),
// Billing
subscriptionTier: varchar("subscription_tier", { length: 50 }).default("free"),
subscriptionStatus: varchar("subscription_status", { length: 50 }).default("active"),
stripeCustomerId: varchar("stripe_customer_id", { length: 255 }),
stripeSubscriptionId: varchar("stripe_subscription_id", { length: 255 }),
// Timestamps
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_tenants_slug").on(table.slug),
index("idx_tenants_custom_domain").on(table.customDomain),
]
);
// =============================================================================
// USERS [EXISTS β no changes]
// =============================================================================
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey().defaultRandom(),
email: varchar("email", { length: 255 }).notNull().unique(),
name: varchar("name", { length: 255 }),
avatarUrl: text("avatar_url"),
clerkUserId: varchar("clerk_user_id", { length: 255 }).unique(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_users_clerk_id").on(table.clerkUserId),
]
);
// =============================================================================
// TENANT MEMBERS [EXISTS β no changes]
// =============================================================================
export const tenantMembers = pgTable(
"tenant_members",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
role: varchar("role", { length: 50 }).notNull().default("viewer"),
invitedBy: uuid("invited_by").references(() => users.id),
invitedAt: timestamp("invited_at", { withTimezone: true }),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_tenant_members_tenant").on(table.tenantId),
index("idx_tenant_members_user").on(table.userId),
uniqueIndex("idx_tenant_members_unique").on(table.tenantId, table.userId),
]
);
// =============================================================================
// TAXONOMY AXES [NEW]
// =============================================================================
// Defines the 5 axes of the taxonomy system. Seeded per-tenant on creation.
// Most orgs will have: what, why, impact, where.
// "when" is implicit (published_at).
export const taxonomyAxes = pgTable(
"taxonomy_axes",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
slug: varchar("slug", { length: 63 }).notNull(), // e.g., "what", "why", "impact", "where"
label: varchar("label", { length: 255 }).notNull(), // e.g., "Change Type", "Reason", "Impact", "Module"
description: text("description"),
isMultiSelect: boolean("is_multi_select").default(true), // "impact" is single-select; others are multi
isRequired: boolean("is_required").default(false),
sortOrder: integer("sort_order").default(0),
isSystem: boolean("is_system").default(false), // system axes can't be deleted
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_taxonomy_axes_tenant").on(table.tenantId),
uniqueIndex("idx_taxonomy_axes_tenant_slug").on(table.tenantId, table.slug),
]
);
// =============================================================================
// TAXONOMY VALUES [NEW]
// =============================================================================
// The configurable options within each axis.
// e.g., axis="what" β values: "Data", "Methodology", "Technology"
// e.g., axis="why" β values: "Bug fix", "New feature", "Regulatory requirement"
// e.g., axis="impact" β values: "Breaking", "Non-breaking", "Informational"
// e.g., axis="where" β values: "Scoring Module", "API", "Dashboard"
export const taxonomyValues = pgTable(
"taxonomy_values",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
axisId: uuid("axis_id").notNull().references(() => taxonomyAxes.id, { onDelete: "cascade" }),
slug: varchar("slug", { length: 63 }).notNull(),
label: varchar("label", { length: 255 }).notNull(),
description: text("description"),
color: varchar("color", { length: 7 }), // hex color for badges
icon: varchar("icon", { length: 50 }), // emoji or icon name
sortOrder: integer("sort_order").default(0),
isDefault: boolean("is_default").default(false), // pre-selected in forms
isActive: boolean("is_active").default(true),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_taxonomy_values_tenant").on(table.tenantId),
index("idx_taxonomy_values_axis").on(table.axisId),
uniqueIndex("idx_taxonomy_values_axis_slug").on(table.axisId, table.slug),
]
);
// =============================================================================
// CHANGELOG ENTRIES [EXISTS β significant modifications]
// =============================================================================
export const changelogEntries = pgTable(
"changelog_entries",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
// Content
title: varchar("title", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
emoji: varchar("emoji", { length: 10 }),
summary: text("summary"),
body: text("body").notNull(),
// --- REMOVED: updateType, impactLevel ---
// These are now in entry_taxonomy_values junction table.
// Keeping as deprecated columns during migration, then dropping.
// Status & Publishing
status: varchar("status", { length: 50 }).default("draft").notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
// Versioning [NEW]
version: integer("version").default(1).notNull(), // current version number
isEdited: boolean("is_edited").default(false), // has been edited after first publish
// SEO
seoTitle: varchar("seo_title", { length: 60 }),
seoDescription: varchar("seo_description", { length: 160 }),
ogImageUrl: text("og_image_url"),
// AI metadata
aiGenerated: boolean("ai_generated").default(false),
aiAgentUsed: varchar("ai_agent_used", { length: 50 }),
aiConfidenceScore: decimal("ai_confidence_score", { precision: 3, scale: 2 }),
// Full-text search [NEW]
searchVector: sql`tsvector`.as("search_vector"),
// Generated column: to_tsvector('english', coalesce(title,'') || ' ' || coalesce(summary,'') || ' ' || coalesce(body,''))
// Note: Drizzle doesn't natively support generated tsvector columns.
// Implement via raw SQL migration (see Migration Plan section).
// Authoring
authorId: uuid("author_id").references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_entries_tenant").on(table.tenantId),
uniqueIndex("idx_entries_tenant_slug").on(table.tenantId, table.slug),
index("idx_entries_status").on(table.status),
index("idx_entries_published").on(table.publishedAt),
// GIN index for full-text search (create via raw SQL migration):
// CREATE INDEX idx_entries_search ON changelog_entries USING GIN (search_vector);
]
);
// =============================================================================
// ENTRY β TAXONOMY VALUES (Junction) [NEW]
// =============================================================================
// Replaces: entry_audiences, entry_platforms, entry_product_areas
// (and the now-removed updateType/impactLevel columns)
// Each row says: "Entry X is tagged with Taxonomy Value Y"
export const entryTaxonomyValues = pgTable(
"entry_taxonomy_values",
{
entryId: uuid("entry_id").notNull().references(() => changelogEntries.id, { onDelete: "cascade" }),
taxonomyValueId: uuid("taxonomy_value_id").notNull().references(() => taxonomyValues.id, { onDelete: "cascade" }),
// Denormalized for query performance:
axisId: uuid("axis_id").notNull().references(() => taxonomyAxes.id, { onDelete: "cascade" }),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
},
(table) => [
primaryKey({ columns: [table.entryId, table.taxonomyValueId] }),
index("idx_etv_entry").on(table.entryId),
index("idx_etv_value").on(table.taxonomyValueId),
index("idx_etv_axis").on(table.axisId),
index("idx_etv_tenant_axis").on(table.tenantId, table.axisId),
// Composite index for the most common filter query:
// "entries in tenant X where axis Y has value Z"
index("idx_etv_tenant_axis_value").on(table.tenantId, table.axisId, table.taxonomyValueId),
]
);
// =============================================================================
// ENTRY TAGS (Free-form) [EXISTS β no changes]
// =============================================================================
export const entryTags = pgTable(
"entry_tags",
{
entryId: uuid("entry_id").notNull().references(() => changelogEntries.id, { onDelete: "cascade" }),
tag: varchar("tag", { length: 50 }).notNull(),
},
(table) => [
primaryKey({ columns: [table.entryId, table.tag] }),
index("idx_entry_tags_tag").on(table.tag),
]
);
// =============================================================================
// ENTRY VERSIONS (Audit Trail) [NEW]
// =============================================================================
// Every time a published entry is edited, the previous state is saved here.
// The current state is always in changelog_entries.
// Versions are immutable once created.
export const entryVersions = pgTable(
"entry_versions",
{
id: uuid("id").primaryKey().defaultRandom(),
entryId: uuid("entry_id").notNull().references(() => changelogEntries.id, { onDelete: "cascade" }),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
// Version metadata
versionNumber: integer("version_number").notNull(),
editedBy: uuid("edited_by").references(() => users.id),
editReason: text("edit_reason"), // optional note about why the edit was made
// Snapshot of the entry at this version
title: varchar("title", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
emoji: varchar("emoji", { length: 10 }),
summary: text("summary"),
body: text("body").notNull(),
status: varchar("status", { length: 50 }).notNull(),
publishedAt: timestamp("published_at", { withTimezone: true }),
// Snapshot of taxonomy values at this version
taxonomySnapshot: jsonb("taxonomy_snapshot").$type<{
axes: Array<{
axisSlug: string;
axisLabel: string;
values: Array<{ valueSlug: string; valueLabel: string }>;
}>;
tags: string[];
}>().notNull(),
// Diff from previous version (optional, for quick display)
changeSummary: jsonb("change_summary").$type<{
fieldsChanged: string[]; // ["title", "body", "taxonomy.what"]
addedTaxonomyValues?: string[];
removedTaxonomyValues?: string[];
}>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_entry_versions_entry").on(table.entryId),
index("idx_entry_versions_tenant").on(table.tenantId),
uniqueIndex("idx_entry_versions_entry_number").on(table.entryId, table.versionNumber),
index("idx_entry_versions_created").on(table.createdAt),
]
);
// =============================================================================
// SUBSCRIBERS [EXISTS β modify preferences]
// =============================================================================
export const subscribers = pgTable(
"subscribers",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
email: varchar("email", { length: 255 }).notNull(),
name: varchar("name", { length: 255 }),
status: varchar("status", { length: 20 }).default("pending"),
// [MODIFIED] Preferences now reference taxonomy axes/values
preferences: jsonb("preferences").$type<{
frequency: "realtime" | "daily" | "weekly";
// Taxonomy-based filters: "only notify me about these"
// Key = axis slug, Value = array of value slugs
// Empty object = notify about everything
taxonomyFilters: Record<string, string[]>;
// Example:
// { "what": ["methodology"], "impact": ["breaking"] }
// β "notify me about breaking methodology changes"
}>().default({ frequency: "realtime", taxonomyFilters: {} }),
confirmationToken: varchar("confirmation_token", { length: 255 }),
confirmedAt: timestamp("confirmed_at", { withTimezone: true }),
unsubscribedAt: timestamp("unsubscribed_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_subscribers_tenant").on(table.tenantId),
uniqueIndex("idx_subscribers_tenant_email").on(table.tenantId, table.email),
]
);
// =============================================================================
// WEBHOOKS [EXISTS β no changes]
// =============================================================================
export const webhooks = pgTable(
"webhooks",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
url: text("url").notNull(),
secret: varchar("secret", { length: 255 }).notNull(),
events: text("events").array().notNull(),
isActive: boolean("is_active").default(true),
lastTriggeredAt: timestamp("last_triggered_at", { withTimezone: true }),
failureCount: integer("failure_count").default(0),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_webhooks_tenant").on(table.tenantId),
]
);
// =============================================================================
// API KEYS [EXISTS β no changes]
// =============================================================================
export const apiKeys = pgTable(
"api_keys",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(),
keyPrefix: varchar("key_prefix", { length: 12 }).notNull(),
keyHash: varchar("key_hash", { length: 255 }).notNull(),
scopes: text("scopes").array().default(sql`'{}'::text[]`),
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => users.id),
revokedAt: timestamp("revoked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_api_keys_tenant").on(table.tenantId),
index("idx_api_keys_prefix").on(table.keyPrefix),
]
);
// =============================================================================
// ANALYTICS EVENTS [EXISTS β no changes]
// =============================================================================
export const analyticsEvents = pgTable(
"analytics_events",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
entryId: uuid("entry_id").references(() => changelogEntries.id, { onDelete: "set null" }),
eventType: varchar("event_type", { length: 50 }).notNull(),
eventData: jsonb("event_data").$type<Record<string, unknown>>().default({}),
visitorId: varchar("visitor_id", { length: 64 }),
sessionId: varchar("session_id", { length: 64 }),
referrer: text("referrer"),
userAgent: text("user_agent"),
country: varchar("country", { length: 2 }),
pageUrl: text("page_url"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_analytics_tenant_date").on(table.tenantId, table.createdAt),
index("idx_analytics_entry").on(table.entryId),
]
);
// =============================================================================
// NOTIFICATION LOG [NEW]
// =============================================================================
// Track sent notifications for debugging and preventing duplicate sends.
export const notificationLog = pgTable(
"notification_log",
{
id: uuid("id").primaryKey().defaultRandom(),
tenantId: uuid("tenant_id").notNull().references(() => tenants.id, { onDelete: "cascade" }),
entryId: uuid("entry_id").references(() => changelogEntries.id, { onDelete: "set null" }),
subscriberId: uuid("subscriber_id").references(() => subscribers.id, { onDelete: "set null" }),
channel: varchar("channel", { length: 20 }).notNull(), // "email", "webhook", "rss"
status: varchar("status", { length: 20 }).notNull(), // "sent", "failed", "bounced"
externalId: varchar("external_id", { length: 255 }), // e.g., SendGrid message ID
errorMessage: text("error_message"),
sentAt: timestamp("sent_at", { withTimezone: true }).defaultNow().notNull(),
},
(table) => [
index("idx_notification_log_tenant").on(table.tenantId),
index("idx_notification_log_entry").on(table.entryId),
index("idx_notification_log_subscriber").on(table.subscriberId),
]
);
// =============================================================================
// RELATIONS
// =============================================================================
export const tenantsRelations = relations(tenants, ({ many }) => ({
members: many(tenantMembers),
taxonomyAxes: many(taxonomyAxes),
entries: many(changelogEntries),
subscribers: many(subscribers),
webhooks: many(webhooks),
apiKeys: many(apiKeys),
}));
export const usersRelations = relations(users, ({ many }) => ({
tenantMemberships: many(tenantMembers),
authoredEntries: many(changelogEntries),
}));
export const tenantMembersRelations = relations(tenantMembers, ({ one }) => ({
tenant: one(tenants, {
fields: [tenantMembers.tenantId],
references: [tenants.id],
}),
user: one(users, {
fields: [tenantMembers.userId],
references: [users.id],
}),
}));
export const taxonomyAxesRelations = relations(taxonomyAxes, ({ one, many }) => ({
tenant: one(tenants, {
fields: [taxonomyAxes.tenantId],
references: [tenants.id],
}),
values: many(taxonomyValues),
}));
export const taxonomyValuesRelations = relations(taxonomyValues, ({ one, many }) => ({
tenant: one(tenants, {
fields: [taxonomyValues.tenantId],
references: [tenants.id],
}),
axis: one(taxonomyAxes, {
fields: [taxonomyValues.axisId],
references: [taxonomyAxes.id],
}),
entryValues: many(entryTaxonomyValues),
}));
export const changelogEntriesRelations = relations(changelogEntries, ({ one, many }) => ({
tenant: one(tenants, {
fields: [changelogEntries.tenantId],
references: [tenants.id],
}),
author: one(users, {
fields: [changelogEntries.authorId],
references: [users.id],
}),
taxonomyValues: many(entryTaxonomyValues),
tags: many(entryTags),
versions: many(entryVersions),
}));
export const entryTaxonomyValuesRelations = relations(entryTaxonomyValues, ({ one }) => ({
entry: one(changelogEntries, {
fields: [entryTaxonomyValues.entryId],
references: [changelogEntries.id],
}),
taxonomyValue: one(taxonomyValues, {
fields: [entryTaxonomyValues.taxonomyValueId],
references: [taxonomyValues.id],
}),
axis: one(taxonomyAxes, {
fields: [entryTaxonomyValues.axisId],
references: [taxonomyAxes.id],
}),
}));
export const entryTagsRelations = relations(entryTags, ({ one }) => ({
entry: one(changelogEntries, {
fields: [entryTags.entryId],
references: [changelogEntries.id],
}),
}));
export const entryVersionsRelations = relations(entryVersions, ({ one }) => ({
entry: one(changelogEntries, {
fields: [entryVersions.entryId],
references: [changelogEntries.id],
}),
editedByUser: one(users, {
fields: [entryVersions.editedBy],
references: [users.id],
}),
}));
export const subscribersRelations = relations(subscribers, ({ one }) => ({
tenant: one(tenants, {
fields: [subscribers.tenantId],
references: [tenants.id],
}),
}));3.3 Tables Summary: Existing vs. New
| Table | Status | Notes |
|---|---|---|
tenants | MODIFY | Add favicon_url, custom_css, expand settings and brand_colors JSON |
users | NO CHANGE | β |
tenant_members | MODIFY | Add unique index on (tenant_id, user_id) |
taxonomy_axes | NEW | Defines the configurable axes (what/why/impact/where) |
taxonomy_values | NEW | Configurable options within each axis |
changelog_entries | MODIFY | Remove updateType/impactLevel, add version/is_edited/search_vector |
entry_taxonomy_values | NEW | Junction: entries β taxonomy values (replaces multiple old junction tables) |
entry_tags | NO CHANGE | Free-form tags remain |
entry_versions | NEW | Audit trail β snapshots of entry state on each edit |
entry_audiences | DROP | Replaced by taxonomy system (audiences become a taxonomy axis if needed) |
entry_platforms | DROP | Replaced by taxonomy system (platforms become a taxonomy axis if needed) |
entry_product_areas | DROP | Replaced by taxonomy system (product areas β βwhereβ axis) |
product_areas | DROP | Replaced by taxonomy_values where axis = βwhereβ |
subscribers | MODIFY | Preferences restructured to use taxonomy-based filters |
webhooks | NO CHANGE | β |
api_keys | NO CHANGE | β |
analytics_events | NO CHANGE | β |
notification_log | NEW | Track sent notifications |
3.4 Full-Text Search Implementation
Drizzle doesnβt natively support PostgreSQL tsvector generated columns, so this must be implemented via raw SQL migration:
-- Add the tsvector column
ALTER TABLE changelog_entries
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('english', coalesce(body, '')), 'C')
) STORED;
-- Create GIN index for fast search
CREATE INDEX idx_entries_search_vector ON changelog_entries USING GIN (search_vector);Query pattern:
// Full-text search with ranking
const results = await db.execute(sql`
SELECT ce.*, ts_rank(ce.search_vector, websearch_to_tsquery('english', ${query})) AS rank
FROM changelog_entries ce
WHERE ce.tenant_id = ${tenantId}
AND ce.status = 'published'
AND ce.search_vector @@ websearch_to_tsquery('english', ${query})
ORDER BY rank DESC
LIMIT ${limit}
OFFSET ${offset}
`);3.5 Default Taxonomy Seeding
When a new tenant is created, seed the taxonomy with defaults:
const DEFAULT_TAXONOMY = {
what: {
label: "Change Type",
isMultiSelect: true,
isRequired: true,
values: [
{ slug: "data", label: "Data", icon: "π", color: "#3B82F6" },
{ slug: "methodology", label: "Methodology", icon: "π¬", color: "#8B5CF6" },
{ slug: "technology", label: "Technology", icon: "βοΈ", color: "#6B7280" },
{ slug: "feature", label: "New Feature", icon: "β¨", color: "#10B981" },
{ slug: "improvement", label: "Improvement", icon: "π", color: "#3B82F6" },
],
},
why: {
label: "Reason",
isMultiSelect: true,
isRequired: true,
values: [
{ slug: "bug-fix", label: "Bug Fix", icon: "π", color: "#EF4444" },
{ slug: "new-feature", label: "New Feature", icon: "β¨", color: "#10B981" },
{ slug: "scheduled-refresh", label: "Scheduled Refresh", icon: "π", color: "#6B7280" },
{ slug: "regulatory", label: "Regulatory Requirement", icon: "π", color: "#F59E0B" },
{ slug: "client-request", label: "Client Request", icon: "π", color: "#8B5CF6" },
{ slug: "deprecation", label: "Deprecation", icon: "β οΈ", color: "#F97316" },
{ slug: "security", label: "Security Update", icon: "π", color: "#EF4444" },
],
},
impact: {
label: "Impact",
isMultiSelect: false, // single-select
isRequired: true,
values: [
{ slug: "breaking", label: "Breaking Change", icon: "π₯", color: "#EF4444" },
{ slug: "non-breaking", label: "Non-breaking", icon: "β
", color: "#10B981" },
{ slug: "informational", label: "Informational", icon: "βΉοΈ", color: "#3B82F6" },
],
},
where: {
label: "Module",
isMultiSelect: true,
isRequired: false,
values: [], // Org-specific; empty by default. Admin configures these.
},
};4. API Specification
4.1 Authentication
All authenticated endpoints require one of:
- API Key in
Authorization: Bearer cl_live_xxxxheader - Clerk session (for dashboard/browser-based access)
API keys are scoped:
entries:readβ Read published entriesentries:writeβ Create/update entriesentries:deleteβ Delete entriestaxonomy:readβ Read taxonomy configurationtaxonomy:writeβ Modify taxonomy (admin)subscribers:read/subscribers:writeβ Manage subscriberswebhooks:read/webhooks:writeβ Manage webhooks
4.2 Public Endpoints (No Auth Required)
GET /api/v1/changelog
List published entries with filtering.
Query Parameters:
| Param | Type | Description |
|---|---|---|
q | string | Full-text search query |
what | string | Comma-separated βwhatβ axis value slugs |
why | string | Comma-separated βwhyβ axis value slugs |
impact | string | Comma-separated βimpactβ value slugs |
where | string | Comma-separated βwhereβ module slugs |
tag | string | Comma-separated free-form tags |
from | ISO date | Published after this date (inclusive) |
to | ISO date | Published before this date (inclusive) |
sort | string | date_asc, date_desc (default), relevance (when q is set) |
limit | number | Results per page (default 20, max 100) |
cursor | string | Cursor for pagination (entry ID) |
Response:
{
"data": [
{
"id": "uuid",
"title": "Q3 Data Refresh",
"slug": "q3-data-refresh",
"emoji": "π",
"summary": "Updated all data sources for Q3 2025.",
"body": "## What changed\n\nWe've refreshed...",
"publishedAt": "2025-10-01T09:00:00Z",
"version": 2,
"isEdited": true,
"author": {
"name": "Jane Doe",
"avatarUrl": "https://..."
},
"taxonomy": {
"what": [{ "slug": "data", "label": "Data", "color": "#3B82F6", "icon": "π" }],
"why": [{ "slug": "scheduled-refresh", "label": "Scheduled Refresh", "color": "#6B7280", "icon": "π" }],
"impact": [{ "slug": "non-breaking", "label": "Non-breaking", "color": "#10B981", "icon": "β
" }],
"where": [{ "slug": "scoring-module", "label": "Scoring Module", "color": "#8B5CF6", "icon": "π" }]
},
"tags": ["q3", "data-refresh"],
"url": "https://changelog.acme.com/q3-data-refresh"
}
],
"pagination": {
"hasMore": true,
"nextCursor": "uuid-of-last-entry"
},
"meta": {
"total": 142,
"tenant": {
"name": "Acme Corp",
"logoUrl": "https://..."
}
}
}Notes:
- Tenant is resolved from subdomain, custom domain, or
X-Tenant-Slugheader. - When
qis present, results include arelevanceScorefield and default sort isrelevance. - Filtering is AND between axes, OR within an axis:
?what=data,methodology&impact=breakingmeans β(data OR methodology) AND breakingβ.
GET /api/v1/changelog/:slug
Get a single published entry by slug.
Response: Same shape as a single item in the list response, plus full body content.
GET /api/v1/changelog/:slug/versions
Get the version history of an entry.
Response:
{
"data": [
{
"versionNumber": 2,
"editedBy": { "name": "Jane Doe" },
"editReason": "Corrected the affected module list",
"createdAt": "2025-10-02T14:30:00Z",
"changeSummary": {
"fieldsChanged": ["body", "taxonomy.where"],
"addedTaxonomyValues": ["api-module"],
"removedTaxonomyValues": []
}
},
{
"versionNumber": 1,
"editedBy": { "name": "Jane Doe" },
"editReason": null,
"createdAt": "2025-10-01T09:00:00Z",
"changeSummary": null
}
]
}GET /api/v1/changelog/:slug/versions/:versionNumber
Get a specific historical version (full snapshot).
GET /api/v1/taxonomy
Get the orgβs taxonomy configuration (axes and their values).
Response:
{
"axes": [
{
"slug": "what",
"label": "Change Type",
"isMultiSelect": true,
"isRequired": true,
"values": [
{ "slug": "data", "label": "Data", "color": "#3B82F6", "icon": "π" },
{ "slug": "methodology", "label": "Methodology", "color": "#8B5CF6", "icon": "π¬" },
{ "slug": "technology", "label": "Technology", "color": "#6B7280", "icon": "βοΈ" }
]
},
// ...
]
}POST /api/v1/subscribe
Subscribe to the changelog via email. Public endpoint.
Request:
{
"email": "user@example.com",
"name": "John Doe",
"preferences": {
"frequency": "weekly",
"taxonomyFilters": {
"what": ["methodology"],
"impact": ["breaking"]
}
}
}Response: 201 Created β Sends confirmation email.
4.3 Authenticated Endpoints
POST /api/v1/entries
Create a new entry. Requires entries:write scope.
Request:
{
"title": "Q3 Data Refresh",
"slug": "q3-data-refresh",
"emoji": "π",
"summary": "Updated all data sources for Q3 2025.",
"body": "## What changed\n\n...",
"status": "draft",
"scheduledFor": null,
"taxonomy": {
"what": ["data"],
"why": ["scheduled-refresh"],
"impact": ["non-breaking"],
"where": ["scoring-module"]
},
"tags": ["q3", "data-refresh"],
"seo": {
"title": "Q3 Data Refresh | Acme Changelog",
"description": "...",
"ogImageUrl": "https://..."
}
}PUT /api/v1/entries/:id
Update an entry. Requires entries:write. If the entry is published, this triggers version creation (audit trail).
Request: Same shape as create, all fields optional. Include editReason for the audit trail:
{
"body": "## Updated content\n\n...",
"taxonomy": {
"where": ["scoring-module", "api-module"]
},
"editReason": "Added API module to affected areas"
}PATCH /api/v1/entries/:id/status
Change entry status. Requires entries:write.
Request:
{
"status": "published",
"scheduledFor": null
}DELETE /api/v1/entries/:id
Delete an entry. Requires entries:delete.
GET /api/v1/entries
List all entries (including drafts). Requires entries:read. Same query params as public endpoint, plus status filter.
Taxonomy Management (Admin)
| Method | Endpoint | Description | Scope |
|---|---|---|---|
GET | /api/v1/taxonomy/axes | List all axes | taxonomy:read |
POST | /api/v1/taxonomy/axes | Create a custom axis | taxonomy:write |
PUT | /api/v1/taxonomy/axes/:id | Update an axis | taxonomy:write |
GET | /api/v1/taxonomy/axes/:axisSlug/values | List values for an axis | taxonomy:read |
POST | /api/v1/taxonomy/axes/:axisSlug/values | Add a value to an axis | taxonomy:write |
PUT | /api/v1/taxonomy/values/:id | Update a value | taxonomy:write |
DELETE | /api/v1/taxonomy/values/:id | Deactivate a value (soft delete) | taxonomy:write |
PUT | /api/v1/taxonomy/axes/:axisSlug/values/reorder | Reorder values | taxonomy:write |
Webhook Management
| Method | Endpoint | Description | Scope |
|---|---|---|---|
GET | /api/v1/webhooks | List webhooks | webhooks:read |
POST | /api/v1/webhooks | Create webhook | webhooks:write |
PUT | /api/v1/webhooks/:id | Update webhook | webhooks:write |
DELETE | /api/v1/webhooks/:id | Delete webhook | webhooks:write |
API Key Management (Dashboard only β not accessible via API keys)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/v1/api-keys | List API keys (prefix + metadata only) |
POST | /api/v1/api-keys | Create API key (returns full key ONCE) |
DELETE | /api/v1/api-keys/:id | Revoke API key |
4.4 Webhook Payload
When a webhook event fires:
{
"event": "entry.published",
"timestamp": "2025-10-01T09:00:00Z",
"tenant": {
"id": "uuid",
"slug": "acme-corp",
"name": "Acme Corp"
},
"entry": {
// Same shape as the public API entry response
}
}Events:
entry.publishedβ New entry published or draftβpublishedentry.updatedβ Published entry edited (includes new version number)entry.archivedβ Entry archivedentry.deletedβ Entry deleted
Webhook delivery:
- Retry 3 times with exponential backoff (1s, 10s, 60s)
X-Webhook-Signatureheader with HMAC-SHA256 of the payload using the webhook secret- After 10 consecutive failures, auto-deactivate the webhook and notify the admin
5. UI/UX Requirements
5.1 Public Changelog Page
Layout
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β [Logo] Acme Corp [RSS] [Subscribe] β
β Changelog β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β π Search changes... π
Date range βΎ β
β β
β WHAT: [Data] [Methodology] [Technology] [Feature] [+] β
β WHY: [Bug Fix] [Regulatory] [Client Request] [+] β
β IMPACT: [Breaking β] [Non-breaking] [Informational] β
β MODULE: [Scoring βΎ] β
β β
β Filtering by: Data Γ Breaking Γ β Clear all β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β ββ October 2025 ββββββββββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β π Q3 Data Refresh Oct 1 ββ
β β [Data] [Scheduled Refresh] [Non-breaking] ββ
β β [Scoring Module] ββ
β β Updated all data sources for Q3 2025... ββ
β β v2 (edited) β ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β π¬ Methodology Update: Risk Scoring Oct 3 ββ
β β [Methodology] [Regulatory] [Breaking] ββ
β β [Scoring Module] [API] ββ
β β Updated the risk scoring methodology to... ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββ September 2025 ββββββββββββββββββββββββββββββββββββ β
β ... β
β β
β [Load more] β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Powered by Better Changelog β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Key Design Requirements
-
Filter bar: Each taxonomy axis gets its own row of filter chips. Active filters are visually highlighted. An βactive filtersβ summary shows below with Γ to remove each.
-
Entry cards: Each card shows:
- Emoji + title
- Taxonomy badges in rows: What badges (colored by category), Why badges, Impact badge (red for breaking, green for non-breaking, blue for informational), Module badges
- Summary text (first 200 chars)
- Published date
- βv2 (edited)β indicator if entry has been edited, linking to version history
- Author avatar + name (if settings.showAuthor is true)
-
Visual differentiation: Impact level should have the strongest visual signal:
- Breaking: Red left border, red impact badge
- Non-breaking: Green or neutral
- Informational: Blue/gray, subtle styling
-
Date grouping: Entries grouped by month with sticky month headers.
-
Responsive: On mobile, filter chips wrap to multiple lines. Search and date range stack vertically. Entry cards go full-width.
Entry Detail Page
- Full markdown rendering with code syntax highlighting
- Version history link: βLast edited Oct 2, 2025 Β· View historyβ
- All taxonomy badges displayed prominently
- Related entries section (same module or category, optional P2)
- Prev/Next navigation links
Version History Page
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β Back to entry β
β β
β Version History: Q3 Data Refresh β
β β
β β v2 β Oct 2, 2025 by Jane Doe β
β "Added API module to affected areas" β
β Changed: body, taxonomy.where β
β + Added: API Module β
β [View this version] β
β β
β β v1 β Oct 1, 2025 by Jane Doe (original) β
β [View this version] β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
5.2 Dashboard
Entry Creation Flow
Step 1: Content
βββ Title (auto-generates slug)
βββ Emoji picker
βββ Summary (300 chars, for previews)
βββ Body (markdown editor with preview pane)
Step 2: Categorization (5-Axis Taxonomy)
βββ What changed? [multi-select chips from org's "what" values]
βββ Why? [multi-select chips from org's "why" values]
βββ Impact [single-select: Breaking / Non-breaking / Informational]
βββ Module(s) affected [multi-select from org's "where" values]
βββ Tags [free-form tag input]
Step 3: Publishing
βββ Status: Draft / Published / Scheduled
βββ Schedule date/time (if scheduled)
βββ SEO fields (collapsible, optional)
Can be a single long form with collapsible sections rather than a multi-step wizard β PMs shouldnβt have to click through pages for a quick entry.
Taxonomy Management Page (/dashboard/settings/taxonomy)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Taxonomy Configuration β
β β
β Configure how changes are categorized in your changelog. β
β β
β ββββ Change Type ("What") ββββββββββββββββββββββββββββββββ
β β Define the types of changes your team makes. ββ
β β ββ
β β β‘ π Data #3B82F6 [Edit] [Deactivate] ββ
β β β‘ π¬ Methodology #8B5CF6 [Edit] [Deactivate] ββ
β β β‘ βοΈ Technology #6B7280 [Edit] [Deactivate] ββ
β β ββ
β β [+ Add category] ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββ Reason ("Why") ββββββββββββββββββββββββββββββββββββββ
β β ...same pattern... ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββ Impact ββββββββββββββββββββββββββββββββββββββββββββββ
β β π₯ Breaking Change #EF4444 [Edit label] ββ
β β β
Non-breaking #10B981 [Edit label] ββ
β β βΉοΈ Informational #3B82F6 [Edit label] ββ
β β (Impact levels cannot be added/removed) ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β ββββ Modules ("Where") ββββββββββββββββββββββββββββββββββ
β β ...same pattern... ββ
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Features:
- Drag-to-reorder (β‘ handle)
- Inline edit (label, color picker, icon picker)
- Deactivate (soft delete) β entries with this value keep it, but itβs hidden from new entries
- Add new value modal: label, slug (auto-generated), color, icon
Settings Page (/dashboard/settings)
Tabs:
- General β Org name, slug, logo upload, timezone
- Taxonomy β The taxonomy management UI above
- Branding β Brand colors, custom CSS
- Domain β Custom domain configuration + DNS verification
- Team β Invite/manage members, assign roles
- API β API key management (create, view, revoke)
- Webhooks β Webhook configuration
- Subscribers β View/manage subscribers, export list
- Billing β Subscription tier, usage, upgrade
5.3 Mobile Considerations
-
Public changelog: Fully responsive. Filter bar collapses into an expandable βFiltersβ button on mobile. Entry cards stack vertically at full width.
-
Dashboard: Sidebar becomes a hamburger menu drawer. Entry form fields stack. Entry table becomes a card list (not a table) on mobile.
-
Touch targets: All interactive elements (filter chips, buttons, dropdown triggers) must be at least 44Γ44px.
-
Performance: Lazy-load entry bodies. Only load full markdown when the user clicks into the detail page. List view shows summary only.
6. Migration Plan
6.1 Current State β Target State
CURRENT (13 tables) TARGET (15 tables)
βββββββββββββββββ ββββββββββββββββββββ
tenants β tenants (modified)
users β users (no change)
tenant_members β tenant_members (add unique index)
product_areas β DROPPED (β taxonomy_values)
changelog_entries β changelog_entries (modified)
entry_audiences β DROPPED (β entry_taxonomy_values)
entry_platforms β DROPPED (β entry_taxonomy_values)
entry_product_areas β DROPPED (β entry_taxonomy_values)
entry_tags β entry_tags (no change)
subscribers β subscribers (modified preferences)
webhooks β webhooks (no change)
api_keys β api_keys (no change)
analytics_events β analytics_events (no change)
+ taxonomy_axes (NEW)
+ taxonomy_values (NEW)
+ entry_taxonomy_values (NEW)
+ entry_versions (NEW)
+ notification_log (NEW)
6.2 Migration Steps (Ordered)
Phase 1: Foundation (Week 1)
Step 1: Fix P0 bugs first
- Set
DEFAULT_TENANT_SLUGenv var on Vercel - Fix tenant resolution fallback logic
- Verify
/changelogand feeds work on deployed site
Step 2: Create new tables
-- Migration: 001_taxonomy_tables.sql
CREATE TABLE taxonomy_axes ( ... );
CREATE TABLE taxonomy_values ( ... );
CREATE TABLE entry_taxonomy_values ( ... );
CREATE TABLE entry_versions ( ... );
CREATE TABLE notification_log ( ... );Step 3: Seed default taxonomy for existing tenants
-- For each existing tenant, create the 4 default axes and their values
-- This is a data migration script that runs onceStep 4: Migrate existing entry data
-- For each existing entry:
-- 1. Map updateType β "what" axis taxonomy value
-- new_feature β feature, improvement β improvement, fix β bug-fix, etc.
-- 2. Map impactLevel β "impact" axis taxonomy value
-- major β breaking, minor β non-breaking, patch β informational
-- 3. Map entry_audiences β optional "audience" axis (or drop)
-- 4. Map entry_platforms β optional "platform" axis (or drop)
-- 5. Map entry_product_areas β "where" axis taxonomy values
-- Mapping updateType to "what" values:
INSERT INTO entry_taxonomy_values (entry_id, taxonomy_value_id, axis_id, tenant_id)
SELECT
ce.id,
tv.id,
ta.id,
ce.tenant_id
FROM changelog_entries ce
JOIN taxonomy_axes ta ON ta.tenant_id = ce.tenant_id AND ta.slug = 'what'
JOIN taxonomy_values tv ON tv.axis_id = ta.id AND tv.slug = (
CASE ce.update_type
WHEN 'new_feature' THEN 'feature'
WHEN 'improvement' THEN 'improvement'
WHEN 'fix' THEN 'bug-fix'
WHEN 'performance' THEN 'improvement'
WHEN 'security' THEN 'security'
WHEN 'deprecation' THEN 'deprecation'
WHEN 'breaking_change' THEN 'breaking-change'
WHEN 'documentation' THEN 'documentation'
ELSE 'improvement'
END
);
-- Similar for impactLevel β "impact" axis
-- Similar for product_areas β "where" axisStep 5: Add new columns to changelog_entries
ALTER TABLE changelog_entries ADD COLUMN version integer DEFAULT 1 NOT NULL;
ALTER TABLE changelog_entries ADD COLUMN is_edited boolean DEFAULT false;
-- Add full-text search
ALTER TABLE changelog_entries ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
setweight(to_tsvector('english', coalesce(body, '')), 'C')
) STORED;
CREATE INDEX idx_entries_search_vector
ON changelog_entries USING GIN (search_vector);Phase 2: Core Features (Week 2-3)
Step 6: Update the entry form
- Replace hardcoded
updateTypedropdown with dynamic taxonomy selectors - Replace hardcoded
impactLeveldropdown with dynamic taxonomy selector - Replace
productAreasmulti-select with βwhereβ axis selector - Add βwhyβ axis selector (new)
- Keep free-form tags input
Step 7: Update the public changelog
- Replace
updateTypefilter chips with dynamic chips from βwhatβ axis - Add βwhyβ filter chips
- Add βimpactβ filter
- Add βwhereβ module filter
- Replace ILIKE search with full-text search
- Add date range picker (replacing period dropdown)
Step 8: Implement entry versioning
- On
updateEntryserver action: if entry.status === βpublishedβ, snapshot current state toentry_versionsbefore applying update - Increment
versioncounter - Set
is_edited = true - Add βVersion Historyβ link on entry detail page
Step 9: Build taxonomy management UI
/dashboard/settings/taxonomypage- CRUD for values within each axis
- Drag-to-reorder
- Color/icon picker
Step 10: Build basic settings page
/dashboard/settingswith General + Taxonomy tabs
Phase 3: API & Polish (Week 3-4)
Step 11: Build REST API
/api/v1/changelog(public, read)/api/v1/entries(authenticated, CRUD)/api/v1/taxonomy(public, read)- API key authentication middleware
Step 12: Clean up deprecated columns
-- Only after all code is updated to use taxonomy system:
ALTER TABLE changelog_entries DROP COLUMN update_type;
ALTER TABLE changelog_entries DROP COLUMN impact_level;
DROP TABLE entry_audiences;
DROP TABLE entry_platforms;
DROP TABLE entry_product_areas;
DROP TABLE product_areas;Step 13: Update feeds
- Include taxonomy data in RSS/Atom/JSON feeds
- Support feed filtering by taxonomy (
/feed.rss?what=methodology)
Step 14: Add pagination
- Cursor-based pagination for public changelog and API
- Offset pagination for dashboard
Phase 4: Subscriptions & Notifications (Week 4-5)
Step 15: Email subscriptions
- Subscribe form on public changelog
- Confirmation email flow (Resend/SendGrid)
- Taxonomy-based subscription preferences
Step 16: Webhook delivery
- Background job to fire webhooks on entry.published/updated
- Retry logic with exponential backoff
- Webhook signature verification
6.3 What Can Be Reused As-Is
| Component | Reuse? | Notes |
|---|---|---|
| Auth system (Clerk + auto-provision) | β Yes | Works well |
| Tenant resolution logic | β Yes | After P0 fix |
| Dashboard layout + sidebar | β Yes | Add mobile hamburger later |
| Entry table component | β Yes | Update columns for taxonomy |
| Filter chip component | β Yes | Make dynamic instead of hardcoded |
| Entry card component | β Yes | Add taxonomy badges |
| RSS/Atom/JSON feed logic | β Yes | Update to include taxonomy |
| Badge component | β Yes | Make configurable colors |
| Zod validation schemas | π Modify | Replace hardcoded enums with dynamic validation |
| Server actions (create/update/delete) | π Modify | Add versioning + taxonomy junction writes |
| Entry queries | π Modify | Add taxonomy joins + FTS |
6.4 Risk Considerations
-
Data migration safety: Run the taxonomy data migration in a transaction. Test on the staging tenant first.
-
Backward compatibility: Keep
updateTypeandimpactLevelcolumns during the transition period. The UI reads from the new taxonomy system, but the old columns are still populated as a fallback. Drop them only after verification. -
Performance: The
entry_taxonomy_valuesjunction table will be queried on every page load. The composite indexes (idx_etv_tenant_axis_value) are critical. Monitor query performance after migration. -
Full-text search column: The generated
tsvectorcolumn updates automatically on INSERT/UPDATE, which is great. But it does add overhead to writes. For the current scale (< 1000 entries per tenant), this is negligible. -
Version history storage: Each version stores a full snapshot (not a diff). This is intentional β it makes version retrieval O(1) instead of requiring replay. Storage cost is acceptable for the compliance use case where immutable snapshots are expected.
Appendix A: Taxonomy Preset Profiles
βData Providerβ (Adamβs default)
What: Data, Methodology, Technology
Why: Bug fix, New feature, Scheduled refresh, Regulatory requirement, Client request, Deprecation, Security update
Impact: Breaking, Non-breaking, Informational
Where: (org configures)
βSaaS Product"
What: Feature, Improvement, Fix, Security, Deprecation, Infrastructure
Why: Roadmap, Bug report, Customer request, Compliance, Technical debt, Performance
Impact: Breaking, Non-breaking, Informational
Where: (org configures)
"Healthcare / Regulated"
What: Clinical, Regulatory, Technical, Data
Why: FDA requirement, Audit finding, Bug fix, Enhancement, Security patch, Scheduled maintenance
Impact: Breaking, Non-breaking, Informational
Where: (org configures)
"Financial Servicesβ
What: Model, Data, Platform, Compliance, Reporting
Why: Regulatory change, Model recalibration, Data correction, Feature request, Security, Scheduled refresh
Impact: Breaking, Non-breaking, Informational
Where: (org configures)
Appendix B: Competitive Differentiation Summary
| Feature | Better Changelog | Beamer | LaunchNotes | Headway |
|---|---|---|---|---|
| 5-axis taxonomy | β Configurable | β Labels only | β Categories | β Categories |
| Audit trail (versioning) | β Full history | β | β | β |
| Full-text search + multi-filter | β PostgreSQL FTS | β Basic | β Basic | β Basic |
| Customer-configurable categories | β Per-org | β Fixed | πΆ Custom labels | β Fixed |
| Compliance/regulated focus | β Core positioning | β | β | β |
| API for programmatic entry | β Full REST | πΆ Limited | β GraphQL | πΆ Limited |
| Entry version diffing | β | β | β | β |
| Axis-based subscriptions | β | β | πΆ Segments | β |
End of specification. This is a living document β update as decisions are made during implementation.