Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions docs/cms-hardening-refactor-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# CMS Hardening + Refactor Plan

Date: 2026-04-13
Branch: `codex/cms-hardening-refactor`
Status: in progress

## Goals
- Make CMS stable under real editing load.
- Prevent invalid content writes and path abuse.
- Reduce maintenance cost by splitting monolithic admin code.
- Improve content loading and rendering behavior for speed and reliability.

## Scope
1. CMS reliability and data safety
2. Code refactor and architecture cleanup
3. Performance and content loading optimizations

## Work Plan

### Phase 1 (P0): CMS Contract and Write Safety
- Add strict server-side validation for case payloads.
- Enforce path allowlist for save/upload endpoints.
- Centralize GitHub write calls with retry, timeout, and normalized errors.
- Add conflict-aware behavior (`sha` based updates).

Done when:
- Invalid payloads cannot be committed through API.
- Invalid paths are rejected deterministically.
- API error codes are stable and user-facing errors are clear.

### Phase 2 (P0/P1): Admin Decomposition
- Split `src/app/admin/page.tsx` into composable editors.
- Extract data and side-effect logic into hooks.
- Keep behavior parity, then simplify.

Done when:
- Admin page no longer has monolithic business logic.
- Component boundaries are explicit and testable.

### Phase 3 (P0): Rendering Correctness
- Remove unsafe fallback for unknown case slugs.
- Return proper 404 (`notFound`) for missing slugs.
- Keep ISR behavior predictable for published content.

Done when:
- Unknown slug does not silently render unrelated case.

### Phase 4 (P1): Content Loading and Performance
- Move repeated content read logic to shared loader helpers.
- Remove sync I/O from hot paths where possible.
- Optimize media rendering strategy and preserve mobile behavior.

Done when:
- Content loading path is centralized and measurable.
- No performance regression on homepage/case pages.

## Test Strategy

### Unit
- Case payload validation (facts, sections, block discriminants).
- Path sanitization and allowlist guards.
- GitHub error mapping and retry policy behavior.

### API Integration
- `POST /api/save-content`: success, validation fail, conflict, rate limit.
- `POST /api/upload-image`: success, mime/type/path/size failures.
- `GET /api/cases` and `GET /api/cases/[slug]`: valid + not found.

### E2E (Playwright, planned)
- Open admin, edit case, save, confirm success state.
- Upload media image to block and verify path update.
- Simulate concurrent edits and verify conflict messaging.

### Regression
- Existing Jest suite must remain green.
- Add smoke tests for case page rendering by slug.
- Verify `/work/[slug]` 404 behavior.

## Risk Controls
- Draft PR only until P0 hardening tests pass.
- Keep changes incremental in small commits.
- No force-push on shared branches.
- No direct edits on `main`.
102 changes: 102 additions & 0 deletions src/app/admin/lib/__tests__/case-editor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import {
addBlock,
addFact,
addSection,
createUploadPath,
moveBlock,
removeBlock,
removeFact,
toPublicPath,
updateBlockValue,
updateCaseField,
updateFactField,
updateSectionField,
validateCaseForSave,
} from "@/app/admin/lib/case-editor";
import type { CaseStudy } from "@/app/admin/types";

const buildCase = (): CaseStudy => ({
slug: "demo-case",
title: "Demo Case",
subtitle: "Subtitle",
coverSrc: "/cases/demo/cover.png",
coverAlt: "Cover alt",
facts: [{ label: "role", value: "Product Designer" }],
sections: [
{
title: "Context",
blocks: [
{ discriminant: "paragraph", value: { text: "A" } },
{ discriminant: "paragraph", value: { text: "B" } },
],
},
],
});

describe("case-editor", () => {
it("updates top-level field", () => {
const updated = updateCaseField(buildCase(), "title", "Updated");
expect(updated.title).toBe("Updated");
});

it("adds and removes facts", () => {
const withFact = addFact(buildCase());
expect(withFact.facts).toHaveLength(2);

const removed = removeFact(withFact, 1);
expect(removed.facts).toHaveLength(1);
});

it("updates fact field", () => {
const updated = updateFactField(buildCase(), 0, "label", "scope");
expect(updated.facts[0].label).toBe("scope");
});

it("adds and updates sections", () => {
const withSection = addSection(buildCase());
expect(withSection.sections).toHaveLength(2);

const updated = updateSectionField(withSection, 1, "title", "Outcome");
expect(updated.sections[1].title).toBe("Outcome");
});

it("updates, adds, removes and moves blocks", () => {
const updated = updateBlockValue(buildCase(), 0, 0, { text: "Updated" });
expect(updated.sections[0].blocks[0].value.text).toBe("Updated");

const withBlock = addBlock(updated, 0, "media");
expect(withBlock.sections[0].blocks).toHaveLength(3);

const moved = moveBlock(withBlock, 0, 0, 1);
expect(moved.sections[0].blocks[1].value.text).toBe("Updated");

const removed = removeBlock(moved, 0, 2);
expect(removed.sections[0].blocks).toHaveLength(2);
});

it("does not mutate input when adding or removing blocks", () => {
const original = buildCase();
const originalBlockCount = original.sections[0].blocks.length;

const withBlock = addBlock(original, 0, "list");
expect(withBlock.sections[0].blocks).toHaveLength(originalBlockCount + 1);
expect(original.sections[0].blocks).toHaveLength(originalBlockCount);

const removed = removeBlock(withBlock, 0, 0);
expect(removed.sections[0].blocks).toHaveLength(originalBlockCount);
expect(withBlock.sections[0].blocks).toHaveLength(originalBlockCount + 1);
});

it("builds upload and public paths", () => {
const uploadPath = createUploadPath("demo-case", "image.webp", 123456);
expect(uploadPath).toBe("public/cases/demo-case/123456.webp");
expect(toPublicPath(uploadPath)).toBe("/cases/demo-case/123456.webp");
});

it("validates required case fields", () => {
expect(validateCaseForSave(buildCase())).toBeNull();

const invalid = { ...buildCase(), title: "" };
expect(validateCaseForSave(invalid)).toBe("Title is required");
});
});
144 changes: 144 additions & 0 deletions src/app/admin/lib/case-editor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { Block, CaseStudy, Fact, SeoData, Section } from "../types";

export const defaultBlockValue: Record<Block["discriminant"], Block["value"]> = {
paragraph: { text: "" },
list: { items: [""] },
link: { label: "", href: "" },
media: { src: "", alt: "", caption: "", variant: "diagram" },
};

export const updateCaseField = <K extends keyof CaseStudy>(
data: CaseStudy,
field: K,
value: CaseStudy[K],
): CaseStudy => ({ ...data, [field]: value });

export const updateFactField = (
data: CaseStudy,
index: number,
field: keyof Fact,
value: string | string[],
): CaseStudy => {
const facts = [...data.facts];
facts[index] = { ...facts[index], [field]: value };
return updateCaseField(data, "facts", facts);
};

export const addFact = (data: CaseStudy): CaseStudy =>
updateCaseField(data, "facts", [...data.facts, { label: "", value: "" }]);

export const removeFact = (data: CaseStudy, index: number): CaseStudy =>
updateCaseField(
data,
"facts",
data.facts.filter((_, i) => i !== index),
);

export const updateSeoField = (
data: CaseStudy,
field: keyof SeoData,
value: string,
): CaseStudy => updateCaseField(data, "seo", { ...(data.seo || {}), [field]: value });

export const updateSectionField = (
data: CaseStudy,
sectionIndex: number,
field: keyof Section,
value: string,
): CaseStudy => {
const sections = [...data.sections];
sections[sectionIndex] = { ...sections[sectionIndex], [field]: value };
return updateCaseField(data, "sections", sections);
};

export const addSection = (data: CaseStudy): CaseStudy =>
updateCaseField(data, "sections", [
...data.sections,
{ title: "", blocks: [{ discriminant: "paragraph", value: { text: "" } }] },
]);

export const removeSection = (data: CaseStudy, index: number): CaseStudy =>
updateCaseField(
data,
"sections",
data.sections.filter((_, i) => i !== index),
);

export const updateBlockValue = (
data: CaseStudy,
sectionIndex: number,
blockIndex: number,
value: Partial<Block["value"]>,
): CaseStudy => {
const sections = [...data.sections];
const blocks = [...sections[sectionIndex].blocks];
blocks[blockIndex] = {
...blocks[blockIndex],
value: { ...blocks[blockIndex].value, ...value },
};
sections[sectionIndex] = { ...sections[sectionIndex], blocks };
return updateCaseField(data, "sections", sections);
};

export const addBlock = (
data: CaseStudy,
sectionIndex: number,
type: Block["discriminant"],
): CaseStudy => {
const sections = [...data.sections];
const blocks = [...sections[sectionIndex].blocks, { discriminant: type, value: defaultBlockValue[type] }];
sections[sectionIndex] = { ...sections[sectionIndex], blocks };
return updateCaseField(data, "sections", sections);
};

export const removeBlock = (
data: CaseStudy,
sectionIndex: number,
blockIndex: number,
): CaseStudy => {
const sections = [...data.sections];
const blocks = sections[sectionIndex].blocks.filter((_, i) => i !== blockIndex);
sections[sectionIndex] = { ...sections[sectionIndex], blocks };
return updateCaseField(data, "sections", sections);
};

export const moveBlock = (
data: CaseStudy,
sectionIndex: number,
fromIndex: number,
toIndex: number,
): CaseStudy => {
if (fromIndex === toIndex) return data;

const sections = [...data.sections];
const blocks = [...sections[sectionIndex].blocks];
const [moved] = blocks.splice(fromIndex, 1);
blocks.splice(toIndex, 0, moved);
sections[sectionIndex] = { ...sections[sectionIndex], blocks };
return updateCaseField(data, "sections", sections);
};

export const createUploadPath = (
slug: string,
fileName: string,
now = Date.now(),
) => {
const ext = fileName.split(".").pop() || "png";
return `public/cases/${slug}/${now}.${ext}`;
};

export const toPublicPath = (path: string) => path.replace(/^public/, "");

export const validateCaseForSave = (data: CaseStudy): string | null => {
if (!data.title.trim()) return "Title is required";
if (!data.slug.trim()) return "Slug is required";
if (!data.coverAlt.trim()) return "Cover alt text is required";

const emptyFact = data.facts.find((fact) => !fact.label.trim());
if (emptyFact) return "All fact labels must be filled";

const emptySection = data.sections.find((section) => !section.title.trim());
if (emptySection) return "All section titles must be filled";

return null;
};
Loading