← back to Nineoh Guide
packages/core/src/schemas.ts
81 lines
import { z } from "zod";
/**
* Shared zod schemas — the single source of truth for data contracts across
* the Next.js web app and the Expo mobile app. Web forms and mobile screens
* validate against the SAME schema, so the two apps can never drift.
*
* Design note: every user-facing text field that could carry third-party
* expression is named `*_original` to encode the hard rail that summaries and
* bios are ORIGINAL, never copied synopses.
*/
export const AssetSchema = z.object({
id: z.string().uuid(),
type: z.enum(["image", "thumbnail"]),
fileUrl: z.string().url().nullable(), // null while PENDING a cleared license
thumbnailUrl: z.string().url().nullable(),
licenseType: z.enum([
"public-domain",
"cc-by",
"cc-by-sa",
"licensed",
"PENDING",
]),
licenseUrl: z.string().url().nullable(),
attributionText: z.string().nullable(),
usageScope: z.enum(["editorial", "marketing", "icon"]).default("editorial"),
approvedForMarketing: z.boolean().default(false),
licensePurchaseStatus: z
.enum(["none", "drafted-for-approval", "purchased"])
.default("none"),
});
export type Asset = z.infer<typeof AssetSchema>;
/** An asset may only render if it has a cleared, non-PENDING license. */
export function assetIsRenderable(a: Asset): boolean {
return a.fileUrl != null && a.licenseType !== "PENDING";
}
export const EpisodeSchema = z.object({
id: z.string().uuid(),
seasonNumber: z.number().int().positive(),
episodeNumber: z.number().int().positive(),
title: z.string().min(1),
airDate: z.string().nullable(), // ISO date
writer: z.string().nullable(),
director: z.string().nullable(),
summaryShortOriginal: z.string().nullable(),
summaryFullOriginal: z.string().nullable(),
spoilerRating: z.number().int().min(0).max(3).default(0),
externalIds: z.record(z.string()).default({}),
// Featured cast for this episode: series regulars + this episode's guest
// stars (factual, sourced from TVmaze). Defaults to [] for older payloads.
cast: z
.array(z.object({ name: z.string(), character: z.string().nullable() }))
.default([]),
});
export type Episode = z.infer<typeof EpisodeSchema>;
export const CastPersonSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1),
biographyOriginal: z.string().nullable(),
officialSiteUrl: z.string().url().nullable(),
verifiedSocialLinks: z.array(z.string().url()).default([]),
headshotAssetId: z.string().uuid().nullable(),
publicityRiskLevel: z.enum(["editorial-only", "cleared"]).default("editorial-only"),
});
export type CastPerson = z.infer<typeof CastPersonSchema>;
export const NewsItemSchema = z.object({
id: z.string().uuid(),
headline: z.string().min(1),
summaryOriginal: z.string().min(1), // one-sentence ORIGINAL summary
canonicalUrl: z.string().url(),
publisherName: z.string().nullable(),
publishedAt: z.string().nullable(),
topicTags: z.array(z.string()).default([]),
});
export type NewsItem = z.infer<typeof NewsItemSchema>;