diff --git a/apps/api/src/controllers/Actions.ts b/apps/api/src/controllers/Actions.ts index e3b7d3a36..a8199e74e 100644 --- a/apps/api/src/controllers/Actions.ts +++ b/apps/api/src/controllers/Actions.ts @@ -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) @@ -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)) { @@ -85,6 +86,8 @@ export class Actions { email, data as Record | undefined, subscribed, + true, + {preserveExistingSubscription}, ); // Track the event with ALL data (persistent + non-persistent) diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index fbc81ca77..86fa039b0 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -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. @@ -295,6 +299,7 @@ export class ContactService { data?: Record, subscribed?: boolean, defaultSubscribed: boolean = true, + options: ContactUpsertOptions = {}, ): Promise { const normalizedEmail = ContactService.normalizeEmail(email); @@ -310,7 +315,8 @@ 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 { @@ -318,7 +324,7 @@ export class ContactService { where: {id: existing.id}, data: { data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull, - ...(subscribed !== undefined ? {subscribed} : {}), + ...(subscribed !== undefined && !options.preserveExistingSubscription ? {subscribed} : {}), }, }); @@ -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, diff --git a/apps/api/src/services/__tests__/ContactService.test.ts b/apps/api/src/services/__tests__/ContactService.test.ts index 67c393470..bcc068f74 100644 --- a/apps/api/src/services/__tests__/ContactService.test.ts +++ b/apps/api/src/services/__tests__/ContactService.test.ts @@ -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', () => { @@ -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(resolve => { + releaseInitialLookups = resolve; + }); + let releaseCreateOnlyAttempt!: () => void; + const subscribedContactCreated = new Promise(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)', () => { diff --git a/apps/wiki/content/docs/concepts/contacts.mdx b/apps/wiki/content/docs/concepts/contacts.mdx index 238719ab5..1f772e4c0 100644 --- a/apps/wiki/content/docs/concepts/contacts.mdx +++ b/apps/wiki/content/docs/concepts/contacts.mdx @@ -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 diff --git a/apps/wiki/content/docs/recipes/double-opt-in.mdx b/apps/wiki/content/docs/recipes/double-opt-in.mdx index a018ea226..a23f50b76 100644 --- a/apps/wiki/content/docs/recipes/double-opt-in.mdx +++ b/apps/wiki/content/docs/recipes/double-opt-in.mdx @@ -35,21 +35,24 @@ import {Step, Steps} from 'fumadocs-ui/components/steps'; -### 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. diff --git a/apps/wiki/openapi.json b/apps/wiki/openapi.json index 622517bb0..5a6ca0fda 100644 --- a/apps/wiki/openapi.json +++ b/apps/wiki/openapi.json @@ -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, diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 0087d8ffa..2cb2d9855 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -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