Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

Conventions

The response envelope, error shapes, pagination semantics, and the scope parameter — the rules the generated reference does not state.

Base URL and prefix

Every application route is prefixed with api. The one exception is GET /health, excluded in the setGlobalPrefix() call and therefore served at the root.

http://localhost:8000/api/…      application routes
http://localhost:8000/health     liveness
http://localhost:8000/api-docs   Swagger UI

The response envelope

Successful responses wrap their payload in an object with message and data:

{
  "message": "Collection fetched successfully",
  "data": {
    "id": "CLa7Bk9x2Q",
    "title": "Brand assets",
    "description": "Logos and wordmarks"
  }
}

Deletes return the message alone:

{ "message": "Collection deleted successfully" }

The SPA relies on this shape. api/tala.ts types it as Envelope<T> and unwraps .data for every caller:

type Envelope<T> = { data: T; message?: string };

const api = async <T>(path: string, options?: RequestInit) =>
  (await fetchJSON<Envelope<T>>(`${API_BASE_URL}/api${path}`, options)).data;

Two endpoints break the pattern and return data with no message:

Endpoint Returns
POST /api/auth/refresh-token { data: { token, refreshToken } }
GET /api/auth/user { data: <user> }

The second matters more than it looks: AuthProvider calls it on every mount, so it is the most frequently hit endpoint in the app. Type an envelope as { data: T; message?: string }, not as a required pair.

Errors

Errors are not enveloped. They carry Nest’s standard shape:

{ "statusCode": 404, "message": "Collection with id CLa7Bk9x2Q not found" }
Status Raised by
400 ValidationPipe — a declared field failed its constraint
401 AuthGuard — the bearer token is missing, malformed, or expired, or the account is not VERIFIED
403 OrganizationGuard / OrganizationRoleGuard — no active membership, or insufficient org role
404 ownership mismatch, deliberately indistinguishable from “does not exist”
409 unhandled Postgres 23505 unique violation — retryable
429 ThrottlerGuard — over 60 requests in 60 seconds from this IP
500 anything unexpected; the real message and stack stay server-side

The frontend surfaces these through an ApiError carrying status and the parsed body:

export class ApiError extends Error {
  status: number;
  body: any;
}

Pagination

Paginated endpoints take three query parameters, built by PaginatorBuilder:

Parameter Type Default Notes
page string → int 0 Zero-based. Clamped to ≥ 0
perPage string → int 10 Clamped to [1, 100]
q string "" Case-insensitive LIKE. Collections match on title and description; assets match on asset_name only

The clamp exists so a caller cannot request ?perPage=100000000 and force a memory or database spike. A non-numeric value falls back to the default rather than erroring.

Queries carry a stable id DESC tiebreaker alongside their primary sort, so rows do not skip or duplicate across pages:

.orderBy("collections.createdAt", "DESC")
.addOrderBy("collections.id", "DESC")
.take(paginator.perPage)
.skip(paginator.page * paginator.perPage)

Scope

Listing endpoints for collections and assets take ?scope=personal|organization, defaulting to personal.

GET /api/collections?scope=organization&q=brand&page=0&perPage=20
GET /api/assets?scope=personal&projectId=PRa7Bk9x2Q

personal filters on the caller’s user_id; organization filters on the resolved orgContext.organizationId. The org context is resolved by OrganizationGuard regardless of scope, so a caller with no active membership gets 403 even when asking for personal rows.

GET /api/assets additionally accepts projectId and collectionId filters.

scope is also a body field on create

Reading is not the only place scope appears. POST /api/collections/create takes it in the body, and it is the only switch that produces an organization-owned collection:

POST /api/collections/create
Content-Type: application/json

{ "title": "Brand assets", "description": "Logos and wordmarks", "scope": "organization" }
Field Rules
title required, ≤ 100 characters
description optional, ≤ 2000 characters
scope optional, personal | organization, defaults to personal

Omit scope and you get a personal collection every time, whatever x-organization-id says. scope: "organization" binds the row to the resolved orgContext.organizationId instead — and because ownership is exclusive, that choice is permanent for the row.

Uploads use a different key for the same idea: ownerScope on POST /api/upload/initiate. See Uploads.

Idempotency and concurrency

There is no idempotency-key support. POST /api/upload/complete is the one write where a retry after a network failure could duplicate an asset — the client is expected to POST /api/upload/abort instead.

Refresh is the one endpoint with explicit replay handling, and it punishes a replay rather than ignoring it: see token rotation.

Was this page helpful?