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¶
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¶
- Architecture: ARCH-001
- Customer Website Module: MODULE-001
- Inventory Module: MODULE-002
- Media Module: MODULE-003
- Admin Settings Module: MODULE-004
- Rich Text Editor Module: MODULE-005
- Auth Module: MODULE-006
Change Log¶
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-20 | Claude | Initial module spec - Draft |