@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/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,54 @@ 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.
39
+
40
+ ## Proposed capture provenance (unreleased)
41
+
42
+ All four capture inputs add `collectionKind?: 'standalone' | 'flow'` and
43
+ `flowId?: string`. New captures default to `standalone`. A response inside a
44
+ Flow must explicitly declare both fields, including a one-step or incomplete
45
+ Flow; the API verifies the Flow belongs to the company and instrument.
46
+
47
+ ```ts
48
+ await woku.captureNps({
49
+ npsId: 'nps_123',
50
+ score: 2,
51
+ comment: 'Delivery took too long.',
52
+ collectionKind: 'flow',
53
+ flowId: 'flow_123',
54
+ });
55
+ ```
56
+
57
+ The context is persisted before the first request and survives restart, screen
58
+ changes and concurrent flushes in both JSON and Woku/NPS audio multipart.
59
+ Already queued captures keep their original ID and body, including old captures
60
+ without provenance or audio language. Their missing provenance remains unknown;
61
+ flush never changes it to standalone or assigns a Flow from the current screen.
62
+ If you construct `CaptureSubmission` for `WokuClient.send()` directly, declare
63
+ the context at creation time; the transport never adds or infers it.
64
+ Keep queued audio files at stable URIs with unchanged contents until delivery
65
+ is confirmed; the headless SDK persists file references, not native file copies.
66
+ Flow responses may create normal support tickets but do not activate follow-up.
67
+ CSAT/CES audio support is unchanged. Use this contract after the matching API
68
+ has been deployed and the capture package released.
33
69
 
34
70
  ## Install
35
71
 
@@ -48,7 +84,7 @@ adapters land).
48
84
  import { WokuSdk } from '@wokuapp/react-native';
49
85
 
50
86
  const woku = new WokuSdk({
51
- apiUrl: 'https://api.woku.app',
87
+ apiUrl: 'https://clientapi.woku.app',
52
88
  publicKey: 'pk_live_xxx', // per-company SDK key
53
89
  companyId: 'company_123',
54
90
  storage: mmkvStorageAdapter, // see "Adapters" below
@@ -61,11 +97,11 @@ await woku.captureNps({
61
97
  comment: 'Fast checkout, loved it.',
62
98
  });
63
99
 
64
- // Woku rating (1–5) with an audio comment
100
+ // Woku rating (1–5) with a text comment
65
101
  await woku.captureWoku({
66
102
  wokuId: 'woku_store_centro',
67
103
  rating: 5,
68
- audio: { uri: fileUri, mimeType: 'audio/m4a', durationMs: 4200 },
104
+ comment: 'Great staff, quick service.',
69
105
  respondent: { email: 'ana@example.com' },
70
106
  });
71
107
 
@@ -110,12 +146,12 @@ const http: HttpClient = { request: async (req) => /* ... */ };
110
146
 
111
147
  `flush()` walks the queue oldest-first:
112
148
 
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 |
149
+ | Outcome | Behavior |
150
+ | ------------------ | -------------------------------------------------------------------------------------- |
151
+ | `sent` | removed from the queue |
152
+ | quarantine (429) | **stops** the flush, keeps everything for the next attempt |
153
+ | network error | attempt count bumped, item kept, flush continues |
154
+ | `failed` (4xx/5xx) | permanent 4xx retained as failed; transient errors retained after the 8-attempt budget |
119
155
 
120
156
  The queue persists through your `Storage` adapter, so it survives restarts.
121
157
 
@@ -125,10 +161,13 @@ The queue persists through your `Storage` adapter, so it survives restarts.
125
161
  | --------------------- | --------------------------------------------------- |
126
162
  | `new WokuSdk(config)` | Create an SDK instance. |
127
163
  | `captureWoku(input)` | Submit a 1–5 rating (+ comment/audio). |
164
+ | `captureCsat(input)` | Submit a 1–5 satisfaction score. |
165
+ | `captureCes(input)` | Submit a 1–5 effort score. |
166
+ | `failedCaptures()` | Inspect retained failures and their reason. |
128
167
  | `captureNps(input)` | Submit a 0–10 NPS score (+ review). |
129
168
  | `flush()` | Retry all queued captures. Returns a `FlushResult`. |
130
169
  | `pendingCount()` | Number of captures waiting to send. |
131
- | `clearQueue()` | Drop the queue (e.g. on logout). |
170
+ | `clearQueue()` | Remove only this company's pending and failed rows. |
132
171
 
133
172
  Lower-level building blocks `WokuClient` and `OfflineQueue` are exported too,
134
173
  along with all types and error classes (`WokuValidationError`,
@@ -137,3 +176,53 @@ along with all types and error classes (`WokuValidationError`,
137
176
  ## License
138
177
 
139
178
  [MIT](../../LICENSE) © Woku
179
+
180
+ ## Identified journey responses
181
+
182
+ ```ts
183
+ await sdk.captureCsat({
184
+ csatId: prepared.toolId,
185
+ score: 5,
186
+ respondent: { email: customer.email },
187
+ dispatchToken: prepared.token,
188
+ });
189
+ await sdk.captureCes({
190
+ cesId: effortToolId,
191
+ score: 4,
192
+ respondent: { phone: customer.phone },
193
+ });
194
+ ```
195
+
196
+ Use only a public pk\_ key: the transport uses x-woku-key and rejects management
197
+ keys. remoteId is the server id. Woku/NPS audio supports language es/en (default
198
+ Spanish); CSAT/CES audio is not supported. timeoutMs bounds an attempt (default
199
+ 30s), including injected adapters.
200
+
201
+ Await capture calls and provide persistent storage. Captures are stored before
202
+ delivery. Call flush on reconnect/foreground; the core has no connectivity listener.
203
+ Company-scoped instances never send or clear another company's rows. Concurrent
204
+ enqueue/flush preserves incoming data. Share one storage adapter per app and one
205
+ SDK per company; isolate storage between API environments.
206
+
207
+ Inspect failedCaptures for permanent failures, exhausted attempts and captures
208
+ older than the server's 24-hour replay window (based on the device clock). These records are retained and are
209
+ not blindly sent with a new id: inspect delivery before resubmitting. clearQueue
210
+ removes only the current company's pending and retained failed rows.
211
+
212
+ The published package includes separate ESM/CJS JavaScript and matching
213
+ `.d.ts`/`.d.cts` exports. Both imports are checked against the packed artifact.
214
+
215
+ ### Delivery failures and clearing
216
+
217
+ `timeoutMs` covers receiving the response and reading its body. Validation
218
+ messages use the API's nested error envelope and remain strings. If the server
219
+ confirms a capture but local acknowledgement cannot be stored, the capture call
220
+ still returns `sent`; the pending row retains its original ID for a safe later
221
+ flush. A storage failure before enqueue prevents the network request.
222
+
223
+ `clearQueue()` removes this company's rows and prevents an active flush from
224
+ starting further sends from its old snapshot. An already started request may
225
+ finish and cannot be recalled.
226
+
227
+ Persisted capture queues reject malformed rows with `WokuConfigError` and preserve
228
+ 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,85 @@ 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
+ 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"
106
115
  });
107
- } catch (err) {
108
- 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);
109
121
  }
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
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"
119
174
  );
175
+ } finally {
176
+ if (timer) clearTimeout(timer);
120
177
  }
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
178
  }
126
179
  async safeJson(json) {
127
180
  try {
@@ -134,94 +187,193 @@ var WokuClient = class {
134
187
 
135
188
  // src/queue.ts
136
189
  var DEFAULT_KEY = "woku.sdk.queue.v1";
137
- var DEFAULT_MAX_ATTEMPTS = 8;
190
+ var mutations = /* @__PURE__ */ new WeakMap();
191
+ var flushes = /* @__PURE__ */ new WeakMap();
138
192
  var OfflineQueue = class {
139
193
  constructor(opts = {}) {
140
- this.items = null;
141
194
  this.storage = opts.storage ?? new InMemoryStorage();
142
195
  this.key = opts.storageKey ?? DEFAULT_KEY;
143
- 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;
144
201
  }
145
202
  async load() {
146
- if (this.items) return this.items;
147
203
  const raw = await this.storage.getItem(this.key);
148
- if (!raw) {
149
- this.items = [];
150
- return this.items;
151
- }
204
+ if (!raw) return [];
152
205
  try {
153
206
  const parsed = JSON.parse(raw);
154
- 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;
155
211
  } catch {
156
- this.items = [];
157
212
  }
158
- return this.items;
213
+ throw new WokuConfigError(
214
+ "The capture queue contains invalid data; repair it before writing."
215
+ );
159
216
  }
160
- async persist() {
161
- 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;
162
237
  }
163
- /** Adds a submission to the tail of the queue (dedup by id). */
164
238
  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();
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
+ });
169
255
  }
170
- async size() {
171
- 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
+ );
172
260
  }
173
- /** Returns a copy of the pending submissions (oldest first). */
174
- async pending() {
175
- 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
+ );
176
265
  }
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);
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++;
192
328
  continue;
193
329
  }
330
+ let permanent = false;
331
+ let reason;
332
+ let accepted = false;
194
333
  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) {
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) {
205
340
  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);
341
+ break;
214
342
  }
343
+ reason = error instanceof Error ? error.message : "Delivery failed";
215
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++;
216
364
  }
217
- this.items = survivors;
218
- await this.persist();
219
- return { sent, failed, remaining: survivors.length, quarantined };
365
+ return { sent, failed, remaining: await this.size(), quarantined };
220
366
  }
221
- /** Clears the queue (e.g. on logout). */
222
- async clear() {
223
- this.items = [];
224
- 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
+ });
225
377
  }
226
378
  };
227
379
 
@@ -243,10 +395,12 @@ var WokuSdk = class {
243
395
  this.client = new WokuClient(config);
244
396
  this.queue = new OfflineQueue({
245
397
  storage: config.storage,
398
+ companyId: config.companyId,
246
399
  maxAttempts: config.maxQueueAttempts
247
400
  });
248
401
  this.logger = config.logger ?? noopLogger;
249
402
  this.companyId = config.companyId;
403
+ this.language = config.language ?? "es";
250
404
  }
251
405
  /** Captures a Woku rating (1..5) with optional text/audio comment. */
252
406
  async captureWoku(input) {
@@ -261,6 +415,10 @@ var WokuSdk = class {
261
415
  audio: input.audio,
262
416
  respondent: input.respondent,
263
417
  metadata: input.metadata,
418
+ dispatchToken: input.dispatchToken,
419
+ collectionKind: input.collectionKind ?? "standalone",
420
+ flowId: input.flowId,
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,48 @@ var WokuSdk = class {
278
436
  audio: input.audio,
279
437
  respondent: input.respondent,
280
438
  metadata: input.metadata,
439
+ dispatchToken: input.dispatchToken,
440
+ collectionKind: input.collectionKind ?? "standalone",
441
+ flowId: input.flowId,
442
+ language: input.language ?? (input.audio ? this.language : void 0),
281
443
  createdAt: Date.now()
282
444
  };
283
445
  return this.deliver(submission);
284
446
  }
447
+ async captureCsat(input) {
448
+ assertInt(input.score, 1, 5, "score");
449
+ return this.deliver({
450
+ id: generateId(),
451
+ kind: "csat",
452
+ companyId: this.companyId,
453
+ targetId: input.csatId,
454
+ score: input.score,
455
+ comment: input.comment,
456
+ respondent: input.respondent,
457
+ metadata: input.metadata,
458
+ dispatchToken: input.dispatchToken,
459
+ collectionKind: input.collectionKind ?? "standalone",
460
+ flowId: input.flowId,
461
+ createdAt: Date.now()
462
+ });
463
+ }
464
+ async captureCes(input) {
465
+ assertInt(input.score, 1, 5, "score");
466
+ return this.deliver({
467
+ id: generateId(),
468
+ kind: "ces",
469
+ companyId: this.companyId,
470
+ targetId: input.cesId,
471
+ score: input.score,
472
+ comment: input.comment,
473
+ respondent: input.respondent,
474
+ metadata: input.metadata,
475
+ dispatchToken: input.dispatchToken,
476
+ collectionKind: input.collectionKind ?? "standalone",
477
+ flowId: input.flowId,
478
+ createdAt: Date.now()
479
+ });
480
+ }
285
481
  /** Retries every queued submission. Safe to call on app foreground or
286
482
  * when connectivity is regained. */
287
483
  flush() {
@@ -291,6 +487,10 @@ var WokuSdk = class {
291
487
  pendingCount() {
292
488
  return this.queue.size();
293
489
  }
490
+ /** Inspect retained captures whose automatic attempt budget was exhausted. */
491
+ failedCaptures() {
492
+ return this.queue.failures();
493
+ }
294
494
  /** Clears the offline queue (e.g. on user logout). */
295
495
  clearQueue() {
296
496
  return this.queue.clear();
@@ -300,9 +500,25 @@ var WokuSdk = class {
300
500
  * submission is queued for a later flush rather than thrown away.
301
501
  */
302
502
  async deliver(submission) {
503
+ submission = JSON.parse(JSON.stringify(submission));
504
+ await this.queue.enqueue(submission);
303
505
  try {
304
506
  const result = await this.client.send(submission);
305
- if (result.status === "sent") return result;
507
+ if (result.status === "sent") {
508
+ try {
509
+ await this.queue.acknowledge(submission);
510
+ } catch {
511
+ this.logger.warn(
512
+ "Capture accepted; local acknowledgement is pending",
513
+ { id: submission.id }
514
+ );
515
+ }
516
+ return result;
517
+ }
518
+ if (result.retryable === false) {
519
+ await this.queue.reject(submission, result.error ?? "Capture rejected");
520
+ return result;
521
+ }
306
522
  await this.queue.enqueue(submission);
307
523
  return { ...result, status: "queued" };
308
524
  } catch (err) {