Skip to content

fix: goals.investment_type_id ganha FK ON DELETE SET NULL (closes #65) - #105

Open
Guiroos wants to merge 4 commits into
mainfrom
claude/issue-65-goals-investment-type-fk
Open

fix: goals.investment_type_id ganha FK ON DELETE SET NULL (closes #65)#105
Guiroos wants to merge 4 commits into
mainfrom
claude/issue-65-goals-investment-type-fk

Conversation

@Guiroos

@Guiroos Guiroos commented Aug 18, 2026

Copy link
Copy Markdown
Owner

O que mudou

  • lib/db/schema.tsgoals.investmentTypeId ganha .references((): AnyPgColumn => investmentTypes.id, { onDelete: 'set null' }). Era a única coluna uuid não-PK do schema sem FK declarada.
  • lib/db/migrations/0017_dizzy_the_leader.sql — nova migration: primeiro nulifica qualquer goals.investment_type_id já órfão (UPDATE ... WHERE investment_type_id NOT IN (SELECT id FROM investment_types)), depois cria a constraint ON DELETE SET NULL.
  • __tests__/integration/actions-investments.test.ts — novo describe('deleteInvestmentType'): cria um tipo sem nenhum registro mensal (evita que o restrict de investments/investmentWithdrawals mascare o teste), cria uma meta vinculada a ele, chama a action real deleteInvestmentType, e assere que goals.investment_type_id fica null no banco (não só que a query não lança).

Por que dessa forma

goals.investmentTypeId referenciava investmentTypes, uma tabela declarada 11 linhas depois no mesmo arquivo — e investmentTypes.goalId já referencia goals.id de volta (ON DELETE SET NULL). É uma referência mútua entre as duas tabelas, o mesmo problema de inferência circular de tipos que o self-FK de debtorEntries.settledByPaymentId resolve — por isso segui o mesmo padrão documentado em .claude/db.md (AnyPgColumn + função lazy), mesmo a issue não tendo citado esse detalhe explicitamente. tsc --noEmit confirma que resolve sem erro.

A política set null (não restrict/cascade) é a que a issue já justifica: espelha a direção inversa da mesma relação (investment_types_goal_id_goals_id_fk), evita apagar a meta do usuário junto com o tipo, e evita transformar o delete numa segunda falha em runtime com mensagem de erro que descreveria outra causa (RowActions do InvestmentTypeCard/InvestmentTypeAccordion mostra "não é possível excluir — tipo em uso").

Não adicionei nenhum update(goals) manual em deleteInvestmentType — com a FK no lugar o banco resolve, e não há estado dependente do id (diferente do caso deleteDebtEntry, onde o SET NULL não reseta status).

Como testei

npm run lint && npm run format:check && npm run typecheck && npm test   # verde (490 testes)
npm run build                                                            # verde

npm run test:integration não rodei — exige credenciais do Neon e só roda em push para main no CI. O teste novo (deleteInvestmentType) está escrito e segue o padrão de mock (requireUserId + next/cache) já usado no resto do arquivo; ele valida a asserção que só a correção certa passa: goal.investmentTypeId === null no banco após o delete real do tipo, não apenas ausência de erro.

Risco e o que NÃO foi coberto

  • Bloqueante de ordem de merge (apontado na revisão): o teste de integração novo só passa depois que a 0017 estiver aplicada no branch pai do Neon (NEON_PARENT_BRANCH_ID), porque o branch de teste é clone do pai e nunca roda migrate() (.claude/testing.md). O vercel.json só roda db:migrate contra a DATABASE_URL de produção — nenhum caminho automático aplica 0017 no branch dev. Sem isso, integration falha depois do merge, com main vermelho, e não aqui no PR (o job só roda em push para main). Esta sessão não tem credenciais do Neon dev branch para aplicar a migration — antes de mergear, alguém com acesso precisa rodar DATABASE_URL=<connection string do branch dev> npm run db:migrate (ou aplicar via console do Neon).
  • Migration nova (0017_dizzy_the_leader.sql) também precisa rodar via npm run db:migrate antes de qualquer mutação em produção — se aplicada antes do deploy do código (fluxo padrão do vercel.json), sem risco: o UPDATE de limpeza é idempotente e no-op quando não há órfãos.
  • Não toquei na UI (InvestmentTypeCard/InvestmentTypeAccordion/MetasList/GoalDialog) — a issue não pede isso; com a FK, a meta simplesmente passa a mostrar o estado "sem tipo vinculado" (mesmo comportamento hoje reservado a metas manuais) depois do delete, em vez de ficar travada em R$ 0,00 com id órfão invisível.
  • npm run test:integration não roda nesta sessão (exige NEON_API_KEY/NEON_PROJECT_ID etc.) — fica para o CI em push para main.

Arquivos tocados

  • lib/db/schema.ts
  • lib/db/migrations/0017_dizzy_the_leader.sql (novo)
  • lib/db/migrations/meta/0017_snapshot.json (novo, gerado)
  • lib/db/migrations/meta/_journal.json
  • __tests__/integration/actions-investments.test.ts

Excluir um tipo de investimento deixava metas vinculadas com um uuid
órfão em investmentTypeId — a coluna era a única uuid não-PK do schema
sem .references(), e nenhuma migration criava a constraint no banco.
getGoalsWithProgress entrava no ramo de saldo vinculado (id truthy) e
somava 0, travando a meta em R$ 0,00/0% sem erro nem indicação na UI.

Migration nulifica órfãos pré-existentes antes do ADD CONSTRAINT.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUxAA67srmEXVPBcZAa7Es

@Guiroos Guiroos left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Um achado, na ordem de merge — o código em si está limpo.

O teste de integração novo depende de a 0017 estar aplicada no branch pai do Neon, e o job integration só roda em push para main: se a migration não subir para o branch dev antes do merge, a falha aparece com o main já vermelho. Detalhe e correção no comentário inline.

Verificações que fiz e passaram: drizzle-kit check, drizzle-kit generate (sem drift schema↔snapshot), prettier --check lib/db/migrations/, tsc --noEmit, 490 testes unitários.


Generated by Claude Code

Comment on lines +404 to +419
describe('deleteInvestmentType', () => {
it('nulifica investmentTypeId de metas vinculadas em vez de deixar id órfão', async () => {
const type = await createInvestmentType(db, userId, { name: 'Tipo para Deletar' })
const goal = await createGoal(db, userId, {
name: 'Meta Vinculada',
investmentTypeId: type.id,
})

const { deleteInvestmentType } = await import('@/lib/actions/investments')
await deleteInvestmentType(type.id)

const savedGoal = await db.query.goals.findFirst({
where: eq(schema.goals.id, goal.id),
})
expect(savedGoal).toBeDefined()
expect(savedGoal?.investmentTypeId).toBeNull()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bloqueante para a ordem de merge (não é defeito do código): este teste só passa depois que a 0017 estiver aplicada no branch pai do Neon (NEON_PARENT_BRANCH_ID), e nada neste PR faz isso acontecer.

Por quê: __tests__/integration/setup.ts monta o makeNeonTesting com parentBranchId, e o branch de teste é um clone do pai — .claude/testing.md é explícito em "Branches são clones — sem migrations: o branch de teste herda o schema completo do pai; nunca chamar migrate() nos testes". O vercel.json roda db:migrate contra a DATABASE_URL de produção, não contra o branch dev. Ou seja: nenhum caminho automático leva a 0017 até o schema que o teste enxerga.

O modo de falha é o mesmo que .claude/testing.md já registra para onConflictDoUpdate ("se o unique index não existir no banco, o clause nunca dispara"): sem a constraint no banco de teste, deleteInvestmentType apaga o tipo normalmente, goals.investment_type_id continua com o id órfão, e a linha 419 falha com o UUID em vez de null. E como o job integration do CI tem if: github.event_name == 'push' && github.ref == 'refs/heads/main', a falha não aparece aqui no PR — ela aparece depois do merge, com o main vermelho, que é o pior lugar para descobrir.

Correção concreta, antes do merge: aplicar a migration no branch dev que serve de pai (DATABASE_URL=<connection string do branch dev> npm run db:migrate, ou via console do Neon) e registrar isso na descrição do PR junto da nota que já existe sobre produção — hoje a seção "Risco e o que NÃO foi coberto" cobre só o lado de produção (db:migrate antes de mutação), e o branch de teste é a outra metade da mesma exigência.

Resto do PR conferido e sem achado: drizzle-kit check → "Everything's fine"; drizzle-kit generate não acusa drift entre schema.ts e o 0017_snapshot.json; prettier --check lib/db/migrations/ verde (o passo do .claude/db.md foi feito); tsc --noEmit e os 490 testes unitários verdes; o UPDATE de limpeza é idempotente e o NOT IN não tem risco de NULL porque investment_types.id é PK. A ordem de resetAccount (investmentTypes na linha 79, goals na 80) continua válida com o SET NULL novo — as metas são nuladas e apagadas em seguida.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Achado correto — confirmei a cadeia (setup.tsparentBranchId, .claude/testing.md sobre branches serem clone sem migration, vercel.json só migrando produção). Não é algo que eu consiga corrigir com um push: esta sessão não tem credenciais do branch dev do Neon (NEON_PARENT_BRANCH_ID) para rodar db:migrate contra ele.

Atualizei a descrição do PR: a seção "Risco e o que NÃO foi coberto" agora tem uma entrada dedicada a isso, marcada como bloqueante de ordem de merge, com o comando exato (DATABASE_URL=<branch dev> npm run db:migrate) para quem tiver acesso rodar antes do merge.


Generated by Claude Code

@Guiroos
Guiroos marked this pull request as ready for review August 19, 2026 16:28

@Guiroos Guiroos left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revisão do head atual (a3fe7ee, depois dos dois merges de main). Um achado novo, não bloqueante, no inline em lib/db/schema.ts.

Resumo: a FK faz deleteInvestmentType passar a mutar goals, e ela revalida só /investimentos. Não é preciosismo de cache — a nulificação troca o ramo de cálculo de getGoalsWithProgress, então uma meta com contribuições manuais sai de R$ 0,00 para o valor real, que é o ganho da #65. Detalhe, cenário de reprodução e o assert discriminante no inline.

O que verifiquei nesta rodada e não virou achado:

  • A coluna é viva, não vestígio. goals.investmentTypeId é lida em lib/queries/goals.ts:44,104-107,142 e escrita em lib/actions/goals.ts:29-39 — a FK vale para uma relação que a UI usa de fato, não para uma coluna morta.
  • ON DELETE SET NULL não conflita com a direção inversa. investmentTypes.goalId já é set null para goals.id; as duas pontas nulificam, nenhuma cascateia, então não há caminho de delete em cadeia.
  • resetAccount e deleteAccount seguem válidos. No resetAccount a ordem (investmentTypes antes de goals) só ganha um UPDATE intermediário dentro da mesma transação. No deleteAccount, goals.userId é cascade e o novo FK é set null — não é do tipo que faria o DELETE FROM users estourar violação, que é o que __tests__/integration/actions-delete-account.test.ts existe para pegar.
  • Padrão AnyPgColumn + função lazy é o certo aqui, e pelo motivo declarado no corpo do PR (referência mútua entre as duas tabelas) — mesmo caso do self-FK de debtorEntries.settledByPaymentId em .claude/db.md.
  • .sql sem newline final não quebra o format:check — o Prettier não parseia .sql sem plugin, e o passo do .claude/db.md (prettier --write lib/db/migrations/meta/) foi feito.

O bloqueante de ordem de merge da rodada anterior (aplicar a 0017 no branch pai do Neon antes do merge, senão integration falha com o main já vermelho) continua de pé — a descrição do PR passou a registrá-lo, mas a thread segue aberta de propósito, porque a ação em si depende de credenciais que nenhuma sessão aqui tem.


Generated by Claude Code

Comment thread lib/db/schema.ts
Com a FK nova (goals.investmentTypeId -> investmentTypes.id ON DELETE
SET NULL), deleteInvestmentType passou a mutar linhas de goals — e a
nulificação troca o ramo de cálculo de getGoalsWithProgress (vinculado
-> manual, somando goalContributions em vez de amountsByType). Sem
revalidar /metas, uma meta que tinha contribuições manuais e ficava
travada em R$ 0,00 continua mostrando o valor antigo no Router Cache
até a próxima navegação forçada.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUxAA67srmEXVPBcZAa7Es
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.

2 participants