Skip to content

🌐 API & Data Layer ​

HTTP Client (lib/api/api-client.ts) ​

The API client automatically attaches a Firebase ID token as a Bearer header on every request. It adds Sentry breadcrumbs for requests and errors, and remaps localhost β†’ 10.0.2.2 on Android emulators.

ts
import { apiClient } from '@/lib/api/api-client';

// All methods return the parsed JSON body (typed with generics)
const field = await apiClient.get<Field>(`/fields/${id}`);
await apiClient.post<Note>('/drafts/from-map-note', payload);
await apiClient.patch<User>(`/users/${uid}`, { name });
await apiClient.delete(`/fields/${id}`);

Base URL is read from EXPO_PUBLIC_API_URL at build time.

Streaming (SSE) ​

The LLM chat feature uses a streaming endpoint. Call apiClient.stream() to get an AsyncIterable<string>:

ts
for await (const chunk of apiClient.stream(
  `/streaming-chat/conversations/${id}/stream`,
)) {
  // chunk is a raw SSE line
}

Use parseSSE() from lib/chat/ to extract start, chunk, end, and error events.

TanStack Query (lib/api/query-client.ts) ​

ConfigValue
staleTime60 seconds
gcTime5 minutes
RetrySmart: no retry on 4xx, 3 retries on network errors

Error callbacks pipe into Sentry automatically.

Error Handling (lib/api/api-error.ts) ​

All HTTP errors are normalized to ApiError:

ts
class ApiError extends Error {
  statusCode: number;
  messageKey: string;
  isValidationError: boolean; // 422
  isUnauthorized: boolean; // 401
  isForbidden: boolean; // 403
  isNotFound: boolean; // 404
}

Use captureError() from lib/sentry/capture-error.ts to report errors:

ts
import { captureError } from '@/lib/sentry/capture-error';

try {
  await apiClient.post('/fields', payload);
} catch (err) {
  captureError(err, { tags: { feature: 'field-creation' } });
}

captureError distinguishes ApiError from generic errors and adds structured context.

Custom Hooks ​

All data hooks wrap TanStack Query with company-scoped params. companyId comes from useCompanyId().

Data Fetching ​

HookEndpointNotes
useUserProfile()GET /users/firebase/{uid}Uses Firebase UID
useFields()GET /fieldsAll fields for company
useNotes()GET /draftsFilters to location-tagged only
useParcels(bbox?)GET /parcelsAccepts bbox for map bounds
useConversation()GET /streaming-chat/conversations
useMessages(conversationId)GET /streaming-chat/conversations/{id}/messages

Mutations ​

HookEndpointNotes
useCreateField()POST /fields
useCreateNote()POST /drafts/from-map-noteOptimistic update + rollback
useUploadImage()POST /attachments/upload β†’ S3 β†’ confirm3-step presigned URL
useUploadAudio()POST /call-logs/generate-s3-signed-url β†’ S3 β†’ transcription4-step

Optimistic Update Pattern ​

useCreateNote demonstrates the standard pattern:

ts
// 1. Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['notes', companyId] });

// 2. Snapshot current cache
const snapshot = queryClient.getQueryData(['notes', companyId]);

// 3. Apply optimistic item
queryClient.setQueryData(['notes', companyId], (old) => [
  { ...newNote, id: `optimistic-${Date.now()}` },
  ...(old ?? []),
]);

// 4. On error: rollback
onError: () => queryClient.setQueryData(['notes', companyId], snapshot),

MongoDB ObjectId Normalization ​

API responses may return ObjectIds as { $oid: "..." } objects. Use resolveId() from useCompanyId to normalize:

ts
import { resolveId } from '@/hooks/use-company-id';

const id = resolveId(field.companyId); // always returns a string