@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 +73 -14
- package/dist/index.cjs +303 -93
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +88 -60
- package/dist/index.d.ts +88 -60
- package/dist/index.js +303 -93
- package/dist/index.js.map +1 -1
- package/package.json +27 -18
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# @wokuapp/react-native
|
|
4
4
|
|
|
5
|
-
Capture **Woku
|
|
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
|
[](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**
|
|
22
|
-
|
|
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
|
|
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://
|
|
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
|
|
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
|
-
|
|
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) |
|
|
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()` |
|
|
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 = "/
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
return
|
|
258
|
+
size() {
|
|
259
|
+
return this.mutation(
|
|
260
|
+
async () => (await this.load()).filter((item) => this.owned(item) && !item.failure).length
|
|
261
|
+
);
|
|
172
262
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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.
|
|
218
|
-
await this.persist();
|
|
219
|
-
return { sent, failed, remaining: survivors.length, quarantined };
|
|
367
|
+
return { sent, failed, remaining: await this.size(), quarantined };
|
|
220
368
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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")
|
|
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) {
|