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');