@vtex/faststore-plugin-buyer-portal 2.0.19 → 2.0.20

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
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.0.20] - 2026-08-12
11
+
12
+ ### Added
13
+
14
+ - Analytics events now include a derived `metadata.actor_type` (`internal` | `external`) based on the logged-in user's email domain (`@vtex.com` → internal), with no email or identity fields in the payload ([B2BTEAM-3639](https://vtex-dev.atlassian.net/browse/B2BTEAM-3639))
15
+
10
16
  ## [2.0.19] - 2026-08-04
11
17
 
12
18
  ### Changed
@@ -821,7 +827,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
821
827
  - Add CHANGELOG file
822
828
  - Add README file
823
829
 
824
- [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.19...HEAD
830
+ [unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.20...HEAD
825
831
  [1.3.55]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.54...v1.3.55
826
832
  [1.3.54]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.53...v1.3.54
827
833
  [1.3.53]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.52...v1.3.53
@@ -919,6 +925,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
919
925
  [2.0.10]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.9...v2.0.10
920
926
  [2.0.9]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/2.0.9
921
927
 
928
+ [2.0.20]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.19...v2.0.20
922
929
  [2.0.19]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.18...v2.0.19
923
930
  [2.0.18]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.17...v2.0.18
924
931
  [2.0.17]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.16...v2.0.17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.19",
3
+ "version": "2.0.20",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,243 @@
1
+ # Analytics Actor Type (Internal vs External)
2
+
3
+ > **Status**: Done
4
+ > **Created**: 2026-07-28
5
+ > **Jira**: [B2BTEAM-3639](https://vtex-dev.atlassian.net/browse/B2BTEAM-3639)
6
+ > **Epic**: [B2BTEAM-3596](https://vtex-dev.atlassian.net/browse/B2BTEAM-3596) — Melhoria 1
7
+ > **Sibling task**: [B2BTEAM-3640](https://vtex-dev.atlassian.net/browse/B2BTEAM-3640) (Redshift column + QuickSight segmentation)
8
+
9
+ ## 1. Business Context
10
+
11
+ ### Problem Statement
12
+
13
+ Organization Account already emits usage analytics (create/edit/delete, timing, errors) via `useAnalytics` into the VTEX Data Ingestion API, Redshift, and QuickSight. Those numbers mix real customer usage with traffic from VTEX employees testing and supporting accounts.
14
+
15
+ PMs cannot filter “real usage” without a reliable, PII-free signal on each event that says whether the actor was internal VTEX or external.
16
+
17
+ ### Goals
18
+
19
+ 1. Every Org Account analytics event includes a derived `actor_type` of `internal` or `external`.
20
+ 2. Usage metrics can exclude VTEX traffic without dropping error analysis options (filtering stays at the consumer; emission always sends the field).
21
+ 3. Zero PII in the analytics envelope: never send email, login, user id, or other identity — only the derived enum.
22
+
23
+ ### User Stories
24
+
25
+ #### US-1: Emit actor_type on every analytics event
26
+
27
+ - **Story**: As a PM analyzing Org Account usage, I want every event tagged as internal or external, so I can segment real customer usage from VTEX traffic.
28
+ - **Acceptance Criteria**:
29
+ - **Given** a logged-in user whose email domain is `vtex.com`, **when** any analytics event is emitted through `useAnalytics` / `useDataIngestionApi`, **then** the payload includes `actor_type: "internal"`.
30
+ - **Given** a logged-in user whose email domain is not `vtex.com`, **when** any analytics event is emitted, **then** the payload includes `actor_type: "external"`.
31
+ - **Given** any successful emission path (`trackEvent`, `trackError`, `trackEntityCreated`, `trackEntityEdited`, `trackEntityCreateError`, `trackEntityEditError`), **when** the event is sent, **then** `actor_type` is present on the envelope (not only inside caller-supplied metadata).
32
+
33
+ #### US-2: Never send identity or email in the event
34
+
35
+ - **Story**: As a platform owner, I want the analytics envelope to stay free of PII, so we do not leak user identity into Redshift / QuickSight / Slack summaries.
36
+ - **Acceptance Criteria**:
37
+ - **Given** a user with email `alice@vtex.com`, **when** an event is emitted, **then** the JSON body does not contain that email, login, `userId`, or session `sub`.
38
+ - **Given** derivation needs the email in memory, **when** building the payload, **then** only the enum `actor_type` is attached to the outgoing event.
39
+
40
+ #### US-3: Safe default when identity is unavailable
41
+
42
+ - **Story**: As an engineer, I want a deterministic fallback when email is missing, so every event still carries `actor_type` and the pipeline never breaks.
43
+ - **Acceptance Criteria**:
44
+ - **Given** `currentUser` is null or `email` is empty/missing, **when** an event is emitted, **then** `actor_type` is `"external"`.
45
+ - **Given** derivation throws or email is malformed (no `@`), **when** an event is emitted, **then** `actor_type` is `"external"` and emission still succeeds.
46
+
47
+ ### Key Scenarios
48
+
49
+ | Scenario | Pre-conditions | Steps | Expected Result |
50
+ |---|---|---|---|
51
+ | Happy path — VTEX employee | `currentUser.email` is `rodrigo.tavares@vtex.com` | User creates a budget; `trackEntityCreated` runs | Event has `actor_type: "internal"`; no email in payload |
52
+ | Happy path — customer | `currentUser.email` is `buyer@acme.com` | User edits an address; `trackEntityEdited` runs | Event has `actor_type: "external"` |
53
+ | Error case — Users API failed in loader | `currentUser` is `null` (e.g. `getUserByIdService` returned null) | User still triggers an analytics call from a mounted component | Event has `actor_type: "external"`; send does not throw |
54
+ | Edge case — username login, email from API | Session `sub` is `everton.ataidetestdev` (not an email); Users API returns `email: "everton@vtex.com"` | Any event is emitted | Classification uses API email → `internal`; session `sub` is never sent |
55
+ | Edge case — case / plus addressing | Email is `Name+qa@VTEX.COM` | Event emitted | Domain match is case-insensitive → `internal` |
56
+
57
+ ### Functional Requirements
58
+
59
+ 1. Derive `actor_type` at emission time from the logged-in user's email domain.
60
+ 2. Classification rule: domain equals `vtex.com` (case-insensitive) → `internal`; otherwise → `external`.
61
+ 3. Attach `actor_type` on every event sent through the shared analytics send path so callers do not need to pass it.
62
+ 4. Do not attach email, login, userId, session `sub`, or other identity fields to the event.
63
+ 5. Remain backward compatible with the existing schemaless ingestion pipeline (additive field only).
64
+
65
+ ### Non-Functional Requirements
66
+
67
+ 1. **Privacy**: envelope stays zero-PII for identity; derivation is local/ephemeral.
68
+ 2. **Reliability**: missing user context must not block or fail analytics emission.
69
+ 3. **Performance**: derivation is O(1) string work; no extra network calls at emit time.
70
+ 4. **Testability**: pure domain classifier is unit-tested; send path asserts field presence and absence of PII.
71
+
72
+ ### Out of Scope
73
+
74
+ - Exposing `actor_type` as a Redshift column or QuickSight filter (sibling [B2BTEAM-3640](https://vtex-dev.atlassian.net/browse/B2BTEAM-3640)).
75
+ - Melhoria 2 (`error_type` / stripping raw `error_message` / `error_stack`).
76
+ - Rewriting the telemetry pipeline, DAG, or Slack weekly summary.
77
+ - Classifying by session `sub` alone, account name, IP, or workspace.
78
+ - Matching sibling domains (e.g. `vtex.com.br`) or subdomains (e.g. `corp.vtex.com`) — only exact `vtex.com`.
79
+
80
+ ---
81
+
82
+ ## 2. Arch Decisions
83
+
84
+ ### Proposed Solution
85
+
86
+ Enrich the shared analytics send path so every event automatically receives `actor_type`, derived from `currentUser.email` already available in `BuyerPortalContext`.
87
+
88
+ **Identity source (important):** the auth session JWT does **not** carry email. Example session claims:
89
+
90
+ ```json
91
+ {
92
+ "sub": "everton.ataidetestdev",
93
+ "userId": "dd801513-ae2f-4ff8-b6de-dd3a2159a895",
94
+ "customerId": "…",
95
+ "unitId": "…",
96
+ "account": "b2bfaststoredev",
97
+ "type": "user"
98
+ }
99
+ ```
100
+
101
+ `sub` is a login/username, not an email. Email is loaded server-side in page loaders via `getUserByIdService` → Users API (`usersClient.getUserById`), then passed into `BuyerPortalProvider` as `currentUser.email`. Classification must use that email, never the session `sub`.
102
+
103
+ ```mermaid
104
+ flowchart LR
105
+ Cookie["Auth cookie JWT\nuserId, sub, …"] --> Loader["Page loader\nwithAuthLoader"]
106
+ Loader --> UsersAPI["getUserByIdService\nUsers API"]
107
+ UsersAPI --> Ctx["BuyerPortalContext\ncurrentUser.email"]
108
+ Ctx --> Derive["deriveActorType(email)\ninternal | external"]
109
+ Derive --> Send["useDataIngestionApi.sendEvent\n+ actor_type"]
110
+ Send --> Ingest["VTEX schemaless-events"]
111
+ ```
112
+
113
+ ### Architecture Overview
114
+
115
+ | Layer | Responsibility |
116
+ |---|---|
117
+ | `deriveActorType(email?)` | Pure helper: parse domain, return `internal` \| `external` |
118
+ | `useDataIngestionApi` | Read `currentUser?.email` from `BuyerPortalContext` (optional context, like `useLogger`); merge `actor_type` into every `sendEvent` payload at top level alongside `account`, `locale`, `device`, `production` |
119
+ | `useAnalytics` | Unchanged call sites; inherits enrichment via `sendEvent` |
120
+ | Callers | No changes required to pass `actor_type` |
121
+
122
+ ### Alternatives Considered
123
+
124
+ | Alternative | Pros | Cons | Verdict |
125
+ |---|---|---|---|
126
+ | Derive in each feature caller | Explicit per event | Easy to miss; duplicates logic | Rejected |
127
+ | Derive only inside `useAnalytics.withDefaults` | Covers most UI tracking | Anything calling `useDataIngestionApi` / `dataIngestionApi` directly could skip it; harder to guarantee “every event” | Rejected as sole approach |
128
+ | Classify from session `sub` | No dependency on Users API | `sub` is often not an email (e.g. `everton.ataidetestdev`); false `external` for VTEX staff | Rejected |
129
+ | Put `actor_type` only inside `metadata` | Minimal type change | Sibling Redshift task wants a first-class column; top-level matches existing common fields | Rejected |
130
+ | Centralize in `useDataIngestionApi` + pure helper | Single choke point; additive; testable | Hook must tolerate missing React context | **Accepted** |
131
+
132
+ ### Risks & Mitigations
133
+
134
+ | Risk | Impact | Likelihood | Mitigation |
135
+ |---|---|---|---|
136
+ | `currentUser` null when analytics fires | Med (VTEX traffic mislabeled as external) | Med | Default `external`; ensure loaders keep loading user where events fire; document gap |
137
+ | VTEX staff using non-`@vtex.com` email | Med (internal traffic still pollutes usage) | Low | Document rule; extend domain list only via explicit follow-up |
138
+ | Customer email on `vtex.com` (unlikely) | Low | Low | Accept false `internal`; rule is domain-based by product decision |
139
+ | Accidental PII leak via spread of user object | High | Low | Unit tests asserting outbound payload keys; helper never returns email |
140
+
141
+ ### Key Decisions
142
+
143
+ #### Decision 1: Classification rule = exact `@vtex.com` domain
144
+
145
+ - **Status**: Accepted
146
+ - **Context**: Need a simple, privacy-safe heuristic for “VTEX employee”.
147
+ - **Decision**: `internal` iff the email’s domain equals `vtex.com` (case-insensitive). Plus-tags (`user+tag@vtex.com`) still match. Domains like `vtex.com.br` or `*.vtex.com` are **not** internal in this iteration.
148
+ - **Consequences**: Simple to implement and document; may miss regional VTEX domains until explicitly expanded.
149
+
150
+ #### Decision 2: Fallback = `external` when email unavailable
151
+
152
+ - **Status**: Accepted
153
+ - **Context**: Ticket enum is only `internal \| external`; every event must carry the field.
154
+ - **Decision**: Missing/invalid email → `external`.
155
+ - **Consequences**: Some VTEX traffic without a loaded user profile remains in “external” usage until context is present; safer than inventing a third enum value for Melhoria 1.
156
+
157
+ #### Decision 3: Identity source = `currentUser.email` from BuyerPortalContext
158
+
159
+ - **Status**: Accepted
160
+ - **Context**: Session JWT has `userId` / `sub` but not email; email already exists on `UserData` after loader fetch.
161
+ - **Decision**: Read `currentUser?.email` from context at send time. Do not decode the auth cookie on the client for classification. Do not use `login` / `sub` unless it is later proven to be an email and product asks for it — out of scope for v1 (email-only).
162
+ - **Consequences**: Correct for the common path where pages load the user; no extra network at emit time.
163
+
164
+ #### Decision 4: Place `actor_type` at top level of the event envelope
165
+
166
+ - **Status**: Accepted
167
+ - **Context**: Sibling task needs a Redshift column; common context fields (`account`, `locale`, `device`, `production`) are already top-level.
168
+ - **Decision**: Set `actor_type` as a top-level field on the object passed to `dataIngestionApi`, not buried only under `metadata`.
169
+ - **Consequences**: Easier DAG mapping; additive and backward compatible for schemaless ingestion.
170
+
171
+ ### Implementation Plan
172
+
173
+ 1. Add `ActorType` type and pure `deriveActorType(email?: string | null): ActorType` under shared analytics (or shared utils) with unit tests.
174
+ 2. Extend `DataIngestionApiFields` / send path types with optional/required `actor_type`.
175
+ 3. Update `useDataIngestionApi` to read `BuyerPortalContext` (gracefully if undefined) and always set `actor_type` on `sendEvent`.
176
+ 4. Add tests that mock context email and assert outbound payload contains `actor_type` and does not contain email/login/userId.
177
+ 5. Manual smoke on a host store: one `@vtex.com` user and one external user; confirm events in the analytics pipeline / network tab.
178
+ 6. Hand off to [B2BTEAM-3640](https://vtex-dev.atlassian.net/browse/B2BTEAM-3640) for Redshift + QuickSight.
179
+
180
+ ---
181
+
182
+ ## 3. Technical Contract
183
+
184
+ ### Data Models
185
+
186
+ ```ts
187
+ type ActorType = "internal" | "external";
188
+
189
+ /** Pure classifier — never logs or returns the input email. */
190
+ function deriveActorType(email?: string | null): ActorType;
191
+ ```
192
+
193
+ **Rules for `deriveActorType`:**
194
+
195
+ 1. Trim and lowercase the email.
196
+ 2. If empty or no `@`, return `"external"`.
197
+ 3. Take the substring after the last `@` as the domain.
198
+ 4. If domain === `"vtex.com"`, return `"internal"`; else `"external"`.
199
+
200
+ **Event envelope (additive):**
201
+
202
+ | Field | Type | Required on emit | Notes |
203
+ |---|---|---|---|
204
+ | `actor_type` | `"internal" \| "external"` | Yes | Derived; top-level |
205
+ | `email` / `login` / `userId` / `sub` | — | **Must not appear** | PII / identity ban |
206
+
207
+ Existing fields (`name`, `account`, `locale`, `production`, `device`, `event_name`, `event_category`, `metadata`, …) unchanged.
208
+
209
+ ### Interfaces
210
+
211
+ ```ts
212
+ // analytics types — additive
213
+ interface DataIngestionApiFields {
214
+ // …existing fields…
215
+ actor_type?: ActorType; // set by send path; callers need not pass
216
+ }
217
+
218
+ // useDataIngestionApi sendEvent behavior
219
+ sendEvent(fields: DataIngestionApiFields): void
220
+ // always merges: { ...commonFields, ...fields, actor_type: deriveActorType(currentUser?.email) }
221
+ // caller-supplied actor_type must not override the derived value (server of truth = derivation)
222
+ ```
223
+
224
+ `useAnalytics` public API stays the same; no new required options on `UseAnalyticsConfig`.
225
+
226
+ ### Integration Points
227
+
228
+ | System | Interaction |
229
+ |---|---|
230
+ | Auth cookie / session JWT | Provides `userId` only (indirectly); **not** used for domain classification |
231
+ | Users API via `getUserByIdService` | Source of `currentUser.email` in loaders |
232
+ | `BuyerPortalContext` | Runtime source of email for derivation in the client send hook |
233
+ | VTEX Data Ingestion (`schemaless-events`) | Receives additive `actor_type`; no schema migration in this task |
234
+ | Redshift / QuickSight | Consumer of the field in sibling task B2BTEAM-3640 |
235
+
236
+ ### Invariants & Constraints
237
+
238
+ 1. Every event leaving `useDataIngestionApi.sendEvent` includes `actor_type`.
239
+ 2. Outgoing analytics JSON must never include email, login, userId, or session `sub` as a result of this feature.
240
+ 3. Derivation does not perform network I/O.
241
+ 4. Callers cannot force a conflicting `actor_type` that bypasses derivation (derived value wins).
242
+ 5. Domains other than exact `vtex.com` are always `external` in this version.
243
+ 6. This feature does not change event names, categories, or existing metadata shapes beyond the additive field.
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useEffect, useRef } from "react";
2
2
 
3
- import { useDataIngestionApi } from "../../services/logger/analytics/analytics";
3
+ import { useDataIngestionApi } from "../../services/logger/analytics/useDataIngestionApi";
4
4
 
5
5
  import type {
6
6
  AnalyticsTimer,
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { deriveActorType } from "../deriveActorType";
4
+
5
+ describe("deriveActorType", () => {
6
+ it("returns internal for exact @vtex.com domain", () => {
7
+ expect(deriveActorType("rodrigo.tavares@vtex.com")).toBe("internal");
8
+ });
9
+
10
+ it("returns internal for case-insensitive and plus-tagged vtex.com emails", () => {
11
+ expect(deriveActorType("Name+qa@VTEX.COM")).toBe("internal");
12
+ });
13
+
14
+ it("returns external for non-vtex.com domains", () => {
15
+ expect(deriveActorType("buyer@acme.com")).toBe("external");
16
+ });
17
+
18
+ it("returns external for vtex.com.br and subdomain lookalikes", () => {
19
+ expect(deriveActorType("user@vtex.com.br")).toBe("external");
20
+ expect(deriveActorType("user@corp.vtex.com")).toBe("external");
21
+ });
22
+
23
+ it("returns external when email is missing, empty, or malformed", () => {
24
+ expect(deriveActorType(undefined)).toBe("external");
25
+ expect(deriveActorType(null)).toBe("external");
26
+ expect(deriveActorType("")).toBe("external");
27
+ expect(deriveActorType(" ")).toBe("external");
28
+ expect(deriveActorType("not-an-email")).toBe("external");
29
+ });
30
+ });
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import * as deriveActorTypeModule from "../deriveActorType";
4
+ import { mergeActorTypeIntoEvent } from "../mergeActorTypeIntoEvent";
5
+
6
+ import type { DataIngestionApiFields } from "../types";
7
+
8
+ describe("mergeActorTypeIntoEvent", () => {
9
+ it("attaches internal actor_type into metadata from a vtex.com email", () => {
10
+ const result = mergeActorTypeIntoEvent(
11
+ { event_name: "budget_created" },
12
+ "alice@vtex.com"
13
+ );
14
+
15
+ expect(result.metadata?.actor_type).toBe("internal");
16
+ expect(result).not.toHaveProperty("actor_type");
17
+ expect(result).not.toHaveProperty("email");
18
+ expect(result).not.toHaveProperty("login");
19
+ expect(result).not.toHaveProperty("userId");
20
+ expect(result).not.toHaveProperty("sub");
21
+ });
22
+
23
+ it("attaches external actor_type into metadata from a non-vtex email", () => {
24
+ const result = mergeActorTypeIntoEvent(
25
+ { event_name: "budget_created" },
26
+ "buyer@acme.com"
27
+ );
28
+
29
+ expect(result.metadata?.actor_type).toBe("external");
30
+ });
31
+
32
+ it("defaults to external when email is unavailable", () => {
33
+ expect(
34
+ mergeActorTypeIntoEvent({ event_name: "budget_created" }, null).metadata
35
+ ?.actor_type
36
+ ).toBe("external");
37
+ expect(
38
+ mergeActorTypeIntoEvent({ event_name: "budget_created" }, undefined)
39
+ .metadata?.actor_type
40
+ ).toBe("external");
41
+ });
42
+
43
+ it("preserves other metadata fields while attaching actor_type", () => {
44
+ const fields: DataIngestionApiFields = {
45
+ event_name: "budget_created",
46
+ metadata: { foo: "bar" },
47
+ };
48
+
49
+ const result = mergeActorTypeIntoEvent(fields, "buyer@acme.com");
50
+
51
+ expect(result.metadata).toEqual({ foo: "bar", actor_type: "external" });
52
+ });
53
+
54
+ it("falls back to external when deriveActorType throws", () => {
55
+ vi.spyOn(deriveActorTypeModule, "deriveActorType").mockImplementation(
56
+ () => {
57
+ throw new Error("unexpected");
58
+ }
59
+ );
60
+
61
+ expect(
62
+ mergeActorTypeIntoEvent(
63
+ { event_name: "budget_created" },
64
+ "alice@vtex.com"
65
+ ).metadata?.actor_type
66
+ ).toBe("external");
67
+
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ it("does not put the source email into the event payload", () => {
72
+ const email = "alice@vtex.com";
73
+ const result = mergeActorTypeIntoEvent(
74
+ { event_name: "address_edited", event_category: "edition" },
75
+ email
76
+ );
77
+ const serialized = JSON.stringify(result);
78
+
79
+ expect(serialized).not.toContain(email);
80
+ expect(serialized).not.toContain("alice");
81
+ expect(result.metadata?.actor_type).toBe("internal");
82
+ });
83
+ });
@@ -2,7 +2,7 @@ import storeConfig from "discovery.config";
2
2
 
3
3
  import { isDevelopment } from "../../../utils/environment";
4
4
 
5
- import type { DataIngestionApiFields, DataIngestionApiParams } from "./types";
5
+ import type { DataIngestionApiParams } from "./types";
6
6
 
7
7
  /**
8
8
  * VTEX Data Ingestion API for Analytics
@@ -50,7 +50,7 @@ export async function dataIngestionApi(
50
50
  /**
51
51
  * Get common analytics context
52
52
  */
53
- function getCommonAnalyticsContext() {
53
+ export function getCommonAnalyticsContext() {
54
54
  const account = storeConfig?.api?.storeId || "unknown";
55
55
  const locale =
56
56
  typeof window !== "undefined"
@@ -77,25 +77,3 @@ function getCommonAnalyticsContext() {
77
77
  device,
78
78
  };
79
79
  }
80
-
81
- /**
82
- * Hook to send analytics events
83
- * This is a simple version that will be extended by useAnalytics hook
84
- */
85
- export function useDataIngestionApi() {
86
- const commonFields = getCommonAnalyticsContext();
87
-
88
- const sendEvent = (fields: DataIngestionApiFields) => {
89
- // Get workspace from environment
90
- const workspace = isDevelopment() ? "dev" : "master";
91
-
92
- dataIngestionApi({
93
- ...commonFields,
94
- event_category: fields.event_category || "click",
95
- ...fields,
96
- workspace,
97
- });
98
- };
99
-
100
- return { sendEvent };
101
- }
@@ -0,0 +1,24 @@
1
+ import type { ActorType } from "./types";
2
+
3
+ const INTERNAL_EMAIL_DOMAIN = "vtex.com";
4
+
5
+ /**
6
+ * Derive analytics actor_type from the logged-in user's email domain.
7
+ * Never logs or returns the input email — only the enum.
8
+ */
9
+ export function deriveActorType(email?: string | null): ActorType {
10
+ if (!email) {
11
+ return "external";
12
+ }
13
+
14
+ const trimmed = email.trim().toLowerCase();
15
+ const atIndex = trimmed.lastIndexOf("@");
16
+
17
+ if (atIndex < 0 || atIndex === trimmed.length - 1) {
18
+ return "external";
19
+ }
20
+
21
+ const domain = trimmed.slice(atIndex + 1);
22
+
23
+ return domain === INTERNAL_EMAIL_DOMAIN ? "internal" : "external";
24
+ }
@@ -0,0 +1,29 @@
1
+ import { deriveActorType } from "./deriveActorType";
2
+
3
+ import type { ActorType, DataIngestionApiFields } from "./types";
4
+
5
+ /**
6
+ * Attach derived actor_type to analytics fields' metadata.
7
+ * Caller-supplied actor_type is ignored — derivation always wins.
8
+ * Never copies email or other identity into the payload.
9
+ */
10
+ export function mergeActorTypeIntoEvent(
11
+ fields: DataIngestionApiFields,
12
+ email?: string | null
13
+ ): DataIngestionApiFields {
14
+ let actorType: ActorType;
15
+
16
+ try {
17
+ actorType = deriveActorType(email);
18
+ } catch {
19
+ actorType = "external";
20
+ }
21
+
22
+ return {
23
+ ...fields,
24
+ metadata: {
25
+ ...fields.metadata,
26
+ actor_type: actorType,
27
+ },
28
+ };
29
+ }
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Actor classification for analytics events (internal VTEX vs external).
3
+ */
4
+ export type ActorType = "internal" | "external";
5
+
1
6
  /**
2
7
  * Analytics event categories
3
8
  */
@@ -0,0 +1,32 @@
1
+ import { useContext } from "react";
2
+
3
+ import { BuyerPortalContext } from "../../../components/BuyerPortalProvider/BuyerPortalProvider";
4
+ import { isDevelopment } from "../../../utils/environment";
5
+
6
+ import { dataIngestionApi, getCommonAnalyticsContext } from "./analytics";
7
+ import { mergeActorTypeIntoEvent } from "./mergeActorTypeIntoEvent";
8
+
9
+ import type { DataIngestionApiFields } from "./types";
10
+
11
+ /**
12
+ * Hook to send analytics events with actor_type derived from the logged-in user.
13
+ */
14
+ export function useDataIngestionApi() {
15
+ const buyerPortalContext = useContext(BuyerPortalContext);
16
+ const commonFields = getCommonAnalyticsContext();
17
+ const email = buyerPortalContext?.currentUser?.email;
18
+
19
+ const sendEvent = (fields: DataIngestionApiFields) => {
20
+ const workspace = isDevelopment() ? "dev" : "master";
21
+ const enrichedFields = mergeActorTypeIntoEvent(fields, email);
22
+
23
+ dataIngestionApi({
24
+ ...commonFields,
25
+ event_category: enrichedFields.event_category || "click",
26
+ ...enrichedFields,
27
+ workspace,
28
+ });
29
+ };
30
+
31
+ return { sendEvent };
32
+ }
@@ -22,4 +22,4 @@ export const SCOPE_KEYS = {
22
22
  CREDIT_CARDS: "creditCards",
23
23
  } as const;
24
24
 
25
- export const CURRENT_VERSION = "2.0.19";
25
+ export const CURRENT_VERSION = "2.0.20";