@doswiftly/storefront-operations 6.0.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,74 @@
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
+
3
72
  ## 6.0.0
4
73
 
5
74
  ### Major Changes
package/fragments.graphql CHANGED
@@ -285,6 +285,11 @@ fragment Customer on Customer {
285
285
  isEmailVerified
286
286
  emailMarketing
287
287
  tags
288
+ customerType
289
+ companyName
290
+ taxId
291
+ vatNumber
292
+ regon
288
293
  defaultAddress {
289
294
  ...MailingAddress
290
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doswiftly/storefront-operations",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "GraphQL operations for DoSwiftly Storefront - SSOT from backend",
5
5
  "homepage": "https://doswiftly.pl",
6
6
  "publishConfig": {
package/schema.graphql CHANGED
@@ -2010,9 +2010,15 @@ type Customer implements Node {
2010
2010
  """Saved addresses (Relay Connection)"""
2011
2011
  addresses(after: String, before: String, first: Int, last: Int): MailingAddressConnection!
2012
2012
 
2013
+ """Company name (populated for COMPANY type)"""
2014
+ companyName: String
2015
+
2013
2016
  """Account creation date"""
2014
2017
  createdAt: DateTime!
2015
2018
 
2019
+ """Business type discriminator (INDIVIDUAL/COMPANY)"""
2020
+ customerType: CustomerType!
2021
+
2016
2022
  """Default address"""
2017
2023
  defaultAddress: MailingAddress
2018
2024
 
@@ -2046,14 +2052,23 @@ type Customer implements Node {
2046
2052
  """Phone number"""
2047
2053
  phone: String
2048
2054
 
2055
+ """Polish business registry number — REGON"""
2056
+ regon: String
2057
+
2049
2058
  """Customer tags for segmentation (e.g. vip, wholesale, b2b)"""
2050
2059
  tags: [String!]!
2051
2060
 
2061
+ """Polish tax ID — NIP"""
2062
+ taxId: String
2063
+
2052
2064
  """Total amount spent"""
2053
2065
  totalSpent: Money!
2054
2066
 
2055
2067
  """Last update date"""
2056
2068
  updatedAt: DateTime!
2069
+
2070
+ """EU VAT number"""
2071
+ vatNumber: String
2057
2072
  }
2058
2073
 
2059
2074
  """Customer access token"""
@@ -2103,6 +2118,11 @@ type CustomerAddAddressPayload {
2103
2118
 
2104
2119
  """Input for customer registration"""
2105
2120
  input CustomerCreateInput {
2121
+ """
2122
+ Opt-in to email marketing checkbox. true → state SUBSCRIBED (single opt-in) unless `marketingOptInLevel: CONFIRMED_OPT_IN` is also set (then PENDING + double opt-in confirmation email). false/null → no consent change.
2123
+ """
2124
+ acceptsMarketing: Boolean
2125
+
2106
2126
  """Email address"""
2107
2127
  email: String!
2108
2128
 
@@ -2112,6 +2132,11 @@ input CustomerCreateInput {
2112
2132
  """Last name"""
2113
2133
  lastName: String
2114
2134
 
2135
+ """
2136
+ Opt-in level. Default SINGLE_OPT_IN (signup = email proven implicitly). Set CONFIRMED_OPT_IN to force double opt-in via confirmation email.
2137
+ """
2138
+ marketingOptInLevel: MarketingOptInLevel
2139
+
2115
2140
  """Password"""
2116
2141
  password: String!
2117
2142
 
@@ -2234,6 +2259,29 @@ type CustomerSignupPayload {
2234
2259
  userErrors: [UserError!]!
2235
2260
  }
2236
2261
 
2262
+ """Input for newsletter subscribe (guest-friendly)."""
2263
+ input CustomerSubscribeToMarketingInput {
2264
+ """Email address to subscribe to newsletter"""
2265
+ email: String!
2266
+
2267
+ """
2268
+ Opt-in level. Guest mutation always enforces CONFIRMED_OPT_IN regardless of value.
2269
+ """
2270
+ marketingOptInLevel: MarketingOptInLevel
2271
+ }
2272
+
2273
+ """Customer business type — INDIVIDUAL (B2C) or COMPANY (B2B)."""
2274
+ enum CustomerType {
2275
+ COMPANY
2276
+ INDIVIDUAL
2277
+ }
2278
+
2279
+ """Input for newsletter unsubscribe (guest-friendly)."""
2280
+ input CustomerUnsubscribeFromMarketingInput {
2281
+ """Email address to unsubscribe"""
2282
+ email: String!
2283
+ }
2284
+
2237
2285
  """Result of address update"""
2238
2286
  type CustomerUpdateAddressPayload {
2239
2287
  """Updated address"""
@@ -2247,6 +2295,17 @@ type CustomerUpdateAddressPayload {
2247
2295
 
2248
2296
  """Input for customer update"""
2249
2297
  input CustomerUpdateInput {
2298
+ """
2299
+ Email marketing toggle. true → state SUBSCRIBED bezpośrednio (auth'd customer = email proven). false → UNSUBSCRIBED. null/undefined → no change.
2300
+ """
2301
+ acceptsMarketing: Boolean
2302
+
2303
+ """Company name (required when customerType is COMPANY)"""
2304
+ companyName: String
2305
+
2306
+ """Business type discriminator (INDIVIDUAL/COMPANY)"""
2307
+ customerType: CustomerType
2308
+
2250
2309
  """First name"""
2251
2310
  firstName: String
2252
2311
 
@@ -2255,6 +2314,15 @@ input CustomerUpdateInput {
2255
2314
 
2256
2315
  """Phone number"""
2257
2316
  phone: String
2317
+
2318
+ """Polish business registry number — REGON (9 or 14 digits with checksum)"""
2319
+ regon: String
2320
+
2321
+ """Polish tax ID — NIP (10 digits with checksum)"""
2322
+ taxId: String
2323
+
2324
+ """EU VAT number (e.g. PL1234567890)"""
2325
+ vatNumber: String
2258
2326
  }
2259
2327
 
2260
2328
  """Result of customer update"""
@@ -3274,6 +3342,15 @@ input MailingAddressInput {
3274
3342
  streetLine2: String
3275
3343
  }
3276
3344
 
3345
+ """
3346
+ Email marketing opt-in level — SINGLE_OPT_IN, CONFIRMED_OPT_IN, UNKNOWN.
3347
+ """
3348
+ enum MarketingOptInLevel {
3349
+ CONFIRMED_OPT_IN
3350
+ SINGLE_OPT_IN
3351
+ UNKNOWN
3352
+ }
3353
+
3277
3354
  """Navigation menu"""
3278
3355
  type Menu {
3279
3356
  """URL handle (e.g., main-menu, footer)"""
@@ -3455,6 +3532,12 @@ type Mutation {
3455
3532
  """Register new customer"""
3456
3533
  customerSignup(input: CustomerCreateInput!): CustomerSignupPayload!
3457
3534
 
3535
+ """Subscribe an email to the newsletter (guest-friendly, double opt-in)."""
3536
+ customerSubscribeToMarketing(input: CustomerSubscribeToMarketingInput!): SubscribeToMarketingPayload!
3537
+
3538
+ """Unsubscribe an email from the newsletter (guest-friendly, idempotent)."""
3539
+ customerUnsubscribeFromMarketing(input: CustomerUnsubscribeFromMarketingInput!): UnsubscribeFromMarketingPayload!
3540
+
3458
3541
  """Update customer profile"""
3459
3542
  customerUpdate(customer: CustomerUpdateInput!): CustomerUpdatePayload!
3460
3543
 
@@ -5662,6 +5745,17 @@ enum StorefrontOrderStatus {
5662
5745
  PROCESSING
5663
5746
  }
5664
5747
 
5748
+ """Payload for customerSubscribeToMarketing mutation"""
5749
+ type SubscribeToMarketingPayload {
5750
+ """
5751
+ Always true (anti-enumeration). Backend may silently skip enqueue if rate-limited or already SUBSCRIBED.
5752
+ """
5753
+ accepted: Boolean!
5754
+
5755
+ """User-facing errors"""
5756
+ userErrors: [UserError!]!
5757
+ }
5758
+
5665
5759
  """Tax line item"""
5666
5760
  type TaxLine {
5667
5761
  """Tax amount"""
@@ -5756,6 +5850,15 @@ Unsigned 64-bit integer, JSON-serialized as String (BigInt-safe). Format: "{inte
5756
5850
  """
5757
5851
  scalar UnsignedInt64
5758
5852
 
5853
+ """Payload for customerUnsubscribeFromMarketing mutation"""
5854
+ type UnsubscribeFromMarketingPayload {
5855
+ """Always true (anti-enumeration)."""
5856
+ accepted: Boolean!
5857
+
5858
+ """User-facing errors"""
5859
+ userErrors: [UserError!]!
5860
+ }
5861
+
5759
5862
  """URL redirect (SEO, store migration)"""
5760
5863
  type UrlRedirect {
5761
5864
  """Source path"""