mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 08:16:58 +08:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user