From cd764c07a7fcf39b4efee9f1f3065bb6c8e7dfdd Mon Sep 17 00:00:00 2001 From: Jasper Van Date: Thu, 9 Apr 2026 17:55:09 -0400 Subject: [PATCH] feat(server): recycle bin API for matters (#273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server): recycle bin API for matters Add trash, restore, permanent delete, and empty-trash operations on MatterService. Trashing/restoring/purging a folder cascades to all descendants. Permanent delete removes S3 objects and decrements storages.used and org_quotas.used. Endpoints: - PATCH /api/objects/:id/trash — soft delete - PATCH /api/objects/:id/restore — restore from trash - DELETE /api/objects/:id — permanent delete (only if trashed) - POST /api/recycle-bin/empty — purge all trashed items Note: empty-trash is mounted at /api/recycle-bin/empty (instead of /api/objects/trash/empty as in the task spec) because Hono's router does not allow mounting two sub-apps at overlapping prefixes (/api/objects and /api/objects/trash) — doing so silently breaks routing for unrelated paths like /api/auth/**. The functional behavior is identical. Schema: matters gets a nullable trashed_at column (migration 0003). Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * test(server): improve branch coverage for recycle bin code Add tests covering: - File creation with presigned upload URL - File copy with S3 copyObject - Permanent delete of trashed files (S3 deleteObjects verified) - Cascade purge of folder with file children - Trash/restore idempotency (already-trashed, not-trashed) - 404 for missing items on trash/restore - Empty trash with mixed folders and files - Empty trash on empty bin (no-op) - File download URL generation Mock S3Service methods (presignUpload, presignDownload, copyObject) globally in beforeEach to enable file-based route tests. Branch coverage: 87.84% (threshold: 80%) Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Bob Co-authored-by: Claude Opus 4.6 --- migrations/0003_recycle_bin.sql | 2 + migrations/meta/_journal.json | 7 + packages/server/src/app.ts | 2 + packages/server/src/db/schema.ts | 1 + packages/server/src/routes/objects.test.ts | 261 ++++++++++++++++++++- packages/server/src/routes/objects.ts | 74 +++++- packages/server/src/routes/trash.ts | 59 +++++ packages/server/src/services/matter.ts | 112 ++++++++- packages/server/src/test/setup.ts | 1 + 9 files changed, 500 insertions(+), 19 deletions(-) create mode 100644 migrations/0003_recycle_bin.sql create mode 100644 packages/server/src/routes/trash.ts diff --git a/migrations/0003_recycle_bin.sql b/migrations/0003_recycle_bin.sql new file mode 100644 index 00000000..4c1f15be --- /dev/null +++ b/migrations/0003_recycle_bin.sql @@ -0,0 +1,2 @@ +-- Add trashed_at column for recycle bin support +ALTER TABLE `matters` ADD `trashed_at` integer; diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 1beb3c0f..ba818f38 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1775080000000, "tag": "0002_storage_pool_fields", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1775090000000, + "tag": "0003_recycle_bin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index 37432e45..22c90043 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -10,6 +10,7 @@ import objects from './routes/objects' import { adminQuotas, userQuotas } from './routes/quotas' import storages from './routes/storages' import system from './routes/system' +import trash from './routes/trash' import users from './routes/users' export function createApp(platform: Platform, auth: Auth) { @@ -37,6 +38,7 @@ export function createApp(platform: Platform, auth: Auth) { const routes = app .route('/api/objects', objects) + .route('/api/recycle-bin', trash) .route('/api/admin/storages', storages) .route('/api/admin/users', users) .route('/api/admin/quotas', adminQuotas) diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index baf793bf..0b211434 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -12,6 +12,7 @@ export const matters = sqliteTable('matters', { object: text('object').notNull().default(''), storageId: text('storage_id').notNull(), status: text('status').notNull().default('draft'), // draft, active, trashed + trashedAt: integer('trashed_at'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }) diff --git a/packages/server/src/routes/objects.test.ts b/packages/server/src/routes/objects.test.ts index d4e2efcc..32ff0ce8 100644 --- a/packages/server/src/routes/objects.test.ts +++ b/packages/server/src/routes/objects.test.ts @@ -1,5 +1,5 @@ import { sql } from 'drizzle-orm' -import { describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { batchDelete, batchMove, @@ -13,8 +13,18 @@ import { listMatters, updateMatter, } from '../services/matter.js' +import { S3Service } from '../services/s3.js' import { authedHeaders, createTestApp } from '../test/setup.js' +beforeEach(() => { + vi.restoreAllMocks() + vi.spyOn(S3Service.prototype, 'deleteObject').mockResolvedValue(undefined) + vi.spyOn(S3Service.prototype, 'deleteObjects').mockResolvedValue(undefined) + vi.spyOn(S3Service.prototype, 'presignUpload').mockResolvedValue('https://presigned-upload.example.com') + vi.spyOn(S3Service.prototype, 'presignDownload').mockResolvedValue('https://presigned-download.example.com') + vi.spyOn(S3Service.prototype, 'copyObject').mockResolvedValue(undefined) +}) + const validStorage = { id: 'st-1', title: 'Test S3', @@ -282,27 +292,110 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) - it('DELETE /api/objects/:id deletes a folder', async () => { + it('DELETE /api/objects/:id rejects active object (must trash first)', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFolder(db, orgId, { id: 'f1', name: 'Active Folder' }) + + const res = await app.request('/api/objects/f1', { method: 'DELETE', headers }) + expect(res.status).toBe(409) + }) + + it('DELETE /api/objects/:id permanently deletes a trashed folder', async () => { const { app, db } = createTestApp() const headers = await authedHeaders(app) await insertStorage(db) const orgId = await getOrgId(db) await insertFolder(db, orgId, { id: 'f1', name: 'Delete Me' }) - const res = await app.request('/api/objects/f1', { - method: 'DELETE', - headers, - }) + const trashRes = await app.request('/api/objects/f1/trash', { method: 'PATCH', headers }) + expect(trashRes.status).toBe(200) + + const res = await app.request('/api/objects/f1', { method: 'DELETE', headers }) expect(res.status).toBe(200) const body = (await res.json()) as Record expect(body.id).toBe('f1') expect(body.deleted).toBe(true) - // Verify deleted from DB const check = await app.request('/api/objects/f1', { headers }) expect(check.status).toBe(404) }) + it('PATCH /api/objects/:id/trash trashes a file', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt' }) + + const res = await app.request('/api/objects/m1/trash', { method: 'PATCH', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.status).toBe('trashed') + expect(body.trashedAt).toBeTruthy() + + const list = await app.request('/api/objects?status=trashed', { headers }) + const listBody = (await list.json()) as { total: number } + expect(listBody.total).toBe(1) + }) + + it('PATCH /api/objects/:id/restore restores a trashed file', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' }) + + const res = await app.request('/api/objects/m1/restore', { method: 'PATCH', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.status).toBe('active') + }) + + it('PATCH /api/objects/:id/trash cascades to folder children', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFolder(db, orgId, { id: 'f1', name: 'Parent' }) + await insertFile(db, orgId, { id: 'm1', name: 'child.txt', parent: 'f1' }) + await insertFolder(db, orgId, { id: 'f2', name: 'Sub', parent: 'f1' }) + await insertFile(db, orgId, { id: 'm2', name: 'deep.txt', parent: 'f2' }) + + const res = await app.request('/api/objects/f1/trash', { method: 'PATCH', headers }) + expect(res.status).toBe(200) + + const trashed = await app.request('/api/objects?status=trashed', { headers }) + const tBody = (await trashed.json()) as { total: number } + // Only the root folder shows in root listing of trash + expect(tBody.total).toBe(1) + + // But all descendants are flagged trashed: restore restores them all + await app.request('/api/objects/f1/restore', { method: 'PATCH', headers }) + const childRes = await app.request('/api/objects/m2', { headers }) + const childBody = (await childRes.json()) as Record + expect(childBody.status).toBe('active') + }) + + it('POST /api/recycle-bin/empty purges all trashed items', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' }) + await insertFile(db, orgId, { id: 'm2', name: 'b.txt', status: 'trashed' }) + + const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as { purged: number } + expect(body.purged).toBe(2) + + const check = await app.request('/api/objects/m1', { headers }) + expect(check.status).toBe(404) + }) + it('DELETE /api/objects/:id returns 404 for missing object', async () => { const { app } = createTestApp() const headers = await authedHeaders(app) @@ -353,6 +446,160 @@ describe('Objects API', () => { expect(res.status).toBe(404) }) + it('POST /api/objects creates a file with upload URL', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const res = await app.request('/api/objects', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'photo.jpg', type: 'image/jpeg', size: 2048 }), + }) + expect(res.status).toBe(201) + const body = (await res.json()) as Record + expect(body.status).toBe('draft') + expect(body.uploadUrl).toBe('https://presigned-upload.example.com') + expect(body.object).toBeTruthy() + }) + + it('POST /api/objects/:id/copy copies a file with S3', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' }) + + const res = await app.request('/api/objects/m1/copy', { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ parent: '' }), + }) + expect(res.status).toBe(201) + expect(S3Service.prototype.copyObject).toHaveBeenCalled() + }) + + it('DELETE /api/objects/:id permanently deletes a trashed file with S3 cleanup', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'file.txt' }) + + await app.request('/api/objects/m1/trash', { method: 'PATCH', headers }) + const res = await app.request('/api/objects/m1', { method: 'DELETE', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.deleted).toBe(true) + expect(S3Service.prototype.deleteObjects).toHaveBeenCalled() + }) + + it('DELETE /api/objects/:id purges folder with file children from S3', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFolder(db, orgId, { id: 'f1', name: 'Folder' }) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt', parent: 'f1' }) + await insertFile(db, orgId, { id: 'm2', name: 'b.txt', parent: 'f1' }) + + await app.request('/api/objects/f1/trash', { method: 'PATCH', headers }) + const res = await app.request('/api/objects/f1', { method: 'DELETE', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as { purged: number } + expect(body.purged).toBe(3) + expect(S3Service.prototype.deleteObjects).toHaveBeenCalled() + }) + + it('PATCH /api/objects/:id/trash returns 404 for missing object', async () => { + const { app } = createTestApp() + const headers = await authedHeaders(app) + const res = await app.request('/api/objects/nonexistent/trash', { method: 'PATCH', headers }) + expect(res.status).toBe(404) + }) + + it('PATCH /api/objects/:id/trash is idempotent for already-trashed item', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' }) + const res = await app.request('/api/objects/m1/trash', { method: 'PATCH', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.status).toBe('trashed') + }) + + it('PATCH /api/objects/:id/restore returns 404 for missing object', async () => { + const { app } = createTestApp() + const headers = await authedHeaders(app) + const res = await app.request('/api/objects/nonexistent/restore', { method: 'PATCH', headers }) + expect(res.status).toBe(404) + }) + + it('PATCH /api/objects/:id/restore is no-op for active item', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'active' }) + + const res = await app.request('/api/objects/m1/restore', { method: 'PATCH', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.status).toBe('active') + }) + + it('POST /api/recycle-bin/empty with files calls S3 deleteObjects', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' }) + + const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers }) + expect(res.status).toBe(200) + expect(S3Service.prototype.deleteObjects).toHaveBeenCalled() + }) + + it('POST /api/recycle-bin/empty handles folders (no S3 object) and files together', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFolder(db, orgId, { id: 'f1', name: 'Trash Folder' }) + await insertFile(db, orgId, { id: 'm1', name: 'child.txt', parent: 'f1' }) + + // Trash the folder (cascades to child) + await app.request('/api/objects/f1/trash', { method: 'PATCH', headers }) + + const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as { purged: number } + expect(body.purged).toBe(2) + }) + + it('POST /api/recycle-bin/empty returns 0 when trash is empty', async () => { + const { app } = createTestApp() + const headers = await authedHeaders(app) + const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as { purged: number } + expect(body.purged).toBe(0) + }) + + it('GET /api/objects/:id returns downloadUrl for files', async () => { + const { app, db } = createTestApp() + const headers = await authedHeaders(app) + await insertStorage(db) + const orgId = await getOrgId(db) + await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' }) + + const res = await app.request('/api/objects/m1', { headers }) + expect(res.status).toBe(200) + const body = (await res.json()) as Record + expect(body.downloadUrl).toBe('https://presigned-download.example.com') + }) + it('POST /api/objects/batch/move moves multiple items', async () => { const { app, db } = createTestApp() const headers = await authedHeaders(app) diff --git a/packages/server/src/routes/objects.ts b/packages/server/src/routes/objects.ts index 84a862e9..19b7f7e9 100644 --- a/packages/server/src/routes/objects.ts +++ b/packages/server/src/routes/objects.ts @@ -14,12 +14,16 @@ import { batchDelete, batchMove, batchTrash, + collectForPurge, confirmUpload, copyMatter, createMatter, - deleteMatter, + decrementUsage, getMatter, listMatters, + purgeMatters, + restoreMatter, + trashMatter, updateMatter, } from '../services/matter' import { buildObjectKey } from '../services/path-template' @@ -33,6 +37,43 @@ function fileExt(name: string): string { return dot >= 0 ? name.slice(dot) : '' } +async function purgeRecursively( + db: import('../platform/interface').Database, + orgId: string, + matters: import('../services/matter').Matter[], +): Promise { + const keysByStorage = new Map() + const bytesByStorage = new Map() + let totalBytes = 0 + + for (const m of matters) { + if (m.dirtype === 0 && m.size > 0) { + bytesByStorage.set(m.storageId, (bytesByStorage.get(m.storageId) ?? 0) + m.size) + totalBytes += m.size + } + if (!m.object) continue + let entry = keysByStorage.get(m.storageId) + if (!entry) { + const storage = (await getStorage(db, m.storageId)) as unknown as S3Storage | null + entry = { storage, keys: [] } + keysByStorage.set(m.storageId, entry) + } + entry.keys.push(m.object) + } + + for (const { storage, keys } of keysByStorage.values()) { + if (storage && keys.length > 0) await s3.deleteObjects(storage, keys) + } + + await purgeMatters( + db, + orgId, + matters.map((m) => m.id), + ) + await decrementUsage(db, orgId, bytesByStorage, totalBytes) + return matters.length +} + const app = new Hono() .use(requireAuth) .get('/', async (c) => { @@ -190,20 +231,33 @@ const app = new Hono() if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404) return c.json(matter) }) + .patch('/:id/trash', async (c) => { + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'No active organization' }, 400) + const db = c.get('platform').db + const matter = await trashMatter(db, orgId, c.req.param('id')) + if (!matter) return c.json({ error: 'Not found' }, 404) + return c.json(matter) + }) + .patch('/:id/restore', async (c) => { + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'No active organization' }, 400) + const db = c.get('platform').db + const matter = await restoreMatter(db, orgId, c.req.param('id')) + if (!matter) return c.json({ error: 'Not found' }, 404) + return c.json(matter) + }) .delete('/:id', async (c) => { const orgId = c.get('orgId') if (!orgId) return c.json({ error: 'No active organization' }, 400) - const db = c.get('platform').db - const matter = await deleteMatter(db, c.req.param('id'), orgId) - if (!matter) return c.json({ error: 'Not found' }, 404) - - if (matter.object) { - const storage = (await getStorage(db, matter.storageId)) as unknown as S3Storage - if (storage) await s3.deleteObject(storage, matter.object) + const ms = await collectForPurge(db, orgId, c.req.param('id')) + if (!ms) return c.json({ error: 'Not found' }, 404) + if (ms[0].status !== 'trashed') { + return c.json({ error: 'Object must be trashed before permanent deletion' }, 409) } - - return c.json({ id: matter.id, deleted: true }) + const purged = await purgeRecursively(db, orgId, ms) + return c.json({ id: ms[0].id, deleted: true, purged }) }) .post('/:id/copy', async (c) => { const orgId = c.get('orgId') diff --git a/packages/server/src/routes/trash.ts b/packages/server/src/routes/trash.ts new file mode 100644 index 00000000..9fb27c3d --- /dev/null +++ b/packages/server/src/routes/trash.ts @@ -0,0 +1,59 @@ +import type { Storage as S3Storage } from '@zpan/shared/types' +import { Hono } from 'hono' +import { requireAuth } from '../middleware/auth' +import type { Env } from '../middleware/platform' +import type { Database } from '../platform/interface' +import { collectForPurge, decrementUsage, listTrashedRoots, type Matter, purgeMatters } from '../services/matter' +import { S3Service } from '../services/s3' +import { getStorage } from '../services/storage' + +const s3 = new S3Service() + +async function purgeRecursively(db: Database, orgId: string, matters: Matter[]): Promise { + const keysByStorage = new Map() + const bytesByStorage = new Map() + let totalBytes = 0 + + for (const m of matters) { + if (m.dirtype === 0 && m.size > 0) { + bytesByStorage.set(m.storageId, (bytesByStorage.get(m.storageId) ?? 0) + m.size) + totalBytes += m.size + } + if (!m.object) continue + let entry = keysByStorage.get(m.storageId) + if (!entry) { + const storage = (await getStorage(db, m.storageId)) as unknown as S3Storage | null + entry = { storage, keys: [] } + keysByStorage.set(m.storageId, entry) + } + entry.keys.push(m.object) + } + + for (const { storage, keys } of keysByStorage.values()) { + if (storage && keys.length > 0) await s3.deleteObjects(storage, keys) + } + + await purgeMatters( + db, + orgId, + matters.map((m) => m.id), + ) + await decrementUsage(db, orgId, bytesByStorage, totalBytes) + return matters.length +} + +const app = new Hono().use(requireAuth).post('/empty', async (c) => { + const orgId = c.get('orgId') + if (!orgId) return c.json({ error: 'No active organization' }, 400) + const db = c.get('platform').db + const roots = await listTrashedRoots(db, orgId) + let purgedCount = 0 + for (const root of roots) { + const ms = await collectForPurge(db, orgId, root.id) + if (!ms) continue + purgedCount += await purgeRecursively(db, orgId, ms) + } + return c.json({ purged: purgedCount }) +}) + +export default app diff --git a/packages/server/src/services/matter.ts b/packages/server/src/services/matter.ts index 4149689f..32f2866f 100644 --- a/packages/server/src/services/matter.ts +++ b/packages/server/src/services/matter.ts @@ -15,6 +15,7 @@ export interface Matter { object: string storageId: string status: string + trashedAt: number | null createdAt: number updatedAt: number } @@ -56,6 +57,7 @@ export async function createMatter(db: Database, input: CreateMatterInput): Prom object: input.object, storageId: input.storageId, status: input.status, + trashedAt: null, createdAt: now, updatedAt: now, } @@ -77,6 +79,7 @@ export async function listMatters( const items = await db.all(sql` SELECT id, org_id AS orgId, alias, name, type, size, dirtype, parent, object, storage_id AS storageId, status, + trashed_at AS trashedAt, created_at AS createdAt, updated_at AS updatedAt FROM matters WHERE org_id = ${orgId} AND parent = ${filters.parent} AND status = ${filters.status} @@ -91,6 +94,7 @@ export async function getMatter(db: Database, id: string, orgId: string): Promis const rows = await db.all(sql` SELECT id, org_id AS orgId, alias, name, type, size, dirtype, parent, object, storage_id AS storageId, status, + trashed_at AS trashedAt, created_at AS createdAt, updated_at AS updatedAt FROM matters WHERE id = ${id} AND org_id = ${orgId} @@ -160,6 +164,7 @@ export async function copyMatter( object: newObject, storageId: source.storageId, status: 'active', + trashedAt: null, createdAt: now, updatedAt: now, } @@ -173,6 +178,8 @@ export async function deleteMatter(db: Database, id: string, orgId: string): Pro return existing } +// ─── Batch Operations ──────────────────────────────────────────────────────── + export async function getMatters(db: Database, orgId: string, ids: string[]): Promise { if (ids.length === 0) return [] @@ -183,6 +190,7 @@ export async function getMatters(db: Database, orgId: string, ids: string[]): Pr return db.all(sql` SELECT id, org_id AS orgId, alias, name, type, size, dirtype, parent, object, storage_id AS storageId, status, + trashed_at AS trashedAt, created_at AS createdAt, updated_at AS updatedAt FROM matters WHERE org_id = ${orgId} AND id IN (${idList}) @@ -219,6 +227,7 @@ async function getChildrenRecursive(db: Database, orgId: string, parentIds: stri const children = await db.all(sql` SELECT id, org_id AS orgId, alias, name, type, size, dirtype, parent, object, storage_id AS storageId, status, + trashed_at AS trashedAt, created_at AS createdAt, updated_at AS updatedAt FROM matters WHERE org_id = ${orgId} AND parent IN (${idList}) @@ -244,12 +253,12 @@ export async function batchTrash(db: Database, orgId: string, ids: string[]): Pr const now = Date.now() for (const matter of allMatters) { await db.run(sql` - UPDATE matters SET status = 'trashed', updated_at = ${now} + UPDATE matters SET status = 'trashed', trashed_at = ${now}, updated_at = ${now} WHERE id = ${matter.id} AND org_id = ${orgId} `) } - return allMatters.map((m) => ({ ...m, status: 'trashed', updatedAt: now })) + return allMatters.map((m) => ({ ...m, status: 'trashed', trashedAt: now, updatedAt: now })) } export async function batchDelete(db: Database, orgId: string, ids: string[]): Promise { @@ -270,3 +279,102 @@ export async function batchDelete(db: Database, orgId: string, ids: string[]): P return matters } + +// ─── Recycle Bin ───────────────────────────────────────────────────────────── + +async function collectDescendants(db: Database, orgId: string, rootId: string): Promise { + const result: Matter[] = [] + let frontier = [rootId] + while (frontier.length > 0) { + const next: string[] = [] + for (const parentId of frontier) { + const children = await db.all(sql` + SELECT id, org_id AS orgId, alias, name, type, size, dirtype, + parent, object, storage_id AS storageId, status, + trashed_at AS trashedAt, + created_at AS createdAt, updated_at AS updatedAt + FROM matters + WHERE org_id = ${orgId} AND parent = ${parentId} + `) + for (const child of children) { + result.push(child) + if (child.dirtype !== 0) next.push(child.id) + } + } + frontier = next + } + return result +} + +export async function trashMatter(db: Database, orgId: string, id: string): Promise { + const existing = await getMatter(db, id, orgId) + if (!existing) return null + if (existing.status === 'trashed') return existing + + const now = Date.now() + const descendants = await collectDescendants(db, orgId, existing.id) + const ids = [existing.id, ...descendants.map((m) => m.id)] + for (const targetId of ids) { + await db.run(sql` + UPDATE matters SET status = 'trashed', trashed_at = ${now}, updated_at = ${now} + WHERE id = ${targetId} AND org_id = ${orgId} AND status = 'active' + `) + } + return { ...existing, status: 'trashed', trashedAt: now, updatedAt: now } +} + +export async function restoreMatter(db: Database, orgId: string, id: string): Promise { + const existing = await getMatter(db, id, orgId) + if (!existing) return null + if (existing.status !== 'trashed') return existing + + const now = Date.now() + const descendants = await collectDescendants(db, orgId, existing.id) + const ids = [existing.id, ...descendants.map((m) => m.id)] + for (const targetId of ids) { + await db.run(sql` + UPDATE matters SET status = 'active', trashed_at = NULL, updated_at = ${now} + WHERE id = ${targetId} AND org_id = ${orgId} AND status = 'trashed' + `) + } + return { ...existing, status: 'active', trashedAt: null, updatedAt: now } +} + +export async function collectForPurge(db: Database, orgId: string, id: string): Promise { + const existing = await getMatter(db, id, orgId) + if (!existing) return null + const descendants = await collectDescendants(db, orgId, existing.id) + return [existing, ...descendants] +} + +export async function purgeMatters(db: Database, orgId: string, ids: string[]): Promise { + for (const id of ids) { + await db.run(sql`DELETE FROM matters WHERE id = ${id} AND org_id = ${orgId}`) + } +} + +export async function listTrashedRoots(db: Database, orgId: string): Promise { + return db.all(sql` + SELECT id, org_id AS orgId, alias, name, type, size, dirtype, + parent, object, storage_id AS storageId, status, + trashed_at AS trashedAt, + created_at AS createdAt, updated_at AS updatedAt + FROM matters + WHERE org_id = ${orgId} AND status = 'trashed' + `) +} + +export async function decrementUsage( + db: Database, + orgId: string, + bytesByStorage: Map, + totalBytes: number, +): Promise { + for (const [storageId, bytes] of bytesByStorage) { + if (bytes <= 0) continue + await db.run(sql`UPDATE storages SET used = MAX(0, used - ${bytes}) WHERE id = ${storageId}`) + } + if (totalBytes > 0) { + await db.run(sql`UPDATE org_quotas SET used = MAX(0, used - ${totalBytes}) WHERE org_id = ${orgId}`) + } +} diff --git a/packages/server/src/test/setup.ts b/packages/server/src/test/setup.ts index c81eb51e..ed23b754 100644 --- a/packages/server/src/test/setup.ts +++ b/packages/server/src/test/setup.ts @@ -103,6 +103,7 @@ const APP_SCHEMA_SQL = ` object TEXT NOT NULL DEFAULT '', storage_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft', + trashed_at INTEGER, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL );