@htlkg/data 0.0.20 → 0.0.21

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,554 @@
1
+ /**
2
+ * Contact Mutation Functions
3
+ *
4
+ * Provides type-safe mutation functions for creating, updating, and deleting contacts.
5
+ * Includes Zod validation for input data before mutations.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import type { Contact } from "@htlkg/core/types";
10
+ import {
11
+ checkRestoreEligibility,
12
+ DEFAULT_SOFT_DELETE_RETENTION_DAYS,
13
+ } from "../queries/systemSettings";
14
+ import { getContact } from "../queries/contacts";
15
+ import type { CreateAuditFields, UpdateWithSoftDeleteFields } from "./common";
16
+
17
+ // ============================================
18
+ // Added Zod Validation Schemas
19
+ // ============================================
20
+
21
+ /**
22
+ * Zod schema for creating a contact
23
+ */
24
+ export const createContactSchema = z.object({
25
+ brandId: z.string().min(1, "Brand ID is required"),
26
+ email: z.string().email("Invalid email address"),
27
+ phone: z.string().optional(),
28
+ firstName: z.string().min(1, "First name is required"),
29
+ lastName: z.string().min(1, "Last name is required"),
30
+ locale: z.string().optional(),
31
+ gdprConsent: z.boolean(),
32
+ gdprConsentDate: z.string().optional(),
33
+ marketingOptIn: z.boolean().optional(),
34
+ preferences: z.record(z.any()).optional(),
35
+ tags: z.array(z.string()).optional(),
36
+ totalVisits: z.number().int().min(0).optional(),
37
+ lastVisitDate: z.string().optional(),
38
+ firstVisitDate: z.string().optional(),
39
+ legacyId: z.string().optional(),
40
+ // Audit fields
41
+ createdAt: z.string().optional(),
42
+ createdBy: z.string().optional(),
43
+ updatedAt: z.string().optional(),
44
+ updatedBy: z.string().optional(),
45
+ });
46
+
47
+ /**
48
+ * Zod schema for updating a contact
49
+ */
50
+ export const updateContactSchema = z.object({
51
+ id: z.string().min(1, "Contact ID is required"),
52
+ brandId: z.string().min(1).optional(),
53
+ email: z.string().email("Invalid email address").optional(),
54
+ phone: z.string().optional(),
55
+ firstName: z.string().min(1).optional(),
56
+ lastName: z.string().min(1).optional(),
57
+ locale: z.string().optional(),
58
+ gdprConsent: z.boolean().optional(),
59
+ gdprConsentDate: z.string().optional(),
60
+ marketingOptIn: z.boolean().optional(),
61
+ preferences: z.record(z.any()).optional(),
62
+ tags: z.array(z.string()).optional(),
63
+ totalVisits: z.number().int().min(0).optional(),
64
+ lastVisitDate: z.string().optional(),
65
+ firstVisitDate: z.string().optional(),
66
+ legacyId: z.string().optional(),
67
+ // Audit fields
68
+ updatedAt: z.string().optional(),
69
+ updatedBy: z.string().optional(),
70
+ deletedAt: z.string().nullable().optional(),
71
+ deletedBy: z.string().nullable().optional(),
72
+ });
73
+
74
+ /**
75
+ * Zod schema for merging contacts
76
+ */
77
+ export const mergeContactsSchema = z.object({
78
+ primaryId: z.string().min(1, "Primary contact ID is required"),
79
+ duplicateIds: z.array(z.string().min(1)).min(1, "At least one duplicate ID is required"),
80
+ });
81
+
82
+ // ============================================
83
+ // Validation Error Class
84
+ // ============================================
85
+
86
+ /**
87
+ * Validation error class for contact operations
88
+ */
89
+ export class ContactValidationError extends Error {
90
+ public readonly issues: z.ZodIssue[];
91
+
92
+ constructor(message: string, issues: z.ZodIssue[] = []) {
93
+ super(message);
94
+ this.name = "ContactValidationError";
95
+ this.issues = issues;
96
+ }
97
+ }
98
+
99
+ // ============================================
100
+ // Input Types
101
+ // ============================================
102
+
103
+ /**
104
+ * Input type for creating a contact
105
+ */
106
+ export interface CreateContactInput extends CreateAuditFields {
107
+ brandId: string;
108
+ email: string;
109
+ phone?: string;
110
+ firstName: string;
111
+ lastName: string;
112
+ locale?: string;
113
+ gdprConsent: boolean;
114
+ gdprConsentDate?: string;
115
+ marketingOptIn?: boolean;
116
+ preferences?: Record<string, any>;
117
+ tags?: string[];
118
+ totalVisits?: number;
119
+ lastVisitDate?: string;
120
+ firstVisitDate?: string;
121
+ legacyId?: string;
122
+ }
123
+
124
+ /**
125
+ * Input type for updating a contact
126
+ */
127
+ export interface UpdateContactInput extends UpdateWithSoftDeleteFields {
128
+ id: string;
129
+ brandId?: string;
130
+ email?: string;
131
+ phone?: string;
132
+ firstName?: string;
133
+ lastName?: string;
134
+ locale?: string;
135
+ gdprConsent?: boolean;
136
+ gdprConsentDate?: string;
137
+ marketingOptIn?: boolean;
138
+ preferences?: Record<string, any>;
139
+ tags?: string[];
140
+ totalVisits?: number;
141
+ lastVisitDate?: string;
142
+ firstVisitDate?: string;
143
+ legacyId?: string;
144
+ }
145
+
146
+ /**
147
+ * Input type for merging contacts
148
+ */
149
+ export interface MergeContactsInput {
150
+ primaryId: string;
151
+ duplicateIds: string[];
152
+ }
153
+
154
+ /**
155
+ * Result type for merge operation
156
+ */
157
+ export interface MergeContactsResult {
158
+ success: boolean;
159
+ mergedContact?: Contact;
160
+ deletedIds?: string[];
161
+ error?: string;
162
+ }
163
+
164
+ // ============================================
165
+ // Mutation Functions
166
+ // ============================================
167
+
168
+ /**
169
+ * Create a new contact with Zod validation
170
+ *
171
+ * @throws {ContactValidationError} if input validation fails
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * import { createContact } from '@htlkg/data/mutations';
176
+ * import { generateClient } from '@htlkg/data/client';
177
+ *
178
+ * const client = generateClient<Schema>();
179
+ * const contact = await createContact(client, {
180
+ * brandId: 'brand-123',
181
+ * email: 'guest@example.com',
182
+ * firstName: 'John',
183
+ * lastName: 'Doe',
184
+ * gdprConsent: true,
185
+ * gdprConsentDate: new Date().toISOString()
186
+ * });
187
+ * ```
188
+ */
189
+ export async function createContact<TClient = any>(
190
+ client: TClient,
191
+ input: CreateContactInput,
192
+ ): Promise<Contact | null> {
193
+ try {
194
+ // Validate input with Zod
195
+ const validationResult = createContactSchema.safeParse(input);
196
+
197
+ if (!validationResult.success) {
198
+ const errorMessage = validationResult.error.issues
199
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
200
+ .join(", ");
201
+ throw new ContactValidationError(
202
+ `Validation failed: ${errorMessage}`,
203
+ validationResult.error.issues
204
+ );
205
+ }
206
+
207
+ const { data, errors } = await (client as any).models.Contact.create(input);
208
+
209
+ if (errors) {
210
+ console.error("[createContact] GraphQL errors:", errors);
211
+ return null;
212
+ }
213
+
214
+ return data as Contact;
215
+ } catch (error) {
216
+ if (error instanceof ContactValidationError) {
217
+ console.error("[createContact] Validation error:", error.message);
218
+ throw error;
219
+ }
220
+ console.error("[createContact] Error creating contact:", error);
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Update an existing contact with Zod validation
227
+ *
228
+ * @throws {ContactValidationError} if input validation fails
229
+ *
230
+ * @example
231
+ * ```typescript
232
+ * import { updateContact } from '@htlkg/data/mutations';
233
+ * import { generateClient } from '@htlkg/data/client';
234
+ *
235
+ * const client = generateClient<Schema>();
236
+ * const contact = await updateContact(client, {
237
+ * id: 'contact-123',
238
+ * firstName: 'Jane',
239
+ * marketingOptIn: true
240
+ * });
241
+ * ```
242
+ */
243
+ export async function updateContact<TClient = any>(
244
+ client: TClient,
245
+ input: UpdateContactInput,
246
+ ): Promise<Contact | null> {
247
+ try {
248
+ // Validate input with Zod
249
+ const validationResult = updateContactSchema.safeParse(input);
250
+
251
+ if (!validationResult.success) {
252
+ const errorMessage = validationResult.error.issues
253
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
254
+ .join(", ");
255
+ throw new ContactValidationError(
256
+ `Validation failed: ${errorMessage}`,
257
+ validationResult.error.issues
258
+ );
259
+ }
260
+
261
+ const { data, errors } = await (client as any).models.Contact.update(input);
262
+
263
+ if (errors) {
264
+ console.error("[updateContact] GraphQL errors:", errors);
265
+ return null;
266
+ }
267
+
268
+ return data as Contact;
269
+ } catch (error) {
270
+ if (error instanceof ContactValidationError) {
271
+ console.error("[updateContact] Validation error:", error.message);
272
+ throw error;
273
+ }
274
+ console.error("[updateContact] Error updating contact:", error);
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Soft delete a contact (sets deletedAt/deletedBy instead of removing)
281
+ *
282
+ * @example
283
+ * ```typescript
284
+ * import { softDeleteContact } from '@htlkg/data/mutations';
285
+ * import { generateClient } from '@htlkg/data/client';
286
+ *
287
+ * const client = generateClient<Schema>();
288
+ * await softDeleteContact(client, 'contact-123', 'admin@example.com');
289
+ * ```
290
+ */
291
+ export async function softDeleteContact<TClient = any>(
292
+ client: TClient,
293
+ id: string,
294
+ deletedBy: string,
295
+ ): Promise<boolean> {
296
+ try {
297
+ const { errors } = await (client as any).models.Contact.update({
298
+ id,
299
+ deletedAt: new Date().toISOString(),
300
+ deletedBy,
301
+ });
302
+
303
+ if (errors) {
304
+ console.error("[softDeleteContact] GraphQL errors:", errors);
305
+ return false;
306
+ }
307
+
308
+ return true;
309
+ } catch (error) {
310
+ console.error("[softDeleteContact] Error soft-deleting contact:", error);
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Restore a soft-deleted contact
317
+ * Checks retention period before allowing restoration
318
+ *
319
+ * @example
320
+ * ```typescript
321
+ * import { restoreContact } from '@htlkg/data/mutations';
322
+ * import { generateClient } from '@htlkg/data/client';
323
+ *
324
+ * const client = generateClient<Schema>();
325
+ * await restoreContact(client, 'contact-123');
326
+ * // Or with custom retention days:
327
+ * await restoreContact(client, 'contact-123', 60);
328
+ * ```
329
+ */
330
+ export async function restoreContact<TClient = any>(
331
+ client: TClient,
332
+ id: string,
333
+ retentionDays: number = DEFAULT_SOFT_DELETE_RETENTION_DAYS,
334
+ ): Promise<{ success: boolean; error?: string }> {
335
+ try {
336
+ // First, get the contact to check its deletedAt timestamp
337
+ const contact = await getContact(client, id);
338
+
339
+ if (!contact) {
340
+ console.error("[restoreContact] Contact not found");
341
+ return { success: false, error: "Contact not found" };
342
+ }
343
+
344
+ // Check if restoration is allowed based on retention period
345
+ const eligibility = checkRestoreEligibility(
346
+ (contact as any).deletedAt,
347
+ retentionDays
348
+ );
349
+
350
+ if (!eligibility.canRestore) {
351
+ const errorMsg = `Cannot restore contact. Retention period of ${retentionDays} days has expired. Item was deleted ${eligibility.daysExpired} days ago.`;
352
+ console.error("[restoreContact]", errorMsg);
353
+ return { success: false, error: errorMsg };
354
+ }
355
+
356
+ const { errors } = await (client as any).models.Contact.update({
357
+ id,
358
+ deletedAt: null,
359
+ deletedBy: null,
360
+ });
361
+
362
+ if (errors) {
363
+ console.error("[restoreContact] GraphQL errors:", errors);
364
+ return { success: false, error: "Failed to restore contact" };
365
+ }
366
+
367
+ return { success: true };
368
+ } catch (error) {
369
+ console.error("[restoreContact] Error restoring contact:", error);
370
+ throw error;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Hard delete a contact (permanently removes from database)
376
+ * Use with caution - prefer softDeleteContact for recoverable deletion
377
+ *
378
+ * @example
379
+ * ```typescript
380
+ * import { deleteContact } from '@htlkg/data/mutations';
381
+ * import { generateClient } from '@htlkg/data/client';
382
+ *
383
+ * const client = generateClient<Schema>();
384
+ * await deleteContact(client, 'contact-123');
385
+ * ```
386
+ */
387
+ export async function deleteContact<TClient = any>(
388
+ client: TClient,
389
+ id: string,
390
+ ): Promise<boolean> {
391
+ try {
392
+ const { errors } = await (client as any).models.Contact.delete({ id });
393
+
394
+ if (errors) {
395
+ console.error("[deleteContact] GraphQL errors:", errors);
396
+ return false;
397
+ }
398
+
399
+ return true;
400
+ } catch (error) {
401
+ console.error("[deleteContact] Error deleting contact:", error);
402
+ throw error;
403
+ }
404
+ }
405
+
406
+ /**
407
+ * Merge duplicate contacts into a primary contact
408
+ *
409
+ * This function:
410
+ * 1. Validates all contact IDs exist
411
+ * 2. Aggregates data from duplicates into the primary contact
412
+ * 3. Soft deletes the duplicate contacts
413
+ * 4. Returns the updated primary contact
414
+ *
415
+ * Merge strategy:
416
+ * - totalVisits: Sum of all contacts
417
+ * - firstVisitDate: Earliest date across all contacts
418
+ * - lastVisitDate: Latest date across all contacts
419
+ * - tags: Merged unique tags from all contacts
420
+ * - preferences: Primary contact preferences take precedence
421
+ * - Other fields: Primary contact values are preserved
422
+ *
423
+ * @throws {ContactValidationError} if input validation fails
424
+ *
425
+ * @example
426
+ * ```typescript
427
+ * import { mergeContacts } from '@htlkg/data/mutations';
428
+ * import { generateClient } from '@htlkg/data/client';
429
+ *
430
+ * const client = generateClient<Schema>();
431
+ * const result = await mergeContacts(client, {
432
+ * primaryId: 'contact-primary',
433
+ * duplicateIds: ['contact-dup-1', 'contact-dup-2']
434
+ * }, 'admin@example.com');
435
+ * ```
436
+ */
437
+ export async function mergeContacts<TClient = any>(
438
+ client: TClient,
439
+ input: MergeContactsInput,
440
+ mergedBy: string,
441
+ ): Promise<MergeContactsResult> {
442
+ try {
443
+ // Validate input with Zod
444
+ const validationResult = mergeContactsSchema.safeParse(input);
445
+
446
+ if (!validationResult.success) {
447
+ const errorMessage = validationResult.error.issues
448
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
449
+ .join(", ");
450
+ throw new ContactValidationError(
451
+ `Validation failed: ${errorMessage}`,
452
+ validationResult.error.issues
453
+ );
454
+ }
455
+
456
+ const { primaryId, duplicateIds } = input;
457
+
458
+ // Fetch primary contact
459
+ const primaryContact = await getContact(client, primaryId);
460
+ if (!primaryContact) {
461
+ return {
462
+ success: false,
463
+ error: `Primary contact not found: ${primaryId}`,
464
+ };
465
+ }
466
+
467
+ // Fetch all duplicate contacts
468
+ const duplicateContacts: Contact[] = [];
469
+ for (const duplicateId of duplicateIds) {
470
+ const duplicate = await getContact(client, duplicateId);
471
+ if (!duplicate) {
472
+ return {
473
+ success: false,
474
+ error: `Duplicate contact not found: ${duplicateId}`,
475
+ };
476
+ }
477
+ duplicateContacts.push(duplicate);
478
+ }
479
+
480
+ // Aggregate data from duplicates
481
+ let totalVisits = primaryContact.totalVisits || 0;
482
+ let firstVisitDate = primaryContact.firstVisitDate;
483
+ let lastVisitDate = primaryContact.lastVisitDate;
484
+ const allTags = new Set<string>(primaryContact.tags || []);
485
+
486
+ for (const duplicate of duplicateContacts) {
487
+ // Sum total visits
488
+ totalVisits += duplicate.totalVisits || 0;
489
+
490
+ // Find earliest first visit date
491
+ if (duplicate.firstVisitDate) {
492
+ if (!firstVisitDate || duplicate.firstVisitDate < firstVisitDate) {
493
+ firstVisitDate = duplicate.firstVisitDate;
494
+ }
495
+ }
496
+
497
+ // Find latest last visit date
498
+ if (duplicate.lastVisitDate) {
499
+ if (!lastVisitDate || duplicate.lastVisitDate > lastVisitDate) {
500
+ lastVisitDate = duplicate.lastVisitDate;
501
+ }
502
+ }
503
+
504
+ // Merge tags
505
+ if (duplicate.tags) {
506
+ duplicate.tags.forEach((tag) => allTags.add(tag));
507
+ }
508
+ }
509
+
510
+ // Update primary contact with aggregated data
511
+ const updateInput: UpdateContactInput = {
512
+ id: primaryId,
513
+ totalVisits,
514
+ firstVisitDate,
515
+ lastVisitDate,
516
+ tags: Array.from(allTags),
517
+ updatedAt: new Date().toISOString(),
518
+ updatedBy: mergedBy,
519
+ };
520
+
521
+ const updatedPrimary = await updateContact(client, updateInput);
522
+
523
+ if (!updatedPrimary) {
524
+ return {
525
+ success: false,
526
+ error: "Failed to update primary contact with merged data",
527
+ };
528
+ }
529
+
530
+ // Soft delete all duplicate contacts
531
+ const deletedIds: string[] = [];
532
+ for (const duplicateId of duplicateIds) {
533
+ const deleted = await softDeleteContact(client, duplicateId, mergedBy);
534
+ if (deleted) {
535
+ deletedIds.push(duplicateId);
536
+ } else {
537
+ console.error(`[mergeContacts] Failed to soft-delete duplicate: ${duplicateId}`);
538
+ }
539
+ }
540
+
541
+ return {
542
+ success: true,
543
+ mergedContact: updatedPrimary,
544
+ deletedIds,
545
+ };
546
+ } catch (error) {
547
+ if (error instanceof ContactValidationError) {
548
+ console.error("[mergeContacts] Validation error:", error.message);
549
+ throw error;
550
+ }
551
+ console.error("[mergeContacts] Error merging contacts:", error);
552
+ throw error;
553
+ }
554
+ }
@@ -75,3 +75,21 @@ export {
75
75
  type UpdateReservationInput,
76
76
  type ReservationStatus,
77
77
  } from "./reservations";
78
+
79
+ // Contact mutations
80
+ export {
81
+ createContact,
82
+ updateContact,
83
+ deleteContact,
84
+ softDeleteContact,
85
+ restoreContact,
86
+ mergeContacts,
87
+ ContactValidationError,
88
+ createContactSchema,
89
+ updateContactSchema,
90
+ mergeContactsSchema,
91
+ type CreateContactInput,
92
+ type UpdateContactInput,
93
+ type MergeContactsInput,
94
+ type MergeContactsResult,
95
+ } from "./contacts";