Repository navigation
feat(catalog,orders)!: product variants — size becomes the sellable unit - #19
Merged
Merged
Conversation
BREAKING CHANGE: POST /cart/items takes { variantId, quantity } instead of
{ productId, quantity }; the cart and order response shapes change with it;
CreateProductDto loses stockQuantity and gains variants; and
PATCH /products/:id/stock is replaced by
PATCH /products/:id/variants/:variantId/stock.
The API had no variants: one product, one price, one stock count. Every
clothing store needs size, and this is the storefront's blocking gap — its
design has a P/M/G/GG/XGG selector where an unavailable size is struck
through, never hidden, so the response has to be able to say "this size
exists and has none left", which is a different sentence from "this size
does not exist".
Stock lives on the variant, always. The migration gives every existing
product exactly one variant labelled `Único` carrying its stock, and a
product created without variants gets the same, so there is never a second
code path for "products without variants" — that fork is how this becomes
unmaintainable.
products.stock_quantity is DROPPED rather than kept as a denormalized sum:
a second place for stock to live is a second place for it to be wrong.
ProductResponse.stockQuantity survives as a sum computed on read, so the
catalogue grid still says "Esgotado" from one number and that number cannot
drift from what checkout decrements.
Price stays on the product — a shirt costs the same in P and in XGG, and
per-variant pricing is a nullable column on the day a real store needs one.
Cart lines and order items address a variant; order items snapshot
variantLabel beside productName, so renaming a size later cannot rewrite
what somebody bought. Checkout decrements variant stock through the same
conditional UPDATE as before, and both stock 409s now name the piece —
`unavailableItems: [{ variantId, productId, productName, variantLabel }]`
— because "Camiseta Preta is out of stock" is false when only the M is.
No compatibility path for a bare productId: it does not say which size, and
the only guess available would be wrong on every shirt with more than one.
There is exactly one consumer, it regenerates its client from openapi.json,
and this is the cheapest this change will ever be.
See docs/specs/product-variants.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
O que muda
A API não tinha variante: um produto, um preço, um estoque. Toda loja de
roupa precisa de tamanho, e este é o buraco que trava a vitrine da AVESSO —
o desenho tem um seletor P/M/G/GG/XGG em que o tamanho indisponível aparece
riscado, nunca escondido, e pra isso a resposta precisa conseguir dizer
"este tamanho existe e acabou", que é diferente de "este tamanho não
existe".
Isto é domínio puro: schema, estoque por variante, linha de pedido. Não tem
como resolver no front-end sem inventar um campo.
POST /cart/items, as respostas do carrinho e as do pedido mudam deforma, e não há caminho de compatibilidade. Um
productIdaceito "porenquanto" teria de escolher um tamanho sozinho, e a única escolha possível
seria "o primeiro" — errada em toda camiseta com mais de um.
Existe um consumidor, a loja AVESSO, ela está sendo escrita agora e regenera
o cliente do
openapi.jsondeste repo. Este é o momento mais barato que estamudança vai ter.
A quebra é barulhenta: o
ValidationPipeglobal roda comforbidNonWhitelisted, entãoPOST /cart/itemscomproductIdePOST /productscomstockQuantityrespondem400nomeando a propriedadeque não existe — nunca
200ignorando o campo em silêncio.Decisões que valem revisão
products.stock_quantityfoi REMOVIDA, não mantida comosoma desnormalizada. Duas fontes de verdade pra estoque é como se vende o
que não se tem.
ProductResponse.stockQuantitycontinua existindo, agoracalculado na leitura a partir das variantes — a grade continua dizendo
"Esgotado" com um número só, e ele não pode divergir do que o checkout
decrementa.
Registrado na spec como decisão, não como esquecimento: vira uma coluna
nullable no dia em que uma loja real cobrar mais pelo XGG.
Únicoa todos osque existem e
POST /productssemvariantscriaÚnicosozinho — é aregra que impede a bifurcação "produto com variante / produto sem", que é
como isto ficaria impossível de manter.
seguro; remover exige política pro que já foi vendido (
order_itemsfazRESTRICT), e política é decisão, não detalhe. Adiado explicitamente.CartProductResponseperdeustockQuantity. Numa linha de carrinho oúnico número que significa alguma coisa é o estoque daquele tamanho, que
está em
variantao lado. A soma do produto ali convidaria a exibir "10disponíveis" numa linha de M que acabou.
Superfície nova
ProductResponse{ // ...inalterado: id, name, slug, description, priceCents, imageUrls, // status, weightGrams, categories, createdAt, updatedAt "stockQuantity": 42, // agora a SOMA das variantes, calculada na leitura "variants": [ // NUNCA vazio; ordenado por position { "id": "uuid", "label": "P", "position": 0, "stockQuantity": 3 }, { "id": "uuid", "label": "M", "position": 1, "stockQuantity": 0 }, { "id": "uuid", "label": "G", "position": 2, "stockQuantity": 7 } ] }POST /products/POST /products/{id}/variantsCarrinho
PATCH /cart/items/{variantId}eDELETE /cart/items/{variantId}.Pedido
Rotas
POST /products/{id}/variantsPATCH /products/{id}/stockPATCH /products/{id}/variants/{variantId}/stock38 → 39 rotas.
EXPECTED_ROUTE_COUNTemsrc/openapi/document.spec.tsacompanhou.
Migration
20260826120000_add_product_variants, escrita à mão como todas as outras:cria
product_variants, dá a todo produto existente uma varianteÚnicocom o estoque que ele tinha, migra
cart_itemseorder_itemspraendereçarem variante (com
variant_label='Único'no histórico, que é averdade — era o que se vendia), derruba
products.stock_quantity, efecha com os três
CHECKe oENABLE ROW LEVEL SECURITYsem policy quetoda tabela nova deste repo tem.
Critérios de aceitação
Todos verdes (
docs/specs/product-variants.md):position, não na alfabética (que seriaG, GG, M, P, XGG)
stockQuantitydo produto é a soma das variantesvariantsnasce com exatamente uma,Únicovariantscom o mesmolabel⇒400POST /cart/itemscomvariantIdde produtoACTIVEentra com orótulo daquele tamanho
variantIdde produtoDRAFT/ARCHIVEDou inexistente ⇒404indistinguíveis
409nomeando a peça, carrinho e estoqueintactos
um pedido
variantIdevariantLabelcongeladosPATCH .../variants/{variantId}/stockmexe só naquela variante, e404se a variante for de outro produtoopenapi.jsonregenerado descreve tudo issoVerificação
pnpm lint:checkpnpm typecheckpnpm testpnpm buildpnpm test:e2eA única falha do e2e foi um teste meu, não o código: eu assumi que as
duas linhas do carrinho voltariam na ordem P, M. Elas voltam ordenadas por
cart_items.id, um UUID — estável, mas sem significado. Corrigi aasserção pra comparar como conjunto (com o porquê no comentário), registrei
a ordenação em
docs/known-issues.mdcom esboço deconserto —
cart_itemsnão temcreated_at, que é justamente por que aordem de inserção não está disponível — e rodei
test/orders.e2e-spec.tsinteiro de novo: verde. Os outros sete arquivospassaram na primeira passada e não foram tocados depois.
A migration foi aplicada e conferida contra o Supabase de
desenvolvimento (
commerce-core-dev) com catálogo, carrinho e pedidodentro:
SUM(products.stock_quantity)antes = 42;SUM(product_variants.stock_quantity)depois = 42, 3 produtos → 3variantes, todas
Único, nenhum produto sem variante, a linha de carrinho eo item de pedido remapeados. Nenhuma unidade criada ou perdida.
Uma ressalva honesta sobre como ela foi aplicada:
prisma migrate deploynão roda desta máquina — o schema engine morre contra o pooler de
transação da Supabase com
Error: Schema engine error:e mensagem vazia(mesmo sintoma que
docs/deploy.mdjá registra pra porta 5432, que aqui estábloqueada). A migration foi aplicada pelo mesmo driver
pgque o app usa,numa transação, com a linha correspondente escrita em
_prisma_migrationscom o checksum SHA-256 correto do arquivo — exatamente o que
prisma migrate resolve --appliedfaria. Em produção nada disso se aplica: odocker/entrypoint.shrodaprisma migrate deploynormalmente, e lá amigration nunca foi aplicada, então ela roda inteira e limpa.