Data shapes
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
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
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;
}
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
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
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.
Billing and dashboard
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.
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, 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.
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.
