feat: Wire up initiative deletion end-to-end

Add deleteInitiative tRPC procedure, wire Delete button in InitiativeCard
with confirm dialog (Shift+click bypass), remove unused onDelete prop chain.
Fix agents table FK constraints (initiative_id, account_id missing ON DELETE
SET NULL) via table recreation migration. Register conversations migration
in journal. Expand cascade delete tests to cover pages, projects, change
sets, agents (set null), and conversations (set null).
This commit is contained in:
Lukas May
2026-02-18 17:54:53 +09:00
parent 80aa3e42fb
commit 6fa025251e
8 changed files with 180 additions and 13 deletions

View File

@@ -6,10 +6,17 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { nanoid } from 'nanoid';
import { DrizzleInitiativeRepository } from './initiative.js';
import { DrizzlePhaseRepository } from './phase.js';
import { DrizzleTaskRepository } from './task.js';
import { DrizzlePageRepository } from './page.js';
import { DrizzleProjectRepository } from './project.js';
import { DrizzleChangeSetRepository } from './change-set.js';
import { DrizzleAgentRepository } from './agent.js';
import { DrizzleConversationRepository } from './conversation.js';
import { createTestDatabase } from './test-helpers.js';
import { changeSets } from '../../schema.js';
import type { DrizzleDatabase } from '../../index.js';
describe('Cascade Deletes', () => {
@@ -17,12 +24,22 @@ describe('Cascade Deletes', () => {
let initiativeRepo: DrizzleInitiativeRepository;
let phaseRepo: DrizzlePhaseRepository;
let taskRepo: DrizzleTaskRepository;
let pageRepo: DrizzlePageRepository;
let projectRepo: DrizzleProjectRepository;
let changeSetRepo: DrizzleChangeSetRepository;
let agentRepo: DrizzleAgentRepository;
let conversationRepo: DrizzleConversationRepository;
beforeEach(() => {
db = createTestDatabase();
initiativeRepo = new DrizzleInitiativeRepository(db);
phaseRepo = new DrizzlePhaseRepository(db);
taskRepo = new DrizzleTaskRepository(db);
pageRepo = new DrizzlePageRepository(db);
projectRepo = new DrizzleProjectRepository(db);
changeSetRepo = new DrizzleChangeSetRepository(db);
agentRepo = new DrizzleAgentRepository(db);
conversationRepo = new DrizzleConversationRepository(db);
});
/**
@@ -102,17 +119,72 @@ describe('Cascade Deletes', () => {
order: 1,
});
// Create a page for the initiative
const page = await pageRepo.create({
initiativeId: initiative.id,
parentPageId: null,
title: 'Root Page',
content: null,
sortOrder: 0,
});
// Create a project and link it via junction table
const project = await projectRepo.create({
name: 'test-project',
url: 'https://github.com/test/test-project.git',
});
await projectRepo.setInitiativeProjects(initiative.id, [project.id]);
// Create two agents (need two for conversations, and one for changeSet FK)
const agent1 = await agentRepo.create({
name: 'agent-1',
worktreeId: 'wt-1',
initiativeId: initiative.id,
});
const agent2 = await agentRepo.create({
name: 'agent-2',
worktreeId: 'wt-2',
initiativeId: initiative.id,
});
// Insert change set directly (createWithEntries uses async tx, incompatible with better-sqlite3 sync driver)
const changeSetId = nanoid();
await db.insert(changeSets).values({
id: changeSetId,
agentId: agent1.id,
agentName: agent1.name,
initiativeId: initiative.id,
mode: 'plan',
status: 'applied',
createdAt: new Date(),
});
const changeSet = (await changeSetRepo.findById(changeSetId))!;
// Create a conversation between agents with initiative context
const conversation = await conversationRepo.create({
fromAgentId: agent1.id,
toAgentId: agent2.id,
initiativeId: initiative.id,
question: 'Test question',
});
return {
initiative,
phases: { phase1, phase2 },
parentTasks: { parentTask1, parentTask2, parentTask3 },
tasks: { task1, task2, task3, task4 },
page,
project,
changeSet,
agents: { agent1, agent2 },
conversation,
};
}
describe('delete initiative', () => {
it('should cascade delete all phases and tasks', async () => {
const { initiative, phases, parentTasks, tasks } = await createFullHierarchy();
it('should cascade delete all phases, tasks, pages, junction rows, and change sets', async () => {
const { initiative, phases, parentTasks, tasks, page, project, changeSet } =
await createFullHierarchy();
// Verify everything exists
expect(await initiativeRepo.findById(initiative.id)).not.toBeNull();
@@ -125,11 +197,15 @@ describe('Cascade Deletes', () => {
expect(await taskRepo.findById(tasks.task2.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).not.toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).not.toBeNull();
expect(await pageRepo.findById(page.id)).not.toBeNull();
expect(await changeSetRepo.findById(changeSet.id)).not.toBeNull();
const linkedProjects = await projectRepo.findProjectsByInitiativeId(initiative.id);
expect(linkedProjects).toHaveLength(1);
// Delete initiative
await initiativeRepo.delete(initiative.id);
// Verify everything is gone
// Verify cascade deletes — all gone
expect(await initiativeRepo.findById(initiative.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase1.id)).toBeNull();
expect(await phaseRepo.findById(phases.phase2.id)).toBeNull();
@@ -140,6 +216,38 @@ describe('Cascade Deletes', () => {
expect(await taskRepo.findById(tasks.task2.id)).toBeNull();
expect(await taskRepo.findById(tasks.task3.id)).toBeNull();
expect(await taskRepo.findById(tasks.task4.id)).toBeNull();
expect(await pageRepo.findById(page.id)).toBeNull();
expect(await changeSetRepo.findById(changeSet.id)).toBeNull();
// Junction row gone but project itself survives
const remainingLinks = await projectRepo.findProjectsByInitiativeId(initiative.id);
expect(remainingLinks).toHaveLength(0);
expect(await projectRepo.findById(project.id)).not.toBeNull();
});
it('should set null on agents and conversations (not cascade)', async () => {
const { initiative, agents, conversation } = await createFullHierarchy();
// Verify agents are linked
const a1Before = await agentRepo.findById(agents.agent1.id);
expect(a1Before!.initiativeId).toBe(initiative.id);
// Delete initiative
await initiativeRepo.delete(initiative.id);
// Agents survive with initiativeId set to null
const a1After = await agentRepo.findById(agents.agent1.id);
expect(a1After).not.toBeNull();
expect(a1After!.initiativeId).toBeNull();
const a2After = await agentRepo.findById(agents.agent2.id);
expect(a2After).not.toBeNull();
expect(a2After!.initiativeId).toBeNull();
// Conversation survives with initiativeId set to null
const convAfter = await conversationRepo.findById(conversation.id);
expect(convAfter).not.toBeNull();
expect(convAfter!.initiativeId).toBeNull();
});
});

View File

@@ -108,6 +108,14 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
return repo.update(id, data);
}),
deleteInitiative: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requireInitiativeRepository(ctx);
await repo.delete(input.id);
return { success: true };
}),
updateInitiativeConfig: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),