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

  1. Executive Summary
  2. Feature Specification
  3. Data Model
  4. API Specification
  5. UI/UX Requirements
  6. 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:

RequirementCurrent State
5-axis taxonomy (What/Why/Impact/Where/When)Only 2 axes: updateType (hardcoded) + impactLevel (hardcoded)
Customer-configurable categoriesAll categories are hardcoded TypeScript enums
”Why” axis (change reason)Completely missing
Entry versioning / audit trailEdits overwrite; no history preserved
Full-text searchILIKE queries only (no proper FTS)
Combined multi-axis filteringPartial β€” type + date + search, but no reason/impact/module combos
Axis-based subscriptionsSubscriber table exists, but can only filter by audience/platform

Design Principles

  1. Findability over freshness β€” The UI should make it trivially easy to find a specific past change, not just browse the latest.
  2. Configurable taxonomy β€” Every organization defines their own β€œWhat” and β€œWhy” categories. No hardcoded enums.
  3. Immutable audit trail β€” Published entries can be edited, but every version is preserved forever.
  4. Multi-tag per axis β€” A single entry can belong to multiple categories on each axis (e.g., both β€œData” and β€œMethodology”).
  5. 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 via published_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)

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

PriorityFeature IDFeatureStatus
P0F-TAX-01Configurable β€œWhat” categoriesMissing
P0F-TAX-02Configurable β€œWhy” categoriesMissing
P0F-TAX-03Impact level axisPartial (needs migration)
P0F-TAX-04Configurable β€œWhere” modulesPartial (exists, needs alignment)
P0F-TAX-05Multi-tag per axisPartial
P0F-TAX-06Unified taxonomy systemMissing
P0F-ENT-01Create entry (with taxonomy)Exists (needs update)
P0F-ENT-03Entry versioning / audit trailMissing
P0F-SRC-01Full-text searchPartial (ILIKE only)
P0F-SRC-02Multi-axis filteringPartial
P0F-SRC-04Filter via URL paramsExists
P0F-PUB-01Public changelog pageExists (broken)
P0F-PUB-02Entry detail pageExists
P0F-PUB-03Visual type differentiationPartial
P0F-PUB-06RSS/Atom/JSON feedsExists (broken)
P0F-API-01REST API for entriesMissing
P0F-API-02API key managementPartial
P0F-API-04Public read APIMissing
P0F-DSH-01Dashboard entry listExists
P0F-DSH-02Taxonomy management UIMissing
P0F-DSH-07Version history viewerMissing
P1F-TAX-07Default taxonomy presetsMissing
P1F-TAX-08Free-form tagsExists
P1F-ENT-02Rich text editorPartial
P1F-ENT-04Entry scheduling (auto-publish)Partial
P1F-ENT-05Entry archivingPartial
P1F-SRC-05Sort optionsPartial
P1F-SRC-06PaginationMissing
P1F-PUB-04Custom brandingPartial
P1F-PUB-05Custom domainPartial
P1F-PUB-08Subscribe + emailMissing
P1F-API-03Webhook deliveryMissing
P1F-NOT-01Email subscriptionsMissing
P1F-NOT-02Axis-based subscription filtersMissing
P1F-DSH-03Settings pageMissing
P1F-DSH-04Team managementMissing
P1F-DSH-06Mobile dashboardMissing
P2F-ENT-06Bulk operationsMissing
P2F-ENT-07Entry templatesMissing
P2F-SRC-03Saved filters / viewsMissing
P2F-PUB-07Embeddable widgetMissing
P2F-NOT-04RSS feed filteringMissing
P2F-DSH-05Entry analyticsMissing
P2F-FUT-01AI from Git commitsMissing
P2F-FUT-02CLI toolMissing
P2F-FUT-03GitHub ActionMissing

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

TableStatusNotes
tenantsMODIFYAdd favicon_url, custom_css, expand settings and brand_colors JSON
usersNO CHANGEβ€”
tenant_membersMODIFYAdd unique index on (tenant_id, user_id)
taxonomy_axesNEWDefines the configurable axes (what/why/impact/where)
taxonomy_valuesNEWConfigurable options within each axis
changelog_entriesMODIFYRemove updateType/impactLevel, add version/is_edited/search_vector
entry_taxonomy_valuesNEWJunction: entries ↔ taxonomy values (replaces multiple old junction tables)
entry_tagsNO CHANGEFree-form tags remain
entry_versionsNEWAudit trail β€” snapshots of entry state on each edit
entry_audiencesDROPReplaced by taxonomy system (audiences become a taxonomy axis if needed)
entry_platformsDROPReplaced by taxonomy system (platforms become a taxonomy axis if needed)
entry_product_areasDROPReplaced by taxonomy system (product areas β†’ β€œwhere” axis)
product_areasDROPReplaced by taxonomy_values where axis = β€œwhere”
subscribersMODIFYPreferences restructured to use taxonomy-based filters
webhooksNO CHANGEβ€”
api_keysNO CHANGEβ€”
analytics_eventsNO CHANGEβ€”
notification_logNEWTrack 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_xxxx header
  • Clerk session (for dashboard/browser-based access)

API keys are scoped:

  • entries:read β€” Read published entries
  • entries:write β€” Create/update entries
  • entries:delete β€” Delete entries
  • taxonomy:read β€” Read taxonomy configuration
  • taxonomy:write β€” Modify taxonomy (admin)
  • subscribers:read / subscribers:write β€” Manage subscribers
  • webhooks:read / webhooks:write β€” Manage webhooks

4.2 Public Endpoints (No Auth Required)

GET /api/v1/changelog

List published entries with filtering.

Query Parameters:

ParamTypeDescription
qstringFull-text search query
whatstringComma-separated β€œwhat” axis value slugs
whystringComma-separated β€œwhy” axis value slugs
impactstringComma-separated β€œimpact” value slugs
wherestringComma-separated β€œwhere” module slugs
tagstringComma-separated free-form tags
fromISO datePublished after this date (inclusive)
toISO datePublished before this date (inclusive)
sortstringdate_asc, date_desc (default), relevance (when q is set)
limitnumberResults per page (default 20, max 100)
cursorstringCursor 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-Slug header.
  • When q is present, results include a relevanceScore field and default sort is relevance.
  • Filtering is AND between axes, OR within an axis: ?what=data,methodology&impact=breaking means β€œ(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)

MethodEndpointDescriptionScope
GET/api/v1/taxonomy/axesList all axestaxonomy:read
POST/api/v1/taxonomy/axesCreate a custom axistaxonomy:write
PUT/api/v1/taxonomy/axes/:idUpdate an axistaxonomy:write
GET/api/v1/taxonomy/axes/:axisSlug/valuesList values for an axistaxonomy:read
POST/api/v1/taxonomy/axes/:axisSlug/valuesAdd a value to an axistaxonomy:write
PUT/api/v1/taxonomy/values/:idUpdate a valuetaxonomy:write
DELETE/api/v1/taxonomy/values/:idDeactivate a value (soft delete)taxonomy:write
PUT/api/v1/taxonomy/axes/:axisSlug/values/reorderReorder valuestaxonomy:write

Webhook Management

MethodEndpointDescriptionScope
GET/api/v1/webhooksList webhookswebhooks:read
POST/api/v1/webhooksCreate webhookwebhooks:write
PUT/api/v1/webhooks/:idUpdate webhookwebhooks:write
DELETE/api/v1/webhooks/:idDelete webhookwebhooks:write

API Key Management (Dashboard only β€” not accessible via API keys)

MethodEndpointDescription
GET/api/v1/api-keysList API keys (prefix + metadata only)
POST/api/v1/api-keysCreate API key (returns full key ONCE)
DELETE/api/v1/api-keys/:idRevoke 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β†’published
  • entry.updated β€” Published entry edited (includes new version number)
  • entry.archived β€” Entry archived
  • entry.deleted β€” Entry deleted

Webhook delivery:

  • Retry 3 times with exponential backoff (1s, 10s, 60s)
  • X-Webhook-Signature header 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

  1. 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.

  2. 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)
  3. 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
  4. Date grouping: Entries grouped by month with sticky month headers.

  5. 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:

  1. General β€” Org name, slug, logo upload, timezone
  2. Taxonomy β€” The taxonomy management UI above
  3. Branding β€” Brand colors, custom CSS
  4. Domain β€” Custom domain configuration + DNS verification
  5. Team β€” Invite/manage members, assign roles
  6. API β€” API key management (create, view, revoke)
  7. Webhooks β€” Webhook configuration
  8. Subscribers β€” View/manage subscribers, export list
  9. Billing β€” Subscription tier, usage, upgrade

5.3 Mobile Considerations

  1. Public changelog: Fully responsive. Filter bar collapses into an expandable β€œFilters” button on mobile. Entry cards stack vertically at full width.

  2. Dashboard: Sidebar becomes a hamburger menu drawer. Entry form fields stack. Entry table becomes a card list (not a table) on mobile.

  3. Touch targets: All interactive elements (filter chips, buttons, dropdown triggers) must be at least 44Γ—44px.

  4. 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_SLUG env var on Vercel
  • Fix tenant resolution fallback logic
  • Verify /changelog and 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 once

Step 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" axis

Step 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 updateType dropdown with dynamic taxonomy selectors
  • Replace hardcoded impactLevel dropdown with dynamic taxonomy selector
  • Replace productAreas multi-select with β€œwhere” axis selector
  • Add β€œwhy” axis selector (new)
  • Keep free-form tags input

Step 7: Update the public changelog

  • Replace updateType filter 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 updateEntry server action: if entry.status === β€œpublished”, snapshot current state to entry_versions before applying update
  • Increment version counter
  • Set is_edited = true
  • Add β€œVersion History” link on entry detail page

Step 9: Build taxonomy management UI

  • /dashboard/settings/taxonomy page
  • CRUD for values within each axis
  • Drag-to-reorder
  • Color/icon picker

Step 10: Build basic settings page

  • /dashboard/settings with 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

ComponentReuse?Notes
Auth system (Clerk + auto-provision)βœ… YesWorks well
Tenant resolution logicβœ… YesAfter P0 fix
Dashboard layout + sidebarβœ… YesAdd mobile hamburger later
Entry table componentβœ… YesUpdate columns for taxonomy
Filter chip componentβœ… YesMake dynamic instead of hardcoded
Entry card componentβœ… YesAdd taxonomy badges
RSS/Atom/JSON feed logicβœ… YesUpdate to include taxonomy
Badge componentβœ… YesMake configurable colors
Zod validation schemasπŸ”„ ModifyReplace hardcoded enums with dynamic validation
Server actions (create/update/delete)πŸ”„ ModifyAdd versioning + taxonomy junction writes
Entry queriesπŸ”„ ModifyAdd taxonomy joins + FTS

6.4 Risk Considerations

  1. Data migration safety: Run the taxonomy data migration in a transaction. Test on the staging tenant first.

  2. Backward compatibility: Keep updateType and impactLevel columns 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.

  3. Performance: The entry_taxonomy_values junction table will be queried on every page load. The composite indexes (idx_etv_tenant_axis_value) are critical. Monitor query performance after migration.

  4. Full-text search column: The generated tsvector column 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.

  5. 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

FeatureBetter ChangelogBeamerLaunchNotesHeadway
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.