@wireai/activation 0.14.3 → 0.16.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/AGENTS.md +96 -21
- package/CHANGELOG.md +780 -0
- package/INTEGRATION_PROMPT.md +61 -23
- package/README.md +110 -31
- package/dist/analytics/index.d.mts +35 -13
- package/dist/analytics/index.d.ts +35 -13
- package/dist/analytics/index.js +288 -127
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +288 -127
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +15 -1
- package/dist/coachmarks/index.d.ts +15 -1
- package/dist/coachmarks/index.js +120 -22
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +120 -22
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-Bz7G6lno.d.mts} +35 -39
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-z-CZ55ad.d.ts} +35 -39
- package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
- package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +125 -36
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +125 -36
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -16
- package/dist/questionnaire/index.d.ts +3 -16
- package/dist/questionnaire/index.js +202 -46
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +203 -47
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +10 -6
- package/dist/reviews/index.d.ts +10 -6
- package/dist/reviews/index.js +269 -52
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +270 -53
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js +106 -20
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +107 -21
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
- package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
- package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
- package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
- package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
- package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
- package/llms.txt +9 -9
- package/package.json +6 -9
- package/src/WireOnboarding.tsx +4 -1
- package/src/analytics/currentSession.ts +141 -4
- package/src/analytics/index.ts +6 -1
- package/src/analytics/reportClientEvent.ts +19 -10
- package/src/analytics/useAnalytics.ts +74 -15
- package/src/analytics/wireDoctor.ts +152 -7
- package/src/coachmarks/CoachmarkProvider.tsx +26 -5
- package/src/coachmarks/runtime.ts +156 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/coachmarks/useCoachmarkTour.ts +51 -1
- package/src/context/deviceId.ts +72 -6
- package/src/features/WireFeaturesProvider.tsx +72 -12
- package/src/features/fetchWireFeatures.ts +49 -11
- package/src/features/useWireFeatures.ts +39 -3
- package/src/identity/identityRecord.ts +15 -2
- package/src/questionnaire/QuestionnaireGate.tsx +40 -1
- package/src/questionnaire/transport.ts +22 -8
- package/src/questionnaire/useQuestionnaireGate.ts +58 -7
- package/src/reviews/ReviewGate.tsx +125 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +109 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +44 -13
- package/src/reviews/transport.ts +39 -20
- package/src/reviews/types.ts +7 -0
- package/src/reviews/useReviewGate.ts +57 -7
- package/src/session-analytics/lifecycle.ts +16 -0
- package/src/session-analytics/useLifecycleEvents.ts +30 -2
- package/src/session-analytics/useSessionStart.ts +22 -2
- package/src/showcase/FeatureShowcase.tsx +50 -3
- package/src/types.ts +15 -8
- package/src/utils/submitResult.ts +17 -9
- package/src/utils/withDeadline.ts +70 -0
|
@@ -5,13 +5,21 @@
|
|
|
5
5
|
* network resolves. When no `serverUrl`+`apiKey` is supplied it never fetches and just returns
|
|
6
6
|
* defaults, so a host that never adopts flags sees ZERO behavior change and ZERO network calls.
|
|
7
7
|
*
|
|
8
|
+
* ⚠️ THE OPTIMISTIC DEFAULT IS A GUESS, AND SOME CONSUMERS CANNOT ACT ON A GUESS. Fail-open is the
|
|
9
|
+
* right resting state, but "all on because we have not asked yet" and "all on because the tenant
|
|
10
|
+
* says so" are different answers and the returned flags cannot tell them apart. A surface whose
|
|
11
|
+
* first render is IRREVERSIBLE — the review gate posts a `review_prompt_shown` and can take a row
|
|
12
|
+
* the moment it appears — needs the difference, or a tenant with `review.enabled: false` still gets
|
|
13
|
+
* one impression on every launch before the answer lands. {@link useWireFeaturesState} exposes it as
|
|
14
|
+
* `settled`; `useWireFeatures` is the flags-only wrapper and is unchanged.
|
|
15
|
+
*
|
|
8
16
|
* frequent_rules #11 (unstable inline props): the config is idiomatically passed inline
|
|
9
17
|
* (`config={{ serverUrl, apiKey }}`), a fresh identity every render. We stabilize the fetch key
|
|
10
18
|
* by VALUE so the effect fires once per real credential change, and we only swap the returned
|
|
11
19
|
* flags object when its VALUES change — so a host that lists the returned flags in an effect dep
|
|
12
20
|
* array settles in a bounded number of commits instead of looping (enforced by the canary suite).
|
|
13
21
|
*/
|
|
14
|
-
import { useEffect, useRef, useState } from "react";
|
|
22
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
15
23
|
|
|
16
24
|
import { defaultWireFeatures, featuresEqual } from "./defaults";
|
|
17
25
|
import { fetchWireFeatures } from "./fetchWireFeatures";
|
|
@@ -28,16 +36,35 @@ const useStableValue = <T,>(value: T, isEqual: (a: T, b: T) => boolean): T => {
|
|
|
28
36
|
const sameFetchKey = (a: WireFeaturesConfig | undefined, b: WireFeaturesConfig | undefined): boolean =>
|
|
29
37
|
a?.serverUrl === b?.serverUrl && a?.apiKey === b?.apiKey && a?.appId === b?.appId;
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The flags PLUS whether they are an answer yet — the shape `useWireFeatures` returns half of.
|
|
41
|
+
*
|
|
42
|
+
* `settled: false` means "nobody has answered; these are the optimistic defaults". It becomes
|
|
43
|
+
* `true` the moment the fetch resolves (fail-open included: an unreachable control plane resolves
|
|
44
|
+
* to the defaults and that IS a settled answer, because it is the answer we are keeping), and it
|
|
45
|
+
* starts `true` whenever there is nothing to wait for — no creds, so no flag can ever arrive.
|
|
46
|
+
*/
|
|
47
|
+
export interface WireFeaturesState {
|
|
48
|
+
flags: WireFeatures;
|
|
49
|
+
/** `false` only while a fetch that could still change the answer is in flight. */
|
|
50
|
+
settled: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const useWireFeaturesState = (config?: WireFeaturesConfig): WireFeaturesState => {
|
|
32
54
|
const stableConfig = useStableValue(config, sameFetchKey);
|
|
33
55
|
const [flags, setFlags] = useState<WireFeatures>(defaultWireFeatures);
|
|
34
56
|
|
|
35
57
|
const canFetch = !!stableConfig?.serverUrl && !!stableConfig?.apiKey;
|
|
58
|
+
// Nothing to fetch → the defaults are already the final answer, so a consumer that waits for an
|
|
59
|
+
// answer must not wait forever. Only ever moves to `true`: a later credential change refreshes a
|
|
60
|
+
// KNOWN answer, and un-answering it would re-hide a surface the user is already looking at.
|
|
61
|
+
const [settled, setSettled] = useState<boolean>(!canFetch);
|
|
36
62
|
|
|
37
63
|
useEffect(() => {
|
|
38
64
|
if (!canFetch) {
|
|
39
65
|
// No creds → stay on the all-on defaults (and reset if creds were removed).
|
|
40
66
|
setFlags((prev) => (featuresEqual(prev, defaultWireFeatures) ? prev : defaultWireFeatures));
|
|
67
|
+
setSettled(true);
|
|
41
68
|
return;
|
|
42
69
|
}
|
|
43
70
|
let alive = true;
|
|
@@ -47,11 +74,20 @@ export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures => {
|
|
|
47
74
|
// Only adopt a NEW reference on a real value change, so a host effect keyed on the
|
|
48
75
|
// returned flags doesn't loop (frequent_rules #11).
|
|
49
76
|
setFlags((prev) => (featuresEqual(prev, next) ? prev : next));
|
|
77
|
+
// Settled on EVERY resolution, including the fail-open one: `fetchWireFeatures` swallows a
|
|
78
|
+
// timeout / 401 / 5xx into the defaults, and that fallback is the final answer, not a
|
|
79
|
+
// pending one. This is what keeps fail-open intact — a broken control plane resolves fast
|
|
80
|
+
// and permissive, it does not leave a gated surface waiting.
|
|
81
|
+
setSettled(true);
|
|
50
82
|
});
|
|
51
83
|
return () => {
|
|
52
84
|
alive = false;
|
|
53
85
|
};
|
|
54
86
|
}, [canFetch, stableConfig]);
|
|
55
87
|
|
|
56
|
-
return flags;
|
|
88
|
+
return useMemo(() => ({ flags, settled }), [flags, settled]);
|
|
57
89
|
};
|
|
90
|
+
|
|
91
|
+
/** The flags alone — the original hook, unchanged for every consumer that cannot act on `settled`. */
|
|
92
|
+
export const useWireFeatures = (config?: WireFeaturesConfig): WireFeatures =>
|
|
93
|
+
useWireFeaturesState(config).flags;
|
|
@@ -96,6 +96,20 @@ const provenanceRegistry = (): ProvenanceRegistry => {
|
|
|
96
96
|
const provenanceKey = (space: IdentitySpace, scope?: string): string =>
|
|
97
97
|
`${space}:${scope ?? "default"}`;
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Is this candidate a usable identity value? A string with at least one non-whitespace character.
|
|
101
|
+
*
|
|
102
|
+
* ⛔ PURE, and that is the whole point of it existing separately from {@link resolveIdentity}: it is
|
|
103
|
+
* the predicate a READ-ONLY caller needs. `resolveIdentity` cannot serve that caller, because for a
|
|
104
|
+
* `host`-sourced input it WRITES to the process provenance registry — so merely asking it "is this a
|
|
105
|
+
* usable key?" would enrol the asker in the census it was only trying to read. That is the exact
|
|
106
|
+
* defect 0.15.1 fixed (a propless reader minting into `registry.keys` made the census read two, and
|
|
107
|
+
* the reader answered `undefined` forever), so the predicate is shared rather than re-typed:
|
|
108
|
+
* `resolveIdentity` below is its only other caller, so the two can never drift.
|
|
109
|
+
*/
|
|
110
|
+
export const isUsableIdentityValue = (value: unknown): value is string =>
|
|
111
|
+
typeof value === "string" && value.trim().length > 0;
|
|
112
|
+
|
|
99
113
|
/**
|
|
100
114
|
* Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
|
|
101
115
|
* (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
|
|
@@ -107,9 +121,8 @@ const provenanceKey = (space: IdentitySpace, scope?: string): string =>
|
|
|
107
121
|
* key" from a silent third id space into a warnable condition. Never throws.
|
|
108
122
|
*/
|
|
109
123
|
export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
|
|
110
|
-
if (
|
|
124
|
+
if (!isUsableIdentityValue(input.value)) return undefined;
|
|
111
125
|
const value = input.value.trim();
|
|
112
|
-
if (!value) return undefined;
|
|
113
126
|
const durable = input.durable ?? input.source === "host";
|
|
114
127
|
if (input.source === "host") {
|
|
115
128
|
provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
|
|
@@ -236,6 +236,31 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
236
236
|
const postedRef = useRef(false);
|
|
237
237
|
/** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
|
|
238
238
|
const unackedRef = useRef<QuestionnaireSubmission | null>(null);
|
|
239
|
+
/**
|
|
240
|
+
* THE NET WAS INERT WHILE THE POST WAS IN FLIGHT, which is precisely when it is needed.
|
|
241
|
+
*
|
|
242
|
+
* This is the review-gate defect on its second copy: 0.15.1 closed it in `reviews/ReviewGate.tsx`
|
|
243
|
+
* and left the twin here open. The latch is taken OPTIMISTICALLY and only released in the `.then`
|
|
244
|
+
* below, while `finish` auto-closes the modal 1500ms after the post. So on any network slower than
|
|
245
|
+
* that — the offline answerer, a stalled radio, a captive portal — the unmount ran with the latch
|
|
246
|
+
* still CLOSED: `recoverRef`'s only branch requires `!postedRef.current`, so it no-opped, and the
|
|
247
|
+
* post later resolved `"unsent"` with nothing mounted to notice. The host's `onResolved` →
|
|
248
|
+
* `markResolved` has ALREADY written the permanent `wire_questionnaire_<id>_<ver>_seen` key, so
|
|
249
|
+
* that user is never asked again and their answers are gone. Silent. The existing canary missed it
|
|
250
|
+
* because every case awaits the submit BEFORE unmounting — the fast path, where the latch has
|
|
251
|
+
* already reopened.
|
|
252
|
+
*
|
|
253
|
+
* So the unmount hands the recovery to the promise: when it finds a post STILL IN FLIGHT — the one
|
|
254
|
+
* state its branch cannot serve — it arms `recoverOnSettleRef`, and the `.then` below does the
|
|
255
|
+
* re-post if nothing reached the server. It is armed ONLY in that state, so the fast path is
|
|
256
|
+
* untouched and still recovers exactly once, in the net. `recoveredRef` bounds this to exactly ONE
|
|
257
|
+
* extra post — the same in-process ceiling the net always had, never a retry loop.
|
|
258
|
+
*/
|
|
259
|
+
const recoverOnSettleRef = useRef(false);
|
|
260
|
+
const recoveredRef = useRef(false);
|
|
261
|
+
/** Latest `postOnce`, so the recovery above can call it from inside its own promise chain
|
|
262
|
+
* (the same ref convention `recoverRef` below uses) without a self-referencing callback. */
|
|
263
|
+
const postOnceRef = useRef<(body: QuestionnaireSubmission) => void>(() => {});
|
|
239
264
|
const postOnce = useCallback(
|
|
240
265
|
(body: QuestionnaireSubmission) => {
|
|
241
266
|
if (postedRef.current) return;
|
|
@@ -245,6 +270,12 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
245
270
|
if (result === "unsent") {
|
|
246
271
|
// Nothing reached the server → un-latch, so these answers can still go out.
|
|
247
272
|
postedRef.current = false;
|
|
273
|
+
// The gate is already gone: the unmount net ran while this post was still in flight and
|
|
274
|
+
// found the latch closed, so nothing else will ever recover this row. Do it here, once.
|
|
275
|
+
if (recoverOnSettleRef.current && !recoveredRef.current) {
|
|
276
|
+
recoveredRef.current = true;
|
|
277
|
+
postOnceRef.current(body);
|
|
278
|
+
}
|
|
248
279
|
return;
|
|
249
280
|
}
|
|
250
281
|
// `accepted` OR `rejected`: the server answered, so it owns this row. Never re-post it.
|
|
@@ -253,6 +284,7 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
253
284
|
},
|
|
254
285
|
[target, id],
|
|
255
286
|
);
|
|
287
|
+
postOnceRef.current = postOnce;
|
|
256
288
|
|
|
257
289
|
/**
|
|
258
290
|
* The recovery point for a submission the server never ANSWERED — never for one it answered
|
|
@@ -268,7 +300,14 @@ const _QuestionnaireGate: React.FC<QuestionnaireGateProps> = ({
|
|
|
268
300
|
const recoverRef = useRef<() => void>(() => {});
|
|
269
301
|
recoverRef.current = () => {
|
|
270
302
|
const undelivered = unackedRef.current;
|
|
271
|
-
if (undelivered && !postedRef.current)
|
|
303
|
+
if (undelivered && !postedRef.current) {
|
|
304
|
+
postOnce(undelivered);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// STILL IN FLIGHT (latch closed, row unanswered) — the state that made this net inert. The
|
|
308
|
+
// branch above cannot act from here, so the pending post is asked to recover itself when it
|
|
309
|
+
// settles unsent. Nothing else is scheduled to look at this row again.
|
|
310
|
+
if (postedRef.current && undelivered) recoverOnSettleRef.current = true;
|
|
272
311
|
};
|
|
273
312
|
React.useEffect(() => () => recoverRef.current(), []);
|
|
274
313
|
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* non-2xx (incl. 404), a missing `fetch`, or bad JSON all resolve to null, so a host wiring
|
|
11
11
|
* pure server-directed firing simply shows nothing - exactly the reviews decision seam.
|
|
12
12
|
*/
|
|
13
|
+
import { DEADLINE_EXPIRED, withDeadline } from "../utils/withDeadline";
|
|
13
14
|
import type { SubmitResult } from "../utils/submitResult";
|
|
14
15
|
import type {
|
|
15
16
|
QuestionnaireDecisionResponse,
|
|
@@ -63,11 +64,19 @@ export const submitQuestionnaireResponse = async (
|
|
|
63
64
|
const url = `${base}/v1/questionnaires/${encodeURIComponent(id)}/responses`;
|
|
64
65
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
65
66
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// Under a deadline (see `utils/withDeadline`): a hung POST used to hold `QuestionnaireGate`'s
|
|
68
|
+
// `postOnce` latch closed for the ~60s platform default, which is exactly the window the
|
|
69
|
+
// unmount recovery net cannot re-post inside.
|
|
70
|
+
const res = await withDeadline((signal) =>
|
|
71
|
+
fetch(url, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers,
|
|
74
|
+
body: JSON.stringify(submission),
|
|
75
|
+
signal,
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
// A deadline that expired is not a rejection: nothing reached the server, so it stays re-postable.
|
|
79
|
+
if (res === DEADLINE_EXPIRED) return "unsent";
|
|
71
80
|
// No response object at all is not an answer — treat it as nothing having reached the server
|
|
72
81
|
// rather than as a rejection, or a stubbed-out `fetch` would silently latch the answers away.
|
|
73
82
|
if (!res) return "unsent";
|
|
@@ -107,9 +116,14 @@ export const fetchQuestionnaireDecision = async (
|
|
|
107
116
|
const url = `${base}/v1/questionnaires/decision${qs ? `?${qs}` : ""}`;
|
|
108
117
|
const headers: Record<string, string> = {};
|
|
109
118
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const json =
|
|
119
|
+
// The BODY read is inside the deadline too: a ceiling cleared when the headers land is not a
|
|
120
|
+
// ceiling (the `fetchWireFeatures` lesson — see `utils/withDeadline`).
|
|
121
|
+
const json = await withDeadline(async (signal): Promise<QuestionnaireDecisionResponse | null> => {
|
|
122
|
+
const res = await fetch(url, { headers, signal });
|
|
123
|
+
if (!res || !res.ok) return null;
|
|
124
|
+
return (await res.json()) as QuestionnaireDecisionResponse | null;
|
|
125
|
+
});
|
|
126
|
+
if (json === DEADLINE_EXPIRED) return null;
|
|
113
127
|
// A body without a boolean `fire` is not a decision. Guard it explicitly (mirrors
|
|
114
128
|
// `fetchReviewDecision`) rather than letting `{}` through as a truthy object a gate would treat
|
|
115
129
|
// as a verdict (`{}.fire === undefined` is falsy, so it would silently read as "never fire").
|
|
@@ -13,10 +13,11 @@
|
|
|
13
13
|
* the popup. The global `isTestingCoachmark` flag force-shows the gate for QA replay, and the
|
|
14
14
|
* `questionnaire` master switch (a disabled tenant) wins over everything.
|
|
15
15
|
*/
|
|
16
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
17
17
|
|
|
18
|
+
import { getCurrentSessionId, subscribeCurrentSessionId } from "../analytics/currentSession";
|
|
18
19
|
import { hasSeenGate, isCoachmarkTesting, markSeenGate } from "../coachmarks/runtime";
|
|
19
|
-
import {
|
|
20
|
+
import { useResolvedFeaturesState } from "../features/WireFeaturesProvider";
|
|
20
21
|
import { sameDecision, shallowEqual } from "../reviews/equality";
|
|
21
22
|
import { decideQuestionnaire, evaluateGate, resolveRules } from "./decision";
|
|
22
23
|
import {
|
|
@@ -64,8 +65,20 @@ export const useQuestionnaireGate = ({
|
|
|
64
65
|
|
|
65
66
|
// The questionnaire MASTER switch. Disabled → the gate never fires, composing as an extra
|
|
66
67
|
// AND over the local rules + the server decision seam below (fail-open: defaults to enabled).
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
//
|
|
69
|
+
// `settled` is read too, and it is the half that makes the switch real — the same asymmetry
|
|
70
|
+
// `useReviewGate` closed, on the same seam. The flags start on the all-on defaults and swap only
|
|
71
|
+
// when the fetch resolves, so `featureEnabled` is `true` on the FIRST render of every mount,
|
|
72
|
+
// including a tenant with `questionnaire.enabled: false`. With no `timeoutFallbackMs` the local
|
|
73
|
+
// wait is skipped as well (`elapsed` starts `true`), so the gate could become visible, fire
|
|
74
|
+
// `questionnaire_prompt_shown`, and POST a response from a user the tenant had switched off. The
|
|
75
|
+
// comment below ("must never see it") was falsified by its own async default. Holding the FIRST
|
|
76
|
+
// impression until an answer exists is what honours the switch.
|
|
77
|
+
const { flags: resolvedFeatures, settled: featuresSettled } = useResolvedFeaturesState({
|
|
78
|
+
flags: features,
|
|
79
|
+
config: featuresConfig,
|
|
80
|
+
});
|
|
81
|
+
const featureEnabled = resolvedFeatures.questionnaire.enabled;
|
|
69
82
|
|
|
70
83
|
const testing = isTesting ?? isCoachmarkTesting();
|
|
71
84
|
const seenKey = questionnaireSeenKey(
|
|
@@ -76,16 +89,48 @@ export const useQuestionnaireGate = ({
|
|
|
76
89
|
const sessionsKey = questionnaireSessionsKey(config.id);
|
|
77
90
|
const sessionOpenKey = questionnaireSessionOpenKey(config.id);
|
|
78
91
|
|
|
92
|
+
// ── FOLLOW real app-opens, do not sample one ────────────────────────────────────────────────
|
|
93
|
+
//
|
|
94
|
+
// `bumpSessionCount` is IDEMPOTENT per app-open and moves once per open — but it used to be reached
|
|
95
|
+
// ONLY from the `useState` initializer below, which runs once per component INSTANCE. On a screen
|
|
96
|
+
// that never unmounts (a home feed, a tab that stays alive) that meant once per PROCESS, and iOS
|
|
97
|
+
// suspends rather than kills: `useLifecycleEvents` fires a fresh `app.session_started` on every
|
|
98
|
+
// foreground past its threshold and the SERVER's `min_sessions` advances, while `wire_questionnaire_<id>_sessions`
|
|
99
|
+
// stayed at its launch value and the LOCAL `minSessions` rule was unsatisfiable for the life of the
|
|
100
|
+
// app. Silent, and in the safe direction (a prompt that never shows), which is why it survived —
|
|
101
|
+
// `sessionCountAcrossOpens.test.ts` proved the FUNCTION advances and could not see that the HOOK
|
|
102
|
+
// never asked it again.
|
|
103
|
+
//
|
|
104
|
+
// So subscribe to the registry that knows. A notification naming the same open is a no-op by
|
|
105
|
+
// construction (`bumpSessionCount` reads its stored count back), so the cold-start ordering this
|
|
106
|
+
// pin exists for is untouched.
|
|
107
|
+
const openSessionId = useSyncExternalStore(
|
|
108
|
+
subscribeCurrentSessionId,
|
|
109
|
+
getCurrentSessionId,
|
|
110
|
+
getCurrentSessionId,
|
|
111
|
+
);
|
|
112
|
+
// Read through a ref: a host that passes `storage={{ … }}` inline (idiomatic React, and what
|
|
113
|
+
// `frequent_rules` #11 requires this hook to tolerate) mints a fresh identity every render, and
|
|
114
|
+
// listing it in the deps below would re-run the effect on every one of them.
|
|
115
|
+
const storageRef = useRef(storage);
|
|
116
|
+
storageRef.current = storage;
|
|
117
|
+
|
|
79
118
|
// Read (and bump) the app-open counter. IDEMPOTENT per app-open, NOT per mount — see the same note
|
|
80
119
|
// in `useReviewGate`; `bumpSessionCount` keys off the live per-open session id, so a remount or a
|
|
81
120
|
// StrictMode double-invoke of this initializer reads the same number back instead of inflating it.
|
|
82
|
-
const sessions = useState(() => {
|
|
121
|
+
const [sessions, setSessions] = useState(() => {
|
|
83
122
|
const store = resolveStorage(storage);
|
|
84
123
|
// Same as `useReviewGate`: no storage means the counter never leaves 1, so the fail-closed
|
|
85
124
|
// minSessions rule is unsatisfiable and the gate silently never fires. Dev-only, once per process.
|
|
86
125
|
warnMissingGateStorage(store, "questionnaire");
|
|
87
126
|
return bumpSessionCount(store, sessionsKey, sessionOpenKey);
|
|
88
|
-
})
|
|
127
|
+
});
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
const next = bumpSessionCount(resolveStorage(storageRef.current), sessionsKey, sessionOpenKey);
|
|
130
|
+
// A bail-out when the count is unchanged, so a notification for an open already counted costs
|
|
131
|
+
// nothing: React skips the re-render when the state is identical.
|
|
132
|
+
setSessions((prev) => (prev === next ? prev : next));
|
|
133
|
+
}, [openSessionId, sessionsKey, sessionOpenKey]);
|
|
89
134
|
|
|
90
135
|
// Gate the local rules behind an optional client-side timeout, so a reachable server gets a
|
|
91
136
|
// window to answer first. A present `decision` bypasses the wait entirely.
|
|
@@ -129,11 +174,17 @@ export const useQuestionnaireGate = ({
|
|
|
129
174
|
const resolved = decideQuestionnaire(local, decision);
|
|
130
175
|
// (3) the once-gate always wins locally, even over a server "fire".
|
|
131
176
|
const alreadySeen = hasSeenGate(seenKey, storage, isTesting);
|
|
132
|
-
|
|
177
|
+
// FAIL-OPEN IS NOT WEAKENED BY THIS. `featuresSettled` is `false` only while a fetch that could
|
|
178
|
+
// still change the answer is in flight; it is `true` with no `featuresConfig` (nothing to ask),
|
|
179
|
+
// with explicit `features`, and — the case that matters — the moment a FAILED fetch resolves,
|
|
180
|
+
// because `fetchWireFeatures` swallows a timeout / 401 / 5xx into the all-on defaults. So an
|
|
181
|
+
// unreachable control plane still allows the gate; only an unanswered one holds it.
|
|
182
|
+
const ready = (hasServerDecision || elapsed) && featuresSettled;
|
|
133
183
|
return { visible: ready && resolved.fire && !alreadySeen, verdict: resolved };
|
|
134
184
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
185
|
}, [
|
|
136
186
|
featureEnabled,
|
|
187
|
+
featuresSettled,
|
|
137
188
|
testing,
|
|
138
189
|
config,
|
|
139
190
|
decision,
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
*
|
|
18
18
|
* The form NEVER asks for identity (no name / email / contact field): the host already
|
|
19
19
|
* identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
|
|
20
|
-
* the POST body carries alongside the rating and the free text.
|
|
20
|
+
* the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
|
|
21
|
+
* fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
|
|
22
|
+
* identification, but because it is the unit the server scopes the idempotency key to, and without
|
|
23
|
+
* it the key is discarded. Any non-blank string the host passes still wins verbatim. See
|
|
24
|
+
* `idempotency.ts`.
|
|
21
25
|
*
|
|
22
26
|
* Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
|
|
23
27
|
* ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
|
|
@@ -45,6 +49,7 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
|
45
49
|
import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
|
|
46
50
|
import { buildReviewSubmission, routeRating } from "./decision";
|
|
47
51
|
import { feedbackInputHeights } from "./feedbackForm";
|
|
52
|
+
import { createImpression, hasDeviceKey, type ReviewImpression } from "./idempotency";
|
|
48
53
|
import { resolvePresentation } from "./presentation";
|
|
49
54
|
import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
|
|
50
55
|
import { requestStoreReview } from "./storeReview";
|
|
@@ -107,18 +112,63 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
107
112
|
onResolved?.();
|
|
108
113
|
}, [onResolved]);
|
|
109
114
|
|
|
115
|
+
/**
|
|
116
|
+
* ONE identity + ONE idempotency key for this whole gate impression, opened lazily at the first
|
|
117
|
+
* post so a prompt the user never acts on mints nothing.
|
|
118
|
+
*
|
|
119
|
+
* Both values live for the IMPRESSION, not the render and not the post: the server derives the
|
|
120
|
+
* row id from `(app_id, unit, key)`, so a key that moves between two posts writes two rows and a
|
|
121
|
+
* unit that moves does the same. `idempotency.ts` carries the full reasoning.
|
|
122
|
+
*/
|
|
123
|
+
const impressionRef = React.useRef<ReviewImpression | null>(null);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a submission for THIS impression: the host's `meta` plus whatever the call site adds,
|
|
127
|
+
* stamped with the impression's key and — only when the host supplied none — its device key.
|
|
128
|
+
*
|
|
129
|
+
* A host-supplied `meta.device_key` ALWAYS wins and is never overwritten — "supplied" meaning a
|
|
130
|
+
* string with at least one non-whitespace character, the bar {@link hasDeviceKey} sets; a blank
|
|
131
|
+
* or non-string value is not a usable unit and is replaced. This mirrors the `userContext` /
|
|
132
|
+
* `autoJoinKey` rule one directory over, which sets the same bar through `resolveIdentity`. The
|
|
133
|
+
* injection exists because without a resolvable unit the server discards the key outright, and a
|
|
134
|
+
* home-feed gate has no `session_id` to fall back to.
|
|
135
|
+
*/
|
|
136
|
+
const buildBody = useCallback(
|
|
137
|
+
(input: {
|
|
138
|
+
stars: number;
|
|
139
|
+
feedbackText?: string;
|
|
140
|
+
suggestion?: string;
|
|
141
|
+
extraMeta?: Record<string, unknown>;
|
|
142
|
+
}): ReviewSubmission => {
|
|
143
|
+
let impression = impressionRef.current;
|
|
144
|
+
if (!impression) {
|
|
145
|
+
impression = createImpression();
|
|
146
|
+
impressionRef.current = impression;
|
|
147
|
+
}
|
|
148
|
+
const merged: Record<string, unknown> = { ...meta, ...input.extraMeta };
|
|
149
|
+
if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
|
|
150
|
+
return buildReviewSubmission({
|
|
151
|
+
stars: input.stars,
|
|
152
|
+
feedbackText: input.feedbackText,
|
|
153
|
+
suggestion: input.suggestion,
|
|
154
|
+
sessionId,
|
|
155
|
+
meta: merged,
|
|
156
|
+
idempotencyKey: impression.idempotencyKey,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[meta, sessionId],
|
|
160
|
+
);
|
|
161
|
+
|
|
110
162
|
/**
|
|
111
163
|
* EXACTLY ONE review row per gate, ever — the latch that makes "force feedback" safe.
|
|
112
164
|
*
|
|
113
|
-
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* the failure is invisible to the caller and permanent. Post once, at the last responsible
|
|
121
|
-
* moment, and never depend on an unmerged/undeployed server capability.
|
|
165
|
+
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then post
|
|
166
|
+
* again with the text, enriching the first row. The server now supports exactly that — a post
|
|
167
|
+
* carrying `idempotency_key` upserts on a derived row id instead of inserting — but the latch
|
|
168
|
+
* stays, because two posts buy nothing the one-at-the-last-responsible-moment post does not
|
|
169
|
+
* already have, and every additional post is another chance to hand the server a unit it cannot
|
|
170
|
+
* resolve. What the key genuinely buys is the RECOVERY paths below: the `unsent` re-post and the
|
|
171
|
+
* abandonment net now merge into the row they may have already written instead of duplicating it.
|
|
122
172
|
*
|
|
123
173
|
* ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
|
|
124
174
|
*
|
|
@@ -134,17 +184,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
134
184
|
*
|
|
135
185
|
* "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
|
|
136
186
|
* resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
|
|
137
|
-
* A non-2xx means the server was REACHED, and
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* cleared: the row is the server's problem now.
|
|
187
|
+
* A non-2xx means the server was REACHED, and we still cannot know whether it stored the row
|
|
188
|
+
* before failing to say so — a gateway 502 after the insert commits looks identical to a refusal.
|
|
189
|
+
* That verdict is left alone DELIBERATELY (2026-08-19): re-posting on it is now safe wherever the
|
|
190
|
+
* key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
|
|
191
|
+
* budget, so changing it is its own decision and not this one. On any answer the latch stays
|
|
192
|
+
* closed and `unackedRef` is cleared: the row is the server's problem now.
|
|
143
193
|
*
|
|
144
|
-
*
|
|
145
|
-
* after the row committed throws here exactly like an offline device, so the
|
|
146
|
-
*
|
|
147
|
-
* the server
|
|
194
|
+
* WHAT THE KEY CHANGED, STATED AS A CAPABILITY: `unsent` was never proof the server got nothing —
|
|
195
|
+
* a connection dropped after the row committed throws here exactly like an offline device, so the
|
|
196
|
+
* one re-post below could duplicate. It now carries this impression's `idempotency_key` and the
|
|
197
|
+
* unit the server scopes it to, so the server upserts that row rather than adding a second one.
|
|
198
|
+
* That holds while the unit is resolvable, and a host CANNOT make it unresolvable by editing
|
|
199
|
+
* `meta`: `buildBody` tests the MERGED bucket, so stripping `device_key` there only gets the auto
|
|
200
|
+
* id re-injected. The one post that carries no unit is the one where the kit cannot name an id
|
|
201
|
+
* honestly — two tenants registered in one process leave `impression.deviceKey` undefined, and
|
|
202
|
+
* nothing is stamped. On a gate with no `session_id` that post reaches the server with no unit to
|
|
203
|
+
* scope the key to, so the server ignores the key and it is back in the old, duplicating world.
|
|
148
204
|
*
|
|
149
205
|
* NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
|
|
150
206
|
* on the next line, so the user is never waiting on the network to dismiss the prompt.
|
|
@@ -158,6 +214,31 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
158
214
|
const postedRef = React.useRef(false);
|
|
159
215
|
/** The last submission fired with no answer yet. Non-null after an UNSENT post = still owed. */
|
|
160
216
|
const unackedRef = React.useRef<ReviewSubmission | null>(null);
|
|
217
|
+
/**
|
|
218
|
+
* THE NET WAS INERT WHILE THE POST WAS IN FLIGHT, which is precisely when it is needed.
|
|
219
|
+
*
|
|
220
|
+
* The latch is taken OPTIMISTICALLY and only released in `submitReview`'s `.then`, while the gate
|
|
221
|
+
* auto-closes 1500ms after the post (`resolveWithDelay`). So on any network slower than that —
|
|
222
|
+
* the offline detractor, a stalled radio, a captive portal — the unmount ran with the latch still
|
|
223
|
+
* CLOSED: the recovery branch below requires `!postedRef.current`, and the abandonment branch
|
|
224
|
+
* returns on `postedRef.current`, so BOTH no-opped and a 1-4 rating plus its mandatory text was
|
|
225
|
+
* dropped in silence. The once-gate means that user is never asked again. The existing canary
|
|
226
|
+
* missed it because it awaits the submit BEFORE unmounting — the fast path, where the latch has
|
|
227
|
+
* already reopened.
|
|
228
|
+
*
|
|
229
|
+
* So the unmount hands the recovery to the promise: when it finds a post STILL IN FLIGHT — the
|
|
230
|
+
* one state neither of its branches can serve — it arms `recoverOnSettleRef`, and the `.then`
|
|
231
|
+
* below does the re-post if nothing reached the server. It is armed ONLY in that state, so the
|
|
232
|
+
* fast path (settled before unmount) is untouched and still recovers exactly once, in the net.
|
|
233
|
+
* `recoveredRef` bounds this to exactly ONE extra post — the same in-process ceiling the net
|
|
234
|
+
* always had, never a retry loop — and the re-post carries this impression's `idempotency_key`
|
|
235
|
+
* plus its unit, so a row that did land is upserted rather than duplicated.
|
|
236
|
+
*/
|
|
237
|
+
const recoverOnSettleRef = React.useRef(false);
|
|
238
|
+
const recoveredRef = React.useRef(false);
|
|
239
|
+
/** Latest `postOnce`, so the recovery above can call it from inside its own promise chain
|
|
240
|
+
* (the same ref convention `abandonRef` below uses) without a self-referencing callback. */
|
|
241
|
+
const postOnceRef = React.useRef<(body: ReviewSubmission) => void>(() => {});
|
|
161
242
|
const postOnce = useCallback(
|
|
162
243
|
(body: ReviewSubmission) => {
|
|
163
244
|
if (postedRef.current) return;
|
|
@@ -167,15 +248,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
167
248
|
if (result === "unsent") {
|
|
168
249
|
// Nothing reached the server → un-latch, so this row can still go out.
|
|
169
250
|
postedRef.current = false;
|
|
251
|
+
// The gate is already gone: the unmount net ran while this post was still in flight and
|
|
252
|
+
// found the latch closed, so nothing else will ever recover this row. Do it here, once.
|
|
253
|
+
if (recoverOnSettleRef.current && !recoveredRef.current) {
|
|
254
|
+
recoveredRef.current = true;
|
|
255
|
+
postOnceRef.current(body);
|
|
256
|
+
}
|
|
170
257
|
return;
|
|
171
258
|
}
|
|
172
|
-
// `accepted` OR `rejected`: the server answered, so it owns this row.
|
|
173
|
-
// a non-2xx can follow a committed insert, and
|
|
259
|
+
// `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here —
|
|
260
|
+
// a non-2xx can follow a committed insert, and re-posting a refusal is a retry decision
|
|
261
|
+
// with its own budget, deliberately left out of the change that added the key.
|
|
174
262
|
unackedRef.current = null;
|
|
175
263
|
});
|
|
176
264
|
},
|
|
177
265
|
[target],
|
|
178
266
|
);
|
|
267
|
+
postOnceRef.current = postOnce;
|
|
179
268
|
|
|
180
269
|
/**
|
|
181
270
|
* The abandonment safety net, and the reason forcing feedback does not COST us detractor data.
|
|
@@ -200,14 +289,20 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
200
289
|
postOnce(undelivered);
|
|
201
290
|
return;
|
|
202
291
|
}
|
|
292
|
+
// STILL IN FLIGHT (latch closed, row unanswered) — the state that made this net inert. Neither
|
|
293
|
+
// branch can act from here, so the pending post is asked to recover itself when it settles
|
|
294
|
+
// unsent. Nothing else is scheduled to look at this row again.
|
|
295
|
+
if (postedRef.current && undelivered) {
|
|
296
|
+
recoverOnSettleRef.current = true;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
203
299
|
if (postedRef.current || stars < 1) return;
|
|
204
300
|
postOnce(
|
|
205
|
-
|
|
301
|
+
buildBody({
|
|
206
302
|
stars,
|
|
207
303
|
feedbackText: feedback,
|
|
208
304
|
suggestion,
|
|
209
|
-
|
|
210
|
-
meta: { ...meta, abandoned: true },
|
|
305
|
+
extraMeta: { abandoned: true },
|
|
211
306
|
}),
|
|
212
307
|
);
|
|
213
308
|
};
|
|
@@ -249,10 +344,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
249
344
|
// the user actually left a review — this only ever means we ASKED. Never read it as
|
|
250
345
|
// a review count. We await first so the row carries the route it actually took.
|
|
251
346
|
postOnce(
|
|
252
|
-
|
|
347
|
+
buildBody({
|
|
253
348
|
stars: value,
|
|
254
|
-
|
|
255
|
-
meta: { ...meta, store_route: route },
|
|
349
|
+
extraMeta: { store_route: route },
|
|
256
350
|
}),
|
|
257
351
|
);
|
|
258
352
|
setPhase("thanks");
|
|
@@ -263,7 +357,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
263
357
|
setPhase("feedback");
|
|
264
358
|
}
|
|
265
359
|
},
|
|
266
|
-
[onEvent, id, store, postOnce,
|
|
360
|
+
[onEvent, id, store, postOnce, buildBody, resolveWithDelay],
|
|
267
361
|
);
|
|
268
362
|
|
|
269
363
|
/**
|
|
@@ -284,19 +378,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
284
378
|
if (!feedbackReady) return;
|
|
285
379
|
// Identity is NEVER collected here — the host already sends user_id / device /
|
|
286
380
|
// session via `sessionId` + `meta`. We POST only the rating + the free text.
|
|
287
|
-
const body =
|
|
288
|
-
stars,
|
|
289
|
-
feedbackText: feedback,
|
|
290
|
-
suggestion,
|
|
291
|
-
sessionId,
|
|
292
|
-
meta,
|
|
293
|
-
});
|
|
381
|
+
const body = buildBody({ stars, feedbackText: feedback, suggestion });
|
|
294
382
|
postOnce(body);
|
|
295
383
|
onEvent?.({ name: "review_feedback_submitted", id, stars });
|
|
296
384
|
playHaptic("success");
|
|
297
385
|
setPhase("thanks");
|
|
298
386
|
resolveWithDelay();
|
|
299
|
-
}, [feedbackReady, stars, feedback, suggestion,
|
|
387
|
+
}, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
|
|
300
388
|
|
|
301
389
|
const dismiss = useCallback(() => {
|
|
302
390
|
setPhase("thanks");
|
package/src/reviews/decision.ts
CHANGED
|
@@ -103,7 +103,14 @@ export const evaluateGate = (rules: GateRules, s: GateSignals): ReviewDecision =
|
|
|
103
103
|
export const routeRating = (stars: number): RatingRoute =>
|
|
104
104
|
stars >= 5 ? "store" : "feedback";
|
|
105
105
|
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events.
|
|
108
|
+
*
|
|
109
|
+
* STILL PURE, deliberately: `idempotencyKey` and the `meta.device_key` the server scopes it to are
|
|
110
|
+
* both passed IN rather than resolved here. Reaching into the device-key registry from this function
|
|
111
|
+
* would make the gate logic untestable in isolation, and both values are scoped to an impression
|
|
112
|
+
* this function cannot see — see `reviews/idempotency.ts`.
|
|
113
|
+
*/
|
|
107
114
|
export const buildReviewSubmission = (input: {
|
|
108
115
|
stars: number;
|
|
109
116
|
feedbackText?: string;
|
|
@@ -111,6 +118,8 @@ export const buildReviewSubmission = (input: {
|
|
|
111
118
|
contact?: string;
|
|
112
119
|
sessionId?: string;
|
|
113
120
|
source?: string;
|
|
121
|
+
/** The impression's idempotency key. Omitted → the server mints a row id and inserts, as before. */
|
|
122
|
+
idempotencyKey?: string;
|
|
114
123
|
meta?: Record<string, unknown>;
|
|
115
124
|
}): ReviewSubmission => {
|
|
116
125
|
const body: ReviewSubmission = { stars: input.stars };
|
|
@@ -122,6 +131,7 @@ export const buildReviewSubmission = (input: {
|
|
|
122
131
|
if (contact) body.contact = contact;
|
|
123
132
|
if (input.sessionId) body.session_id = input.sessionId;
|
|
124
133
|
if (input.source) body.source = input.source;
|
|
134
|
+
if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
|
|
125
135
|
if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
|
|
126
136
|
return body;
|
|
127
137
|
};
|