Initial commit: design-018 Phases 0-5 (stomping.me)

Auth BFF (OIDC + prompt=none silent SSO), Mongo data layer, admin CRUD
(folders/tags/stories/chapters with TipTap), public reader with tag
filtering. Built and verified same-session per design-018-stories.md.
This commit is contained in:
Claude Code
2026-07-04 05:36:43 +00:00
commit d6b16f5e06
47 changed files with 5680 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
// folderTree.js — re-parent cycle detection for admin folder CRUD
// (design-018 §6 Phase 3: "incl. re-parent — must reject cycles").
'use strict';
// Would setting `folderId`'s parent to `newParentId` create a cycle? Walks up
// from the proposed new parent; if folderId's own id is ever reached, the
// move would make folderId an ancestor of itself.
async function wouldCreateCycle(db, folderId, newParentId) {
if (!newParentId) return false;
const folderKey = String(folderId);
if (String(newParentId) === folderKey) return true;
const folders = db.collection('folders');
const seen = new Set();
let current = newParentId;
while (current) {
const key = String(current);
if (key === folderKey) return true;
if (seen.has(key)) break; // defensive — stop on a pre-existing corrupt loop
seen.add(key);
const doc = await folders.findOne({ _id: current }, { projection: { parent_id: 1 } });
if (!doc) break;
current = doc.parent_id;
}
return false;
}
// Flattens a folder list into depth-first tree order, annotating each with
// `depth` (0 = root) for indentation. Orphaned folders (parent_id points at
// something no longer present) are appended at depth 0 rather than dropped.
function buildFolderTree(folders) {
const byParent = new Map();
const ids = new Set(folders.map(f => String(f._id)));
for (const folder of folders) {
const parentKey = folder.parent_id && ids.has(String(folder.parent_id))
? String(folder.parent_id)
: 'root';
if (!byParent.has(parentKey)) byParent.set(parentKey, []);
byParent.get(parentKey).push(folder);
}
for (const list of byParent.values()) list.sort((a, b) => a.display_order - b.display_order);
const out = [];
function walk(parentKey, depth) {
for (const folder of byParent.get(parentKey) || []) {
out.push({ ...folder, depth });
walk(String(folder._id), depth + 1);
}
}
walk('root', 0);
return out;
}
module.exports = { wouldCreateCycle, buildFolderTree };
+127
View File
@@ -0,0 +1,127 @@
// normalize.js — shared strip-empty-to-null normalizer (design-018 §2, same
// pattern as design-015's castNormalizer.js). Every write passes through one
// of these before hitting Mongo, so render-if-present is enforced at the
// write path, not scattered across templates.
//
// created_at/updated_at are set by the caller at insert/update time, not
// here — matches the established convention (see castRoutes.js).
'use strict';
const { renderBodyHtml } = require('./renderTiptap');
const { sanitizeBodyHtml } = require('./sanitizeHtml');
const { countWords } = require('./wordCount');
const STORY_TYPES = ['one_shot', 'serial', 'bite_size'];
const CONTENT_RATINGS = ['general', 'teen', 'mature', 'adult'];
const SERIAL_STATUSES = ['ongoing', 'completed', 'hiatus'];
const ENTITY_STATUSES = ['draft', 'published'];
const TAG_KINDS = ['explicit', 'general'];
function stripEmpty(value) {
if (typeof value === 'string') return value.trim() === '' ? null : value;
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
return normalizeObject(value);
}
return value;
}
function normalizeObject(obj) {
const out = {};
for (const key of Object.keys(obj)) out[key] = stripEmpty(obj[key]);
return out;
}
function normalizeSlugArray(raw) {
if (!Array.isArray(raw)) return [];
return raw
.map(v => (typeof v === 'string' ? v.trim() : ''))
.filter(v => v !== '');
}
// ── folders (§2.1) ────────────────────────────────────────────────────────
function normalizeFolder(raw) {
return {
slug: String(raw.slug || '').trim(),
name: String(raw.name || '').trim(),
parent_id: raw.parent_id ?? null,
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
};
}
// ── stories (§2.2) ────────────────────────────────────────────────────────
function normalizeStory(raw) {
return {
slug: String(raw.slug || '').trim(),
folder_id: raw.folder_id ?? null,
title: String(raw.title || '').trim(),
subtitle: stripEmpty(raw.subtitle ?? null),
badges: normalizeSlugArray(raw.badges),
summary: stripEmpty(raw.summary ?? null),
cover_url: stripEmpty(raw.cover_url ?? null),
story_type: STORY_TYPES.includes(raw.story_type) ? raw.story_type : 'one_shot',
content_rating: CONTENT_RATINGS.includes(raw.content_rating) ? raw.content_rating : 'general',
explicit_tags: normalizeSlugArray(raw.explicit_tags),
general_tags: normalizeSlugArray(raw.general_tags),
status: ENTITY_STATUSES.includes(raw.status) ? raw.status : 'draft',
serial_status: SERIAL_STATUSES.includes(raw.serial_status) ? raw.serial_status : null,
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
chronological_order: raw.chronological_order != null && raw.chronological_order !== ''
? Number(raw.chronological_order) : null,
published_at: raw.published_at ?? null,
word_count: 0,
rating_avg: null,
rating_count: 0,
author_user_id: Number(raw.author_user_id),
};
}
// ── chapters (§2.3) ───────────────────────────────────────────────────────
// body_html/word_count are always derived from body_json here — never
// accepted from the caller — so JSON stays the one source of truth.
function normalizeChapter(raw) {
const bodyJson = raw.body_json && typeof raw.body_json === 'object'
? raw.body_json
: { type: 'doc', content: [] };
return {
story_id: raw.story_id,
slug: String(raw.slug || '').trim(),
title: String(raw.title || '').trim(),
display_order: raw.display_order != null ? Number(raw.display_order) : 0,
status: ENTITY_STATUSES.includes(raw.status) ? raw.status : 'draft',
body_json: bodyJson,
body_html: sanitizeBodyHtml(renderBodyHtml(bodyJson)),
note_top: stripEmpty(raw.note_top ?? null),
note_bottom: stripEmpty(raw.note_bottom ?? null),
word_count: countWords(bodyJson),
published_at: raw.published_at ?? null,
};
}
// ── tags (§2.4) ───────────────────────────────────────────────────────────
function normalizeTag(raw) {
return {
slug: String(raw.slug || '').trim().toLowerCase(),
label: String(raw.label || '').trim(),
kind: TAG_KINDS.includes(raw.kind) ? raw.kind : 'general',
description: stripEmpty(raw.description ?? null),
};
}
module.exports = {
stripEmpty,
normalizeObject,
normalizeFolder,
normalizeStory,
normalizeChapter,
normalizeTag,
STORY_TYPES,
CONTENT_RATINGS,
SERIAL_STATUSES,
ENTITY_STATUSES,
TAG_KINDS,
};
+14
View File
@@ -0,0 +1,14 @@
// renderTiptap.js — TipTap JSON -> HTML, server-side. JSON is the canonical
// source (design-018 §2.3); HTML is re-rendered from it on every save, never
// hand-edited, never trusted from the client directly.
'use strict';
const { generateHTML } = require('@tiptap/html/server');
const extensions = require('./tiptapExtensions');
function renderBodyHtml(bodyJson) {
if (!bodyJson || typeof bodyJson !== 'object') return '';
return generateHTML(bodyJson, extensions);
}
module.exports = { renderBodyHtml };
+32
View File
@@ -0,0 +1,32 @@
// sanitizeHtml.js — whitelist sanitizer for chapter body_html. TipTap's own
// output is structured, but this is sanitized anyway (design-018 §2.3) since
// it's a write path that will eventually be shared with other content
// (comments, someday). Whitelist matches exactly the tags/marks the v1
// editor extensions (StarterKit + Image, see tiptapExtensions.js) can emit —
// nothing else is expected here, so nothing else is allowed through.
'use strict';
const sanitizeHtml = require('sanitize-html');
const OPTIONS = {
allowedTags: [
'p', 'br', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'strong', 'em', 's',
'ul', 'ol', 'li',
'blockquote',
'a', 'img',
],
allowedAttributes: {
a: ['href', 'target', 'rel'],
img: ['src', 'alt'],
},
allowedSchemes: ['http', 'https', 'mailto'],
allowProtocolRelative: false,
};
function sanitizeBodyHtml(html) {
return sanitizeHtml(html || '', OPTIONS);
}
module.exports = { sanitizeBodyHtml };
+21
View File
@@ -0,0 +1,21 @@
// storyAggregate.js — story.word_count is the sum of its published chapters'
// word counts (design-018 §2.2). Recomputed on every chapter save/publish-
// toggle/delete — full recount each time, matching this project's established
// "cheap at this scale, full recount is correct" convention (design-018 §2.6
// documents the same tradeoff for rating aggregates).
'use strict';
async function recomputeStoryWordCount(db, storyId) {
const chapters = await db.collection('chapters')
.find({ story_id: storyId, status: 'published' }, { projection: { word_count: 1 } })
.toArray();
const wordCount = chapters.reduce((sum, c) => sum + (c.word_count || 0), 0);
await db.collection('stories').updateOne(
{ _id: storyId },
{ $set: { word_count: wordCount, updated_at: new Date() } }
);
return wordCount;
}
module.exports = { recomputeStoryWordCount };
+11
View File
@@ -0,0 +1,11 @@
// tiptapExtensions.js — shared extension set for server-side rendering
// (renderTiptap.js) and, later, the admin editor bundle (Phase 3). One list,
// one source of truth, per design-018 §4: StarterKit + image-by-URL. Link is
// bundled inside StarterKit v3 by default — do not add @tiptap/extension-link
// separately, it registers a duplicate 'link' extension name.
'use strict';
const { StarterKit } = require('@tiptap/starter-kit');
const { Image } = require('@tiptap/extension-image');
module.exports = [StarterKit, Image];
+26
View File
@@ -0,0 +1,26 @@
// wordCount.js — word count from TipTap JSON, computed on every chapter
// save (design-018 §2.3). Walks the document tree collecting text node
// content, joining across node boundaries with whitespace so words never
// merge across a paragraph/heading/list-item break.
'use strict';
function collectText(node, parts) {
if (!node || typeof node !== 'object') return;
if (typeof node.text === 'string') {
parts.push(node.text);
return;
}
if (Array.isArray(node.content)) {
for (const child of node.content) collectText(child, parts);
}
}
function countWords(bodyJson) {
if (!bodyJson || typeof bodyJson !== 'object') return 0;
const parts = [];
collectText(bodyJson, parts);
const words = parts.join(' ').trim().split(/\s+/).filter(Boolean);
return words.length;
}
module.exports = { countWords };