All files / src/trpc/routers change-set.ts

1.51% Statements 1/66
0% Branches 0/53
25% Functions 1/4
1.58% Lines 1/63

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147                              11x                                                                                                                                                                                                                                                                      
/**
 * Change Set Router — list, get, revert workflows
 */
 
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import type { ProcedureBuilder } from '../trpc.js';
import {
  requireChangeSetRepository,
  requirePhaseRepository,
  requireTaskRepository,
  requirePageRepository,
} from './_helpers.js';
 
export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
  return {
    listChangeSets: publicProcedure
      .input(z.object({
        initiativeId: z.string().min(1).optional(),
        agentId: z.string().min(1).optional(),
      }))
      .query(async ({ ctx, input }) => {
        const repo = requireChangeSetRepository(ctx);
        if (input.agentId) {
          return repo.findByAgentId(input.agentId);
        }
        if (input.initiativeId) {
          return repo.findByInitiativeId(input.initiativeId);
        }
        throw new TRPCError({
          code: 'BAD_REQUEST',
          message: 'Either agentId or initiativeId is required',
        });
      }),
 
    getChangeSet: publicProcedure
      .input(z.object({ id: z.string().min(1) }))
      .query(async ({ ctx, input }) => {
        const repo = requireChangeSetRepository(ctx);
        const cs = await repo.findByIdWithEntries(input.id);
        if (!cs) {
          throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
        }
        return cs;
      }),
 
    revertChangeSet: publicProcedure
      .input(z.object({ id: z.string().min(1), force: z.boolean().optional() }))
      .mutation(async ({ ctx, input }) => {
        const repo = requireChangeSetRepository(ctx);
        const cs = await repo.findByIdWithEntries(input.id);
        if (!cs) {
          throw new TRPCError({ code: 'NOT_FOUND', message: `ChangeSet '${input.id}' not found` });
        }
        if (cs.status === 'reverted') {
          throw new TRPCError({ code: 'BAD_REQUEST', message: 'ChangeSet is already reverted' });
        }
 
        const phaseRepo = requirePhaseRepository(ctx);
        const taskRepo = requireTaskRepository(ctx);
        const pageRepo = requirePageRepository(ctx);
 
        // Conflict detection (unless force)
        if (!input.force) {
          const conflicts: string[] = [];
          for (const entry of cs.entries) {
            if (entry.action === 'create') {
              if (entry.entityType === 'phase') {
                const phase = await phaseRepo.findById(entry.entityId);
                if (phase && phase.status === 'in_progress') {
                  conflicts.push(`Phase "${phase.name}" is in progress`);
                }
              } else if (entry.entityType === 'task') {
                const task = await taskRepo.findById(entry.entityId);
                if (task && task.status === 'in_progress') {
                  conflicts.push(`Task "${task.name}" is in progress`);
                }
              }
            } else if (entry.action === 'update' && entry.entityType === 'page' && entry.newState) {
              const page = await pageRepo.findById(entry.entityId);
              if (page) {
                const expectedContent = JSON.parse(entry.newState).content;
                if (page.content !== expectedContent) {
                  conflicts.push(`Page "${page.title}" was modified since change set was applied`);
                }
              }
            }
          }
          if (conflicts.length > 0) {
            return { success: false as const, conflicts };
          }
        }
 
        // Apply reverts in reverse entry order
        const reversedEntries = [...cs.entries].reverse();
        for (const entry of reversedEntries) {
          try {
            if (entry.action === 'create') {
              switch (entry.entityType) {
                case 'phase':
                  try { await phaseRepo.delete(entry.entityId); } catch { /* already deleted */ }
                  break;
                case 'task':
                  try { await taskRepo.delete(entry.entityId); } catch { /* already deleted */ }
                  break;
                case 'phase_dependency': {
                  const depData = JSON.parse(entry.newState || '{}');
                  if (depData.phaseId && depData.dependsOnPhaseId) {
                    try { await phaseRepo.removeDependency(depData.phaseId, depData.dependsOnPhaseId); } catch { /* already removed */ }
                  }
                  break;
                }
              }
            } else if (entry.action === 'update' && entry.previousState) {
              const prev = JSON.parse(entry.previousState);
              switch (entry.entityType) {
                case 'page':
                  await pageRepo.update(entry.entityId, {
                    content: prev.content,
                    title: prev.title,
                  });
                  ctx.eventBus.emit({
                    type: 'page:updated',
                    timestamp: new Date(),
                    payload: { pageId: entry.entityId, initiativeId: cs.initiativeId, title: prev.title },
                  });
                  break;
              }
            }
          } catch (err) {
            // Log but continue reverting other entries
          }
        }
 
        await repo.markReverted(input.id);
 
        ctx.eventBus.emit({
          type: 'changeset:reverted' as const,
          timestamp: new Date(),
          payload: { changeSetId: cs.id, initiativeId: cs.initiativeId },
        });
 
        return { success: true as const };
      }),
  };
}