feat(server): batch operations API for move, trash, delete (#272)

* feat(server): add batch move, trash, and delete operations

Add batch endpoints for multi-select file manager actions:
- POST /api/objects/batch/move — move multiple items to a new parent
- POST /api/objects/batch/trash — trash items with cascade into folder children
- POST /api/objects/batch/delete — permanently delete trashed items with S3 cleanup

Includes input validation schemas, org-scoped ownership verification,
recursive child traversal with depth limit, and service-level tests.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

* fix(server): change auth wildcard to /api/auth/* and add batch route tests

The /api/auth/** double-wildcard breaks in Hono v4.12 when sub-routers
register additional routes alongside /:id and /:id/copy patterns,
silently failing all /api/auth/* requests with 404. Switching to single
* fixes this and still matches nested paths like /api/auth/sign-up/email.

Also adds route-level tests for the new batch endpoints to maintain
coverage thresholds.

Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f

---------

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-09 17:22:21 -04:00
committed by GitHub
parent efec832c6f
commit 1620a2e43e
5 changed files with 532 additions and 2 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ export function createApp(platform: Platform, auth: Auth) {
}),
)
app.on(['POST', 'GET'], '/api/auth/**', async (c) => {
app.on(['POST', 'GET'], '/api/auth/*', async (c) => {
const a = c.get('auth')
return a.handler(c.req.raw)
})
+352
View File
@@ -1,11 +1,15 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import {
batchDelete,
batchMove,
batchTrash,
confirmUpload,
copyMatter,
createMatter,
deleteMatter,
getMatter,
getMatters,
listMatters,
updateMatter,
} from '../services/matter.js'
@@ -348,6 +352,128 @@ describe('Objects API', () => {
})
expect(res.status).toBe(404)
})
it('POST /api/objects/batch/move moves multiple 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' })
await insertFile(db, orgId, { id: 'm2', name: 'b.txt' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1', 'm2'], parent: 'target' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { moved: number }
expect(body.moved).toBe(2)
})
it('POST /api/objects/batch/move returns 400 for invalid input', async () => {
const { app } = createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [] }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/move returns 400 if any id missing from org', 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/batch/move', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1', 'nope'], parent: 'x' }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/trash trashes items and cascades', 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: 'c1', name: 'child.txt', parent: 'f1' })
const res = await app.request('/api/objects/batch/trash', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['f1'] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { trashed: number }
expect(body.trashed).toBe(2)
})
it('POST /api/objects/batch/trash returns 400 for invalid input', async () => {
const { app } = createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/batch/trash', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/delete permanently deletes trashed items', async () => {
const { app, db } = createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
// Use folders (empty object key) to avoid S3 calls
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
VALUES ('t1', ${orgId}, 't1-a', 't1', 'folder', 0, 1, '', '', ${validStorage.id}, 'trashed', ${now}, ${now}),
('t2', ${orgId}, 't2-a', 't2', 'folder', 0, 1, '', '', ${validStorage.id}, 'trashed', ${now}, ${now})
`)
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['t1', 't2'] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { deleted: number }
expect(body.deleted).toBe(2)
})
it('POST /api/objects/batch/delete returns 400 if any item is not trashed', async () => {
const { app, db } = createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'tx', name: 'a.txt', status: 'trashed' })
await insertFile(db, orgId, { id: 'ax', name: 'b.txt', status: 'active' })
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['tx', 'ax'] }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/delete returns 400 for invalid input', async () => {
const { app } = createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
})
describe('Matter service', () => {
@@ -524,4 +650,230 @@ describe('Matter service', () => {
expect(copy.object).toBe('copy/key')
expect(copy.status).toBe('active')
})
it('getMatters returns empty array for empty ids list', async () => {
const { db } = createTestApp()
const result = await getMatters(db, 'org-1', [])
expect(result).toEqual([])
})
it('batchMove moves multiple items to a new parent', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const a = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
const b = await createMatter(db, {
orgId: 'org-1',
name: 'b.txt',
type: 'text/plain',
object: 'b',
storageId: 's1',
status: 'active',
})
const results = await batchMove(db, 'org-1', [a.id, b.id], 'folder-x')
expect(results).toHaveLength(2)
expect(results.every((m) => m.parent === 'folder-x')).toBe(true)
const check = await getMatters(db, 'org-1', [a.id, b.id])
expect(check.every((m) => m.parent === 'folder-x')).toBe(true)
})
it('batchMove throws if any ID does not belong to the org', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const a = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
await expect(batchMove(db, 'org-1', [a.id, 'nonexistent-id'], 'folder-x')).rejects.toThrow(
'Some IDs do not belong to this organization',
)
})
it('batchTrash sets status to trashed for multiple items', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const a = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
const b = await createMatter(db, {
orgId: 'org-1',
name: 'b.txt',
type: 'text/plain',
object: 'b',
storageId: 's1',
status: 'active',
})
await batchTrash(db, 'org-1', [a.id, b.id])
const check = await getMatters(db, 'org-1', [a.id, b.id])
expect(check.every((m) => m.status === 'trashed')).toBe(true)
})
it('batchTrash cascades into folder children', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const folder = await createMatter(db, {
orgId: 'org-1',
name: 'folder',
type: 'folder',
dirtype: 1,
object: '',
storageId: 's1',
status: 'active',
})
const child = await createMatter(db, {
orgId: 'org-1',
name: 'child.txt',
type: 'text/plain',
object: 'c',
parent: folder.id,
storageId: 's1',
status: 'active',
})
await batchTrash(db, 'org-1', [folder.id])
const checkedFolder = await getMatter(db, folder.id, 'org-1')
expect(checkedFolder?.status).toBe('trashed')
const checkedChild = await getMatter(db, child.id, 'org-1')
expect(checkedChild?.status).toBe('trashed')
})
it('batchTrash throws if any ID does not belong to the org', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const a = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'active',
})
await expect(batchTrash(db, 'org-1', [a.id, 'nonexistent-id'])).rejects.toThrow(
'Some IDs do not belong to this organization',
)
})
it('batchDelete permanently deletes trashed items', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const a = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'trashed',
})
const b = await createMatter(db, {
orgId: 'org-1',
name: 'b.txt',
type: 'text/plain',
object: 'b',
storageId: 's1',
status: 'trashed',
})
const deleted = await batchDelete(db, 'org-1', [a.id, b.id])
expect(deleted).toHaveLength(2)
const remaining = await getMatters(db, 'org-1', [a.id, b.id])
expect(remaining).toHaveLength(0)
})
it('batchDelete throws if any item is not trashed', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const trashed = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'trashed',
})
const active = await createMatter(db, {
orgId: 'org-1',
name: 'b.txt',
type: 'text/plain',
object: 'b',
storageId: 's1',
status: 'active',
})
await expect(batchDelete(db, 'org-1', [trashed.id, active.id])).rejects.toThrow(
'Only trashed items can be permanently deleted',
)
})
it('batchDelete throws if any ID does not belong to the org', async () => {
const { db } = createTestApp()
const now = Date.now()
await db.run(sql`
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
VALUES ('s1', 'S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'k', 's', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
`)
const trashed = await createMatter(db, {
orgId: 'org-1',
name: 'a.txt',
type: 'text/plain',
object: 'a',
storageId: 's1',
status: 'trashed',
})
await expect(batchDelete(db, 'org-1', [trashed.id, 'nonexistent-id'])).rejects.toThrow(
'Some IDs do not belong to this organization',
)
})
})
+71 -1
View File
@@ -1,10 +1,19 @@
import { DirType } from '@zpan/shared/constants'
import { copyMatterSchema, createMatterSchema, updateMatterSchema } from '@zpan/shared/schemas'
import {
batchIdsSchema,
batchMoveSchema,
copyMatterSchema,
createMatterSchema,
updateMatterSchema,
} from '@zpan/shared/schemas'
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 {
batchDelete,
batchMove,
batchTrash,
confirmUpload,
copyMatter,
createMatter,
@@ -80,6 +89,67 @@ const app = new Hono<Env>()
const uploadUrl = await s3.presignUpload(storage, objectKey, type)
return c.json({ ...matter, uploadUrl }, 201)
})
.post('/batch/move', async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const raw = await c.req.json()
const parsed = batchMoveSchema.safeParse(raw)
if (!parsed.success) return c.json({ error: parsed.error.issues[0].message }, 400)
const db = c.get('platform').db
try {
const moved = await batchMove(db, orgId, parsed.data.ids, parsed.data.parent)
return c.json({ moved: moved.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
}
})
.post('/batch/trash', async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const raw = await c.req.json()
const parsed = batchIdsSchema.safeParse(raw)
if (!parsed.success) return c.json({ error: parsed.error.issues[0].message }, 400)
const db = c.get('platform').db
try {
const trashed = await batchTrash(db, orgId, parsed.data.ids)
return c.json({ trashed: trashed.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
}
})
.post('/batch/delete', async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const raw = await c.req.json()
const parsed = batchIdsSchema.safeParse(raw)
if (!parsed.success) return c.json({ error: parsed.error.issues[0].message }, 400)
const db = c.get('platform').db
try {
const deleted = await batchDelete(db, orgId, parsed.data.ids)
const byStorage = new Map<string, string[]>()
for (const m of deleted) {
if (!m.object) continue
const keys = byStorage.get(m.storageId) ?? []
keys.push(m.object)
byStorage.set(m.storageId, keys)
}
for (const [storageId, keys] of byStorage) {
const storage = (await getStorage(db, storageId)) as unknown as S3Storage
if (storage) await s3.deleteObjects(storage, keys)
}
return c.json({ deleted: deleted.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
}
})
.get('/:id', async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
+99
View File
@@ -1,3 +1,4 @@
import { DirType } from '@zpan/shared/constants'
import { sql } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import type { Database } from '../platform/interface'
@@ -171,3 +172,101 @@ export async function deleteMatter(db: Database, id: string, orgId: string): Pro
await db.run(sql`DELETE FROM matters WHERE id = ${id} AND org_id = ${orgId}`)
return existing
}
export async function getMatters(db: Database, orgId: string, ids: string[]): Promise<Matter[]> {
if (ids.length === 0) return []
const idList = sql.join(
ids.map((id) => sql`${id}`),
sql`, `,
)
return db.all<Matter>(sql`
SELECT id, org_id AS orgId, alias, name, type, size, dirtype,
parent, object, storage_id AS storageId, status,
created_at AS createdAt, updated_at AS updatedAt
FROM matters
WHERE org_id = ${orgId} AND id IN (${idList})
`)
}
export async function batchMove(db: Database, orgId: string, ids: string[], newParent: string): Promise<Matter[]> {
const uniqueIds = [...new Set(ids)]
const matters = await getMatters(db, orgId, uniqueIds)
if (matters.length !== uniqueIds.length) {
throw new Error('Some IDs do not belong to this organization')
}
const now = Date.now()
for (const matter of matters) {
await db.run(sql`
UPDATE matters SET parent = ${newParent}, updated_at = ${now}
WHERE id = ${matter.id} AND org_id = ${orgId}
`)
}
return matters.map((m) => ({ ...m, parent: newParent, updatedAt: now }))
}
const MAX_RECURSION_DEPTH = 20
async function getChildrenRecursive(db: Database, orgId: string, parentIds: string[], depth = 0): Promise<Matter[]> {
if (parentIds.length === 0 || depth >= MAX_RECURSION_DEPTH) return []
const idList = sql.join(
parentIds.map((id) => sql`${id}`),
sql`, `,
)
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,
created_at AS createdAt, updated_at AS updatedAt
FROM matters
WHERE org_id = ${orgId} AND parent IN (${idList})
`)
if (children.length === 0) return []
const folderIds = children.filter((c) => c.dirtype !== DirType.FILE).map((c) => c.id)
const deeper = await getChildrenRecursive(db, orgId, folderIds, depth + 1)
return [...children, ...deeper]
}
export async function batchTrash(db: Database, orgId: string, ids: string[]): Promise<Matter[]> {
const uniqueIds = [...new Set(ids)]
const matters = await getMatters(db, orgId, uniqueIds)
if (matters.length !== uniqueIds.length) {
throw new Error('Some IDs do not belong to this organization')
}
const folderIds = matters.filter((m) => m.dirtype !== DirType.FILE).map((m) => m.id)
const children = await getChildrenRecursive(db, orgId, folderIds)
const allMatters = [...matters, ...children]
const now = Date.now()
for (const matter of allMatters) {
await db.run(sql`
UPDATE matters SET status = 'trashed', updated_at = ${now}
WHERE id = ${matter.id} AND org_id = ${orgId}
`)
}
return allMatters.map((m) => ({ ...m, status: 'trashed', updatedAt: now }))
}
export async function batchDelete(db: Database, orgId: string, ids: string[]): Promise<Matter[]> {
const uniqueIds = [...new Set(ids)]
const matters = await getMatters(db, orgId, uniqueIds)
if (matters.length !== uniqueIds.length) {
throw new Error('Some IDs do not belong to this organization')
}
const nonTrashed = matters.filter((m) => m.status !== 'trashed')
if (nonTrashed.length > 0) {
throw new Error('Only trashed items can be permanently deleted')
}
for (const matter of matters) {
await db.run(sql`DELETE FROM matters WHERE id = ${matter.id} AND org_id = ${orgId}`)
}
return matters
}
+9
View File
@@ -34,3 +34,12 @@ export type UpdateMatterInput = z.infer<typeof updateMatterSchema>
export const copyMatterSchema = z.object({
parent: z.string().default(''),
})
export const batchMoveSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
parent: z.string().default(''),
})
export const batchIdsSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
})