Skip to content

Built-in CMS - Module Specification

1. Overview

1.1 Purpose

Built-in CMS provides content management for staff to create and edit blog posts, landing pages, customer reviews, and virtual locations without developer involvement. Integrates with Rich Text Editor for content authoring.

1.2 Scope

In Scope: - Blog post management (CRUD, publishing workflow) - Landing page management (dynamic content pages) - Customer review management (testimonials) - Virtual location management (SEO geo-targeting) - Content preview and publishing workflow

Out of Scope: - Page rendering/display (→ Customer Website module) - SEO keyword planning (→ Phase 3 SEO AI Agent) - AI content generation (→ Phase 3 SEO AI Agent) - User authentication (→ Auth module)


2. Dependencies

2.1 Upstream (What This Module Needs)

Module/Service What We Need Interface
Rich Text Editor Editor component, LexicalEditorState type React Component
Media Image/video embedding in content Server Actions
Auth User ID for authorship, role checks Server Actions
Admin Settings Store references for virtual locations Server Actions

2.2 Downstream (What Needs This Module)

Module/Service What They Need Interface
Customer Website Blog posts, landing pages, reviews, virtual locations Server Actions
SEO AI Agent (Phase 3) Content for optimization, keyword targets Server Actions

3. Data Ownership

3.1 Entities This Module Owns

Entity Description Key Fields
Blog Blog posts/articles id, title, slug, content, author, status, publishedAt
LandingPage Dynamic content pages id, title, slug, blocks, status
Review Customer testimonials id, customerName, content, rating, puppyBreed, status
VirtualLocation SEO geo-targeting pages id, city, state, slug, content, stores

3.2 Entities This Module Uses (Read-Only)

Entity Owner How We Use It
User Auth Track content authorship
Media Media Embed images/videos in content
Store Admin Settings Link virtual locations to nearby stores

3.3 Data Schema

import type { LexicalEditorState } from '@/modules/rich-text-editor';
import type { Media } from '@/modules/media';
import type { User } from '@/modules/auth';
import type { Store } from '@/modules/admin-settings';

type ContentStatus = 'draft' | 'published' | 'archived';

interface Blog {
  id: string;
  title: string;
  slug: string;                        // URL-friendly identifier
  excerpt?: string;                    // Short summary for listings
  content: LexicalEditorState;
  featuredImageId?: Media['id'];
  authorId: User['id'];
  status: ContentStatus;
  publishedAt?: Date;
  createdAt: Date;
  updatedAt: Date;
}

interface LandingPage {
  id: string;
  title: string;
  slug: string;
  blocks: LandingPageBlock[];          // Vertically stacked blocks
  status: ContentStatus;
  createdAt: Date;
  updatedAt: Date;
}

type LandingPageBlock =
  | HeroBlock
  | RichTextBlock
  | ImageBlock
  | VideoBlock
  | CallToActionBlock
  | TestimonialsBlock
  | FeatureGridBlock
  | FaqBlock;

interface HeroBlock {
  type: 'hero';
  title: string;
  subtitle?: LexicalEditorState;
  backgroundImageId?: Media['id'];
  ctaText?: string;
  ctaLink?: string;
}

interface RichTextBlock {
  type: 'rich_text';
  content: LexicalEditorState;
}

interface ImageBlock {
  type: 'image';
  imageId: Media['id'];
  alt?: string;
  caption?: LexicalEditorState;
}

interface VideoBlock {
  type: 'video';
  videoId: Media['id'];
  caption?: LexicalEditorState;
}

interface CallToActionBlock {
  type: 'cta';
  title: string;
  description?: LexicalEditorState;
  buttonText: string;
  buttonLink: string;
  backgroundImageId?: Media['id'];
}

interface TestimonialsBlock {
  type: 'testimonials';
  title?: string;
  reviewIds: Review['id'][];
}

interface FeatureGridBlock {
  type: 'feature_grid';
  title?: string;
  features: {
    iconName?: string;
    title: string;
    description: LexicalEditorState;
  }[];
}

interface FaqBlock {
  type: 'faq';
  title?: string;
  items: {
    question: string;
    answer: LexicalEditorState;
  }[];
}

interface Review {
  id: string;
  customerName: string;
  customerLocation?: string;           // e.g., "Dallas, TX"
  content: LexicalEditorState;
  rating: number;                      // 1-5 stars
  puppyBreed?: string;                 // What breed they purchased
  purchaseDate?: Date;
  photoIds: Media['id'][];             // Customer photos with puppy
  videoId?: Media['id'];               // Optional video testimonial
  status: ContentStatus;
  createdAt: Date;
  updatedAt: Date;
}

interface VirtualLocation {
  id: string;
  city: string;
  state: string;
  slug: string;                        // e.g., "puppies-for-sale-dallas-tx"
  metaTitle?: string;                  // SEO title override
  metaDescription?: string;            // SEO description
  content: LexicalEditorState;         // Location-specific content
  featuredImageId?: Media['id'];
  storeIds: Store['id'][];             // Nearby stores serving this area
  isActive: boolean;
  createdAt: Date;
  updatedAt: Date;
}

4. Service Level Objectives

Objective Target Measurement
Content Save Latency < 500ms Application monitoring
Content Load Latency < 200ms Application monitoring
Availability 99.9% Health checks
Autosave Interval 30s Application logic

Note: Content editing performance is critical for staff productivity. Autosave prevents data loss during long editing sessions.


5. Interface Contract

5.1 Internal Service Interface

interface CMSService {
  // Blog operations
  getBlog(id: string, include?: BlogInclude): Promise<Blog>;
  getBlogBySlug(slug: string, include?: BlogInclude): Promise<Blog>;
  listBlogs(filters?: BlogFilters, pagination?: Pagination, include?: BlogInclude): Promise<PaginatedResult<Blog>>;
  createBlog(data: CreateBlogDTO): Promise<Blog>;
  updateBlog(id: string, data: UpdateBlogDTO): Promise<Blog>;
  deleteBlog(id: string): Promise<void>;
  publishBlog(id: string): Promise<Blog>;
  unpublishBlog(id: string): Promise<Blog>;

  // Landing Page operations
  getLandingPage(id: string, include?: LandingPageInclude): Promise<LandingPage>;
  getLandingPageBySlug(slug: string, include?: LandingPageInclude): Promise<LandingPage>;
  listLandingPages(filters?: LandingPageFilters, pagination?: Pagination, include?: LandingPageInclude): Promise<PaginatedResult<LandingPage>>;
  createLandingPage(data: CreateLandingPageDTO): Promise<LandingPage>;
  updateLandingPage(id: string, data: UpdateLandingPageDTO): Promise<LandingPage>;
  deleteLandingPage(id: string): Promise<void>;
  publishLandingPage(id: string): Promise<LandingPage>;
  unpublishLandingPage(id: string): Promise<LandingPage>;

  // Review operations
  getReview(id: string, include?: ReviewInclude): Promise<Review>;
  listReviews(filters?: ReviewFilters, pagination?: Pagination, include?: ReviewInclude): Promise<PaginatedResult<Review>>;
  createReview(data: CreateReviewDTO): Promise<Review>;
  updateReview(id: string, data: UpdateReviewDTO): Promise<Review>;
  deleteReview(id: string): Promise<void>;
  publishReview(id: string): Promise<Review>;
  unpublishReview(id: string): Promise<Review>;

  // Virtual Location operations
  getVirtualLocation(id: string, include?: VirtualLocationInclude): Promise<VirtualLocation>;
  getVirtualLocationBySlug(slug: string, include?: VirtualLocationInclude): Promise<VirtualLocation>;
  listVirtualLocations(filters?: VirtualLocationFilters, pagination?: Pagination, include?: VirtualLocationInclude): Promise<PaginatedResult<VirtualLocation>>;
  createVirtualLocation(data: CreateVirtualLocationDTO): Promise<VirtualLocation>;
  updateVirtualLocation(id: string, data: UpdateVirtualLocationDTO): Promise<VirtualLocation>;
  deleteVirtualLocation(id: string): Promise<void>;
}

// Include options for relationship hydration
interface BlogInclude {
  featuredImage?: boolean;             // Hydrate Media from featuredImageId
  author?: boolean;                    // Hydrate User from authorId
}

interface LandingPageInclude {
  blockMedia?: boolean;                // Hydrate all Media refs in blocks
  blockReviews?: boolean;              // Hydrate Reviews in TestimonialsBlock
}

interface ReviewInclude {
  photos?: boolean;                    // Hydrate Media[] from photoIds
  video?: boolean;                     // Hydrate Media from videoId
}

interface VirtualLocationInclude {
  featuredImage?: boolean;             // Hydrate Media from featuredImageId
  stores?: boolean;                    // Hydrate Store[] from storeIds
}

// Filters
interface BlogFilters {
  status?: ContentStatus;
  authorId?: string;
  search?: string;
}

interface LandingPageFilters {
  status?: ContentStatus;
  search?: string;
}

interface ReviewFilters {
  status?: ContentStatus;
  minRating?: number;
  search?: string;
}

interface VirtualLocationFilters {
  state?: string;
  isActive?: boolean;
  search?: string;
}

// DTOs
interface CreateBlogDTO {
  title: string;
  slug: string;
  excerpt?: string;
  content: LexicalEditorState;
  featuredImageId?: Media['id'];
  status?: ContentStatus;
}

interface UpdateBlogDTO {
  title?: string;
  slug?: string;
  excerpt?: string;
  content?: LexicalEditorState;
  featuredImageId?: Media['id'];
}

interface CreateLandingPageDTO {
  title: string;
  slug: string;
  blocks: LandingPageBlock[];
  status?: ContentStatus;
}

interface UpdateLandingPageDTO {
  title?: string;
  slug?: string;
  blocks?: LandingPageBlock[];
}

interface CreateReviewDTO {
  customerName: string;
  customerLocation?: string;
  content: LexicalEditorState;
  rating: number;
  puppyBreed?: string;
  purchaseDate?: Date;
  photoIds?: Media['id'][];
  videoId?: Media['id'];
  status?: ContentStatus;
}

interface UpdateReviewDTO {
  customerName?: string;
  customerLocation?: string;
  content?: LexicalEditorState;
  rating?: number;
  puppyBreed?: string;
  purchaseDate?: Date;
  photoIds?: Media['id'][];
  videoId?: Media['id'];
}

interface CreateVirtualLocationDTO {
  city: string;
  state: string;
  slug: string;
  metaTitle?: string;
  metaDescription?: string;
  content: LexicalEditorState;
  featuredImageId?: Media['id'];
  storeIds: Store['id'][];
  isActive?: boolean;
}

interface UpdateVirtualLocationDTO {
  city?: string;
  state?: string;
  slug?: string;
  metaTitle?: string;
  metaDescription?: string;
  content?: LexicalEditorState;
  featuredImageId?: Media['id'];
  storeIds?: Store['id'][];
  isActive?: boolean;
}

5.2 Remote Procedure Interface

Communication Pattern: Next.js Server Actions

Procedure Input Output Auth Description
getBlog { id, include? } Blog Public Get blog post (published only for public)
getBlogBySlug { slug, include? } Blog Public Get blog by slug
listBlogs { filters?, pagination?, include? } PaginatedResult Public List blogs (published only for public)
createBlog CreateBlogDTO Blog Admin Create new blog
updateBlog { id, data } Blog Admin Update blog
deleteBlog { id } void Admin Delete blog
publishBlog { id } Blog Admin Publish blog
getLandingPage { id, include? } LandingPage Public Get landing page
getLandingPageBySlug { slug, include? } LandingPage Public Get page by slug
listLandingPages { filters?, pagination?, include? } PaginatedResult Admin List pages (admin only)
createLandingPage CreateLandingPageDTO LandingPage Admin Create page
updateLandingPage { id, data } LandingPage Admin Update page
deleteLandingPage { id } void Admin Delete page
getReview { id, include? } Review Public Get review
listReviews { filters?, pagination?, include? } PaginatedResult Public List reviews (published only for public)
createReview CreateReviewDTO Review Admin Create review
updateReview { id, data } Review Admin Update review
deleteReview { id } void Admin Delete review
getVirtualLocation { id, include? } VirtualLocation Public Get virtual location
getVirtualLocationBySlug { slug, include? } VirtualLocation Public Get by slug
listVirtualLocations { filters?, pagination?, include? } PaginatedResult Public List locations (active only for public)
createVirtualLocation CreateVirtualLocationDTO VirtualLocation Admin Create location
updateVirtualLocation { id, data } VirtualLocation Admin Update location
deleteVirtualLocation { id } void Admin Delete location

6. Error Handling Strategy

Error Code Condition User Message
CMS_BLOG_NOT_FOUND Blog doesn't exist "Blog post not found"
CMS_PAGE_NOT_FOUND Landing page doesn't exist "Page not found"
CMS_REVIEW_NOT_FOUND Review doesn't exist "Review not found"
CMS_LOCATION_NOT_FOUND Virtual location doesn't exist "Location not found"
CMS_SLUG_EXISTS Slug already in use "This URL slug is already taken"
CMS_INVALID_SLUG Slug format invalid "URL slug must be lowercase letters, numbers, and hyphens only"
CMS_INVALID_RATING Rating out of range "Rating must be between 1 and 5"
CMS_INVALID_BLOCK Invalid block structure "Invalid content block"
CMS_CONTENT_NOT_PUBLISHED Accessing unpublished content (public) "Content not available"
CMS_UNAUTHORIZED Insufficient permissions "You don't have permission to perform this action"

Error Response Format

interface CMSErrorResponse {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}

7. Observability

7.1 Logging

Event Level Fields to Include
Blog created INFO blog_id, slug, author_id
Blog published INFO blog_id, slug, author_id
Blog unpublished INFO blog_id, slug, user_id
Blog deleted INFO blog_id, slug, user_id
Landing page created INFO page_id, slug
Landing page published INFO page_id, slug
Landing page deleted INFO page_id, slug, user_id
Review created INFO review_id, rating
Review published INFO review_id
Review deleted INFO review_id, user_id
Virtual location created INFO location_id, city, state, slug
Virtual location deleted INFO location_id, slug, user_id
Content save failed ERROR entity_type, entity_id, error_message
Invalid block structure WARN page_id, block_index, block_type

7.2 Metrics

Metric Type Description
cms_blogs_total Gauge Total blogs by status
cms_pages_total Gauge Total landing pages by status
cms_reviews_total Gauge Total reviews by status
cms_locations_total Gauge Total virtual locations
cms_content_saves_total Counter Content save operations by entity type
cms_content_publishes_total Counter Publish operations by entity type
cms_save_duration_ms Histogram Content save latency

8. Testing Strategy

Test Type Coverage Target Tools Focus Areas
Unit 80%+ Vitest Slug validation, block structure validation, content transforms
Integration Critical paths Vitest CRUD operations, publish/unpublish workflows, include hydration
E2E Happy paths Playwright Blog editor, page builder, review management, content preview
Visual Key states Playwright Landing page block rendering, blog display

9. Feature Inventory

Feature ID Name Status Priority
FEATURE-057 Blog Management Draft Must Have
FEATURE-058 Landing Page Builder Draft Must Have
FEATURE-059 Review Management Draft Must Have
FEATURE-060 Virtual Location Management Draft Should Have

FEATURE-057 Note: CRUD, publish/unpublish workflow, rich text editing, featured images, author tracking.

FEATURE-058 Note: Block-based page builder with drag-and-drop reordering, block type library (Hero, Rich Text, Image, Video, CTA, Testimonials, Feature Grid, FAQ), preview functionality.

FEATURE-059 Note: CRUD for customer testimonials, rating management, photo/video attachments, publish workflow.

FEATURE-060 Note: Geo-targeted SEO pages, city/state management, store assignment, SEO metadata fields.


10. Access Control

Operation Permissions

Operation Public Sales Kennel Staff Photographer Admin
View published blogs Yes Yes Yes Yes Yes
View draft blogs No No No No Yes
Create/Edit/Delete blogs No No No No Yes
Publish/Unpublish blogs No No No No Yes
View published landing pages Yes Yes Yes Yes Yes
View draft landing pages No No No No Yes
Create/Edit/Delete landing pages No No No No Yes
View published reviews Yes Yes Yes Yes Yes
View draft reviews No No No No Yes
Create/Edit/Delete reviews No No No No Yes
View active virtual locations Yes Yes Yes Yes Yes
View inactive virtual locations No No No No Yes
Create/Edit/Delete virtual locations No No No No Yes

Note: All CMS content management is Admin-only. Public users and staff roles can only view published/active content.


11. Decisions

D1: Block-Based Landing Pages (2026-01-20)

Status: Accepted Context: Need flexible page building without developer involvement Decision: Use typed block array for landing pages instead of single rich text field Consequences: More complex schema, but enables drag-and-drop page building with structured, predictable content blocks

D2: Rich Text Within Blocks (2026-01-20)

Status: Accepted Context: Block descriptions and captions need formatting flexibility Decision: Use LexicalEditorState for description/caption fields within blocks instead of plain strings Consequences: Consistent rich text editing experience throughout, slightly larger JSON payloads

D3: Content Status Workflow (2026-01-20)

Status: Accepted Context: Need to manage content lifecycle (draft → published → archived) Decision: Use ContentStatus enum with explicit publish/unpublish operations Consequences: Clear separation between draft and live content, public API only returns published content

D4: Slug-Based Routing (2026-01-20)

Status: Accepted Context: Need SEO-friendly URLs for all content types Decision: All content entities have unique slugs used for public URL routing Consequences: Requires slug validation and uniqueness checks, enables clean URLs

D5: Admin-Only Content Management (2026-01-20)

Status: Accepted Context: Content editing requires editorial judgment and brand consistency Decision: All CMS CRUD operations restricted to Admin role Consequences: Simpler permission model, may need "Editor" role in future if content team grows


12. References


Change Log

Version Date Author Changes
1.0 2026-01-20 Claude Initial module spec - Draft