---
title: Data shapes
description: Every domain interface the SPA receives from api/tala.ts, what each endpoint returns, and the three shapes that mislead you if you skim them.
---

These are the types the SPA actually works in, all exported from `src/api/tala.ts`. They describe what comes back **after** the envelope is unwrapped — `api<T>()` returns `.data`, so a function typed `Promise<Project[]>` resolves to the array, not to `{ message, data }`.

## Unions

```ts
export type Scope = "personal" | "organization";
export type WorkStatus = "QUEUED" | "IN_PROGRESS" | "COMPLETED";
export type WorkPriority = "LOW" | "MEDIUM" | "HIGH";
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
```

`WorkStatus` and `WorkPriority` are shared by projects and tasks — the backend backs both with the same Postgres enum types, so a value added for one applies to both.

## Organizations

```ts
interface Organization {
  id: string;
  name: string;
  slug: string;
  role: OrgRole;          // the caller's role, not the org's
}

interface Member {
  id: string;             // the membership id, NOT the user id
  role: OrgRole;
  status: string;
  user: { id: string; fullName?: string; email: string; imageUrl?: string };
}

interface Invitation {
  id: string;
  email: string;
  role: OrgRole;
  expiresAt: string;
  acceptedAt?: string;
  token?: string;
  organization?: Organization;
}
```

:::danger[`Member.id` is not `Member.user.id`]
Two different identifiers sit one level apart. `Member.id` is the row in `organization_members`; `Member.user.id` is the person.

Everything membership-shaped takes the **membership** id — `PATCH /organizations/:id/members/:memberId`, `DELETE .../:memberId`, and the `memberIds` array on `PUT /tasks/:id/assignees`. Passing a user id there returns 404 `Member not found`, which reads like the person does not exist.
:::

`Member.status` is typed `string`, not a union, even though the backend only ever sends `ACTIVE`, `INVITED`, or `SUSPENDED`. Narrow it yourself if you branch on it.

`Invitation.organization` is populated by `GET /organizations/invitations/:token` so the invite screen can name the organization before the user commits. It is absent on the invitations returned inside `GET /organizations/:id/members`.

## Work items

```ts
interface Project {
  id: string;
  name: string;
  description?: string;
  status: WorkStatus;
  priority: WorkPriority;
  startDate?: string;     // date only
  dueDate?: string;       // date only
  position: number;
  progress: number;
}

interface Task {
  id: string;
  title: string;
  description?: string;
  status: WorkStatus;
  priority: WorkPriority;
  startAt?: string;       // timestamp
  dueAt?: string;         // timestamp
  position: number;
  project: Project;       // always expanded, never an id
  assignees: Member[];    // memberships, not users
}

interface TaskComment {
  id: string;
  body: string;
  author: { id: string; fullName?: string; email: string };
  createdAt: string;
  assets: AssetDetail[];
}
```

Projects carry `startDate` / `dueDate` as **dates**; tasks carry `startAt` / `dueAt` as **timestamps**. The columns differ in the database too, so do not format them with the same helper.

`position` is the board ordering within a status column. Move a card with `PATCH /projects/:id/position` or `PATCH /tasks/:id/position`, sending both the new `status` and the new `position` — the second is not inferred from the first.

## Library

```ts
interface Collection {
  id: string;
  title: string;
  description?: string;
  isFavorite?: boolean;
}

interface AssetDetail {
  id: string;
  assetName: string;
  assetType: string;      // MIME type, e.g. "image/png"
  currentVersion: number;
  project?: Project;
  isFavorite?: boolean;
}

interface AssetVersion {
  id: string;
  versionNumber: number;
  changeNote: string | null;
  createdAt: string;
}
```

`isFavorite` is computed per caller, not stored on the row — the backend joins `asset_favorites` / `collection_favorites` for the requesting user. It is optional because endpoints that do not perform that join omit it rather than sending `false`.

:::warning[`AssetDetail` has no URL]
Neither the short nor the long S3 URL is exposed on this interface. To get a file, call `downloadAsset(id)` for a presigned URL that is valid for **five minutes**. Do not cache it.
:::

## Billing and dashboard

```ts
interface Plan {
  id: string;
  name: string;
  minimumSeat: number;
  maximumSeat: number;
}

interface DashboardSummary {
  projectCount: number;
  dueTaskCount: number;   // not COMPLETED, due within 7 days
  memberCount: number;    // ACTIVE memberships only
  recentActivity: {
    id: string;
    title: string;
    projectName: string;
    status: WorkStatus;
    occurredAt: string;
  }[];
}
```

`maximumSeat` on the organization owner's plan is what the seat limit is enforced against — see [Organizations](/api/organizations#what-goes-wrong).

`recentActivity` is the eight most recently updated tasks from the last 30 days. Despite the name it is not the audit log; that is [`/api/timeline`](/status/integration), which has no consumer.

## Every function

| Function | Calls |
| --- | --- |
| `organizations()` | `GET /organizations` |
| `createOrganization(name)` | `POST /organizations` |
| `organizationMembers(id)` | `GET /organizations/:id/members` → `{ members, invitations }` |
| `inviteMember(id, email, role)` | `POST /organizations/:id/invitations` |
| `getInvitation(token)` | `GET /organizations/invitations/:token` |
| `acceptInvitation(token)` | `POST /organizations/invitations/:token/accept` |
| `updateMember(orgId, memberId, role)` | `PATCH /organizations/:orgId/members/:memberId` |
| `removeMember(orgId, memberId)` | `DELETE /organizations/:orgId/members/:memberId` |
| `projects()` | `GET /projects` |
| `createProject(input)` | `POST /projects` |
| `moveProject(id, status, position)` | `PATCH /projects/:id/position` |
| `tasks(params)` | `GET /tasks?from&to&projectId&status&assigneeId` |
| `projectTasks(projectId)` | `GET /projects/:projectId/tasks` |
| `createTask(projectId, input)` | `POST /projects/:projectId/tasks` |
| `setTaskAssignees(taskId, memberIds)` | `PUT /tasks/:id/assignees` |
| `taskComments(taskId)` | `GET /tasks/:taskId/comments` |
| `createTaskComment(taskId, body, assetIds)` | `POST /tasks/:taskId/comments` |
| `collections(scope, q)` | `GET /collections?scope&q` |
| `createCollection(input)` | `POST /collections/create` |
| `favoriteCollection(id, favorite)` | `PATCH /collections/:id/favorite` — **exported, never called** |
| `assets(scope, q)` | `GET /assets?scope&q` |
| `favoriteAsset(id, favorite)` | `PATCH /assets/:id/favorite` |
| `downloadAsset(id)` | `GET /assets/:id/download` → `{ url }` |
| `publishAsset(id, input)` | `POST /assets/:id/publish` |
| `getAssetVersions(id)` | `GET /assets/:id/versions` — **exported, never called** |
| `restoreAssetVersion(id, n)` | `PATCH /assets/:id/versions/restore/:n` |
| `updateProfile(fullName)` | `PATCH /auth/user/profile` |
| `startAvatarUpload(contentType)` | `POST /auth/user/avatar-upload` → `{ key, uploadUrl }` |
| `completeAvatarUpload(key)` | `POST /auth/user/avatar-upload/complete` |
| `getPlans()` | `GET /plan` |
| `dashboardSummary()` | `GET /dashboard/summary` |
| `verifyPasswordResetToken(token)` | `GET /auth/verify-token?token` |

The two marked **exported, never called** are wrappers with no caller. `favoriteCollection` is the server-backed version of a favourite that the UI currently keeps only in `localStorage`; `getAssetVersions` is the listing that version restore ships without. Wiring either is a small change — see [What's wired](/status/integration).

## Presigned URL lifetimes

Three different windows, none of them configurable from the client:

| URL | Valid for | From |
| --- | --- | --- |
| Multipart upload part | **1 hour** | `POST /api/upload/initiate` |
| Avatar upload | **15 minutes** | `POST /api/auth/user/avatar-upload` |
| Asset download | **5 minutes** | `GET /api/assets/:id/download` |

Fetch a download URL at the moment of use. Putting one in component state and rendering it later is the common way to ship a broken image.
