@startsimpli/integrations 0.1.0 → 0.1.4

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 ADDED
@@ -0,0 +1,178 @@
1
+ # @startsimpli/integrations
2
+
3
+ Provider-agnostic TypeScript client for the OAuth-backed **email** and **calendar** integrations that StartSimpli apps consume from the Django API. Ships:
4
+
5
+ - **Typed API clients** wrapping `start-simpli-api`'s `/auth/oauth/*`, `/auth/oauth-accounts/`, `/calendar/events/`, and `/email/messages/` endpoints
6
+ - **React Query hooks** for connected accounts, calendar events, and email messages — with built-in re-consent flow handling
7
+ - **Headless React UI primitives** for connect cards, account status, event/email lists, and re-consent prompts
8
+ - **Normalized error types** (`UnauthenticatedError`, `ReconsentRequiredError`, `ApiError`) so the UI can branch on auth failure modes without parsing payloads
9
+
10
+ ## The connections layer (foundries)
11
+
12
+ Alongside the email/calendar clients above, this package ships the UI for the
13
+ **connector framework** — the surface a foundry admin uses to connect a vendor
14
+ account and wire it up:
15
+
16
+ - **`CredentialSchemaForm`** — ONE renderer for every vendor, driven by the
17
+ catalog's own field schema (`{version, fields[]}`). It knows about FIELDS and
18
+ nothing about vendors: an unknown field type degrades to a text input, an
19
+ unrecognised schema version still renders its fields, and a field the server
20
+ marks `type: "secret"` / `write_only` is masked and never populated from a
21
+ read response.
22
+ - **`ConnectionsPage`** — the page composer: connected accounts, the catalog,
23
+ and the wiring flow (**discover → wire → confirm → fire**). Injected api, no
24
+ React context and no app-local provider, so an app mounts it in ten lines:
25
+
26
+ ```tsx
27
+ <ConnectionsPage api={apiClient.foundry} foundrySlug={slug} />
28
+ ```
29
+
30
+ **There are no per-vendor components, and a test enforces it** — it greps the
31
+ component tree for vendor keys. The backend refuses to hardcode a vendor (the
32
+ catalog is `DestinationProvider` rows and the field schemas come off the
33
+ connector class); that is worth nothing if the browser answers with
34
+ `if (key === 'n8n')`.
35
+
36
+ **OAuth providers shipped in-box:** `google` and `microsoft`. The Django backend brokers the OAuth handshake — this package never touches client secrets or refresh tokens directly. Reference lifecycle: `email-cal-srcs-dests`.
37
+
38
+ ## Install
39
+
40
+ Workspace-only:
41
+
42
+ ```jsonc
43
+ "dependencies": {
44
+ "@startsimpli/integrations": "workspace:*"
45
+ }
46
+ ```
47
+
48
+ Peer deps (both optional, only needed for the hooks/components subpaths):
49
+
50
+ - `react ^18 || ^19`
51
+ - `@tanstack/react-query ^5`
52
+
53
+ You can consume `@startsimpli/integrations/types` and `@startsimpli/integrations/api` from a non-React context (e.g. a Node server, an MSW handler) without pulling in React.
54
+
55
+ ## Public surface
56
+
57
+ | Export | Type | Description |
58
+ | --- | --- | --- |
59
+ | `OAuthProvider` | `'google' \| 'microsoft'` | The two providers backed by Django |
60
+ | `AccountStatus` | `'connected' \| 'disconnected' \| 'needs_reauth'` | Status union for `ConnectAccountCard` |
61
+ | `ConnectedAccount` / `CalendarEvent` / `CalendarEventAttendee` / `CalendarEventStatus` / `EmailMessage` / `PaginatedResponse<T>` / `CalendarWindow` / `ApiErrorPayload` | types | DRF response shapes |
62
+ | `ApiError` / `UnauthenticatedError` / `ReconsentRequiredError` | classes | Thrown by every API client; the UI uses `instanceof` to branch |
63
+ | `connectProvider(provider, redirectUri?)` | `POST /auth/oauth/{provider}/initiate/` | Returns the upstream `authorization_url` to redirect to |
64
+ | `revokeAccount(provider, oauthAccountId)` | `POST /auth/oauth/{provider}/revoke/` | Disconnects a connected account |
65
+ | `listConnectedAccounts()` | `GET /auth/oauth-accounts/` | All accounts for the current user |
66
+ | `listCalendarEvents({ from, to, provider?, calendar_id?, page?, page_size? })` | `GET /calendar/events/` | Paginated events in a window |
67
+ | `listEmailMessages({ provider?, account_id?, since?, is_outbound?, page?, page_size? })` | `GET /email/messages/` | Paginated messages |
68
+ | `setApiBase(url)` / `getApiBase()` | functions | Override the default `/api/v1` base URL |
69
+ | `setAuthTokenProvider(fn)` | function | Wire up a token source — injected as `Authorization: Bearer <token>` |
70
+ | `useConnectedAccounts(options?)` | hook | React Query wrapper around `listConnectedAccounts` (30 s `staleTime`) |
71
+ | `useDisconnectAccount()` | hook | Returns a mutator that revokes + invalidates the accounts query |
72
+ | `useCalendarEvents(window, options?)` | hook | Wraps `listCalendarEvents`; surfaces `events`, `reconsentUrl` |
73
+ | `useEmailMessages(filter?, options?)` | hook | Wraps `listEmailMessages`; surfaces `messages`, `reconsentUrl` |
74
+ | `useReconsent(error)` | hook | Extracts a `reconsent_url` from a thrown error + returns `openReconsent()` |
75
+ | `CONNECTED_ACCOUNTS_KEY` | const tuple | Stable React Query key for invalidation |
76
+ | `AccountStatusBadge` | component | "Connected / Needs re-auth / Disconnected" pill |
77
+ | `ConnectAccountCard` | component | Per-provider connect/disconnect card |
78
+ | `EmailList` / `EventList` / `EventListItem` | components | Headless list renderers |
79
+ | `ReconsentPromptCard` | component | Re-consent CTA card driven by `useReconsent` |
80
+
81
+ Subpath exports: `.`, `./types` (zero React), `./api` (zero React).
82
+
83
+ ### Adapters that ship today
84
+
85
+ | Provider | OAuth | Mail | Calendar |
86
+ | --- | :---: | :---: | :---: |
87
+ | **Google** | yes (`gmail.readonly`, `calendar.readonly`) | yes | yes |
88
+ | **Microsoft** | yes (`Mail.Read`, `Calendars.Read`) | yes | yes |
89
+
90
+ No other adapters (Apollo, Hunter, Stripe, …) live in this package — those concerns belong in their own packages or directly in the Django backend. The single source of truth for available providers is `OAuthProvider` in `src/types/index.ts`.
91
+
92
+ ## Configuration
93
+
94
+ This is a thin client over Django — all provider credentials (Google client ID/secret, Microsoft app registration, scopes, refresh-token vault) live in `start-simpli-api`. From the browser side, you only need to wire up:
95
+
96
+ | Setup call | Why |
97
+ | --- | --- |
98
+ | `setApiBase('https://api.example.com/api/v1')` | Point at your Django host. Defaults to `/api/v1` (relative). |
99
+ | `setAuthTokenProvider(() => getAccessToken())` | Provide a fresh JWT/session token for every request. Returning `null` falls back to cookie auth (the fetch wrapper uses `credentials: 'include'`). |
100
+
101
+ Errors surfaced from the Django API are normalized:
102
+
103
+ - **401** → `UnauthenticatedError`
104
+ - **403** with `reconsent_url` in the payload → `ReconsentRequiredError` (drives the re-consent prompt)
105
+ - All other non-2xx → `ApiError` with `status` + `payload`
106
+
107
+ ## Usage
108
+
109
+ Real example from `raise-simpli/web-app/src/app/(dashboard)/settings/integrations/page.tsx`:
110
+
111
+ ```tsx
112
+ 'use client'
113
+ import {
114
+ ConnectAccountCard,
115
+ useConnectedAccounts,
116
+ useDisconnectAccount,
117
+ type ConnectedAccount,
118
+ type OAuthProvider,
119
+ } from '@startsimpli/integrations'
120
+
121
+ const PROVIDERS: OAuthProvider[] = ['microsoft', 'google']
122
+
123
+ export default function IntegrationsPage() {
124
+ const { data: accounts = [], isLoading, error } = useConnectedAccounts()
125
+ const disconnect = useDisconnectAccount()
126
+
127
+ const accountByProvider = (provider: OAuthProvider): ConnectedAccount | undefined =>
128
+ accounts.find((a) => a.provider === provider)
129
+
130
+ return (
131
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
132
+ {PROVIDERS.map((provider) => (
133
+ <ConnectAccountCard
134
+ key={provider}
135
+ provider={provider}
136
+ account={accountByProvider(provider)}
137
+ onDisconnect={(id) => disconnect(provider, id)}
138
+ />
139
+ ))}
140
+ </div>
141
+ )
142
+ }
143
+ ```
144
+
145
+ Calendar window with re-consent handling:
146
+
147
+ ```tsx
148
+ const { events, reconsentUrl, isLoading } = useCalendarEvents({
149
+ from: startOfWeek,
150
+ to: endOfWeek,
151
+ }, { provider: 'google', pageSize: 200 })
152
+
153
+ if (reconsentUrl) return <ReconsentPromptCard href={reconsentUrl} />
154
+ ```
155
+
156
+ App startup wiring (called once):
157
+
158
+ ```ts
159
+ import { setApiBase, setAuthTokenProvider } from '@startsimpli/integrations'
160
+ import { getAccessToken } from '@startsimpli/auth/client'
161
+
162
+ setApiBase(process.env.NEXT_PUBLIC_API_BASE_URL!)
163
+ setAuthTokenProvider(() => getAccessToken())
164
+ ```
165
+
166
+ ## Verification
167
+
168
+ ```bash
169
+ cd packages/integrations
170
+ pnpm vitest run
171
+ pnpm tsc --noEmit
172
+ ```
173
+
174
+ The test suite (`src/__tests__/api.test.ts`) ships **10 tests** covering the OAuth/calendar/email clients and the 401 / 403-with-reconsent / 5xx error normalisation paths against an MSW-mocked Django backend.
175
+
176
+ ## Shared-first
177
+
178
+ Per CLAUDE.md **rule 9**, do **not** copy fetch helpers, types, or React Query hooks for calendar/email into an app's `src/`. The package guards this — `raise-simpli/web-app/src/__tests__/no_app_local_calendar_code.test.ts` exists to fail CI if app-local duplicates reappear. Extend `@startsimpli/integrations` instead.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/integrations",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Provider-agnostic types, API clients, hooks, and UI primitives for email + calendar integrations across StartSimpli apps.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -16,27 +16,36 @@
16
16
  "publishConfig": {
17
17
  "access": "public"
18
18
  },
19
- "scripts": {
20
- "type-check": "tsc --noEmit",
21
- "test": "vitest run",
22
- "test:watch": "vitest"
23
- },
24
19
  "dependencies": {},
25
20
  "peerDependencies": {
26
21
  "react": ">=18.0.0",
27
22
  "@tanstack/react-query": ">=5.0.0"
28
23
  },
29
24
  "peerDependenciesMeta": {
30
- "react": { "optional": true },
31
- "@tanstack/react-query": { "optional": true }
25
+ "react": {
26
+ "optional": true
27
+ },
28
+ "@tanstack/react-query": {
29
+ "optional": true
30
+ }
32
31
  },
33
32
  "devDependencies": {
34
33
  "@tanstack/react-query": "^5.0.0",
34
+ "@testing-library/jest-dom": "^6.6.3",
35
+ "@testing-library/react": "^16.1.0",
36
+ "@testing-library/user-event": "^14.5.2",
35
37
  "@types/node": "^22.0.0",
36
38
  "@types/react": "^19.0.0",
39
+ "jsdom": "^29.0.0",
37
40
  "msw": "^2.0.0",
38
41
  "react": "^19.0.0",
42
+ "react-dom": "^19.0.0",
39
43
  "typescript": "^6.0.0",
40
44
  "vitest": "^3.0.0"
45
+ },
46
+ "scripts": {
47
+ "type-check": "tsc --noEmit",
48
+ "test": "vitest run",
49
+ "test:watch": "vitest"
41
50
  }
42
- }
51
+ }