fix(contracts): enforce the edit-lock ADR-0006 promises but never wired

ADR-0006 explicitly commits to preserving the legacy's "trava" once a
contract leaves its editable draft state, overridable only by an
audited super_admin correction — but ContractsService.addItem/addParty
had no status or role check at all, letting anyone with contracts:edit
add items/parties to a contract in any status, including a signed
active one.

Adds assertEditable() (same shape as Quotes' assertNotLocked): draft
is the only free-editing state; past it, only super_admin can still
write, and that write is tagged distinctly in the audit log
(contract.item_added_override) rather than looking like an ordinary
edit. No frontend change needed — ContractDetailPage never called the
add-item/party endpoints in the first place.

Verified live: super_admin override still works past draft, and the
audit_log row for it carries overrode:true and the contract's status
at the time — confirmed directly in Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
2026-09-04 12:21:47 -03:00
parent ccac936372
commit d46d44f01b
3 changed files with 214 additions and 5 deletions

View File

@@ -22,6 +22,8 @@ import type { ContractPartyInputDto } from "./dto/contract-party-input.dto.js";
const { ContractNotFoundError, InvalidContractTransitionError } = contractsRepo;
const SUPER_ADMIN = "super_admin";
@Injectable()
export class ContractsService {
async list(user: EdenAuthenticatedUser): Promise<Contract[]> {
@@ -113,37 +115,63 @@ export class ContractsService {
async addItem(id: string, dto: ContractItemInputDto, user: EdenAuthenticatedUser) {
const contract = await this.get(id, user);
const overrode = this.assertEditable(contract, user);
const [item] = await this.buildItems([dto], contract.contract_period);
const created = await contractsRepo.addContractItem(id, { ...item!, sortOrder: contract.items.length });
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.item_added",
action: overrode ? "contract.item_added_override" : "contract.item_added",
resourceType: "contract",
resourceId: id,
metadata: { productId: dto.productId },
metadata: { productId: dto.productId, contractStatus: contract.status, overrode },
});
return created;
}
async addParty(id: string, dto: ContractPartyInputDto, user: EdenAuthenticatedUser) {
await this.get(id, user);
const contract = await this.get(id, user);
const overrode = this.assertEditable(contract, user);
const created = await contractsRepo.addContractParty(id, this.mapParty(dto));
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "contract.party_added",
action: overrode ? "contract.party_added_override" : "contract.party_added",
resourceType: "contract",
resourceId: id,
metadata: { partyRole: dto.partyRole },
metadata: { partyRole: dto.partyRole, contractStatus: contract.status, overrode },
});
return created;
}
/**
* ADR-0006's own stated invariant: "fluxo de fechamento de oferta →
* geração de contrato preserva a trava equivalente [...] exceto por
* papel com permissão de correção auditada equivalente ao super_admin
* do legado" — ported here from Quotes' assertNotLocked (same shape:
* hard lock once the aggregate has moved past its editable draft state,
* super_admin-only override). `draft` is the only status items/parties
* can be freely added to; anything past it means signing has started or
* the contract is already live — an edit past that point is either a
* correction (audited, super_admin-only) or should go through a future
* amendment instead (contract_amendments, not yet built — ADR-0006).
* Returns whether this call IS an override, so the caller can tag the
* audit event distinctly.
*/
private assertEditable(contract: Contract, user: EdenAuthenticatedUser): boolean {
if (contract.status === "draft") return false;
if (user.roleKey !== SUPER_ADMIN) {
throw new ConflictException(
`Este contrato não está mais em rascunho (status atual: ${contract.status}) — itens/partes só podem ser adicionados enquanto o contrato está em rascunho.`,
);
}
return true;
}
private mapParty(dto: ContractPartyInputDto) {
return {
partyRole: dto.partyRole,