All source files · Workbench

src/lib/sanity.ts

import seedNdjson from '../../data/seed.ndjson?raw';

export const SANITY_API_VERSION = '2026-10-02';

type BaseDocument = { id: string; _id: string; _type: string; isSimulated?: boolean };
type Reference = { _ref: string; _type: 'reference' };

export interface EvidenceSource extends BaseDocument {
  title: string;
  url?: string;
  publisher: string;
  kind: 'officialRules' | 'officialChallenge' | 'officialDocumentation' | 'simulation';
  checkedAt: string;
  summary: string;
}

export interface Competition extends BaseDocument {
  slug: string;
  title: string;
  organizer: string;
  summary: string;
  url: string;
  startUtc: string;
  deadlineUtc: string;
  deadlineLabel: string;
  prizeAmountUsd: number;
  prizeDescription: string;
  winnerAnnouncement: string;
  eligibility: string;
  aiPolicy: string;
  noSpendPolicy: string;
  payoutTiming: string;
  paymentMechanism: string;
  ipSummary: string;
  verifiedAt: string;
  verificationStatus: 'verified' | 'needsReview' | 'unverified';
  sourceIds: string[];
  buildHours?: number;
  humanMinutes?: number;
  dependencies?: string[];
  availableSince?: string;
  effortEstimateNote?: string;
}

export interface SourceClaim extends BaseDocument {
  competitionId: string;
  sourceIds: string[];
  fieldKey: string;
  reportedValue: string;
  normalizedValue?: string;
  state: 'consistent' | 'conflicting' | 'superseded' | 'unverified';
  severity: 'info' | 'warning' | 'critical';
  observedAt: string;
  note: string;
}

export interface EvidenceRevision extends BaseDocument {
  competitionId: string;
  sourceIds: string[];
  fieldKey: string;
  previousValue: string;
  proposedValue: string;
  observedAt: string;
  status: 'pendingReview' | 'approved' | 'rejected';
  impact: string;
  description: string;
}

export interface ReviewDecision extends BaseDocument {
  competitionId: string;
  revisionId: string;
  decision: 'pending' | 'approved' | 'rejected';
  decisionNote: string;
  reviewerLabel: string;
  decidedAt?: string;
  localOnly?: boolean;
}

export interface ProjectNote extends BaseDocument {
  title: string;
  body: string;
  category: 'planning' | 'verification' | 'simulation' | 'setup';
  competitionId?: string;
  updatedAt: string;
}

export interface ProofboardData {
  mode: 'live' | 'sample';
  notice: string;
  competitions: Competition[];
  sources: EvidenceSource[];
  claims: SourceClaim[];
  evidenceRevisions: EvidenceRevision[];
  approvals: ReviewDecision[];
  projectNotes: ProjectNote[];
}

type RawDocument = Record<string, unknown> & { _id: string; _type: string };

const refId = (value: unknown): string =>
  value && typeof value === 'object' && '_ref' in value
    ? String((value as Reference)._ref)
    : '';
const refIds = (value: unknown): string[] =>
  Array.isArray(value) ? value.map(refId).filter(Boolean) : [];

function normalizeDocument(document: RawDocument): RawDocument {
  const row: RawDocument = { ...document, id: document._id };
  if (document.competition) row.competitionId = refId(document.competition);
  if (document.revision) row.revisionId = refId(document.revision);
  if (document.sources) row.sourceIds = refIds(document.sources);
  if (document.slug && typeof document.slug === 'object') {
    row.slug = String((document.slug as { current?: string }).current || '');
  }
  delete row.competition;
  delete row.revision;
  delete row.sources;
  return row;
}

export function parseSeedDocuments(raw: string = seedNdjson): RawDocument[] {
  return raw.split(/\r?\n/).filter((line) => line.trim()).map((line, index) => {
    const document = JSON.parse(line) as RawDocument;
    if (!document._id || !document._type) {
      throw new Error(`Seed line ${index + 1} lacks an id or type.`);
    }
    return document;
  });
}

function groupDocuments(documents: RawDocument[], mode: 'live' | 'sample', notice: string): ProofboardData {
  const rows = documents.map(normalizeDocument);
  const select = <T>(type: string): T[] => rows.filter((row) => row._type === type) as unknown as T[];
  return {
    mode, notice,
    competitions: select<Competition>('competition').sort((a, b) => a.deadlineUtc.localeCompare(b.deadlineUtc)),
    sources: select<EvidenceSource>('evidenceSource'),
    claims: select<SourceClaim>('sourceClaim'),
    evidenceRevisions: select<EvidenceRevision>('evidenceRevision'),
    approvals: select<ReviewDecision>('reviewDecision').sort((a, b) => (b.decidedAt || '').localeCompare(a.decidedAt || '')),
    projectNotes: select<ProjectNote>('projectNote'),
  };
}

export function getSampleData(notice = 'Local sample: official opportunity facts checked October 2, 2026. Sanity is not connected. Simulated changes and effort estimates are labeled separately.'): ProofboardData {
  return groupDocuments(parseSeedDocuments(), 'sample', notice);
}

export interface SanityReadConfig { projectId: string; dataset: string; timeoutMs?: number }

export function getSanityReadConfig(): SanityReadConfig | null {
  const environment = (import.meta as ImportMeta & { env?: Record<string, string | undefined> }).env || {};
  const projectId = environment.PUBLIC_SANITY_PROJECT_ID || '';
  const dataset = environment.PUBLIC_SANITY_DATASET || 'production';
  if (!projectId || projectId === 'your-project-id') return null;
  if (!/^[a-z0-9]{1,32}$/.test(projectId) || !/^[a-z0-9_-]{1,64}$/.test(dataset)) {
    throw new Error('Invalid public Sanity project ID or dataset.');
  }
  return { projectId, dataset };
}

export async function queryPublicSanity<T>(query: string, config: SanityReadConfig): Promise<T> {
  if (!/^[a-z0-9]{1,32}$/.test(config.projectId) || !/^[a-z0-9_-]{1,64}$/.test(config.dataset)) {
    throw new Error('Invalid public Sanity project ID or dataset.');
  }
  const url = new URL(`https://${config.projectId}.api.sanity.io/v${SANITY_API_VERSION}/data/query/${config.dataset}`);
  url.searchParams.set('query', query);
  url.searchParams.set('perspective', 'published');
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), config.timeoutMs || 8000);
  try {
    // Public dataset reads intentionally use no Authorization header and no write token.
    const response = await fetch(url, { signal: controller.signal, headers: { Accept: 'application/json' } });
    if (!response.ok) throw new Error(`Sanity public read failed (${response.status}).`);
    const payload = await response.json() as { result?: T; error?: unknown };
    if (payload.error || !('result' in payload)) throw new Error('Sanity returned an invalid query response.');
    return payload.result as T;
  } finally {
    clearTimeout(timeout);
  }
}

export async function getProofboardData(config?: SanityReadConfig | null): Promise<ProofboardData> {
  let connection: SanityReadConfig | null;
  try { connection = config === undefined ? getSanityReadConfig() : config; }
  catch { return getSampleData('Local sample: Sanity configuration is invalid. No live query was made.'); }
  if (!connection) return getSampleData();
  try {
    const documents = await queryPublicSanity<RawDocument[]>(
      '*[_type in ["competition", "evidenceSource", "sourceClaim", "evidenceRevision", "reviewDecision", "projectNote"]] | order(_id asc)',
      connection,
    );
    if (!Array.isArray(documents) || !documents.some((document) => document._type === 'competition')) {
      throw new Error('Sanity dataset has no competition documents.');
    }
    return groupDocuments(documents, 'live', `Live public Sanity dataset: ${connection.projectId}/${connection.dataset}. Source check dates, simulations, and estimates remain visible.`);
  } catch {
    // Do not render remote error payloads; they can contain operational details.
    return getSampleData('Local sample: the public Sanity read was unavailable. Displayed changes are not live updates.');
  }
}

export const fetchProofboardData = getProofboardData;
export const getDeadlineLoomData = getProofboardData;

/** Prepare a local review record. This does not persist or mutate any Sanity data. */
export function createReviewDecision(input: {
  competitionId: string; revisionId: string; decision: 'approved' | 'rejected';
  decisionNote: string; reviewerLabel?: string; isSimulated?: boolean;
}): ReviewDecision {
  if (!input.competitionId || !input.revisionId || !input.decisionNote.trim() || !['approved', 'rejected'].includes(input.decision)) {
    throw new Error('A competition, revision, and review reason are required.');
  }
  const now = new Date().toISOString();
  const id = `local-review-${globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`}`;
  return {
    id, _id: id, _type: 'reviewDecision',
    ...input, decisionNote: input.decisionNote.trim(), reviewerLabel: input.reviewerLabel || 'Local reviewer',
    decidedAt: now, localOnly: true,
  };
}