> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hr-easy.nlead.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# API route conventions

> The seven-step skeleton every route handler follows, and why each step is non-negotiable.

Every API route follows the same skeleton. Not "usually" — every one.

```typescript theme={null}
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  // 1. Auth — always first
  const session = await getSession();
  if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  // 2. Permission check
  if (!hasPermission(session.user.role, "module:action")) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  // 3. Ownership / IDOR check
  const resource = await db.resource.findUnique({ where: { id: (await params).id } });
  if (!resource) return NextResponse.json({ error: "Not found" }, { status: 404 });
  if (resource.tenantId !== session.user.tenantId) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  // 4. Input validation with Zod (POST/PATCH)
  const parsed = schema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Validation failed", details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  // 5. Business logic in the service layer — never inline
  const result = await myService.doThing(parsed.data);

  // 6. Audit log every state-changing operation
  await logAudit({ action: "RESOURCE_UPDATED", entityId: resource.id, userId: session.user.id });

  // 7. Standardised response
  return NextResponse.json({ success: true, data: result });
}
```

## The rules behind the steps

<AccordionGroup>
  <Accordion title="Ownership is checked on EVERY verb">
    Checking ownership on `PATCH` but not on `GET` is an IDOR vulnerability, and it is
    the single most repeated review finding on this codebase. When you touch one handler
    in a file, check the others in the same file.
  </Accordion>

  <Accordion title="Routes never throw">
    Services may throw. Routes catch and return `{ success: false, error }`. An
    uncaught throw in a route is a stack trace where a JSON error belongs.
  </Accordion>

  <Accordion title="Never return a raw Prisma object">
    Project to a DTO. An over-broad select that leaks a token, a password hash or an
    unrelated column is a data breach with a `200` status code.
  </Accordion>

  <Accordion title="Business logic lives in services">
    A route handler that contains a decision has put business logic in the transport
    layer, where it cannot be unit-tested or reused.
  </Accordion>

  <Accordion title="Audit every state change">
    Every `POST`, `PATCH`, `PUT` and `DELETE` — including error paths and fallback
    redirects. CI fails a state-changing route with no audit call in its import graph
    and no justified allowlist entry.
  </Accordion>
</AccordionGroup>

## Bounded queries

Every `findMany` in an API route must carry `take:` or `cursor:`. CI enforces it.

* List endpoints clamp a `limit` query parameter — default 50–100, maximum 200–1000
  depending on the list — and return a `pagination: { total, limit, offset, hasMore }`
  block.
* Queries that are bounded by their nature still take an explicit `take` cap, with a
  comment saying why that number.

## Scoped reads

Where a permission controls breadth rather than access — `applications:view-all`,
`staff-absence:view-all`, `salaries:view-all` — resolve the scope centrally rather than
re-deriving it per route. The manager graph and the position scope both have shared
resolvers; use them, so a scoping fix lands everywhere at once.

## Legal entity on writes

Reads may use the ambient `getCurrentLegalEntityId()`. **Writes may not.** Every write
persists an explicit, tenant-validated `legalEntityId` from `resolveWriteLegalEntity`.
The ambient resolver is on a path to becoming cookie-driven, and a user-controlled
cookie must never decide who legally employs a person.

## The sibling-path rule

The second most repeated review finding is *"the fix is correct but incomplete — the
same bug still lives in a parallel code path"*. Before pushing:

1. `grep` for every sibling call site of what you changed — both handlers on a route,
   every resolver in a family, every caller of the service you patched.
2. Where two functions must stay in sync by construction, pin them with a same-shape
   test so divergence fails CI rather than surviving to the next refactor.

## Error handling

```typescript theme={null}
// WRONG — silently swallows the failure
try {
  await risky();
} catch {}

// CORRECT — log, then handle
try {
  await risky();
} catch (error) {
  logger.error("risky failed", { error });
  return NextResponse.json({ success: false, error: "…" }, { status: 500 });
}
```

Empty catch blocks are an ESLint **error**. A catch block must log **and** do one of:
re-throw, return an error response, or set UI error state. `console.error` followed by
carrying on is not handling.

`JSON.parse()` is never called without a try/catch — malformed JSON from the database
crashes the process.
