Previously a single "that name or URL" message was thrown regardless of which column violated uniqueness. Now the catch block inspects the error string from SQLite to emit a name-specific or url-specific message, with a generic fallback when neither column can be identified. Adds vitest tests covering all four scenarios: name conflict, url conflict, unknown column conflict, and non-UNIQUE error passthrough. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
7.1 KiB
TypeScript
201 lines
7.1 KiB
TypeScript
/**
|
|
* Project Router — register, list, get, delete, initiative associations
|
|
*/
|
|
|
|
import { TRPCError } from '@trpc/server';
|
|
import { z } from 'zod';
|
|
import { join } from 'node:path';
|
|
import { rm, access } from 'node:fs/promises';
|
|
import type { ProcedureBuilder } from '../trpc.js';
|
|
import { requireProjectRepository, requireProjectSyncManager } from './_helpers.js';
|
|
import { cloneProject } from '../../git/clone.js';
|
|
import { getProjectCloneDir } from '../../git/project-clones.js';
|
|
|
|
export function projectProcedures(publicProcedure: ProcedureBuilder) {
|
|
return {
|
|
registerProject: publicProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
url: z.string().min(1),
|
|
defaultBranch: z.string().min(1).optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
|
|
let project;
|
|
try {
|
|
project = await repo.create({
|
|
name: input.name,
|
|
url: input.url,
|
|
...(input.defaultBranch && { defaultBranch: input.defaultBranch }),
|
|
});
|
|
} catch (error) {
|
|
const msg = (error as Error).message ?? '';
|
|
if (msg.includes('UNIQUE') || msg.includes('unique')) {
|
|
if (msg.includes('projects.name') || (msg.includes('name') && !msg.includes('url'))) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'A project with this name already exists',
|
|
});
|
|
}
|
|
if (msg.includes('projects.url') || msg.includes('url')) {
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'A project with this URL already exists',
|
|
});
|
|
}
|
|
// fallback: neither column identifiable
|
|
throw new TRPCError({
|
|
code: 'CONFLICT',
|
|
message: 'A project with this name or URL already exists',
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (ctx.workspaceRoot) {
|
|
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(input.name, project.id));
|
|
|
|
try {
|
|
await cloneProject(input.url, clonePath);
|
|
} catch (cloneError) {
|
|
await repo.delete(project.id);
|
|
throw new TRPCError({
|
|
code: 'INTERNAL_SERVER_ERROR',
|
|
message: `Failed to clone repository: ${(cloneError as Error).message}`,
|
|
});
|
|
}
|
|
|
|
// Validate that the specified default branch exists in the cloned repo
|
|
const branchToValidate = input.defaultBranch ?? 'main';
|
|
if (ctx.branchManager) {
|
|
const exists = await ctx.branchManager.remoteBranchExists(clonePath, branchToValidate);
|
|
if (!exists) {
|
|
// Clean up: remove project and clone
|
|
await rm(clonePath, { recursive: true, force: true }).catch(() => {});
|
|
await repo.delete(project.id);
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Branch '${branchToValidate}' does not exist in the repository`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for preview config
|
|
let hasPreviewConfig = false;
|
|
if (ctx.workspaceRoot) {
|
|
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(input.name, project.id));
|
|
try {
|
|
await access(join(clonePath, '.cw-preview.yml'));
|
|
hasPreviewConfig = true;
|
|
} catch { /* no config */ }
|
|
}
|
|
|
|
return { ...project, hasPreviewConfig };
|
|
}),
|
|
|
|
listProjects: publicProcedure
|
|
.query(async ({ ctx }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
return repo.findAll();
|
|
}),
|
|
|
|
getProject: publicProcedure
|
|
.input(z.object({ id: z.string().min(1) }))
|
|
.query(async ({ ctx, input }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
const project = await repo.findById(input.id);
|
|
if (!project) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: `Project '${input.id}' not found`,
|
|
});
|
|
}
|
|
return project;
|
|
}),
|
|
|
|
deleteProject: publicProcedure
|
|
.input(z.object({ id: z.string().min(1) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
const project = await repo.findById(input.id);
|
|
if (project && ctx.workspaceRoot) {
|
|
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(project.name, project.id));
|
|
await rm(clonePath, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
await repo.delete(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
updateProject: publicProcedure
|
|
.input(z.object({
|
|
id: z.string().min(1),
|
|
defaultBranch: z.string().min(1).optional(),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
const { id, ...data } = input;
|
|
const existing = await repo.findById(id);
|
|
if (!existing) {
|
|
throw new TRPCError({
|
|
code: 'NOT_FOUND',
|
|
message: `Project '${id}' not found`,
|
|
});
|
|
}
|
|
|
|
// Validate that the new default branch exists in the repo
|
|
if (data.defaultBranch && ctx.workspaceRoot && ctx.branchManager) {
|
|
const clonePath = join(ctx.workspaceRoot, getProjectCloneDir(existing.name, existing.id));
|
|
const exists = await ctx.branchManager.remoteBranchExists(clonePath, data.defaultBranch);
|
|
if (!exists) {
|
|
throw new TRPCError({
|
|
code: 'BAD_REQUEST',
|
|
message: `Branch '${data.defaultBranch}' does not exist in the repository`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return repo.update(id, data);
|
|
}),
|
|
|
|
getInitiativeProjects: publicProcedure
|
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
|
.query(async ({ ctx, input }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
return repo.findProjectsByInitiativeId(input.initiativeId);
|
|
}),
|
|
|
|
updateInitiativeProjects: publicProcedure
|
|
.input(z.object({
|
|
initiativeId: z.string().min(1),
|
|
projectIds: z.array(z.string().min(1)).min(1),
|
|
}))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const repo = requireProjectRepository(ctx);
|
|
await repo.setInitiativeProjects(input.initiativeId, input.projectIds);
|
|
return { success: true };
|
|
}),
|
|
|
|
syncProject: publicProcedure
|
|
.input(z.object({ id: z.string().min(1) }))
|
|
.mutation(async ({ ctx, input }) => {
|
|
const syncManager = requireProjectSyncManager(ctx);
|
|
return syncManager.syncProject(input.id);
|
|
}),
|
|
|
|
syncAllProjects: publicProcedure
|
|
.mutation(async ({ ctx }) => {
|
|
const syncManager = requireProjectSyncManager(ctx);
|
|
return syncManager.syncAllProjects();
|
|
}),
|
|
|
|
getProjectSyncStatus: publicProcedure
|
|
.input(z.object({ id: z.string().min(1) }))
|
|
.query(async ({ ctx, input }) => {
|
|
const syncManager = requireProjectSyncManager(ctx);
|
|
return syncManager.getSyncStatus(input.id);
|
|
}),
|
|
};
|
|
}
|