@bisondesk/website-commons-sdk 1.0.36 → 1.0.38

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.
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+
3
+ const emailSchema = z.string().email().min(1).toLowerCase();
4
+
5
+ export const Contact = z.object({
6
+ email: emailSchema,
7
+ status: z.enum(['unsubscribed', 'subscribed', 'pending']).optional(),
8
+ fields: z
9
+ .object({
10
+ Country: z.string().nullable().optional(),
11
+ PreferredLanguage: z.string().nullable().optional(),
12
+ FirstName: z.string().nullable().optional(),
13
+ LastName: z.string().nullable().optional(),
14
+ })
15
+ .transform((data) => {
16
+ const normalizedData = Object.fromEntries(
17
+ Object.entries(data).filter(([, value]) => value !== undefined)
18
+ );
19
+
20
+ return normalizedData;
21
+ }),
22
+ tags: z.record(z.string(), z.boolean()).optional(),
23
+ });
24
+
25
+ export type Contact = z.input<typeof Contact>;
26
+
27
+ export const CheckUserIsSubscribedRequest = z.object({
28
+ email: emailSchema,
29
+ });
30
+
31
+ export type CheckUserIsSubscribedRequest = z.infer<typeof CheckUserIsSubscribedRequest>;
32
+
33
+ export type ContactStatus = z.infer<typeof Contact.shape.status>;
34
+
35
+ export type CheckUserIsSubscribedResponse = {
36
+ createdAt?: string;
37
+ status?: 'unsubscribed' | 'subscribed' | 'pending';
38
+ };
39
+
40
+ export type UpsertContactResponse = {
41
+ subscribed: boolean;
42
+ };
43
+
44
+ export const SubscribeContactRequest = z.object({
45
+ email: emailSchema,
46
+ language: z.string(),
47
+ });
48
+
49
+ export type SubscribeContactRequest = z.infer<typeof SubscribeContactRequest>;
50
+
51
+ export type SubscribeContactResponse = {
52
+ subscribedAt: string;
53
+ };
@@ -102,6 +102,7 @@ export type TermAggregation<T = string> = {
102
102
  type: AggregationType.term;
103
103
  fieldType: FieldTypes;
104
104
  orderBy?: 'count' | 'field';
105
+ format?: NumberMeta;
105
106
  };
106
107
 
107
108
  export type Range =
@@ -18,7 +18,7 @@ const BaseUserSearch = z
18
18
  type BaseUserSearch = z.infer<typeof BaseUserSearch>;
19
19
 
20
20
  export const UserSearch = BaseUserSearch.extend({
21
- id: z.uuidv7(),
21
+ id: z.string().uuid(),
22
22
  createdBy: z.string(),
23
23
  createdAt: z.string(),
24
24
  });
@@ -0,0 +1,128 @@
1
+ import { UserDetails, VatSchema, isValidVatForCountry } from './user.js';
2
+
3
+ const companyVatSchema = UserDetails.shape.company;
4
+
5
+ describe('VatSchema (standalone)', () => {
6
+ describe('valid', () => {
7
+ test.each([
8
+ { input: 'PT123', expected: 'PT123' },
9
+ { input: 'gb123', expected: 'GB123' },
10
+ { input: 'X', expected: 'X' },
11
+ { input: 'X', expected: 'X' },
12
+ ])('$input -> $expected', ({ input, expected }) => {
13
+ const result = VatSchema.safeParse(input);
14
+ expect(result.success).toBe(true);
15
+ expect(result.data).toBe(expected);
16
+ });
17
+ });
18
+
19
+ test('empty string coerces to undefined', () => {
20
+ const result = VatSchema.safeParse('');
21
+ expect(result.success).toBe(true);
22
+ expect(result.data).toBeUndefined();
23
+ });
24
+ });
25
+
26
+ describe('isValidVatForCountry', () => {
27
+ test.each([
28
+ { vat: 'PT123', country: 'PT', expected: true },
29
+ { vat: 'DE123', country: 'DE', expected: true },
30
+ { vat: 'EL123', country: 'GR', expected: true },
31
+ { vat: 'IE1234567X', country: 'IE', expected: true },
32
+ { vat: 'GB123', country: 'GB', expected: true },
33
+ { vat: 'XI123456789', country: 'GB', expected: true },
34
+ { vat: 'CHE123456789', country: 'CH', expected: true },
35
+ { vat: 'NO123456789', country: 'NO', expected: true },
36
+ { vat: 'ANYTHING', country: 'US', expected: true },
37
+ { vat: '1234567890', country: 'UA', expected: true },
38
+ { vat: '123456789', country: 'BY', expected: true },
39
+ { vat: '123456789', country: 'RS', expected: true },
40
+ { vat: 'DE123', country: 'PT', expected: false },
41
+ { vat: 'GR123', country: 'GR', expected: false },
42
+ { vat: 'PT', country: 'PT', expected: false },
43
+ { vat: 'CH123', country: 'CH', expected: false },
44
+ { vat: 'XI', country: 'GB', expected: false },
45
+ ])('vat=$vat country=$country -> $expected', ({ vat, country, expected }) => {
46
+ expect(isValidVatForCountry({ vat, country })).toBe(expected);
47
+ });
48
+ });
49
+
50
+ describe('UserDetails (full schema)', () => {
51
+ const validBase = {
52
+ email: 'test@example.com',
53
+ company: { country: 'PT', vat: 'PT123456789' },
54
+ marketingChannels: [],
55
+ };
56
+
57
+ test('parses valid full payload', () => {
58
+ const result = UserDetails.safeParse(validBase);
59
+ expect(result.success).toBe(true);
60
+ expect(result.data?.email).toBe('test@example.com');
61
+ expect(result.data?.company.vat).toBe('PT123456789');
62
+ });
63
+
64
+ test('lowercases email', () => {
65
+ const result = UserDetails.safeParse({ ...validBase, email: 'TEST@EXAMPLE.COM' });
66
+ expect(result.success).toBe(true);
67
+ expect(result.data?.email).toBe('test@example.com');
68
+ });
69
+
70
+ test('company vat invalid does not affect other fields parsing', () => {
71
+ const result = UserDetails.safeParse({
72
+ ...validBase,
73
+ company: { country: 'PT', vat: 'DE123' },
74
+ });
75
+ expect(result.success).toBe(false);
76
+ });
77
+
78
+ test('company without vat still valid', () => {
79
+ const result = UserDetails.safeParse({ ...validBase, company: { country: 'PT' } });
80
+ expect(result.success).toBe(true);
81
+ });
82
+ });
83
+
84
+ describe('UserDetails company vat (with country)', () => {
85
+ describe('valid', () => {
86
+ test.each([
87
+ { input: { country: 'PT', vat: 'PT123456789' } },
88
+ { input: { country: 'DE', vat: 'DE123' } },
89
+ { input: { country: 'gr', vat: 'el123' }, expectedVat: 'EL123' },
90
+ { input: { country: 'IE', vat: 'IE1234567X' } },
91
+ { input: { country: 'GB', vat: 'GB123456789' } },
92
+ { input: { country: 'GB', vat: 'XI123456789' } },
93
+ { input: { country: 'CH', vat: 'CHE123456789' } },
94
+ { input: { country: 'NO', vat: 'NO123456789' } },
95
+ { input: { country: 'US', vat: 'ANYTHING123' } },
96
+ { input: { country: 'UA', vat: '1234567890' } },
97
+ { input: { country: 'BY', vat: '123456789' } },
98
+ { input: { country: 'RS', vat: '123456789' } },
99
+ { input: { country: 'BR', vat: 'abc' }, expectedVat: 'ABC' },
100
+ { input: { vat: 'SOMETHING' } },
101
+ { input: { vat: '' } },
102
+ { input: {} },
103
+ ])('$input', ({ input, expectedVat }) => {
104
+ const result = companyVatSchema.safeParse(input);
105
+ expect(result.success).toBe(true);
106
+ if (expectedVat) {
107
+ expect(result.data?.vat).toBe(expectedVat);
108
+ }
109
+ });
110
+ });
111
+
112
+ describe('invalid', () => {
113
+ test.each([
114
+ { input: { country: 'PT', vat: 'DE123' } },
115
+ { input: { country: 'DE', vat: 'DE' } },
116
+ { input: { country: 'GR', vat: 'GR123' } },
117
+ { input: { country: 'CH', vat: 'CH123' } },
118
+ { input: { country: 'GB', vat: 'XI' } },
119
+ ])('$input', ({ input }) => {
120
+ const result = companyVatSchema.safeParse(input);
121
+ expect(result.success).toBe(false);
122
+ if (!result.success) {
123
+ const issue = result.error.issues[0];
124
+ expect((issue as any).params).toEqual({ messageKey: 'vat.invalidFormat' });
125
+ }
126
+ });
127
+ });
128
+ });
package/src/types/user.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { z } from 'zod';
1
2
  import { MarketingChannel } from './index.js';
2
3
 
3
4
  export type AuthRequest = {
@@ -10,20 +11,113 @@ export enum FleetSize {
10
11
  Large = '6+',
11
12
  }
12
13
 
13
- export type UserDetails = {
14
- firstName?: string;
15
- lastName?: string;
16
- phone?: string;
17
- preferredLanguage?: string;
18
- fleetSize?: FleetSize;
19
- trader?: boolean;
20
- email?: string;
21
- company: {
22
- country?: string;
23
- city?: string | undefined;
24
- name?: string | undefined;
25
- vat?: string | undefined;
26
- postalCode?: string | undefined;
27
- };
28
- marketingChannels: MarketingChannel[];
14
+ export const VatSchema = z.string().toUpperCase();
15
+
16
+ /**
17
+ * Light VAT-number validation patterns by country.
18
+ *
19
+ * Countries without an entry accept any VAT number (no validation).
20
+ *
21
+ * Includes EU-27, EEA non-EU (NO, IS), Switzerland (CH), and the UK (GB).
22
+ * Note: Liechtenstein shares the Swiss CHE VAT system and is intentionally
23
+ * excluded — its numbers don't start with "LI".
24
+ */
25
+ const VAT_PATTERNS: Record<string, RegExp> = {
26
+ // EU-27
27
+ AT: /^AT.+/,
28
+ BE: /^BE.+/,
29
+ BG: /^BG.+/,
30
+ CY: /^CY.+/,
31
+ CZ: /^CZ.+/,
32
+ DE: /^DE.+/,
33
+ DK: /^DK.+/,
34
+ EE: /^EE.+/,
35
+ ES: /^ES.+/,
36
+ FI: /^FI.+/,
37
+ FR: /^FR.+/,
38
+ GR: /^EL.+/,
39
+ HR: /^HR.+/,
40
+ HU: /^HU.+/,
41
+ IE: /^IE.+/,
42
+ IT: /^IT.+/,
43
+ LT: /^LT.+/,
44
+ LU: /^LU.+/,
45
+ LV: /^LV.+/,
46
+ MT: /^MT.+/,
47
+ NL: /^NL.+/,
48
+ PL: /^PL.+/,
49
+ PT: /^PT.+/,
50
+ RO: /^RO.+/,
51
+ SE: /^SE.+/,
52
+ SI: /^SI.+/,
53
+ SK: /^SK.+/,
54
+ // EEA non-EU
55
+ NO: /^NO.+/,
56
+ IS: /^IS.+/,
57
+ // Switzerland — 3-letter CHE prefix
58
+ CH: /^CHE.+/,
59
+ // United Kingdom + Northern Ireland (XI prefix post-Brexit)
60
+ GB: /^(GB|XI).+/,
61
+ };
62
+
63
+ export const isValidVatForCountry = ({
64
+ vat,
65
+ country,
66
+ }: {
67
+ vat: string;
68
+ country: string;
69
+ }): boolean => {
70
+ const pattern = VAT_PATTERNS[country.toUpperCase()];
71
+ if (!pattern) {
72
+ return true;
73
+ }
74
+ return pattern.test(vat);
75
+ };
76
+
77
+ const getVatPrefix = (country: string): string => {
78
+ const pattern = VAT_PATTERNS[country.toUpperCase()];
79
+ if (!pattern) {
80
+ return '';
81
+ }
82
+ const match = pattern.toString().match(/\^(\w+)/);
83
+ return match ? match[1] : '';
29
84
  };
85
+
86
+ export const UserDetails = z.object({
87
+ firstName: z.string().optional(),
88
+ lastName: z.string().optional(),
89
+ phone: z.string().optional(),
90
+ preferredLanguage: z.string().optional(),
91
+ fleetSize: z.nativeEnum(FleetSize).optional(),
92
+ trader: z.boolean().optional(),
93
+ email: z.string().email().toLowerCase(),
94
+ company: z
95
+ .object({
96
+ country: z.string().optional(),
97
+ city: z.string().optional(),
98
+ name: z.string().optional(),
99
+ vat: VatSchema.optional(),
100
+ postalCode: z.string().optional(),
101
+ })
102
+ /* TODO when updating to zod v4, change addIssue options to
103
+ message: 'vat.invalidFormat',
104
+ path: ['vat'],
105
+ */
106
+ .superRefine((data, ctx) => {
107
+ if (!data.vat || !data.country) {
108
+ return;
109
+ }
110
+ if (!isValidVatForCountry({ vat: data.vat, country: data.country })) {
111
+ const prefix = getVatPrefix(data.country);
112
+
113
+ ctx.addIssue({
114
+ code: z.ZodIssueCode.custom,
115
+ path: ['vat'],
116
+ params: { prefix, messageKey: 'vat.invalidFormat' },
117
+ });
118
+ }
119
+ }),
120
+ marketingChannels: z.array(z.nativeEnum(MarketingChannel)),
121
+ });
122
+
123
+ export type UserDetails = z.infer<typeof UserDetails>;