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,
};
}