Skip to content

feat(orders): name the buyer on an order, for callers holding orders.read - #24

Merged
ArtRiv merged 3 commits into
mainfrom
feature/order-buyer
Aug 28, 2026
Merged

ArtRiv merged 3 commits into
mainfrom
feature/order-buyer

Conversation

@ArtRiv

@ArtRiv ArtRiv commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Um pedido dizia userId e mais nada sobre quem comprou, então um back-office
só conseguia mostrar um UUID. customers.read está no catálogo de permissões
sem rota nenhuma atrás, e construir esse diretório agora é uma decisão maior do
que a tela precisa — o comprador daquele pedido é dado do pedido, do mesmo
jeito que o nome numa nota fiscal.

Nasceu de uma lacuna real: o painel administrativo da AVESSO tem coluna
Cliente na lista e um card com nome e e-mail no detalhe, e não havia como
preenchê-los. Caminho upstream em vez de contorno no front-end, conforme
docs/upstream-first.md.

O que muda no contrato

// OrderBuyerResponse
{ id: string; name: string | null; email: string }

// em OrderResponse — obrigatório e anulável
buyer: OrderBuyerResponse | null

42 operações, as mesmas de antes. Nenhuma rota nova: é um campo numa
resposta que já existia, e é por isso que o painel não ganha chamada nenhuma.

  • buyer vem preenchido só para quem carrega orders.read, e null para
    todo mundo mais — o próprio comprador incluído. null em vez de ausente
    para que o campo esteja sempre no contrato e um cliente tipado não tenha que
    tratar undefined além de null.
  • name é anulável porque User.name é String?: uma conta criada pelo
    Google nunca passou por RegisterDto. Quem exibe decide o fallback.

Como o vazamento fica fechado

Três colunas escolhidas pelo nome ({ id: true, name: true, email: true }) e
copiadas uma a uma para a resposta. Nunca spread, nunca include: { user: true }
— a forma genérica é exatamente como uma listagem de back-office distribui
todo hash de senha do banco, e é o item 2 de docs/admin-api.md.

Dois testes seguram isso:

  • um unitário afirma que o select são aqueles três e nenhum a mais
    (toEqual, não toMatchObject — coluna acrescentada é a regressão);
  • um e2e serializa o corpo inteiro e compara com o hash bcrypt real do
    comprador — o valor, não o nome do campo. Um vazamento que chegasse por
    outra relação, ou sob outra grafia, ainda falha.

Conferido no documento gerado: passwordHash, googleId, emailVerifiedAt e
roleId não aparecem.

Uma decisão que merece revisão

As quatro transições de back-office (mark-paid, ship, deliver, refund)
passaram a receber o chamador, para que as respostas delas obedeçam à mesma
regra — o painel re-renderiza o detalhe a partir delas, e o card do cliente
esvaziaria a cada transição. O webhook mantém a assinatura e recebe null:
ele não tem chamador, e não é uma pessoa.

Consequência real: um operador com orders.update_status e sem
orders.read envia o pedido e recebe buyer: null. É o comportamento certo —
uma regra só, sem exceção por rota — e está registrado nos edge cases da spec.

A permissão é orders.read e não customers.read de propósito: quem lê o
pedido de qualquer pessoa já lê o endereço de entrega dela, que identifica
mais do que um nome. customers.read continua reservada para o diretório de
clientes, que lista gente sem pedido nenhum e é outra superfície.

Verificação

porta resultado
unitários 502 (492 + 10 novos)
e2e 210 em 8 suítes (204 + 6 novos)
lint:check limpo
typecheck limpo
build limpo
openapi:generate 42 operações, +47 linhas

payments-live não rodou: depende de rede até a Stripe, não toca em nada que
esta mudança altera, e a própria docstring dele manda rodar sob demanda.

Spec: docs/specs/order-buyer.md.

ArtRiv added 3 commits August 27, 2026 23:20
…read

An order said `userId` and nothing else about who bought it, so a back office
could only show a UUID. `customers.read` sits in the permission catalogue with
no route behind it, and building that directory now is a bigger decision than
this screen needs — the buyer of *this order* is order data, the way a name on
an invoice is.

`OrderResponse.buyer` is `{ id, name, email }` for a caller holding
`orders.read`, and null for everyone else, the buyer included. Null rather
than absent so the field is always in the contract and only its value varies.

The three columns are selected and copied one at a time, never spread and
never `include: { user: true }`: `User` carries `passwordHash` and `googleId`,
and the generic form is exactly how a back-office listing hands out every
password hash. A test asserts the select is those three and no more, and
another feeds the mapper a widened row to prove nothing else can get through.

The four back-office transitions now take the caller so their responses obey
the same rule — the panel re-renders the detail from them, and the customer
card would otherwise empty out on every transition. The webhook keeps its
signature and gets null: it has no caller, and it is not a person.

10 new unit tests, 502 total. No new route; the document stays at 42
operations.
The unit tests pin the permission rule against a mocked row. The other half of
the claim is about the query itself — that the Prisma include really narrows
to three columns — and a mock cannot falsify that.

Six tests at the HTTP level: the three fields and no fourth key, both listing
shapes, the buyer surviving mark-paid → ship → deliver so the panel's customer
card does not empty out, and checkout answering null.

One of them serialises the whole response and compares it against the buyer's
real bcrypt hash — the value, not the field name. A leak arriving through some
other relation, or under some other spelling, still fails it.

43 tests in the orders file, from 37.
@ArtRiv
ArtRiv merged commit b023ca2 into main Aug 28, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant