@catandbox/schrodinger-contracts 0.1.28 → 0.1.33

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,117 @@
1
+ # @catandbox/schrodinger-contracts
2
+
3
+ TypeScript contracts and API client for [Schrodinger](https://github.com/yetone/schrodinger) — an AI-powered headless helpdesk built on Cloudflare Workers.
4
+
5
+ This package contains:
6
+ - **Generated TypeScript types** from the OpenAPI spec
7
+ - **`SchrodingerApiClient`** — low-level generated HTTP client
8
+ - **`SupportApiClient`** — high-level fetch-based client for the customer-facing portal API
9
+ - All shared interfaces used by the web adapter and any custom integrations
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ npm install @catandbox/schrodinger-contracts
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ### SupportApiClient
20
+
21
+ The primary client for interacting with the Schrodinger support portal API from a browser or edge runtime. No framework dependencies — works anywhere `fetch` is available.
22
+
23
+ ```ts
24
+ import { SupportApiClient } from "@catandbox/schrodinger-contracts";
25
+
26
+ const client = new SupportApiClient({
27
+ basePath: "/support/api", // path prefix where the proxy is mounted
28
+ // baseUrl: "https://...", // explicit base URL (optional)
29
+ // headers: { "X-My-Header": "value" }, // static headers
30
+ // getHeaders: async () => ({ ... }), // dynamic headers (e.g. auth tokens)
31
+ });
32
+
33
+ // List tickets
34
+ const { items } = await client.listTickets({ status: "Active" });
35
+
36
+ // Get a single ticket with its messages
37
+ const { ticket, messages, events } = await client.getTicket(ticketId);
38
+
39
+ // Create a new ticket
40
+ const ticket = await client.createTicket({
41
+ title: "Something isn't working",
42
+ body: "Here are the details...",
43
+ categoryId: "cat_123", // optional
44
+ });
45
+
46
+ // Reply to a ticket
47
+ const message = await client.createReply(ticketId, { body: "Thank you!" });
48
+
49
+ // Close / reopen
50
+ await client.closeTicket(ticketId);
51
+ await client.reopenTicket(ticketId);
52
+
53
+ // Rate a ticket or a message
54
+ await client.rateTicket(ticketId, { stars: 5, comment: "Great support!" });
55
+ await client.rateMessage(messageId, { stars: 4 });
56
+ ```
57
+
58
+ ### File uploads
59
+
60
+ ```ts
61
+ // 1. Initialise uploads
62
+ const { uploads } = await client.initUploads({
63
+ ticketId,
64
+ files: [{ filename: "screenshot.png", mime: "image/png", sizeBytes: 12345, sha256: "..." }],
65
+ });
66
+
67
+ // 2. PUT each file to its pre-signed URL
68
+ await client.putUpload(uploads[0].putUrl, file, (loaded, total) => {
69
+ console.log(`${Math.round((loaded / total) * 100)}%`);
70
+ });
71
+
72
+ // 3. Confirm completion
73
+ await client.completeUploads({
74
+ ticketId,
75
+ uploads: [{ uploadId: uploads[0].uploadId, sizeBytes: file.size, sha256: "..." }],
76
+ messageId, // optional — attach to an existing message
77
+ });
78
+ ```
79
+
80
+ ### SupportApiError
81
+
82
+ ```ts
83
+ import { SupportApiClient, SupportApiError } from "@catandbox/schrodinger-contracts";
84
+
85
+ try {
86
+ await client.createTicket({ title: "...", body: "..." });
87
+ } catch (err) {
88
+ if (err instanceof SupportApiError) {
89
+ console.error(err.status, err.code, err.message, err.requestId);
90
+ }
91
+ }
92
+ ```
93
+
94
+ ## Types
95
+
96
+ All types are exported from the package root:
97
+
98
+ | Type | Description |
99
+ |---|---|
100
+ | `SupportClientOptions` | Constructor options for `SupportApiClient` |
101
+ | `ClientHeadersProvider` | Async function returning dynamic request headers |
102
+ | `ListTicketsParams` | Parameters for `listTickets()` |
103
+ | `SupportCategory` | A support category |
104
+ | `TicketDetailData` | Return type of `getTicket()` — ticket + messages + events |
105
+ | `UploadInputFile` | File descriptor for `initUploads()` |
106
+ | `UploadInitResult` | Return type of `initUploads()` |
107
+ | `UploadCompleteInput` | Input for `completeUploads()` |
108
+ | `Ticket`, `Message`, `Alias` | Core domain types (generated from OpenAPI) |
109
+ | `TicketStatus` | `"Active" \| "InProgress" \| "AwaitingResponse" \| "Closed" \| "Archived"` |
110
+
111
+ ## OpenAPI spec
112
+
113
+ The raw OpenAPI specification is included in the package at `openapi/schrodinger.openapi.yaml` and can be used to generate clients in other languages.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,120 @@
1
+ import type { Alias, Message, RatingRequest, Ticket, TicketStatus } from "../generated/types";
2
+ export interface ClientHeadersProvider {
3
+ (): Record<string, string> | Promise<Record<string, string>>;
4
+ }
5
+ export interface SupportClientOptions {
6
+ basePath?: string;
7
+ baseUrl?: string;
8
+ fetchImpl?: typeof fetch;
9
+ headers?: Record<string, string>;
10
+ getHeaders?: ClientHeadersProvider;
11
+ /** BCP 47 locale tag (e.g. "en", "fr", "de"). Defaults to the browser locale. */
12
+ locale?: string;
13
+ }
14
+ export interface ListTicketsParams {
15
+ status?: TicketStatus;
16
+ }
17
+ export interface SupportCategory {
18
+ id: string;
19
+ integrationId?: string;
20
+ name: string;
21
+ isDeleted?: boolean;
22
+ }
23
+ export interface TicketDetailData {
24
+ ticket: Ticket;
25
+ messages: Message[];
26
+ events: Array<{
27
+ id: string;
28
+ eventType: string;
29
+ createdAt: number;
30
+ payloadJson?: string;
31
+ }>;
32
+ }
33
+ export interface UploadInputFile {
34
+ filename: string;
35
+ mime: string;
36
+ sizeBytes: number;
37
+ sha256: string;
38
+ }
39
+ export interface UploadInitResult {
40
+ uploads: Array<{
41
+ uploadId: string;
42
+ attachmentId: string;
43
+ filename: string;
44
+ putUrl: string;
45
+ }>;
46
+ }
47
+ export interface UploadCompleteInput {
48
+ ticketId: string;
49
+ uploads: Array<{
50
+ uploadId: string;
51
+ sizeBytes: number;
52
+ sha256: string;
53
+ }>;
54
+ messageId?: string | null;
55
+ }
56
+ export declare class SupportApiError extends Error {
57
+ readonly code: string;
58
+ readonly requestId: string | null;
59
+ readonly status: number;
60
+ constructor(input: {
61
+ message: string;
62
+ status: number;
63
+ code?: string;
64
+ requestId?: string | null;
65
+ });
66
+ }
67
+ export declare class SupportApiClient {
68
+ private readonly basePath;
69
+ private readonly baseUrl;
70
+ private readonly fetchImpl;
71
+ private readonly staticHeaders;
72
+ private readonly getHeaders;
73
+ private readonly contractsClient;
74
+ constructor(options?: SupportClientOptions);
75
+ getCategories(): Promise<{
76
+ items: SupportCategory[];
77
+ }>;
78
+ getPortalConfig(): Promise<{
79
+ categories: Array<{
80
+ id: string;
81
+ name: string;
82
+ }>;
83
+ aliases: Alias[];
84
+ }>;
85
+ listTickets(params?: ListTicketsParams): Promise<{
86
+ items: Ticket[];
87
+ }>;
88
+ getTicket(ticketId: string): Promise<TicketDetailData>;
89
+ createTicket(input: {
90
+ categoryId?: string | null;
91
+ title: string;
92
+ body: string;
93
+ clientMessageId?: string | null;
94
+ }): Promise<Ticket>;
95
+ createReply(ticketId: string, input: {
96
+ body: string;
97
+ clientMessageId?: string | null;
98
+ }): Promise<Message>;
99
+ closeTicket(ticketId: string): Promise<Ticket>;
100
+ archiveTicket(ticketId: string): Promise<Ticket>;
101
+ reopenTicket(ticketId: string): Promise<Ticket>;
102
+ rateTicket(ticketId: string, input: Omit<RatingRequest, "tenantExternalId" | "principalExternalId">): Promise<{
103
+ ok: true;
104
+ }>;
105
+ rateMessage(messageId: string, input: Omit<RatingRequest, "tenantExternalId" | "principalExternalId">): Promise<{
106
+ ok: true;
107
+ aliasRated: boolean;
108
+ }>;
109
+ initUploads(input: {
110
+ ticketId: string;
111
+ files: UploadInputFile[];
112
+ }): Promise<UploadInitResult>;
113
+ completeUploads(input: UploadCompleteInput): Promise<{
114
+ completed: number;
115
+ scanStatus: "pending";
116
+ }>;
117
+ putUpload(putUrl: string, file: File, onProgress?: (loaded: number, total: number) => void): Promise<void>;
118
+ private requestJson;
119
+ private resolveHeaders;
120
+ }
@@ -0,0 +1,334 @@
1
+ import { SchrodingerApiClient } from "../generated/client";
2
+ export class SupportApiError extends Error {
3
+ code;
4
+ requestId;
5
+ status;
6
+ constructor(input) {
7
+ super(input.message);
8
+ this.name = "SupportApiError";
9
+ this.status = input.status;
10
+ this.code = input.code ?? "SUPPORT_API_ERROR";
11
+ this.requestId = input.requestId ?? null;
12
+ }
13
+ }
14
+ export class SupportApiClient {
15
+ basePath;
16
+ baseUrl;
17
+ fetchImpl;
18
+ staticHeaders;
19
+ getHeaders;
20
+ contractsClient;
21
+ constructor(options = {}) {
22
+ this.basePath = normalizeBasePath(options.basePath ?? "/support/api");
23
+ this.baseUrl = resolveBaseUrl(options.baseUrl, this.basePath);
24
+ this.fetchImpl = (options.fetchImpl ?? fetch).bind(globalThis);
25
+ this.staticHeaders = options.headers ?? {};
26
+ this.getHeaders = options.getHeaders ?? null;
27
+ this.contractsClient = new SchrodingerApiClient({
28
+ baseUrl: this.baseUrl,
29
+ fetchImpl: (input, init) => {
30
+ const requestUrl = new URL(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url, this.baseUrl);
31
+ requestUrl.pathname = requestUrl.pathname.replace(new RegExp(`^${escapeRegExp(this.basePath)}/v1(?=/|$)`), // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
32
+ this.basePath);
33
+ return this.fetchImpl(requestUrl.toString(), init);
34
+ }
35
+ });
36
+ }
37
+ async getCategories() {
38
+ return await this.requestJson("GET", "/categories");
39
+ }
40
+ async getPortalConfig() {
41
+ return await this.contractsClient.request("GET /v1/portal-config", undefined, {
42
+ headers: await this.resolveHeaders()
43
+ });
44
+ }
45
+ async listTickets(params = {}) {
46
+ return await this.contractsClient.request("GET /v1/tickets", undefined, {
47
+ headers: await this.resolveHeaders(),
48
+ query: {
49
+ status: params.status
50
+ }
51
+ });
52
+ }
53
+ async getTicket(ticketId) {
54
+ const [payload, historyPayload] = await Promise.all([
55
+ this.requestJson("GET", `/tickets/${encodeURIComponent(ticketId)}`),
56
+ this.requestJson("GET", `/tickets/${encodeURIComponent(ticketId)}/history`).catch(() => null)
57
+ ]);
58
+ const detail = normalizeTicketDetailPayload(payload);
59
+ if (historyPayload !== null) {
60
+ const historyEvents = asEventArrayFromHistory(historyPayload);
61
+ if (historyEvents.length > 0) {
62
+ detail.events = historyEvents;
63
+ }
64
+ }
65
+ return detail;
66
+ }
67
+ async createTicket(input) {
68
+ return await this.contractsClient.request("POST /v1/tickets", input, {
69
+ headers: await this.resolveHeaders()
70
+ });
71
+ }
72
+ async createReply(ticketId, input) {
73
+ return await this.contractsClient.request("POST /v1/tickets/{ticketId}/messages", input, {
74
+ headers: await this.resolveHeaders(),
75
+ pathParams: {
76
+ ticketId
77
+ }
78
+ });
79
+ }
80
+ async closeTicket(ticketId) {
81
+ return await this.requestJson("POST", `/tickets/${encodeURIComponent(ticketId)}/close`, {});
82
+ }
83
+ async archiveTicket(ticketId) {
84
+ return await this.requestJson("POST", `/tickets/${encodeURIComponent(ticketId)}/archive`, {});
85
+ }
86
+ async reopenTicket(ticketId) {
87
+ return await this.requestJson("POST", `/tickets/${encodeURIComponent(ticketId)}/reopen`, {});
88
+ }
89
+ async rateTicket(ticketId, input) {
90
+ return await this.contractsClient.request("POST /v1/tickets/{ticketId}/ratings", input, {
91
+ headers: await this.resolveHeaders(),
92
+ pathParams: {
93
+ ticketId
94
+ }
95
+ });
96
+ }
97
+ async rateMessage(messageId, input) {
98
+ return await this.contractsClient.request("POST /v1/messages/{messageId}/ratings", input, {
99
+ headers: await this.resolveHeaders(),
100
+ pathParams: {
101
+ messageId
102
+ }
103
+ });
104
+ }
105
+ async initUploads(input) {
106
+ return await this.contractsClient.request("POST /v1/uploads/init", input, {
107
+ headers: await this.resolveHeaders()
108
+ });
109
+ }
110
+ async completeUploads(input) {
111
+ return await this.contractsClient.request("POST /v1/uploads/complete", input, {
112
+ headers: await this.resolveHeaders()
113
+ });
114
+ }
115
+ async putUpload(putUrl, file, onProgress) {
116
+ if (onProgress) {
117
+ await uploadViaXmlHttpRequest({
118
+ url: putUrl,
119
+ file,
120
+ onProgress
121
+ });
122
+ return;
123
+ }
124
+ await uploadViaXmlHttpRequest({
125
+ url: putUrl,
126
+ file
127
+ });
128
+ }
129
+ async requestJson(method, path, body) {
130
+ const url = new URL(`${this.baseUrl}${path}`);
131
+ const headers = await this.resolveHeaders();
132
+ const response = await this.fetchImpl(url.toString(), {
133
+ method,
134
+ headers: {
135
+ ...headers,
136
+ ...(body === undefined ? {} : { "content-type": "application/json" })
137
+ },
138
+ body: body === undefined ? null : JSON.stringify(body)
139
+ });
140
+ if (!response.ok) {
141
+ throw await toSupportApiError(response);
142
+ }
143
+ return (await response.json());
144
+ }
145
+ async resolveHeaders() {
146
+ const dynamic = this.getHeaders ? await this.getHeaders() : {};
147
+ return {
148
+ ...this.staticHeaders,
149
+ ...dynamic
150
+ };
151
+ }
152
+ }
153
+ async function uploadViaXmlHttpRequest(input) {
154
+ await new Promise((resolve, reject) => {
155
+ const xhr = new XMLHttpRequest();
156
+ xhr.open("PUT", input.url);
157
+ xhr.setRequestHeader("content-type", input.file.type || "application/octet-stream");
158
+ xhr.upload.onprogress = (event) => {
159
+ if (event.lengthComputable) {
160
+ input.onProgress?.(event.loaded, event.total);
161
+ }
162
+ };
163
+ xhr.onerror = () => reject(new Error("Upload failed"));
164
+ xhr.onload = () => {
165
+ if (xhr.status >= 200 && xhr.status < 300) {
166
+ resolve();
167
+ }
168
+ else {
169
+ reject(new Error(`Upload failed with status ${xhr.status}`));
170
+ }
171
+ };
172
+ xhr.send(input.file);
173
+ });
174
+ }
175
+ async function toSupportApiError(response) {
176
+ try {
177
+ const payload = (await response.json());
178
+ return new SupportApiError(payload.error
179
+ ? {
180
+ status: response.status,
181
+ message: payload.message ?? `Request failed with status ${response.status}`,
182
+ code: payload.error,
183
+ requestId: payload.requestId ?? response.headers.get("X-Request-Id")
184
+ }
185
+ : {
186
+ status: response.status,
187
+ message: payload.message ?? `Request failed with status ${response.status}`,
188
+ requestId: payload.requestId ?? response.headers.get("X-Request-Id")
189
+ });
190
+ }
191
+ catch {
192
+ return new SupportApiError({
193
+ status: response.status,
194
+ message: `Request failed with status ${response.status}`,
195
+ requestId: response.headers.get("X-Request-Id")
196
+ });
197
+ }
198
+ }
199
+ function resolveBaseUrl(baseUrl, basePath) {
200
+ if (baseUrl) {
201
+ return trimTrailingSlash(baseUrl);
202
+ }
203
+ if (typeof window !== "undefined" && window.location?.origin) {
204
+ return `${window.location.origin}${basePath}`;
205
+ }
206
+ return `http://localhost${basePath}`;
207
+ }
208
+ function normalizeBasePath(path) {
209
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
210
+ return withSlash.replace(/\/+$/, "");
211
+ }
212
+ function trimTrailingSlash(value) {
213
+ return value.replace(/\/+$/, "");
214
+ }
215
+ function escapeRegExp(value) {
216
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
217
+ }
218
+ function normalizeTicketDetailPayload(payload) {
219
+ if (isTicket(payload)) {
220
+ return {
221
+ ticket: payload,
222
+ messages: [],
223
+ events: []
224
+ };
225
+ }
226
+ if (isRecord(payload) && isTicket(payload.ticket)) {
227
+ const messages = asMessageArray(payload.messages);
228
+ const events = asEventArray(payload.events);
229
+ return {
230
+ ticket: payload.ticket,
231
+ messages,
232
+ events
233
+ };
234
+ }
235
+ throw new Error("Invalid ticket detail payload");
236
+ }
237
+ function asMessageArray(value) {
238
+ if (!Array.isArray(value)) {
239
+ return [];
240
+ }
241
+ return value.filter(isMessage).sort((left, right) => left.createdAt - right.createdAt);
242
+ }
243
+ function asEventArray(value) {
244
+ if (!Array.isArray(value)) {
245
+ return [];
246
+ }
247
+ const events = [];
248
+ for (const entry of value) {
249
+ if (!isRecord(entry)) {
250
+ continue;
251
+ }
252
+ if (typeof entry.id !== "string" ||
253
+ typeof entry.eventType !== "string" ||
254
+ typeof entry.createdAt !== "number") {
255
+ continue;
256
+ }
257
+ if ("payloadJson" in entry &&
258
+ entry.payloadJson !== undefined &&
259
+ typeof entry.payloadJson !== "string") {
260
+ continue;
261
+ }
262
+ if (typeof entry.payloadJson === "string") {
263
+ events.push({
264
+ id: entry.id,
265
+ eventType: entry.eventType,
266
+ createdAt: entry.createdAt,
267
+ payloadJson: entry.payloadJson
268
+ });
269
+ }
270
+ else {
271
+ events.push({
272
+ id: entry.id,
273
+ eventType: entry.eventType,
274
+ createdAt: entry.createdAt
275
+ });
276
+ }
277
+ }
278
+ return events.sort((left, right) => left.createdAt - right.createdAt);
279
+ }
280
+ function isTicket(value) {
281
+ if (!isRecord(value)) {
282
+ return false;
283
+ }
284
+ return (typeof value.id === "string" &&
285
+ typeof value.tenantId === "string" &&
286
+ typeof value.principalId === "string" &&
287
+ (typeof value.categoryId === "string" || value.categoryId === null) &&
288
+ typeof value.status === "string" &&
289
+ typeof value.title === "string" &&
290
+ (typeof value.assignedAliasId === "string" || value.assignedAliasId === null) &&
291
+ typeof value.createdAt === "number" &&
292
+ typeof value.updatedAt === "number");
293
+ }
294
+ function isMessage(value) {
295
+ if (!isRecord(value)) {
296
+ return false;
297
+ }
298
+ return (typeof value.id === "string" &&
299
+ typeof value.ticketId === "string" &&
300
+ typeof value.authorType === "string" &&
301
+ typeof value.isPublic === "boolean" &&
302
+ typeof value.bodyPlain === "string" &&
303
+ (typeof value.authorAliasId === "string" || value.authorAliasId === null) &&
304
+ typeof value.createdAt === "number");
305
+ }
306
+ function isRecord(value) {
307
+ return typeof value === "object" && value !== null;
308
+ }
309
+ function asEventArrayFromHistory(payload) {
310
+ const items = isRecord(payload) && Array.isArray(payload.items) ? payload.items : [];
311
+ const events = [];
312
+ for (const entry of items) {
313
+ if (!isRecord(entry)) {
314
+ continue;
315
+ }
316
+ if (typeof entry.id !== "string" ||
317
+ typeof entry.eventType !== "string" ||
318
+ typeof entry.createdAt !== "number") {
319
+ continue;
320
+ }
321
+ const payloadJson = typeof entry.payloadJson === "string"
322
+ ? entry.payloadJson
323
+ : isRecord(entry.payload)
324
+ ? JSON.stringify(entry.payload)
325
+ : undefined;
326
+ events.push({
327
+ id: entry.id,
328
+ eventType: entry.eventType,
329
+ createdAt: entry.createdAt,
330
+ ...(payloadJson !== undefined ? { payloadJson } : {})
331
+ });
332
+ }
333
+ return events.sort((left, right) => left.createdAt - right.createdAt);
334
+ }
@@ -1,2 +1,3 @@
1
1
  export * from "../generated/types";
2
2
  export * from "../generated/client";
3
+ export * from "./client";
package/dist/src/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from "../generated/types";
2
2
  export * from "../generated/client";
3
+ export * from "./client";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@catandbox/schrodinger-contracts",
3
- "version": "0.1.28",
3
+ "version": "0.1.33",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",