From a0a6aabdc3deb16253097f1f19d428ad8d79ef01 Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 07:51:39 +0900 Subject: [PATCH 1/2] Add content_type support for fiction vs cartoon storylines (#1212) Adds a content_type column to storylines (default 'fiction') to distinguish fiction from cartoon content. The index endpoint validates the field, the update endpoint allows admin changes, and story cards/detail pages display a Cartoon badge when applicable. Co-Authored-By: Claude Opus 4.6 --- lib/content-type.test.ts | 22 +++++++++++++++++++ lib/genres.ts | 3 +++ lib/supabase.ts | 3 +++ src/app/api/index/storyline/route.ts | 9 +++++++- src/app/api/storyline/update/route.ts | 13 ++++++++++- src/app/story/[storylineId]/page.tsx | 5 +++++ src/components/StoryCard.tsx | 11 +++++++--- src/components/__tests__/StoryCard.test.tsx | 10 +++++++++ .../migrations/00039_add_content_type.sql | 3 +++ 9 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 lib/content-type.test.ts create mode 100644 supabase/migrations/00039_add_content_type.sql diff --git a/lib/content-type.test.ts b/lib/content-type.test.ts new file mode 100644 index 00000000..e6e7f7c0 --- /dev/null +++ b/lib/content-type.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { CONTENT_TYPES } from "./genres"; + +describe("CONTENT_TYPES", () => { + it("includes fiction and cartoon", () => { + expect(CONTENT_TYPES).toContain("fiction"); + expect(CONTENT_TYPES).toContain("cartoon"); + }); + + it("defaults to fiction (first entry)", () => { + expect(CONTENT_TYPES[0]).toBe("fiction"); + }); + + it("rejects invalid content types", () => { + const invalid = "manga"; + expect((CONTENT_TYPES as readonly string[]).includes(invalid)).toBe(false); + }); + + it("accepts valid cartoon content type", () => { + expect((CONTENT_TYPES as readonly string[]).includes("cartoon")).toBe(true); + }); +}); diff --git a/lib/genres.ts b/lib/genres.ts index e55ab0a8..c2916b14 100644 --- a/lib/genres.ts +++ b/lib/genres.ts @@ -36,5 +36,8 @@ export const LANGUAGES = [ "Others", ] as const; +export const CONTENT_TYPES = ["fiction", "cartoon"] as const; + export type Genre = (typeof GENRES)[number]; export type Language = (typeof LANGUAGES)[number]; +export type ContentType = (typeof CONTENT_TYPES)[number]; diff --git a/lib/supabase.ts b/lib/supabase.ts index b91feb11..8bb476f5 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -66,6 +66,7 @@ export interface Database { language: string; cover_cid: string | null; is_nsfw: boolean; + content_type: string; }; Insert: { id?: never; @@ -89,6 +90,7 @@ export interface Database { language?: string; cover_cid?: string | null; is_nsfw?: boolean; + content_type?: string; }; Update: { id?: never; @@ -112,6 +114,7 @@ export interface Database { language?: string; cover_cid?: string | null; is_nsfw?: boolean; + content_type?: string; }; Relationships: []; }; diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 1c43b73e..2274f0f0 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -10,7 +10,7 @@ import { import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; import { detectWriterType } from "../../../../../lib/contracts/erc8004"; import { hashContent } from "../../../../../lib/content"; -import { GENRES, LANGUAGES } from "../../../../../lib/genres"; +import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; import { reconcileStorylinePlotCount } from "../../../../../lib/reconcile"; import { awardWritePoints } from "../../../../../lib/airdrop/award"; @@ -40,11 +40,17 @@ export async function POST(req: Request) { const rawLanguage = body.language as string | undefined; const rawCoverCid = body.coverCid as string | undefined; const rawIsNsfw = body.isNsfw as string | undefined; + const rawContentType = body.contentType as string | undefined; const genre = rawGenre && (GENRES as readonly string[]).includes(rawGenre) ? rawGenre : null; const language = rawLanguage && (LANGUAGES as readonly string[]).includes(rawLanguage) ? rawLanguage : "English"; const coverCid = rawCoverCid && /^[a-zA-Z0-9]{46,64}$/.test(rawCoverCid) ? rawCoverCid : null; const isNsfw = rawIsNsfw === "true"; + if (rawContentType && !(CONTENT_TYPES as readonly string[]).includes(rawContentType)) { + return error("Invalid contentType; allowed values: fiction, cartoon"); + } + const contentType = rawContentType || "fiction"; + if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) { return error("Missing or invalid txHash"); } @@ -178,6 +184,7 @@ export async function POST(req: Request) { language, cover_cid: coverCid, is_nsfw: isNsfw, + content_type: contentType, }; const { error: dbError } = await supabase.from("storylines").upsert( diff --git a/src/app/api/storyline/update/route.ts b/src/app/api/storyline/update/route.ts index d9718e24..85ce7b0e 100644 --- a/src/app/api/storyline/update/route.ts +++ b/src/app/api/storyline/update/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { recoverMessageAddress } from "viem"; import { createServerClient } from "../../../../../lib/supabase"; import { STORY_FACTORY } from "../../../../../lib/contracts/constants"; -import { GENRES, LANGUAGES } from "../../../../../lib/genres"; +import { GENRES, LANGUAGES, CONTENT_TYPES } from "../../../../../lib/genres"; import type { Database } from "../../../../../lib/supabase"; const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1000; @@ -122,6 +122,17 @@ export async function POST(req: Request) { updates.is_nsfw = Boolean(body.isNsfw); } + if ("contentType" in body) { + if (!isAdmin) { + return error("Only admin can update the contentType field", 403); + } + const ct = body.contentType as string; + if (!(CONTENT_TYPES as readonly string[]).includes(ct)) { + return error("Invalid contentType; allowed values: fiction, cartoon"); + } + updates.content_type = ct; + } + if ("hidden" in body) { if (!isAdmin) { return error("Only admin can update the hidden field", 403); diff --git a/src/app/story/[storylineId]/page.tsx b/src/app/story/[storylineId]/page.tsx index a4462f9e..f223445b 100644 --- a/src/app/story/[storylineId]/page.tsx +++ b/src/app/story/[storylineId]/page.tsx @@ -395,6 +395,11 @@ function StoryHeader({ {storyline.genre || "Uncategorized"} + {storyline.content_type === "cartoon" && ( + + Cartoon + + )} {storyline.writer_type === 1 && ( AI Writer diff --git a/src/components/StoryCard.tsx b/src/components/StoryCard.tsx index 952dbc15..9a11c266 100644 --- a/src/components/StoryCard.tsx +++ b/src/components/StoryCard.tsx @@ -19,7 +19,7 @@ export const FALLBACK_STYLES: Record = { D: { background: "linear-gradient(175deg, oklch(94% 0.015 50) 0%, oklch(90% 0.02 40) 100%)" }, }; -function Badges({ genre, writerType, status, isNsfw }: { genre?: string | null; writerType: number | null; status: string; isNsfw?: boolean }) { +function Badges({ genre, writerType, status, isNsfw, contentType }: { genre?: string | null; writerType: number | null; status: string; isNsfw?: boolean; contentType?: string }) { const isActive = status === "active"; return (
@@ -43,6 +43,11 @@ function Badges({ genre, writerType, status, isNsfw }: { genre?: string | null; Ongoing )} + {contentType === "cartoon" && ( + + Cartoon + + )} {isNsfw && ( 18+ @@ -77,7 +82,7 @@ export function StoryCard({ />
- +

@@ -102,7 +107,7 @@ export function StoryCard({
- + {/* Centered title with accent line */}
diff --git a/src/components/__tests__/StoryCard.test.tsx b/src/components/__tests__/StoryCard.test.tsx index 43b428d0..6fa1ee82 100644 --- a/src/components/__tests__/StoryCard.test.tsx +++ b/src/components/__tests__/StoryCard.test.tsx @@ -90,4 +90,14 @@ describe("StoryCard", () => { render(); expect(screen.getByText("AI Writer")).toBeInTheDocument(); }); + + it("shows Cartoon badge when content_type is cartoon", () => { + render(); + expect(screen.getAllByText("Cartoon").length).toBeGreaterThan(0); + }); + + it("does not show Cartoon badge for fiction stories", () => { + render(); + expect(screen.queryByText("Cartoon")).not.toBeInTheDocument(); + }); }); diff --git a/supabase/migrations/00039_add_content_type.sql b/supabase/migrations/00039_add_content_type.sql new file mode 100644 index 00000000..cce4395d --- /dev/null +++ b/supabase/migrations/00039_add_content_type.sql @@ -0,0 +1,3 @@ +-- Add content_type column to storylines (fiction | cartoon) +ALTER TABLE storylines + ADD COLUMN content_type text NOT NULL DEFAULT 'fiction'; From 0093b3c20accee34f22a0d6814a8454a76d31fe5 Mon Sep 17 00:00:00 2001 From: project7 Date: Sun, 17 May 2026 07:56:25 +0900 Subject: [PATCH 2/2] Address RE1 review: strict contentType validation and DB CHECK constraint - Reject non-string/invalid contentType with 400 when field is present - Add CHECK constraint to migration ensuring only fiction/cartoon persisted Co-Authored-By: Claude Opus 4.6 --- src/app/api/index/storyline/route.ts | 8 +++++--- supabase/migrations/00039_add_content_type.sql | 3 +++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app/api/index/storyline/route.ts b/src/app/api/index/storyline/route.ts index 2274f0f0..b98c0475 100644 --- a/src/app/api/index/storyline/route.ts +++ b/src/app/api/index/storyline/route.ts @@ -46,10 +46,12 @@ export async function POST(req: Request) { const coverCid = rawCoverCid && /^[a-zA-Z0-9]{46,64}$/.test(rawCoverCid) ? rawCoverCid : null; const isNsfw = rawIsNsfw === "true"; - if (rawContentType && !(CONTENT_TYPES as readonly string[]).includes(rawContentType)) { - return error("Invalid contentType; allowed values: fiction, cartoon"); + if ("contentType" in body) { + if (typeof rawContentType !== "string" || !(CONTENT_TYPES as readonly string[]).includes(rawContentType)) { + return error("Invalid contentType; allowed values: fiction, cartoon"); + } } - const contentType = rawContentType || "fiction"; + const contentType = rawContentType && (CONTENT_TYPES as readonly string[]).includes(rawContentType) ? rawContentType : "fiction"; if (!txHash || !/^0x[0-9a-fA-F]{64}$/.test(txHash)) { return error("Missing or invalid txHash"); diff --git a/supabase/migrations/00039_add_content_type.sql b/supabase/migrations/00039_add_content_type.sql index cce4395d..a525bfbf 100644 --- a/supabase/migrations/00039_add_content_type.sql +++ b/supabase/migrations/00039_add_content_type.sql @@ -1,3 +1,6 @@ -- Add content_type column to storylines (fiction | cartoon) ALTER TABLE storylines ADD COLUMN content_type text NOT NULL DEFAULT 'fiction'; + +ALTER TABLE storylines + ADD CONSTRAINT storylines_content_type_check CHECK (content_type IN ('fiction', 'cartoon'));