@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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # @wokuapp/react-native
4
4
 
5
- Capture **Woku ratings** and **NPS** (text + audio) from your React Native
5
+ Capture **Woku, NPS, CSAT and CES** from your React Native
6
6
  app, with offline buffering and quarantine-aware delivery.
7
7
 
8
8
  [![npm](https://img.shields.io/npm/v/@wokuapp/react-native)](https://www.npmjs.com/package/@wokuapp/react-native)
@@ -18,18 +18,24 @@ your own ingestion, retry, or offline logic. The SDK:
18
18
 
19
19
  - Captures **Woku ratings** (1–5) and **NPS** (0–10) with optional text or
20
20
  audio comments.
21
- - **Buffers offline** and retries automatically when connectivity returns —
22
- a tap on the subway never loses a response.
21
+ - **Buffers offline** through your storage adapter. Call `flush()` when
22
+ connectivity returns; the headless core has no connectivity listener.
23
23
  - Is **quarantine-aware**: when the backend rate-limits a respondent (HTTP
24
24
  429), the SDK backs off instead of hammering it.
25
25
  - Has **zero runtime dependencies** and a **fully typed**, framework-agnostic
26
26
  core: you inject the platform adapters (storage, http, audio), so it stays
27
27
  small and testable.
28
28
 
29
- > **Architecture note (v0.1).** This release ships the headless TypeScript
29
+ > **Architecture note.** This release ships the headless TypeScript
30
30
  > core plus the adapter interfaces. Pre-built React Native adapters
31
31
  > (MMKV storage, audio recorder) ship in a follow-up minor; until then you
32
32
  > wire your app's libraries to the small interfaces below (a few lines).
33
+ > Captures post to the resource-oriented `/v1/captures` endpoint and are
34
+ > tagged server-side with the `mobile-sdk` response channel. Text/rating and
35
+ > score captures are sent as JSON; **audio captures are sent as multipart**
36
+ > (the `audio.uri` file plus a `payload` field) and become a voicemail review
37
+ > server-side. Submissions deduplicate by their client-generated `id` for 24h.
38
+ > Older uncertain submissions are retained for inspection instead of replayed.
33
39
 
34
40
  ## Install
35
41
 
@@ -48,7 +54,7 @@ adapters land).
48
54
  import { WokuSdk } from '@wokuapp/react-native';
49
55
 
50
56
  const woku = new WokuSdk({
51
- apiUrl: 'https://api.woku.app',
57
+ apiUrl: 'https://clientapi.woku.app',
52
58
  publicKey: 'pk_live_xxx', // per-company SDK key
53
59
  companyId: 'company_123',
54
60
  storage: mmkvStorageAdapter, // see "Adapters" below
@@ -61,11 +67,11 @@ await woku.captureNps({
61
67
  comment: 'Fast checkout, loved it.',
62
68
  });
63
69
 
64
- // Woku rating (1–5) with an audio comment
70
+ // Woku rating (1–5) with a text comment
65
71
  await woku.captureWoku({
66
72
  wokuId: 'woku_store_centro',
67
73
  rating: 5,
68
- audio: { uri: fileUri, mimeType: 'audio/m4a', durationMs: 4200 },
74
+ comment: 'Great staff, quick service.',
69
75
  respondent: { email: 'ana@example.com' },
70
76
  });
71
77
 
@@ -110,12 +116,12 @@ const http: HttpClient = { request: async (req) => /* ... */ };
110
116
 
111
117
  `flush()` walks the queue oldest-first:
112
118
 
113
- | Outcome | Behavior |
114
- | ------------------ | ------------------------------------------------------------------------------------------------------ |
115
- | `sent` | removed from the queue |
116
- | quarantine (429) | **stops** the flush, keeps everything for the next attempt |
117
- | network error | attempt count bumped, item kept, flush continues |
118
- | `failed` (4xx/5xx) | attempt count bumped; dropped after `maxQueueAttempts` (default 8) so a bad item can't wedge the queue |
119
+ | Outcome | Behavior |
120
+ | ------------------ | -------------------------------------------------------------------------------------- |
121
+ | `sent` | removed from the queue |
122
+ | quarantine (429) | **stops** the flush, keeps everything for the next attempt |
123
+ | network error | attempt count bumped, item kept, flush continues |
124
+ | `failed` (4xx/5xx) | permanent 4xx retained as failed; transient errors retained after the 8-attempt budget |
119
125
 
120
126
  The queue persists through your `Storage` adapter, so it survives restarts.
121
127
 
@@ -125,10 +131,13 @@ The queue persists through your `Storage` adapter, so it survives restarts.
125
131
  | --------------------- | --------------------------------------------------- |
126
132
  | `new WokuSdk(config)` | Create an SDK instance. |
127
133
  | `captureWoku(input)` | Submit a 1–5 rating (+ comment/audio). |
134
+ | `captureCsat(input)` | Submit a 1–5 satisfaction score. |
135
+ | `captureCes(input)` | Submit a 1–5 effort score. |
136
+ | `failedCaptures()` | Inspect retained failures and their reason. |
128
137
  | `captureNps(input)` | Submit a 0–10 NPS score (+ review). |
129
138
  | `flush()` | Retry all queued captures. Returns a `FlushResult`. |
130
139
  | `pendingCount()` | Number of captures waiting to send. |
131
- | `clearQueue()` | Drop the queue (e.g. on logout). |
140
+ | `clearQueue()` | Remove only this company's pending and failed rows. |
132
141
 
133
142
  Lower-level building blocks `WokuClient` and `OfflineQueue` are exported too,
134
143
  along with all types and error classes (`WokuValidationError`,
@@ -137,3 +146,53 @@ along with all types and error classes (`WokuValidationError`,
137
146
  ## License
138
147
 
139
148
  [MIT](../../LICENSE) © Woku
149
+
150
+ ## Identified journey responses
151
+
152
+ ```ts
153
+ await sdk.captureCsat({
154
+ csatId: prepared.toolId,
155
+ score: 5,
156
+ respondent: { email: customer.email },
157
+ dispatchToken: prepared.token,
158
+ });
159
+ await sdk.captureCes({
160
+ cesId: effortToolId,
161
+ score: 4,
162
+ respondent: { phone: customer.phone },
163
+ });
164
+ ```
165
+
166
+ Use only a public pk\_ key: the transport uses x-woku-key and rejects management
167
+ keys. remoteId is the server id. Woku/NPS audio supports language es/en (default
168
+ Spanish); CSAT/CES audio is not supported. timeoutMs bounds an attempt (default
169
+ 30s), including injected adapters.
170
+
171
+ Await capture calls and provide persistent storage. Captures are stored before
172
+ delivery. Call flush on reconnect/foreground; the core has no connectivity listener.
173
+ Company-scoped instances never send or clear another company's rows. Concurrent
174
+ enqueue/flush preserves incoming data. Share one storage adapter per app and one
175
+ SDK per company; isolate storage between API environments.
176
+
177
+ Inspect failedCaptures for permanent failures, exhausted attempts and captures
178
+ older than the server's 24-hour replay window (based on the device clock). These records are retained and are
179
+ not blindly sent with a new id: inspect delivery before resubmitting. clearQueue
180
+ removes only the current company's pending and retained failed rows.
181
+
182
+ The published package includes separate ESM/CJS JavaScript and matching
183
+ `.d.ts`/`.d.cts` exports. Both imports are checked against the packed artifact.
184
+
185
+ ### Delivery failures and clearing
186
+
187
+ `timeoutMs` covers receiving the response and reading its body. Validation
188
+ messages use the API's nested error envelope and remain strings. If the server
189
+ confirms a capture but local acknowledgement cannot be stored, the capture call
190
+ still returns `sent`; the pending row retains its original ID for a safe later
191
+ flush. A storage failure before enqueue prevents the network request.
192
+
193
+ `clearQueue()` removes this company's rows and prevents an active flush from
194
+ starting further sends from its old snapshot. An already started request may
195
+ finish and cannot be recalled.
196
+
197
+ Persisted capture queues reject malformed rows with `WokuConfigError` and preserve
198
+ the stored bytes for recovery instead of overwriting them.
package/dist/index.cjs CHANGED
@@ -6,7 +6,8 @@ var fetchHttpClient = {
6
6
  const res = await fetch(req.url, {
7
7
  method: req.method,
8
8
  headers: req.headers,
9
- body: req.body
9
+ body: req.body,
10
+ signal: req.signal
10
11
  });
11
12
  return {
12
13
  status: res.status,
@@ -71,12 +72,16 @@ var WokuNetworkError = class extends WokuError {
71
72
  };
72
73
 
73
74
  // src/client.ts
74
- var DEFAULT_CAPTURE_PATH = "/sdk/v1/captures";
75
+ var DEFAULT_CAPTURE_PATH = "/v1/captures";
75
76
  var WokuClient = class {
76
77
  constructor(config) {
77
78
  this.config = config;
78
79
  if (!config.apiUrl) throw new WokuConfigError("apiUrl is required");
79
80
  if (!config.publicKey) throw new WokuConfigError("publicKey is required");
81
+ if (config.publicKey.startsWith("sk_"))
82
+ throw new WokuConfigError(
83
+ "Use a publishable key, never a management secret key, in a mobile app."
84
+ );
80
85
  if (!config.companyId) throw new WokuConfigError("companyId is required");
81
86
  this.http = config.http ?? fetchHttpClient;
82
87
  this.logger = config.logger ?? noopLogger;
@@ -91,37 +96,87 @@ var WokuClient = class {
91
96
  * non-2xx responses (so the caller can decide to re-queue).
92
97
  */
93
98
  async send(submission) {
94
- let response;
95
- try {
96
- response = await this.http.request({
97
- method: "POST",
98
- url: this.endpoint,
99
- headers: {
100
- "Content-Type": "application/json",
101
- Authorization: `Bearer ${this.config.publicKey}`,
102
- "X-Woku-Company": this.config.companyId,
103
- "X-Woku-Idempotency-Key": submission.id
104
- },
105
- body: JSON.stringify(submission)
99
+ if (submission.companyId !== this.config.companyId)
100
+ throw new WokuConfigError(
101
+ "Cannot deliver a capture for another company."
102
+ );
103
+ if (submission.audio && !submission.language)
104
+ submission = { ...submission, language: this.config.language ?? "es" };
105
+ const headers = {
106
+ "x-woku-key": this.config.publicKey,
107
+ "X-Woku-Company": this.config.companyId,
108
+ "X-Woku-Idempotency-Key": submission.id
109
+ };
110
+ let requestBody;
111
+ if (submission.audio) {
112
+ const form = new FormData();
113
+ form.append("file", {
114
+ uri: submission.audio.uri,
115
+ type: submission.audio.mimeType,
116
+ name: "capture-audio"
106
117
  });
107
- } catch (err) {
108
- throw new WokuNetworkError(err.message);
118
+ form.append("payload", JSON.stringify(submission));
119
+ requestBody = form;
120
+ } else {
121
+ headers["Content-Type"] = "application/json";
122
+ requestBody = JSON.stringify(submission);
109
123
  }
110
- if (response.ok) {
111
- const body2 = await this.safeJson(response.json);
112
- return { id: submission.id, status: "sent", remoteId: body2?.id };
113
- }
114
- if (response.status === 429) {
115
- const retryAfter = Number(response.headers.get("Retry-After"));
116
- throw new WokuQuarantineError(
117
- "Submission blocked by quarantine",
118
- Number.isFinite(retryAfter) ? retryAfter : void 0
124
+ const controller = new AbortController();
125
+ let timer;
126
+ try {
127
+ return await Promise.race([
128
+ (async () => {
129
+ const response = await this.http.request({
130
+ method: "POST",
131
+ url: this.endpoint,
132
+ headers,
133
+ body: requestBody,
134
+ signal: controller.signal
135
+ });
136
+ if (response.ok) {
137
+ const body2 = await this.safeJson(response.json);
138
+ return {
139
+ id: submission.id,
140
+ status: "sent",
141
+ remoteId: body2?.remoteId ?? body2?.id
142
+ };
143
+ }
144
+ if (response.status === 429) {
145
+ const raw = response.headers.get("Retry-After");
146
+ const retryAfter = raw === null ? void 0 : Number(raw);
147
+ throw new WokuQuarantineError(
148
+ "Submission blocked by quarantine",
149
+ retryAfter !== void 0 && Number.isFinite(retryAfter) ? Math.max(0, retryAfter) : void 0
150
+ );
151
+ }
152
+ const body = await this.safeJson(response.json);
153
+ const value = body?.message;
154
+ const message = value && typeof value === "object" && !Array.isArray(value) ? value.message : value;
155
+ const detail = Array.isArray(message) ? message.filter((item) => typeof item === "string").join(", ") : message;
156
+ const error = typeof detail === "string" && detail ? detail : `HTTP ${response.status}`;
157
+ this.logger.warn(`woku capture rejected: ${error}`);
158
+ return {
159
+ id: submission.id,
160
+ status: "failed",
161
+ error,
162
+ retryable: response.status === 408 || response.status >= 500
163
+ };
164
+ })(),
165
+ new Promise((_resolve, reject) => {
166
+ timer = setTimeout(() => {
167
+ controller.abort();
168
+ reject(new WokuNetworkError("Capture request timed out"));
169
+ }, this.config.timeoutMs ?? 3e4);
170
+ })
171
+ ]);
172
+ } catch (err) {
173
+ if (err instanceof WokuQuarantineError) throw err;
174
+ throw new WokuNetworkError(
175
+ err instanceof Error ? err.message : "Capture request failed"
119
176
  );
177
+ } finally {
178
+ if (timer) clearTimeout(timer);
120
179
  }
121
- const body = await this.safeJson(response.json);
122
- const error = body?.message ?? `HTTP ${response.status}`;
123
- this.logger.warn(`woku capture rejected: ${error}`);
124
- return { id: submission.id, status: "failed", error };
125
180
  }
126
181
  async safeJson(json) {
127
182
  try {
@@ -134,94 +189,193 @@ var WokuClient = class {
134
189
 
135
190
  // src/queue.ts
136
191
  var DEFAULT_KEY = "woku.sdk.queue.v1";
137
- var DEFAULT_MAX_ATTEMPTS = 8;
192
+ var mutations = /* @__PURE__ */ new WeakMap();
193
+ var flushes = /* @__PURE__ */ new WeakMap();
138
194
  var OfflineQueue = class {
139
195
  constructor(opts = {}) {
140
- this.items = null;
141
196
  this.storage = opts.storage ?? new InMemoryStorage();
142
197
  this.key = opts.storageKey ?? DEFAULT_KEY;
143
- this.maxAttempts = opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
198
+ this.maxAttempts = opts.maxAttempts ?? 8;
199
+ this.companyId = opts.companyId;
200
+ }
201
+ owned(item) {
202
+ return !this.companyId || item.submission.companyId === this.companyId;
144
203
  }
145
204
  async load() {
146
- if (this.items) return this.items;
147
205
  const raw = await this.storage.getItem(this.key);
148
- if (!raw) {
149
- this.items = [];
150
- return this.items;
151
- }
206
+ if (!raw) return [];
152
207
  try {
153
208
  const parsed = JSON.parse(raw);
154
- this.items = Array.isArray(parsed) ? parsed : [];
209
+ if (Array.isArray(parsed) && parsed.every(
210
+ (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
211
+ ))
212
+ return parsed;
155
213
  } catch {
156
- this.items = [];
157
214
  }
158
- return this.items;
215
+ throw new WokuConfigError(
216
+ "The capture queue contains invalid data; repair it before writing."
217
+ );
159
218
  }
160
- async persist() {
161
- await this.storage.setItem(this.key, JSON.stringify(this.items ?? []));
219
+ async persist(items) {
220
+ await this.storage.setItem(this.key, JSON.stringify(items));
221
+ }
222
+ mutation(run) {
223
+ let locks = mutations.get(this.storage);
224
+ if (!locks) {
225
+ locks = /* @__PURE__ */ new Map();
226
+ mutations.set(this.storage, locks);
227
+ }
228
+ const previous = locks.get(this.key) ?? Promise.resolve();
229
+ const result = previous.then(run, run);
230
+ const settled = result.then(
231
+ () => void 0,
232
+ () => void 0
233
+ );
234
+ locks.set(this.key, settled);
235
+ void settled.then(() => {
236
+ if (locks?.get(this.key) === settled) locks.delete(this.key);
237
+ });
238
+ return result;
162
239
  }
163
- /** Adds a submission to the tail of the queue (dedup by id). */
164
240
  async enqueue(submission) {
165
- const items = await this.load();
166
- if (items.some((i) => i.submission.id === submission.id)) return;
167
- items.push({ submission, attempts: 0 });
168
- await this.persist();
241
+ if (this.companyId && submission.companyId !== this.companyId)
242
+ throw new WokuConfigError(
243
+ "Cannot enqueue a capture from another company."
244
+ );
245
+ const snapshot = JSON.parse(
246
+ JSON.stringify(submission)
247
+ );
248
+ return this.mutation(async () => {
249
+ const items = await this.load();
250
+ if (items.some(
251
+ (item) => item.submission.id === snapshot.id && item.submission.companyId === snapshot.companyId
252
+ ))
253
+ return;
254
+ items.push({ submission: snapshot, attempts: 0 });
255
+ await this.persist(items);
256
+ });
169
257
  }
170
- async size() {
171
- return (await this.load()).length;
258
+ size() {
259
+ return this.mutation(
260
+ async () => (await this.load()).filter((item) => this.owned(item) && !item.failure).length
261
+ );
172
262
  }
173
- /** Returns a copy of the pending submissions (oldest first). */
174
- async pending() {
175
- return (await this.load()).map((i) => i.submission);
263
+ pending() {
264
+ return this.mutation(
265
+ async () => (await this.load()).filter((item) => this.owned(item) && !item.failure).map((item) => item.submission)
266
+ );
176
267
  }
177
- /**
178
- * Attempts to deliver every queued submission through `send`, applying
179
- * the delivery semantics documented on the class. Persists once at the
180
- * end and returns a summary.
181
- */
182
- async flush(send) {
183
- const items = await this.load();
184
- const survivors = [];
185
- let sent = 0;
186
- let failed = 0;
187
- let quarantined = false;
188
- for (let index = 0; index < items.length; index++) {
189
- const item = items[index];
190
- if (quarantined) {
191
- survivors.push(item);
268
+ failures() {
269
+ return this.mutation(
270
+ async () => (await this.load()).filter((item) => this.owned(item) && item.failure).map((item) => ({
271
+ submission: item.submission,
272
+ reason: item.failure
273
+ }))
274
+ );
275
+ }
276
+ acknowledge(submission) {
277
+ return this.mutation(async () => {
278
+ const items = await this.load();
279
+ await this.persist(
280
+ items.filter(
281
+ (item) => !(item.submission.id === submission.id && item.submission.companyId === submission.companyId && this.owned(item))
282
+ )
283
+ );
284
+ });
285
+ }
286
+ reject(submission, reason) {
287
+ return this.mutation(async () => {
288
+ const items = await this.load();
289
+ const item = items.find(
290
+ (row) => row.submission.id === submission.id && row.submission.companyId === submission.companyId && this.owned(row)
291
+ );
292
+ if (item) {
293
+ item.failure = reason;
294
+ await this.persist(items);
295
+ }
296
+ });
297
+ }
298
+ flush(send) {
299
+ let jobs = flushes.get(this.storage);
300
+ if (!jobs) {
301
+ jobs = /* @__PURE__ */ new Map();
302
+ flushes.set(this.storage, jobs);
303
+ }
304
+ const scope = JSON.stringify([this.key, this.companyId ?? "*"]);
305
+ const existing = jobs.get(scope);
306
+ if (existing) return existing;
307
+ const result = this.runFlush(send).finally(() => {
308
+ if (jobs?.get(scope) === result) jobs.delete(scope);
309
+ });
310
+ jobs.set(scope, result);
311
+ return result;
312
+ }
313
+ async runFlush(send) {
314
+ const batch = await this.pending();
315
+ let sent = 0, failed = 0, quarantined = false;
316
+ for (const candidate of batch) {
317
+ const current = await this.mutation(
318
+ async () => (await this.load()).find(
319
+ (item) => this.owned(item) && !item.failure && item.submission.id === candidate.id && item.submission.companyId === candidate.companyId
320
+ )
321
+ );
322
+ if (!current) continue;
323
+ const submission = current.submission;
324
+ if (submission.createdAt > 0 && Date.now() - submission.createdAt >= 24 * 60 * 60 * 1e3) {
325
+ await this.reject(
326
+ submission,
327
+ "Retry window expired; inspect delivery before resubmitting."
328
+ );
329
+ failed++;
192
330
  continue;
193
331
  }
332
+ let permanent = false;
333
+ let reason;
334
+ let accepted = false;
194
335
  try {
195
- const result = await send(item.submission);
196
- if (result.status === "sent") {
197
- sent++;
198
- } else {
199
- item.attempts++;
200
- if (item.attempts >= this.maxAttempts) failed++;
201
- else survivors.push(item);
202
- }
203
- } catch (err) {
204
- if (err instanceof WokuQuarantineError) {
336
+ const result = await send(submission);
337
+ accepted = result.status === "sent";
338
+ reason = result.error;
339
+ permanent = result.retryable === false;
340
+ } catch (error) {
341
+ if (error instanceof WokuQuarantineError) {
205
342
  quarantined = true;
206
- survivors.push(item);
207
- } else if (err instanceof WokuNetworkError) {
208
- item.attempts++;
209
- survivors.push(item);
210
- } else {
211
- item.attempts++;
212
- if (item.attempts >= this.maxAttempts) failed++;
213
- else survivors.push(item);
343
+ break;
214
344
  }
345
+ reason = error instanceof Error ? error.message : "Delivery failed";
215
346
  }
347
+ const outcome = await this.mutation(async () => {
348
+ const items = await this.load();
349
+ const index = items.findIndex(
350
+ (item) => item.submission.id === submission.id && item.submission.companyId === submission.companyId && !item.failure
351
+ );
352
+ if (index < 0) return "removed";
353
+ if (accepted) items.splice(index, 1);
354
+ else {
355
+ const item = items[index];
356
+ item.attempts++;
357
+ if (permanent || item.attempts >= this.maxAttempts)
358
+ item.failure = reason ?? "Attempt budget exhausted";
359
+ }
360
+ const exhausted = !accepted && Boolean(items[index]?.failure);
361
+ await this.persist(items);
362
+ return accepted ? "sent" : exhausted ? "failed" : "pending";
363
+ });
364
+ if (outcome === "sent") sent++;
365
+ if (outcome === "failed") failed++;
216
366
  }
217
- this.items = survivors;
218
- await this.persist();
219
- return { sent, failed, remaining: survivors.length, quarantined };
367
+ return { sent, failed, remaining: await this.size(), quarantined };
220
368
  }
221
- /** Clears the queue (e.g. on logout). */
222
- async clear() {
223
- this.items = [];
224
- await this.storage.removeItem(this.key);
369
+ clear() {
370
+ return this.mutation(async () => {
371
+ if (!this.companyId) {
372
+ await this.storage.removeItem(this.key);
373
+ return;
374
+ }
375
+ const remaining = (await this.load()).filter((item) => !this.owned(item));
376
+ if (remaining.length) await this.persist(remaining);
377
+ else await this.storage.removeItem(this.key);
378
+ });
225
379
  }
226
380
  };
227
381
 
@@ -243,10 +397,12 @@ var WokuSdk = class {
243
397
  this.client = new WokuClient(config);
244
398
  this.queue = new OfflineQueue({
245
399
  storage: config.storage,
400
+ companyId: config.companyId,
246
401
  maxAttempts: config.maxQueueAttempts
247
402
  });
248
403
  this.logger = config.logger ?? noopLogger;
249
404
  this.companyId = config.companyId;
405
+ this.language = config.language ?? "es";
250
406
  }
251
407
  /** Captures a Woku rating (1..5) with optional text/audio comment. */
252
408
  async captureWoku(input) {
@@ -261,6 +417,8 @@ var WokuSdk = class {
261
417
  audio: input.audio,
262
418
  respondent: input.respondent,
263
419
  metadata: input.metadata,
420
+ dispatchToken: input.dispatchToken,
421
+ language: input.language ?? (input.audio ? this.language : void 0),
264
422
  createdAt: Date.now()
265
423
  };
266
424
  return this.deliver(submission);
@@ -278,10 +436,42 @@ var WokuSdk = class {
278
436
  audio: input.audio,
279
437
  respondent: input.respondent,
280
438
  metadata: input.metadata,
439
+ dispatchToken: input.dispatchToken,
440
+ language: input.language ?? (input.audio ? this.language : void 0),
281
441
  createdAt: Date.now()
282
442
  };
283
443
  return this.deliver(submission);
284
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
+ createdAt: Date.now()
458
+ });
459
+ }
460
+ async captureCes(input) {
461
+ assertInt(input.score, 1, 5, "score");
462
+ return this.deliver({
463
+ id: generateId(),
464
+ kind: "ces",
465
+ companyId: this.companyId,
466
+ targetId: input.cesId,
467
+ score: input.score,
468
+ comment: input.comment,
469
+ respondent: input.respondent,
470
+ metadata: input.metadata,
471
+ dispatchToken: input.dispatchToken,
472
+ createdAt: Date.now()
473
+ });
474
+ }
285
475
  /** Retries every queued submission. Safe to call on app foreground or
286
476
  * when connectivity is regained. */
287
477
  flush() {
@@ -291,6 +481,10 @@ var WokuSdk = class {
291
481
  pendingCount() {
292
482
  return this.queue.size();
293
483
  }
484
+ /** Inspect retained captures whose automatic attempt budget was exhausted. */
485
+ failedCaptures() {
486
+ return this.queue.failures();
487
+ }
294
488
  /** Clears the offline queue (e.g. on user logout). */
295
489
  clearQueue() {
296
490
  return this.queue.clear();
@@ -300,9 +494,25 @@ var WokuSdk = class {
300
494
  * submission is queued for a later flush rather than thrown away.
301
495
  */
302
496
  async deliver(submission) {
497
+ submission = JSON.parse(JSON.stringify(submission));
498
+ await this.queue.enqueue(submission);
303
499
  try {
304
500
  const result = await this.client.send(submission);
305
- if (result.status === "sent") return result;
501
+ if (result.status === "sent") {
502
+ try {
503
+ await this.queue.acknowledge(submission);
504
+ } catch {
505
+ this.logger.warn(
506
+ "Capture accepted; local acknowledgement is pending",
507
+ { id: submission.id }
508
+ );
509
+ }
510
+ return result;
511
+ }
512
+ if (result.retryable === false) {
513
+ await this.queue.reject(submission, result.error ?? "Capture rejected");
514
+ return result;
515
+ }
306
516
  await this.queue.enqueue(submission);
307
517
  return { ...result, status: "queued" };
308
518
  } catch (err) {