@huloglobal/vendure-plugin-visitor-analytics 0.17.1 → 0.18.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/CHANGELOG.md CHANGED
@@ -5,6 +5,27 @@ documented here. The format follows
5
5
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project
6
6
  adheres to [semantic versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.18.0] — 2026-09-11
9
+
10
+ ### Added
11
+ - **Recovery links can resume an order.** `issueRecoveryLink(cartId, { resumeOrderCode })` (and `POST /ees/abandoned-carts/:id/recovery-link` with the same body) binds the link to the visitor's open Vendure order. `GET /ees/recover-cart` now returns `orderCode`, `orderState` and `resumable`, and the new public `POST /ees/recover-cart/resume?t=…` adds `resumeOrderCode` — set only while that order is still `AddingItems` / `ArrangingPayment`. Storefronts keep re-adding `items` as the universal fallback.
12
+ - **Attribution.** New `abandoned_cart` columns `recoveryStep` (`link_issued` → `link_opened` → `resumed` → `converted`, monotonic), `convertedAt`, `convertedOrderId`, `convertedOrderCode` and `resumeOrderCode`. The storefront reports a recovered checkout with the token-bound public `POST /ees/recover-cart/converted { t, orderCode }`; the scanner's own `checkout_completed` match sets `convertedAt` too. `GET /ees/abandoned-carts/summary` gains an `attribution` block (link issued / opened / resumed / converted counts, value recovered via link, opt-outs) and the list endpoint returns the new columns.
13
+ - **Email opt-out.** New `abandoned_cart_opt_out` table. `GET|POST /ees/abandoned-carts/opt-out?e=<token>` (public; POST is the RFC 8058 one-click form) records an opt-out for the address in the HMAC token. Service API: `isOptedOut(email)` (fails closed), `optOut`, `optIn`, `listOptOuts`, `buildOptOutLink(email)` and `buildListUnsubscribeHeaders(email)` for the `List-Unsubscribe` / `List-Unsubscribe-Post` headers. Admin: `GET /ees/abandoned-carts/opt-outs`, `POST /ees/abandoned-carts/opt-outs/remove { email }`; the detail endpoint reports `optedOut`. New option `abandonment.optOutSecret` (defaults to `recoveryLinkSecret`, then `signingSecret`).
14
+ - **Storefront helper.** `hulo.resumeCart(token)` and `hulo.recoveryConverted(orderCode)` — the helper remembers the token from `restoreCart` / `resumeCart` in `sessionStorage`, so the thank-you page needs one call.
15
+ - Pure helpers exported for hosts: `isResumableOrderState`, `advanceRecoveryStep`, `normaliseEmail`, `hashEmail`, `buildOptOutToken`, `verifyOptOutToken`, `buildOptOutUrl`, `buildListUnsubscribeHeaders`.
16
+
17
+ ### Changed
18
+ - The public recovery endpoints are rate-limited per client IP (`recover-cart` and `resume` 30/min, `converted` and `opt-out` 10/min) and answer with `Cache-Control: no-store`.
19
+ - The new columns and the opt-out table are created at boot with `ADD COLUMN IF NOT EXISTS` / `CREATE TABLE IF NOT EXISTS` (MariaDB and PostgreSQL), so installs that do not run TypeORM migrations for plugins need no manual step. Installs that do can generate a migration as usual — the entity declares the same columns.
20
+
21
+ ## [0.17.2] — 2026-09-10
22
+
23
+ ### Added
24
+ - **Per-channel recovery links.** `abandonment.storefrontBaseUrls` (channel code → storefront origin) makes a cart abandoned on a second storefront link back to that storefront; unlisted channels use `storefrontBaseUrl` as before.
25
+
26
+ ### Fixed
27
+ - README: the storefront restore route must show the basket the way the storefront does (drawer or page) rather than assume a `/cart` route, and must treat an `ErrorResult` from `addItemToOrder` as a failed line. The reference storefront implementation now does both.
28
+
8
29
  ## [0.17.1] — 2026-09-02
9
30
 
10
31
  ### Changed
package/README.md CHANGED
@@ -64,6 +64,9 @@ export const config: VendureConfig = {
64
64
  recoveryLinkSecret: process.env.HULO_ABANDONMENT_SECRET,
65
65
  recoveryLinkTtlHours: 72,
66
66
  storefrontBaseUrl: 'https://shop.example.com',
67
+ // Opt-out link signing (0.18.0). Falls back to
68
+ // recoveryLinkSecret, then signingSecret.
69
+ optOutSecret: process.env.HULO_ABANDONMENT_OPTOUT_SECRET,
67
70
  },
68
71
  }),
69
72
  ],
@@ -101,6 +104,8 @@ server-side scanners look those event types up by name.
101
104
  | `hulo.checkoutCompleted(orderCode, totalMinor)` | on the thank-you page | closes any open `abandoned_cart` row for this session |
102
105
  | `hulo.rageClick(selector)` / `hulo.deadClick(selector)` | fire yourself if you have a better signal than the auto-detector | rage-click / dead-click hot-spot lists |
103
106
  | `hulo.restoreCart(token)` | on your `/cart/restore?t=...` route | rebuild a cart from a signed recovery link |
107
+ | `hulo.resumeCart(token)` | same route, when you can resume the visitor's open order | as above, plus `resumeOrderCode` while the bound order is still open (0.18.0) |
108
+ | `hulo.recoveryConverted(orderCode)` | on the thank-you page | attributes the order to the recovery link the visitor arrived through (0.18.0) |
104
109
 
105
110
  Full payload shapes:
106
111
 
@@ -130,9 +135,26 @@ needs a route that:
130
135
 
131
136
  1. Reads `?t=` from the URL
132
137
  2. Calls `GET /ees/recover-cart?t=<token>` to fetch `{ items: [...] }`
138
+ (or `POST /ees/recover-cart/resume?t=<token>` — see below)
133
139
  3. Re-adds each `{ variantId, qty }` via your Vendure order API (usually
134
- `addItemToOrder(productVariantId, quantity)`)
135
- 4. Navigates to `/cart` when done
140
+ `addItemToOrder(productVariantId, quantity)`) — check the result is an
141
+ `Order`, not an `ErrorResult` (out of stock, purchase limit…)
142
+ 4. Shows the basket when done — open your cart drawer or navigate to your
143
+ cart page, whichever your storefront has (don't assume a `/cart` route)
144
+ 5. On the thank-you page, calls `hulo.recoveryConverted(order.code)` (or
145
+ `POST /ees/recover-cart/converted { t, orderCode }`) so the cart is
146
+ attributed to the link — the helper remembers `t` in `sessionStorage`
147
+ from step 2, so this is a no-op for visitors who did not arrive through
148
+ a recovery link
149
+
150
+ **Resuming the visitor's order (0.18.0).** When the host mints the link
151
+ with `issueRecoveryLink(id, { resumeOrderCode })`, both recovery endpoints
152
+ return `orderCode`, `orderState` and `resumable`, and the `resume` endpoint
153
+ adds `resumeOrderCode` — non-null only while that order is still in
154
+ `AddingItems` / `ArrangingPayment`. The Shop API cannot adopt an order
155
+ anonymously, so treat it as a hint: a signed-in owner already has it as
156
+ their active order (skip the re-add), a guest gets the items re-added as
157
+ usual. Either way the `items` array is always present as the fallback.
136
158
 
137
159
  Guard against silently overwriting a live cart — if the visitor
138
160
  already has items, show a "you already have items in your cart"
@@ -232,9 +254,58 @@ The token is time-bounded (`recoveryLinkTtlHours`, default 72) and
232
254
  non-reusable. The storefront exchanges it via
233
255
  `GET /ees/recover-cart?t=<token>` to get back the persisted item list.
234
256
 
257
+ Multi-storefront installs: set `abandonment.storefrontBaseUrls` to a map of
258
+ channel code → storefront origin (for example
259
+ `{ licensedock: 'https://license-dock.com' }`) and each cart's link points
260
+ at the storefront it was abandoned on; channels not listed fall back to
261
+ `storefrontBaseUrl`.
262
+
235
263
  Set `abandonment.recoveryLinkSecret` in plugin options to enable this —
236
264
  without it, the endpoint returns `{ error: 'recovery-disabled-or-not-found' }`.
237
265
 
266
+ **Attribution (0.18.0).**
267
+ Every `abandoned_cart` row carries `recoveryStep` — `link_issued` →
268
+ `link_opened` → `resumed` → `converted`, never moving backwards — plus
269
+ `convertedAt`, `convertedOrderId`, `convertedOrderCode` and
270
+ `resumeOrderCode`. Steps advance as the link is minted, exchanged
271
+ (`recover-cart`), resumed (`recover-cart/resume`) and finally reported
272
+ converted by the storefront (`POST /ees/recover-cart/converted
273
+ { t, orderCode }` — token-bound, the order must exist and be past
274
+ `AddingItems`). The scanner's own `checkout_completed` match still marks
275
+ rows `converted` and stamps `convertedAt`, but leaves `recoveryStep`
276
+ alone — so "converted via link" is exactly `recoveryStep = 'converted'`.
277
+ `GET /ees/abandoned-carts/summary` returns an `attribution` block:
278
+
279
+ ```json
280
+ { "linkIssued": 120, "linkOpened": 41, "resumed": 9, "convertedViaLink": 14,
281
+ "convertedViaLinkValueMinor": 184950, "convertedTotal": 37, "optOuts": 3 }
282
+ ```
283
+
284
+ **Email opt-out (0.18.0).**
285
+ Recovery emails must carry an unsubscribe link. Build it with
286
+ `abandonedCartService.buildOptOutLink(email)` and add the headers from
287
+ `abandonedCartService.buildListUnsubscribeHeaders(email)`
288
+ (`List-Unsubscribe` + `List-Unsubscribe-Post: List-Unsubscribe=One-Click`)
289
+ so Gmail / Outlook / Yahoo show their native "Unsubscribe" button. Both
290
+ point at `GET|POST /ees/abandoned-carts/opt-out?e=<token>` on the Vendure
291
+ server: GET renders a small confirmation page, POST is the RFC 8058
292
+ one-click form. The token is `base64url(email).hmac(email)` signed with
293
+ `abandonment.optOutSecret` (default: `recoveryLinkSecret`, then
294
+ `signingSecret`) — nobody can unsubscribe someone else.
295
+
296
+ Before every send call `await abandonedCartService.isOptedOut(email)`;
297
+ it fails closed (a DB error counts as opted out). Opt-outs live in
298
+ `abandoned_cart_opt_out` (keyed by the SHA-256 of the lower-cased
299
+ address). Admin: `GET /ees/abandoned-carts/opt-outs`,
300
+ `POST /ees/abandoned-carts/opt-outs/remove { email }` after an explicit
301
+ customer request; `GET /ees/abandoned-carts/:id` reports `optedOut`.
302
+
303
+ **Schema.** The 0.18.0 columns and the opt-out table are added at boot
304
+ with `ADD COLUMN IF NOT EXISTS` / `CREATE TABLE IF NOT EXISTS` (MariaDB
305
+ and PostgreSQL). Installs that run TypeORM migrations for plugins can
306
+ generate one as usual — the `AbandonedCart` entity declares the same
307
+ columns, so the generator finds nothing to add once the plugin has booted.
308
+
238
309
  **Slack notification.**
239
310
  `abandonment.slackWebhookUrl` + `abandonment.slackMinValueMinor`
240
311
  control an at-most-once Slack post per abandonment above the value
@@ -315,7 +386,10 @@ storefront origin):
315
386
  | --- | --- | --- |
316
387
  | `POST` | `/ees/track` | ingest a batch of visitor events |
317
388
  | `GET` | `/ees/hulo.js` | typed storefront helper JS (since 0.8.1) |
318
- | `GET` | `/ees/recover-cart?t=<token>` | resolve a recovery token → items |
389
+ | `GET` | `/ees/recover-cart?t=<token>` | resolve a recovery token → items (+ `orderCode`, `orderState`, `resumable` since 0.18.0); 30/min per IP |
390
+ | `POST` | `/ees/recover-cart/resume?t=<token>` | as above plus `resumeOrderCode` while the bound order is open (0.18.0); 30/min per IP |
391
+ | `POST` | `/ees/recover-cart/converted` | `{ t, orderCode }` — attribute a placed order to its recovery link (0.18.0); 10/min per IP |
392
+ | `GET`/`POST` | `/ees/abandoned-carts/opt-out?e=<token>` | email opt-out; POST is RFC 8058 one-click (0.18.0); 10/min per IP |
319
393
  | `GET` | `/ees/recommendations/also-viewed?productId=…` | co-view recs |
320
394
  | `GET` | `/ees/recommendations/personal?visitorId=…` | personalised recs |
321
395
  | `GET` | `/ees/recommendations/trending?hours=…` | most-viewed products |
@@ -344,7 +418,9 @@ Vendure admin session cookie):
344
418
  | `GET` | `/ees/abandoned-carts` | paginated list w/ filters (0.8.0) |
345
419
  | `GET` | `/ees/abandoned-carts/summary` | totals + recovery rate (0.8.0) |
346
420
  | `GET` | `/ees/abandoned-carts/:id` | detail incl. parsed items (0.8.0) |
347
- | `POST` | `/ees/abandoned-carts/:id/recovery-link` | mint signed URL (0.8.0, `UpdateCustomer`) |
421
+ | `POST` | `/ees/abandoned-carts/:id/recovery-link` | mint signed URL (0.8.0, `UpdateCustomer`); body `{ resumeOrderCode? }` binds it to an order (0.18.0) |
422
+ | `GET` | `/ees/abandoned-carts/opt-outs` | opted-out addresses (0.18.0) |
423
+ | `POST` | `/ees/abandoned-carts/opt-outs/remove` | `{ email }` — re-enable after an explicit request (0.18.0, `UpdateCustomer`) |
348
424
  | `POST` | `/ees/abandoned-carts/:id/status` | mark recovered/dismissed (0.8.0, `UpdateCustomer`) |
349
425
  | `GET` | `/ees/abandoned-carts/export.csv` | CSV export (0.8.0) |
350
426
  | `GET` | `/ees/recommendations/aggregate-now` | force co-view sweep (0.8.0, `SuperAdmin`) |
@@ -1,3 +1,4 @@
1
+ import { OnApplicationBootstrap } from '@nestjs/common';
1
2
  import { RequestContext } from '@vendure/core';
2
3
  import { Request, Response } from 'express';
3
4
  import { AbandonedCartService } from './abandoned-cart.service';
@@ -9,15 +10,26 @@ import { AbandonedCartService } from './abandoned-cart.service';
9
10
  * GET /ees/abandoned-carts/summary — totals + top-value + recovery rate
10
11
  * GET /ees/abandoned-carts/:id — detail incl. parsed items
11
12
  * POST /ees/abandoned-carts/:id/recovery-link — mint/reissue signed URL
13
+ * (body `{ resumeOrderCode? }` binds it to an order, 0.18.0)
12
14
  * POST /ees/abandoned-carts/:id/status — mark recovered/dismissed
13
15
  * GET /ees/abandoned-carts/export.csv — CSV export
16
+ * GET /ees/abandoned-carts/opt-outs — opted-out addresses (0.18.0)
17
+ * POST /ees/abandoned-carts/opt-outs/remove — `{ email }` re-enable (0.18.0)
14
18
  *
15
- * Storefront-side (unauthenticated):
16
- * GET /ees/recover-cart?t=... — resolve a token → items
19
+ * Storefront-side (unauthenticated, rate-limited per IP):
20
+ * GET /ees/recover-cart?t=... — resolve a token → items (+ orderCode / resumable, 0.18.0)
21
+ * POST /ees/recover-cart/resume?t=... — same, plus `resumeOrderCode` while the bound order is open (0.18.0)
22
+ * POST /ees/recover-cart/converted — `{ t, orderCode }` attribution when the restored cart checks out (0.18.0)
23
+ * GET|POST /ees/abandoned-carts/opt-out?e= — email opt-out (RFC 8058 one-click on POST) (0.18.0)
17
24
  */
18
- export declare class AbandonedCartController {
25
+ export declare class AbandonedCartController implements OnApplicationBootstrap {
19
26
  private readonly service;
27
+ private limiter;
20
28
  constructor(service: AbandonedCartService);
29
+ onApplicationBootstrap(): void;
30
+ /** True (and 429 already written) when the caller is over budget.
31
+ * `cost` = 60 / per-minute allowance, so cost 2 → 30/min, cost 6 → 10/min. */
32
+ private rateLimited;
21
33
  list(ctx: RequestContext, takeRaw?: string, skipRaw?: string, status?: string, minValueRaw?: string, email?: string): Promise<{
22
34
  items: any;
23
35
  total: number;
@@ -28,6 +40,15 @@ export declare class AbandonedCartController {
28
40
  private buildItemsPreview;
29
41
  summary(ctx: RequestContext, daysRaw?: string): Promise<{
30
42
  windowDays: number;
43
+ attribution: {
44
+ linkIssued: number;
45
+ linkOpened: number;
46
+ resumed: number;
47
+ convertedViaLink: number;
48
+ convertedViaLinkValueMinor: number;
49
+ convertedTotal: number;
50
+ optOuts: number;
51
+ };
31
52
  total: number;
32
53
  openCount: number;
33
54
  recoveredCount: number;
@@ -40,6 +61,32 @@ export declare class AbandonedCartController {
40
61
  avgValueMinor: number;
41
62
  }>;
42
63
  exportCsv(ctx: RequestContext, res: Response, daysRaw?: string): Promise<void>;
64
+ /**
65
+ * Email opt-out. Public, token-bound (`e=` is an HMAC of the address —
66
+ * see `buildOptOutToken`). GET renders a tiny confirmation page for
67
+ * humans clicking the footer link; POST is the RFC 8058 one-click form
68
+ * mail clients send when the user hits their native "Unsubscribe".
69
+ * Both are idempotent.
70
+ */
71
+ optOutGet(req: Request, res: Response, tokenRaw?: string): Promise<void>;
72
+ optOutPost(req: Request, res: Response, tokenRaw?: string, body?: any): Promise<void>;
73
+ private applyOptOut;
74
+ private optOutPage;
75
+ listOptOuts(ctx: RequestContext, takeRaw?: string, skipRaw?: string): Promise<{
76
+ items: any[];
77
+ total: number;
78
+ take: number;
79
+ skip: number;
80
+ }>;
81
+ removeOptOut(ctx: RequestContext, body: {
82
+ email?: string;
83
+ }): Promise<{
84
+ ok: boolean;
85
+ error?: undefined;
86
+ } | {
87
+ error: string;
88
+ ok?: undefined;
89
+ }>;
43
90
  detail(ctx: RequestContext, idRaw: string): Promise<any>;
44
91
  /**
45
92
  * Fill in missing `name` (and, when possible, `productId`) on each
@@ -50,7 +97,9 @@ export declare class AbandonedCartController {
50
97
  * cart was abandoned).
51
98
  */
52
99
  private enrichItemsWithNames;
53
- issueRecoveryLink(ctx: RequestContext, idRaw: string): Promise<{
100
+ issueRecoveryLink(ctx: RequestContext, idRaw: string, body?: {
101
+ resumeOrderCode?: string | null;
102
+ }): Promise<{
54
103
  error: string;
55
104
  hint: string;
56
105
  ok?: undefined;
@@ -75,15 +124,22 @@ export declare class AbandonedCartController {
75
124
  * of cart items the storefront can restore. Rate-limited by the
76
125
  * plugin's usual ingest limiter (same origin as the tracker).
77
126
  */
78
- recover(req: Request, token?: string): Promise<{
79
- error: string;
80
- } | {
81
- id: number;
82
- currency: string;
83
- items: any[];
84
- email: string | null;
85
- ok: boolean;
86
- error?: undefined;
87
- }>;
127
+ recover(req: Request, res: Response, token?: string): Promise<void>;
128
+ /**
129
+ * Resume the exact order the link was bound to. Same payload as
130
+ * `recover-cart` plus `resumeOrderCode` — non-null only while that
131
+ * order is still `AddingItems` / `ArrangingPayment`. The storefront
132
+ * cannot adopt an order anonymously through the Shop API, so it
133
+ * should treat `resumeOrderCode` as a hint (e.g. sign-in prompt for
134
+ * the owner, or "your order S2BZ… is waiting") and fall back to
135
+ * re-adding `items` — which always works.
136
+ */
137
+ resume(req: Request, res: Response, tokenQ?: string, body?: any): Promise<void>;
138
+ /**
139
+ * Attribution — the restored cart checked out. Token-bound, so a
140
+ * stranger cannot mark carts converted; the order must exist and be
141
+ * past `AddingItems`. Idempotent.
142
+ */
143
+ converted(req: Request, res: Response, tokenQ?: string, body?: any): Promise<void>;
88
144
  }
89
145
  //# sourceMappingURL=abandoned-cart.controller.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"abandoned-cart.controller.d.ts","sourceRoot":"","sources":["../src/abandoned-cart.controller.ts"],"names":[],"mappings":"AACA,OAAO,EAAO,cAAc,EAAqB,MAAM,eAAe,CAAC;AACvE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAC5C,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAEhE;;;;;;;;;;;;;GAaG;AACH,qBACa,uBAAuB;IACpB,OAAO,CAAC,QAAQ,CAAC,OAAO;gBAAP,OAAO,EAAE,oBAAoB;IAIpD,IAAI,CACC,GAAG,EAAE,cAAc,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACb,WAAW,CAAC,EAAE,MAAM,EACvB,KAAK,CAAC,EAAE,MAAM;;;;;;IA6DlC,uDAAuD;IACvD,OAAO,CAAC,iBAAiB;IAiBnB,OAAO,CAAQ,GAAG,EAAE,cAAc,EAAiB,OAAO,CAAC,EAAE,MAAM;;;;;;;;;;;;;IAuCnE,SAAS,CAAQ,GAAG,EAAE,cAAc,EAAS,GAAG,EAAE,QAAQ,EAAiB,OAAO,CAAC,EAAE,MAAM;IA+B3F,MAAM,CAAQ,GAAG,EAAE,cAAc,EAAe,KAAK,EAAE,MAAM;IAwBnE;;;;;;;OAOG;YACW,oBAAoB;IAyE5B,iBAAiB,CAAQ,GAAG,EAAE,cAAc,EAAe,KAAK,EAAE,MAAM;;;;;;;;;;;IASxE,SAAS,CACJ,GAAG,EAAE,cAAc,EACb,KAAK,EAAE,MAAM,EAClB,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;;;;;IAWrC;;;;OAIG;IAEG,OAAO,CAAQ,GAAG,EAAE,OAAO,EAAc,KAAK,CAAC,EAAE,MAAM;;;;;;;;;;CAOhE"}
1
+ {"version":3,"file":"abandoned-cart.controller.d.ts","sourceRoot":"","sources":["../src/abandoned-cart.controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAuD,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAC7G,OAAO,EAAO,cAAc,EAAqB,MAAM,eAAe,CAAC;AACvE,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAE5C,OAAO,EAAE,oBAAoB,EAAE,MAAM,0BAA0B,CAAC;AAIhE;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBACa,uBAAwB,YAAW,sBAAsB;IAGtD,OAAO,CAAC,QAAQ,CAAC,OAAO;IAFpC,OAAO,CAAC,OAAO,CAA4B;gBAEd,OAAO,EAAE,oBAAoB;IAE1D,sBAAsB,IAAI,IAAI;IAO9B;mFAC+E;IAC/E,OAAO,CAAC,WAAW;IAab,IAAI,CACC,GAAG,EAAE,cAAc,EACX,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,MAAM,EACd,MAAM,CAAC,EAAE,MAAM,EACb,WAAW,CAAC,EAAE,MAAM,EACvB,KAAK,CAAC,EAAE,MAAM;;;;;;IA8DlC,uDAAuD;IACvD,OAAO,CAAC,iBAAiB;IAiBnB,OAAO,CAAQ,GAAG,EAAE,cAAc,EAAiB,OAAO,CAAC,EAAE,MAAM;;;;;;;;;;;;;;;;;;;;;;IAyCnE,SAAS,CAAQ,GAAG,EAAE,cAAc,EAAS,GAAG,EAAE,QAAQ,EAAiB,OAAO,CAAC,EAAE,MAAM;IA6BjG;;;;;;OAMG;IAEG,SAAS,CAAQ,GAAG,EAAE,OAAO,EAAS,GAAG,EAAE,QAAQ,EAAc,QAAQ,CAAC,EAAE,MAAM;IASlF,UAAU,CAAQ,GAAG,EAAE,OAAO,EAAS,GAAG,EAAE,QAAQ,EAAc,QAAQ,CAAC,EAAE,MAAM,EAAU,IAAI,CAAC,EAAE,GAAG;YAQ/F,WAAW;IASzB,OAAO,CAAC,UAAU;IAcZ,WAAW,CAAQ,GAAG,EAAE,cAAc,EAAiB,OAAO,CAAC,EAAE,MAAM,EAAiB,OAAO,CAAC,EAAE,MAAM;;;;;;IASxG,YAAY,CAAQ,GAAG,EAAE,cAAc,EAAU,IAAI,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE;;;;;;;IAOzE,MAAM,CAAQ,GAAG,EAAE,cAAc,EAAe,KAAK,EAAE,MAAM;IA0BnE;;;;;;;OAOG;YACW,oBAAoB;IAyE5B,iBAAiB,CACZ,GAAG,EAAE,cAAc,EACb,KAAK,EAAE,MAAM,EAClB,IAAI,CAAC,EAAE;QAAE,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE;;;;;;;;;;;IAahD,SAAS,CACJ,GAAG,EAAE,cAAc,EACb,KAAK,EAAE,MAAM,EAClB,IAAI,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE;;;;;;;IAWrC;;;;OAIG;IAEG,OAAO,CAAQ,GAAG,EAAE,OAAO,EAAS,GAAG,EAAE,QAAQ,EAAc,KAAK,CAAC,EAAE,MAAM;IAUnF;;;;;;;;OAQG;IAEG,MAAM,CAAQ,GAAG,EAAE,OAAO,EAAS,GAAG,EAAE,QAAQ,EAAc,MAAM,CAAC,EAAE,MAAM,EAAU,IAAI,CAAC,EAAE,GAAG;IAUvG;;;;OAIG;IAEG,SAAS,CAAQ,GAAG,EAAE,OAAO,EAAS,GAAG,EAAE,QAAQ,EAAc,MAAM,CAAC,EAAE,MAAM,EAAU,IAAI,CAAC,EAAE,GAAG;CAS7G"}
@@ -15,7 +15,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.AbandonedCartController = void 0;
16
16
  const common_1 = require("@nestjs/common");
17
17
  const core_1 = require("@vendure/core");
18
+ const vendure_licence_sdk_1 = require("@huloglobal/vendure-licence-sdk");
18
19
  const abandoned_cart_service_1 = require("./abandoned-cart.service");
20
+ const proxy_headers_1 = require("./proxy-headers");
21
+ const recovery_tokens_1 = require("./recovery-tokens");
19
22
  /**
20
23
  * Admin API for the Abandoned Cart feature.
21
24
  *
@@ -24,15 +27,41 @@ const abandoned_cart_service_1 = require("./abandoned-cart.service");
24
27
  * GET /ees/abandoned-carts/summary — totals + top-value + recovery rate
25
28
  * GET /ees/abandoned-carts/:id — detail incl. parsed items
26
29
  * POST /ees/abandoned-carts/:id/recovery-link — mint/reissue signed URL
30
+ * (body `{ resumeOrderCode? }` binds it to an order, 0.18.0)
27
31
  * POST /ees/abandoned-carts/:id/status — mark recovered/dismissed
28
32
  * GET /ees/abandoned-carts/export.csv — CSV export
33
+ * GET /ees/abandoned-carts/opt-outs — opted-out addresses (0.18.0)
34
+ * POST /ees/abandoned-carts/opt-outs/remove — `{ email }` re-enable (0.18.0)
29
35
  *
30
- * Storefront-side (unauthenticated):
31
- * GET /ees/recover-cart?t=... — resolve a token → items
36
+ * Storefront-side (unauthenticated, rate-limited per IP):
37
+ * GET /ees/recover-cart?t=... — resolve a token → items (+ orderCode / resumable, 0.18.0)
38
+ * POST /ees/recover-cart/resume?t=... — same, plus `resumeOrderCode` while the bound order is open (0.18.0)
39
+ * POST /ees/recover-cart/converted — `{ t, orderCode }` attribution when the restored cart checks out (0.18.0)
40
+ * GET|POST /ees/abandoned-carts/opt-out?e= — email opt-out (RFC 8058 one-click on POST) (0.18.0)
32
41
  */
33
42
  let AbandonedCartController = class AbandonedCartController {
34
43
  constructor(service) {
35
44
  this.service = service;
45
+ this.limiter = null;
46
+ }
47
+ onApplicationBootstrap() {
48
+ // One shared bucket map; the bucket name is part of the key so each
49
+ // endpoint gets its own allowance. Capacity is the most generous of
50
+ // the per-endpoint limits — `cost` scales the others down.
51
+ this.limiter = new vendure_licence_sdk_1.RateLimiter({ capacity: 60, windowMs: 60000 });
52
+ }
53
+ /** True (and 429 already written) when the caller is over budget.
54
+ * `cost` = 60 / per-minute allowance, so cost 2 → 30/min, cost 6 → 10/min. */
55
+ rateLimited(req, res, bucket, cost) {
56
+ const ip = (0, proxy_headers_1.getRealIp)(req) || '';
57
+ if (!ip || !this.limiter)
58
+ return false;
59
+ if (!this.limiter.allow(`${bucket}|${ip}`, cost)) {
60
+ res.setHeader('Retry-After', '60');
61
+ res.status(429).json({ error: 'rate-limited' });
62
+ return true;
63
+ }
64
+ return false;
36
65
  }
37
66
  async list(ctx, takeRaw, skipRaw, status, minValueRaw, email) {
38
67
  var _a;
@@ -65,6 +94,7 @@ let AbandonedCartController = class AbandonedCartController {
65
94
  // so the payload growth stays proportional to the page size.
66
95
  `SELECT id, sessionId, visitorId, customerId, currency, totalMinor, itemCount,
67
96
  itemsJson, email, status, abandonedAt, recoveredAt, notificationSent,
97
+ resumeOrderCode, recoveryStep, convertedAt, convertedOrderId, convertedOrderCode,
68
98
  utmSource, utmMedium, utmCampaign, countryCode, regionCode, city,
69
99
  ip, ipHash, userAgent, browser, deviceType,
70
100
  landingUrl, lastKnownUrl, lastKnownReferrer,
@@ -135,8 +165,10 @@ let AbandonedCartController = class AbandonedCartController {
135
165
  const s = (rows === null || rows === void 0 ? void 0 : rows[0]) || {};
136
166
  const total = Number(s.total || 0);
137
167
  const rec = Number(s.recoveredCount || 0) + Number(s.convertedCount || 0);
168
+ const attribution = await this.service.attributionSummary(since);
138
169
  return {
139
170
  windowDays: days,
171
+ attribution,
140
172
  total,
141
173
  openCount: Number(s.openCount || 0),
142
174
  recoveredCount: Number(s.recoveredCount || 0),
@@ -173,6 +205,60 @@ let AbandonedCartController = class AbandonedCartController {
173
205
  }
174
206
  res.end();
175
207
  }
208
+ /**
209
+ * Email opt-out. Public, token-bound (`e=` is an HMAC of the address —
210
+ * see `buildOptOutToken`). GET renders a tiny confirmation page for
211
+ * humans clicking the footer link; POST is the RFC 8058 one-click form
212
+ * mail clients send when the user hits their native "Unsubscribe".
213
+ * Both are idempotent.
214
+ */
215
+ async optOutGet(req, res, tokenRaw) {
216
+ if (this.rateLimited(req, res, 'opt-out', 6))
217
+ return;
218
+ const result = await this.applyOptOut(req, tokenRaw);
219
+ res.setHeader('cache-control', 'no-store');
220
+ res.setHeader('content-type', 'text/html; charset=utf-8');
221
+ res.status(result.ok ? 200 : 400).send(this.optOutPage(result.ok));
222
+ }
223
+ async optOutPost(req, res, tokenRaw, body) {
224
+ if (this.rateLimited(req, res, 'opt-out', 6))
225
+ return;
226
+ const token = tokenRaw || (body === null || body === void 0 ? void 0 : body.e) || (typeof body === 'string' ? '' : undefined);
227
+ const result = await this.applyOptOut(req, token);
228
+ res.setHeader('cache-control', 'no-store');
229
+ res.status(result.ok ? 200 : 400).json(result);
230
+ }
231
+ async applyOptOut(req, tokenRaw) {
232
+ const secret = this.service.getOptOutSecret();
233
+ if (!secret)
234
+ return { ok: false, error: 'opt-out-disabled' };
235
+ const email = (0, recovery_tokens_1.verifyOptOutToken)(tokenRaw, secret);
236
+ if (!email)
237
+ return { ok: false, error: 'invalid-token' };
238
+ await this.service.optOut(email, { source: 'link', ip: (0, proxy_headers_1.getRealIp)(req) });
239
+ return { ok: true };
240
+ }
241
+ optOutPage(ok) {
242
+ const title = ok ? 'You have been unsubscribed' : 'This link is not valid';
243
+ const body = ok
244
+ ? 'We will not send you any more reminders about items left in your basket. You can close this page.'
245
+ : 'The unsubscribe link is incomplete or has been altered. Please use the link exactly as it appears in the email.';
246
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">`
247
+ + `<meta name="robots" content="noindex"><title>${title}</title>`
248
+ + `<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f6f7f9;color:#1b1f24;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}`
249
+ + `main{background:#fff;border:1px solid #e3e6ea;border-radius:12px;padding:32px 36px;max-width:440px;box-shadow:0 2px 12px rgba(0,0,0,.05)}h1{font-size:20px;margin:0 0 12px}p{margin:0;line-height:1.5;color:#4b5563}</style>`
250
+ + `</head><body><main><h1>${title}</h1><p>${body}</p></main></body></html>`;
251
+ }
252
+ async listOptOuts(ctx, takeRaw, skipRaw) {
253
+ const take = Math.min(Math.max(1, parseInt(takeRaw || '50', 10) || 50), 500);
254
+ const skip = Math.max(0, parseInt(skipRaw || '0', 10) || 0);
255
+ const { items, total } = await this.service.listOptOuts(take, skip);
256
+ return { items, total, take, skip };
257
+ }
258
+ async removeOptOut(ctx, body) {
259
+ const ok = await this.service.optIn(String((body === null || body === void 0 ? void 0 : body.email) || ''));
260
+ return ok ? { ok: true } : { error: 'not-found' };
261
+ }
176
262
  async detail(ctx, idRaw) {
177
263
  const id = parseInt(idRaw, 10);
178
264
  const conn = this.service.connection.rawConnection;
@@ -191,10 +277,12 @@ let AbandonedCartController = class AbandonedCartController {
191
277
  // before the product had a translation, or third-party
192
278
  // integrations that fire cart_snapshot without a name field.
193
279
  const enrichedItems = await this.enrichItemsWithNames(items);
280
+ const optedOut = r.email ? await this.service.isOptedOut(r.email) : false;
194
281
  return {
195
282
  ...r,
196
283
  items: enrichedItems,
197
284
  itemsPreview: this.buildItemsPreview(enrichedItems),
285
+ optedOut,
198
286
  };
199
287
  }
200
288
  /**
@@ -279,9 +367,12 @@ let AbandonedCartController = class AbandonedCartController {
279
367
  return it;
280
368
  });
281
369
  }
282
- async issueRecoveryLink(ctx, idRaw) {
370
+ async issueRecoveryLink(ctx, idRaw, body) {
283
371
  const id = parseInt(idRaw, 10);
284
- const url = await this.service.issueRecoveryLink(id);
372
+ const options = body && body.resumeOrderCode !== undefined
373
+ ? { resumeOrderCode: body.resumeOrderCode === null ? null : (0, recovery_tokens_1.sanitiseOrderCode)(body.resumeOrderCode) || null }
374
+ : {};
375
+ const url = await this.service.issueRecoveryLink(id, options);
285
376
  if (!url)
286
377
  return { error: 'recovery-disabled-or-not-found', hint: 'Set abandonment.recoveryLinkSecret in plugin options' };
287
378
  return { ok: true, url };
@@ -300,14 +391,64 @@ let AbandonedCartController = class AbandonedCartController {
300
391
  * of cart items the storefront can restore. Rate-limited by the
301
392
  * plugin's usual ingest limiter (same origin as the tracker).
302
393
  */
303
- async recover(req, token) {
394
+ async recover(req, res, token) {
395
+ if (this.rateLimited(req, res, 'recover', 2))
396
+ return;
397
+ res.setHeader('cache-control', 'no-store');
304
398
  const t = String(token || '').trim();
305
- if (!t)
306
- return { error: 'missing-token' };
399
+ if (!t) {
400
+ res.status(400).json({ error: 'missing-token' });
401
+ return;
402
+ }
307
403
  const result = await this.service.findByRecoveryToken(t);
308
- if (!result)
309
- return { error: 'expired-or-invalid' };
310
- return { ok: true, ...result };
404
+ if (!result) {
405
+ res.json({ error: 'expired-or-invalid' });
406
+ return;
407
+ }
408
+ res.json({ ok: true, ...result });
409
+ }
410
+ /**
411
+ * Resume the exact order the link was bound to. Same payload as
412
+ * `recover-cart` plus `resumeOrderCode` — non-null only while that
413
+ * order is still `AddingItems` / `ArrangingPayment`. The storefront
414
+ * cannot adopt an order anonymously through the Shop API, so it
415
+ * should treat `resumeOrderCode` as a hint (e.g. sign-in prompt for
416
+ * the owner, or "your order S2BZ… is waiting") and fall back to
417
+ * re-adding `items` — which always works.
418
+ */
419
+ async resume(req, res, tokenQ, body) {
420
+ if (this.rateLimited(req, res, 'recover', 2))
421
+ return;
422
+ res.setHeader('cache-control', 'no-store');
423
+ const t = String(tokenQ || (body === null || body === void 0 ? void 0 : body.t) || '').trim();
424
+ if (!t) {
425
+ res.status(400).json({ error: 'missing-token' });
426
+ return;
427
+ }
428
+ const result = await this.service.resumeByRecoveryToken(t);
429
+ if (!result) {
430
+ res.json({ error: 'expired-or-invalid' });
431
+ return;
432
+ }
433
+ res.json({ ok: true, ...result });
434
+ }
435
+ /**
436
+ * Attribution — the restored cart checked out. Token-bound, so a
437
+ * stranger cannot mark carts converted; the order must exist and be
438
+ * past `AddingItems`. Idempotent.
439
+ */
440
+ async converted(req, res, tokenQ, body) {
441
+ if (this.rateLimited(req, res, 'converted', 6))
442
+ return;
443
+ res.setHeader('cache-control', 'no-store');
444
+ const t = String(tokenQ || (body === null || body === void 0 ? void 0 : body.t) || '').trim();
445
+ const orderCode = (0, recovery_tokens_1.sanitiseOrderCode)(body === null || body === void 0 ? void 0 : body.orderCode);
446
+ if (!t || !orderCode) {
447
+ res.status(400).json({ error: 'missing-token-or-order-code' });
448
+ return;
449
+ }
450
+ const result = await this.service.markConvertedByToken(t, orderCode);
451
+ res.status(result.ok ? 200 : 400).json(result);
311
452
  }
312
453
  };
313
454
  exports.AbandonedCartController = AbandonedCartController;
@@ -343,6 +484,44 @@ __decorate([
343
484
  __metadata("design:paramtypes", [core_1.RequestContext, Object, String]),
344
485
  __metadata("design:returntype", Promise)
345
486
  ], AbandonedCartController.prototype, "exportCsv", null);
487
+ __decorate([
488
+ (0, common_1.Get)('abandoned-carts/opt-out'),
489
+ __param(0, (0, common_1.Req)()),
490
+ __param(1, (0, common_1.Res)()),
491
+ __param(2, (0, common_1.Query)('e')),
492
+ __metadata("design:type", Function),
493
+ __metadata("design:paramtypes", [Object, Object, String]),
494
+ __metadata("design:returntype", Promise)
495
+ ], AbandonedCartController.prototype, "optOutGet", null);
496
+ __decorate([
497
+ (0, common_1.Post)('abandoned-carts/opt-out'),
498
+ __param(0, (0, common_1.Req)()),
499
+ __param(1, (0, common_1.Res)()),
500
+ __param(2, (0, common_1.Query)('e')),
501
+ __param(3, (0, common_1.Body)()),
502
+ __metadata("design:type", Function),
503
+ __metadata("design:paramtypes", [Object, Object, String, Object]),
504
+ __metadata("design:returntype", Promise)
505
+ ], AbandonedCartController.prototype, "optOutPost", null);
506
+ __decorate([
507
+ (0, common_1.Get)('abandoned-carts/opt-outs'),
508
+ (0, core_1.Allow)(core_1.Permission.ReadCustomer),
509
+ __param(0, (0, core_1.Ctx)()),
510
+ __param(1, (0, common_1.Query)('take')),
511
+ __param(2, (0, common_1.Query)('skip')),
512
+ __metadata("design:type", Function),
513
+ __metadata("design:paramtypes", [core_1.RequestContext, String, String]),
514
+ __metadata("design:returntype", Promise)
515
+ ], AbandonedCartController.prototype, "listOptOuts", null);
516
+ __decorate([
517
+ (0, common_1.Post)('abandoned-carts/opt-outs/remove'),
518
+ (0, core_1.Allow)(core_1.Permission.UpdateCustomer),
519
+ __param(0, (0, core_1.Ctx)()),
520
+ __param(1, (0, common_1.Body)()),
521
+ __metadata("design:type", Function),
522
+ __metadata("design:paramtypes", [core_1.RequestContext, Object]),
523
+ __metadata("design:returntype", Promise)
524
+ ], AbandonedCartController.prototype, "removeOptOut", null);
346
525
  __decorate([
347
526
  (0, common_1.Get)('abandoned-carts/:id'),
348
527
  (0, core_1.Allow)(core_1.Permission.ReadCustomer),
@@ -357,8 +536,9 @@ __decorate([
357
536
  (0, core_1.Allow)(core_1.Permission.UpdateCustomer),
358
537
  __param(0, (0, core_1.Ctx)()),
359
538
  __param(1, (0, common_1.Param)('id')),
539
+ __param(2, (0, common_1.Body)()),
360
540
  __metadata("design:type", Function),
361
- __metadata("design:paramtypes", [core_1.RequestContext, String]),
541
+ __metadata("design:paramtypes", [core_1.RequestContext, String, Object]),
362
542
  __metadata("design:returntype", Promise)
363
543
  ], AbandonedCartController.prototype, "issueRecoveryLink", null);
364
544
  __decorate([
@@ -374,11 +554,32 @@ __decorate([
374
554
  __decorate([
375
555
  (0, common_1.Get)('recover-cart'),
376
556
  __param(0, (0, common_1.Req)()),
377
- __param(1, (0, common_1.Query)('t')),
557
+ __param(1, (0, common_1.Res)()),
558
+ __param(2, (0, common_1.Query)('t')),
378
559
  __metadata("design:type", Function),
379
- __metadata("design:paramtypes", [Object, String]),
560
+ __metadata("design:paramtypes", [Object, Object, String]),
380
561
  __metadata("design:returntype", Promise)
381
562
  ], AbandonedCartController.prototype, "recover", null);
563
+ __decorate([
564
+ (0, common_1.Post)('recover-cart/resume'),
565
+ __param(0, (0, common_1.Req)()),
566
+ __param(1, (0, common_1.Res)()),
567
+ __param(2, (0, common_1.Query)('t')),
568
+ __param(3, (0, common_1.Body)()),
569
+ __metadata("design:type", Function),
570
+ __metadata("design:paramtypes", [Object, Object, String, Object]),
571
+ __metadata("design:returntype", Promise)
572
+ ], AbandonedCartController.prototype, "resume", null);
573
+ __decorate([
574
+ (0, common_1.Post)('recover-cart/converted'),
575
+ __param(0, (0, common_1.Req)()),
576
+ __param(1, (0, common_1.Res)()),
577
+ __param(2, (0, common_1.Query)('t')),
578
+ __param(3, (0, common_1.Body)()),
579
+ __metadata("design:type", Function),
580
+ __metadata("design:paramtypes", [Object, Object, String, Object]),
581
+ __metadata("design:returntype", Promise)
582
+ ], AbandonedCartController.prototype, "converted", null);
382
583
  exports.AbandonedCartController = AbandonedCartController = __decorate([
383
584
  (0, common_1.Controller)('ees'),
384
585
  __metadata("design:paramtypes", [abandoned_cart_service_1.AbandonedCartService])