Files
zpan/server/routes/objects.ts
T
Jasper Van f45e8732fc feat: integrate cloud credits store flow
Replace ZPAN Store Wallet semantics with Credits-only APIs and UI.

Proxy Cloud credits through credit-account routes, send unitless gift-card credits, and block metered downloads on insufficient Cloud credits before presign while rolling back local counters.

Verification waiver: E2E fails only because the hosted Cloud target rejects the merged Credits gift-card payload; AK follow-up kxel47x6x6l3 tracks that target gap.
2026-06-01 22:31:46 -04:00

346 lines
12 KiB
TypeScript

import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { DirType } from '../../shared/constants'
import {
batchDeleteSchema,
batchPatchSchema,
copyMatterSchema,
createMatterSchema,
patchMatterSchema,
} from '../../shared/schemas'
import type { Storage as S3Storage } from '../../shared/types'
import { requireAuth, requireTeamRole } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { recordActivity } from '../services/activity'
import { consumeTrafficIfQuotaAllows, refundTraffic } from '../services/effective-quota'
import {
batchMove,
batchTrash,
cancelDraftMatter,
collectForPurge,
confirmUpload,
copyMatter,
createMatter,
getMatter,
getMatters,
incrementUsageIfAllowed,
listMatters,
restoreMatter,
trashMatter,
updateMatter,
} from '../services/matter'
import { NameConflictError } from '../services/matter-name-conflict'
import { buildObjectKey } from '../services/path-template'
import { purgeRecursively } from '../services/purge'
import { S3Service } from '../services/s3'
import { getStorage, selectStorage } from '../services/storage'
import { reportTrafficForDownload } from './traffic-metering-utils'
const s3 = new S3Service()
function fileExt(name: string): string {
const dot = name.lastIndexOf('.')
return dot >= 0 ? name.slice(dot) : ''
}
function conflictBody(err: NameConflictError) {
return {
error: err.message,
code: 'NAME_CONFLICT' as const,
conflictingName: err.conflictingName,
conflictingId: err.conflictingId,
}
}
const app = new Hono<Env>()
.use(requireAuth)
.get('/', requireTeamRole('viewer'), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const parent = c.req.query('path') ?? c.req.query('parent') ?? ''
const status = c.req.query('status') ?? 'active'
const typeFilter = c.req.query('type')
const search = c.req.query('search')
const page = Number(c.req.query('page') ?? '1')
const pageSize = Number(c.req.query('pageSize') ?? '20')
const db = c.get('platform').db
const result = await listMatters(db, orgId, { parent, status, typeFilter, search, page, pageSize })
return c.json(result)
})
.post('/', requireTeamRole('editor'), zValidator('json', createMatterSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
const { name, type, size, parent, dirtype, onConflict } = c.req.valid('json')
const isFolder = dirtype !== DirType.FILE
const storage = (await selectStorage(db, 'private')) as unknown as S3Storage
const objectKey = isFolder
? ''
: buildObjectKey({
uid: userId,
orgId,
rawExt: fileExt(name),
})
try {
const matter = await createMatter(db, {
orgId,
userId,
name,
type: isFolder ? 'folder' : type,
size: isFolder ? 0 : size,
dirtype,
parent,
object: objectKey,
storageId: storage.id,
status: isFolder ? 'active' : 'draft',
onConflict,
})
if (isFolder) return c.json(matter, 201)
const contentDisposition = `attachment; filename="${name.replace(/"/g, '\\"')}"; filename*=UTF-8''${encodeURIComponent(name)}`
const uploadUrl = await s3.presignUpload(storage, objectKey, type, name)
return c.json({ ...matter, uploadUrl, contentDisposition }, 201)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
})
.patch('/batch', requireTeamRole('editor'), zValidator('json', batchPatchSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const body = c.req.valid('json')
const db = c.get('platform').db
switch (body.action) {
case 'move': {
const userId = c.get('userId')!
try {
const moved = await batchMove(db, orgId, body.ids, body.parent, userId, body.onConflict ?? 'fail')
return c.json({ moved: moved.length })
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
return c.json({ error: (e as Error).message }, 400)
}
}
case 'trash': {
const userId = c.get('userId')!
try {
const trashed = await batchTrash(db, orgId, body.ids)
await recordActivity(db, {
orgId,
userId,
action: 'batch_trash',
targetType: 'file',
targetName: `${trashed.length} items`,
metadata: { count: trashed.length, ids: body.ids },
})
return c.json({ trashed: trashed.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
}
}
}
})
.delete('/batch', requireTeamRole('editor'), zValidator('json', batchDeleteSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const userId = c.get('userId')!
const { ids } = c.req.valid('json')
const db = c.get('platform').db
try {
const uniqueIds = [...new Set(ids)]
const items = await getMatters(db, orgId, uniqueIds)
if (items.length !== uniqueIds.length) {
return c.json({ error: 'Some IDs do not belong to this organization' }, 400)
}
if (items.some((m) => m.status !== 'trashed')) {
return c.json({ error: 'Only trashed items can be permanently deleted' }, 400)
}
let purged = 0
for (const item of items) {
const ms = await collectForPurge(db, orgId, item)
purged += await purgeRecursively(db, orgId, ms)
}
await recordActivity(db, {
orgId,
userId,
action: 'batch_purge',
targetType: 'file',
targetName: `${purged} items`,
metadata: { count: purged, ids: uniqueIds },
})
return c.json({ deleted: purged })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
}
})
.get('/:id', requireTeamRole('viewer'), 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 getMatter(db, c.req.param('id'), orgId)
if (!matter) return c.json({ error: 'Not found' }, 404)
if (matter.dirtype !== DirType.FILE || !matter.object) {
return c.json(matter)
}
const storage = (await getStorage(db, matter.storageId)) as unknown as S3Storage
if (!storage) return c.json({ error: 'Storage not found' }, 404)
const trafficAllowed = await consumeTrafficIfQuotaAllows(db, orgId, matter.size ?? 0)
if (!trafficAllowed) return c.json({ error: 'Traffic quota exceeded' }, 422)
const trafficReportError = await reportTrafficForDownload(c, {
orgId,
bytes: matter.size ?? 0,
source: 'object_download',
sourceId: matter.id,
})
if (trafficReportError) return trafficReportError
let downloadUrl: string
try {
downloadUrl = await s3.presignDownload(storage, matter.object, matter.name)
} catch (e) {
await refundTraffic(db, orgId, matter.size ?? 0)
throw e
}
return c.json({ ...matter, downloadUrl })
})
.patch('/:id', requireTeamRole('editor'), zValidator('json', patchMatterSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
const body = c.req.valid('json')
switch (body.action) {
case 'update': {
try {
const matter = await updateMatter(db, c.req.param('id'), orgId, body, userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
return c.json({ error: (e as Error).message }, 400)
}
}
case 'confirm': {
try {
const { matter, quotaExceeded } = await confirmUpload(db, c.req.param('id'), orgId, {
onConflict: body.onConflict,
userId,
})
if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422)
if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
}
case 'cancel': {
const matter = await cancelDraftMatter(db, c.req.param('id'), orgId, userId)
if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404)
if (matter.object) {
const storage = (await getStorage(db, matter.storageId)) as unknown as S3Storage | null
if (storage) {
try {
await s3.deleteObject(storage, matter.object)
} catch {
// Best-effort cleanup: the browser may abort before S3 writes anything.
}
}
}
return c.json({ id: matter.id, cancelled: true })
}
case 'trash': {
const matter = await trashMatter(db, orgId, c.req.param('id'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
}
case 'restore': {
try {
const matter = await restoreMatter(db, orgId, c.req.param('id'), userId, body.onConflict ?? 'fail')
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
}
}
})
.delete('/:id', requireTeamRole('editor'), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const userId = c.get('userId')!
const db = c.get('platform').db
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)
}
const purged = await purgeRecursively(db, orgId, ms)
await recordActivity(db, {
orgId,
userId,
action: 'object_purge',
targetType: ms[0].dirtype !== DirType.FILE ? 'folder' : 'file',
targetId: ms[0].id,
targetName: ms[0].name,
metadata: { count: purged },
})
return c.json({ id: ms[0].id, deleted: true, purged })
})
.post('/copy', requireTeamRole('editor'), zValidator('json', copyMatterSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
const { copyFrom, parent, onConflict } = c.req.valid('json')
const source = await getMatter(db, copyFrom, orgId)
if (!source) return c.json({ error: 'Not found' }, 404)
const sourceSize = source.size ?? 0
if (sourceSize > 0) {
const allowed = await incrementUsageIfAllowed(db, orgId, source.storageId, sourceSize)
if (!allowed) return c.json({ error: 'Quota exceeded' }, 422)
}
let newObject = ''
if (source.object) {
const storage = (await getStorage(db, source.storageId)) as unknown as S3Storage
if (!storage) return c.json({ error: 'Storage not found' }, 404)
newObject = buildObjectKey({
uid: userId,
orgId,
rawExt: fileExt(source.name),
})
await s3.copyObject(storage, source.object, storage, newObject)
}
try {
const copy = await copyMatter(db, source, parent, newObject, { onConflict, userId })
return c.json(copy, 201)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
})
export default app