← 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>;