diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index f5ea170..3976d29 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -234,10 +234,22 @@ export class ContactService { continue; } + // Skip empty string values - they don't provide meaningful data + // and can cause issues with template rendering and data integrity + if (value === '') { + continue; + } + + // Delete field if null is passed (allows removing fields from contact data) + if (value === null) { + delete mergedData[key]; + continue; + } + // Validate locale field (special user-settable field) // Only validate type - any locale string is accepted since we default to English if unsupported if (key === 'locale') { - if (value !== null && value !== undefined && typeof value !== 'string') { + if (value !== undefined && typeof value !== 'string') { throw new HttpException(400, 'Locale must be a string'); } } @@ -265,33 +277,49 @@ export class ContactService { const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed; const wasSubscribed = existing.subscribed; - const updated = await prisma.contact.update({ - where: {id: existing.id}, - data: { - data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, - ...(subscribed !== undefined ? {subscribed} : {}), - }, - }); + try { + const updated = await prisma.contact.update({ + where: {id: existing.id}, + data: { + data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, + ...(subscribed !== undefined ? {subscribed} : {}), + }, + }); - // Track subscription event if status changed - if (isSubscriptionChanging) { - if (subscribed && !wasSubscribed) { - await EventService.trackEvent(projectId, 'contact.subscribed', updated.id); - } else if (!subscribed && wasSubscribed) { - await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id); + // Track subscription event if status changed + if (isSubscriptionChanging) { + if (subscribed && !wasSubscribed) { + await EventService.trackEvent(projectId, 'contact.subscribed', updated.id); + } else if (!subscribed && wasSubscribed) { + await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id); + } } - } - return updated; + return updated; + } catch (error) { + // Provide helpful error message for database/validation issues + throw new HttpException( + 500, + `Failed to update contact: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } } else { - return prisma.contact.create({ - data: { - projectId, - email, - data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, - subscribed: subscribed ?? defaultSubscribed, - }, - }); + try { + return await prisma.contact.create({ + data: { + projectId, + email, + data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, + subscribed: subscribed ?? defaultSubscribed, + }, + }); + } catch (error) { + // Provide helpful error message for database/validation issues + throw new HttpException( + 500, + `Failed to create contact: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } } } diff --git a/apps/api/src/services/__tests__/ContactService.test.ts b/apps/api/src/services/__tests__/ContactService.test.ts index 44b12a1..b72514e 100644 --- a/apps/api/src/services/__tests__/ContactService.test.ts +++ b/apps/api/src/services/__tests__/ContactService.test.ts @@ -336,19 +336,75 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => { }); }); - it('should handle null values in data', async () => { + it('should delete keys when null value is passed', async () => { const email = 'test@example.com'; const contact = await ContactService.upsert(projectId, email, { firstName: 'John', - middleName: null, + middleName: null, // null should delete/not store the key lastName: 'Doe', }); expect(contact.data).toHaveProperty('firstName', 'John'); - expect(contact.data).toHaveProperty('middleName', null); + expect(contact.data).not.toHaveProperty('middleName'); // Key should not exist expect(contact.data).toHaveProperty('lastName', 'Doe'); }); + + it('should remove existing fields when null is passed', async () => { + const email = 'test@example.com'; + + // Create contact with data + await ContactService.upsert(projectId, email, { + firstName: 'John', + middleName: 'Michael', + lastName: 'Doe', + }); + + // Update with null to remove middleName + const updated = await ContactService.upsert(projectId, email, { + middleName: null, // Should delete this field + }); + + expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved + expect(updated.data).not.toHaveProperty('middleName'); // Removed + expect(updated.data).toHaveProperty('lastName', 'Doe'); // Preserved + }); + + it('should filter out empty string values from data', async () => { + const email = 'test@example.com'; + + const contact = await ContactService.upsert(projectId, email, { + firstName: 'John', + middleName: '', // Empty string should be filtered out + lastName: 'Doe', + company: '', // Empty string should be filtered out + }); + + expect(contact.data).toHaveProperty('firstName', 'John'); + expect(contact.data).toHaveProperty('lastName', 'Doe'); + expect(contact.data).not.toHaveProperty('middleName'); + expect(contact.data).not.toHaveProperty('company'); + }); + + it('should not overwrite existing data with empty strings', async () => { + const email = 'test@example.com'; + + // Create contact with data + await ContactService.upsert(projectId, email, { + firstName: 'John', + company: 'Acme Inc', + }); + + // Update with empty string - should preserve existing values + const updated = await ContactService.upsert(projectId, email, { + firstName: '', // Should be filtered out, preserving "John" + lastName: 'Doe', + }); + + expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved + expect(updated.data).toHaveProperty('company', 'Acme Inc'); // Preserved + expect(updated.data).toHaveProperty('lastName', 'Doe'); // New value + }); }); describe('Contact CRUD Operations', () => { diff --git a/apps/wiki/content/docs/concepts/contacts.mdx b/apps/wiki/content/docs/concepts/contacts.mdx index 65a8603..2feca3b 100644 --- a/apps/wiki/content/docs/concepts/contacts.mdx +++ b/apps/wiki/content/docs/concepts/contacts.mdx @@ -7,16 +7,20 @@ icon: Users Contacts in Plunk represent an individual email recipient. Each contact has an identifier and is linked to an email address. ## Adding contacts + Contacts can be added to your Plunk project in several ways: + - Using [/v1/track](/api-reference/public-api/trackEvent), when tracking an event for a contact that does not yet exist, Plunk will automatically create it. - Using [/contacts](/api-reference/contacts/createContact), to create a single contact. - Import through CSV - Manually through the dashboard ## Contact Data + You can associate custom data with each contact using key-value pairs. This data can be used for segmentation and personalization. ### Data types + Contact data types are inferred based on the value provided: | Type | Description | |------|-------------| @@ -25,13 +29,27 @@ Contact data types are inferred based on the value provided: | Boolean | True or false values | | Date | Date values in ISO 8601 format | - -If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string. + + If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string. + + +### Special value handling + +When creating or updating contacts, certain values are handled specially: + +| Value | Behavior | Example | +| ------------------- | ------------------------------------------- | ------------------------------------- | +| Empty string (`""`) | Ignored - field is not stored or updated | `{ name: "" }` → Field is skipped | +| `null` | Delete - field is removed from contact data | `{ name: null }` → Field is deleted | +| Other values | Stored/updated normally | `{ name: "John" }` → Stored as "John" | + + + To remove a field from a contact's data, set it to `null` when creating or updating the contact. Empty strings are + automatically filtered out and won't overwrite existing data. ### Reserved keys + Certain keys are reserved by the system and automatically set by Plunk: | Key | Description | |-----|-------------| @@ -41,8 +59,9 @@ Certain keys are reserved by the system and automatically set by Plunk: | subscribed | Boolean indicating if the contact is globally subscribed or not | ### Special keys -| Key | Description | -|-----|-------------| + +| Key | Description | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers | ## Subscription State @@ -52,6 +71,7 @@ Every contact has a `subscribed` field that determines which types of emails the ### How contacts become unsubscribed A contact can become unsubscribed in several ways: + - **Manually** through the dashboard or via the API - **Self-service** by clicking the unsubscribe link in an email - **Automatically** when an email to the contact bounces or results in a complaint @@ -60,18 +80,17 @@ A contact can become unsubscribed in several ways: The subscription state controls whether a contact receives marketing emails. Transactional emails are always delivered regardless of subscription state. -| Email type | Subscribed | Unsubscribed | -|---|---|---| -| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered | -| **Campaigns** (marketing) | Delivered | Not delivered | -| **Campaigns** (headless) | Delivered | Not delivered | -| **Campaigns** (transactional) | Delivered | Delivered | -| **Automations** (marketing template) | Delivered | Not delivered | -| **Automations** (headless template) | Delivered | Not delivered | -| **Automations** (transactional template) | Delivered | Delivered | +| Email type | Subscribed | Unsubscribed | +| ------------------------------------------------------------------------------------ | ---------- | ------------- | +| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered | +| **Campaigns** (marketing) | Delivered | Not delivered | +| **Campaigns** (headless) | Delivered | Not delivered | +| **Campaigns** (transactional) | Delivered | Delivered | +| **Automations** (marketing template) | Delivered | Not delivered | +| **Automations** (headless template) | Delivered | Not delivered | +| **Automations** (transactional template) | Delivered | Delivered | - -Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed contact. Use a transactional template instead if the email must reach unsubscribed contacts. - \ No newline at end of file + + Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed + contact. Use a transactional template instead if the email must reach unsubscribed contacts. +