mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat(server): recycle bin API for matters (#273)
* 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 <noreply@anthropic.com> --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- Add trashed_at column for recycle bin support
|
||||
ALTER TABLE `matters` ADD `trashed_at` integer;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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)
|
||||
|
||||
@@ -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<number> {
|
||||
const keysByStorage = new Map<string, { storage: S3Storage | null; keys: string[] }>()
|
||||
const bytesByStorage = new Map<string, number>()
|
||||
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<Env>()
|
||||
.use(requireAuth)
|
||||
.get('/', async (c) => {
|
||||
@@ -190,20 +231,33 @@ const app = new Hono<Env>()
|
||||
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')
|
||||
|
||||
@@ -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<number> {
|
||||
const keysByStorage = new Map<string, { storage: S3Storage | null; keys: string[] }>()
|
||||
const bytesByStorage = new Map<string, number>()
|
||||
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<Env>().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
|
||||
@@ -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<Matter>(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<Matter>(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<Matter[]> {
|
||||
if (ids.length === 0) return []
|
||||
|
||||
@@ -183,6 +190,7 @@ export async function getMatters(db: Database, orgId: string, ids: string[]): Pr
|
||||
return db.all<Matter>(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<Matter>(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<Matter[]> {
|
||||
@@ -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<Matter[]> {
|
||||
const result: Matter[] = []
|
||||
let frontier = [rootId]
|
||||
while (frontier.length > 0) {
|
||||
const next: string[] = []
|
||||
for (const parentId of frontier) {
|
||||
const children = await db.all<Matter>(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<Matter | null> {
|
||||
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<Matter | null> {
|
||||
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<Matter[] | null> {
|
||||
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<void> {
|
||||
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<Matter[]> {
|
||||
return db.all<Matter>(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<string, number>,
|
||||
totalBytes: number,
|
||||
): Promise<void> {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user