mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 15:49:00 +08:00
8abca2f88c
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>
124 lines
3.7 KiB
TypeScript
124 lines
3.7 KiB
TypeScript
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'
|
|
import { listNotificationsQuerySchema, pageSchema } from '@shared/schemas'
|
|
import { requireAuth } from '../middleware/auth'
|
|
import type { Env } from '../middleware/platform'
|
|
import {
|
|
getUnreadCount,
|
|
listNotifications,
|
|
markAllNotificationsRead,
|
|
markNotificationRead,
|
|
} from '../usecases/notification'
|
|
import { type NotificationRecord, notFound } from '../usecases/ports'
|
|
import { errorResponse, jsonContent } from './openapi'
|
|
|
|
const notificationSchema = z
|
|
.object({
|
|
id: z.string(),
|
|
userId: z.string(),
|
|
type: z.string(),
|
|
title: z.string(),
|
|
body: z.string(),
|
|
refType: z.string().nullable(),
|
|
refId: z.string().nullable(),
|
|
metadata: z.string().nullable(),
|
|
readAt: z.string().nullable(),
|
|
createdAt: z.string(),
|
|
})
|
|
.openapi('Notification')
|
|
|
|
type NotificationDTO = z.infer<typeof notificationSchema>
|
|
|
|
// Serialize the domain record's `Date` timestamps to ISO strings — the one place
|
|
// the domain type crosses to the wire.
|
|
function toNotificationDTO(n: NotificationRecord): NotificationDTO {
|
|
return {
|
|
id: n.id,
|
|
userId: n.userId,
|
|
type: n.type,
|
|
title: n.title,
|
|
body: n.body,
|
|
refType: n.refType,
|
|
refId: n.refId,
|
|
metadata: n.metadata,
|
|
readAt: n.readAt ? n.readAt.toISOString() : null,
|
|
createdAt: n.createdAt.toISOString(),
|
|
}
|
|
}
|
|
|
|
// The unread count is intentionally NOT part of the list envelope — it lives only
|
|
// at GET /stats so the list shares the one Page<T> shape with every other resource.
|
|
const notificationPageSchema = pageSchema(notificationSchema, 'NotificationPage')
|
|
|
|
const listRoute = createRoute({
|
|
operationId: 'listNotifications',
|
|
summary: 'List notifications',
|
|
tags: ['Notifications'],
|
|
method: 'get',
|
|
path: '/',
|
|
request: { query: listNotificationsQuerySchema },
|
|
responses: { 200: jsonContent(notificationPageSchema, 'Notifications') },
|
|
})
|
|
|
|
const statsRoute = createRoute({
|
|
operationId: 'getNotificationStats',
|
|
summary: 'Get unread notification count',
|
|
tags: ['Notifications'],
|
|
method: 'get',
|
|
path: '/stats',
|
|
responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Unread count') },
|
|
})
|
|
|
|
const markReadRoute = createRoute({
|
|
operationId: 'markNotificationRead',
|
|
summary: 'Mark a notification read',
|
|
tags: ['Notifications'],
|
|
method: 'patch',
|
|
path: '/{id}',
|
|
request: { params: z.object({ id: z.string() }) },
|
|
responses: {
|
|
204: { description: 'Marked read' },
|
|
404: errorResponse('Not found'),
|
|
},
|
|
})
|
|
|
|
const markAllReadRoute = createRoute({
|
|
operationId: 'markAllNotificationsRead',
|
|
summary: 'Mark all notifications read',
|
|
tags: ['Notifications'],
|
|
method: 'patch',
|
|
path: '/',
|
|
responses: { 200: jsonContent(z.object({ count: z.number().int() }), 'Number marked read') },
|
|
})
|
|
|
|
const app = new OpenAPIHono<Env>()
|
|
app.use(requireAuth)
|
|
|
|
export const notifications = app
|
|
.openapi(listRoute, async (c) => {
|
|
const { page, pageSize, unread } = c.req.valid('query')
|
|
const result = await listNotifications(c.get('deps'), c.get('userId')!, {
|
|
page,
|
|
pageSize,
|
|
unreadOnly: unread === 'true',
|
|
})
|
|
return c.json(
|
|
{
|
|
items: result.items.map(toNotificationDTO),
|
|
total: result.total,
|
|
page,
|
|
pageSize,
|
|
},
|
|
200,
|
|
)
|
|
})
|
|
.openapi(statsRoute, async (c) => {
|
|
const count = await getUnreadCount(c.get('deps'), c.get('userId')!)
|
|
return c.json({ count }, 200)
|
|
})
|
|
.openapi(markReadRoute, async (c) => {
|
|
const found = await markNotificationRead(c.get('deps'), c.get('userId')!, c.req.valid('param').id)
|
|
if (!found) throw notFound()
|
|
return c.body(null, 204)
|
|
})
|
|
.openapi(markAllReadRoute, async (c) => c.json(await markAllNotificationsRead(c.get('deps'), c.get('userId')!), 200))
|