Files
zpan/server/usecases/site/invite-code.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

73 lines
2.3 KiB
TypeScript

// The invite-codes resource usecase. Owns every business decision behind the
// /api/admin/invite-codes and /api/site/invite-codes routes — the expiry policy
// (days → absolute timestamp), the delete guards (unused-only), and activity
// logging — so the http handlers only validate input, call these functions, and
// serialize the result.
import {
type ActivityRepo,
type AppError,
badRequest,
type InviteCodeRecord,
type InviteRepo,
notFound,
} from '../ports'
export type InviteCodeDeps = {
invites: InviteRepo
activity: ActivityRepo
}
export type DeleteInviteCodeOutcome = { ok: true } | { ok: false; error: AppError }
export function listInviteCodes(
deps: Pick<InviteCodeDeps, 'invites'>,
params: { page: number; pageSize: number },
): Promise<{ items: InviteCodeRecord[]; total: number }> {
return deps.invites.list(params.page, params.pageSize)
}
export function validateInviteCode(
deps: Pick<InviteCodeDeps, 'invites'>,
code: string,
): Promise<{ valid: boolean; error?: string }> {
return deps.invites.validate(code)
}
export async function generateInviteCodes(
deps: InviteCodeDeps,
params: { userId: string; orgId: string; count: number; expiresInDays?: number },
): Promise<{ codes: InviteCodeRecord[] }> {
const { userId, orgId, count, expiresInDays } = params
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 86400000) : undefined
const codes = await deps.invites.generate(userId, count, expiresAt)
await deps.activity.record({
orgId,
userId,
action: 'invite_code_generate',
targetType: 'invite_code',
targetName: `${codes.length} codes`,
metadata: { count: codes.length, expiresInDays },
})
return { codes }
}
export async function deleteInviteCode(
deps: InviteCodeDeps,
params: { userId: string; orgId: string; id: string },
): Promise<DeleteInviteCodeOutcome> {
const { userId, orgId, id } = params
const result = await deps.invites.delete(id)
if (result === 'not_found') return { ok: false, error: notFound('Invite code not found') }
if (result === 'already_used') return { ok: false, error: badRequest('Cannot delete a used invite code') }
await deps.activity.record({
orgId,
userId,
action: 'invite_code_delete',
targetType: 'invite_code',
targetId: id,
targetName: id,
})
return { ok: true }
}