@reblu/site-contracts 0.5.0 → 1.0.1

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 ADDED
@@ -0,0 +1,304 @@
1
+ # @reblu/site-contracts
2
+
3
+ ## 1.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 9f54c58: REB-774 — ship `CHANGELOG.md` inside the published npm tarball for every
8
+ `@reblu/site-*` package (verified via `pnpm pack`: it was previously absent,
9
+ since `"files": ["dist"]` limited the tarball to `dist/` and npm only
10
+ auto-includes `README.md`, `LICENSE` and `package.json`). Consumers can now
11
+ read the changelog for the version they installed straight from
12
+ `node_modules/@reblu/<pkg>/CHANGELOG.md`.
13
+
14
+ ## 1.0.0
15
+
16
+ ### Major Changes
17
+
18
+ - 340db30: REB-659 — phone fields on the SITE API are now E.164, and two public boundaries
19
+ changed behaviour. A consuming SITE should read this before upgrading.
20
+
21
+ **Type change.** `ShopAddressInput.phone` and `ShopCheckoutInput.customerPhone`
22
+ widen from `string | undefined` to `string | null | undefined`. `null` is now a
23
+ real state meaning "clear this phone", which the server accepts and the previous
24
+ type could not express. Existing code that only ever assigns a string keeps
25
+ compiling.
26
+
27
+ **Behaviour change — `POST /api/site/contact-form` and `POST /api/site/leads`
28
+ (more permissive).** These used to store whatever string arrived. They now
29
+ resolve it to E.164 against the tenant's configured country
30
+ (`SiteSettings.addressCountry` in nexus). A form that posts what a person typed
31
+ — `922 123 456`, `(922) 123-456`, `0034 922 123 456` — keeps working and the
32
+ value is stored as `+34922123456`.
33
+
34
+ On `/api/site/leads` the phone stays optional: an absent key, `''` and
35
+ whitespace all store no phone, exactly as before.
36
+
37
+ A **non-blank** value that cannot be stored now returns an error instead of an
38
+ undialable string. There are two branches, and they answer differently:
39
+ - **shape** — shorter than 6 or longer than 32 characters, or containing
40
+ anything outside digits, spaces, `+`, dashes, parentheses and dots. An
41
+ extension always lands here, whichever way it is written (`x22`, `ext. 22`,
42
+ `;ext=22`, `#22`, `,22`), because every marker for one is outside that
43
+ allowlist — E.164 cannot represent an extension, so it is refused rather than
44
+ silently truncated to the switchboard. `/api/site/contact-form` answers 400;
45
+ `/api/site/leads` answers **422**, its long-standing status for a malformed
46
+ payload on any field. On that 422 the actionable message is in
47
+ `details.fieldErrors.phone` — the top-level `error` is the generic
48
+ `Validation error`.
49
+ - **resolution** — looks like a phone but cannot be resolved: the library does
50
+ not recognise it, or it lacks an international prefix while the tenant has no
51
+ country configured. **400** on both endpoints, with the message in `error` and
52
+ in `details.fieldErrors.phone`.
53
+
54
+ So: read `details.fieldErrors.phone` rather than branching on the status, and
55
+ do not assume the two endpoints answer with the same code for a bad shape.
56
+
57
+ On both endpoints the resolution prefers the LOCAL reading for a prefix-less
58
+ number over the international one, because the caller is typing (or posting)
59
+ into one specific tenant's form. The residual: a prefix-less number that is
60
+ also a valid national number of the tenant's own country is normalised into
61
+ that country rather than refused — for a Spanish tenant, Myanmar digits
62
+ `951234567` become `+34951234567`. The alternative — preferring the
63
+ international reading — reopens a different and worse class this file's own
64
+ header documents (a US number silently reading as Egyptian), so this is a
65
+ chosen trade, not an oversight. Quantifying how often the residual is actually
66
+ hit is a follow-up issue.
67
+
68
+ **Read-path change — `GET /api/site/config` (same shape, different VALUE).**
69
+ No field was added or removed here, but the VALUE of `address.telephone`
70
+ changes once the phone backfill runs against a tenant: today it can read
71
+ `34922123456` (the legacy shape stored before this issue); after the backfill
72
+ it reads `+34922123456`. Any SITE that pattern-matches or otherwise assumes the
73
+ prefix-less shape when rendering this field (a `tel:`/`wa.me` link builder,
74
+ for instance) should read this before the backfill runs against its tenant.
75
+
76
+ **Behaviour change — `POST /api/site/shop/checkout` (stricter).**
77
+ `customerPhone` and `shippingAddress.phone` must now be E.164 (`+34…`) or
78
+ absent; a bare local number is rejected. Unlike the contact form this payload is
79
+ assembled by the SITE from a cart rather than typed by a person at that moment,
80
+ so there is no "typed locally for this tenant" provenance to resolve against.
81
+
82
+ **Why the SDK does not build E.164 for you.** Every SITE would construct it
83
+ differently and each would be wrong in its own way — which is the defect this
84
+ issue fixes one layer down. The platform owns the rule because the platform is
85
+ the layer that knows the tenant's country. A shared formatter helper for SITEs
86
+ is REB-703, and it comes after this.
87
+
88
+ ***
89
+
90
+ **This release is `1.0.0`, and that is deliberate — see REB-755.**
91
+
92
+ `@reblu/site-contracts` has been at `0.x` while `@reblu/site-client` reached
93
+ 4.0.1. That ordering is hard to defend, and it had a concrete cost: consumers
94
+ pin `^0.5.0`, and **a caret on `0.x` does not cross the minor** (verified with
95
+ `semver@7`: `^0.5.0` admits `0.5.3`, not `0.6.0`). So every change — additive or
96
+ breaking — cost every consuming SITE repo a hand edit. The careful work of
97
+ deciding whether a change breaks anything bought nobody anything.
98
+
99
+ This release is breaking regardless, so the adoption is already owed. Shipping
100
+ `1.0.0` here means the toll and the breaking change are **one edit rather than
101
+ two** — a bare `1.0.0` later would make this a `2.0.0` and cost an extra
102
+ adoption for nothing.
103
+
104
+ From here, `^1.0.0` carries every additive change automatically. REB-651
105
+ (SEO fields, additive by design) lands as `1.1.0` and reaches consumers with no
106
+ edit. REB-665 reshapes `Config` and will be `2.0.0`.
107
+
108
+ `1.0.0` is not a claim that the contract is finished. It is a claim that
109
+ breakage will be signalled through the major — which we were doing anyway, into
110
+ a version range where it had no effect.
111
+
112
+ ## 0.5.0
113
+
114
+ ### Minor Changes
115
+
116
+ - 7dcfb4f: `Config` gains a `fiscal` block — the tenant's tax identifier, registered name
117
+ and official registry entry — plus the types `ConfigFiscal`,
118
+ `ConfigFiscalRegistry` and `ConfigTaxIdKind`.
119
+
120
+ A Spanish commercial site is legally required to publish its tax identifier, and
121
+ until now no tenant could enter one: `SiteSettings` had no `cif`, no `nif`, no
122
+ `taxId` and no registered name distinct from the trading name. Every SITE that
123
+ needed one therefore hardcoded it — the Dermamedicin footer carries
124
+ `B-XXXXXXXX` as a literal in its own repo, which is wrong for every other tenant
125
+ by construction. REB-73 (downloadable PDF invoice) was silently blocked on the
126
+ same gap: an invoice without the issuer's CIF is not a valid invoice.
127
+
128
+ ## Shape
129
+
130
+ ```ts
131
+ type ConfigFiscal = {
132
+ taxId: string | null
133
+ taxIdKind: 'NIF' | 'CIF' | null
134
+ legalName: string | null
135
+ registry: { name: string; number: string } | null
136
+ }
137
+
138
+ // on Config:
139
+ fiscal: ConfigFiscal | null
140
+ ```
141
+
142
+ ## `taxIdKind` — why the SITE is told which document it is holding
143
+
144
+ A tenant holds exactly one tax identifier, but it is not one document. In Spain
145
+ an autónomo files a **NIF** (8 digits + a mod-23 control letter, or a NIE for a
146
+ foreign resident) and every other legal form files a **CIF** (organisation
147
+ letter + 7 digits + a control character that is a digit for some organisation
148
+ letters and a letter for others). Which applies is derivable only from
149
+ `businessType`.
150
+
151
+ So `taxId` alone would force every SITE to re-implement that derivation, and a
152
+ SITE that hardcoded `CIF:` would print the wrong word next to an autónomo's
153
+ number. `taxIdKind` carries the platform's single derivation, and a SITE renders
154
+ the label verbatim:
155
+
156
+ ```tsx
157
+ {
158
+ config.fiscal?.taxId && (
159
+ <span>
160
+ {config.fiscal.taxIdKind}: {config.fiscal.taxId}
161
+ </span>
162
+ )
163
+ }
164
+ ```
165
+
166
+ `taxId` is stored normalised (uppercase, separators stripped), so it is safe to
167
+ render as-is.
168
+
169
+ ## `legalName` carries no fallback, on purpose
170
+
171
+ `legalName` is the registered name / razón social, which routinely differs from
172
+ the trading name in `business.name` ("Dermamedicin" vs "Dermamedicin Canarias,
173
+ S.L."). It is `null` when unset and does **not** silently fall back to the
174
+ trading name: a legal footer and an invoice need the registered one, and quietly
175
+ substituting the other would publish the wrong name on a document with legal
176
+ weight. A SITE that wants a fallback writes it itself, seeing that it is doing
177
+ so.
178
+
179
+ ## `registry` is sector-agnostic, and both-or-nothing
180
+
181
+ `registry` is a free-text `name` plus a `number`, not a `healthRegistryNumber`.
182
+ Every regulated sector is entered in one of these and only the registry's name
183
+ differs: Registro Sanitario for a clinic, RGSEAA for a restaurant, Registro de
184
+ Turismo for a hotel, a colegiado number for a self-employed professional, a
185
+ Registro Mercantil entry for a company. Naming the field after the first
186
+ tenant's sector would put medical vocabulary into a niche-agnostic contract,
187
+ which is the defect REB-628 exists to undo.
188
+
189
+ The object is `null` unless **both** members were filled in. A bare number with
190
+ no registry named is unreadable in a footer; a registry named with no number is
191
+ decorative.
192
+
193
+ ## Absence
194
+
195
+ `fiscal` is `null` when the tenant has entered nothing at all — the same shape
196
+ `address` and `social` already use. That is the signal to render **no** legal
197
+ block, rather than a block of empty labels. The underlying columns are nullable
198
+ with no default and are never backfilled, so "empty" here means "the owner has
199
+ never filled this in", never "filled in blank".
200
+
201
+ Additive: no existing field changed and no existing consumer breaks.
202
+
203
+ ## 0.4.0
204
+
205
+ ### Minor Changes
206
+
207
+ - eef13bf: Expose typed professional-profile fields on `Page`/`pages.list`, widen the list filter beyond the legacy DB page type, and add the pagination/sort params `pages.list` was missing.
208
+
209
+ `pageType='team'` pages carry a structured, sector-agnostic professional
210
+ profile (role, structured CV, social links) — previously trapped in
211
+ `Page.metadata`, which never reached the wire. `pageListItemSchema` and
212
+ `pageDetailSchema` now expose it as a typed `professional` field (`null` for
213
+ every other pageType).
214
+ - `@reblu/site-contracts` (minor, additive-only) publishes `ProfessionalSummary`,
215
+ `ProfessionalProfile`, `ProfessionalCv`, `ProfessionalCvEntry`,
216
+ `ProfessionalLanguage`, `ProfessionalLanguageLevel`, `ProfessionalSocialLink`,
217
+ `ProfessionalSocialPlatform`, and `PagesListSort`.
218
+ - `@reblu/site-client` (major — see below) `pages.list({ type })` is widened
219
+ from `LegacyPageType` (SERVICE|CUSTOM) to the flexible `PageType`
220
+ (service|custom|treatments|specialties|team|generic) — the value space the
221
+ route actually filters on. A typed SITE can now request `type: 'team'`
222
+ without escaping its own types. `type` also accepts an array for a
223
+ multi-type filter (repeated `?type=`).
224
+ - `pages.list` also gains `page`/`pageSize`/`sort` — `pagesListParamsSchema`
225
+ already accepted them and the route already returns a populated `meta`
226
+ (total/page/limit/pages), but `PagesListParams` exposed none of it, so a
227
+ typed caller had no way to request page 2 or change sort. Note the param
228
+ is `pageSize`, not `limit` — pages and blog genuinely differ (this route
229
+ reads `pageSize`; blog's reads `limit`), while the response `meta.limit`
230
+ key is the same word for a different request param on this endpoint.
231
+
232
+ **Breaking (major, not minor):** `PagesListParams.type`'s value space changes
233
+ from `LegacyPageType` to `PageType` — a caller passing `'SERVICE'`/`'CUSTOM'`
234
+ literally will no longer typecheck. No runtime behavior changes for existing
235
+ callers: the route was already silently dropping those values, since it only
236
+ ever validated the lowercase flexible set — but the type change itself is a
237
+ real breaking change for compilation, so this ships as major rather than
238
+ minor even though nothing behaves differently at runtime.
239
+
240
+ ## 0.3.0
241
+
242
+ ### Minor Changes
243
+
244
+ - 495d32d: Relax the contact-form input contract and add a `NOT_CONFIGURED` contact-form mode.
245
+
246
+ `ContactFormInput` now marks `surname` and `businessName` OPTIONAL. The contract
247
+ had required both from every caller, which made it a B2B lead-capture shape: a
248
+ clinic patient, a diner or a shopper has no company name, and a payload without
249
+ one died with a 400 before the captcha was ever consulted. Purely a relaxation —
250
+ a caller that already sends both keeps typechecking unchanged.
251
+
252
+ `ContactFormMode` gains a fifth member, `NOT_CONFIGURED`. No tenant was ever
253
+ provisioned with a `site_integration_config` row, and every read site coalesced
254
+ the absent row to `DISABLED` — so a brand-new tenant's SITE was told its owner had
255
+ switched the contact form off, a decision nobody had made. `NOT_CONFIGURED` is
256
+ that missing state named honestly: nobody has chosen yet. It is never selectable
257
+ by an owner; only provisioning writes it.
258
+
259
+ **Consumers doing an exhaustive `switch` on `ContactFormMode` must handle the new
260
+ member.** Treat it as non-operational — like `DISABLED`, it accepts no
261
+ submissions. The two differ only in what they say about _why_, which is the point:
262
+ a SITE can reasonably prompt the owner to finish setup on `NOT_CONFIGURED` while
263
+ staying quiet on `DISABLED`.
264
+
265
+ Note this is a minor rather than a patch because of that enum widening. The
266
+ package is pre-1.0, so a `switch` without a default is the realistic break.
267
+
268
+ ## 0.2.0
269
+
270
+ ### Minor Changes
271
+
272
+ - d081f12: Expose the academy course catalogue through the SDK.
273
+
274
+ The headless academy endpoints shipped in REB-528/REB-534, but their contract
275
+ file was never re-exported from base-api's contracts barrel, so the types could
276
+ not reach `@reblu/site-contracts` and SITEs had no typed way to call them.
277
+ - `@reblu/site-contracts` publishes the catalogue DTOs: `AcademyCourseSummary`,
278
+ `AcademyCourseDetail`, `AcademyCategoryPublic`, `AcademyModulePublic`,
279
+ `AcademyLessonPublic`, `AcademyInstructorPublic`, and the `AcademyCourseSort`
280
+ query union.
281
+ - `@reblu/site-client` adds the `academy` namespace: `academy.courses(params?)`
282
+ (filtered + paginated), `academy.bySlug(slug)`, and `academy.categories()`.
283
+ `sort` is typed as a union because the server silently ignores an unknown key,
284
+ so a typo is now a compile error instead of a no-op falling back to default
285
+ order.
286
+
287
+ Additive only — no existing export changes shape.
288
+
289
+ ## 0.1.2
290
+
291
+ ### Patch Changes
292
+
293
+ - 356e04d: Republish with `dist/` included and shop surface exposed.
294
+
295
+ `0.1.0` was published broken: the bootstrap publish ran without a build, so the
296
+ tarball shipped only `LICENSE`, `README.md`, and `package.json` — `dist/` was
297
+ absent and `main`/`module`/`types` pointed at files that did not exist, making
298
+ both packages unusable when installed.
299
+ - Add a `prepack` lifecycle script to both packages so `pnpm pack` / `npm pack`
300
+ / `npm publish` always run the build and emit `dist/` into the tarball. Build
301
+ before publish is now enforced by the package itself, not only by CI.
302
+ - `@reblu/site-client` ships the `shop` domain (`domains/shop.ts`) and
303
+ `@reblu/site-contracts` exports the matching shop types, so the SITE API shop
304
+ surface is available to external consumers.
package/dist/index.d.cts CHANGED
@@ -113,11 +113,11 @@ export type ProfessionalSummary = { role: string | null;}
113
113
 
114
114
  export type SectionLayout = "TEXT" | "TEXT_IMAGE" | "IMAGE_TEXT" | "IMAGE_FULL" | "FEATURE_LIST" | "GALLERY" | "CTA_BLOCK"
115
115
 
116
- export type ShopAddressInput = { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined;}
116
+ export type ShopAddressInput = { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined;}
117
117
 
118
118
  export type ShopApplyDiscountInput = { code: string; orderSubtotal: number; customerEmail?: string | undefined;}
119
119
 
120
- export type ShopBillingInput = { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined; } | undefined;}
120
+ export type ShopBillingInput = { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined; } | undefined;}
121
121
 
122
122
  export type ShopBundleComponent = { quantity: number; component: { id: string; name: string; slug: string; price: number; };}
123
123
 
@@ -133,7 +133,7 @@ export type ShopCategory = { id: string; name: string; slug: string;
133
133
 
134
134
  export type ShopCheckEmailInput = { email: string;}
135
135
 
136
- export type ShopCheckoutInput = { items: { productId: string; quantity: number; variantId?: string | null | undefined; }[]; customerEmail: string; customerName: string; successUrl: string; cancelUrl: string; customerPhone?: string | undefined; discountCode?: string | undefined; shippingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined; } | undefined; billing?: { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined; } | undefined; } | undefined;}
136
+ export type ShopCheckoutInput = { items: { productId: string; quantity: number; variantId?: string | null | undefined; }[]; customerEmail: string; customerName: string; successUrl: string; cancelUrl: string; customerPhone?: string | null | undefined; discountCode?: string | undefined; shippingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined; } | undefined; billing?: { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined; } | undefined; } | undefined;}
137
137
 
138
138
  export type ShopCheckoutSession = { url: string; sessionId: string;}
139
139
 
package/dist/index.d.ts CHANGED
@@ -113,11 +113,11 @@ export type ProfessionalSummary = { role: string | null;}
113
113
 
114
114
  export type SectionLayout = "TEXT" | "TEXT_IMAGE" | "IMAGE_TEXT" | "IMAGE_FULL" | "FEATURE_LIST" | "GALLERY" | "CTA_BLOCK"
115
115
 
116
- export type ShopAddressInput = { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined;}
116
+ export type ShopAddressInput = { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined;}
117
117
 
118
118
  export type ShopApplyDiscountInput = { code: string; orderSubtotal: number; customerEmail?: string | undefined;}
119
119
 
120
- export type ShopBillingInput = { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined; } | undefined;}
120
+ export type ShopBillingInput = { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined; } | undefined;}
121
121
 
122
122
  export type ShopBundleComponent = { quantity: number; component: { id: string; name: string; slug: string; price: number; };}
123
123
 
@@ -133,7 +133,7 @@ export type ShopCategory = { id: string; name: string; slug: string;
133
133
 
134
134
  export type ShopCheckEmailInput = { email: string;}
135
135
 
136
- export type ShopCheckoutInput = { items: { productId: string; quantity: number; variantId?: string | null | undefined; }[]; customerEmail: string; customerName: string; successUrl: string; cancelUrl: string; customerPhone?: string | undefined; discountCode?: string | undefined; shippingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined; } | undefined; billing?: { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | undefined; } | undefined; } | undefined;}
136
+ export type ShopCheckoutInput = { items: { productId: string; quantity: number; variantId?: string | null | undefined; }[]; customerEmail: string; customerName: string; successUrl: string; cancelUrl: string; customerPhone?: string | null | undefined; discountCode?: string | undefined; shippingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined; } | undefined; billing?: { billingName: string; billingNif?: string | undefined; billingEmail?: string | undefined; billingAddress?: { firstName: string; lastName: string; street: string; city: string; postalCode: string; region?: string | undefined; country?: string | undefined; phone?: string | null | undefined; } | undefined; } | undefined;}
137
137
 
138
138
  export type ShopCheckoutSession = { url: string; sessionId: string;}
139
139
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reblu/site-contracts",
3
- "version": "0.5.0",
3
+ "version": "1.0.1",
4
4
  "private": false,
5
5
  "description": "Public, dependency-free TypeScript types for the Reblu SITE API (types only — no Zod, no Prisma at runtime).",
6
6
  "license": "Apache-2.0",
@@ -43,7 +43,8 @@
43
43
  }
44
44
  },
45
45
  "files": [
46
- "dist"
46
+ "dist",
47
+ "CHANGELOG.md"
47
48
  ],
48
49
  "devDependencies": {
49
50
  "@size-limit/preset-small-lib": "^11.1.6",