@wokuapp/react-native 0.1.0 → 0.3.0

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/dist/index.js CHANGED
@@ -4,7 +4,8 @@ var fetchHttpClient = {
4
4
  const res = await fetch(req.url, {
5
5
  method: req.method,
6
6
  headers: req.headers,
7
- body: req.body
7
+ body: req.body,
8
+ signal: req.signal
8
9
  });
9
10
  return {
10
11
  status: res.status,
@@ -69,12 +70,16 @@ var WokuNetworkError = class extends WokuError {
69
70
  };
70
71
 
71
72
  // src/client.ts
72
- var DEFAULT_CAPTURE_PATH = "/sdk/v1/captures";
73
+ var DEFAULT_CAPTURE_PATH = "/v1/captures";
73
74
  var WokuClient = class {
74
75
  constructor(config) {
75
76
  this.config = config;
76
77
  if (!config.apiUrl) throw new WokuConfigError("apiUrl is required");
77
78
  if (!config.publicKey) throw new WokuConfigError("publicKey is required");
79
+ if (config.publicKey.startsWith("sk_"))
80
+ throw new WokuConfigError(
81
+ "Use a publishable key, never a management secret key, in a mobile app."
82
+ );
78
83
  if (!config.companyId) throw new WokuConfigError("companyId is required");
79
84
  this.http = config.http ?? fetchHttpClient;
80
85
  this.logger = config.logger ?? noopLogger;
@@ -89,37 +94,85 @@ var WokuClient = class {
89
94
  * non-2xx responses (so the caller can decide to re-queue).
90
95
  */
91
96
  async send(submission) {
92
- let response;
93
- try {
94
- response = await this.http.request({
95
- method: "POST",
96
- url: this.endpoint,
97
- headers: {
98
- "Content-Type": "application/json",
99
- Authorization: `Bearer ${this.config.publicKey}`,
100
- "X-Woku-Company": this.config.companyId,
101
- "X-Woku-Idempotency-Key": submission.id
102
- },
103
- body: JSON.stringify(submission)
97
+ if (submission.companyId !== this.config.companyId)
98
+ throw new WokuConfigError(
99
+ "Cannot deliver a capture for another company."
100
+ );
101
+ const headers = {
102
+ "x-woku-key": this.config.publicKey,
103
+ "X-Woku-Company": this.config.companyId,
104
+ "X-Woku-Idempotency-Key": submission.id
105
+ };
106
+ let requestBody;
107
+ if (submission.audio) {
108
+ const form = new FormData();
109
+ form.append("file", {
110
+ uri: submission.audio.uri,
111
+ type: submission.audio.mimeType,
112
+ name: "capture-audio"
104
113
  });
105
- } catch (err) {
106
- throw new WokuNetworkError(err.message);
114
+ form.append("payload", JSON.stringify(submission));
115
+ requestBody = form;
116
+ } else {
117
+ headers["Content-Type"] = "application/json";
118
+ requestBody = JSON.stringify(submission);
107
119
  }
108
- if (response.ok) {
109
- const body2 = await this.safeJson(response.json);
110
- return { id: submission.id, status: "sent", remoteId: body2?.id };
111
- }
112
- if (response.status === 429) {
113
- const retryAfter = Number(response.headers.get("Retry-After"));
114
- throw new WokuQuarantineError(
115
- "Submission blocked by quarantine",
116
- Number.isFinite(retryAfter) ? retryAfter : void 0
120
+ const controller = new AbortController();
121
+ let timer;
122
+ try {
123
+ return await Promise.race([
124
+ (async () => {
125
+ const response = await this.http.request({
126
+ method: "POST",
127
+ url: this.endpoint,
128
+ headers,
129
+ body: requestBody,
130
+ signal: controller.signal
131
+ });
132
+ if (response.ok) {
133
+ const body2 = await this.safeJson(response.json);
134
+ return {
135
+ id: submission.id,
136
+ status: "sent",
137
+ remoteId: body2?.remoteId ?? body2?.id
138
+ };
139
+ }
140
+ if (response.status === 429) {
141
+ const raw = response.headers.get("Retry-After");
142
+ const retryAfter = raw === null ? void 0 : Number(raw);
143
+ throw new WokuQuarantineError(
144
+ "Submission blocked by quarantine",
145
+ retryAfter !== void 0 && Number.isFinite(retryAfter) ? Math.max(0, retryAfter) : void 0
146
+ );
147
+ }
148
+ const body = await this.safeJson(response.json);
149
+ const value = body?.message;
150
+ const message = value && typeof value === "object" && !Array.isArray(value) ? value.message : value;
151
+ const detail = Array.isArray(message) ? message.filter((item) => typeof item === "string").join(", ") : message;
152
+ const error = typeof detail === "string" && detail ? detail : `HTTP ${response.status}`;
153
+ this.logger.warn(`woku capture rejected: ${error}`);
154
+ return {
155
+ id: submission.id,
156
+ status: "failed",
157
+ error,
158
+ retryable: response.status === 408 || response.status >= 500
159
+ };
160
+ })(),
161
+ new Promise((_resolve, reject) => {
162
+ timer = setTimeout(() => {
163
+ controller.abort();
164
+ reject(new WokuNetworkError("Capture request timed out"));
165
+ }, this.config.timeoutMs ?? 3e4);
166
+ })
167
+ ]);
168
+ } catch (err) {
169
+ if (err instanceof WokuQuarantineError) throw err;
170
+ throw new WokuNetworkError(
171
+ err instanceof Error ? err.message : "Capture request failed"
117
172
  );
173
+ } finally {
174
+ if (timer) clearTimeout(timer);
118
175
  }
119
- const body = await this.safeJson(response.json);
120
- const error = body?.message ?? `HTTP ${response.status}`;
121
- this.logger.warn(`woku capture rejected: ${error}`);
122
- return { id: submission.id, status: "failed", error };
123
176
  }
124
177
  async safeJson(json) {
125
178
  try {
@@ -132,94 +185,193 @@ var WokuClient = class {
132
185
 
133
186
  // src/queue.ts
134
187
  var DEFAULT_KEY = "woku.sdk.queue.v1";
135
- var DEFAULT_MAX_ATTEMPTS = 8;
188
+ var mutations = /* @__PURE__ */ new WeakMap();
189
+ var flushes = /* @__PURE__ */ new WeakMap();
136
190
  var OfflineQueue = class {
137
191
  constructor(opts = {}) {
138
- this.items = null;
139
192
  this.storage = opts.storage ?? new InMemoryStorage();
140
193
  this.key = opts.storageKey ?? DEFAULT_KEY;
141
- this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
194
+ this.maxAttempts = opts.maxAttempts ?? 8;
195
+ this.companyId = opts.companyId;
196
+ }
197
+ owned(item) {
198
+ return !this.companyId || item.submission.companyId === this.companyId;
142
199
  }
143
200
  async load() {
144
- if (this.items) return this.items;
145
201
  const raw = await this.storage.getItem(this.key);
146
- if (!raw) {
147
- this.items = [];
148
- return this.items;
149
- }
202
+ if (!raw) return [];
150
203
  try {
151
204
  const parsed = JSON.parse(raw);
152
- this.items = Array.isArray(parsed) ? parsed : [];
205
+ if (Array.isArray(parsed) && parsed.every(
206
+ (item) => item && typeof item === "object" && item.submission && typeof item.submission.id === "string" && typeof item.submission.companyId === "string" && Number.isFinite(item.submission.createdAt) && Number.isInteger(item.attempts) && item.attempts >= 0
207
+ ))
208
+ return parsed;
153
209
  } catch {
154
- this.items = [];
155
210
  }
156
- return this.items;
211
+ throw new WokuConfigError(
212
+ "The capture queue contains invalid data; repair it before writing."
213
+ );
157
214
  }
158
- async persist() {
159
- await this.storage.setItem(this.key, JSON.stringify(this.items ?? []));
215
+ async persist(items) {
216
+ await this.storage.setItem(this.key, JSON.stringify(items));
217
+ }
218
+ mutation(run) {
219
+ let locks = mutations.get(this.storage);
220
+ if (!locks) {
221
+ locks = /* @__PURE__ */ new Map();
222
+ mutations.set(this.storage, locks);
223
+ }
224
+ const previous = locks.get(this.key) ?? Promise.resolve();
225
+ const result = previous.then(run, run);
226
+ const settled = result.then(
227
+ () => void 0,
228
+ () => void 0
229
+ );
230
+ locks.set(this.key, settled);
231
+ void settled.then(() => {
232
+ if (locks?.get(this.key) === settled) locks.delete(this.key);
233
+ });
234
+ return result;
160
235
  }
161
- /** Adds a submission to the tail of the queue (dedup by id). */
162
236
  async enqueue(submission) {
163
- const items = await this.load();
164
- if (items.some((i) => i.submission.id === submission.id)) return;
165
- items.push({ submission, attempts: 0 });
166
- await this.persist();
237
+ if (this.companyId && submission.companyId !== this.companyId)
238
+ throw new WokuConfigError(
239
+ "Cannot enqueue a capture from another company."
240
+ );
241
+ const snapshot = JSON.parse(
242
+ JSON.stringify(submission)
243
+ );
244
+ return this.mutation(async () => {
245
+ const items = await this.load();
246
+ if (items.some(
247
+ (item) => item.submission.id === snapshot.id && item.submission.companyId === snapshot.companyId
248
+ ))
249
+ return;
250
+ items.push({ submission: snapshot, attempts: 0 });
251
+ await this.persist(items);
252
+ });
167
253
  }
168
- async size() {
169
- return (await this.load()).length;
254
+ size() {
255
+ return this.mutation(
256
+ async () => (await this.load()).filter((item) => this.owned(item) && !item.failure).length
257
+ );
170
258
  }
171
- /** Returns a copy of the pending submissions (oldest first). */
172
- async pending() {
173
- return (await this.load()).map((i) => i.submission);
259
+ pending() {
260
+ return this.mutation(
261
+ async () => (await this.load()).filter((item) => this.owned(item) && !item.failure).map((item) => item.submission)
262
+ );
174
263
  }
175
- /**
176
- * Attempts to deliver every queued submission through `send`, applying
177
- * the delivery semantics documented on the class. Persists once at the
178
- * end and returns a summary.
179
- */
180
- async flush(send) {
181
- const items = await this.load();
182
- const survivors = [];
183
- let sent = 0;
184
- let failed = 0;
185
- let quarantined = false;
186
- for (let index = 0; index < items.length; index++) {
187
- const item = items[index];
188
- if (quarantined) {
189
- survivors.push(item);
264
+ failures() {
265
+ return this.mutation(
266
+ async () => (await this.load()).filter((item) => this.owned(item) && item.failure).map((item) => ({
267
+ submission: item.submission,
268
+ reason: item.failure
269
+ }))
270
+ );
271
+ }
272
+ acknowledge(submission) {
273
+ return this.mutation(async () => {
274
+ const items = await this.load();
275
+ await this.persist(
276
+ items.filter(
277
+ (item) => !(item.submission.id === submission.id && item.submission.companyId === submission.companyId && this.owned(item))
278
+ )
279
+ );
280
+ });
281
+ }
282
+ reject(submission, reason) {
283
+ return this.mutation(async () => {
284
+ const items = await this.load();
285
+ const item = items.find(
286
+ (row) => row.submission.id === submission.id && row.submission.companyId === submission.companyId && this.owned(row)
287
+ );
288
+ if (item) {
289
+ item.failure = reason;
290
+ await this.persist(items);
291
+ }
292
+ });
293
+ }
294
+ flush(send) {
295
+ let jobs = flushes.get(this.storage);
296
+ if (!jobs) {
297
+ jobs = /* @__PURE__ */ new Map();
298
+ flushes.set(this.storage, jobs);
299
+ }
300
+ const scope = JSON.stringify([this.key, this.companyId ?? "*"]);
301
+ const existing = jobs.get(scope);
302
+ if (existing) return existing;
303
+ const result = this.runFlush(send).finally(() => {
304
+ if (jobs?.get(scope) === result) jobs.delete(scope);
305
+ });
306
+ jobs.set(scope, result);
307
+ return result;
308
+ }
309
+ async runFlush(send) {
310
+ const batch = await this.pending();
311
+ let sent = 0, failed = 0, quarantined = false;
312
+ for (const candidate of batch) {
313
+ const current = await this.mutation(
314
+ async () => (await this.load()).find(
315
+ (item) => this.owned(item) && !item.failure && item.submission.id === candidate.id && item.submission.companyId === candidate.companyId
316
+ )
317
+ );
318
+ if (!current) continue;
319
+ const submission = current.submission;
320
+ if (submission.createdAt > 0 && Date.now() - submission.createdAt >= 24 * 60 * 60 * 1e3) {
321
+ await this.reject(
322
+ submission,
323
+ "Retry window expired; inspect delivery before resubmitting."
324
+ );
325
+ failed++;
190
326
  continue;
191
327
  }
328
+ let permanent = false;
329
+ let reason;
330
+ let accepted = false;
192
331
  try {
193
- const result = await send(item.submission);
194
- if (result.status === "sent") {
195
- sent++;
196
- } else {
197
- item.attempts++;
198
- if (item.attempts >= this.maxAttempts) failed++;
199
- else survivors.push(item);
200
- }
201
- } catch (err) {
202
- if (err instanceof WokuQuarantineError) {
332
+ const result = await send(submission);
333
+ accepted = result.status === "sent";
334
+ reason = result.error;
335
+ permanent = result.retryable === false;
336
+ } catch (error) {
337
+ if (error instanceof WokuQuarantineError) {
203
338
  quarantined = true;
204
- survivors.push(item);
205
- } else if (err instanceof WokuNetworkError) {
206
- item.attempts++;
207
- survivors.push(item);
208
- } else {
209
- item.attempts++;
210
- if (item.attempts >= this.maxAttempts) failed++;
211
- else survivors.push(item);
339
+ break;
212
340
  }
341
+ reason = error instanceof Error ? error.message : "Delivery failed";
213
342
  }
343
+ const outcome = await this.mutation(async () => {
344
+ const items = await this.load();
345
+ const index = items.findIndex(
346
+ (item) => item.submission.id === submission.id && item.submission.companyId === submission.companyId && !item.failure
347
+ );
348
+ if (index < 0) return "removed";
349
+ if (accepted) items.splice(index, 1);
350
+ else {
351
+ const item = items[index];
352
+ item.attempts++;
353
+ if (permanent || item.attempts >= this.maxAttempts)
354
+ item.failure = reason ?? "Attempt budget exhausted";
355
+ }
356
+ const exhausted = !accepted && Boolean(items[index]?.failure);
357
+ await this.persist(items);
358
+ return accepted ? "sent" : exhausted ? "failed" : "pending";
359
+ });
360
+ if (outcome === "sent") sent++;
361
+ if (outcome === "failed") failed++;
214
362
  }
215
- this.items = survivors;
216
- await this.persist();
217
- return { sent, failed, remaining: survivors.length, quarantined };
363
+ return { sent, failed, remaining: await this.size(), quarantined };
218
364
  }
219
- /** Clears the queue (e.g. on logout). */
220
- async clear() {
221
- this.items = [];
222
- await this.storage.removeItem(this.key);
365
+ clear() {
366
+ return this.mutation(async () => {
367
+ if (!this.companyId) {
368
+ await this.storage.removeItem(this.key);
369
+ return;
370
+ }
371
+ const remaining = (await this.load()).filter((item) => !this.owned(item));
372
+ if (remaining.length) await this.persist(remaining);
373
+ else await this.storage.removeItem(this.key);
374
+ });
223
375
  }
224
376
  };
225
377
 
@@ -241,10 +393,12 @@ var WokuSdk = class {
241
393
  this.client = new WokuClient(config);
242
394
  this.queue = new OfflineQueue({
243
395
  storage: config.storage,
396
+ companyId: config.companyId,
244
397
  maxAttempts: config.maxQueueAttempts
245
398
  });
246
399
  this.logger = config.logger ?? noopLogger;
247
400
  this.companyId = config.companyId;
401
+ this.language = config.language ?? "es";
248
402
  }
249
403
  /** Captures a Woku rating (1..5) with optional text/audio comment. */
250
404
  async captureWoku(input) {
@@ -259,6 +413,10 @@ var WokuSdk = class {
259
413
  audio: input.audio,
260
414
  respondent: input.respondent,
261
415
  metadata: input.metadata,
416
+ dispatchToken: input.dispatchToken,
417
+ collectionKind: input.collectionKind ?? "standalone",
418
+ flowId: input.flowId,
419
+ language: input.language ?? (input.audio ? this.language : void 0),
262
420
  createdAt: Date.now()
263
421
  };
264
422
  return this.deliver(submission);
@@ -276,10 +434,48 @@ var WokuSdk = class {
276
434
  audio: input.audio,
277
435
  respondent: input.respondent,
278
436
  metadata: input.metadata,
437
+ dispatchToken: input.dispatchToken,
438
+ collectionKind: input.collectionKind ?? "standalone",
439
+ flowId: input.flowId,
440
+ language: input.language ?? (input.audio ? this.language : void 0),
279
441
  createdAt: Date.now()
280
442
  };
281
443
  return this.deliver(submission);
282
444
  }
445
+ async captureCsat(input) {
446
+ assertInt(input.score, 1, 5, "score");
447
+ return this.deliver({
448
+ id: generateId(),
449
+ kind: "csat",
450
+ companyId: this.companyId,
451
+ targetId: input.csatId,
452
+ score: input.score,
453
+ comment: input.comment,
454
+ respondent: input.respondent,
455
+ metadata: input.metadata,
456
+ dispatchToken: input.dispatchToken,
457
+ collectionKind: input.collectionKind ?? "standalone",
458
+ flowId: input.flowId,
459
+ createdAt: Date.now()
460
+ });
461
+ }
462
+ async captureCes(input) {
463
+ assertInt(input.score, 1, 5, "score");
464
+ return this.deliver({
465
+ id: generateId(),
466
+ kind: "ces",
467
+ companyId: this.companyId,
468
+ targetId: input.cesId,
469
+ score: input.score,
470
+ comment: input.comment,
471
+ respondent: input.respondent,
472
+ metadata: input.metadata,
473
+ dispatchToken: input.dispatchToken,
474
+ collectionKind: input.collectionKind ?? "standalone",
475
+ flowId: input.flowId,
476
+ createdAt: Date.now()
477
+ });
478
+ }
283
479
  /** Retries every queued submission. Safe to call on app foreground or
284
480
  * when connectivity is regained. */
285
481
  flush() {
@@ -289,6 +485,10 @@ var WokuSdk = class {
289
485
  pendingCount() {
290
486
  return this.queue.size();
291
487
  }
488
+ /** Inspect retained captures whose automatic attempt budget was exhausted. */
489
+ failedCaptures() {
490
+ return this.queue.failures();
491
+ }
292
492
  /** Clears the offline queue (e.g. on user logout). */
293
493
  clearQueue() {
294
494
  return this.queue.clear();
@@ -298,9 +498,25 @@ var WokuSdk = class {
298
498
  * submission is queued for a later flush rather than thrown away.
299
499
  */
300
500
  async deliver(submission) {
501
+ submission = JSON.parse(JSON.stringify(submission));
502
+ await this.queue.enqueue(submission);
301
503
  try {
302
504
  const result = await this.client.send(submission);
303
- if (result.status === "sent") return result;
505
+ if (result.status === "sent") {
506
+ try {
507
+ await this.queue.acknowledge(submission);
508
+ } catch {
509
+ this.logger.warn(
510
+ "Capture accepted; local acknowledgement is pending",
511
+ { id: submission.id }
512
+ );
513
+ }
514
+ return result;
515
+ }
516
+ if (result.retryable === false) {
517
+ await this.queue.reject(submission, result.error ?? "Capture rejected");
518
+ return result;
519
+ }
304
520
  await this.queue.enqueue(submission);
305
521
  return { ...result, status: "queued" };
306
522
  } catch (err) {