Files
zpan/server/http/notifications.ts
T
Jasper Van 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

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))