Files
zpan/server/http/quotas.ts
T
Jasper VanandClaude Opus 4.8 8abca2f88c refactor(errors)!: unify error handling on typed AppError + single jsonError renderer (#445)
Collapse the two error conventions (string-reason `{ok:false,reason}` outcomes
and thrown domain-error classes) onto one. Usecases now produce typed `AppError`
values via factories (`notFound()`/`quotaExceeded()`/`featureBlocked()`/…);
handlers `throw result.error`; and `jsonError` (renamed from `renderError`) is the
single place that renders any error to an AIP-193 body + access-log line, in
`app.onError`/accessLog.

Why: the previous setup had a string→code mapping (`outcomeError` + the `OUTCOME`
table) living in parallel with a type→code mapping (`mapDomainError`), plus inline
`apiError(c, <status>, …)` calls that hand-wrote the status at every site — exactly
the drift that left the same `quota_exceeded` at 400 in one handler and 422 in the
rest. Now the status/reason live once, in the factory.

- Add `server/usecases/ports/app-error.ts`: `AppError` + factories. Status/reason
  are baked in per factory, so no usecase or handler writes an HTTP code or a
  magic-string reason. `AppError` also carries optional response headers
  (`Retry-After`) via a `rateLimited()` factory.
- Delete `apiError`, `outcomeError`, the `OUTCOME` table, and the dead `ApiError`
  class. The 67 inline guard/middleware `apiError` sites became `throw <factory>()`.
- Control-flow outcomes a handler branches on (not just renders) stay discriminated
  reasons (e.g. `deleteObject` `not_trashed`); internal shared sub-usecases
  (traffic-metering, licensing internals) keep string reasons, mapped at the boundary.
- Regenerate the Go OpenAPI client (saveShare gained a 422 response).

BREAKING CHANGE: POST /shares/{token}/objects quota rejection now returns 422
(was an inconsistent 400); every other quota path already returned 422.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 01:47:59 -04:00

87 lines
3.1 KiB
TypeScript

import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
import { pageSchema } from '@shared/schemas'
import { requireAdmin, requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { notFound } from '../usecases/ports'
import { getUserQuota, listQuotaOverview } from '../usecases/quota'
import { errorResponse, jsonContent } from './openapi'
// Quota types are already wire-shaped (timestamps are ISO strings, not Date), so
// the schemas match the usecase return types directly — no DTO mapper needed.
const currentStoragePlanSchema = z.object({
sourceId: z.string(),
packageId: z.string().nullable(),
name: z.string(),
storageBytes: z.number().int(),
trafficBytes: z.number().int(),
trafficOveragePriceCents: z.number().int().nullable(),
expiresAt: z.string().nullable(),
subscription: z.boolean(),
})
const effectiveQuotaSchema = z
.object({
orgId: z.string(),
baseQuota: z.number().int(),
entitlementQuota: z.number().int(),
quota: z.number().int(),
used: z.number().int(),
baseTrafficQuota: z.number().int(),
entitlementTrafficQuota: z.number().int(),
trafficQuota: z.number().int(),
trafficUsed: z.number().int(),
trafficPeriod: z.string(),
storagePlanName: z.string().nullable(),
storageExtraNames: z.array(z.string()),
trafficPlanName: z.string().nullable(),
trafficExtraNames: z.array(z.string()),
currentPlan: currentStoragePlanSchema.nullable(),
})
.openapi('EffectiveQuota')
const quotaOverviewItemSchema = effectiveQuotaSchema
.extend({ id: z.string(), orgName: z.string(), orgType: z.string() })
.openapi('QuotaOverviewItem')
const quotaOverviewSchema = pageSchema(quotaOverviewItemSchema, 'QuotaOverview')
const listQuotaOverviewRoute = createRoute({
operationId: 'listQuotaOverview',
summary: 'List quota overview across all spaces',
tags: ['Quotas'],
method: 'get',
path: '/',
middleware: [requireAdmin] as const,
responses: { 200: jsonContent(quotaOverviewSchema, 'Quota overview') },
})
const getMyQuotaRoute = createRoute({
operationId: 'getMyQuota',
summary: "Get the current user's effective quota",
tags: ['Quotas'],
method: 'get',
path: '/me',
middleware: [requireAuth] as const,
responses: {
200: jsonContent(effectiveQuotaSchema, 'Effective quota'),
404: errorResponse('No organization found'),
},
})
// Quota overview across all orgs (personal + team), used by the admin dashboard.
// Per-team entitlement management lives under /api/teams.
const adminQuotas = new OpenAPIHono<Env>().openapi(listQuotaOverviewRoute, async (c) => {
// The overview returns every space in one shot rather than paging, so the page
// metadata mirrors the full result.
const { items, total } = await listQuotaOverview(c.get('deps'))
return c.json({ items, total, page: 1, pageSize: items.length }, 200)
})
const userQuotas = new OpenAPIHono<Env>().openapi(getMyQuotaRoute, async (c) => {
const quota = await getUserQuota(c.get('deps'), { userId: c.get('userId')!, orgId: c.get('orgId') ?? undefined })
if (!quota) throw notFound('No organization found')
return c.json(quota, 200)
})
export { adminQuotas, userQuotas }