Conservar progresso editorial entre renovações MFA e tornar a retoma visível
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-10 21:55:42 +02:00
parent d830fbca50
commit 4f3dbcf864
7 changed files with 308 additions and 79 deletions
+1 -1
View File
@@ -97,7 +97,7 @@ final class FundingProgrammeReferences
'id' => $row?->id, 'published' => $row?->published_version_id !== null];
}
return ['dataset_hash' => $package['sha256'], 'items' => $items];
return ['dataset_hash' => $package['sha256'], 'editor_id' => $actor->id, 'items' => $items];
}
/** @return array{id:string,created:bool} */
@@ -1,3 +1,15 @@
## 10/11/2026 — retoma após MFA, sem repetir fichas conferidas
Verificação activa posterior:90rascunhos/zero publicações; continuação GETMomentum403 às21:49:39 ePOSTpublicar403 às21:49:54 (+02). A sessão voltou a expirar. A publicação comunicada não chegou a ser executada pelo servidor. Nenhum rascunho foi removido.
Correcção delimitada emQA: a mesma aba conserva selecção, recibos/versionamento/hash e fundamento, separados por conta e conjunto de fontes; após reautenticação salta fichas já conferidas. As confirmações de revisão/publicação são desmarcadas na nova entrada, sem publicação automática. Uma publicação de resposta incerta conserva o mesmo corpo para recuperação idempotente. Novo comando de renovação utiliza logout/login/MFA nativos; limite900s e guardas mantidos. «Seleccionar todas as instituições(270)», contagem de progresso e controlos antes da lista recolhida tornam o estado visível. Sem migração, dados novos ou alterações de permissões. Alteração ainda não instalada nesta anotação; revisão activaef24cbdc.
Prova dirigida do componente efectivo: remonta a página como após autenticação, recupera selecção/recibos, não repete preparação, não publica automaticamente, recupera confirmação incerta e isola outra conta/fontes. Prova API editorial anterior1caso/27asserções reexecutada apenas pela propriedadeeditor_id acrescentada; provas270fichas e do dossier reutilizadas. Tipos/análise/compilação da entrega são registados pelo executor existente.
## 10/11/2026 — confirmação da preparação parcial e expiração do MFA
Após execução no editor, a homologação contém **32rascunhos e zero fichas publicadas**. A preparação gravou efectivamente e avançou por diferentes instituições. Os registos HTTP mostram preparação200 atéMyBucks às21:31:42, seguida deGETxtenda403 às21:31:43 ePOSTpublicar403 às21:31:59 (Africa/Johannesburg). A entrada no editor fora confirmada às21:16:43; corresponde ao limite existente de900segundos do MFA em `PortalProfileAccess::mfa`. Não é perda da instalação nem repetição de dados. Reautenticar no portal com MFA e retomar a preparação idempotente conserva os32rascunhos, sem substituir edições; publicação continua a exigir a confirmação separada. Nenhuma guarda, sessão ou permissão foi alterada. Revisão instalada `ef24cbdc`, ACT12 ainda sem conteúdo público confirmado. Prova de leitura: `outputs/funding-directory-20261110/institutions/publication-observation.json`.
## 10/11/2026 — instalação confirmada do directório financeiro
Revisão `ef24cbdc9218e2027f7d1a39968cc60869a35410` instalada na homologação às19:15UTC, pelo executor Git existente.33ficheiros conferidos por hash com o commit; configuração preservada, zero migrações/importações automáticas. Backup/restauro confirmado de190tabelas e369ficheiros. Entradas `/fundos?kind=institution`, `/concursos`, `/login`, `/up` e revisão HTTP200. Pacote anterior preservado; nova entrega `outputs/entrega-instituicoes-editorial-20261110.zip` SHA256 `168d7ffb6d054533674f3f07799959b46bc12641c045accfc1766da5cead9077`.
@@ -1,3 +1,15 @@
## 10/11/2026 — retoma após MFA, sem repetir fichas conferidas
Verificação activa posterior:90rascunhos/zero publicações; continuação GETMomentum403 às21:49:39 ePOSTpublicar403 às21:49:54 (+02). A sessão voltou a expirar. A publicação comunicada não chegou a ser executada pelo servidor. Nenhum rascunho foi removido.
Correcção delimitada emQA: a mesma aba conserva selecção, recibos/versionamento/hash e fundamento, separados por conta e conjunto de fontes; após reautenticação salta fichas já conferidas. As confirmações de revisão/publicação são desmarcadas na nova entrada, sem publicação automática. Uma publicação de resposta incerta conserva o mesmo corpo para recuperação idempotente. Novo comando de renovação utiliza logout/login/MFA nativos; limite900s e guardas mantidos. «Seleccionar todas as instituições(270)», contagem de progresso e controlos antes da lista recolhida tornam o estado visível. Sem migração, dados novos ou alterações de permissões. Alteração ainda não instalada nesta anotação; revisão activaef24cbdc.
Prova dirigida do componente efectivo: remonta a página como após autenticação, recupera selecção/recibos, não repete preparação, não publica automaticamente, recupera confirmação incerta e isola outra conta/fontes. Prova API editorial anterior1caso/27asserções reexecutada apenas pela propriedadeeditor_id acrescentada; provas270fichas e do dossier reutilizadas. Tipos/análise/compilação da entrega são registados pelo executor existente.
## 10/11/2026 — confirmação da preparação parcial e expiração do MFA
Após execução no editor, a homologação contém **32rascunhos e zero fichas publicadas**. A preparação gravou efectivamente e avançou por diferentes instituições. Os registos HTTP mostram preparação200 atéMyBucks às21:31:42, seguida deGETxtenda403 às21:31:43 ePOSTpublicar403 às21:31:59 (Africa/Johannesburg). A entrada no editor fora confirmada às21:16:43; corresponde ao limite existente de900segundos do MFA em `PortalProfileAccess::mfa`. Não é perda da instalação nem repetição de dados. Reautenticar no portal com MFA e retomar a preparação idempotente conserva os32rascunhos, sem substituir edições; publicação continua a exigir a confirmação separada. Nenhuma guarda, sessão ou permissão foi alterada. Revisão instalada `ef24cbdc`, ACT12 ainda sem conteúdo público confirmado. Prova de leitura: `outputs/funding-directory-20261110/institutions/publication-observation.json`.
## 10/11/2026 — instalação confirmada do directório financeiro
Revisão `ef24cbdc9218e2027f7d1a39968cc60869a35410` instalada na homologação às19:15UTC, pelo executor Git existente.33ficheiros conferidos por hash com o commit; configuração preservada, zero migrações/importações automáticas. Backup/restauro confirmado de190tabelas e369ficheiros. Entradas `/fundos?kind=institution`, `/concursos`, `/login`, `/up` e revisão HTTP200. Pacote anterior preservado; nova entrega `outputs/entrega-instituicoes-editorial-20261110.zip` SHA256 `168d7ffb6d054533674f3f07799959b46bc12641c045accfc1766da5cead9077`.
File diff suppressed because one or more lines are too long
@@ -1,3 +1,15 @@
## 10/11/2026 — retoma após MFA, sem repetir fichas conferidas
Verificação activa posterior:90rascunhos/zero publicações; continuação GETMomentum403 às21:49:39 ePOSTpublicar403 às21:49:54 (+02). A sessão voltou a expirar. A publicação comunicada não chegou a ser executada pelo servidor. Nenhum rascunho foi removido.
Correcção delimitada emQA: a mesma aba conserva selecção, recibos/versionamento/hash e fundamento, separados por conta e conjunto de fontes; após reautenticação salta fichas já conferidas. As confirmações de revisão/publicação são desmarcadas na nova entrada, sem publicação automática. Uma publicação de resposta incerta conserva o mesmo corpo para recuperação idempotente. Novo comando de renovação utiliza logout/login/MFA nativos; limite900s e guardas mantidos. «Seleccionar todas as instituições(270)», contagem de progresso e controlos antes da lista recolhida tornam o estado visível. Sem migração, dados novos ou alterações de permissões. Alteração ainda não instalada nesta anotação; revisão activaef24cbdc.
Prova dirigida do componente efectivo: remonta a página como após autenticação, recupera selecção/recibos, não repete preparação, não publica automaticamente, recupera confirmação incerta e isola outra conta/fontes. Prova API editorial anterior1caso/27asserções reexecutada apenas pela propriedadeeditor_id acrescentada; provas270fichas e do dossier reutilizadas. Tipos/análise/compilação da entrega são registados pelo executor existente.
## 10/11/2026 — confirmação da preparação parcial e expiração do MFA
Após execução no editor, a homologação contém **32rascunhos e zero fichas publicadas**. A preparação gravou efectivamente e avançou por diferentes instituições. Os registos HTTP mostram preparação200 atéMyBucks às21:31:42, seguida deGETxtenda403 às21:31:43 ePOSTpublicar403 às21:31:59 (Africa/Johannesburg). A entrada no editor fora confirmada às21:16:43; corresponde ao limite existente de900segundos do MFA em `PortalProfileAccess::mfa`. Não é perda da instalação nem repetição de dados. Reautenticar no portal com MFA e retomar a preparação idempotente conserva os32rascunhos, sem substituir edições; publicação continua a exigir a confirmação separada. Nenhuma guarda, sessão ou permissão foi alterada. Revisão instalada `ef24cbdc`, ACT12 ainda sem conteúdo público confirmado. Prova de leitura: `outputs/funding-directory-20261110/institutions/publication-observation.json`.
## 10/11/2026 — instalação confirmada do directório financeiro
Revisão `ef24cbdc9218e2027f7d1a39968cc60869a35410` instalada na homologação às19:15UTC, pelo executor Git existente.33ficheiros conferidos por hash com o commit; configuração preservada, zero migrações/importações automáticas. Backup/restauro confirmado de190tabelas e369ficheiros. Entradas `/fundos?kind=institution`, `/concursos`, `/login`, `/up` e revisão HTTP200. Pacote anterior preservado; nova entrega `outputs/entrega-instituicoes-editorial-20261110.zip` SHA256 `168d7ffb6d054533674f3f07799959b46bc12641c045accfc1766da5cead9077`.
+213 -69
View File
@@ -1,5 +1,5 @@
import { Link } from "@inertiajs/react";
import { useRef, useState } from "react";
import { Link, router } from "@inertiajs/react";
import { useEffect, useRef, useState } from "react";
import {
financialCategories,
financialTypes,
@@ -27,21 +27,82 @@ type Receipt = {
published?: boolean;
};
export default function Assisted({
type Progress = {
selected: string[];
receipts: Record<string, Receipt>;
reason: string;
pending: Record<string, object>;
};
const emptyProgress = (): Progress => ({
selected: [],
receipts: {},
reason: "",
pending: {},
});
export function readProgress(key: string): Progress {
try {
const value = JSON.parse(sessionStorage.getItem(key) ?? "null");
if (
!value ||
!Array.isArray(value.selected) ||
!value.selected.every((c: unknown) => typeof c === "string") ||
!value.receipts ||
typeof value.receipts !== "object" ||
typeof value.reason !== "string" ||
!value.pending ||
typeof value.pending !== "object"
)
return emptyProgress();
return value;
} catch {
return emptyProgress();
}
}
function saveProgress(key: string, value: Progress): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* Native receipts remain authoritative if tab storage is unavailable. */
}
}
export function AssistedEditor({
batch,
}: {
batch: { dataset_hash: string; items: Item[] };
batch: { dataset_hash: string; editor_id: number; items: Item[] };
}) {
const [selected, setSelected] = useState<string[]>([]);
const [receipts, setReceipts] = useState<Record<string, Receipt>>({});
const storageKey = `investmoz-funding-progress:${batch.editor_id}:${batch.dataset_hash}`;
const [restored] = useState(() => readProgress(storageKey));
const [selected, setSelected] = useState<string[]>(
restored.selected.filter((c) => batch.items.some((i) => i.code === c)),
);
const [receipts, setReceipts] = useState<Record<string, Receipt>>(
restored.receipts,
);
const [reviewed, setReviewed] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [reason, setReason] = useState("");
const [reason, setReason] = useState(restored.reason);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState("");
const running = useRef(false);
const stop = useRef(false);
const pending = useRef<Record<string, object>>({});
const pending = useRef<Record<string, object>>(restored.pending);
const [needsMfa, setNeedsMfa] = useState(false);
useEffect(() => {
saveProgress(storageKey, {
selected,
receipts,
reason,
pending: pending.current,
});
}, [storageKey, selected, receipts, reason]);
const ready = selected.filter(
(c) =>
receipts[c]?.matches_reference &&
receipts[c]?.publication &&
!receipts[c]?.published,
).length;
const published = selected.filter((c) => receipts[c]?.published).length;
async function run(publish: boolean) {
if (
running.current ||
@@ -57,6 +118,7 @@ export default function Assisted({
try {
for (const code of selected) {
if (stop.current) break;
if (!publish && receipts[code]) continue;
setMessage(
`${publish ? "Publicação" : "Preparação"}: ${batch.items.find((i) => i.code === code)?.title}`,
);
@@ -74,6 +136,12 @@ export default function Assisted({
confirm: true,
};
pending.current[code] = body;
saveProgress(storageKey, {
selected,
receipts,
reason,
pending: pending.current,
});
await profileJson(
`/management/fundos/${receipt.id}/publicar`,
body,
@@ -108,6 +176,14 @@ export default function Assisted({
: "Preparação concluída. Confira os resultados antes de confirmar a publicação.",
);
} catch (error) {
setNeedsMfa(
!!error &&
typeof error === "object" &&
"status" in error &&
(error.status === 401 ||
error.status === 403 ||
error.status === 419),
);
setMessage(
`${error instanceof Error ? error.message : "Operação interrompida."} Os resultados anteriores foram conservados. Se a sessão ou MFA expirou, autentique-se no portal e retome. A publicação incerta pode ser repetida com a mesma confirmação.`,
);
@@ -145,71 +221,94 @@ export default function Assisted({
setConfirmed(false);
}}
>
Seleccionar instituições do documento
Seleccionar todas as instituições (270)
</button>
<p>{selected.length} seleccionadas</p>
{batch.items.map((item) => (
<details key={item.code} className="border-b py-2">
<summary>
<label>
<input
type="checkbox"
checked={selected.includes(item.code)}
onChange={(e) => {
setSelected((old) =>
e.target.checked
? [...old, item.code]
: old.filter(
(c) => c !== item.code,
),
);
setConfirmed(false);
}}
/>{" "}
{item.title}
</label>{" "}
—{" "}
{receipts[item.code]?.published
? "Publicação confirmada"
: receipts[item.code]
? receipts[item.code].matches_reference
? "Conferida, aguarda publicação"
: "Edições existentes: rever individualmente"
: item.published
? "Já tem publicação"
: "Por preparar"}
</summary>
<p>{item.summary}</p>
<p>
{item.categories.map(c => financialCategories[c] ?? c).join(", ")} —{" "}
{item.types.map(t => financialTypes[t] ?? t).join(", ")} — {item.status}
</p>
<p>
{item.source_document}{" "}
<code className="break-all">
{item.source_sha256}
</code>
</p>
<p>
{item.locations.length} localizações;{" "}
{item.contacts.length} contactos.
</p>
<Link
className="underline"
href={`/management/fundos/referencias/${item.code}`}
>
Conferir fonte e dados completos
</Link>
{receipts[item.code] && (
<p role="status">
{selected.length} seleccionadas ·{" "}
{Object.keys(receipts).length} preparadas nesta retoma ·{" "}
{ready} prontas para publicar · {published} publicadas
</p>
<p>
O progresso fica guardado nesta aba, separado por conta e
fontes. Depois de renovar o MFA, retome os passos sem voltar
a preparar as fichas conferidas. As confirmações devem ser
marcadas novamente; nenhuma publicação é automática.
</p>
<details>
<summary>
Conferir a lista e alterar a selecção (
{batch.items.length} referências)
</summary>
{batch.items.map((item) => (
<details key={item.code} className="border-b py-2">
<summary>
<label>
<input
type="checkbox"
checked={selected.includes(item.code)}
onChange={(e) => {
setSelected((old) =>
e.target.checked
? [...old, item.code]
: old.filter(
(c) =>
c !== item.code,
),
);
setConfirmed(false);
}}
/>{" "}
{item.title}
</label>{" "}
—{" "}
{receipts[item.code]?.published
? "Publicação confirmada"
: receipts[item.code]
? receipts[item.code].matches_reference
? "Conferida, aguarda publicação"
: "Edições existentes: rever individualmente"
: item.published
? "Já tem publicação"
: "Por preparar"}
</summary>
<p>{item.summary}</p>
<p>
{item.categories
.map((c) => financialCategories[c] ?? c)
.join(", ")}{" "}
—{" "}
{item.types
.map((t) => financialTypes[t] ?? t)
.join(", ")}{" "}
— {item.status}
</p>
<p>
{item.source_document}{" "}
<code className="break-all">
{item.source_sha256}
</code>
</p>
<p>
{item.locations.length} localizações;{" "}
{item.contacts.length} contactos.
</p>
<Link
className="ml-4 underline"
href={`/management/fundos/${receipts[item.code].id}`}
className="underline"
href={`/management/fundos/referencias/${item.code}`}
>
Alterar ficha existente
Conferir fonte e dados completos
</Link>
)}
</details>
))}
{receipts[item.code] && (
<Link
className="ml-4 underline"
href={`/management/fundos/${receipts[item.code].id}`}
>
Alterar ficha existente
</Link>
)}
</details>
))}
</details>
<label className="block py-3">
<input
type="checkbox"
@@ -245,6 +344,11 @@ export default function Assisted({
Confirmo a publicação pública das versões conferidas,
preservando os dados e as incertezas apresentados.
</label>
<p>
Para publicar: termine a preparação, marque a conferência,
preencha o fundamento e confirme a publicação. Fichas com
edições existentes exigem revisão individual.
</p>
<button
type="button"
className={button}
@@ -263,6 +367,33 @@ export default function Assisted({
2. Publicar conferidas
</button>
</fieldset>
{needsMfa && (
<div role="alert" className={card}>
<p>
A sessão ou confirmação MFA precisa de renovação. O
progresso desta aba foi conservado.
</p>
<button
className={button}
type="button"
disabled={busy}
onClick={() =>
router.post(
"/logout",
{},
{
onSuccess: () =>
router.visit(
"/management/fundos/referencias-assistidas",
),
},
)
}
>
Renovar entrada com MFA e retomar
</button>
</div>
)}
{busy && (
<button
type="button"
@@ -283,3 +414,16 @@ export default function Assisted({
</Frame>
);
}
export default function Assisted({
batch,
}: {
batch: { dataset_hash: string; editor_id: number; items: Item[] };
}) {
return (
<AssistedEditor
key={`${batch.editor_id}:${batch.dataset_hash}`}
batch={batch}
/>
);
}
+53 -4
View File
@@ -124,7 +124,13 @@ const text = (node) =>
? text(node.props?.children)
: String(node ?? "");
test("assisted editorial preparation never publishes until a separate confirmation and retries the same publication", async () => {
test("assisted editor resumes after renewed authentication without preparing completed fiches or automatically publishing", async () => {
const previousStorage = globalThis.sessionStorage;
const tab = new Map();
globalThis.sessionStorage = {
getItem: (key) => tab.get(key) ?? null,
setItem: (key, value) => tab.set(key, value),
};
const states = [],
refs = [],
calls = [];
@@ -133,9 +139,13 @@ test("assisted editorial preparation never publishes until a separate confirmati
lost = true;
const hooks = {
...React,
useEffect(fn) {
fn();
},
useState(initial) {
const i = si++;
if (!(i in states)) states[i] = initial;
if (!(i in states))
states[i] = typeof initial === "function" ? initial() : initial;
return [
states[i],
(v) => {
@@ -175,10 +185,11 @@ test("assisted editorial preparation never publishes until a separate confirmati
return { package_sha256: "qa-dataset", hash: "qa-source" };
},
},
}).default;
}).AssistedEditor;
const props = {
batch: {
dataset_hash: "qa-dataset",
editor_id: 42,
items: [
{
code: "qa",
@@ -214,6 +225,23 @@ test("assisted editorial preparation never publishes until a separate confirmati
assert.equal(calls.length, 2);
assert.ok(calls.every((c) => !c.url.endsWith("/publicar")));
tree = render();
states.length = 0;
refs.length = 0; // Full page remount after login/MFA in the same tab.
tree = render();
assert.match(text(tree), /1 seleccionadas/);
assert.match(text(tree), /1 preparadas nesta retoma/);
boxes = flatten(tree).filter((n) => n.type === "input");
assert.equal(boxes[1].props.checked, false);
boxes[1].props.onChange({ target: { checked: true } });
tree = render();
click(tree, "1.");
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
calls.length,
2,
"A completed fiche must not be prepared again",
);
tree = render();
assert.equal(
flatten(tree).find(
(n) => n.type === "button" && text(n).startsWith("2."),
@@ -246,6 +274,25 @@ test("assisted editorial preparation never publishes until a separate confirmati
assert.deepEqual(calls[3], calls[2]);
assert.equal(calls[2].body.confirm, true);
assert.match(text(render()), /Publicação confirmada/);
props.batch.editor_id = 43;
states.length = 0;
refs.length = 0;
assert.match(
text(render()),
/0 seleccionadas/,
"Another account must not inherit progress",
);
props.batch.editor_id = 42;
props.batch.dataset_hash = "changed-source";
states.length = 0;
refs.length = 0;
assert.match(
text(render()),
/0 seleccionadas/,
"Changed sources must not inherit receipts",
);
if (previousStorage === undefined) delete globalThis.sessionStorage;
else globalThis.sessionStorage = previousStorage;
});
test("actual dossier continuation freezes a lost response retry and retains dossier/opportunity and selected product", async () => {
const states = [],
@@ -255,9 +302,11 @@ test("actual dossier continuation freezes a lost response retry and retains doss
const calls = [];
const hooks = {
...React,
useEffect() {},
useState(initial) {
const i = stateIndex++;
if (!(i in states)) states[i] = initial;
if (!(i in states))
states[i] = typeof initial === "function" ? initial() : initial;
return [states[i], (value) => (states[i] = value)];
},
useRef(initial) {