Files
Codewalkers/apps/server/trpc/routers/phase.ts
Lukas May 34578d39c6 refactor: Restructure monorepo to apps/server/ and apps/web/ layout
Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
2026-03-03 11:22:53 +01:00

239 lines
8.2 KiB
TypeScript

/**
* Phase Router — create, list, get, update, dependencies, bulk create
*/
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { Phase } from '../../db/schema.js';
import type { ProcedureBuilder } from '../trpc.js';
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator } from './_helpers.js';
import { phaseBranchName } from '../../git/branch-naming.js';
import { ensureProjectClone } from '../../git/project-clones.js';
export function phaseProcedures(publicProcedure: ProcedureBuilder) {
return {
createPhase: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
name: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.create({
initiativeId: input.initiativeId,
name: input.name,
status: 'pending',
});
}),
listPhases: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.findByInitiativeId(input.initiativeId);
}),
getPhase: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const phase = await repo.findById(input.id);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.id}' not found`,
});
}
return phase;
}),
updatePhase: publicProcedure
.input(z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
content: z.string().nullable().optional(),
status: z.enum(['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review']).optional(),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const { id, ...data } = input;
return repo.update(id, data);
}),
approvePhase: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const taskRepo = requireTaskRepository(ctx);
const phase = await repo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
if (phase.status !== 'pending') {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `Phase must be pending to approve (current status: ${phase.status})`,
});
}
// Validate phase has work tasks (filter out detail tasks)
const phaseTasks = await taskRepo.findByPhaseId(input.phaseId);
const workTasks = phaseTasks.filter((t) => t.category !== 'detail');
if (workTasks.length === 0) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Phase must have tasks before it can be approved',
});
}
return repo.update(input.phaseId, { status: 'approved' });
}),
deletePhase: publicProcedure
.input(z.object({ id: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
await repo.delete(input.id);
return { success: true };
}),
createPhasesFromPlan: publicProcedure
.input(z.object({
initiativeId: z.string().min(1),
phases: z.array(z.object({
name: z.string().min(1),
})),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const created: Phase[] = [];
for (const p of input.phases) {
const phase = await repo.create({
initiativeId: input.initiativeId,
name: p.name,
status: 'pending',
});
created.push(phase);
}
return created;
}),
listInitiativePhaseDependencies: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
return repo.findDependenciesByInitiativeId(input.initiativeId);
}),
createPhaseDependency: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
dependsOnPhaseId: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const phase = await repo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.phaseId}' not found`,
});
}
const dependsOnPhase = await repo.findById(input.dependsOnPhaseId);
if (!dependsOnPhase) {
throw new TRPCError({
code: 'NOT_FOUND',
message: `Phase '${input.dependsOnPhaseId}' not found`,
});
}
await repo.createDependency(input.phaseId, input.dependsOnPhaseId);
return { success: true };
}),
getPhaseDependencies: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const dependencies = await repo.getDependencies(input.phaseId);
return { dependencies };
}),
getPhaseDependents: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
const dependents = await repo.getDependents(input.phaseId);
return { dependents };
}),
removePhaseDependency: publicProcedure
.input(z.object({
phaseId: z.string().min(1),
dependsOnPhaseId: z.string().min(1),
}))
.mutation(async ({ ctx, input }) => {
const repo = requirePhaseRepository(ctx);
await repo.removeDependency(input.phaseId, input.dependsOnPhaseId);
return { success: true };
}),
getPhaseReviewDiff: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const phaseRepo = requirePhaseRepository(ctx);
const initiativeRepo = requireInitiativeRepository(ctx);
const projectRepo = requireProjectRepository(ctx);
const branchManager = requireBranchManager(ctx);
const phase = await phaseRepo.findById(input.phaseId);
if (!phase) {
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
}
if (phase.status !== 'pending_review') {
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
}
const initiative = await initiativeRepo.findById(phase.initiativeId);
if (!initiative?.branch) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
}
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
let rawDiff = '';
for (const project of projects) {
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch);
if (diff) {
rawDiff += diff + '\n';
}
}
return {
phaseName: phase.name,
sourceBranch: phBranch,
targetBranch: initBranch,
rawDiff,
};
}),
approvePhaseReview: publicProcedure
.input(z.object({ phaseId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
const orchestrator = requireExecutionOrchestrator(ctx);
await orchestrator.approveAndMergePhase(input.phaseId);
return { success: true };
}),
};
}