import { and, count, desc, inArray, isNull } from "drizzle-orm"; import { devices, timelineEvents } from "../db/schema"; import type { SyncDb } from "../sync/types"; import type { ClaudeMetadata, FileMetadata, GitMetadata, } from "../types/timeline"; import { isClaudeMetadata, isFileMetadata, isGitMetadata, } from "../types/timeline"; /** A logical grouping of unassigned timeline events (by file dir / project / remote). */ export interface UnassignedGroup { key: string; displayName: string; path: string; source: "file" | "claude" | "git" | "mixed"; count: number; firstSeen: Date | string; lastSeen: Date | string; deviceName?: string; samples: Array<{ id: string; timestamp: Date | string; preview: string }>; /** For Claude groups: a representative conversation file for viewing. */ sampleConversationFile?: string; } /** Aggregated unassigned-work payload returned by the `/api/unassigned` routes. */ export interface UnassignedWork { totalUnassigned: number; limitReached: boolean; fileGroups: UnassignedGroup[]; claudeGroups: UnassignedGroup[]; gitGroups: UnassignedGroup[]; paths: Array<{ path: string; count: number; source: UnassignedGroup["source"]; firstSeen: Date | string; lastSeen: Date | string; }>; canClassify: boolean; } /** Options for {@link getUnassignedWork} — the local-vs-hub context seam. */ export interface UnassignedWorkOptions { /** * Organization device scope (hub multi-tenant). `null`/`undefined` means no * device filter (desktop/local single-device). An **empty array** means the * caller has no devices in scope and yields an empty result — matching the * hub's `getTeamDeviceFilter` contract. */ deviceIds?: string[] | null; /** Max events to scan (prevents memory issues with huge datasets). */ maxEvents: number; /** Whether the caller may classify events (false when hosted / read-only). */ canClassify: boolean; } const EMPTY_WORK = (canClassify: boolean): UnassignedWork => ({ totalUnassigned: 0, limitReached: false, fileGroups: [], claudeGroups: [], gitGroups: [], paths: [], canClassify, }); /** * Group unassigned timeline events (no `projectRootId`) into file / Claude / git * buckets for the "Unassigned work" classification UI. * * Shared by the desktop and hub `GET /api/unassigned` routes so the grouping * logic has a single source of truth (OPL-2656). The only local-vs-hub * difference is the device-scope filter, expressed here via `deviceIds`. */ export async function getUnassignedWork( db: SyncDb, { deviceIds, maxEvents, canClassify }: UnassignedWorkOptions, ): Promise { // Hub: caller has no devices in scope → nothing to show. if (deviceIds && deviceIds.length === 0) { return EMPTY_WORK(canClassify); } // Build device lookup map for group display names. const deviceList = await db.select().from(devices); const deviceMap = new Map( deviceList.map((d: { id: string; name: string }) => [d.id, d.name]), ); // SECURITY: when a device scope is present, filter by it to prevent data leaks. const whereUnassigned = deviceIds ? and( isNull(timelineEvents.projectRootId), inArray(timelineEvents.deviceId, deviceIds), ) : isNull(timelineEvents.projectRootId); // True total of unassigned events (a cheap COUNT, not memory-bound). The // grouping scan below is capped at `maxEvents` for memory, but the reported // total must never be a capped undercount. const [totalRow] = await db .select({ total: count() }) .from(timelineEvents) .where(whereUnassigned); const totalUnassigned = totalRow?.total ?? 0; // Query unassigned events with limit, ordered most-recent-first, for grouping. const events = await db .select({ id: timelineEvents.id, timestamp: timelineEvents.timestamp, source: timelineEvents.source, metadata: timelineEvents.metadata, deviceId: timelineEvents.deviceId, }) .from(timelineEvents) .where(whereUnassigned) .orderBy(desc(timelineEvents.timestamp)) .limit(maxEvents); const groupMap = new Map(); for (const event of events) { let groupKey = "unknown"; let displayName = "Unknown"; let path = "unknown"; let preview = ""; let conversationFile: string | undefined; if ( event.source === "file" && event.metadata && isFileMetadata(event.metadata) ) { const fileMeta = event.metadata as FileMetadata; const filePath = fileMeta.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("/"); displayName = "code/ (root files)"; groupKey = `file:${path}`; } else { path = pathParts.slice(0, codeIndex + 2).join("/"); displayName = pathParts[codeIndex + 1]; groupKey = `file:${path}`; } } else { path = pathParts.slice(0, -1).join("/"); displayName = pathParts[pathParts.length - 2] || "unknown"; groupKey = `file:${path}`; } preview = pathParts[pathParts.length - 1]; } else if ( event.source === "claude" && event.metadata && isClaudeMetadata(event.metadata) ) { const claudeMeta = event.metadata as ClaudeMetadata; // Group by projectPath (the actual folder) instead of sessionId for better classification const projectPath = claudeMeta.projectPath || "unknown"; groupKey = `claude:${projectPath}`; // Extract project name from path (e.g., /Users/satchmo/code/scribe -> scribe) displayName = projectPath.split("/").pop() || "Claude Session"; path = projectPath; preview = claudeMeta.messagePreview?.substring(0, 100) || "(no preview)"; // Track conversation file for transcript viewing conversationFile = claudeMeta.conversationFile; } else if ( event.source === "git" && event.metadata && isGitMetadata(event.metadata) ) { const gitMeta = event.metadata as GitMetadata; const remote = gitMeta.gitRemote || "unknown-remote"; groupKey = `git:${remote}`; displayName = remote.split("/").slice(-2).join("/"); path = remote; preview = gitMeta.message?.substring(0, 100) || "(no message)"; } else { groupKey = `${event.source}:unknown`; displayName = `Unknown ${event.source}`; path = "unknown"; preview = JSON.stringify(event.metadata)?.substring(0, 50) || ""; } const existing = groupMap.get(groupKey); if (existing) { existing.count++; if (event.timestamp < existing.firstSeen) existing.firstSeen = event.timestamp; if (event.timestamp > existing.lastSeen) existing.lastSeen = event.timestamp; // Only add samples that have actual preview content if ( existing.samples.length < 5 && preview && preview !== "(no preview)" ) { existing.samples.push({ id: event.id, timestamp: event.timestamp, preview, }); } } else { const deviceName = event.deviceId ? deviceMap.get(event.deviceId) : undefined; // Only include initial sample if it has actual content const initialSamples = preview && preview !== "(no preview)" ? [{ id: event.id, timestamp: event.timestamp, preview }] : []; groupMap.set(groupKey, { key: groupKey, displayName, path, source: event.source as "file" | "claude" | "git", count: 1, firstSeen: event.timestamp, lastSeen: event.timestamp, deviceName, samples: initialSamples, // Store first conversation file for Claude groups (for transcript viewing) sampleConversationFile: conversationFile, }); } } const groups = Array.from(groupMap.values()).sort( (a, b) => b.count - a.count, ); return { totalUnassigned, // The grouping scan (not the total) was capped: group counts/details cover // the most-recent `maxEvents` unassigned events, but `totalUnassigned` above // is the true count. limitReached: events.length >= maxEvents, fileGroups: groups.filter((g) => g.source === "file"), claudeGroups: groups.filter((g) => g.source === "claude"), gitGroups: groups.filter((g) => g.source === "git"), paths: groups.map((g) => ({ path: g.path, count: g.count, source: g.source, firstSeen: g.firstSeen, lastSeen: g.lastSeen, })), canClassify, }; }