mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
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>
87 lines
3.1 KiB
TypeScript
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 }
|