import { createId } from "@paralleldrive/cuid2"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { CapabilityDb } from "../capabilities/context"; import { projectRoots, projects, timelineEvents } from "../db/schema"; import { type ClaudeMetadata, type FileMetadata, type GitMetadata, isClaudeMetadata, isFileMetadata, isGitMetadata, } from "../types/timeline"; import { getColorForName } from "../utils/colors"; /** Batch id lists for `IN (...)` writes — well under SQLite's variable limit, * turning one query-per-event into a handful of queries for a large group. */ const ID_BATCH_SIZE = 500; function chunk(arr: T[], size: number): T[][] { const out: T[][] = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out; } export type MapUnassignedAction = "create" | "assign" | "delete"; export interface MapUnassignedParams { /** Group key from getUnassignedWork: "file:" | "claude:" | "git:". */ groupKey: string; /** The group's path or git remote (from the group's `path`). */ path: string; action: MapUnassignedAction; /** Required for `assign` — the existing project to map to. */ projectId?: string; /** Name for the project created by `create`. */ projectName?: string; projectColor?: string; /** Org to scope the project/root to (hub). Null/undefined = local single-tenant. */ organizationId?: string | null; /** Restrict which unassigned events are visible (hub = org member devices). */ deviceIds?: string[] | null; /** Mark the root as a human decision so device sync won't overwrite it (hub). */ manuallyAssigned?: boolean; } export interface MapUnassignedResult { action: MapUnassignedAction; projectId?: string; projectRootId?: string; eventsUpdated?: number; eventsDeleted?: number; matched: number; } /** * Map (or delete) a group of unassigned timeline events. Shared by the local * `/api/unassigned/classify` route and the `mapUnassignedWork` chat capability * so the two never drift. * * Mapping writes a `project_roots` row (path -> project) and re-points the * group's currently-loose events at it. Because the timeline resolves an event's * project via a live join through `project_roots.projectId`, that single root * both re-buckets these existing events and — via the scan-time path/remote * matcher — attaches future events for the same repo/folder automatically. */ export async function mapUnassignedGroup( db: CapabilityDb, params: MapUnassignedParams, ): Promise { const { groupKey, path, action, projectId, projectName, projectColor, organizationId, deviceIds, manuallyAssigned, } = params; const [sourceType, ...keyParts] = groupKey.split(":"); const keyValue = keyParts.join(":"); const conds = [isNull(timelineEvents.projectRootId)]; if (deviceIds) { // Org member with no devices in scope → nothing they can map. if (deviceIds.length === 0) { return { error: "No matching unassigned events found." }; } conds.push(inArray(timelineEvents.deviceId, deviceIds)); } const unassignedEvents = await db .select() .from(timelineEvents) .where(and(...conds)); const matchingEvents = unassignedEvents.filter((event) => { if ( sourceType === "file" && event.source === "file" && event.metadata && isFileMetadata(event.metadata) ) { const filePath = (event.metadata as FileMetadata).path; const pathParts = filePath.split("/"); const codeIndex = pathParts.indexOf("code"); if (codeIndex >= 0 && pathParts.length > codeIndex + 1) { if ( pathParts.length === codeIndex + 2 && path === pathParts.slice(0, codeIndex + 1).join("/") ) { return true; } return pathParts.slice(0, codeIndex + 2).join("/") === path; } return false; } if ( sourceType === "claude" && event.source === "claude" && event.metadata && isClaudeMetadata(event.metadata) ) { return ( ((event.metadata as ClaudeMetadata).projectPath || "unknown") === keyValue ); } if ( sourceType === "git" && event.source === "git" && event.metadata && isGitMetadata(event.metadata) ) { return (event.metadata as GitMetadata).gitRemote === keyValue; } return false; }); if (matchingEvents.length === 0) { return { error: "No matching unassigned events found — they may already be assigned.", }; } if (action === "delete") { const ids = matchingEvents.map((e) => e.id); for (const batch of chunk(ids, ID_BATCH_SIZE)) { await db.delete(timelineEvents).where(inArray(timelineEvents.id, batch)); } return { action, eventsDeleted: ids.length, matched: matchingEvents.length, }; } const folderName = path.split("/").pop() || path; // For a git group, stamp the remote on the root so the scan-time git matcher // (which reconnects by normalized remote, not path) attaches future events. const gitRemote = sourceType === "git" ? keyValue : undefined; let targetProjectId: string; let targetProjectRootId: string; if (action === "create") { const displayName = projectName || folderName; const [newProject] = await db .insert(projects) .values({ id: createId(), name: displayName, color: projectColor || getColorForName(displayName), organizationId: organizationId ?? null, }) .returning(); targetProjectId = newProject.id; const [newRoot] = await db .insert(projectRoots) .values({ id: createId(), path, projectId: targetProjectId, detectedName: folderName, manuallyAssigned: manuallyAssigned ?? false, ...(gitRemote ? { gitRemote } : {}), }) .returning(); targetProjectRootId = newRoot.id; } else { if (!projectId) { return { error: "projectId is required to assign to an existing project.", }; } const projConds = [eq(projects.id, projectId)]; if (organizationId) { projConds.push(eq(projects.organizationId, organizationId)); } const existingProject = await db .select() .from(projects) .where(and(...projConds)); if (existingProject.length === 0) { return { error: `Project ${projectId} not found in your organization.` }; } targetProjectId = projectId; const existingRoot = await db .select() .from(projectRoots) .where(eq(projectRoots.path, path)); if (existingRoot.length > 0) { await db .update(projectRoots) .set({ projectId: targetProjectId, manuallyAssigned: manuallyAssigned ?? existingRoot[0].manuallyAssigned, }) .where(eq(projectRoots.id, existingRoot[0].id)); targetProjectRootId = existingRoot[0].id; } else { const [newRoot] = await db .insert(projectRoots) .values({ id: createId(), path, projectId: targetProjectId, detectedName: folderName, manuallyAssigned: manuallyAssigned ?? false, ...(gitRemote ? { gitRemote } : {}), }) .returning(); targetProjectRootId = newRoot.id; } } const ids = matchingEvents.map((e) => e.id); for (const batch of chunk(ids, ID_BATCH_SIZE)) { await db .update(timelineEvents) .set({ projectRootId: targetProjectRootId }) .where(inArray(timelineEvents.id, batch)); } const eventsUpdated = ids.length; return { action, projectId: targetProjectId, projectRootId: targetProjectRootId, eventsUpdated, matched: matchingEvents.length, }; }