feat: enhance contact data handling by filtering empty strings and allowing null to delete fields

This commit is contained in:
Dries Augustyns
2026-04-27 16:59:18 +02:00
parent 5f741c66c7
commit 10946511b0
3 changed files with 150 additions and 47 deletions
+52 -24
View File
@@ -234,10 +234,22 @@ export class ContactService {
continue; 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) // Validate locale field (special user-settable field)
// Only validate type - any locale string is accepted since we default to English if unsupported // Only validate type - any locale string is accepted since we default to English if unsupported
if (key === 'locale') { 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'); throw new HttpException(400, 'Locale must be a string');
} }
} }
@@ -265,33 +277,49 @@ export class ContactService {
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed; const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
const wasSubscribed = existing.subscribed; const wasSubscribed = existing.subscribed;
const updated = await prisma.contact.update({ try {
where: {id: existing.id}, const updated = await prisma.contact.update({
data: { where: {id: existing.id},
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, data: {
...(subscribed !== undefined ? {subscribed} : {}), data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
}, ...(subscribed !== undefined ? {subscribed} : {}),
}); },
});
// Track subscription event if status changed // Track subscription event if status changed
if (isSubscriptionChanging) { if (isSubscriptionChanging) {
if (subscribed && !wasSubscribed) { if (subscribed && !wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id); await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
} else if (!subscribed && wasSubscribed) { } else if (!subscribed && wasSubscribed) {
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id); 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 { } else {
return prisma.contact.create({ try {
data: { return await prisma.contact.create({
projectId, data: {
email, projectId,
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, email,
subscribed: subscribed ?? defaultSubscribed, 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'}`,
);
}
} }
} }
@@ -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 = '[email protected]'; const email = '[email protected]';
const contact = await ContactService.upsert(projectId, email, { const contact = await ContactService.upsert(projectId, email, {
firstName: 'John', firstName: 'John',
middleName: null, middleName: null, // null should delete/not store the key
lastName: 'Doe', lastName: 'Doe',
}); });
expect(contact.data).toHaveProperty('firstName', 'John'); 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'); expect(contact.data).toHaveProperty('lastName', 'Doe');
}); });
it('should remove existing fields when null is passed', async () => {
const email = '[email protected]';
// 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 = '[email protected]';
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 = '[email protected]';
// 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', () => { describe('Contact CRUD Operations', () => {
+39 -20
View File
@@ -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. Contacts in Plunk represent an individual email recipient. Each contact has an identifier and is linked to an email address.
## Adding contacts ## Adding contacts
Contacts can be added to your Plunk project in several ways: 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 [/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. - Using [/contacts](/api-reference/contacts/createContact), to create a single contact.
- Import through CSV - Import through CSV
- Manually through the dashboard - Manually through the dashboard
## Contact Data ## Contact Data
You can associate custom data with each contact using key-value pairs. This data can be used for segmentation and personalization. You can associate custom data with each contact using key-value pairs. This data can be used for segmentation and personalization.
### Data types ### Data types
Contact data types are inferred based on the value provided: Contact data types are inferred based on the value provided:
| Type | Description | | Type | Description |
|------|-------------| |------|-------------|
@@ -25,13 +29,27 @@ Contact data types are inferred based on the value provided:
| Boolean | True or false values | | Boolean | True or false values |
| Date | Date values in ISO 8601 format | | Date | Date values in ISO 8601 format |
<Callout <Callout title="Default data type" variant="idea">
title="Default data type" If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string.
variant="idea"> </Callout>
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" |
<Callout title="Removing contact data" variant="idea">
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.
</Callout> </Callout>
### Reserved keys ### Reserved keys
Certain keys are reserved by the system and automatically set by Plunk: Certain keys are reserved by the system and automatically set by Plunk:
| Key | Description | | 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 | | subscribed | Boolean indicating if the contact is globally subscribed or not |
### Special keys ### 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 | | 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 ## Subscription State
@@ -52,6 +71,7 @@ Every contact has a `subscribed` field that determines which types of emails the
### How contacts become unsubscribed ### How contacts become unsubscribed
A contact can become unsubscribed in several ways: A contact can become unsubscribed in several ways:
- **Manually** through the dashboard or via the API - **Manually** through the dashboard or via the API
- **Self-service** by clicking the unsubscribe link in an email - **Self-service** by clicking the unsubscribe link in an email
- **Automatically** when an email to the contact bounces or results in a complaint - **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. The subscription state controls whether a contact receives marketing emails. Transactional emails are always delivered regardless of subscription state.
| Email type | Subscribed | Unsubscribed | | Email type | Subscribed | Unsubscribed |
|---|---|---| | ------------------------------------------------------------------------------------ | ---------- | ------------- |
| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered | | **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered |
| **Campaigns** (marketing) | Delivered | Not delivered | | **Campaigns** (marketing) | Delivered | Not delivered |
| **Campaigns** (headless) | Delivered | Not delivered | | **Campaigns** (headless) | Delivered | Not delivered |
| **Campaigns** (transactional) | Delivered | Delivered | | **Campaigns** (transactional) | Delivered | Delivered |
| **Automations** (marketing template) | Delivered | Not delivered | | **Automations** (marketing template) | Delivered | Not delivered |
| **Automations** (headless template) | Delivered | Not delivered | | **Automations** (headless template) | Delivered | Not delivered |
| **Automations** (transactional template) | Delivered | Delivered | | **Automations** (transactional template) | Delivered | Delivered |
<Callout <Callout title="Transactional emails and marketing templates" variant="warn">
title="Transactional emails and marketing templates" Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed
variant="warn"> contact. Use a transactional template instead if the email must reach unsubscribed contacts.
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. </Callout>
</Callout>