@htlkg/data 0.0.24 → 0.0.25

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,611 @@
1
+ /**
2
+ * Contact Validation Schemas
3
+ *
4
+ * Comprehensive Zod validation schemas for Contact entities.
5
+ * Includes field-level validators, custom field schemas, and complete entity schemas.
6
+ *
7
+ * @module @htlkg/data/validation/contact.schemas
8
+ */
9
+
10
+ import { z } from "zod";
11
+
12
+ // ============================================
13
+ // Field-Level Validators
14
+ // ============================================
15
+
16
+ /**
17
+ * Email validation schema
18
+ * - Must be valid email format
19
+ * - Transforms to lowercase for consistency
20
+ * - Max 254 characters (RFC 5321)
21
+ */
22
+ export const emailSchema = z
23
+ .string()
24
+ .trim()
25
+ .toLowerCase()
26
+ .email("Invalid email address")
27
+ .max(254, "Email must not exceed 254 characters");
28
+
29
+ /**
30
+ * Optional email validation schema
31
+ * Same rules as emailSchema but allows undefined
32
+ */
33
+ export const optionalEmailSchema = z
34
+ .string()
35
+ .trim()
36
+ .toLowerCase()
37
+ .email("Invalid email address")
38
+ .max(254, "Email must not exceed 254 characters")
39
+ .optional();
40
+
41
+ /**
42
+ * Phone number validation schema
43
+ * Supports international formats:
44
+ * - E.164 format: +14155552671
45
+ * - With spaces/dashes: +1 415-555-2671
46
+ * - Local formats: (415) 555-2671
47
+ * - Minimum 7 digits, maximum 15 digits (ITU-T E.164)
48
+ */
49
+ const PHONE_REGEX = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,3}[)]?[-\s\.]?[0-9]{1,4}[-\s\.]?[0-9]{1,4}[-\s\.]?[0-9]{0,9}$/;
50
+
51
+ export const phoneSchema = z
52
+ .string()
53
+ .trim()
54
+ .min(7, "Phone number must be at least 7 characters")
55
+ .max(20, "Phone number must not exceed 20 characters")
56
+ .regex(PHONE_REGEX, "Invalid phone number format");
57
+
58
+ /**
59
+ * Optional phone validation schema
60
+ * Validates format only if value is provided and non-empty
61
+ */
62
+ export const optionalPhoneSchema = z
63
+ .string()
64
+ .trim()
65
+ .optional()
66
+ .refine(
67
+ (val) => {
68
+ if (!val || val === "") return true;
69
+ return PHONE_REGEX.test(val) && val.length >= 7 && val.length <= 20;
70
+ },
71
+ { message: "Invalid phone number format" }
72
+ );
73
+
74
+ /**
75
+ * First name validation schema
76
+ * - Required, 1-100 characters
77
+ * - Trims whitespace
78
+ * - Allows Unicode letters, spaces, hyphens, apostrophes
79
+ */
80
+ const NAME_REGEX = /^[\p{L}\s\-'\.]+$/u;
81
+
82
+ export const firstNameSchema = z
83
+ .string()
84
+ .trim()
85
+ .min(1, "First name is required")
86
+ .max(100, "First name must not exceed 100 characters")
87
+ .regex(NAME_REGEX, "First name contains invalid characters");
88
+
89
+ /**
90
+ * Last name validation schema
91
+ * - Required, 1-100 characters
92
+ * - Trims whitespace
93
+ * - Allows Unicode letters, spaces, hyphens, apostrophes
94
+ */
95
+ export const lastNameSchema = z
96
+ .string()
97
+ .trim()
98
+ .min(1, "Last name is required")
99
+ .max(100, "Last name must not exceed 100 characters")
100
+ .regex(NAME_REGEX, "Last name contains invalid characters");
101
+
102
+ /**
103
+ * Optional first name validation schema
104
+ */
105
+ export const optionalFirstNameSchema = z
106
+ .string()
107
+ .trim()
108
+ .min(1, "First name cannot be empty if provided")
109
+ .max(100, "First name must not exceed 100 characters")
110
+ .regex(NAME_REGEX, "First name contains invalid characters")
111
+ .optional();
112
+
113
+ /**
114
+ * Optional last name validation schema
115
+ */
116
+ export const optionalLastNameSchema = z
117
+ .string()
118
+ .trim()
119
+ .min(1, "Last name cannot be empty if provided")
120
+ .max(100, "Last name must not exceed 100 characters")
121
+ .regex(NAME_REGEX, "Last name contains invalid characters")
122
+ .optional();
123
+
124
+ /**
125
+ * Locale validation schema (BCP 47 language tag)
126
+ * Examples: "en", "en-US", "pt-BR", "zh-Hans-CN"
127
+ */
128
+ const LOCALE_REGEX = /^[a-z]{2,3}(-[A-Z][a-z]{3})?(-([A-Z]{2}|[0-9]{3}))?$/;
129
+
130
+ export const localeSchema = z
131
+ .string()
132
+ .regex(LOCALE_REGEX, "Invalid locale format. Use BCP 47 format (e.g., 'en', 'en-US', 'pt-BR')");
133
+
134
+ /**
135
+ * Optional locale validation schema
136
+ */
137
+ export const optionalLocaleSchema = z
138
+ .string()
139
+ .regex(LOCALE_REGEX, "Invalid locale format. Use BCP 47 format (e.g., 'en', 'en-US', 'pt-BR')")
140
+ .optional();
141
+
142
+ /**
143
+ * ISO 8601 date string regex pattern
144
+ * Matches: YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS, YYYY-MM-DDTHH:MM:SSZ, YYYY-MM-DDTHH:MM:SS±HH:MM
145
+ */
146
+ const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})?)?$/;
147
+
148
+ /**
149
+ * ISO 8601 date string validation schema
150
+ * Validates date strings in ISO 8601 format
151
+ */
152
+ export const isoDateStringSchema = z
153
+ .string()
154
+ .refine(
155
+ (val) => {
156
+ // First check format matches ISO 8601
157
+ if (!ISO_DATE_REGEX.test(val)) return false;
158
+ // Then verify it's a valid date
159
+ const date = new Date(val);
160
+ return !isNaN(date.getTime());
161
+ },
162
+ { message: "Invalid date format. Use ISO 8601 format (e.g., '2024-01-15T10:30:00Z')" }
163
+ );
164
+
165
+ /**
166
+ * Optional ISO 8601 date string validation schema
167
+ */
168
+ export const optionalIsoDateStringSchema = z
169
+ .string()
170
+ .refine(
171
+ (val) => {
172
+ // First check format matches ISO 8601
173
+ if (!ISO_DATE_REGEX.test(val)) return false;
174
+ // Then verify it's a valid date
175
+ const date = new Date(val);
176
+ return !isNaN(date.getTime());
177
+ },
178
+ { message: "Invalid date format. Use ISO 8601 format (e.g., '2024-01-15T10:30:00Z')" }
179
+ )
180
+ .optional();
181
+
182
+ /**
183
+ * UUID validation schema
184
+ */
185
+ export const uuidSchema = z
186
+ .string()
187
+ .uuid("Invalid UUID format")
188
+ .or(z.string().min(1, "ID is required"));
189
+
190
+ /**
191
+ * Brand ID validation schema
192
+ */
193
+ export const brandIdSchema = z
194
+ .string()
195
+ .min(1, "Brand ID is required");
196
+
197
+ /**
198
+ * Tag validation schema
199
+ * - Non-empty string
200
+ * - Max 50 characters per tag
201
+ * - Trimmed and lowercased for consistency
202
+ */
203
+ export const tagSchema = z
204
+ .string()
205
+ .trim()
206
+ .toLowerCase()
207
+ .min(1, "Tag cannot be empty")
208
+ .max(50, "Tag must not exceed 50 characters");
209
+
210
+ /**
211
+ * Tags array validation schema
212
+ * - Array of valid tags
213
+ * - Max 100 tags per contact
214
+ */
215
+ export const tagsArraySchema = z
216
+ .array(tagSchema)
217
+ .max(100, "Cannot have more than 100 tags")
218
+ .optional();
219
+
220
+ // ============================================
221
+ // Custom Field Validation Schemas
222
+ // ============================================
223
+
224
+ /**
225
+ * Supported custom field types
226
+ */
227
+ export const CustomFieldType = {
228
+ STRING: "string",
229
+ NUMBER: "number",
230
+ BOOLEAN: "boolean",
231
+ DATE: "date",
232
+ ARRAY: "array",
233
+ OBJECT: "object",
234
+ } as const;
235
+
236
+ export type CustomFieldTypeValue = (typeof CustomFieldType)[keyof typeof CustomFieldType];
237
+
238
+ /**
239
+ * Custom field definition schema
240
+ * Defines the structure for a single custom field
241
+ */
242
+ export const customFieldDefinitionSchema = z.object({
243
+ type: z.enum(["string", "number", "boolean", "date", "array", "object"]),
244
+ required: z.boolean().optional().default(false),
245
+ maxLength: z.number().positive().optional(),
246
+ minLength: z.number().nonnegative().optional(),
247
+ min: z.number().optional(),
248
+ max: z.number().optional(),
249
+ pattern: z.string().optional(),
250
+ enum: z.array(z.union([z.string(), z.number()])).optional(),
251
+ });
252
+
253
+ export type CustomFieldDefinition = z.infer<typeof customFieldDefinitionSchema>;
254
+
255
+ /**
256
+ * Custom field value schema
257
+ * Validates a value based on its field definition
258
+ */
259
+ export function createCustomFieldValueSchema(definition: CustomFieldDefinition): z.ZodTypeAny {
260
+ let schema: z.ZodTypeAny;
261
+
262
+ switch (definition.type) {
263
+ case "string":
264
+ schema = z.string();
265
+ if (definition.maxLength) {
266
+ schema = (schema as z.ZodString).max(definition.maxLength);
267
+ }
268
+ if (definition.minLength) {
269
+ schema = (schema as z.ZodString).min(definition.minLength);
270
+ }
271
+ if (definition.pattern) {
272
+ schema = (schema as z.ZodString).regex(new RegExp(definition.pattern));
273
+ }
274
+ if (definition.enum) {
275
+ schema = z.enum(definition.enum as [string, ...string[]]);
276
+ }
277
+ break;
278
+
279
+ case "number":
280
+ schema = z.number();
281
+ if (definition.min !== undefined) {
282
+ schema = (schema as z.ZodNumber).min(definition.min);
283
+ }
284
+ if (definition.max !== undefined) {
285
+ schema = (schema as z.ZodNumber).max(definition.max);
286
+ }
287
+ break;
288
+
289
+ case "boolean":
290
+ schema = z.boolean();
291
+ break;
292
+
293
+ case "date":
294
+ schema = isoDateStringSchema;
295
+ break;
296
+
297
+ case "array":
298
+ schema = z.array(z.any());
299
+ break;
300
+
301
+ case "object":
302
+ schema = z.record(z.any());
303
+ break;
304
+
305
+ default:
306
+ schema = z.any();
307
+ }
308
+
309
+ if (!definition.required) {
310
+ schema = schema.optional();
311
+ }
312
+
313
+ return schema;
314
+ }
315
+
316
+ /**
317
+ * Contact preferences schema
318
+ * Validates the preferences Record with type-safe structure
319
+ *
320
+ * Known preference fields with validation:
321
+ * - theme: "light" | "dark" | "system"
322
+ * - language: BCP 47 locale
323
+ * - notifications: { email: boolean, sms: boolean, push: boolean }
324
+ * - communication: { preferredChannel: "email" | "phone" | "sms" }
325
+ * - Other fields: any valid JSON value
326
+ */
327
+ export const contactPreferencesSchema = z
328
+ .record(
329
+ z.string(),
330
+ z.union([
331
+ z.string(),
332
+ z.number(),
333
+ z.boolean(),
334
+ z.array(z.any()),
335
+ z.record(z.any()),
336
+ z.null(),
337
+ ])
338
+ )
339
+ .optional()
340
+ .refine(
341
+ (preferences) => {
342
+ if (!preferences) return true;
343
+
344
+ // Validate known preference fields if present
345
+ if (preferences.theme && !["light", "dark", "system"].includes(preferences.theme as string)) {
346
+ return false;
347
+ }
348
+
349
+ if (preferences.language && typeof preferences.language === "string") {
350
+ const localeResult = localeSchema.safeParse(preferences.language);
351
+ if (!localeResult.success) return false;
352
+ }
353
+
354
+ return true;
355
+ },
356
+ {
357
+ message: "Invalid preferences structure. Check theme (light/dark/system) and language (BCP 47) values.",
358
+ }
359
+ );
360
+
361
+ /**
362
+ * Notification preferences sub-schema
363
+ */
364
+ export const notificationPreferencesSchema = z.object({
365
+ email: z.boolean().optional().default(true),
366
+ sms: z.boolean().optional().default(false),
367
+ push: z.boolean().optional().default(false),
368
+ });
369
+
370
+ /**
371
+ * Communication preferences sub-schema
372
+ */
373
+ export const communicationPreferencesSchema = z.object({
374
+ preferredChannel: z.enum(["email", "phone", "sms"]).optional().default("email"),
375
+ });
376
+
377
+ // ============================================
378
+ // Complete Contact Schemas
379
+ // ============================================
380
+
381
+ /**
382
+ * Base contact fields schema (shared between create and update)
383
+ */
384
+ const baseContactFieldsSchema = {
385
+ brandId: brandIdSchema,
386
+ email: emailSchema,
387
+ phone: optionalPhoneSchema,
388
+ firstName: firstNameSchema,
389
+ lastName: lastNameSchema,
390
+ locale: optionalLocaleSchema,
391
+ gdprConsent: z.boolean({
392
+ required_error: "GDPR consent is required",
393
+ invalid_type_error: "GDPR consent must be a boolean",
394
+ }),
395
+ gdprConsentDate: optionalIsoDateStringSchema,
396
+ marketingOptIn: z.boolean().optional(),
397
+ preferences: contactPreferencesSchema,
398
+ tags: tagsArraySchema,
399
+ totalVisits: z.number().int().min(0, "Total visits cannot be negative").optional(),
400
+ lastVisitDate: optionalIsoDateStringSchema,
401
+ firstVisitDate: optionalIsoDateStringSchema,
402
+ legacyId: z.string().max(255, "Legacy ID must not exceed 255 characters").optional(),
403
+ };
404
+
405
+ /**
406
+ * Audit fields for creation
407
+ */
408
+ const createAuditFieldsSchema = {
409
+ createdAt: optionalIsoDateStringSchema,
410
+ createdBy: z.string().optional(),
411
+ updatedAt: optionalIsoDateStringSchema,
412
+ updatedBy: z.string().optional(),
413
+ };
414
+
415
+ /**
416
+ * Audit fields for updates (includes soft delete)
417
+ */
418
+ const updateAuditFieldsSchema = {
419
+ updatedAt: optionalIsoDateStringSchema,
420
+ updatedBy: z.string().optional(),
421
+ deletedAt: z.string().nullable().optional(),
422
+ deletedBy: z.string().nullable().optional(),
423
+ };
424
+
425
+ /**
426
+ * Create Contact Schema
427
+ * Full validation for creating a new contact
428
+ */
429
+ export const createContactSchema = z.object({
430
+ ...baseContactFieldsSchema,
431
+ ...createAuditFieldsSchema,
432
+ });
433
+
434
+ /**
435
+ * Update Contact Schema
436
+ * Partial validation for updating an existing contact
437
+ * All fields except 'id' are optional
438
+ */
439
+ export const updateContactSchema = z.object({
440
+ id: z.string().min(1, "Contact ID is required"),
441
+ brandId: brandIdSchema.optional(),
442
+ email: optionalEmailSchema,
443
+ phone: optionalPhoneSchema,
444
+ firstName: optionalFirstNameSchema,
445
+ lastName: optionalLastNameSchema,
446
+ locale: optionalLocaleSchema,
447
+ gdprConsent: z.boolean().optional(),
448
+ gdprConsentDate: optionalIsoDateStringSchema,
449
+ marketingOptIn: z.boolean().optional(),
450
+ preferences: contactPreferencesSchema,
451
+ tags: tagsArraySchema,
452
+ totalVisits: z.number().int().min(0, "Total visits cannot be negative").optional(),
453
+ lastVisitDate: optionalIsoDateStringSchema,
454
+ firstVisitDate: optionalIsoDateStringSchema,
455
+ legacyId: z.string().max(255, "Legacy ID must not exceed 255 characters").optional(),
456
+ ...updateAuditFieldsSchema,
457
+ });
458
+
459
+ /**
460
+ * Merge Contacts Schema
461
+ * Validation for merging duplicate contacts
462
+ */
463
+ export const mergeContactsSchema = z.object({
464
+ primaryId: z.string().min(1, "Primary contact ID is required"),
465
+ duplicateIds: z
466
+ .array(z.string().min(1, "Duplicate ID cannot be empty"))
467
+ .min(1, "At least one duplicate ID is required")
468
+ .max(50, "Cannot merge more than 50 contacts at once"),
469
+ });
470
+
471
+ /**
472
+ * Search/Filter Contact Schema
473
+ * Validation for contact search and filter operations
474
+ */
475
+ export const searchContactSchema = z.object({
476
+ brandId: brandIdSchema.optional(),
477
+ email: z.string().optional(),
478
+ phone: z.string().optional(),
479
+ firstName: z.string().optional(),
480
+ lastName: z.string().optional(),
481
+ search: z.string().max(255, "Search query must not exceed 255 characters").optional(),
482
+ tags: z.array(z.string()).optional(),
483
+ gdprConsent: z.boolean().optional(),
484
+ marketingOptIn: z.boolean().optional(),
485
+ includeDeleted: z.boolean().optional().default(false),
486
+ limit: z.number().int().min(1).max(250).optional().default(25),
487
+ nextToken: z.string().optional(),
488
+ });
489
+
490
+ /**
491
+ * Bulk Import Contact Schema
492
+ * Validation for importing contacts in bulk
493
+ */
494
+ export const bulkImportContactSchema = z.object({
495
+ contacts: z
496
+ .array(createContactSchema.omit({ createdAt: true, createdBy: true, updatedAt: true, updatedBy: true }))
497
+ .min(1, "At least one contact is required")
498
+ .max(1000, "Cannot import more than 1000 contacts at once"),
499
+ skipDuplicates: z.boolean().optional().default(true),
500
+ updateExisting: z.boolean().optional().default(false),
501
+ });
502
+
503
+ // ============================================
504
+ // Type Exports
505
+ // ============================================
506
+
507
+ export type CreateContactInput = z.infer<typeof createContactSchema>;
508
+ export type UpdateContactInput = z.infer<typeof updateContactSchema>;
509
+ export type MergeContactsInput = z.infer<typeof mergeContactsSchema>;
510
+ export type SearchContactInput = z.infer<typeof searchContactSchema>;
511
+ export type BulkImportContactInput = z.infer<typeof bulkImportContactSchema>;
512
+ export type ContactPreferences = z.infer<typeof contactPreferencesSchema>;
513
+ export type NotificationPreferences = z.infer<typeof notificationPreferencesSchema>;
514
+ export type CommunicationPreferences = z.infer<typeof communicationPreferencesSchema>;
515
+
516
+ // ============================================
517
+ // Validation Helper Functions
518
+ // ============================================
519
+
520
+ /**
521
+ * Validate email address
522
+ * @param email - Email string to validate
523
+ * @returns Validation result with success status and normalized email or error
524
+ */
525
+ export function validateEmail(email: string): z.SafeParseReturnType<string, string> {
526
+ return emailSchema.safeParse(email);
527
+ }
528
+
529
+ /**
530
+ * Validate phone number
531
+ * @param phone - Phone string to validate
532
+ * @returns Validation result with success status and normalized phone or error
533
+ */
534
+ export function validatePhone(phone: string): z.SafeParseReturnType<string, string> {
535
+ return phoneSchema.safeParse(phone);
536
+ }
537
+
538
+ /**
539
+ * Validate contact name
540
+ * @param firstName - First name to validate
541
+ * @param lastName - Last name to validate
542
+ * @returns Object with validation results for both names
543
+ */
544
+ export function validateContactName(
545
+ firstName: string,
546
+ lastName: string
547
+ ): {
548
+ firstName: z.SafeParseReturnType<string, string>;
549
+ lastName: z.SafeParseReturnType<string, string>;
550
+ isValid: boolean;
551
+ } {
552
+ const firstNameResult = firstNameSchema.safeParse(firstName);
553
+ const lastNameResult = lastNameSchema.safeParse(lastName);
554
+
555
+ return {
556
+ firstName: firstNameResult,
557
+ lastName: lastNameResult,
558
+ isValid: firstNameResult.success && lastNameResult.success,
559
+ };
560
+ }
561
+
562
+ /**
563
+ * Validate create contact input
564
+ * @param input - Contact input to validate
565
+ * @returns Validation result with success status and validated data or error
566
+ */
567
+ export function validateCreateContact(
568
+ input: unknown
569
+ ): z.SafeParseReturnType<unknown, CreateContactInput> {
570
+ return createContactSchema.safeParse(input);
571
+ }
572
+
573
+ /**
574
+ * Validate update contact input
575
+ * @param input - Contact update input to validate
576
+ * @returns Validation result with success status and validated data or error
577
+ */
578
+ export function validateUpdateContact(
579
+ input: unknown
580
+ ): z.SafeParseReturnType<unknown, UpdateContactInput> {
581
+ return updateContactSchema.safeParse(input);
582
+ }
583
+
584
+ /**
585
+ * Format Zod errors into user-friendly message
586
+ * @param error - Zod error object
587
+ * @returns Formatted error message string
588
+ */
589
+ export function formatValidationErrors(error: z.ZodError): string {
590
+ return error.issues
591
+ .map((issue) => {
592
+ const path = issue.path.join(".");
593
+ return path ? `${path}: ${issue.message}` : issue.message;
594
+ })
595
+ .join("; ");
596
+ }
597
+
598
+ /**
599
+ * Get validation error details as array
600
+ * @param error - Zod error object
601
+ * @returns Array of error details with path and message
602
+ */
603
+ export function getValidationErrorDetails(
604
+ error: z.ZodError
605
+ ): Array<{ path: string; message: string; code: string }> {
606
+ return error.issues.map((issue) => ({
607
+ path: issue.path.join("."),
608
+ message: issue.message,
609
+ code: issue.code,
610
+ }));
611
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Validation Module
3
+ *
4
+ * Exports all Zod validation schemas and utilities for the data package.
5
+ *
6
+ * @module @htlkg/data/validation
7
+ */
8
+
9
+ // Contact validation schemas and utilities
10
+ export {
11
+ // Field-level validators
12
+ emailSchema,
13
+ optionalEmailSchema,
14
+ phoneSchema,
15
+ optionalPhoneSchema,
16
+ firstNameSchema,
17
+ lastNameSchema,
18
+ optionalFirstNameSchema,
19
+ optionalLastNameSchema,
20
+ localeSchema,
21
+ optionalLocaleSchema,
22
+ isoDateStringSchema,
23
+ optionalIsoDateStringSchema,
24
+ uuidSchema,
25
+ brandIdSchema,
26
+ tagSchema,
27
+ tagsArraySchema,
28
+ // Custom field schemas
29
+ CustomFieldType,
30
+ customFieldDefinitionSchema,
31
+ createCustomFieldValueSchema,
32
+ contactPreferencesSchema,
33
+ notificationPreferencesSchema,
34
+ communicationPreferencesSchema,
35
+ // Additional entity schemas (not exported from mutations)
36
+ searchContactSchema,
37
+ bulkImportContactSchema,
38
+ // Validation helper functions
39
+ validateEmail,
40
+ validatePhone,
41
+ validateContactName,
42
+ validateCreateContact,
43
+ validateUpdateContact,
44
+ formatValidationErrors,
45
+ getValidationErrorDetails,
46
+ // Types (only types not already exported from mutations)
47
+ type CustomFieldTypeValue,
48
+ type CustomFieldDefinition,
49
+ type SearchContactInput,
50
+ type BulkImportContactInput,
51
+ type ContactPreferences,
52
+ type NotificationPreferences,
53
+ type CommunicationPreferences,
54
+ } from "./contact.schemas";