Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/api/src/controllers/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export class Actions {
* - event: string (required) - Event name
* - email: string (required) - Contact email
* - subscribed: boolean (optional) - Contact subscription status (only updates if explicitly specified)
* - preserveExistingSubscription: boolean (optional) - Apply `subscribed` only when creating the contact
* - data: object (optional) - Event and contact data
* - Simple values are saved to contact (persistent)
* - {value: any, persistent: false} are only available to workflows (non-persistent)
Expand Down Expand Up @@ -59,7 +60,7 @@ export class Actions {
const auth = res.locals.auth;

// Zod validation - errors automatically handled by global error handler
const {event, email, subscribed, data} = ActionSchemas.track.parse(req.body);
const {event, email, subscribed, preserveExistingSubscription, data} = ActionSchemas.track.parse(req.body);

// Prevent manual tracking of reserved system events
if (EventService.isReservedEvent(event)) {
Expand All @@ -85,6 +86,8 @@ export class Actions {
email,
data as Record<string, unknown> | undefined,
subscribed,
true,
{preserveExistingSubscription},
);

// Track the event with ALL data (persistent + non-persistent)
Expand Down
27 changes: 25 additions & 2 deletions apps/api/src/services/ContactService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js';

export interface ContactUpsertOptions {
/** Apply `subscribed` only when this call creates the contact. */
preserveExistingSubscription?: boolean;
}
export class ContactService {
/**
* Normalize an email address for storage and lookup.
Expand Down Expand Up @@ -295,6 +299,7 @@ export class ContactService {
data?: Record<string, unknown>,
subscribed?: boolean,
defaultSubscribed: boolean = true,
options: ContactUpsertOptions = {},
): Promise<Contact> {
const normalizedEmail = ContactService.normalizeEmail(email);

Expand All @@ -310,15 +315,16 @@ export class ContactService {

if (existing) {
// Track subscription status change
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
const isSubscriptionChanging =
subscribed !== undefined && !options.preserveExistingSubscription && existing.subscribed !== subscribed;
const wasSubscribed = existing.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} : {}),
...(subscribed !== undefined && !options.preserveExistingSubscription ? {subscribed} : {}),
},
});

Expand Down Expand Up @@ -350,6 +356,23 @@ export class ContactService {
},
});
} catch (error) {
if (
options.preserveExistingSubscription &&
error instanceof Error &&
'code' in error &&
error.code === 'P2002'
) {
const elected = await prisma.contact.findUnique({
where: {
projectId_email: {
projectId,
email: normalizedEmail,
},
},
});
if (elected) return elected;
}

// Provide helpful error message for database/validation issues
throw new HttpException(
500,
Expand Down
104 changes: 103 additions & 1 deletion apps/api/src/services/__tests__/ContactService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {beforeEach, describe, expect, it} from 'vitest';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {ContactService} from '../ContactService';
import {prisma as servicePrisma} from '../../database/prisma.js';
import {factories, getPrismaClient} from '../../../../../test/helpers';

describe('ContactService - Duplicate Prevention & Data Merging', () => {
Expand Down Expand Up @@ -73,6 +74,107 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
});
expect(contacts).toHaveLength(1);
});

it('applies a create-only subscription value without changing existing preferences', async () => {
const subscribed = await ContactService.upsert(
projectId,
'already-subscribed@example.com',
{source: 'existing'},
true,
);
const unsubscribed = await ContactService.upsert(
projectId,
'already-unsubscribed@example.com',
{source: 'existing'},
false,
);

const [preservedSubscribed, preservedUnsubscribed, createdPending] = await Promise.all([
ContactService.upsert(projectId, subscribed.email, {attempt: 'repeat opt-in'}, false, true, {
preserveExistingSubscription: true,
}),
ContactService.upsert(projectId, unsubscribed.email, {attempt: 'suppressed opt-in'}, false, true, {
preserveExistingSubscription: true,
}),
ContactService.upsert(projectId, 'new-pending@example.com', {attempt: 'first opt-in'}, false, true, {
preserveExistingSubscription: true,
}),
]);

expect(preservedSubscribed.subscribed).toBe(true);
expect(preservedUnsubscribed.subscribed).toBe(false);
expect(createdPending.subscribed).toBe(false);
expect(
await prisma.event.count({
where: {
projectId,
contactId: subscribed.id,
name: 'contact.unsubscribed',
},
}),
).toBe(0);
});

it('preserves the elected state when a create-only upsert loses the first-contact race', async () => {
const email = 'create-only-race@example.com';
const originalFindFirst = servicePrisma.contact.findFirst.bind(servicePrisma.contact);
const originalCreate = servicePrisma.contact.create.bind(servicePrisma.contact);
let initialLookups = 0;
let releaseInitialLookups!: () => void;
const bothInitialLookupsStarted = new Promise<void>(resolve => {
releaseInitialLookups = resolve;
});
let releaseCreateOnlyAttempt!: () => void;
const subscribedContactCreated = new Promise<void>(resolve => {
releaseCreateOnlyAttempt = resolve;
});

const contactLookup = vi.spyOn(servicePrisma.contact, 'findFirst').mockImplementation(async args => {
if (args.where?.projectId === projectId && args.where?.email === email) {
initialLookups += 1;
if (initialLookups === 2) releaseInitialLookups();
await bothInitialLookupsStarted;
return null;
}

return originalFindFirst(args);
});
const contactCreate = vi.spyOn(servicePrisma.contact, 'create').mockImplementation(async args => {
if (args.data.projectId === projectId && args.data.email === email && args.data.subscribed === false) {
await subscribedContactCreated;
}

const contact = await originalCreate(args);
if (args.data.projectId === projectId && args.data.email === email && args.data.subscribed === true) {
releaseCreateOnlyAttempt();
}
return contact;
});

try {
const contacts = await Promise.all([
ContactService.upsert(projectId, email, {source: 'winner'}, true),
ContactService.upsert(projectId, email, {source: 'create-only loser'}, false, true, {
preserveExistingSubscription: true,
}),
]);
expect(new Set(contacts.map(contact => contact.id))).toHaveLength(1);
} finally {
contactLookup.mockRestore();
contactCreate.mockRestore();
}

const stored = await prisma.contact.findUniqueOrThrow({
where: {projectId_email: {projectId, email}},
});
expect(stored.subscribed).toBe(true);
expect(stored.data).toEqual({source: 'winner'});
expect(
await prisma.event.count({
where: {projectId, contactId: stored.id, name: 'contact.unsubscribed'},
}),
).toBe(0);
});
});

describe('Email Normalization (case-insensitive find-or-create)', () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/wiki/content/docs/concepts/contacts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ Every contact has a `subscribed` field that determines which types of emails the

When you update a contact, **omitting `subscribed` keeps the current state** — it is not the same as passing `false`. To change the state, pass an explicit `true` or `false`.

For a one-call double-opt-in event, pass `subscribed: false` with
`preserveExistingSubscription: true` to create a new contact pending while
leaving every existing contact's preference unchanged.

Every flip of `subscribed` automatically tracks an event on the contact:

- `subscribed` flipped to `true` → `contact.subscribed` event
Expand Down
21 changes: 12 additions & 9 deletions apps/wiki/content/docs/recipes/double-opt-in.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,24 @@ import {Step, Steps} from 'fumadocs-ui/components/steps';

<Step>

### Trigger the signup from your backend
### Trigger the signup

Two calls with your secret key (`sk_*`): create the contact unsubscribed, then track the event that fires the confirmation workflow.
One call with your public key (`pk_*`) creates a new contact unsubscribed and tracks the event that fires the confirmation workflow:

```bash
curl https://next-api.useplunk.com/contacts \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{ "email": "ada@example.com", "subscribed": false, "data": { "firstName": "Ada" } }'

curl https://next-api.useplunk.com/v1/track \
-H "Authorization: Bearer sk_your_secret_key" \
-d '{ "event": "signup.pending", "email": "ada@example.com", "subscribed": false }'
-H "Authorization: Bearer pk_your_public_key" \
-H "Content-Type: application/json" \
-d '{
"event": "signup.pending",
"email": "ada@example.com",
"subscribed": false,
"preserveExistingSubscription": true,
"data": { "firstName": "Ada" }
}'
```

Both calls pass `subscribed: false`. If you skip the first call and rely on `/v1/track` alone, tracking on an unknown email creates the contact — but defaults it to subscribed, which defeats the point.
`subscribed: false` makes a new contact pending. `preserveExistingSubscription: true` makes that value create-only: an existing active contact stays active, while an existing unsubscribed contact stays unsubscribed. This is the safe shape for repeat opt-ins and retries because it never silently changes an existing preference.

</Step>

Expand Down
4 changes: 4 additions & 0 deletions apps/wiki/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,10 @@
"type": "boolean",
"description": "Subscription state to apply to the contact. **New** contacts default to subscribed (`true`). **Existing** contacts keep their current state unless you pass an explicit value here. Pass `false` to track an event without resubscribing an unsubscribed contact."
},
"preserveExistingSubscription": {
"type": "boolean",
"description": "When `true`, applies `subscribed` only if this request creates the contact. Existing contacts keep their stored subscription state, including when another request wins a concurrent first-contact race. Use with `subscribed: false` for one-call double opt-in."
},
"data": {
"type": "object",
"additionalProperties": true,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ export const ActionSchemas = {
event: z.string().min(1),
email,
subscribed: z.boolean().optional(),
preserveExistingSubscription: z.boolean().optional(),
data: jsonSchema.optional(),
}),
send: z
Expand Down