feat: add project workspaces (DB, API, service, and UI)

New project_workspaces table with primary workspace designation.
Full CRUD routes, service with auto-primary promotion on delete,
workspace management UI in project properties panel, and workspace
data included in project/issue ancestor responses.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-25 08:38:46 -06:00
parent 6f7172c028
commit 29af525167
10 changed files with 6273 additions and 15 deletions

View File

@@ -1,6 +1,11 @@
import { Router } from "express";
import type { Db } from "@paperclip/db";
import { createProjectSchema, updateProjectSchema } from "@paperclip/shared";
import {
createProjectSchema,
createProjectWorkspaceSchema,
updateProjectSchema,
updateProjectWorkspaceSchema,
} from "@paperclip/shared";
import { validate } from "../middleware/validate.js";
import { projectService, logActivity } from "../services/index.js";
import { assertCompanyAccess, getActorInfo } from "./authz.js";
@@ -74,6 +79,122 @@ export function projectRoutes(db: Db) {
res.json(project);
});
router.get("/projects/:id/workspaces", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspaces = await svc.listWorkspaces(id);
res.json(workspaces);
});
router.post("/projects/:id/workspaces", validate(createProjectWorkspaceSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspace = await svc.createWorkspace(id, req.body);
if (!workspace) {
res.status(404).json({ error: "Project not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.workspace_created",
entityType: "project",
entityId: id,
details: {
workspaceId: workspace.id,
name: workspace.name,
cwd: workspace.cwd,
isPrimary: workspace.isPrimary,
},
});
res.status(201).json(workspace);
});
router.patch(
"/projects/:id/workspaces/:workspaceId",
validate(updateProjectWorkspaceSchema),
async (req, res) => {
const id = req.params.id as string;
const workspaceId = req.params.workspaceId as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspace = await svc.updateWorkspace(id, workspaceId, req.body);
if (!workspace) {
res.status(404).json({ error: "Project workspace not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.workspace_updated",
entityType: "project",
entityId: id,
details: {
workspaceId: workspace.id,
changedKeys: Object.keys(req.body).sort(),
},
});
res.json(workspace);
},
);
router.delete("/projects/:id/workspaces/:workspaceId", async (req, res) => {
const id = req.params.id as string;
const workspaceId = req.params.workspaceId as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Project not found" });
return;
}
assertCompanyAccess(req, existing.companyId);
const workspace = await svc.removeWorkspace(id, workspaceId);
if (!workspace) {
res.status(404).json({ error: "Project workspace not found" });
return;
}
const actor = getActorInfo(req);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.workspace_deleted",
entityType: "project",
entityId: id,
details: {
workspaceId: workspace.id,
name: workspace.name,
},
});
res.json(workspace);
});
router.delete("/projects/:id", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);

View File

@@ -1,13 +1,16 @@
import { eq, inArray } from "drizzle-orm";
import { and, asc, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { projects, projectGoals, goals } from "@paperclip/db";
import { PROJECT_COLORS, type ProjectGoalRef } from "@paperclip/shared";
import { projects, projectGoals, goals, projectWorkspaces } from "@paperclip/db";
import { PROJECT_COLORS, type ProjectGoalRef, type ProjectWorkspace } from "@paperclip/shared";
type ProjectRow = typeof projects.$inferSelect;
type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect;
interface ProjectWithGoals extends ProjectRow {
goalIds: string[];
goals: ProjectGoalRef[];
workspaces: ProjectWorkspace[];
primaryWorkspace: ProjectWorkspace | null;
}
/** Batch-load goal refs for a set of projects. */
@@ -39,7 +42,61 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
return rows.map((r) => {
const g = map.get(r.id) ?? [];
return { ...r, goalIds: g.map((x) => x.id), goals: g };
return { ...r, goalIds: g.map((x) => x.id), goals: g } as ProjectWithGoals;
});
}
function toWorkspace(row: ProjectWorkspaceRow): ProjectWorkspace {
return {
id: row.id,
companyId: row.companyId,
projectId: row.projectId,
name: row.name,
cwd: row.cwd,
repoUrl: row.repoUrl ?? null,
repoRef: row.repoRef ?? null,
metadata: (row.metadata as Record<string, unknown> | null) ?? null,
isPrimary: row.isPrimary,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function pickPrimaryWorkspace(rows: ProjectWorkspaceRow[]): ProjectWorkspace | null {
if (rows.length === 0) return null;
const explicitPrimary = rows.find((row) => row.isPrimary);
return toWorkspace(explicitPrimary ?? rows[0]);
}
/** Batch-load workspace refs for a set of projects. */
async function attachWorkspaces(db: Db, rows: ProjectWithGoals[]): Promise<ProjectWithGoals[]> {
if (rows.length === 0) return [];
const projectIds = rows.map((r) => r.id);
const workspaceRows = await db
.select()
.from(projectWorkspaces)
.where(inArray(projectWorkspaces.projectId, projectIds))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
const map = new Map<string, ProjectWorkspaceRow[]>();
for (const row of workspaceRows) {
let arr = map.get(row.projectId);
if (!arr) {
arr = [];
map.set(row.projectId, arr);
}
arr.push(row);
}
return rows.map((row) => {
const projectWorkspaceRows = map.get(row.id) ?? [];
const workspaces = projectWorkspaceRows.map(toWorkspace);
return {
...row,
workspaces,
primaryWorkspace: pickPrimaryWorkspace(projectWorkspaceRows),
};
});
}
@@ -65,11 +122,42 @@ function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): s
return undefined;
}
async function ensureSinglePrimaryWorkspace(
dbOrTx: any,
input: {
companyId: string;
projectId: string;
keepWorkspaceId: string;
},
) {
await dbOrTx
.update(projectWorkspaces)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(projectWorkspaces.companyId, input.companyId),
eq(projectWorkspaces.projectId, input.projectId),
),
);
await dbOrTx
.update(projectWorkspaces)
.set({ isPrimary: true, updatedAt: new Date() })
.where(
and(
eq(projectWorkspaces.companyId, input.companyId),
eq(projectWorkspaces.projectId, input.projectId),
eq(projectWorkspaces.id, input.keepWorkspaceId),
),
);
}
export function projectService(db: Db) {
return {
list: async (companyId: string): Promise<ProjectWithGoals[]> => {
const rows = await db.select().from(projects).where(eq(projects.companyId, companyId));
return attachGoals(db, rows);
const withGoals = await attachGoals(db, rows);
return attachWorkspaces(db, withGoals);
},
getById: async (id: string): Promise<ProjectWithGoals | null> => {
@@ -79,8 +167,10 @@ export function projectService(db: Db) {
.where(eq(projects.id, id))
.then((rows) => rows[0] ?? null);
if (!row) return null;
const [enriched] = await attachGoals(db, [row]);
return enriched;
const [withGoals] = await attachGoals(db, [row]);
if (!withGoals) return null;
const [enriched] = await attachWorkspaces(db, [withGoals]);
return enriched ?? null;
},
create: async (
@@ -111,8 +201,9 @@ export function projectService(db: Db) {
await syncGoalLinks(db, row.id, companyId, ids);
}
const [enriched] = await attachGoals(db, [row]);
return enriched;
const [withGoals] = await attachGoals(db, [row]);
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
return enriched!;
},
update: async (
@@ -143,8 +234,9 @@ export function projectService(db: Db) {
await syncGoalLinks(db, id, row.companyId, ids);
}
const [enriched] = await attachGoals(db, [row]);
return enriched;
const [withGoals] = await attachGoals(db, [row]);
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
return enriched ?? null;
},
remove: (id: string) =>
@@ -153,5 +245,223 @@ export function projectService(db: Db) {
.where(eq(projects.id, id))
.returning()
.then((rows) => rows[0] ?? null),
listWorkspaces: async (projectId: string): Promise<ProjectWorkspace[]> => {
const rows = await db
.select()
.from(projectWorkspaces)
.where(eq(projectWorkspaces.projectId, projectId))
.orderBy(desc(projectWorkspaces.isPrimary), asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id));
return rows.map(toWorkspace);
},
createWorkspace: async (
projectId: string,
data: Omit<typeof projectWorkspaces.$inferInsert, "projectId" | "companyId">,
): Promise<ProjectWorkspace | null> => {
const project = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.then((rows) => rows[0] ?? null);
if (!project) return null;
const existing = await db
.select()
.from(projectWorkspaces)
.where(eq(projectWorkspaces.projectId, projectId))
.orderBy(asc(projectWorkspaces.createdAt))
.then((rows) => rows);
const shouldBePrimary = data.isPrimary === true || existing.length === 0;
const created = await db.transaction(async (tx) => {
if (shouldBePrimary) {
await tx
.update(projectWorkspaces)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(projectWorkspaces.companyId, project.companyId),
eq(projectWorkspaces.projectId, projectId),
),
);
}
const row = await tx
.insert(projectWorkspaces)
.values({
companyId: project.companyId,
projectId,
name: data.name,
cwd: data.cwd,
repoUrl: data.repoUrl ?? null,
repoRef: data.repoRef ?? null,
metadata: (data.metadata as Record<string, unknown> | null | undefined) ?? null,
isPrimary: shouldBePrimary,
})
.returning()
.then((rows) => rows[0] ?? null);
return row;
});
return created ? toWorkspace(created) : null;
},
updateWorkspace: async (
projectId: string,
workspaceId: string,
data: Partial<typeof projectWorkspaces.$inferInsert>,
): Promise<ProjectWorkspace | null> => {
const existing = await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.id, workspaceId),
eq(projectWorkspaces.projectId, projectId),
),
)
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
updatedAt: new Date(),
};
if (data.name !== undefined) patch.name = data.name;
if (data.cwd !== undefined) patch.cwd = data.cwd;
if (data.repoUrl !== undefined) patch.repoUrl = data.repoUrl;
if (data.repoRef !== undefined) patch.repoRef = data.repoRef;
if (data.metadata !== undefined) patch.metadata = data.metadata;
const updated = await db.transaction(async (tx) => {
if (data.isPrimary === true) {
await tx
.update(projectWorkspaces)
.set({ isPrimary: false, updatedAt: new Date() })
.where(
and(
eq(projectWorkspaces.companyId, existing.companyId),
eq(projectWorkspaces.projectId, projectId),
),
);
patch.isPrimary = true;
} else if (data.isPrimary === false) {
patch.isPrimary = false;
}
const row = await tx
.update(projectWorkspaces)
.set(patch)
.where(eq(projectWorkspaces.id, workspaceId))
.returning()
.then((rows) => rows[0] ?? null);
if (!row) return null;
if (row.isPrimary) return row;
const hasPrimary = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, row.companyId),
eq(projectWorkspaces.projectId, row.projectId),
eq(projectWorkspaces.isPrimary, true),
),
)
.then((rows) => rows[0] ?? null);
if (!hasPrimary) {
const nextPrimaryCandidate = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, row.companyId),
eq(projectWorkspaces.projectId, row.projectId),
eq(projectWorkspaces.id, row.id),
),
)
.then((rows) => rows[0] ?? null);
const alternateCandidate = await tx
.select({ id: projectWorkspaces.id })
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, row.companyId),
eq(projectWorkspaces.projectId, row.projectId),
),
)
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
.then((rows) => rows.find((candidate) => candidate.id !== row.id) ?? null);
await ensureSinglePrimaryWorkspace(tx, {
companyId: row.companyId,
projectId: row.projectId,
keepWorkspaceId: alternateCandidate?.id ?? nextPrimaryCandidate?.id ?? row.id,
});
const refreshed = await tx
.select()
.from(projectWorkspaces)
.where(eq(projectWorkspaces.id, row.id))
.then((rows) => rows[0] ?? row);
return refreshed;
}
return row;
});
return updated ? toWorkspace(updated) : null;
},
removeWorkspace: async (projectId: string, workspaceId: string): Promise<ProjectWorkspace | null> => {
const existing = await db
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.id, workspaceId),
eq(projectWorkspaces.projectId, projectId),
),
)
.then((rows) => rows[0] ?? null);
if (!existing) return null;
const removed = await db.transaction(async (tx) => {
const row = await tx
.delete(projectWorkspaces)
.where(eq(projectWorkspaces.id, workspaceId))
.returning()
.then((rows) => rows[0] ?? null);
if (!row) return null;
if (!row.isPrimary) return row;
const next = await tx
.select()
.from(projectWorkspaces)
.where(
and(
eq(projectWorkspaces.companyId, row.companyId),
eq(projectWorkspaces.projectId, row.projectId),
),
)
.orderBy(asc(projectWorkspaces.createdAt), asc(projectWorkspaces.id))
.limit(1)
.then((rows) => rows[0] ?? null);
if (next) {
await ensureSinglePrimaryWorkspace(tx, {
companyId: row.companyId,
projectId: row.projectId,
keepWorkspaceId: next.id,
});
}
return row;
});
return removed ? toWorkspace(removed) : null;
},
};
}