π 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.
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>:
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) β
| Config | Value |
|---|---|
staleTime | 60 seconds |
gcTime | 5 minutes |
| Retry | Smart: 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:
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:
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 β
| Hook | Endpoint | Notes |
|---|---|---|
useUserProfile() | GET /users/firebase/{uid} | Uses Firebase UID |
useFields() | GET /fields | All fields for company |
useNotes() | GET /drafts | Filters to location-tagged only |
useParcels(bbox?) | GET /parcels | Accepts bbox for map bounds |
useConversation() | GET /streaming-chat/conversations | |
useMessages(conversationId) | GET /streaming-chat/conversations/{id}/messages |
Mutations β
| Hook | Endpoint | Notes |
|---|---|---|
useCreateField() | POST /fields | |
useCreateNote() | POST /drafts/from-map-note | Optimistic update + rollback |
useUploadImage() | POST /attachments/upload β S3 β confirm | 3-step presigned URL |
useUploadAudio() | POST /call-logs/generate-s3-signed-url β S3 β transcription | 4-step |
Optimistic Update Pattern β
useCreateNote demonstrates the standard pattern:
// 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:
import { resolveId } from '@/hooks/use-company-id';
const id = resolveId(field.companyId); // always returns a string