/** * Error code registry. * * Every code is a stable identifier you can quote in a support ticket. * The catalog drives: * - the HTTP status returned to the client * - the user-facing plain-text message (no jargon, no internal terms) * - the documentation page that lists every code with cause + fix * * **Naming convention**: SCREAMING_SNAKE_CASE, prefixed with the domain. * `EXPENSES_RECEIPT_REQUIRED` * `BERTHS_PDF_MOORING_MISMATCH` * `STORAGE_FILE_TOO_LARGE` * * **Stability contract**: codes are NEVER renamed once shipped. If the * underlying meaning shifts, retire the old code by marking it * deprecated (leave it in the registry forwarding to a new code) and * add a new one. UI / docs / external integrations may pin to a code. * * The plain-text messages are written for the rep on the phone with * the customer — no "constraint violation", no "FK", no internal * service names. The error code is the only technical artifact the * user sees, alongside the request id (`X-Request-Id`). */ export interface ErrorCodeEntry { status: number; /** Plain-language message shown to end-users (toast / inline). */ userMessage: string; /** Optional: short hint surfaced under the message in admin views. */ hint?: string; } /** * The full catalog. Adding a new code is a one-line entry — services * pass the key to `new CodedError('FOO_BAR')` and the rest is automatic. */ export const ERROR_CODES = { // ─── Generic ───────────────────────────────────────────────────────── INTERNAL: { status: 500, userMessage: 'Something went wrong on our end. Please try again, and quote the error ID below if it keeps happening.', }, UNAUTHORIZED: { status: 401, userMessage: 'Please sign in to continue.', }, SESSION_EXPIRED: { status: 401, userMessage: 'Your session has expired. Please sign in again.', }, FORBIDDEN: { status: 403, userMessage: "You don't have permission to do that. Ask an admin if you think you should.", }, NOT_FOUND: { status: 404, userMessage: "We couldn't find what you were looking for. It may have been removed.", }, RATE_LIMITED: { status: 429, userMessage: "You've done that a lot in a short time. Please wait a moment and try again.", }, // ─── Generic validation ───────────────────────────────────────────── VALIDATION_ERROR: { status: 400, userMessage: "Some of the information you entered isn't valid. Please check the highlighted fields.", }, REQUIRED_FIELD_MISSING: { status: 400, userMessage: 'A required field is missing.', }, INVALID_EMAIL: { status: 400, userMessage: "That email address doesn't look right.", }, INVALID_DATE: { status: 400, userMessage: "That date doesn't look right.", }, DUPLICATE_NAME: { status: 409, userMessage: 'Something with that name already exists. Try a different name.', }, // ─── Cross-tenant + auth ──────────────────────────────────────────── PORT_CONTEXT_REQUIRED: { status: 400, userMessage: 'Please select a port first.', }, CROSS_PORT_LINK_REJECTED: { status: 400, userMessage: 'You can only link records that belong to the same port.', }, // ─── Expenses ─────────────────────────────────────────────────────── EXPENSES_RECEIPT_REQUIRED: { status: 400, userMessage: "Please attach a receipt or tick the 'I have no receipt' acknowledgement before saving.", }, EXPENSES_INVOICE_LINKED: { status: 409, userMessage: "This expense is linked to a non-draft invoice and can't be archived. Detach it from the invoice first.", }, // ─── Berths ───────────────────────────────────────────────────────── BERTHS_PDF_MAGIC_BYTE: { status: 400, userMessage: "That file doesn't look like a real PDF. Please re-export it from the original source.", }, BERTHS_PDF_TOO_LARGE: { status: 413, userMessage: 'That PDF is too large. Reduce the file size below the configured upload cap and try again.', }, BERTHS_PDF_EMPTY: { status: 400, userMessage: 'That PDF is empty (0 bytes). Please upload the actual file.', }, BERTHS_PDF_MOORING_MISMATCH: { status: 409, userMessage: "The mooring number in the PDF doesn't match the berth you're uploading to. Confirm to override or upload to the right berth.", }, BERTHS_VERSION_ALREADY_CURRENT: { status: 409, userMessage: "That PDF version is already the active one — there's nothing to roll back to.", }, // ─── Recommender ──────────────────────────────────────────────────── RECOMMENDER_INTEREST_PORT_MISMATCH: { status: 400, userMessage: "The interest you're trying to recommend berths for belongs to a different port.", }, // ─── Storage ──────────────────────────────────────────────────────── STORAGE_FILE_TOO_LARGE: { status: 413, userMessage: 'That file is too large.', }, STORAGE_INVALID_FILE_TYPE: { status: 400, userMessage: "That file type isn't allowed here.", }, STORAGE_NOT_FOUND: { status: 404, userMessage: "We couldn't find that file. It may have been removed.", }, STORAGE_PROXY_TOKEN_INVALID: { status: 403, userMessage: 'That download link is invalid or has expired.', }, // ─── Documenso / Documents ────────────────────────────────────────── DOCUMENT_TEMPLATE_MISSING_FIELD: { status: 400, userMessage: 'The document template is missing a required field. Ask an admin to update the template.', }, DOCUMENT_UNRESOLVED_TOKENS: { status: 400, userMessage: 'The document still has unfilled placeholders. Please complete them before sending.', }, DOCUMENT_TEMPLATE_NOT_FOUND: { status: 404, userMessage: 'That document template is missing or has been removed.', }, // ─── Send-outs / Email ────────────────────────────────────────────── EMAIL_RECIPIENT_MISSING: { status: 400, userMessage: 'No email address on file for this recipient. Add one to the client first, then try again.', }, EMAIL_BODY_TOO_LARGE: { status: 400, userMessage: 'The email body is too long. Please trim it down and try again.', }, EMAIL_RATE_LIMIT_HOURLY: { status: 429, userMessage: "You've hit the hourly send limit. Please wait a bit before sending more.", }, EMAIL_BROCHURE_ARCHIVED: { status: 400, userMessage: 'That brochure is archived and can no longer be sent.', }, // ─── EOI / Interests ──────────────────────────────────────────────── EOI_NO_BERTH_LINKED: { status: 400, userMessage: 'This interest has no berth linked yet. Link a berth before generating the EOI.', }, INTEREST_INVALID_STAGE_TRANSITION: { status: 400, userMessage: "That stage change isn't allowed from the current pipeline stage.", }, // ─── Public form intake ───────────────────────────────────────────── PUBLIC_INTAKE_SECRET_MISMATCH: { status: 403, userMessage: 'This request was rejected by the security check.', }, } as const satisfies Record; export type ErrorCode = keyof typeof ERROR_CODES; /** Type-guard: is `s` one of our registered codes? */ export function isErrorCode(s: string): s is ErrorCode { return Object.prototype.hasOwnProperty.call(ERROR_CODES, s); }