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;
}
// 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'}`,
);
}
}
}
@@ -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', () => {
+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.
## 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 |
<Callout
title="Default data type"
variant="idea">
If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string.
<Callout title="Default data type" variant="idea">
If you accidentally mix data types for a specific key, Plunk will default to treating the value as a string.
</Callout>
### 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>
### 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 |
<Callout
title="Transactional emails and marketing templates"
variant="warn">
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 title="Transactional emails and marketing templates" variant="warn">
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>