@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 +103 -14
- package/dist/index.cjs +309 -93
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +96 -63
- package/dist/index.d.ts +96 -63
- package/dist/index.js +309 -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,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**
|
|
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.
|
|
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://
|
|
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
|
|
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
|
-
|
|
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) |
|
|
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()` |
|
|
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 = "/
|
|
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
|
-
|
|
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
|
+
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
return
|
|
256
|
+
size() {
|
|
257
|
+
return this.mutation(
|
|
258
|
+
async () => (await this.load()).filter((item) => this.owned(item) && !item.failure).length
|
|
259
|
+
);
|
|
172
260
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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.
|
|
218
|
-
await this.persist();
|
|
219
|
-
return { sent, failed, remaining: survivors.length, quarantined };
|
|
365
|
+
return { sent, failed, remaining: await this.size(), quarantined };
|
|
220
366
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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")
|
|
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) {
|