@elqnt/admin 2.2.1 → 2.3.1

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
@@ -2,6 +2,14 @@
2
2
 
3
3
  Admin APIs and types for the Eloquent platform - onboarding, organization settings, billing, and user management.
4
4
 
5
+ > **Building an admin app / using an AI coding agent?** Read [`SKILL.md`](./SKILL.md)
6
+ > — the full agent guide: the one-way architecture (admin app → `@elqnt/admin`
7
+ > hooks → API Gateway → admin Go service), the gateway-token flow, and the exact
8
+ > spec of every hook (`useOrgAdmin` — org CRUD + settings; `useUsersAdmin` — user
9
+ > CRUD + invitations) and its methods. It ships in the package, so the contract is
10
+ > this package's own `.d.ts` — your code type-checks against it.
11
+ > (Product analytics moved to `@elqnt/analytics`.)
12
+
5
13
  ## Installation
6
14
 
7
15
  ```bash
@@ -33,7 +41,7 @@ const plans = await getBillingPlansApi({
33
41
  | `@elqnt/admin` | All exports |
34
42
  | `@elqnt/admin/api` | Browser API functions |
35
43
  | `@elqnt/admin/models` | TypeScript types |
36
- | `@elqnt/admin/hooks` | React hooks (`useOrgAdmin`, `useUsersAdmin`, `useInvitesAdmin`, `useOrgSettings`) |
44
+ | `@elqnt/admin/hooks` | React hooks (`useOrgAdmin`, `useUsersAdmin`) |
37
45
 
38
46
  ## APIs
39
47
 
@@ -55,19 +63,21 @@ const plans = await getBillingPlansApi({
55
63
  ## Hooks
56
64
 
57
65
  ```typescript
58
- import { useOrgAdmin, useUsersAdmin, useInvitesAdmin, useOrgSettings } from "@elqnt/admin/hooks";
59
-
60
- // Organization management
61
- const { listOrgs, getOrg, createOrg, updateOrg, deleteOrg, loading, error } = useOrgAdmin(options);
62
-
63
- // User management
64
- const { listUsers, getUser, createUser, updateUser, deleteUser, getUserSettings, updateUserSettings, loading, error } = useUsersAdmin(options);
65
-
66
- // Invite management
67
- const { listInvites, sendInvite, sendInvites, getInvite, acceptInvite, revokeInvite, resendInvite, loading, error } = useInvitesAdmin(options);
68
-
69
- // Organization settings
70
- const { getSettings, createSettings, updateSettings, loading, error } = useOrgSettings(options);
66
+ import { useOrgAdmin, useUsersAdmin } from "@elqnt/admin/hooks";
67
+
68
+ // Organization management + settings
69
+ const {
70
+ listOrgs, getOrg, createOrg, updateOrg, deleteOrg,
71
+ getSettings, createSettings, updateSettings,
72
+ loading, error,
73
+ } = useOrgAdmin(options);
74
+
75
+ // User management + invitations
76
+ const {
77
+ listUsers, getUser, createUser, updateUser, deleteUser, getUserSettings, updateUserSettings,
78
+ listInvites, sendInvite, sendInvites, getInvite, acceptInvite, revokeInvite, resendInvite,
79
+ loading, error,
80
+ } = useUsersAdmin(options);
71
81
  ```
72
82
 
73
83
  ## Models
package/SKILL.md ADDED
@@ -0,0 +1,570 @@
1
+ ---
2
+ name: admin
3
+ description: Build an admin/platform frontend on the Eloquent admin backend using the @elqnt/admin hooks. Covers the one true path (admin app → @elqnt/admin hooks → API Gateway → admin Go service), the gateway-token/secret flow, and the EXACT input/output spec of every hook method (useOrgAdmin — org CRUD + settings; useUsersAdmin — user CRUD + invitations) plus the onboarding/billing api functions and all DTOs. Product analytics moved to @elqnt/analytics.
4
+ ---
5
+
6
+ # Admin — Building an Admin / Platform Frontend
7
+
8
+ Eloquent's **admin** service owns the platform's control plane: organizations,
9
+ users, invitations, org settings, onboarding, and billing. This skill is **only**
10
+ about one thing: building a frontend (the admin console, an onboarding wizard)
11
+ that drives it through the `@elqnt/admin` package.
12
+
13
+ > Package is `@elqnt/admin`. It exports **two React hooks**:
14
+ > - `useOrgAdmin` — organization CRUD **plus org settings** (the settings methods
15
+ > were merged in from the former `useOrgSettings`).
16
+ > - `useUsersAdmin` — user CRUD **plus invitations** (the invite methods were
17
+ > merged in from the former `useInvitesAdmin`).
18
+ >
19
+ > plus a set of **api functions** for the flows that have no hook yet (onboarding
20
+ > wizard, billing/Stripe). Hooks are the primary interface; api functions are the
21
+ > building blocks the hooks wrap.
22
+ >
23
+ > **Product analytics moved out** to its own package — `@elqnt/analytics`
24
+ > (`useProductAnalytics`). See that package's `SKILL.md`.
25
+
26
+ ---
27
+
28
+ ## ⛔ The contract — read this before writing any code
29
+
30
+ This skill ships **inside the package**, so the contract is not a separate file
31
+ to keep in sync — it **is** the package's own published type declarations
32
+ (`@elqnt/admin` → `dist/**/*.d.ts`). Because your app *imports the real hooks*,
33
+ the TypeScript compiler enforces the contract for you: a wrong param or a misused
34
+ return value won't compile.
35
+
36
+ The single source of truth, in order:
37
+
38
+ 1. `import { useOrgAdmin, useUsersAdmin } from "@elqnt/admin/hooks"`
39
+ — the two hooks.
40
+ 2. `import type { UseOrgAdminReturn, UseUsersAdminReturn } from "@elqnt/admin/hooks"`
41
+ — the **named, exact** method surface of each hook (every method, its params,
42
+ its return type). The tables below are a human-readable mirror of these.
43
+ 3. `import type { … } from "@elqnt/admin/models"` — `Org`, `User`, `Invite`,
44
+ `OrgSettings`, etc. (the DTOs).
45
+ 4. `import { … } from "@elqnt/admin/api"` — the onboarding/billing api functions
46
+ (and the lower-level CRUD api fns the hooks wrap) for flows no hook covers.
47
+
48
+ **Rules (do not drift):**
49
+
50
+ 1. **Use only the two exported hooks** (and the documented api functions) and
51
+ only the methods/fns they expose. Do **not** invent hooks, methods, params, or
52
+ return shapes — if it's not on `UseOrgAdminReturn` / `UseUsersAdminReturn` /
53
+ the exported api signatures, it doesn't exist.
54
+ 2. **Let the compiler check you.** Build with `tsc`; never `as any` / `@ts-ignore`
55
+ your way around a hook's types to force a call that isn't there.
56
+ 3. **Never bypass the package.** No `fetch`/`axios` to `/api/v1/admin/...`, no
57
+ NATS, no direct service calls. (Server-side: mint a token with
58
+ `createServerClient` from `@elqnt/api-client/server` and call the same gateway
59
+ paths — same routes, same shapes.)
60
+ 4. **Wrap, don't scatter.** Import the hook in exactly one app file
61
+ (`hooks/use-<domain>.ts` or a context provider); see ["The app layer"](#the-app-layer).
62
+ 5. If a requirement seems to need a call the package doesn't define, **stop and
63
+ flag it** — do not improvise an endpoint.
64
+
65
+ The prose/tables below explain each method; the package's shipped `.d.ts` is what
66
+ your code type-checks against.
67
+
68
+ ---
69
+
70
+ ## Architecture — the one and only path
71
+
72
+ A component **never** calls a package hook directly. It only sees your **domain
73
+ context** (orgs list, current user, invite table), served by a **context
74
+ provider**, backed by your **app hook**, which is the *only* place that wraps the
75
+ `@elqnt/admin` hook. Everything below the app hook is plumbing the component
76
+ never imports.
77
+
78
+ ```
79
+ ┌──────────────────────────────────────────────────────────────────────────────┐
80
+ │ YOUR ADMIN APP (Next.js / React) │
81
+ │ │
82
+ │ SSR layout: read API_GATEWAY_URL_PUBLIC (request-time) + ORG_ID │
83
+ │ └─► AppConfigProvider { apiGatewayUrl, orgId } │
84
+ │ │
85
+ │ components/orgs/* ── speak only the Org / User / Invite domain types │
86
+ │ │ useOrgsContext() │
87
+ │ ▼ │
88
+ │ contexts/orgs-context.tsx ── one shared app-hook instance │
89
+ │ │ │
90
+ │ ▼ │
91
+ │ hooks/use-orgs.ts ── app hook: CRUD + state │
92
+ │ │ │
93
+ │ ▼ │
94
+ │ useOrgAdmin / useUsersAdmin (@elqnt/admin/hooks) │
95
+ │ │ │
96
+ │ ▼ │
97
+ │ @elqnt/admin/api → browserApiRequest │
98
+ │ │ getGatewayToken() ⇒ GET /api/gateway-token (mint HS256 JWT, JWT_SECRET)│
99
+ │ │ attaches: Authorization: Bearer <jwt>, X-Org-ID, X-User-ID, X-Product│
100
+ └─────────┼──────────────────────────────────────────────────────────────────────┘
101
+
102
+ ┌─────────────────┐ verify JWT, stamp X-Org-ID/X-Product, route match
103
+ │ API GATEWAY │ /api/v1/admin/** , /api/v1/onboarding/** ,
104
+ └───────┬─────────┘ /api/v1/billing/** → admin svc
105
+
106
+ ┌─────────────────┐ per-product admin DB admin_<product> (row-level tenancy)
107
+ │ admin (Go) │ orgs / users / invites / settings / onboarding / billing
108
+ └─────────────────┘
109
+ ```
110
+
111
+ Rules: the frontend **never** calls the admin service directly and **never**
112
+ touches NATS. Every call is HTTP through the gateway carrying an org id + gateway
113
+ token. And **components never import `@elqnt/admin`** — only the app hook does.
114
+
115
+ > **Product routing.** There is no `product` column on the org row. The
116
+ > `product` you send (JWT claim server-side, `X-Product` header browser-side)
117
+ > selects which `admin_<product>` DB the request reads/writes (empty →
118
+ > `eloquent`). `listOrgs`/`createOrg` also accept a transient `product` field as
119
+ > routing metadata; it is **not** persisted on the org.
120
+
121
+ ---
122
+
123
+ ## The gateway token (the secret) — how the app gets it
124
+
125
+ Every request to the gateway needs `Authorization: Bearer <token>`. The token is
126
+ a short-lived **HS256 JWT** signed with the shared **gateway secret**. You never
127
+ hardcode it; there are two flows depending on where the code runs.
128
+
129
+ ### Browser flow (what the hooks use)
130
+
131
+ The hooks → api fns → `browserApiRequest` → `getGatewayToken()` internally. You
132
+ do **not** pass a token to the hook. `getGatewayToken()` (from
133
+ `@elqnt/api-client/browser`) by default does:
134
+
135
+ ```
136
+ fetch("/api/gateway-token") ⇒ { token, expiresIn }
137
+ ```
138
+
139
+ So your admin app must expose a **`/api/gateway-token` route** that mints the JWT
140
+ server-side (the secret stays on the server, never reaches the browser). The
141
+ client caches the token and refreshes ~5 min before expiry.
142
+
143
+ ```ts
144
+ // app/api/gateway-token/route.ts (Next.js route handler — server only)
145
+ import { NextResponse } from "next/server";
146
+ import * as jose from "jose";
147
+
148
+ export async function GET() {
149
+ const secret = new TextEncoder().encode(process.env.JWT_SECRET!); // SAME secret the gateway validates with
150
+ const token = await new jose.SignJWT({
151
+ org_id: process.env.ORG_ID!,
152
+ user_id: "system",
153
+ email: "system@admin-app.com",
154
+ role: "admin", // admin routes typically require admin/super_admin
155
+ scopes: ["read", "write", "admin"], // OR-matched against the route's required scopes
156
+ product: "eloquent", // gateway resolves product from THIS claim first
157
+ })
158
+ .setProtectedHeader({ alg: "HS256" })
159
+ .setIssuedAt()
160
+ .setIssuer("eloquent-gateway") // must match gateway JWT_ISSUER
161
+ .setAudience("eloquent-api") // must match gateway JWT_AUDIENCE
162
+ .setExpirationTime("1h")
163
+ .sign(secret);
164
+
165
+ return NextResponse.json({ token, expiresIn: 3600 });
166
+ }
167
+ ```
168
+
169
+ > The gateway re-stamps `X-Org-ID` / `X-User-ID` / `X-Product` from the **signed
170
+ > JWT claims** before proxying, so a forged header can't override the token — the
171
+ > JWT is authoritative. Most `/api/v1/admin/**` and analytics-global routes
172
+ > require an `admin` / `super_admin` role (or a `*` scope) — mint the token
173
+ > accordingly.
174
+
175
+ Override the token source (non-default URL, native app) once at startup:
176
+
177
+ ```ts
178
+ import { configureAuth } from "@elqnt/api-client/browser";
179
+ configureAuth(async () => myTokenProvider()); // URL string or async () => token|null
180
+ // clearGatewayTokenCache() — re-exported from @elqnt/admin/api; call after switching orgs
181
+ ```
182
+
183
+ ### Server flow (server actions / SSR)
184
+
185
+ `@elqnt/api-client/server` mints the JWT itself with `jose` — no
186
+ `/api/gateway-token` hop. This is where the secret is injected directly:
187
+
188
+ ```ts
189
+ import { createServerClient } from "@elqnt/api-client/server";
190
+
191
+ const client = createServerClient({
192
+ gatewayUrl: process.env.API_GATEWAY_URL_INTERNAL!, // in-cluster gateway URL (server-side)
193
+ jwtSecret: process.env.JWT_SECRET!, // the shared gateway secret
194
+ defaultProduct: "eloquent", // gateway reads product from the JWT claim first
195
+ defaultScopes: ["read", "write", "admin"],
196
+ });
197
+
198
+ await client.get("/api/v1/admin/orgs", { orgId, userId });
199
+ ```
200
+
201
+ > The admin **hooks are browser-only** (`"use client"`). For SSR/server actions
202
+ > call the gateway paths via `createServerClient` directly — not the hooks.
203
+
204
+ ### Env vars
205
+
206
+ | Var | Used by | Purpose |
207
+ |---|---|---|
208
+ | `JWT_SECRET` | `/api/gateway-token` route + `createServerClient` | sign the gateway JWT (same value the gateway validates with) |
209
+ | `API_GATEWAY_URL_INTERNAL` | `createServerClient` `gatewayUrl` | in-cluster gateway URL (server) |
210
+ | `API_GATEWAY_URL_PUBLIC` | SSR layout → `AppConfigProvider` → browser `baseUrl` | public gateway URL, read **at request time** (not `NEXT_PUBLIC_*`) |
211
+ | `ORG_ID` | token route / app config | the acting org all requests are scoped to |
212
+
213
+ ### Headers the api layer sets per request
214
+
215
+ | From hook option | Header |
216
+ |---|---|
217
+ | auto token | `Authorization: Bearer <token>` |
218
+ | `orgId` | `X-Org-ID` |
219
+ | `userId` | `X-User-ID` |
220
+ | `userEmail` | `X-User-Email` |
221
+ | `product` (default `"eloquent"`) | `X-Product` |
222
+
223
+ ---
224
+
225
+ ## Hook options (all hooks)
226
+
227
+ There is **no provider/context** baked into the package. You pass options into
228
+ each hook call. Every hook's options is just `ApiClientOptions`:
229
+
230
+ ```ts
231
+ interface ApiClientOptions {
232
+ baseUrl: string; // API Gateway base URL — required
233
+ orgId: string; // required → X-Org-ID
234
+ userId?: string; // → X-User-ID
235
+ userEmail?: string; // → X-User-Email
236
+ product?: string; // → X-Product, defaults to "eloquent"; selects admin_<product> DB
237
+ headers?: Record<string, string>;
238
+ }
239
+
240
+ type UseOrgAdminOptions = ApiClientOptions;
241
+ type UseUsersAdminOptions = ApiClientOptions;
242
+ ```
243
+
244
+ > **Imperative, not auto-fetching.** Every method returns a Promise you `await`.
245
+ > The hooks expose aggregate `loading` / `error` flags. On failure a method
246
+ > returns its default (`[]`, `null`, `false`) and sets `error` — it does **not**
247
+ > throw.
248
+ >
249
+ > **Callback identity.** Hook methods are recreated when `options` change (the
250
+ > deps array is `[options]`). If you need a stable ref inside `useEffect`/
251
+ > `useCallback`, stash it in a `useRef`:
252
+ > ```ts
253
+ > const listOrgsRef = useRef(listOrgs);
254
+ > listOrgsRef.current = listOrgs;
255
+ > ```
256
+
257
+ ---
258
+
259
+ ## Hook: `useOrgAdmin` — organizations + settings
260
+
261
+ ```ts
262
+ import { useOrgAdmin } from "@elqnt/admin/hooks";
263
+ const orgs = useOrgAdmin({ baseUrl, orgId, userId, userEmail });
264
+ ```
265
+
266
+ Returns `{ loading, error, ...methods }` (`UseOrgAdminReturn`):
267
+
268
+ | Method | Signature | Resolves to | Endpoint |
269
+ |---|---|---|---|
270
+ | `listOrgs` | `(filter?: ListOrgsFilter) => Promise<Org[]>` | `[]` on error | `GET /api/v1/admin/orgs` (`?status&type&product`) |
271
+ | `getOrg` | `(orgId: string) => Promise<Org \| null>` | `null` | `GET /api/v1/admin/orgs/{orgId}` |
272
+ | `getOrgInfo` | `(orgId: string) => Promise<OrgInfo \| null>` | `null` | `GET /api/v1/admin/orgs/{orgId}/info` |
273
+ | `createOrg` | `(org: Partial<Org> & { product?: string }) => Promise<Org \| null>` | created org | `POST /api/v1/admin/orgs` |
274
+ | `updateOrg` | `(orgId: string, updates: Partial<Org>) => Promise<Org \| null>` | updated org | `PUT /api/v1/admin/orgs/{orgId}` |
275
+ | `deleteOrg` | `(orgId: string) => Promise<boolean>` | `true`/`false` | `DELETE /api/v1/admin/orgs/{orgId}` |
276
+ | `getSettings` | `() => Promise<OrgSettings \| null>` | `null` | `GET /api/v1/admin/orgs/{orgId}` → mapped to `OrgSettings` |
277
+ | `createSettings` | `(settings: Partial<OrgSettings>) => Promise<OrgSettings \| null>` | settings | alias of `updateSettings` (settings rows always exist) |
278
+ | `updateSettings` | `(settings: Partial<OrgSettings>) => Promise<OrgSettings \| null>` | settings | `PUT /api/v1/admin/orgs/{orgId}`, then re-GET |
279
+
280
+ ```ts
281
+ interface ListOrgsFilter { status?: OrgStatusTS; type?: OrgTypeTS; product?: string; }
282
+ // → ?status=active&type=enterprise&product=eloquent (product = which admin_<product> DB)
283
+ ```
284
+
285
+ > `createOrg`'s `product` field is transient routing metadata (which
286
+ > `admin_<product>` DB to write to) — it is **not** persisted on the org row.
287
+
288
+ **Org settings** (`getSettings`/`createSettings`/`updateSettings`, merged from the
289
+ former `useOrgSettings`) live on the **canonical admin Org row** (since v2.3) and
290
+ are scoped to the hook's `options.orgId`. The hook keeps the legacy `OrgSettings`
291
+ wire shape, but all three hit `/api/v1/admin/orgs/{orgId}` under the hood.
292
+ `updateSettings` PUTs `{ title, description, logoUrl, defaultLang, timezone,
293
+ settings }` (mapped from the `OrgSettings` snake_case fields) and round-trips a
294
+ fresh GET so you get back the full settings shape.
295
+
296
+ ---
297
+
298
+ ## Hook: `useUsersAdmin` — users + invitations
299
+
300
+ ```ts
301
+ import { useUsersAdmin } from "@elqnt/admin/hooks";
302
+ const users = useUsersAdmin({ baseUrl, orgId, userId, userEmail });
303
+ ```
304
+
305
+ Returns `{ loading, error, ...methods }` (`UseUsersAdminReturn`):
306
+
307
+ | Method | Signature | Resolves to | Endpoint |
308
+ |---|---|---|---|
309
+ | `listUsers` | `() => Promise<User[]>` | `[]` | `GET /api/v1/admin/users` |
310
+ | `getUser` | `(userId: string) => Promise<User \| null>` | `null` | `GET /api/v1/admin/users/{userId}` |
311
+ | `getUserByEmail` | `(email: string) => Promise<User \| null>` | `null` | `GET /api/v1/admin/users/by-email?email=` |
312
+ | `getUserByPhone` | `(phone: string) => Promise<User \| null>` | `null` | `GET /api/v1/admin/users/by-phone?phone=` |
313
+ | `createUser` | `(user: Partial<User>) => Promise<User \| null>` | created user | `POST /api/v1/admin/users` |
314
+ | `updateUser` | `(userId: string, updates: Partial<User>) => Promise<User \| null>` | updated user | `PUT /api/v1/admin/users/{userId}` |
315
+ | `deleteUser` | `(userId: string) => Promise<boolean>` | bool | `DELETE /api/v1/admin/users/{userId}` |
316
+ | `getUserSettings` | `(userId: string) => Promise<UserSettingsResponse \| null>` | `null` | `GET .../users/{userId}/settings` |
317
+ | `updateUserSettings` | `(userId: string, settings: { settings?: UserSettings; notificationPreferences?: NotificationPreferences }) => Promise<UserSettingsResponse \| null>` | settings | `PUT .../users/{userId}/settings` |
318
+ | `listInvites` | `() => Promise<Invite[]>` | `[]` | `GET /api/v1/admin/invites` |
319
+ | `getInvite` | `(inviteId: string) => Promise<Invite \| null>` | `null` | `GET /api/v1/admin/invites/{inviteId}` |
320
+ | `sendInvite` | `(invite: InviteInput) => Promise<Invite \| null>` | invite | `POST /api/v1/admin/invites/single` |
321
+ | `sendInvites` | `(invites: InviteInput[]) => Promise<InvitesResult \| null>` | batch result | `POST /api/v1/admin/invites` body `{ invites }` |
322
+ | `resendInvite` | `(inviteId: string) => Promise<Invite \| null>` | invite | `POST .../invites/{inviteId}/resend` |
323
+ | `revokeInvite` | `(inviteId: string) => Promise<boolean>` | bool | `DELETE /api/v1/admin/invites/{inviteId}` |
324
+ | `acceptInvite` | `(inviteId: string) => Promise<Invite \| null>` | invite | `POST .../invites/{inviteId}/accept` |
325
+
326
+ ```ts
327
+ interface InviteInput { email: string; role: string; }
328
+ interface InvitesResult { sent: InviteSentStatus[]; failed: string[]; nextStep: string; metadata: ResponseMetadata; }
329
+ ```
330
+
331
+ > **Invitations** (`listInvites` … `acceptInvite`) were merged in from the former
332
+ > `useInvitesAdmin`. They share this hook's aggregate `loading`/`error`.
333
+
334
+ > **Product analytics** lives in `@elqnt/analytics` now — import
335
+ > `useProductAnalytics` from `@elqnt/analytics/hooks` and its DTOs
336
+ > (`AnalyticsSummary`, `DateFilter`, …) from `@elqnt/analytics/models`. See that
337
+ > package's `SKILL.md`.
338
+
339
+ ---
340
+
341
+ ## Core types (`@elqnt/admin/models`)
342
+
343
+ > Admin/billing types are tygo-generated (`models/admin.ts`, `models/billing.ts`).
344
+ > `OrgSettings` is re-exported from `@elqnt/agents/models`. (Analytics DTOs moved
345
+ > to `@elqnt/analytics/models`.)
346
+
347
+ ```ts
348
+ interface Org {
349
+ id?: string;
350
+ title: string;
351
+ description?: string;
352
+ logoUrl: string;
353
+ mainDomain: string;
354
+ address: Address;
355
+ defaultLang?: string; timezone?: string;
356
+ settings?: Record<string, any>;
357
+ status: OrgStatusTS; // "active" | "suspended"
358
+ enabled: boolean;
359
+ type: OrgTypeTS; // "self-serve" | "enterprise"
360
+ size?: OrgSizeTS; // "solo" | "small" | "medium" | "large" | "enterprise"
361
+ industry?: string;
362
+ country?: string; // jurisdiction code (e.g. "UAE") → scopes system libraries
363
+ tags?: string[];
364
+ subscription?: OrgSubscription;
365
+ userCount: number;
366
+ onboarding?: OrgOnboarding;
367
+ template?: OrgTemplate; roles: string[];
368
+ metadata?: Record<string, any>;
369
+ createdAt?: number; updatedAt?: number; createdBy?: string; updatedBy?: string;
370
+ }
371
+ interface OrgInfo { id?: string; title: string; logoUrl: string; mainDomain?: string; }
372
+
373
+ interface User {
374
+ id?: string;
375
+ email: string; firstName: string; lastName: string;
376
+ authProviderName: string;
377
+ orgAccess?: UserOrgAccess[];
378
+ enabled?: boolean;
379
+ source?: UserSourceTS; // "signup" | "invite" | "sso" | "api"
380
+ inviteStatus?: InviteStatusTS;
381
+ isSysAdmin?: boolean;
382
+ settings?: UserSettings;
383
+ notificationPreferences?: NotificationPreferences;
384
+ onboarding?: UserOnboarding;
385
+ metadata?: Record<string, any>;
386
+ firstActiveAt?: number; lastActiveAt?: number;
387
+ createdAt?: number; updatedAt?: number; createdBy?: string; updatedBy?: string;
388
+ }
389
+
390
+ interface Invite {
391
+ id?: string; orgId: string; email: string; role: string;
392
+ invitedBy: string;
393
+ status: InviteStatusTS; // "pending" | "accepted" | "expired" | "revoked"
394
+ acceptedBy?: string; acceptedAt?: number; expiresAt?: number;
395
+ createdAt?: number; updatedAt?: number;
396
+ }
397
+
398
+ interface UserSettings { theme?: string; language?: string; timezone?: string; occupation?: string; company?: string; }
399
+ interface NotificationPreferences {
400
+ pushEnabled: boolean; newChatAssignment: boolean; newMessages: boolean;
401
+ escalations: boolean; urgentOnly: boolean; soundEnabled: boolean;
402
+ doNotDisturb: boolean; dndStart?: string; dndEnd?: string;
403
+ }
404
+ ```
405
+
406
+ > Analytics DTOs (`AnalyticsSummary`, `ChatAnalytics`, `OrgAnalytics`,
407
+ > `GlobalChannelSummary`, …) moved to `@elqnt/analytics/models`.
408
+
409
+ ---
410
+
411
+ ## API functions (`@elqnt/admin/api`) — for flows with no hook
412
+
413
+ Hooks cover orgs/settings (`useOrgAdmin`) and users/invites (`useUsersAdmin`).
414
+ The **onboarding wizard** and **billing/Stripe** flows have no hook — call their
415
+ api functions directly (with
416
+ `browserApiRequest` token handling already wired in). All take an options object
417
+ (`OnboardingApiOptions` allows a missing `orgId`; the rest take `ApiClientOptions`)
418
+ and return `ApiResponse<T>` (`{ data?, error?, status }` — check `error`).
419
+
420
+ ### Onboarding wizard
421
+
422
+ | Fn | Endpoint |
423
+ |---|---|
424
+ | `getOnboardingStatusApi(options)` | `GET /api/v1/onboarding/status` |
425
+ | `startOnboardingApi(options)` | `POST /api/v1/onboarding/start` |
426
+ | `createPaymentSessionApi(params, options)` | `POST /api/v1/onboarding/step/payment` |
427
+ | `createOrganizationApi(org, options)` | `POST /api/v1/onboarding/step/organization` |
428
+ | `sendOnboardingInvitesApi(invites, options)` | `POST /api/v1/onboarding/step/invites` |
429
+ | `createOnboardingKnowledgeApi(knowledge, options)` | `POST /api/v1/onboarding/step/knowledge` |
430
+ | `createOnboardingAgentApi(agent, options)` | `POST /api/v1/onboarding/step/agent` |
431
+ | `createAgentWithSkillsApi(payload, options)` | `POST /api/v1/onboarding/agent-with-skills` |
432
+ | `completeOnboardingApi(options)` | `POST /api/v1/onboarding/complete` |
433
+ | `skipOnboardingStepApi(step, options)` | `POST /api/v1/onboarding/skip-step` |
434
+ | `startOnboardingProvisioningApi(options)` | `POST /api/v1/onboarding/provisioning/start` → `{ orgId }` then connect SSE |
435
+ | `provisionAllOnboardingApi(request, options)` | `POST /api/v1/onboarding/provision-all` — unified collect-then-provision |
436
+
437
+ ### Org settings / org artifacts (api-level)
438
+
439
+ | Fn | Endpoint |
440
+ |---|---|
441
+ | `getOrgSettingsApi(options)` | `GET /api/v1/admin/orgs/{orgId}` (→ `OrgSettings`) |
442
+ | `updateOrgSettingsApi(settings, options)` | `PUT /api/v1/admin/orgs/{orgId}`, then re-GET |
443
+ | `createOrgSettingsApi(settings, options)` | *deprecated* alias of `updateOrgSettingsApi` |
444
+ | `updateOrgAgentsApi(agentIds, options)` | `PUT /api/v1/org/agents` |
445
+ | `updateEntityDefinitionsApi(entityNames, options)` | `PUT /api/v1/org/entities` |
446
+ | `updateWorkflowDefinitionsApi(workflowIds, options)` | `PUT /api/v1/org/workflows` |
447
+
448
+ ### Billing
449
+
450
+ | Fn | Endpoint |
451
+ |---|---|
452
+ | `getBillingPlansApi(options)` | `GET /api/v1/billing/plans` |
453
+ | `getSubscriptionApi(options)` | `GET /api/v1/billing/subscription` |
454
+ | `getCreditsApi(options)` | `GET /api/v1/billing/credits` |
455
+ | `createCheckoutSessionApi(params, options)` | `POST /api/v1/billing/checkout` |
456
+ | `createPortalSessionApi(params, options)` | `POST /api/v1/billing/portal` |
457
+ | `cancelSubscriptionApi(options)` | `POST /api/v1/billing/subscription/cancel` |
458
+ | `purchaseCreditsApi(params, options)` | `POST /api/v1/billing/credits/purchase` |
459
+
460
+ > **Deprecated — do not use.** `provisionDefaultAgentsApi`, `provisionEntitiesApi`,
461
+ > `provisionWorkflowsApi` (per-org artifact provisioning is gone — use the domain
462
+ > packages `@elqnt/agents/api`, `@elqnt/entity/api`, etc.). The lower-level CRUD
463
+ > api fns (`listOrgsApi`, `createUserApi`, `sendInvitesApi`, …) exist too, but
464
+ > prefer the hooks for app code. (Analytics api fns moved to
465
+ > `@elqnt/analytics/api`.)
466
+
467
+ ---
468
+
469
+ ## The app layer
470
+
471
+ Wrap the hook in **one** app file and a context, so components speak the domain
472
+ type, not the gateway. Read `baseUrl`/`orgId` from SSR (an `AppConfig` context
473
+ fed by `API_GATEWAY_URL_PUBLIC` at request time), never a `NEXT_PUBLIC_*` var.
474
+
475
+ ```tsx
476
+ // contexts/app-config-context.tsx
477
+ "use client";
478
+ import { createContext, useContext, type ReactNode } from "react";
479
+
480
+ export interface AppConfig { apiGatewayUrl: string; orgId: string; }
481
+ const Ctx = createContext<AppConfig | undefined>(undefined);
482
+ export function AppConfigProvider({ children, initialConfig }: { children: ReactNode; initialConfig: AppConfig; }) {
483
+ return <Ctx.Provider value={initialConfig}>{children}</Ctx.Provider>;
484
+ }
485
+ export function useAppConfig() {
486
+ const c = useContext(Ctx);
487
+ if (!c) throw new Error("useAppConfig must be used within AppConfigProvider");
488
+ return c;
489
+ }
490
+ ```
491
+
492
+ ```tsx
493
+ // app/layout.tsx (server component) — read the gateway URL at request time
494
+ import { AppConfigProvider } from "@/contexts/app-config-context";
495
+ const apiGatewayUrl = process.env.API_GATEWAY_URL_PUBLIC || "http://localhost:8080"; // NOT NEXT_PUBLIC_
496
+ export default async function Layout({ children }: { children: React.ReactNode }) {
497
+ return (
498
+ <AppConfigProvider initialConfig={{ apiGatewayUrl, orgId: process.env.ORG_ID! }}>
499
+ {children}
500
+ </AppConfigProvider>
501
+ );
502
+ }
503
+ ```
504
+
505
+ ```tsx
506
+ // contexts/orgs-context.tsx — one shared hook instance for the orgs subtree
507
+ "use client";
508
+ import { createContext, useContext, type ReactNode } from "react";
509
+ import { useOrgAdmin, type UseOrgAdminReturn } from "@elqnt/admin/hooks";
510
+ import { useAppConfig } from "@/contexts/app-config-context";
511
+
512
+ const OrgsCtx = createContext<UseOrgAdminReturn | undefined>(undefined);
513
+ export function OrgsProvider({ children }: { children: ReactNode }) {
514
+ const { apiGatewayUrl, orgId } = useAppConfig();
515
+ const value = useOrgAdmin({ baseUrl: apiGatewayUrl, orgId }); // single instance
516
+ return <OrgsCtx.Provider value={value}>{children}</OrgsCtx.Provider>;
517
+ }
518
+ export function useOrgsContext(): UseOrgAdminReturn {
519
+ const c = useContext(OrgsCtx);
520
+ if (!c) throw new Error("useOrgsContext must be used within OrgsProvider");
521
+ return c;
522
+ }
523
+ ```
524
+
525
+ ```tsx
526
+ // components/orgs/orgs-table.tsx — consume the context, never import @elqnt/admin
527
+ "use client";
528
+ import { useEffect, useState } from "react";
529
+ import { useOrgsContext } from "@/contexts/orgs-context";
530
+ import type { Org } from "@elqnt/admin/models";
531
+
532
+ export function OrgsTable() {
533
+ const { listOrgs, loading, error } = useOrgsContext();
534
+ const [rows, setRows] = useState<Org[]>([]);
535
+ useEffect(() => { listOrgs({ status: "active" }).then(setRows); }, [listOrgs]);
536
+ if (error) return <p>{error}</p>;
537
+ return <ul>{rows.map((o) => <li key={o.id}>{o.title} — {o.userCount} users</li>)}</ul>;
538
+ }
539
+ ```
540
+
541
+ **Rule of thumb:** `@elqnt/admin` is imported in exactly the context/app-hook
542
+ file. Everything above it speaks `Org` / `User` / `Invite`. Typed `Use*Return`
543
+ imports (`UseOrgAdminReturn`, …) are how the context carries the hook shape.
544
+
545
+ ---
546
+
547
+ ## Gotchas
548
+
549
+ - **Two hooks, browser-only.** `useOrgAdmin` (org CRUD + settings) and
550
+ `useUsersAdmin` (user CRUD + invitations). Both are `"use client"`. For
551
+ SSR/server actions, call the gateway paths via `createServerClient`, not the
552
+ hooks. (Product analytics moved to `@elqnt/analytics` — `useProductAnalytics`.)
553
+ - **Methods don't throw.** They return defaults (`[]`, `null`, `false`) and set
554
+ `error`. Check `error` / null results; don't wrap in try/catch expecting throws.
555
+ - **No provider in the package.** Options go into every hook call — which is why
556
+ your app builds `AppConfigProvider` (inject `baseUrl`/`orgId` once via SSR) and
557
+ a per-domain context.
558
+ - **Callbacks are unstable.** Methods are recreated when `options` change; use a
559
+ `useRef` if you need a stable reference inside `useEffect` deps.
560
+ - **`product` selects the DB, it is not stored.** The gateway resolves product
561
+ from the JWT claim (server) and `X-Product` (browser). `listOrgs`/`createOrg`'s
562
+ `product` field is transient routing metadata — mismatched product = wrong
563
+ `admin_<product>` DB.
564
+ - **Org settings live on the Org row.** `useOrgAdmin`'s
565
+ `getSettings`/`updateSettings` and `get/updateOrgSettingsApi` hit
566
+ `/api/v1/admin/orgs/{orgId}` and re-map to the legacy `OrgSettings` shape —
567
+ there is no separate settings endpoint.
568
+ - **Per-org artifact provisioning is gone.** `provisionDefaultAgentsApi` /
569
+ `provisionEntitiesApi` / `provisionWorkflowsApi` are deprecated; use the domain
570
+ packages. See `docs/_db_v2/03_system_org_split.md`.