import { router, useForm } from '@inertiajs/react'; import { useEffect, useRef, useState } from 'react'; import { createOperationKey } from './operation-key'; import type { Partnership } from '@/pages/partnerships/shared'; export type PublicationProps = { record: Partnership; owner: boolean; canEdit: boolean; moderator: boolean; publicationActions: string[]; publicationCommands: { key: string; action: string; version: number }[]; origins: unknown[]; locations: unknown[]; }; type Snapshot = { id: number; version: number; status: string; context: string; values: Record; actions: string[]; canEdit: boolean; moderator: boolean; commands: PublicationProps['publicationCommands']; }; const states: Record = { submit: 'submitted', publish: 'published', return: 'returned', reject: 'rejected', suspend: 'suspended', archive: 'archived', renew: 'draft', }; const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); const positive = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0; function scalar(value: unknown): string | null { return value == null ? '' : typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? String(value) : null; } function canonical(value: unknown): unknown { return Array.isArray(value) ? value.map(canonical) : object(value) ? Object.fromEntries( Object.keys(value) .sort() .map((key) => [key, canonical(value[key])]), ) : value; } function read(props: unknown, id: number): Snapshot | null { if ( !object(props) || !object(props.record) || props.record.id !== id || !positive(props.record.version) || typeof props.record.status !== 'string' || typeof props.canEdit !== 'boolean' || typeof props.moderator !== 'boolean' || !Array.isArray(props.publicationActions) || !props.publicationActions.every( (value) => typeof value === 'string' && Object.hasOwn(states, value), ) || !Array.isArray(props.publicationCommands) || !props.publicationCommands.every( (value) => object(value) && typeof value.key === 'string' && typeof value.action === 'string' && Object.hasOwn(states, value.action) && positive(value.version), ) || !Array.isArray(props.origins) || !Array.isArray(props.locations) ) return null; const payload = object(props.record.payload) ? props.record.payload : Array.isArray(props.record.payload) && props.record.payload.length === 0 ? {} : null; if (!payload) return null; const { version, status, ...content } = props.record; const values: Record = {}; for (const key of [ 'title', 'offer', 'seeking', 'kind', 'partnership_type', 'sector_id', 'organization_id', 'deadline', ]) { const value = scalar(props.record[key]); if (value === null) return null; values[key] = value; } for (const key of [ 'summary', 'requirements', 'confidentiality', 'availability', 'consent', 'mandatory', ]) { const item = payload[key]; const value = key === 'mandatory' && Array.isArray(item) && item.every((entry) => typeof entry === 'string') ? item.join(', ') : scalar(item); if (value === null) return null; values[key] = value; } values.origins = props.origins .map((value) => object(value) ? (scalar(value.title ?? value.type) ?? 'Origem indisponível para comparação') : '', ) .join('\n'); values.locations = props.locations .map((value) => object(value) ? `${scalar(value.name ?? value.id) ?? 'Localização indisponível'} · ${scalar(value.role) ?? 'Papel indisponível'} · ${scalar(value.source_reference) ?? 'Fonte indisponível'}` : '', ) .join('\n'); return { id, version, status, context: JSON.stringify( canonical([content, props.origins, props.locations]), ), values, actions: props.publicationActions, commands: props.publicationCommands as PublicationProps['publicationCommands'], canEdit: props.canEdit, moderator: props.moderator, }; } type Pending = { command: { client_key: string; version: number; action: string; reason: string; }; generation: number; base: Snapshot; ambiguous: boolean; }; export function usePublication(p: PublicationProps, actorId: number) { const alive = useRef(true); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); const id = p.record.id, current = read(p, id), identity = `${id}:${actorId}`; const opened = useRef(identity), observed = useRef(identity); observed.current = identity; const base = useRef(current), pending = useRef(null), generation = useRef(0); const form = useForm({ reason: '' }); const live = useRef(form.data.reason); live.current = form.data.reason; const [problem, setProblem] = useState(null), [needsCompare, setNeedsCompare] = useState(false), [refreshing, setRefreshing] = useState(false), [mustRefresh, setMustRefresh] = useState(false), [, render] = useState(0); const invalid = !current || !base.current || opened.current !== observed.current; const stale = !!current && !!base.current && (current.version !== base.current.version || current.status !== base.current.status || current.context !== base.current.context); const dirty = !!live.current || !!pending.current; const can = (action: string) => !!current && (['publish', 'return', 'reject', 'suspend'].includes(action) ? current.moderator && !current.canEdit : current.canEdit); useEffect(() => { if (current && stale && !dirty && !needsCompare && !form.processing) { base.current = current; render((value) => value + 1); } }, [current, stale, dirty, needsCompare, form.processing]); function change(value: string) { generation.current++; live.current = value; form.setData('reason', value); } function acknowledge(props: unknown, sent: Pending): boolean { if ( !alive.current || pending.current !== sent || observed.current !== opened.current ) return false; const fresh = read(props, id), command = fresh?.commands.find( (value) => value.key === sent.command.client_key && value.action === sent.command.action && value.version === sent.command.version + 1, ); if ( !fresh || !command || fresh.version < command.version || (fresh.version === command.version && (fresh.status !== states[sent.command.action] || fresh.context !== sent.base.context)) ) return false; const exact = fresh.version === command.version; base.current = exact ? fresh : { ...sent.base, version: command.version, status: states[sent.command.action], }; pending.current = null; if (generation.current === sent.generation) { live.current = ''; form.setData('reason', ''); } form.setDefaults({ reason: '' }); form.clearErrors(); setNeedsCompare(!exact); setProblem( exact ? null : 'A acção foi reconhecida. Há alterações posteriores; compare antes de preparar outra decisão.', ); setMustRefresh(false); render((value) => value + 1); return true; } function refresh() { if (refreshing || form.processing) return; setRefreshing(true); router.reload({ only: [ 'record', 'owner', 'canEdit', 'moderator', 'publicationActions', 'publicationCommands', 'historySummary', 'origins', 'locations', ], onSuccess: (page) => { if (!alive.current || observed.current !== opened.current) return; if (pending.current && acknowledge(page.props, pending.current)) return; const fresh = read(page.props, id); if (!fresh) { setProblem( 'Resposta incompatível com esta proposta. O motivo preparado foi conservado.', ); return; } setMustRefresh(false); if ( base.current && fresh.version === base.current.version && fresh.status === base.current.status && fresh.context === base.current.context ) { setNeedsCompare(false); setProblem(null); } }, onNetworkError: () => { setProblem( 'Não foi possível confirmar a actualização. O motivo foi conservado.', ); return false; }, onHttpException: () => { setNeedsCompare(true); setProblem( 'Não foi possível consultar a proposta com o acesso actual. O motivo foi conservado.', ); return false; }, onFinish: () => { if (alive.current) setRefreshing(false); }, }); } function adopt() { if (invalid || !current || mustRefresh || refreshing || form.processing) return; base.current = current; pending.current = null; setNeedsCompare(false); setProblem(null); form.clearErrors(); render((value) => value + 1); } function send(action: string, recover = false) { if ( invalid || !current || !base.current || refreshing || form.processing || !can(action) ) return; let sent = pending.current; if (recover) { if (!sent?.ambiguous || sent.command.action !== action) return; } else { if ( sent || stale || needsCompare || !current.actions.includes(action) || !live.current.trim() ) return; sent = { command: { client_key: createOperationKey(), version: base.current.version, action, reason: live.current, }, generation: generation.current, base: base.current, ambiguous: false, }; pending.current = sent; } if (!sent) return; const request = sent; form.transform(() => ({ ...request.command })); form.post(`/parcerias/${id}/estado`, { preserveScroll: true, onSuccess: (page) => { if (!alive.current) return; if ( !acknowledge(page.props, request) && pending.current === request ) { request.ambiguous = true; setMustRefresh(true); setNeedsCompare(true); setProblem( 'A resposta não confirmou esta tentativa. Conserve o motivo e recupere a mesma acção ou compare os dados actuais.', ); } }, onError: () => { if ( !alive.current || pending.current !== request || observed.current !== opened.current ) return; pending.current = null; setProblem( 'A acção não foi gravada. Corrija os campos indicados; o motivo foi conservado.', ); }, onNetworkError: () => { if ( alive.current && pending.current === request && observed.current === opened.current ) { request.ambiguous = true; setMustRefresh(true); setProblem( 'A resposta perdeu-se. Pode recuperar exactamente a mesma tentativa, sem repetir uma decisão já gravada.', ); } return false; }, onHttpException: (response) => { if ( !alive.current || pending.current !== request || observed.current !== opened.current ) return false; if (response.status >= 500) request.ambiguous = true; else pending.current = null; setMustRefresh(true); setNeedsCompare(true); setProblem( 'A acção não foi confirmada com os dados e acesso actuais. Compare antes de continuar; o motivo foi conservado.', ); return false; }, }); } return { form, current, base: base.current, stale, invalid, dirty, problem, refreshing, needsCompare, mustRefresh, changes: current && base.current ? Object.keys(current.values).filter( (key) => current.values[key] !== base.current?.values[key], ) : [], blocked: invalid || stale || needsCompare || !!pending.current || refreshing || form.processing, recovery: pending.current?.ambiguous ? pending.current.command.action : null, can, change, refresh, adopt, send, }; }