Files
Investidor-V2/resources/js/lib/technical-incident-handling.ts
T

369 lines
13 KiB
TypeScript

import { router } from '@inertiajs/react';
import { useEffect, useRef, useState } from 'react';
import { portalRequest, PortalApiError } from './portal-api';
import { createOperationKey } from './operation-key';
export type HandlingState = {
version: number;
status: 'open' | 'in_progress' | 'resolved';
assignee_user_id: number | null;
assignee_name: string | null;
updated_at: string | null;
resolved_at: string | null;
};
export type Incident = {
reference: string;
operation: string;
observed_version: number;
occurred_at: string;
error_code: string;
handling: HandlingState;
};
export type Command = 'claim' | 'release' | 'note' | 'resolve' | 'reopen';
export type HandlingProps = {
incident: Incident;
actorId: number;
commands: { key: string; version: number }[];
};
type Attempt = {
values: {
version: number;
action: Command;
note: string;
operation_key: string;
};
generation: number;
};
const object = (v: unknown): v is Record<string, unknown> =>
!!v && typeof v === 'object' && !Array.isArray(v);
const integer = (v: unknown, min = 0): v is number =>
typeof v === 'number' && Number.isSafeInteger(v) && v >= min;
const validState = (v: unknown): v is HandlingState =>
object(v) &&
integer(v.version) &&
['open', 'in_progress', 'resolved'].includes(String(v.status)) &&
(v.assignee_user_id === null || integer(v.assignee_user_id, 1)) &&
(v.assignee_name === null || typeof v.assignee_name === 'string') &&
(v.updated_at === null || typeof v.updated_at === 'string') &&
(v.resolved_at === null || typeof v.resolved_at === 'string');
export function useTechnicalIncidentHandling(props: HandlingProps) {
const { incident, actorId, commands } = props;
const [note, setNote] = useState(''),
[base, setBase] = useState(() => ({ ...incident.handling })),
[busy, setBusy] = useState(false),
[uncertain, setUncertain] = useState(false),
[refreshing, setRefreshing] = useState(false),
[blocked, setBlocked] = useState(false),
[needsRefresh, setNeedsRefresh] = useState(false),
[notice, setNotice] = useState(''),
[error, setError] = useState('');
const pending = useRef<Attempt | null>(null),
activeRequest = useRef<Attempt | null>(null),
liveNote = useRef(note),
generation = useRef(0),
confirmed = useRef<number | null>(null),
alive = useRef(true),
origin = useRef({ actorId, reference: incident.reference }),
scope = useRef({ actorId, reference: incident.reference });
if (
scope.current.actorId !== actorId ||
scope.current.reference !== incident.reference
)
scope.current = { actorId, reference: incident.reference };
liveNote.current = note;
const valid =
integer(actorId, 1) &&
actorId === origin.current.actorId &&
incident.reference === origin.current.reference &&
validState(incident.handling) &&
Array.isArray(commands) &&
commands.every(
(item) => typeof item.key === 'string' && integer(item.version, 1),
);
const stale = base.version !== incident.handling.version;
function change(value: string) {
generation.current++;
liveNote.current = value;
setNote(value);
setError('');
}
function acknowledge(attempt: Attempt, version: number) {
if (pending.current !== attempt) return;
pending.current = null;
activeRequest.current = null;
setBusy(false);
setUncertain(false);
setNeedsRefresh(true);
confirmed.current = version;
if (generation.current === attempt.generation) {
liveNote.current = '';
setNote('');
}
setNotice(
'Tratamento registado. As alterações posteriores da nota foram conservadas.',
);
setError('');
}
function read(value: unknown) {
if (
!object(value) ||
value.actorId !== actorId ||
!object(value.incident) ||
value.incident.reference !== incident.reference ||
!validState(value.incident.handling) ||
!Array.isArray(value.commands)
)
return false;
const attempt = pending.current;
if (
attempt &&
value.commands.some(
(c) =>
object(c) &&
c.key === attempt.values.operation_key &&
c.version === attempt.values.version + 1,
) &&
value.incident.handling.version >= attempt.values.version + 1
)
acknowledge(attempt, attempt.values.version + 1);
if (confirmed.current !== null) {
if (value.incident.handling.version === confirmed.current)
setBase({ ...value.incident.handling });
if (value.incident.handling.version >= confirmed.current) {
confirmed.current = null;
setNeedsRefresh(false);
}
}
setBlocked(false);
return true;
}
useEffect(() => {
alive.current = true;
return () => {
alive.current = false;
};
}, []);
useEffect(() => {
if (valid) read(props);
}, [incident, commands, actorId]);
function refresh() {
if (refreshing) return;
const epoch = scope.current;
setRefreshing(true);
router.reload({
only: ['incident', 'history', 'commands', 'actorId'],
onSuccess: (page) => {
if (
alive.current &&
scope.current === epoch &&
!read(page.props)
)
setError(
'Não foi possível confirmar esta consulta. A nota preparada foi conservada.',
);
},
onError: () => {
if (alive.current && scope.current === epoch)
setError(
'Não foi possível actualizar o incidente. A nota foi conservada.',
);
},
onFinish: () => {
if (alive.current && scope.current === epoch)
setRefreshing(false);
},
});
}
async function send(action: Command, retry = false) {
if (
!valid ||
blocked ||
refreshing ||
activeRequest.current ||
(!retry && (stale || needsRefresh || pending.current))
)
return;
let attempt = pending.current;
if (!attempt) {
if (
liveNote.current.trim().length < 10 ||
liveNote.current.length > 3000
) {
setError('Preencha uma nota com 10 a 3000 caracteres.');
return;
}
try {
attempt = {
values: {
version: base.version,
action,
note: liveNote.current,
operation_key: createOperationKey().toLowerCase(),
},
generation: generation.current,
};
} catch (e) {
setError(
e instanceof Error
? e.message
: 'Não foi possível preparar o registo.',
);
return;
}
pending.current = attempt;
}
const epoch = scope.current;
activeRequest.current = attempt;
setBusy(true);
setUncertain(true);
setError('');
try {
const result = await portalRequest<unknown>(
`/suporte-tecnico/incidentes/${incident.reference}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attempt.values),
},
);
if (
!alive.current ||
scope.current !== epoch ||
pending.current !== attempt
)
return;
if (
!object(result) ||
result.reference !== incident.reference ||
result.actor_id !== actorId ||
result.operation_key !== attempt.values.operation_key ||
result.result_version !== attempt.values.version + 1 ||
!validState(result.handling) ||
result.handling.version < result.result_version
)
throw new Error(
'A confirmação recebida não corresponde a esta tentativa. Actualize o incidente.',
);
acknowledge(attempt, result.result_version);
refresh();
} catch (e) {
if (
!alive.current ||
scope.current !== epoch ||
pending.current !== attempt
)
return;
if (e instanceof PortalApiError && e.status === 422) {
pending.current = null;
setUncertain(false);
setError(
e.errors.note?.join(' ') ||
'Este tratamento não pode ser aplicado ao estado actual. Actualize os dados e reveja a nota.',
);
} else if (
e instanceof PortalApiError &&
(e.status === 403 || e.status === 401 || e.status === 419)
) {
setBlocked(true);
setError(
'O acesso ou a sessão precisam de confirmação. A nota e a tentativa original foram conservadas.',
);
} else
setError(
e instanceof PortalApiError && e.status === 409
? 'O incidente mudou. Actualize e compare o estado antes de preparar outra tentativa.'
: e instanceof Error
? e.message
: 'Resultado sem confirmação. Conserve a tentativa original.',
);
} finally {
if (
alive.current &&
scope.current === epoch &&
activeRequest.current === attempt
) {
activeRequest.current = null;
setBusy(false);
}
}
}
function adopt() {
if (!valid || !stale || busy || refreshing || needsRefresh || blocked)
return;
const attempt = pending.current;
if (attempt) {
if (
incident.handling.version <= attempt.values.version ||
commands.some((c) => c.key === attempt.values.operation_key)
)
return;
pending.current = null;
setUncertain(false);
}
setBase({ ...incident.handling });
setError('');
setNotice(
'Estado actual confirmado. Reveja a nota conservada antes de a enviar.',
);
}
const guard = useRef(false);
guard.current = !!note || uncertain;
useEffect(() => {
const remove = router.on('before', (event) => {
if (
guard.current &&
event.detail.visit.method === 'get' &&
!event.detail.visit.preserveState &&
!window.confirm(
'Existe uma nota preparada ou um envio sem confirmação. Sair desta página?',
)
)
event.preventDefault();
});
const unload = (event: BeforeUnloadEvent) => {
if (guard.current) {
event.preventDefault();
event.returnValue = '';
}
};
window.addEventListener('beforeunload', unload);
return () => {
remove();
window.removeEventListener('beforeunload', unload);
};
}, []);
return {
note,
change,
base,
stale,
busy,
uncertain,
refreshing,
notice,
error,
refresh,
send,
adopt,
blocked:
!valid ||
blocked ||
busy ||
refreshing ||
needsRefresh ||
stale ||
uncertain,
canRetry:
valid && !blocked && !busy && !refreshing && !!pending.current,
canAdopt:
valid &&
!blocked &&
!busy &&
!refreshing &&
!needsRefresh &&
stale &&
(!pending.current ||
incident.handling.version > pending.current.values.version),
pendingAction: pending.current?.values.action,
};
}