@startsimpli/api 0.5.18 → 0.5.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,219 +1,322 @@
1
1
  # @startsimpli/api
2
2
 
3
- Type-safe Django REST API client for StartSimpli Next.js applications.
3
+ Type-safe HTTP client + per-domain wrappers for the Django backend (`start-simpli-api`). One package, used by every Next.js app in the monorepo: `raise-simpli`, `market-simpli`, `trade-simpli`, `vault-web`.
4
4
 
5
- ## Overview
5
+ No Prisma, no DB drivers — this is the only sanctioned way for a Next.js app in this monorepo to talk to Django.
6
6
 
7
- This package provides a type-safe TypeScript client for the Django REST API backend (`start-simpli-api`). It handles authentication, error normalization, pagination, and provides endpoint wrappers for all Django models.
7
+ ## Install
8
8
 
9
- **IMPORTANT**: This is a Django REST API client - NO Prisma, NO database code. All data lives in the Django backend.
9
+ This is a workspace package; consumers depend on it via pnpm workspaces and Next.js needs to transpile its TS source.
10
10
 
11
- ## Installation
11
+ ```jsonc
12
+ // app's package.json
13
+ {
14
+ "dependencies": {
15
+ "@startsimpli/api": "workspace:*"
16
+ }
17
+ }
18
+ ```
12
19
 
13
- ```bash
14
- npm install @startsimpli/api
20
+ ```ts
21
+ // app's next.config.ts — REQUIRED so Next compiles the package's src/
22
+ const config = {
23
+ transpilePackages: [
24
+ '@startsimpli/api',
25
+ // … other @startsimpli/* packages
26
+ ],
27
+ };
15
28
  ```
16
29
 
17
- ## Usage
30
+ The package's `main`/`types` point at `src/index.ts` (no build step), so without `transpilePackages` Next will reject the TypeScript.
18
31
 
19
- ### Basic Setup
32
+ ## Quick start
20
33
 
21
- ```typescript
34
+ ```ts
35
+ // src/lib/api.ts (real shape used by trade-simpli and vault-web)
22
36
  import { createStartSimpliApi } from '@startsimpli/api';
23
-
24
- const api = createStartSimpliApi({
25
- baseUrl: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api/v1',
26
- getToken: async () => {
27
- // Get token from your auth provider
28
- const session = await getServerSession();
29
- return session?.accessToken || null;
37
+ import { getRegisteredToken } from '@/infrastructure/auth';
38
+
39
+ export const api = createStartSimpliApi({
40
+ baseUrl: process.env.NEXT_PUBLIC_API_URL || '',
41
+ getToken: () => getRegisteredToken(),
42
+ onUnauthorized: () => {
43
+ if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
44
+ window.location.href = '/login';
45
+ }
30
46
  },
31
47
  });
32
48
 
33
- // Use API endpoints
34
- const contacts = await api.contacts.list({ tier: 1 });
35
- const orgs = await api.organizations.getByStage('seed');
49
+ export type Api = typeof api;
36
50
  ```
37
51
 
38
- ### Contacts API
52
+ `createStartSimpliApi` returns an object with one wrapper per domain plus the underlying `client`:
53
+
54
+ ```ts
55
+ api.contacts // ContactsApi
56
+ api.organizations // OrganizationsApi
57
+ api.entities // EntitiesApi (low-level tags / metrics / profiles / attributes)
58
+ api.workflows // WorkflowsApi
59
+ api.messages // MessagesApi
60
+ api.messageTemplates// MessageTemplatesApi
61
+ api.funnels // FunnelsApi
62
+ api.featureFlags // FeatureFlagsApi
63
+ api.users // UsersApi
64
+ api.enrichment // EnrichmentApi (Apollo)
65
+ api.targetLists // TargetListsApi
66
+ api.markets // MarketsApi (trade-simpli)
67
+ api.vault // VaultApi (vault-web)
68
+ api.client // raw ApiClient — escape hatch for custom endpoints
69
+ ```
39
70
 
40
- ```typescript
41
- // List contacts with filters
42
- const contacts = await api.contacts.list(
43
- { tier: 1, has_linkedin: true }, // filters
44
- { page: 1, page_size: 20 }, // pagination
45
- { ordering: '-created_at' } // sorting
46
- );
71
+ Every config field is optional. `baseUrl: ''` makes calls relative so Next.js rewrites/proxy routes can intercept them.
72
+
73
+ ## Public surface
74
+
75
+ Grouped by purpose. See `src/index.ts` for the full export list.
76
+
77
+ | Area | Exports |
78
+ |---|---|
79
+ | Factory | `createStartSimpliApi` |
80
+ | Core HTTP | `ApiClient`, `createApiClient`, `FetchWrapper` |
81
+ | Domain wrappers | `ContactsApi`, `OrganizationsApi`, `EntitiesApi`, `WorkflowsApi`, `MessagesApi`, `MessageTemplatesApi`, `FunnelsApi`, `FeatureFlagsApi`, `UsersApi`, `EnrichmentApi`, `TargetListsApi`, `MarketsApi`, `VaultApi` |
82
+ | Generic entity adapter | `createEntityAdapter` |
83
+ | Error contract (HTTP layer) | `ApiException`, `parseErrorResponse`, `handleFetchError`, `isApiException`, `isValidationError`, `isAuthError`, `isNotFoundError` |
84
+ | Application error hierarchy (server-side domain) | `AppError`, `AppErrorCode`, `ValidationError`, `AuthenticationError`, `AuthorizationError`, `NotFoundError`, `ConflictError`, `RateLimitError`, `DatabaseError`, `ExternalServiceError`, `isPrismaError`, `toAppError` |
85
+ | Sanitization | `sanitizeHtml`, `sanitizeSearchQuery`, `validateIdentifier`, `sanitizeUserInput`, `sanitizeChatMessage`, `MAX_PROMPT_LENGTH`, `MAX_CHAT_MESSAGE_LENGTH` |
86
+ | CORS | `getCorsHeaders`, `applyCorsHeaders`, `createCorsMiddleware` |
87
+ | Rate limiting | `createRateLimiter`, `getClientIP` |
88
+ | Server cache | `CacheStore`, `CacheManager`, `getCacheManager`, `resetCacheManager` |
89
+ | Feature flags React glue | `FeatureFlagProvider`, `useFeatureFlags` |
90
+ | Server-side data hooks | `useServerList`, `useServerDetail` |
91
+ | Funnel guards | `isFunnelRunConflict`, `isFunnelValidationError` |
92
+ | Message stats helper | `calculateStatsFromMessages` |
93
+ | Env helpers | `getRequiredEnv`, `getOptionalEnv`, `validateEnvVars` |
94
+ | Constants | `ENDPOINTS`, `COMPANY_SIZE_OPTIONS`, `LIFECYCLE_STAGE_OPTIONS`, `REVENUE_RANGE_OPTIONS`, `ACTIVITY_TYPE_OPTIONS`, `ACTIVITY_OUTCOME_OPTIONS`, `LOSS_REASON_OPTIONS`, `DEAL_STAGE_OPTIONS` |
95
+ | Utilities | re-exported from `./utils` (`buildFilterParams`, `mergeQueryParams`, case-transform helpers, etc.) |
96
+ | Types | re-exported from `./types` (Contact, Organization, Entity, Tag, Metric, Profile, Attribute, PaginatedResponse, ApiError, all per-domain Create/Update/Filter shapes) |
97
+
98
+ Middleware (`withAuth`, `withValidation`, `withErrorHandling`) is **not** in the main entry — import it from `@startsimpli/api/middleware` to keep server-only Next deps out of client/test bundles.
99
+
100
+ ## Per-domain wrappers
101
+
102
+ All wrapper return values are camelCase. Filters/inputs are camelCase too — the fetch layer snake-cases them before they hit Django (see "snake↔camel" below).
103
+
104
+ ### `ContactsApi` — `/api/v1/contacts/`
105
+
106
+ Generic CRUD over the `Contact` entity, with assertion-based writes (`writeTags`, `writeMetrics`, `writeProfiles`).
107
+
108
+ ```ts
109
+ // market-simpli/src/modules/prospects/api/client.ts
110
+ const response = await api.contacts.list(params);
111
+ const created = await api.contacts.create({ name, email, writeTags: ['tier_1'] });
112
+ await api.contacts.update(id, data);
113
+ await api.contacts.delete(id);
114
+ ```
47
115
 
48
- // Get single contact
49
- const contact = await api.contacts.get('contact-uuid');
50
-
51
- // Create contact with assertions
52
- const newContact = await api.contacts.create({
53
- name: 'John Doe',
54
- email: 'john@example.com',
55
- title: 'Partner',
56
- write_tags: ['tier_1', 'focus:fintech'],
57
- write_metrics: { enrichment_score: 0.95 },
58
- write_profiles: { linkedin: 'https://linkedin.com/in/johndoe' },
59
- });
116
+ Also: `get`, `search`, `getByFirm`.
60
117
 
61
- // Update contact
62
- const updated = await api.contacts.update('contact-uuid', {
63
- title: 'Managing Partner',
64
- write_tags: ['tier_1', 'vip'],
65
- });
118
+ ### `OrganizationsApi` — `/api/v1/organizations/`
66
119
 
67
- // Search contacts
68
- const results = await api.contacts.search('john');
120
+ Same shape as `ContactsApi` but for orgs/firms, with extra range helpers (`getByCheckSizeRange`, `getByStage`).
69
121
 
70
- // Get contacts by firm
71
- const firmContacts = await api.contacts.getByFirm('firm-uuid');
122
+ ### `EntitiesApi` `/api/v1/core/`
123
+
124
+ Low-level access to the generic assertion store: `Tags`, `EntityTags`, `Metrics`, `Profiles`, `Attributes`, `Relationships`. Use when you need to query assertions directly instead of through a typed entity wrapper.
125
+
126
+ ```ts
127
+ const tags = await api.entities.listTags();
128
+ const metrics = await api.entities.listMetrics(undefined, { entityId, type: 'financial' });
72
129
  ```
73
130
 
74
- ### Organizations API
131
+ ### `FunnelsApi` — `/api/v1/funnels/`
75
132
 
76
- ```typescript
77
- // List organizations with filters
78
- const orgs = await api.organizations.list(
79
- {
80
- tier: 1,
81
- stage: 'seed',
82
- check_size_min_gte: 100000,
83
- check_size_max_lte: 1000000,
84
- },
85
- { page: 1, page_size: 20 },
86
- { ordering: '-aum' }
87
- );
133
+ Lead/deal funnels: list/create/update/delete, plus `run`, `preview`, `cancelRun`, `getRuns`, `getResults`, `listTemplates`, `getFields`, and stage operations (`addEntity`, `move`, `stats`, `pipeline`).
88
134
 
89
- // Get single organization
90
- const org = await api.organizations.get('org-uuid');
91
-
92
- // Create organization with assertions
93
- const newOrg = await api.organizations.create({
94
- name: 'Acme Ventures',
95
- domain: 'acmevc.com',
96
- location: 'San Francisco, CA',
97
- write_tags: ['tier_1', 'stage:seed', 'focus:fintech'],
98
- write_metrics: {
99
- check_size_min: 500000,
100
- check_size_max: 2000000,
101
- aum: 100000000,
102
- },
103
- write_profiles: {
104
- linkedin: 'https://linkedin.com/company/acme-ventures',
105
- website: 'https://acmevc.com',
106
- },
107
- });
135
+ ```ts
136
+ // market-simpli/src/shared/lib/api/funnels.ts
137
+ const response = await api.funnels.list({ tags });
138
+ const run = await api.funnels.run(funnelId);
139
+ const runs = await api.funnels.getRuns(funnelId);
140
+ ```
141
+
142
+ Ships two type guards for known failure modes: `isFunnelRunConflict(err)` (409 on already-running) and `isFunnelValidationError(err)` (400 with field errors).
143
+
144
+ ### `MessagesApi` + `MessageTemplatesApi` — `/api/v1/messages/`, `/api/v1/message-templates/`
108
145
 
109
- // Get by check size range
110
- const firms = await api.organizations.getByCheckSizeRange(100000, 1000000);
146
+ Outbound messaging campaigns: drafts, scheduling, sending, test sends, per-recipient tracking, channel discovery.
147
+
148
+ ```ts
149
+ // market-simpli/src/app/(dashboard)/email/compose/page.tsx
150
+ const message = draftId
151
+ ? await api.messages.update(draftId, payload)
152
+ : await api.messages.create(payload);
153
+
154
+ await api.messages.sendNow(message.id);
155
+
156
+ const [lists, channels] = await Promise.all([
157
+ api.targetLists.list({ pageSize: 100 }),
158
+ api.messages.getChannels(),
159
+ ]);
111
160
  ```
112
161
 
113
- ### Entities API (Low-level)
162
+ `calculateStatsFromMessages(messages)` computes aggregate open/click/reply/bounce rates client-side from a list.
114
163
 
115
- ```typescript
116
- // Tags
117
- const tags = await api.entities.listTags();
164
+ ### `TargetListsApi` — `/api/v1/targets/lists/`
118
165
 
119
- // Entity Tags
120
- const entityTags = await api.entities.listEntityTags(
121
- { page: 1, page_size: 50 },
122
- { entity_id: 'some-uuid', category: 'focus' }
123
- );
166
+ Reusable contact/organization lists. CRUD plus member ops (`getMembers`, `addMembers`, `removeMembers`, `refresh`). Supports static, funnel-backed, and query-backed lists.
124
167
 
125
- // Metrics
126
- const metrics = await api.entities.listMetrics(
127
- undefined,
128
- { entity_id: 'some-uuid', type: 'financial' }
129
- );
168
+ ```ts
169
+ const members = await api.targetLists.getMembers(id, { pageSize: 500 });
170
+ ```
130
171
 
131
- // Profiles
132
- const profiles = await api.entities.listProfiles(
133
- undefined,
134
- { entity_id: 'some-uuid', type: 'professional' }
135
- );
172
+ ### `EnrichmentApi` — `/api/v1/enrichment/`, `/api/v1/contacts/<id>/enrich/`
173
+
174
+ Apollo enrichment. The backend caps `enrich-apollo` at 100 ids/request; `enrichApollo(ids, { onProgress })` automatically chunks the input, calls the endpoint sequentially, and returns a single aggregated `ApolloEnrichmentSummary`. Partial failures are recorded per id without aborting the run.
175
+
176
+ ### `UsersApi` — `/api/v1/users/`
177
+
178
+ Three calls: `getProfile`, `updateProfile`, `changePassword`. Used by every app's account settings.
179
+
180
+ ```ts
181
+ // market-simpli/src/app/(dashboard)/settings/account/page.tsx
182
+ const profile = await api.users.getProfile();
183
+ await api.users.updateProfile({ firstName, lastName });
184
+ await api.users.changePassword({ oldPassword, newPassword });
136
185
  ```
137
186
 
138
- ### Using Direct Client
187
+ ### `WorkflowsApi` `/api/v1/workflows/`
139
188
 
140
- For custom endpoints not covered by wrappers:
189
+ Workflow definitions + execution history. CRUD on `Workflow`, plus `WorkflowExecution` reads with the standard `status / mode` filters.
141
190
 
142
- ```typescript
143
- import { createApiClient } from '@startsimpli/api';
191
+ ### `FeatureFlagsApi` + `<FeatureFlagProvider>` + `useFeatureFlags()`
144
192
 
145
- const client = createApiClient({
146
- baseUrl: 'http://localhost:8000/api/v1',
147
- getToken: async () => getAccessToken(),
148
- });
193
+ Server-side flags loaded once at app boot, fed into React via context. Apps mount `FeatureFlagProvider` near the root and read flags with `useFeatureFlags()`.
149
194
 
150
- // Direct fetch calls
151
- const data = await client.fetch.get('/custom-endpoint/', {
152
- params: { key: 'value' }
153
- });
195
+ ### `MarketsApi` `/api/v1/markets/*`
154
196
 
155
- const created = await client.fetch.post('/custom-endpoint/', {
156
- name: 'test'
157
- });
197
+ Backs trade-simpli. Instruments, OHLCV bars, analytics (`returns`/`volatility`/`beta`/`pair_spread`), per-instrument news, options chains + IV/Greeks/unusual-activity/ATM IV history, earnings calendar, macro calendar, sector breadth, VIX term structure, social posts, trading snapshots, and an ops health rollup.
198
+
199
+ ```ts
200
+ // trade-simpli/src/app/(dashboard)/options/[symbol]/page.tsx
201
+ api.markets.getOptionsChain({ symbol, expiry })
202
+ api.markets.getOptionsIvHistory({ symbol })
203
+ api.markets.getOptionsUnusualActivity({ symbols: [symbol], lookback: 20 })
204
+
205
+ // trade-simpli/src/app/(dashboard)/pairs/[name]/page.tsx
206
+ api.markets.getTradingSnapshots({ limit: 90 })
207
+ api.markets.getAnalytics(pair.bull, { metric: 'pair_spread', vs: pair.bear, windowDays: 90 })
208
+ api.markets.getNews(pair.bull, { limit: 10, minConfidence: 0.3 })
158
209
  ```
159
210
 
160
- ## Next.js API Routes Middleware
211
+ ### `VaultApi` `/api/v1/vault/*`
161
212
 
162
- ### With Auth
213
+ Backs vault-web. Environments, secrets (value is write-only on create/update; readback requires the separate audited `revealSecret`), access keys, and an audit log per environment.
163
214
 
164
- ```typescript
165
- import { withAuth } from '@startsimpli/api/middleware';
215
+ ```ts
216
+ // vault-web/src/app/(dashboard)/environments/page.tsx via hooks that wrap api.vault
217
+ const { data } = useEnvironments(api.vault);
218
+ const createEnv = useCreateEnvironment(api.vault);
166
219
 
167
- export const GET = withAuth(async (request, context) => {
168
- const { userId, token, isAuthenticated } = context;
220
+ // raw equivalents:
221
+ await api.vault.listEnvironments();
222
+ await api.vault.createEnvironment({ slug, name });
223
+ await api.vault.revealSecret(envSlug, secretId);
224
+ ```
169
225
 
170
- if (!isAuthenticated) {
171
- return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
172
- }
226
+ ## Error contract
173
227
 
174
- // Use token to call Django API
175
- return NextResponse.json({ userId, token });
176
- });
228
+ All HTTP failures throw `ApiException`. The backend (`apps.core.exceptions`) now emits a **standardized error response** with a machine code and structured `details`, and `parseErrorResponse` lifts those onto the thrown exception:
229
+
230
+ ```ts
231
+ class ApiException extends Error {
232
+ status?: number; // HTTP status
233
+ statusText?: string;
234
+ detail?: string; // DRF { detail: ... } string when present
235
+ errors?: Record<string, string[]>; // DRF field-level validation errors
236
+ code?: string; // NEW: machine code, e.g. 'limit_reached', 'no_company'
237
+ details?: Record<string, unknown>; // NEW: extra context — feature_key, limit, current, …
238
+ }
177
239
  ```
178
240
 
179
- ### With Validation
241
+ `parseErrorResponse` recognises three response shapes and normalises them onto the same exception:
180
242
 
181
- ```typescript
182
- import { withValidation } from '@startsimpli/api/middleware';
183
- import { z } from 'zod';
243
+ 1. **Standardized (preferred)** — `{ error, code, statusCode, fieldErrors?, …extras }`. The human `error` becomes `.message`, `code` is preserved on `.code`, and every non-reserved key is stapled onto `.details`. Reserved keys (`error`, `code`, `statusCode`/`status_code`, `fieldErrors`/`field_errors`, `timestamp`, `requestId`/`request_id`, `detail`) are excluded from `.details`.
244
+ 2. **DRF detail** — `{ detail: '...' }` `.detail` + `.message`.
245
+ 3. **DRF field errors** — `{ field: ['msg', …] }` `.errors`.
184
246
 
185
- const createContactSchema = z.object({
186
- name: z.string().min(1),
187
- email: z.string().email().optional(),
188
- tier: z.number().int().min(1).max(3).optional(),
189
- });
247
+ ### Discriminating on `code` (the key new contract)
190
248
 
191
- export const POST = withValidation(
192
- async (request, { body }) => {
193
- // body is typed and validated
194
- const contact = await createContact(body);
195
- return NextResponse.json(contact);
196
- },
197
- { body: createContactSchema }
198
- );
249
+ ```ts
250
+ // vault-web/src/app/(dashboard)/environments/page.tsx
251
+ import { ApiException } from '@startsimpli/api';
252
+
253
+ function limitErrorMessage(err: unknown): string | null {
254
+ if (err instanceof ApiException && err.code === 'limit_reached') {
255
+ // err.details carries { featureKey, limit, current } from the backend
256
+ return err.message || "You've reached the Free plan limit. Upgrade for unlimited environments.";
257
+ }
258
+ return null;
259
+ }
199
260
  ```
200
261
 
201
- ### With Error Handling
262
+ Known codes (server emits these via `apps.core.exceptions`):
263
+ - `limit_reached` — billing/quota gate; `details` carries `featureKey`, `limit`, `current`. Vault-web renders the upgrade nudge from this.
264
+ - `no_company` — user has no active company context selected.
265
+ - Plus any code the backend defines on `apps.core.exceptions` subclasses.
266
+
267
+ ### Status-based guards (still supported)
202
268
 
203
- ```typescript
204
- import { withErrorHandling } from '@startsimpli/api/middleware';
269
+ ```ts
270
+ import {
271
+ isApiException,
272
+ isValidationError, // status === 400 && has .errors
273
+ isAuthError, // status === 401 || 403
274
+ isNotFoundError, // status === 404
275
+ } from '@startsimpli/api';
205
276
 
206
- export const GET = withErrorHandling(async (request) => {
207
- // Errors are automatically caught and formatted
208
- const data = await somethingThatMightThrow();
209
- return NextResponse.json(data);
277
+ try {
278
+ await api.contacts.get(id);
279
+ } catch (err) {
280
+ if (isValidationError(err)) console.log(err.errors);
281
+ else if (isAuthError(err)) /* redirect via onUnauthorized */;
282
+ else if (isNotFoundError(err)) /* show empty state */;
283
+ else if (isApiException(err)) console.error(err.code, err.message);
284
+ else throw err;
285
+ }
286
+ ```
287
+
288
+ ### Server-side domain error hierarchy
289
+
290
+ Separate from `ApiException`, the package also exports a Node-side error hierarchy (`AppError`, `ValidationError`, `AuthenticationError`, …, plus `toAppError` and `isPrismaError`) for Next.js route handlers / server actions that want to throw typed errors and have middleware (`withErrorHandling`) format them. Use these in server code; use `ApiException` everywhere a fetch can fail.
291
+
292
+ ## snake↔camel transform
293
+
294
+ Django speaks `snake_case`; this package speaks `camelCase`. The transform is on by default:
295
+
296
+ - **Responses**: `snakeToCamel(body)` is applied to the parsed JSON before it's returned.
297
+ - **Request bodies**: `camelToSnake(body)` is applied before `JSON.stringify`.
298
+ - **Query params**: built by `buildFilterParams` / `mergeQueryParams` — pass camelCase; they're emitted as snake_case.
299
+
300
+ Opt out per-client when you need raw keys (e.g. an endpoint that already returns camelCase, or one that requires exact-key passthrough):
301
+
302
+ ```ts
303
+ import { createStartSimpliApi } from '@startsimpli/api';
304
+
305
+ const api = createStartSimpliApi({
306
+ baseUrl: process.env.NEXT_PUBLIC_API_URL,
307
+ getToken,
308
+ transformKeys: false, // disable both directions for this client
210
309
  });
211
310
  ```
212
311
 
213
- ### Composing Middleware
312
+ Note: a few Django endpoints (notably some billing endpoints under `/billing/*` proxied through Next route handlers) emit raw snake_case and bypass the DRF camelCase middleware. Apps that proxy those typically pass `fromSnake: true` in their own proxy helpers — that's an app-level concern, not something `transformKeys` toggles.
313
+
314
+ ## Next.js API route middleware
214
315
 
215
- ```typescript
216
- import { withAuth, withErrorHandling, withValidation } from '@startsimpli/api/middleware';
316
+ Imported from a separate entry to avoid pulling server deps into the main bundle:
317
+
318
+ ```ts
319
+ import { withAuth, withValidation, withErrorHandling } from '@startsimpli/api/middleware';
217
320
  import { z } from 'zod';
218
321
 
219
322
  const schema = z.object({ name: z.string() });
@@ -222,107 +325,51 @@ export const POST = withErrorHandling(
222
325
  withAuth(
223
326
  withValidation(
224
327
  async (request, { body }, authContext) => {
225
- // Fully typed, validated, and authenticated
226
- return NextResponse.json({ success: true });
328
+ return NextResponse.json({ ok: true });
227
329
  },
228
- { body: schema }
229
- )
230
- )
330
+ { body: schema },
331
+ ),
332
+ ),
231
333
  );
232
334
  ```
233
335
 
234
- ## Error Handling
336
+ `withAuth` injects `{ userId, token, isAuthenticated }`. `withErrorHandling` catches thrown `AppError` / `ApiException` and formats them into a standard JSON response.
235
337
 
236
- ```typescript
237
- import {
238
- isApiException,
239
- isValidationError,
240
- isAuthError,
241
- isNotFoundError,
242
- } from '@startsimpli/api';
338
+ ## Direct client escape hatch
243
339
 
244
- try {
245
- const contact = await api.contacts.get('invalid-id');
246
- } catch (error) {
247
- if (isValidationError(error)) {
248
- console.log('Validation errors:', error.errors);
249
- } else if (isAuthError(error)) {
250
- console.log('Auth error:', error.status);
251
- } else if (isNotFoundError(error)) {
252
- console.log('Contact not found');
253
- } else if (isApiException(error)) {
254
- console.log('API error:', error.message);
255
- }
256
- }
257
- ```
340
+ For endpoints without a wrapper:
258
341
 
259
- ## TypeScript Types
260
-
261
- All types are exported for use in your application:
262
-
263
- ```typescript
264
- import type {
265
- Contact,
266
- Organization,
267
- Entity,
268
- Tag,
269
- Metric,
270
- Profile,
271
- Attribute,
272
- PaginatedResponse,
273
- ApiError,
274
- CreateContactRequest,
275
- UpdateContactRequest,
276
- ContactFilters,
277
- } from '@startsimpli/api';
278
- ```
342
+ ```ts
343
+ import { createApiClient } from '@startsimpli/api';
279
344
 
280
- ## Architecture
345
+ const client = createApiClient({ baseUrl, getToken });
281
346
 
282
- ### Django Integration
347
+ const data = await client.fetch.get('/custom-endpoint/', { params: { key: 'value' } });
348
+ const created = await client.fetch.post('/custom-endpoint/', { name: 'test' });
349
+ ```
283
350
 
284
- This package is designed to work with the Django REST API:
351
+ `client.fetch` is the `FetchWrapper`; it carries the auth token, runs the key transform, and throws `ApiException` on failure exactly like the typed wrappers.
285
352
 
286
- - **Base URL**: `http://localhost:8000/api/v1/` (configurable)
287
- - **Auth**: Bearer token from `@startsimpli/auth`
288
- - **Pagination**: Django REST Framework format (`page`, `page_size`)
289
- - **Filtering**: Django-filter query params (`field__gte=100`, `field__in=1,2,3`)
353
+ ## Verification
290
354
 
291
- ### Entity System
355
+ ```bash
356
+ pnpm --filter @startsimpli/api test # vitest, 12 files, 133 tests
357
+ pnpm --filter @startsimpli/api type-check # tsc --noEmit
358
+ ```
292
359
 
293
- Django uses a generic Entity model with assertions (Tags, Metrics, Profiles, Attributes):
360
+ Test coverage at the time of writing: 12 test files across `src/__tests__/` and `src/lib/__tests__/`, totalling 133 passing tests. Headline suites: DRF camel/snake transforms, URL building, query-param shaping, JWT refresh, paginated `fetchAllPages`, no-token-on-unsafe-method guard, response schema validation, entity adapter, entity query builder, funnels API contracts, enrichment chunking, and the Vault API surface.
294
361
 
295
- ```typescript
296
- // Entity with assertions
297
- {
298
- id: 'uuid',
299
- entity_type: 'contact',
300
-
301
- // Canonical fields
302
- name: 'John Doe',
303
- email: 'john@example.com',
304
-
305
- // Computed from assertions
306
- tier: 1, // from tags
307
- linkedin: 'https://...', // from profiles
308
- enrichment_score: 0.95, // from metrics
309
-
310
- // Full assertions
311
- tags: [{ category: 'quality', name: 'tier_1', ... }],
312
- metrics: [{ type: 'quality', subtype: 'enrichment_score', value: 0.95 }],
313
- profiles: [{ type: 'professional', subtype: 'linkedin', identifier: '...' }],
314
- }
315
- ```
362
+ ## Shared-first policy
316
363
 
317
- ## Development
364
+ If you find yourself writing an HTTP wrapper, an api-client helper, an error-shape util, or a query-param builder inside an app's `src/`, stop — extend this package instead. App code should call `api.<wrapper>.<method>()`, not build its own fetcher. See the monorepo `CLAUDE.md` rule 9.
318
365
 
319
- ```bash
320
- # Run tests
321
- npm test
366
+ ## Architecture notes
322
367
 
323
- # Type check
324
- npm run type-check
325
- ```
368
+ - **Base URL**: configurable via `baseUrl`; `''` (default) uses relative paths so Next.js rewrites/proxies can intercept.
369
+ - **Auth**: `Authorization: Bearer <token>` from `getToken()`; the wrapper handles refresh via `onTokenRefresh` and one-shot dedup of `onUnauthorized` callbacks (5s window).
370
+ - **Pagination**: DRF format — `page`, `pageSize` (sent as `page_size`).
371
+ - **Filtering**: Django-filter conventions — `field__gte`, `field__in`, etc. — written camelCase from callers.
372
+ - **Entity assertion model**: Django stores generic `Tag` / `Metric` / `Profile` / `Attribute` assertions against entities; writes use `writeTags` / `writeMetrics` / `writeProfiles`, reads include both canonical fields and the raw assertion arrays.
326
373
 
327
374
  ## License
328
375
 
package/package.json CHANGED
@@ -1,11 +1,20 @@
1
1
  {
2
2
  "name": "@startsimpli/api",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "description": "Type-safe Django REST API client for StartSimpli apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
7
7
  "exports": {
8
- ".": "./src/index.ts"
8
+ ".": {
9
+ "types": "./src/index.ts",
10
+ "react-native": "./src/index.ts",
11
+ "default": "./src/index.ts"
12
+ },
13
+ "./middleware": {
14
+ "types": "./src/middleware/index.ts",
15
+ "default": "./src/middleware/index.ts"
16
+ },
17
+ "./package.json": "./package.json"
9
18
  },
10
19
  "files": [
11
20
  "src",