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