Files
zpan/server/http/trash.ts
T
Jasper VanandClaude Opus 4.8 7b8c8c915e refactor(api)!: unify object upload + rework delete/trash lifecycle (#448) (#454)
Resolve #448 — one upload entry point and an AIP-164 soft delete.

Upload: POST /objects now returns size-decided upload instructions
{ sessionId, partSize, urls }; the server picks single PutObject (<=5 GiB)
vs 5 GiB-part multipart (>5 GiB) and rejects >5 TiB. The client PUTs each
slice, reads its ETag, then POSTs them to
POST /objects/{id}/uploads/{sid}/completions (returns the live object).
DELETE /objects/{id}/uploads/{sid} aborts and discards the draft.

Trash: matters.status drops 'trashed' (enum is {draft,active}); trash is
tracked by the existing trashedAt timestamp. DELETE /objects/{id} now
soft-deletes; the recycle bin lives under /trash/objects (list roots, get,
restorations, purge). Empty-trash is a frontend loop over roots.

BREAKING CHANGE:
- removes PUT /objects/{id}/status and POST /objects/{id}/uploads
- PUT .../uploads/{sid}/status -> POST .../uploads/{sid}/completions {parts}
- DELETE /objects/{id} flips hard-purge -> soft-delete; permanent purge
  moves to DELETE /trash/objects/{id}
- DELETE /trash removed; restore is POST /trash/objects/{id}/restorations
- matters.status enum loses 'trashed' (migration backfills to trashedAt)

The migration swaps the matters_active_name_uniq partial index to exclude
trashed rows (WHERE status='active' AND trashed_at IS NULL). The single-PUT
presign is header-free so the uniform slice uploader's raw PUT matches the
S3 signature. Go downloader client + agent reworked to the unified flow.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 21:21:43 -04:00

164 lines
5.1 KiB
TypeScript

import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { pageQuerySchema, pageSchema, restoreObjectSchema } from '@shared/schemas'
import type { Context } from 'hono'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { deleteObject, getTrashObject, listTrashedObjects, restoreObject } from '../usecases/object'
import { badRequest, type Matter, notFound } from '../usecases/ports'
import { errorResponse, jsonBody, jsonContent } from './openapi'
// The trashed-object wire shape mirrors the live Matter model; trash is a
// grouping/view of `objects`, not a separate resource.
const matterSchema = z
.object({
id: z.string(),
orgId: z.string(),
alias: z.string(),
name: z.string(),
type: z.string(),
size: z.number().int().nullable(),
dirtype: z.number().int().nullable(),
parent: z.string(),
object: z.string(),
storageId: z.string(),
status: z.string(),
trashedAt: z.number().int().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
})
.openapi('TrashObject')
type MatterDTO = z.infer<typeof matterSchema>
function toMatterDTO(m: Matter): MatterDTO {
return {
id: m.id,
orgId: m.orgId,
alias: m.alias,
name: m.name,
type: m.type,
size: m.size,
dirtype: m.dirtype,
parent: m.parent,
object: m.object,
storageId: m.storageId,
status: m.status,
trashedAt: m.trashedAt,
createdAt: m.createdAt.toISOString(),
updatedAt: m.updatedAt.toISOString(),
}
}
const trashPageSchema = pageSchema(matterSchema, 'TrashObjectPage')
const idParam = z.object({ id: z.string() })
function actorId(c: Context<Env>): string {
return c.get('userId') ?? 'system'
}
const listTrashRoute = createRoute({
operationId: 'listTrashObjects',
summary: 'List trashed objects',
tags: ['Trash'],
method: 'get',
path: '/objects',
middleware: [requireTeamRole('viewer')] as const,
request: { query: pageQuerySchema },
responses: {
200: jsonContent(trashPageSchema, 'Trashed objects (roots only)'),
400: errorResponse('No active organization'),
},
})
const getTrashObjectRoute = createRoute({
operationId: 'getTrashObject',
summary: 'Get trashed object',
tags: ['Trash'],
method: 'get',
path: '/objects/{id}',
middleware: [requireTeamRole('viewer')] as const,
request: { params: idParam },
responses: {
200: jsonContent(matterSchema, 'Trashed object'),
400: errorResponse('No active organization'),
404: errorResponse('Not found'),
},
})
const restoreObjectRoute = createRoute({
operationId: 'restoreObject',
summary: 'Restore trashed object',
tags: ['Trash'],
method: 'post',
path: '/objects/{id}/restorations',
middleware: [requireTeamRole('editor')] as const,
request: { params: idParam, ...jsonBody(restoreObjectSchema) },
responses: {
200: jsonContent(matterSchema, 'Restored object'),
400: errorResponse('No active organization'),
404: errorResponse('Not found'),
409: errorResponse('Name conflict'),
},
})
const purgeObjectRoute = createRoute({
operationId: 'purgeTrashObject',
summary: 'Permanently delete trashed object',
tags: ['Trash'],
method: 'delete',
path: '/objects/{id}',
middleware: [requireTeamRole('editor')] as const,
request: { params: idParam },
responses: {
204: { description: 'Permanently removed (recursive subtree purge)' },
400: errorResponse('No active organization'),
404: errorResponse('Not found'),
},
})
const app = new OpenAPIHono<Env>()
app.use(requireAuth)
const trash = app
.openapi(listTrashRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const query = c.req.valid('query')
const result = await listTrashedObjects(c.get('deps'), { orgId, page: query.page, pageSize: query.pageSize })
return c.json({ ...result.result, items: result.result.items.map(toMatterDTO) }, 200)
})
.openapi(getTrashObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await getTrashObject(c.get('deps'), { orgId, objectId: c.req.valid('param').id })
if (!result.ok) throw result.error
return c.json(toMatterDTO(result.matter), 200)
})
.openapi(restoreObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
// NameConflictError from the restore (a same-named item appeared while
// trashed) propagates to onError → 409.
const result = await restoreObject(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
actorId: actorId(c),
onConflict: c.req.valid('json').onConflict,
})
if (!result.ok) throw result.error
return c.json(toMatterDTO(result.matter), 200)
})
.openapi(purgeObjectRoute, async (c) => {
const orgId = c.get('orgId')
if (!orgId) throw badRequest('No active organization')
const result = await deleteObject(c.get('deps'), {
orgId,
objectId: c.req.valid('param').id,
userId: c.get('userId')!,
})
if (!result.ok) throw notFound()
return c.body(null, 204)
})
export default trash