@doswiftly/storefront-operations 5.5.0 → 6.1.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
@@ -1,5 +1,783 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - fbc6a94: Rozszerzono `CustomerUpdateInput` w GraphQL Storefront API o pełen kontrakt B2B — storefront developer może teraz zaktualizować dane firmowe klienta (nazwa firmy, NIP, VAT UE, REGON) oraz typ klienta (osoba fizyczna / firma) bezpośrednio przez mutację `customerUpdate`. Wcześniej te pola były dostępne wyłącznie z poziomu panelu administratora.
8
+
9
+ **Nowe pola Input**:
10
+ - `customerType: CustomerType` — `INDIVIDUAL` (B2C) lub `COMPANY` (B2B). Opcjonalne — gdy pominięte, backend dokona inteligentnej inferencji (patrz „Strict hybrid" niżej).
11
+ - `companyName: String` — nazwa firmy (wymagana dla `COMPANY`).
12
+ - `taxId: String` — polski NIP (10 cyfr z sumą kontrolną).
13
+ - `vatNumber: String` — numer VAT UE (np. `PL5260250274`, `DE123456789`).
14
+ - `regon: String` — polski REGON (9 lub 14 cyfr z sumą kontrolną).
15
+
16
+ **Nowe pola czytelne na typie `Customer`** (dostępne automatycznie przez fragment `Customer`):
17
+ - `customerType`, `companyName`, `taxId`, `vatNumber`, `regon`.
18
+
19
+ **Strict hybrid — kontrakt aktualizacji typu klienta**:
20
+ 1. Jeśli `customerType` jest w payloadzie, wygrywa explicit (nawet jeśli inne pola sugerowałyby co innego).
21
+ 2. Jeśli `customerType` jest pominięty, backend dokonuje implicit upgrade `INDIVIDUAL → COMPANY` tylko gdy podano niepuste pole firmowe. Implicit downgrade nigdy się nie zdarza — wymaga jawnego `customerType: INDIVIDUAL`.
22
+ 3. Każde efektywne `COMPANY` (jawne lub przez inferencję) wymaga niepustej `companyName` — w przeciwnym razie mutacja zwraca `userErrors` z kodem `CUSTOMER_UPDATE_FAILED`.
23
+ 4. `undefined` w payloadzie = brak zmiany pola; `null` = świadome czyszczenie wartości.
24
+
25
+ **Walidacja format**: NIP, VAT UE i REGON walidowane przez wspólny dekorator (regex + algorytm sumy kontrolnej). Niepoprawny format → błąd walidacji input'u GraphQL.
26
+
27
+ **Concurrency safety**: `customerUpdate` używa optimistic locking — równoczesne aktualizacje tego samego klienta (np. profil w wielu zakładkach) zwracają `userErrors` z message o konflikcie wersji zamiast cichego nadpisania.
28
+
29
+ **Use cases dla storefront-developera**:
30
+ - Strona „moje dane" w sklepie — pełen formularz B2C/B2B z radio pickerem typu klienta.
31
+ - Checkout pre-fill — możliwość uzupełnienia danych do faktury bez wchodzenia do panelu sklepu.
32
+ - Rejestracja firm / sole-trader scenarios — implicit upgrade gdy klient wpisze NIP w polu opcjonalnym.
33
+
34
+ **Test coverage**: 8 service-level scenariuszy (każda z 4 reguł strict hybrid + concurrency conflict + audit trail + EventEmitter) + 3 GraphQL surface scenariusze (full B2B payload, error mapping, format validation).
35
+
36
+ - c91b700: Rozszerzono storefront GraphQL API o pełną obsługę zgód marketingowych — storefront-developer może teraz zbudować checkbox marketingu przy rejestracji, toggle preferencji w panelu klienta, oraz widget „zapisz się do newslettera" w stopce sklepu. Wszystkie 3 use case'y działają end-to-end (z double opt-in dla anonimowego widget'u i audit trail dla zgodności z RODO).
37
+
38
+ **Nowe pola Input (signup)** — `CustomerCreateInput`:
39
+ - `acceptsMarketing: Boolean` — checkbox marketingu przy `customerSignup`. `true` → state SUBSCRIBED bezpośrednio (signup = adres potwierdzony). `false`/`null` → bez zmian (NOT_SUBSCRIBED).
40
+ - `marketingOptInLevel: MarketingOptInLevel` (`SINGLE_OPT_IN | CONFIRMED_OPT_IN | UNKNOWN`) — opcjonalny override. Ustaw `CONFIRMED_OPT_IN` aby wymusić double opt-in (state PENDING + automatyczny email z linkiem potwierdzającym).
41
+
42
+ **Nowe pole Input (panel klienta)** — `CustomerUpdateInput`:
43
+ - `acceptsMarketing: Boolean` — toggle dla zalogowanego klienta. `true` → SUBSCRIBED, `false` → UNSUBSCRIBED. Działa tylko z access tokenem (auth-required mutation).
44
+
45
+ **Nowe mutacje (newsletter widget — guest, bez auth)**:
46
+ - `customerSubscribeToMarketing(input: { email, marketingOptInLevel? }): SubscribeToMarketingPayload` — anonimowy opt-in. Backend zawsze stosuje double opt-in (state PENDING → email z linkiem → klik → SUBSCRIBED) niezależnie od `marketingOptInLevel` w input — bez tego ktoś mógłby zapisywać cudze adresy.
47
+ - `customerUnsubscribeFromMarketing(input: { email }): UnsubscribeFromMarketingPayload` — anonimowy opt-out, idempotent.
48
+
49
+ **Payloady — `accepted: Boolean!` semantyka (anti-enumeration)**: obie mutacje guest zwracają `accepted: true` niezależnie od stanu emaila (registered / nieistniejący / już SUBSCRIBED). Storefront-developer dostaje deterministyczny shape response — atakujący nie może przez to API enumerate'ować adresów ani odróżnić invalid email format od valid (orzeczenie: też swallowed jako `accepted: true`). Stan końcowy zawsze widoczny przez `Customer.emailMarketing` (gdy klient autoryzowany).
50
+
51
+ **Mutacje C są throttlowane per IP** (limit 10/min) i pod botprotection guardem — storefront powinien zintegrować odpowiedni captcha provider. Email-bombing przez wpisywanie cudzego adresu w widget'cie blokowany dodatkowo na poziomie backendu (max 1 email potwierdzenia na adres na dobę).
52
+
53
+ **Strict-hybrid kontrakt update'u**:
54
+ - Brak pola w Input = no-op dla tego pola (zachowanie back-compat dla istniejących storefrontów bez nowych pól).
55
+ - `acceptsMarketing: null` na auth'd customer = explicit no-change (nie unsubscribe — do unsubscribe użyj `false`).
56
+
57
+ **Double opt-in dla widget'u — co dzieje się po kliknięciu „Zapisz"**:
58
+ 1. Mutation `customerSubscribeToMarketing` → backend zapisuje stan PENDING + uruchamia wysłanie maila potwierdzającego.
59
+ 2. Email idzie z domeny sklepu (per-shop branding — `From: "Sklep <noreply@shop-domain>"`) z CTA buttonem prowadzącym pod URL potwierdzający (token JWT 24h).
60
+ 3. Klient klika link → backend transitionuje stan z PENDING na SUBSCRIBED.
61
+ 4. Po SUBSCRIBED storefront może wysyłać maile marketingowe (gate consent jest egzekwowane przez backend pre-send).
62
+
63
+ **Read surface — `Customer.emailMarketing: EmailMarketingState!`** (pole już istniejące, dla auth'd customer): storefront odczytuje `NOT_SUBSCRIBED | PENDING | SUBSCRIBED | UNSUBSCRIBED | INVALID | REDACTED` aby zdecydować jak renderować checkbox/toggle w UI (np. PENDING = pokaż „sprawdź email", SUBSCRIBED = checkbox zaznaczony).
64
+
65
+ **Use cases**:
66
+ - Strona `/auth/register` — checkbox „Chcę otrzymywać newsletter" zapisany razem z signupem.
67
+ - Strona `/account/preferences` — toggle marketing on/off dla zalogowanego klienta.
68
+ - Stopka layoutu sklepu — input email + button „Zapisz się" → mutation guest subscribe → automatyczny email z linkiem potwierdzającym.
69
+
70
+ **Suppression list awareness**: jeśli adres trafił wcześniej na suppression list (hard bounce / complaint feedback), kolejna subskrypcja zwraca `accepted: true` ale email potwierdzający nie wychodzi. Klient pozostanie w stanie PENDING — admin sklepu musi ręcznie odblokować adres przez panel administracyjny.
71
+
72
+ ## 6.0.0
73
+
74
+ ### Major Changes
75
+
76
+ - d51eb03: Storefront API: cookie-first authentication + comprehensive typing fixes.
77
+
78
+ **⚠️ Breaking changes — migration required**
79
+
80
+ ### Authentication: cookie-first, drop `customerAccessToken` argument
81
+
82
+ The storefront API no longer accepts `customerAccessToken` as a query/mutation argument.
83
+ Authentication context is now resolved per-request from (in priority order):
84
+ 1. **httpOnly cookie `customerAccessToken`** — set automatically by the API after `customerAccessTokenCreate` / `customerCreate` / `customerAccessTokenRenew` / `customerActivateByUrl` / `customerResetByUrl` / `customerReset`. Browser-based storefronts gain XSS-safe auth out of the box.
85
+ 2. **`Authorization: Bearer <token>` header** — for non-browser clients (mobile native, server-to-server, OAuth integrations per RFC 6750).
86
+
87
+ `customerAccessTokenDelete` (logout) clears the cookie via `Set-Cookie: customerAccessToken=; Max-Age=0`.
88
+
89
+ **Affected operations** (drop `customerAccessToken: String!` arg + value pass-through):
90
+ - Queries: `customer`, `customerOrder`, `shipment`, `return`, `returnsByOrder`
91
+ - Mutations: `customerAccessTokenDelete`, `customerAccessTokenRenew`, `customerUpdate`, `customerAddressCreate`, `customerAddressUpdate`, `customerAddressDelete`, `customerDefaultAddressUpdate`, `returnCreate`, `returnCancel`
92
+
93
+ **Migration**:
94
+
95
+ ```graphql
96
+ # Before (4.x)
97
+ query Customer($customerAccessToken: String!) {
98
+ customer(customerAccessToken: $customerAccessToken) { ... }
99
+ }
100
+
101
+ # After (5.x)
102
+ query Customer {
103
+ customer { ... }
104
+ }
105
+ ```
106
+
107
+ For a browser, send the request with `credentials: 'include'` so the httpOnly cookie travels along. For non-browser clients, set `Authorization: Bearer <jwt>` on each request. The `accessToken` field in mutation responses (`customerAccessTokenCreate.customerAccessToken.accessToken`) is still present for non-browser clients that manage their own token storage.
108
+
109
+ ### `LANGUAGE_HEADER_NAME` constant renamed `X-Lang` → `X-Language`
110
+
111
+ Storefronts importing this constant from `@doswiftly/storefront-sdk` will pick up the new value at rebuild time. The previous value did not match the API reader and silently fell through to `Accept-Language` auto-detection — explicit per-request language switching now works as documented.
112
+
113
+ ### `@inContext` directive accepts new arguments
114
+
115
+ `@inContext` previously exposed only `preferredLocationId`. It now also accepts `country: String`, `language: String`, `currency: String`, and `buyer: BuyerInput { customerAccessToken, companyLocationId }`. Resolution priority: `directive > request header > cookie > Accept-Language auto-detect > shop default`. All five layers are independent — pick whichever matches your routing/SSR architecture.
116
+
117
+ ### `Shop.primaryDomain` is now a `Domain` object
118
+
119
+ ```graphql
120
+ # Before
121
+ type Shop {
122
+ primaryDomain: String # "https://example.com"
123
+ primaryDomainObject: Domain! # { host, url, sslEnabled }
124
+ }
125
+
126
+ # After
127
+ type Shop {
128
+ primaryDomain: Domain! # { host, url, sslEnabled }
129
+ }
130
+ ```
131
+
132
+ Update the `Shop` fragment to query the structured fields:
133
+
134
+ ```graphql
135
+ fragment Shop on Shop {
136
+ primaryDomain { host url sslEnabled }
137
+ ...
138
+ }
139
+ ```
140
+
141
+ ### Removed redundant fields
142
+ - `Menu.itemsCount` — drop. Use `items.length` in the client (the count was always equal to the array length).
143
+ - `GiftCard.lastCharacters` — drop. Compute from `maskedCode.slice(-4)` in the client.
144
+
145
+ ### Stronger types — `Date`, enums, structured errors
146
+
147
+ Several fields previously returned `String` (ISO 8601) or untyped error arrays — now properly typed:
148
+ - **DateTime**: `Order.processedAt`, `BlogPost.{publishedAt,createdAt,updatedAt}`, `ShopPage.{publishedAt,createdAt,updatedAt}`, `LoyaltyMember.{lastActivityAt,enrolledAt}`, `LoyaltyTransaction.{createdAt,expiresAt}`, `LoyaltyPointsSummary.nextExpiryDate`, `CustomerAccessToken.expiresAt`. Codegen now emits `Date | string` (or your codegen's date type).
149
+ - **Enums**: `Customer.emailMarketingState` is `EmailMarketingState!`, `ProductAttributeDefinition.fillingMode/billingMode` are typed enums, `ProductAttributeOption.surchargeType` is `AttributeOptionSurchargeType`, `Shop.currencyCode` / `paymentCurrencies` / `supportedCurrencies` use `CurrencyCode`, `Shop.defaultLanguage` / `supportedLanguages` use `LanguageCode`, `Currency.code` is `CurrencyCode`, `Currency.symbolPosition` is the new `CurrencySymbolPosition` enum (`BEFORE` / `AFTER`).
150
+ - **Structured errors**: `WishlistPayload.userErrors`, `RedeemRewardPayload.userErrors`, `GenerateReferralCodePayload.userErrors` are now `[UserError!]!` (`{ message, code, field }`) instead of `[String!]!`. Replace `userErrors[0]` with `userErrors[0].message` in your error handling.
151
+
152
+ ### New: `ProductReview.unhelpfulCount`
153
+
154
+ Storefronts can now render upvote/downvote counts side-by-side without computing one from the other.
155
+
156
+ ### Cross-domain cookie auth — `credentials: 'include'`
157
+
158
+ The SDK GraphQL transport now sends `credentials: 'include'` on every request. If your storefront runs on a domain different from the API and uses cookie auth, the API CORS configuration must allow your origin (with `Access-Control-Allow-Credentials: true`). Same-origin deployments are unaffected.
159
+
160
+ ### Auth store no longer persists `accessToken` to localStorage (XSS hardening)
161
+
162
+ `useAuthStore` only persists `customer` and `isAuthenticated` to localStorage. The token lives in the httpOnly cookie (browser auto-sent) plus in-memory store state (set by login flow). After a page refresh, browser-based storefronts call `GET /api/auth/whoami` (new BFF route shipped with the template) to rehydrate `customer` info — the cookie continues to authenticate every subsequent GraphQL request transparently.
163
+
164
+ The `auth-storage` localStorage version bumps from `v2` to `v3`; the migrate handler clears legacy persisted tokens on first load.
165
+
166
+ ### New: `createWhoamiHandler()` factory
167
+
168
+ Exports from `@doswiftly/storefront-sdk`. Pair with the `app/api/auth/whoami/route.ts` example shipped with the CLI template:
169
+
170
+ ```typescript
171
+ // app/api/auth/whoami/route.ts
172
+ import { createWhoamiHandler } from "@doswiftly/storefront-sdk";
173
+ export const GET = createWhoamiHandler({
174
+ apiUrl: process.env.NEXT_PUBLIC_API_URL,
175
+ shopSlug: process.env.NEXT_PUBLIC_SHOP_SLUG,
176
+ });
177
+ ```
178
+
179
+ ### `AuthClient` (low-level core API) — drop token argument
180
+
181
+ ```typescript
182
+ // Before
183
+ authClient.logout(token);
184
+ authClient.renewToken(token);
185
+ authClient.getCustomer(token);
186
+
187
+ // After
188
+ authClient.logout(); // auth via cookie/Bearer in middleware
189
+ authClient.renewToken();
190
+ authClient.getCustomer();
191
+ ```
192
+
193
+ ### Template (`@doswiftly/cli` scaffolded storefront)
194
+
195
+ The Next.js template now uses cookie-first auth end-to-end:
196
+ - `lib/graphql/hooks.ts` — query/mutation hooks no longer pass `customerAccessToken`
197
+ - `lib/graphql/server.ts` — server-side helper reads `customerAccessToken` cookie via `next/headers` and forwards as `Authorization: Bearer`
198
+ - `app/api/auth/whoami/route.ts` (new) — BFF route for `customer` rehydration after page refresh
199
+ - `hooks/use-auth-sync.ts` — rewritten to use the whoami endpoint instead of token desync detection
200
+ - Auth-gated React components/pages now check `isAuthenticated` instead of `accessToken`
201
+
202
+ ### Wishlist pagination + GiftCard read surface
203
+ - **`wishlists`** is now a paginated **`WishlistConnection`** (Relay pattern). Replace direct array selection with `nodes` (shortcut) or `edges { cursor node { ... } }`. Supports `first: Int = 20` and `after: String` cursor arguments.
204
+
205
+ ```graphql
206
+ # Before (4.x)
207
+ query Wishlists {
208
+ wishlists {
209
+ id
210
+ name
211
+ itemCount
212
+ items {
213
+ id
214
+ productId
215
+ }
216
+ }
217
+ }
218
+
219
+ # After (5.x)
220
+ query Wishlists($first: Int = 20, $after: String) {
221
+ wishlists(first: $first, after: $after) {
222
+ nodes {
223
+ id
224
+ name
225
+ itemCount
226
+ items {
227
+ id
228
+ productId
229
+ }
230
+ }
231
+ pageInfo {
232
+ hasNextPage
233
+ hasPreviousPage
234
+ startCursor
235
+ endCursor
236
+ }
237
+ totalCount
238
+ }
239
+ }
240
+ ```
241
+
242
+ - **`giftCard(code)`** read query no longer wraps result in `GiftCardPayload`. Returns nullable `GiftCard` directly (`null` = not found). The wrapper pattern `{ giftCard, userErrors }` is reserved for mutations only.
243
+
244
+ ```graphql
245
+ # Before (4.x)
246
+ query GiftCard($code: String!) {
247
+ giftCard(code: $code) {
248
+ giftCard {
249
+ id
250
+ maskedCode
251
+ balance {
252
+ amount
253
+ currencyCode
254
+ }
255
+ }
256
+ userErrors {
257
+ message
258
+ code
259
+ }
260
+ }
261
+ }
262
+
263
+ # After (5.x)
264
+ query GiftCard($code: String!) {
265
+ giftCard(code: $code) {
266
+ id
267
+ maskedCode
268
+ balance {
269
+ amount
270
+ currencyCode
271
+ }
272
+ }
273
+ }
274
+ ```
275
+
276
+ - **`GiftCard.transactions`** field removed from storefront API entirely. The field always returned `[]` (PII safety: transactions contain `orderId` / `customerId` not safe to expose at anonymous code-lookup endpoint). Schema now rejects the selection at validation time. Storefronts that selected this field will get a clear schema error instead of an always-empty array.
277
+
278
+ ### Filter scalars + structured Weight type
279
+ - **`Filter.id` and `FilterValue.id`** are now `ID!` (previously `String!`). Codegen treats `ID` identically to `String` in TypeScript, but downstream client tooling (Relay-style caches, normalized stores) gains proper identity semantics for entity deduplication.
280
+ - **`FilterValue.input`** changed from `String!` (JSON-stringified payload) to **`JSON!`** (new opaque structured scalar). Storefronts no longer need to `JSON.parse(filterValue.input)` before passing it to the `filters` argument — the value arrives as a structured object ready to use:
281
+
282
+ ```graphql
283
+ # Before (4.x) — client-side parsing required
284
+ filterValue.input # "{\"variantOption\":{\"name\":\"Color\",\"value\":\"Red\"}}"
285
+ JSON.parse(filterValue.input)
286
+
287
+ # After (5.x) — directly passable as filters arg element
288
+ filterValue.input # { variantOption: { name: "Color", value: "Red" } }
289
+ ```
290
+
291
+ - **`ProductVariant.weight`** changed from `Float` (raw grams, implicit unit per docs) to **`Weight { value: Float!, unit: WeightUnit! }`** structured type with the new `WeightUnit` enum (`GRAMS | KILOGRAMS | OUNCES | POUNDS`). The API always returns `unit: GRAMS` (canonical storage unit). Storefronts can convert to the preferred unit for shipping or checkout UI without hardcoding "grams" anywhere in the codebase:
292
+
293
+ ```graphql
294
+ # Before (4.x)
295
+ variant { weight } # 250 (Float, "grams" implicit per docs)
296
+
297
+ # After (5.x)
298
+ variant { weight { value unit } } # { value: 250, unit: GRAMS }
299
+ ```
300
+
301
+ ### Removed: legacy `x-customer-access-token` header
302
+
303
+ The legacy `x-customer-access-token` header reader (which never had a matching SDK middleware) is removed from the API. If a custom integration was relying on it, switch to `Authorization: Bearer <token>`.
304
+
305
+ ### Field & mutation naming sweep
306
+
307
+ Several renames have landed in this release. Update queries and variables accordingly:
308
+ - **Cart mutations**: `cartLinesAdd` → `cartAddLines`, `cartLinesUpdate` → `cartUpdateLines`, `cartLinesRemove` → `cartRemoveLines`, `cartNoteUpdate` → `cartUpdateNote`, `cartAttributesUpdate` → `cartUpdateAttributes`, `cartBuyerIdentityUpdate` → `cartUpdateBuyerIdentity`, `cartDiscountCodesUpdate` → `cartApplyDiscountCodes`. Resource-id arg renamed `cartId` → `id` on mutations (the foreign-key `CheckoutCreateInput.cartId` keeps its name).
309
+ - **Checkout mutations**: `checkoutId` → `id`. `checkoutSelectShippingRate` arg `shippingRateHandle` → `rateId`.
310
+ - **Wishlist mutations**: `wishlistId` → `id`.
311
+ - **Field renames on output types**:
312
+ - `Product.productType` → `Product.category` (free-text classification field; the `Product.type: ProductTypeEnum` enum field is unchanged)
313
+ - `ProductVariant.quantityAvailable` → `availableStock` (disambiguates effective availability from raw inventory)
314
+ - `ProductOption.position` / `ProductOptionValue.position` → `sortOrder`
315
+ - `Customer.numberOfOrders` → `orderCount`
316
+ - `WishlistItem.priceAtAdd` → `priceWhenAdded`
317
+ - `Shop.primaryDomain.sslEnabled` → `isSslEnabled` (boolean prefix convention)
318
+ - `Shipment.estimatedDeliveryDate` → `estimatedDeliveryAt`
319
+ - `StoreAvailability.pickUpTime` → `pickupTime`
320
+ - `Location.pickupEnabled` → `supportsPickup`
321
+ - `ShopPage.bodySummary` → `excerpt`
322
+ - **New field**: `ProductReview.unhelpfulCount` — render upvote/downvote counts side-by-side without computing one from the other.
323
+ - **`UserError`/payload selection set** is now required on `WishlistPayload.userErrors` (and other payloads switched to typed `[UserError!]!`). Bare `userErrors` selection without a sub-selection is rejected by schema validation.
324
+ - **Error code stability**: typed error codes (e.g. `CART_NOT_FOUND`, `CUSTOMER_TOKEN_INVALID`) are now locale-agnostic — they remain stable across language responses (in earlier versions the mapping silently regressed when a translated message lost a specific English keyword).
325
+
326
+ ### Unified `UserError` across all mutation payloads
327
+
328
+ Per-domain user-error types (`CartUserError`, `CheckoutUserError`, `CustomerUserError`) have been replaced by the single generic `UserError` type used everywhere. Error codes remain unique per domain (e.g. `CART_NOT_FOUND` vs `CUSTOMER_NOT_FOUND` vs `CHECKOUT_PAYMENT_FAILED`) — string comparison on the `code` field gives the same branching power without per-domain type proliferation.
329
+
330
+ Customer mutation payloads also rename the error field from `customerUserErrors` to `userErrors` for consistency with cart, checkout, wishlist, and other payloads:
331
+
332
+ ```graphql
333
+ # Before (4.x)
334
+ mutation CartCreate {
335
+ cartCreate {
336
+ userErrors {
337
+ ...CartUserError
338
+ }
339
+ }
340
+ }
341
+ mutation CustomerLogin {
342
+ customerLogin {
343
+ customerUserErrors {
344
+ ...CustomerUserError
345
+ }
346
+ }
347
+ }
348
+ mutation CheckoutCreate {
349
+ checkoutCreate {
350
+ userErrors {
351
+ ...CheckoutUserError
352
+ }
353
+ }
354
+ }
355
+
356
+ # After (5.x)
357
+ mutation CartCreate {
358
+ cartCreate {
359
+ userErrors {
360
+ ...UserError
361
+ }
362
+ }
363
+ }
364
+ mutation CustomerLogin {
365
+ customerLogin {
366
+ userErrors {
367
+ ...UserError
368
+ }
369
+ }
370
+ }
371
+ mutation CheckoutCreate {
372
+ checkoutCreate {
373
+ userErrors {
374
+ ...UserError
375
+ }
376
+ }
377
+ }
378
+ ```
379
+
380
+ Update the shared `UserError` fragment as the only error fragment you need:
381
+
382
+ ```graphql
383
+ fragment UserError on UserError {
384
+ message
385
+ code # namespaced string — e.g. "CART_NOT_FOUND", "CUSTOMER_TOKEN_INVALID"
386
+ field # path to the input field that caused the error
387
+ }
388
+ ```
389
+
390
+ Client TypeScript change after codegen: `userErrors[i].code` is now `string | null` instead of a strongly-typed enum (`CartErrorCode` / `CheckoutErrorCode` / `CustomerErrorCode`). Switch from `code === CartErrorCode.CART_NOT_FOUND` to `code === 'CART_NOT_FOUND'`. The full list of namespaced codes per domain is documented in the schema descriptions on each payload's `userErrors` field.
391
+
392
+ ### Address `country` field — `CountryCode` enum on inputs
393
+
394
+ Address input types (`MailingAddressInput`, `CheckoutAddressInput`, `ShippingAddressInput`) now use the `CountryCode` enum for the `country` field instead of `String` with regex validation. This matches the pattern already used by output types (`MailingAddress.countryCode: CountryCode`) and gives client codegen a strongly-typed enum value instead of a free-form string:
395
+
396
+ ```graphql
397
+ # Before (4.x)
398
+ input CheckoutAddressInput {
399
+ country: String! # validated server-side via @Matches(/^[A-Z]{2}$/)
400
+ }
401
+
402
+ # After (5.x)
403
+ input CheckoutAddressInput {
404
+ country: CountryCode! # GraphQL enum — invalid values rejected at validation
405
+ }
406
+ ```
407
+
408
+ Output types keep two distinct fields where applicable: `country` (display name, e.g. "Polska") + `countryCode` (typed enum, e.g. `PL`).
409
+
410
+ ### Address fields — international naming
411
+
412
+ `MailingAddress`, `LocationAddress`, `ShopAddress`, and the address inputs (`MailingAddressInput`, `CheckoutAddressInput`) now use international neutral terminology instead of US/Canadian-centric field names. The new names match common international postal conventions and the underlying database column names:
413
+
414
+ | Before (4.x) | After (5.x) |
415
+ | -------------- | ------------- |
416
+ | `address1` | `streetLine1` |
417
+ | `address2` | `streetLine2` |
418
+ | `province` | `state` |
419
+ | `provinceCode` | `stateCode` |
420
+ | `zip` | `postalCode` |
421
+
422
+ ```graphql
423
+ # Before (4.x)
424
+ fragment MailingAddress on MailingAddress {
425
+ address1
426
+ address2
427
+ province
428
+ zip
429
+ }
430
+
431
+ # After (5.x)
432
+ fragment MailingAddress on MailingAddress {
433
+ streetLine1
434
+ streetLine2
435
+ state
436
+ postalCode
437
+ }
438
+
439
+ # Mutation input
440
+ mutation AddAddress($input: MailingAddressInput!) {
441
+ customerAddAddress(input: $input) { ... }
442
+ }
443
+ # variables (4.x): { address1: "...", province: "...", zip: "..." }
444
+ # variables (5.x): { streetLine1: "...", state: "...", postalCode: "..." }
445
+ ```
446
+
447
+ **Backward-compat for existing orders**: `Order.shippingAddress` and `Order.billingAddress` are stored as JSONB snapshots at order placement time. Snapshots created under the previous API contain `address1` / `province` / `zip`; new snapshots use `streetLine1` / `state` / `postalCode`. The `MailingAddress` resolver reads new field names first and falls back to legacy names — historical orders continue to render correctly. Frontend codegen will emit the new field names; templates rendering historical orders gain the new schema without data migration. Backward-compat fallback is scheduled for removal once historical snapshots reach end-of-life (~2-3 years).
448
+
449
+ **`MailingAddress.formatted`** (country-aware ordered lines) continues to work — internal helper updated to use new field names. Storefronts rendering addresses via `formatted` need no changes.
450
+
451
+ ### Costs and totals — nested wrappers everywhere
452
+
453
+ Cart, Checkout, and Order now expose monetary breakdowns via dedicated wrapper types instead of flat fields on the parent. This adds a single hop in selections but makes adding new cost dimensions (duty, fees, surcharges) non-breaking, and brings symmetry across the three checkout-flow stages:
454
+
455
+ ```graphql
456
+ # CartCost — drop "Amount" suffix from field names (Money type implies amount)
457
+ fragment CartCost on CartCost {
458
+ subtotal {
459
+ ...Money
460
+ } # was: subtotalAmount
461
+ total {
462
+ ...Money
463
+ } # was: totalAmount
464
+ totalTax {
465
+ ...Money
466
+ } # was: totalTaxAmount
467
+ totalDuty {
468
+ ...Money
469
+ } # was: totalDutyAmount
470
+ checkoutCharge {
471
+ ...Money
472
+ } # was: checkoutChargeAmount
473
+ }
474
+
475
+ # CartLineCost — drop "Amount" suffix + "amountPerQuantity" → "pricePerUnit"
476
+ fragment CartLineCost on CartLineCost {
477
+ pricePerUnit {
478
+ ...Money
479
+ } # was: amountPerQuantity
480
+ subtotal {
481
+ ...Money
482
+ } # was: subtotalAmount
483
+ total {
484
+ ...Money
485
+ } # was: totalAmount
486
+ compareAtPricePerUnit {
487
+ ...Money
488
+ } # was: compareAtAmountPerQuantity
489
+ }
490
+
491
+ # CheckoutCost — NEW wrapper, replaces flat checkout fields
492
+ query Checkout {
493
+ checkout {
494
+ cost {
495
+ subtotal {
496
+ ...Money
497
+ } # was: Checkout.subtotalPrice
498
+ total {
499
+ ...Money
500
+ } # was: Checkout.totalPrice
501
+ totalTax {
502
+ ...Money
503
+ } # was: Checkout.totalTax (now nested)
504
+ totalShipping {
505
+ ...Money
506
+ } # was: Checkout.totalShippingPrice
507
+ totalDiscounts {
508
+ ...Money
509
+ } # was: Checkout.totalDiscounts (now nested)
510
+ }
511
+ }
512
+ }
513
+
514
+ # OrderTotals — NEW wrapper, replaces flat order fields
515
+ query CustomerOrder {
516
+ customerOrder {
517
+ totals {
518
+ subtotal {
519
+ ...Money
520
+ } # was: Order.subtotalPrice
521
+ total {
522
+ ...Money
523
+ } # was: Order.totalPrice
524
+ totalTax {
525
+ ...Money
526
+ } # was: Order.totalTax (now nested)
527
+ totalShipping {
528
+ ...Money
529
+ } # was: Order.totalShipping (now nested)
530
+ }
531
+ }
532
+ }
533
+
534
+ # CheckoutLineItem — line price field renames
535
+ fragment CheckoutLineItem on CheckoutLineItem {
536
+ pricePerUnit {
537
+ ...Money
538
+ } # was: unitPrice
539
+ total {
540
+ ...Money
541
+ } # was: totalPrice
542
+ }
543
+ ```
544
+
545
+ Conversion-transparency opt-in fields (`*WithConversion`) are renamed in lockstep:
546
+ - `subtotalAmountWithConversion` → `subtotalWithConversion`
547
+ - `totalAmountWithConversion` → `totalWithConversion`
548
+ - `amountPerQuantityWithConversion` → `pricePerUnitWithConversion`
549
+ - ...and so on.
550
+
551
+ ### Cart line: simplified shape — `variant` replaces `merchandise` union
552
+
553
+ `CartLine.merchandise: Merchandise!` (a union currently with only `ProductVariant` member) and the `BaseCartLine` interface have been removed. `CartLine` is now a concrete type with a direct `variant: ProductVariant!` field — no union wrapper, no inline fragments needed:
554
+
555
+ ```graphql
556
+ # Before (4.x)
557
+ fragment CartLine on CartLine {
558
+ id
559
+ quantity
560
+ merchandise {
561
+ ... on ProductVariant {
562
+ id
563
+ title
564
+ price { amount currencyCode }
565
+ }
566
+ }
567
+ cost { ... }
568
+ }
569
+
570
+ # After (5.x) — direct field, no union/inline fragment
571
+ fragment CartLine on CartLine {
572
+ id
573
+ quantity
574
+ variant {
575
+ id
576
+ title
577
+ price { amount currencyCode }
578
+ }
579
+ cost { ... }
580
+ }
581
+ ```
582
+
583
+ Cart connection types renamed for the same reason: `BaseCartLineConnection` / `BaseCartLineEdge` → `CartLineConnection` / `CartLineEdge`. Update fragment selections accordingly:
584
+
585
+ ```graphql
586
+ # Before (4.x)
587
+ query Cart($id: ID!) {
588
+ cart(id: $id) {
589
+ lines(first: 10) {
590
+ edges {
591
+ node {
592
+ ... on CartLine {
593
+ id
594
+ }
595
+ }
596
+ }
597
+ nodes {
598
+ ... on CartLine {
599
+ id
600
+ }
601
+ }
602
+ }
603
+ }
604
+ }
605
+
606
+ # After (5.x)
607
+ query Cart($id: ID!) {
608
+ cart(id: $id) {
609
+ lines(first: 10) {
610
+ edges {
611
+ node {
612
+ id
613
+ }
614
+ }
615
+ nodes {
616
+ id
617
+ }
618
+ }
619
+ }
620
+ }
621
+ ```
622
+
623
+ Cart line input renamed `merchandiseId` → `variantId` (consistent with the output field):
624
+
625
+ ```graphql
626
+ # Before (4.x)
627
+ mutation CartAddLines($id: ID!, $lines: [CartLineInput!]!) {
628
+ cartAddLines(id: $id, lines: $lines) { ... }
629
+ }
630
+ # variables: { lines: [{ merchandiseId: "uuid", quantity: 1 }] }
631
+
632
+ # After (5.x)
633
+ mutation CartAddLines($id: ID!, $lines: [CartLineInput!]!) {
634
+ cartAddLines(id: $id, lines: $lines) { ... }
635
+ }
636
+ # variables: { lines: [{ variantId: "uuid", quantity: 1 }] }
637
+ ```
638
+
639
+ The `variantId` name also matches the `userErrors[].field` path returned for input-related errors (e.g. `field: ["lines", "variantId"]`).
640
+
641
+ ### Recommendations: top-level queries replaced by field resolvers
642
+
643
+ Two top-level queries were removed. Use the field resolvers on the parent entity instead — they return identical payloads with automatic context inheritance, cleaner cache invalidation, and a clearer parent-not-found vs empty-recommendations distinction:
644
+
645
+ ```graphql
646
+ # Before (4.x)
647
+ query SimilarProducts($productId: String!, $first: Int) {
648
+ similarProducts(productId: $productId, first: $first) {
649
+ items {
650
+ product {
651
+ id
652
+ title
653
+ }
654
+ type
655
+ score
656
+ }
657
+ totalCount
658
+ }
659
+ }
660
+ query CartRecommendations($cartId: String!, $first: Int) {
661
+ cartRecommendations(cartId: $cartId, first: $first) {
662
+ frequentlyBoughtTogether {
663
+ product {
664
+ id
665
+ }
666
+ }
667
+ youMayAlsoLike {
668
+ product {
669
+ id
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ # After (5.x) — same payloads, parent-scoped
676
+ query Product($id: ID!, $first: Int) {
677
+ product(id: $id) {
678
+ recommendations(first: $first) {
679
+ items {
680
+ product {
681
+ id
682
+ title
683
+ }
684
+ type
685
+ score
686
+ }
687
+ totalCount
688
+ }
689
+ }
690
+ }
691
+ query Cart($id: ID!, $first: Int) {
692
+ cart(id: $id) {
693
+ recommendations(first: $first) {
694
+ frequentlyBoughtTogether {
695
+ product {
696
+ id
697
+ }
698
+ }
699
+ youMayAlsoLike {
700
+ product {
701
+ id
702
+ }
703
+ }
704
+ }
705
+ }
706
+ }
707
+ ```
708
+
709
+ For broader product-scoped recommendations driven by intent (`SIMILAR | COMPLEMENTARY | UPSELL`), use the existing top-level `productRecommendations(productId, intent, limit)` query, which returns a flat `[Product!]!` payload — separate surface from the rich `Product.recommendations` field (which carries metadata `type`, `score`, `reason` per item).
710
+
711
+ ### Minor Changes
712
+
713
+ - e2e23ee: Wave 3.1/3.2 — Customer email flows end-to-end: welcome email po rejestracji + URL-based activation + URL-based password reset.
714
+
715
+ **Nowe mutations**:
716
+ - `customerActivateByUrl(activationUrl: String!, password: String!): CustomerActivateByUrlPayload!` — admin tworzy customer'a bez hasła → email aktywacyjny → klient klika link → ustawia hasło → auto-login.
717
+ - `customerResetByUrl(resetUrl: String!, password: String!): CustomerResetByUrlPayload!` — modern UX dla password reset (klik link w mailu zamiast ręcznego kopiowania tokenu).
718
+
719
+ **Nowe error codes** (`CustomerErrorCode` enum):
720
+ - `TOKEN_USED` — token został już wykorzystany (one-shot semantics).
721
+ - `INVALID_URL` — URL aktywacji/resetu malformed.
722
+
723
+ **Welcome email po `customerCreate`**: rejestracja triggeruje branded per-shop welcome email — outbox-driven, fully async (resolver odpowiada natychmiast, email queued via worker).
724
+
725
+ **Password reset email**: dokończenie `customerRecover` flow (wcześniej był TODO line 499). Plaintext token NIE przechodzi przez RabbitMQ — worker generuje token po consume outbox event.
726
+
727
+ **Bezpieczeństwo**:
728
+ - 256-bit random tokens (`CryptoHelper.generateToken(32)`), SHA-256 hash storage w `customer_activation_tokens` table.
729
+ - TTL 24h dla activation, 1h dla password reset.
730
+ - Atomic UPDATE z `AND used_at IS NULL` zapobiega replay attack (race condition).
731
+ - `customerResetByUrl` + `customerReset` (legacy) bindują token do customer ID — defense vs forged URL pairing valid token z innym customer'em.
732
+ - Rate limiting (`@Throttle` 10 req/10min prod, bot protection guard) na nowych mutations.
733
+ - Anti-enumeration: `customerRecover` nieistniejący email → ZERO outbox events (silent fake-success).
734
+
735
+ **Architektura** (per `event-driven.md` + `feedback_outbox_docs_first.md`):
736
+ - `TenantOutboxService.append({ tx })` w `prisma.$transaction` z domain mutations (atomic — żadnych orphaned emails na rollback).
737
+ - 3 nowe outbox event types: `CUSTOMER_REGISTERED`, `CUSTOMER_ACTIVATION_REQUESTED`, `CUSTOMER_PASSWORD_RESET_REQUESTED`.
738
+ - `CustomerEventWorker` (RabbitMQ consumer) z `processed_customer_events` idempotency dedup + `OnModuleDestroy` graceful drain.
739
+ - `CommerceEmailService` extended o `sendCustomerWelcome` + `sendCustomerActivation` (per-shop branded, suppression/consent/rate limit reused).
740
+
741
+ **Nowe templates** w `templates/commerce/`: `customer_welcome.hbs`, `customer_account_activation.hbs`, `customer_password_reset.hbs` — 30+ i18n keys (PL+EN).
742
+
743
+ **Test coverage**: 6 nowych integration specs, 40 scenariuszy (registration outbox, welcome worker, activation E2E z security cases, reset URL z token mismatch + race, worker edge cases, email service contract). Plus 2 nowe scenariusze w istniejącym `customer.graphql.spec.ts` dla outbox event assertion.
744
+
745
+ **Wymagania storefront-developera**: dwie nowe strony `/account/activate` i `/account/reset` które parsują `window.location.href` i wywołują odpowiednie mutation z całym URL-em jako argument.
746
+
747
+ - 0048bf9: Dodano pole `tags: [String!]!` na typie `Customer` w GraphQL Storefront API — segmentacja klientów po stronie storefront-developera.
748
+
749
+ **Nowe pole**:
750
+ - `Customer.tags` — array tagów segmentacyjnych (np. `["vip", "wholesale", "b2b"]`). Backend ma już kolumnę `customers.tags` z GIN index dla performance.
751
+
752
+ **Use case dla storefront-developera**:
753
+ - Warunkowy rendering UI w `/account` — badge "VIP" gdy `tags.includes('vip')`.
754
+ - Cart pricing logic — dostęp do private/wholesale produktów dla klientów z odpowiednim tagiem.
755
+ - Customer-specific banners / promocje na storefront.
756
+
757
+ **Side fix**:
758
+ - Fragment `Customer` używa teraz `numberOfOrders: UnsignedInt64!` (zgodnie ze schema po Wave 2.6) zamiast nieaktualnego `ordersCount`.
759
+
760
+ **Test coverage**: 4 nowe E2E integration tests (empty array dla nowo utworzonego, single tag, multi-tag w prawidłowej kolejności, auth boundary — brak leak'u tagów dla unauthenticated requests).
761
+
762
+ - 9a4a447: Dodano `predictiveSearch(query, limit)` query do GraphQL Storefront API — typeahead suggestions dla search bara w storefront UI.
763
+
764
+ **Nowa query**:
765
+ - `predictiveSearch(query: String!, limit: Int = 10): PredictiveSearchResult!`
766
+ - Returns: `{ products: [Product!]!, queries: [SearchQuerySuggestion!]! }` gdzie `SearchQuerySuggestion = { text, styledText }`
767
+ - `styledText` zawiera `<mark>` highlights dla query matching (gotowe do `dangerouslySetInnerHTML` po stronie storefronta)
768
+
769
+ **Use case dla storefront-developera**:
770
+ - Search bar typeahead — debounce 200ms client-side, server respondzie <50ms (wykorzystuje istniejący PostgreSQL FTS GIN index)
771
+ - 10 produktów + 5 query suggestions per call (server-side cap)
772
+ - Fallback gdy 0 wyników: pokaz `queries[]` jako "did you mean" suggestions
773
+
774
+ **Bezpieczeństwo**:
775
+ - Query length capped na 100 chars (DoS protection)
776
+ - `styledText` HTML-escaped — tylko `<mark>`/`</mark>` dozwolone (XSS guard, defense-in-depth)
777
+ - Tylko `ACTIVE` + `PUBLIC` visibility produkty surfaced (no leak of HIDDEN/BUNDLE_ONLY)
778
+
779
+ **Test coverage**: 10 E2E integration scenariuszy (prefix match, polskie znaki, multi-word AND, empty query, limit cap, visibility/status filters, XSS safety, dedup).
780
+
3
781
  ## 5.5.0
4
782
 
5
783
  ### Minor Changes