mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Add project mention system using project:// URI scheme with optional color parameter. Mentions render as colored pill chips in markdown bodies and the WYSIWYG editor. Autocomplete in editors shows both agents and projects. Server extracts mentioned project IDs from issue content and returns them in the issue detail response. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
559 lines
18 KiB
TypeScript
559 lines
18 KiB
TypeScript
import { and, asc, desc, eq, inArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclip/db";
|
|
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;
|
|
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
|
type CreateWorkspaceInput = {
|
|
name?: string | null;
|
|
cwd?: string | null;
|
|
repoUrl?: string | null;
|
|
repoRef?: string | null;
|
|
metadata?: Record<string, unknown> | null;
|
|
isPrimary?: boolean;
|
|
};
|
|
type UpdateWorkspaceInput = Partial<CreateWorkspaceInput>;
|
|
|
|
interface ProjectWithGoals extends ProjectRow {
|
|
goalIds: string[];
|
|
goals: ProjectGoalRef[];
|
|
workspaces: ProjectWorkspace[];
|
|
primaryWorkspace: ProjectWorkspace | null;
|
|
}
|
|
|
|
/** Batch-load goal refs for a set of projects. */
|
|
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
|
if (rows.length === 0) return [];
|
|
|
|
const projectIds = rows.map((r) => r.id);
|
|
|
|
// Fetch join rows + goal titles in one query
|
|
const links = await db
|
|
.select({
|
|
projectId: projectGoals.projectId,
|
|
goalId: projectGoals.goalId,
|
|
goalTitle: goals.title,
|
|
})
|
|
.from(projectGoals)
|
|
.innerJoin(goals, eq(projectGoals.goalId, goals.id))
|
|
.where(inArray(projectGoals.projectId, projectIds));
|
|
|
|
const map = new Map<string, ProjectGoalRef[]>();
|
|
for (const link of links) {
|
|
let arr = map.get(link.projectId);
|
|
if (!arr) {
|
|
arr = [];
|
|
map.set(link.projectId, arr);
|
|
}
|
|
arr.push({ id: link.goalId, title: link.goalTitle });
|
|
}
|
|
|
|
return rows.map((r) => {
|
|
const g = map.get(r.id) ?? [];
|
|
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),
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Sync the project_goals join table for a single project. */
|
|
async function syncGoalLinks(db: Db, projectId: string, companyId: string, goalIds: string[]) {
|
|
// Delete existing links
|
|
await db.delete(projectGoals).where(eq(projectGoals.projectId, projectId));
|
|
|
|
// Insert new links
|
|
if (goalIds.length > 0) {
|
|
await db.insert(projectGoals).values(
|
|
goalIds.map((goalId) => ({ projectId, goalId, companyId })),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Resolve goalIds from input, handling the legacy goalId field. */
|
|
function resolveGoalIds(data: { goalIds?: string[]; goalId?: string | null }): string[] | undefined {
|
|
if (data.goalIds !== undefined) return data.goalIds;
|
|
if (data.goalId !== undefined) {
|
|
return data.goalId ? [data.goalId] : [];
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function normalizeWorkspaceCwd(value: unknown): string | null {
|
|
const cwd = readNonEmptyString(value);
|
|
if (!cwd) return null;
|
|
return cwd === REPO_ONLY_CWD_SENTINEL ? null : cwd;
|
|
}
|
|
|
|
function deriveNameFromCwd(cwd: string): string {
|
|
const normalized = cwd.replace(/[\\/]+$/, "");
|
|
const segments = normalized.split(/[\\/]/).filter(Boolean);
|
|
return segments[segments.length - 1] ?? "Local folder";
|
|
}
|
|
|
|
function deriveNameFromRepoUrl(repoUrl: string): string {
|
|
try {
|
|
const url = new URL(repoUrl);
|
|
const cleanedPath = url.pathname.replace(/\/+$/, "");
|
|
const lastSegment = cleanedPath.split("/").filter(Boolean).pop() ?? "";
|
|
const noGitSuffix = lastSegment.replace(/\.git$/i, "");
|
|
return noGitSuffix || repoUrl;
|
|
} catch {
|
|
return repoUrl;
|
|
}
|
|
}
|
|
|
|
function deriveWorkspaceName(input: {
|
|
name?: string | null;
|
|
cwd?: string | null;
|
|
repoUrl?: string | null;
|
|
}) {
|
|
const explicit = readNonEmptyString(input.name);
|
|
if (explicit) return explicit;
|
|
|
|
const cwd = readNonEmptyString(input.cwd);
|
|
if (cwd) return deriveNameFromCwd(cwd);
|
|
|
|
const repoUrl = readNonEmptyString(input.repoUrl);
|
|
if (repoUrl) return deriveNameFromRepoUrl(repoUrl);
|
|
|
|
return "Workspace";
|
|
}
|
|
|
|
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));
|
|
const withGoals = await attachGoals(db, rows);
|
|
return attachWorkspaces(db, withGoals);
|
|
},
|
|
|
|
listByIds: async (companyId: string, ids: string[]): Promise<ProjectWithGoals[]> => {
|
|
const dedupedIds = [...new Set(ids)];
|
|
if (dedupedIds.length === 0) return [];
|
|
const rows = await db
|
|
.select()
|
|
.from(projects)
|
|
.where(and(eq(projects.companyId, companyId), inArray(projects.id, dedupedIds)));
|
|
const withGoals = await attachGoals(db, rows);
|
|
const withWorkspaces = await attachWorkspaces(db, withGoals);
|
|
const byId = new Map(withWorkspaces.map((project) => [project.id, project]));
|
|
return dedupedIds.map((id) => byId.get(id)).filter((project): project is ProjectWithGoals => Boolean(project));
|
|
},
|
|
|
|
getById: async (id: string): Promise<ProjectWithGoals | null> => {
|
|
const row = await db
|
|
.select()
|
|
.from(projects)
|
|
.where(eq(projects.id, id))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
const [withGoals] = await attachGoals(db, [row]);
|
|
if (!withGoals) return null;
|
|
const [enriched] = await attachWorkspaces(db, [withGoals]);
|
|
return enriched ?? null;
|
|
},
|
|
|
|
create: async (
|
|
companyId: string,
|
|
data: Omit<typeof projects.$inferInsert, "companyId"> & { goalIds?: string[] },
|
|
): Promise<ProjectWithGoals> => {
|
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
|
|
|
// Auto-assign a color from the palette if none provided
|
|
if (!projectData.color) {
|
|
const existing = await db.select({ color: projects.color }).from(projects).where(eq(projects.companyId, companyId));
|
|
const usedColors = new Set(existing.map((r) => r.color).filter(Boolean));
|
|
const nextColor = PROJECT_COLORS.find((c) => !usedColors.has(c)) ?? PROJECT_COLORS[existing.length % PROJECT_COLORS.length];
|
|
projectData.color = nextColor;
|
|
}
|
|
|
|
// Also write goalId to the legacy column (first goal or null)
|
|
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
|
|
|
const row = await db
|
|
.insert(projects)
|
|
.values({ ...projectData, goalId: legacyGoalId, companyId })
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
if (ids && ids.length > 0) {
|
|
await syncGoalLinks(db, row.id, companyId, ids);
|
|
}
|
|
|
|
const [withGoals] = await attachGoals(db, [row]);
|
|
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
|
return enriched!;
|
|
},
|
|
|
|
update: async (
|
|
id: string,
|
|
data: Partial<typeof projects.$inferInsert> & { goalIds?: string[] },
|
|
): Promise<ProjectWithGoals | null> => {
|
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
|
|
|
// Keep legacy goalId column in sync
|
|
const updates: Partial<typeof projects.$inferInsert> = {
|
|
...projectData,
|
|
updatedAt: new Date(),
|
|
};
|
|
if (ids !== undefined) {
|
|
updates.goalId = ids.length > 0 ? ids[0] : null;
|
|
}
|
|
|
|
const row = await db
|
|
.update(projects)
|
|
.set(updates)
|
|
.where(eq(projects.id, id))
|
|
.returning()
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!row) return null;
|
|
|
|
if (ids !== undefined) {
|
|
await syncGoalLinks(db, id, row.companyId, ids);
|
|
}
|
|
|
|
const [withGoals] = await attachGoals(db, [row]);
|
|
const [enriched] = withGoals ? await attachWorkspaces(db, [withGoals]) : [];
|
|
return enriched ?? null;
|
|
},
|
|
|
|
remove: (id: string) =>
|
|
db
|
|
.delete(projects)
|
|
.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: CreateWorkspaceInput,
|
|
): 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 cwd = normalizeWorkspaceCwd(data.cwd);
|
|
const repoUrl = readNonEmptyString(data.repoUrl);
|
|
if (!cwd && !repoUrl) return null;
|
|
const name = deriveWorkspaceName({
|
|
name: data.name,
|
|
cwd,
|
|
repoUrl,
|
|
});
|
|
|
|
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,
|
|
cwd: cwd ?? null,
|
|
repoUrl: repoUrl ?? null,
|
|
repoRef: readNonEmptyString(data.repoRef),
|
|
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: UpdateWorkspaceInput,
|
|
): 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 nextCwd =
|
|
data.cwd !== undefined
|
|
? normalizeWorkspaceCwd(data.cwd)
|
|
: normalizeWorkspaceCwd(existing.cwd);
|
|
const nextRepoUrl =
|
|
data.repoUrl !== undefined
|
|
? readNonEmptyString(data.repoUrl)
|
|
: readNonEmptyString(existing.repoUrl);
|
|
if (!nextCwd && !nextRepoUrl) return null;
|
|
|
|
const patch: Partial<typeof projectWorkspaces.$inferInsert> = {
|
|
updatedAt: new Date(),
|
|
};
|
|
if (data.name !== undefined) patch.name = deriveWorkspaceName({ name: data.name, cwd: nextCwd, repoUrl: nextRepoUrl });
|
|
if (data.name === undefined && (data.cwd !== undefined || data.repoUrl !== undefined)) {
|
|
patch.name = deriveWorkspaceName({ cwd: nextCwd, repoUrl: nextRepoUrl });
|
|
}
|
|
if (data.cwd !== undefined) patch.cwd = nextCwd ?? null;
|
|
if (data.repoUrl !== undefined) patch.repoUrl = nextRepoUrl ?? null;
|
|
if (data.repoRef !== undefined) patch.repoRef = readNonEmptyString(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;
|
|
},
|
|
};
|
|
}
|