/** * Projects CRUD Operations * * HTTP endpoints for project management: * - List all projects (per-user) * - Create new project (or return an existing project already joined by the user) * - Get project by ID * - Update project info * - Delete project (remove user association, cleanup if orphaned) */ import { t } from 'elysia'; import { createRouter } from '$shared/utils/ws-server'; import { initializeDatabase } from '../../database'; import { projectQueries, sessionQueries, snapshotQueries } from '../../database/queries'; import { ws } from '$backend/utils/ws'; import { snapshotService } from '../../snapshot/snapshot-service'; import { blobStore } from '../../snapshot/blob-store'; import { streamManager } from '../../chat/stream-manager'; import { cleanupProjectSessions } from '../../terminal/ptykit'; import { broadcastPresence } from '../projects/status'; import { debug } from '$shared/utils/logger'; import { requireProjectAccess } from '../access'; import { runProjectCleanups } from '../../projects/project-cleanup-registry'; import '../../projects/project-cleanup'; export const crudHandler = createRouter() // List all projects for the current user .http('projects:list', { data: t.Object({}), response: t.Array(t.Any()) }, async ({ conn }) => { await initializeDatabase(); const userId = ws.getUserId(conn); const projects = projectQueries.getAllForUser(userId); return projects; }) // Create new project (or return an existing project already joined by the user) .http('projects:create', { data: t.Object({ name: t.String({ minLength: 1 }), path: t.String({ minLength: 1 }) }), response: t.Any() }, async ({ data, conn }) => { await initializeDatabase(); const userId = ws.getUserId(conn); const { name, path } = data; // Check if project with this path already exists. If so, ensure the // current user is joined to it and return the existing record. This // covers re-adding a project that was previously removed (mode='remove') // which preserves the project row for session restoration. const existing = projectQueries.getByPath(path); if (existing) { if (!projectQueries.userHasProject(userId, existing.id)) { projectQueries.addUserProject(userId, existing.id); } return existing; } const now = new Date().toISOString(); const project = projectQueries.create({ name, path, created_at: now, last_opened_at: now }); // Associate the project with the creating user projectQueries.addUserProject(userId, project.id); return project; }) // Get project by ID .http('projects:get', { data: t.Object({ id: t.String({ minLength: 1 }) }), response: t.Any() }, async ({ data, conn }) => { requireProjectAccess(conn, data.id); // Update last_opened_at when getting project projectQueries.updateLastOpened(data.id); const updatedProject = projectQueries.getById(data.id); return updatedProject; }) // Delete project (remove from list or full delete with sessions) .http('projects:delete', { data: t.Object({ id: t.String({ minLength: 1 }), mode: t.Optional(t.Union([t.Literal('remove'), t.Literal('full')])) }), response: t.Object({ id: t.String(), deleted: t.Boolean() }) }, async ({ data, conn }) => { const userId = ws.getUserId(conn); requireProjectAccess(conn, data.id); const mode = data.mode ?? 'remove'; // Clean up terminal (PTY) sessions for this project const cleanedStreams = cleanupProjectSessions(data.id); debug.log('project', `Cleaned up ${cleanedStreams} terminal sessions for project ${data.id}`); if (mode === 'full') { // Full delete: remove sessions with blob cleanup, then the project itself const sessions = sessionQueries.getByProjectId(data.id); if (sessions.length > 0) { // Cancel active chat streams await Promise.all( sessions.map(s => streamManager.cleanupSessionStreams(s.id).catch((err) => { debug.warn('project', `Stream cleanup error for session ${s.id} during project delete:`, err); })) ); // Collect blob hashes before deleting const baselineHashes = new Set(); for (const s of sessions) { for (const h of snapshotService.getSessionBaselineHashes(s.id)) { baselineHashes.add(h); } } const allSnapshots = snapshotQueries.getAllByProjectId(data.id); const deltaHashes = snapshotQueries.collectBlobHashes(allSnapshots); // Clear in-memory baselines for (const s of sessions) { snapshotService.clearSessionBaseline(s.id); } // Delete all sessions and related DB data const deletedIds = sessionQueries.deleteAllByProjectId(data.id); // GC orphaned blobs const allBlobsOnDisk = await blobStore.scanAllBlobHashes(); const stillReferencedByDB = snapshotQueries.getAllReferencedBlobHashes(); const stillReferencedByMemory = snapshotService.getAllBaselineHashes(); const blobsToDelete = [...allBlobsOnDisk].filter( h => !stillReferencedByDB.has(h) && !stillReferencedByMemory.has(h) ); if (blobsToDelete.length > 0) { const deleted = await blobStore.deleteBlobs(blobsToDelete); debug.log('project', `Cleaned up ${deleted}/${blobsToDelete.length} orphaned blobs`); } // Broadcast session deletions for (const sessionId of deletedIds) { ws.emit.project(data.id, 'sessions:session-deleted', { sessionId, projectId: data.id }); } } } // Remove user's association with the project projectQueries.removeUserProject(userId, data.id); // In full mode, delete the project record if no users remain // In remove mode, keep the project record so sessions can be restored if (mode === 'full') { const remainingUsers = projectQueries.getUserCountForProject(data.id); if (remainingUsers === 0) { await runProjectCleanups(data.id); projectQueries.deleteProject(data.id); } } broadcastPresence().catch((err) => { debug.warn('project', 'Presence broadcast error after project delete:', err); }); return { id: data.id, deleted: true }; });