All source files · Workbench
sanity/schemaTypes/reviewWorkflow.ts
import { createElement, useState, type ChangeEvent, type FormEvent } from 'react';
import { useClient, type DocumentActionComponent, type SanityDocument } from 'sanity';
const API_VERSION = '2026-10-02';
const reviewedFields = new Set(['deadlineUtc', 'startUtc', 'eligibility', 'aiPolicy', 'prizeDescription', 'noSpendPolicy', 'payoutTiming']);
type RevisionDocument = SanityDocument & {
competition: { _ref: string }; sources: Array<{ _ref: string }>;
fieldKey: string; previousValue: string; proposedValue: string;
status: string; isSimulated?: boolean;
};
function buildReviewAction(decision: 'approved' | 'rejected'): DocumentActionComponent {
return function ReviewEvidenceAction(props) {
const client = useClient({ apiVersion: API_VERSION });
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [reason, setReason] = useState('');
const [error, setError] = useState('');
const revision = props.published as RevisionDocument | null;
const documentId = props.id.replace(/^drafts\./, '');
const close = () => { if (!busy) { setOpen(false); props.onComplete(); } };
const commitDecision = async (event: FormEvent) => {
event.preventDefault();
if (!reason.trim() || busy) return;
setBusy(true); setError('');
try {
// Use the signed-in Studio client. The public Astro application has no mutation path.
const latest = await client.getDocument<RevisionDocument>(documentId);
if (!latest || latest.status !== 'pendingReview' || !reviewedFields.has(latest.fieldKey)) {
throw new Error('This revision is no longer pending, or its field is unsupported. Refresh before reviewing.');
}
const competition = await client.getDocument<SanityDocument>(latest.competition._ref);
if (!competition) throw new Error('The referenced competition is missing.');
const sourceIds = (latest.sources || []).map((source) => source._ref);
if (!latest.isSimulated) {
const sources = await client.fetch<Array<{ _id: string; url?: string; isSimulated?: boolean }>>(
'*[_type == "evidenceSource" && _id in $ids]{_id,url,isSimulated}', { ids: sourceIds },
);
if (!sources.some((source) => !source.isSimulated && /^https:\/\//.test(source.url || ''))) {
throw new Error('A real revision needs a linked official source URL.');
}
}
if (decision === 'approved' && !latest.isSimulated) {
if (String(competition[latest.fieldKey]) !== latest.previousValue) {
throw new Error('The accepted value changed since this revision was prepared. Create a fresh revision.');
}
if (latest.fieldKey.endsWith('Utc') && Number.isNaN(Date.parse(latest.proposedValue))) {
throw new Error('The proposed UTC value is invalid.');
}
const start = latest.fieldKey === 'startUtc' ? latest.proposedValue : competition.startUtc;
const deadline = latest.fieldKey === 'deadlineUtc' ? latest.proposedValue : competition.deadlineUtc;
if (Date.parse(String(deadline)) <= Date.parse(String(start))) {
throw new Error('An accepted deadline must remain after the start.');
}
}
const now = new Date().toISOString();
let transaction = client.transaction()
.create({
_id: `review-${documentId}-${Date.now()}`, _type: 'reviewDecision',
competition: { _type: 'reference', _ref: competition._id },
revision: { _type: 'reference', _ref: latest._id }, decision,
decisionNote: `${latest.isSimulated ? 'SIMULATED review. ' : ''}${reason.trim()}`,
reviewerLabel: 'Studio reviewer', decidedAt: now, isSimulated: Boolean(latest.isSimulated),
})
.patch(latest._id, (patch) => patch.ifRevisionId(latest._rev).set({ status: decision }));
if (decision === 'approved' && !latest.isSimulated) {
transaction = transaction.patch(competition._id, (patch) => patch.ifRevisionId(competition._rev).set({
[latest.fieldKey]: latest.proposedValue,
...(latest.fieldKey === 'deadlineUtc' ? { deadlineLabel: latest.proposedValue } : {}),
...(latest.fieldKey === 'startUtc' ? { availableSince: latest.proposedValue } : {}),
verifiedAt: now,
verificationStatus: 'verified',
}));
}
await transaction.commit();
setOpen(false); props.onComplete();
} catch (caught) {
// Sanity SDK errors can contain request diagnostics: keep the UI message limited.
setError(caught instanceof Error && !caught.message.includes('https://')
? caught.message
: 'Review could not be saved. Check access and refresh before trying again.');
} finally { setBusy(false); }
};
return {
label: decision === 'approved' ? 'Approve evidence revision' : 'Reject evidence revision',
tone: decision === 'approved' ? 'positive' : 'critical',
disabled: busy || !revision || Boolean(props.draft) || revision.status !== 'pendingReview',
title: 'Publish the proposed revision first. A reason is required for a recorded decision.',
onHandle: () => { setError(''); setOpen(true); },
dialog: open ? {
type: 'dialog', header: `${decision === 'approved' ? 'Approve' : 'Reject'} evidence revision`, onClose: close,
content: createElement('form', { onSubmit: commitDecision, style: { padding: '1rem', display: 'grid', gap: '1rem' } },
createElement('p', null, revision?.isSimulated
? 'SIMULATED scenario. This decision will be recorded in Sanity, but the accepted competition facts will stay unchanged.'
: 'Review the linked source before confirming. Approval records the reason and applies the proposed value in one transaction.'),
createElement('p', null, `${revision?.fieldKey}: ${revision?.previousValue} → ${revision?.proposedValue}`),
createElement('label', { htmlFor: 'review-reason' }, 'Reason and evidence checked (public; no personal details)'),
createElement('textarea', {
id: 'review-reason', required: true, rows: 4, value: reason, disabled: busy,
onChange: (event: ChangeEvent<HTMLTextAreaElement>) => setReason(event.target.value),
style: { width: '100%' },
}),
error ? createElement('p', { role: 'alert' }, error) : null,
createElement('button', { type: 'submit', disabled: busy || !reason.trim() }, busy ? 'Saving review…' : 'Record decision'),
),
} : undefined,
};
};
}
export const ApproveEvidenceRevisionAction = buildReviewAction('approved');
export const RejectEvidenceRevisionAction = buildReviewAction('rejected');