All files / src/server/api http-errors.ts

100% Statements 36/36
100% Branches 44/44
100% Functions 3/3
100% Lines 33/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179                                                  5x   5x 5x                                                                           96x 7x   89x 12x         77x 1x   76x 2x   74x               18x   56x 4x         52x 11x   41x 2x   39x       24x           15x 15x         15x 15x                                 70x                                                     17x 16x 16x         16x 256x     16x 16x    
import { NextResponse } from "next/server";
import { ZodError } from "zod";
import { UnauthorizedError } from "@/lib/auth";
import {
  DeleteResultStateError,
  SupersedeStateError,
  RevisionDiscardsPinsError,
  FinalizeStateError,
  PinTargetError,
  NothingToSaveError,
  SaveKeyConflictError,
} from "@/server/services/SortResultService";
import { CurrentSessionExistsError } from "@/server/repositories/implementations/SessionRepository";
import { PreferenceExistsError } from "@/server/api/dispositions";
import { PoolGroupError } from "@/server/services/ItemService";
import { ItemInResultError } from "@/server/services/ItemPurgeService";
import { InvitationAcceptError } from "@/server/services/InvitationAcceptService";
 
/**
 * Carries its own HTTP status. For call sites that need a status the domain
 * errors below don't express (route-level auth/validation short-circuits).
 */
export class HttpError extends Error {
  constructor(
    message: string,
    readonly status: number,
  ) {
    super(message);
    this.name = "HttpError";
  }
}
 
/**
 * Shared error-to-HTTP-response mapper for API route handlers.
 *
 * Maps well-known error types to their canonical HTTP status codes so every
 * route returns consistent, correct responses without duplicated catch logic.
 * Routes should call this in their catch block and return the result directly.
 *
 * Mapping:
 *   SyntaxError              → 400 "Invalid JSON"
 *   ZodError                 → 400 "Invalid input" + flatten details
 *   DeleteResultStateError
 *   | SupersedeStateError
 *   | FinalizeStateError
 *   | CurrentSessionExistsError → 409 (error.message)
 *   | PreferenceExistsError
 *   | ItemInResultError        → 409 (error.message)
 *   NothingToSaveError
 *   | SaveKeyConflictError    → 409 (error.message + error.code, so the client can tell them apart)
 *   RevisionDiscardsPinsError → 409 (error.message + code + pinCount)
 *   PoolGroupError            → 409 if code "conflict", else 400 (error.message)
 *   HttpError
 *   | InvitationAcceptError   → error.status (error.message)
 *   PinTargetError → 404 (error.message)
 *   UnauthorizedError
 *   | Error("Unauthorized")  → 401
 *   anything else            → 500 "Something went wrong. Please try again." + errorId
 *                              (full structured error logged server-side as [api_error],
 *                              keyed by that errorId; never echoed except locally)
 *
 * The messages echoed above are curated copy authored on the typed error itself,
 * never driver text — which is why this is the ONLY place allowed to read
 * error.message (enforced by the no-restricted-syntax rule on src/app/api).
 */
export function errorResponse(error: unknown): NextResponse {
  if (error instanceof SyntaxError) {
    return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
  }
  if (error instanceof ZodError) {
    return NextResponse.json(
      { error: "Invalid input", details: error.flatten() },
      { status: 400 },
    );
  }
  if (error instanceof RevisionDiscardsPinsError) {
    return NextResponse.json({ error: error.message, code: error.code, pinCount: error.pinCount }, { status: 409 });
  }
  if (error instanceof NothingToSaveError || error instanceof SaveKeyConflictError) {
    return NextResponse.json({ error: error.message, code: error.code }, { status: 409 });
  }
  if (
    error instanceof DeleteResultStateError ||
    error instanceof SupersedeStateError ||
    error instanceof FinalizeStateError ||
    error instanceof CurrentSessionExistsError ||
    error instanceof PreferenceExistsError ||
    error instanceof ItemInResultError
  ) {
    return NextResponse.json({ error: error.message }, { status: 409 });
  }
  if (error instanceof PoolGroupError) {
    return NextResponse.json(
      { error: error.message },
      { status: error.code === "conflict" ? 409 : 400 },
    );
  }
  if (error instanceof HttpError || error instanceof InvitationAcceptError) {
    return NextResponse.json({ error: error.message }, { status: error.status });
  }
  if (error instanceof PinTargetError) {
    return NextResponse.json({ error: error.message }, { status: 404 });
  }
  if (
    error instanceof UnauthorizedError ||
    (error instanceof Error && error.message === "Unauthorized")
  ) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  // Unmapped errors carry driver text (relation names, constraint names, sometimes
  // column values). ALL of it goes to the server log, keyed by an errorId the caller
  // also gets, so a developer with a failing request can find the exact log line —
  // strictly more useful than echoing one raw message string (purses-hiez).
  const errorId = crypto.randomUUID();
  const detail = describeError(error);
  // Same greppable-prefix + one-JSON-payload shape as [db_error] (src/lib/db-error-log.ts)
  // so both are searchable the same way in Vercel runtime logs. This replaces the old
  // console.error 1:1 — the routes
  // that deliberately don't log (sessions decisions/batch) still log exactly once, here.
  console.error("[api_error]", JSON.stringify({ errorId, error: detail }));
  return NextResponse.json(
    {
      error: "Something went wrong. Please try again.",
      errorId,
      // LOCAL ONLY. NODE_ENV — never VERCEL_ENV: Vercel sets NODE_ENV=production for
      // PREVIEW deploys too, and preview URLs are internet-reachable and get shared,
      // so a VERCEL_ENV gate would leak driver text on every preview.
      ...(process.env.NODE_ENV !== "production" ? { detail } : {}),
    },
    { status: 500 },
  );
}
 
// The fields postgres-js actually populates on a PostgresError: it Object.assigns the
// parsed ErrorResponse onto the Error (node_modules/postgres/src/connection.js
// `errorFields`). Verified against the installed driver — not invented. Deliberately
// omits `query`/`parameters` (they carry user values → PII).
const PG_ERROR_FIELDS = [
  "code",
  "severity",
  "severity_local",
  "detail",
  "hint",
  "position",
  "internal_position",
  "internal_query",
  "where",
  "schema_name",
  "table_name",
  "column_name",
  "constraint_name",
  "file",
  "line",
  "routine",
] as const;
 
/**
 * Everything extractable from an unknown throwable, as a JSON-serializable record.
 *
 * name/message/stack are read EXPLICITLY because they are non-enumerable: a naive
 * JSON.stringify(error) serializes to "{}" and loses the entire error. `cause` is
 * unwrapped recursively — drizzle/postgres wrap the real driver error underneath.
 */
function describeError(error: unknown, depth = 0): Record<string, unknown> {
  if (!(error instanceof Error)) return { value: String(error) };
  const e = error as Error & Record<string, unknown>;
  const detail: Record<string, unknown> = {
    name: e.name,
    message: e.message,
    stack: e.stack,
  };
  for (const field of PG_ERROR_FIELDS) {
    if (e[field] !== undefined) detail[field] = e[field];
  }
  // Depth cap: a self-referential cause chain must not spin forever.
  if (e.cause !== undefined && depth < 4) detail.cause = describeError(e.cause, depth + 1);
  return detail;
}