diff --git a/app/(capture)/checkin/actions.ts b/app/(capture)/checkin/actions.ts index 9ec3351a8b..8b37f11f21 100644 --- a/app/(capture)/checkin/actions.ts +++ b/app/(capture)/checkin/actions.ts @@ -9,8 +9,8 @@ import type { CaptureSubmitResult } from '@/components/crm/leads/capture-form' const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/ /** - * FRONT DOOR #3 — event / attendance capture. Seals an attendee as a lead and records the attendance - * tier so the Space can map tier -> lifecycle stage. Attendance is NOT marketing consent, so the lead + * FRONT DOOR #3 — event contact capture. Seals a guest as a lead with an event label and tier; + * this does not write an event attendance receipt. Attendance is NOT marketing consent, so the lead * stays sealed 'unknown' (the engine enforces this). Public + unauthenticated: honeypot, rate limit, * anti-enumeration. Space + event come only from the signed token. */ @@ -34,8 +34,8 @@ export async function captureCheckIn(input: { const done: CaptureSubmitResult = { ok: true, - heading: "You're checked in.", - message: 'Thanks for coming. Good to have you here.', + heading: 'Thanks for coming.', + message: 'The host has your contact information. Ask your host if you need attendance confirmation.', } // Honeypot: same success, no write. @@ -46,20 +46,31 @@ export async function captureCheckIn(input: { return { ok: false, error: 'This check-in link is no longer valid. Ask a host for a fresh one.' } } - const hdrs = await headers() - const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() || hdrs.get('x-real-ip') || 'unknown' - if (!(await rateLimitOk('lead_checkin', ip, 12, '10 m'))) { - return { ok: false, error: 'Too many tries. Give it a few minutes and try again.' } + const unavailable: CaptureSubmitResult = { + ok: false, + error: "We couldn't save your details. Please try again or ask the host for help.", } - await captureEventLead({ - spaceId: payload.s, - email, - phone, - displayName: name, - eventTitle: payload.w ?? payload.l ?? null, - tier: payload.tr ?? 'attended', - }).catch(() => null) + try { + const hdrs = await headers() + const ip = hdrs.get('x-forwarded-for')?.split(',')[0]?.trim() || hdrs.get('x-real-ip') || 'unknown' + if (!(await rateLimitOk('lead_checkin', ip, 12, '10 m'))) { + return { ok: false, error: 'Too many tries. Give it a few minutes and try again.' } + } - return done + const captured = await captureEventLead({ + spaceId: payload.s, + email, + phone, + displayName: name, + eventTitle: payload.w ?? payload.l ?? null, + tier: payload.tr ?? 'attended', + }) + + // A null result includes storage/capacity failures. A contact receipt is not an attendance ledger. + return captured ? done : unavailable + } catch { + // Keep infrastructure failures generic, without revealing whether this address already exists. + return unavailable + } } diff --git a/app/(capture)/checkin/page.tsx b/app/(capture)/checkin/page.tsx index ab94eb7239..f7db344491 100644 --- a/app/(capture)/checkin/page.tsx +++ b/app/(capture)/checkin/page.tsx @@ -6,8 +6,8 @@ import { CaptureForm } from '@/components/crm/leads/capture-form' import { captureCheckIn } from './actions' // FRONT DOOR #3 — event / attendance capture. A host points attendees here (a printed QR, a link on -// the screen). Leaving a name + email checks them in and drops them into the Space CRM with the event -// as their entry point. Not marketing consent, so it never promises email. Noindex (transactional). +// the screen). Name + email are captured in the Space CRM with the event label; this is not the +// event attendance ledger. Not marketing consent, so it never promises email. Noindex (transactional). export const metadata: Metadata = { title: 'Check in', robots: { index: false }, @@ -37,7 +37,7 @@ export default async function CheckInPage({ searchParams }: { searchParams: Prom Checking in just tells the host you were here. We won't add you to any email list for it. + <>Your details go to the host. We won't add you to any email list for it. } /> diff --git a/app/(capture)/checkin/receipt.test.ts b/app/(capture)/checkin/receipt.test.ts new file mode 100644 index 0000000000..b273b1cd68 --- /dev/null +++ b/app/(capture)/checkin/receipt.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const fx = vi.hoisted(() => ({ + capture: vi.fn(), headers: vi.fn(), rateLimit: vi.fn(), +})) +vi.mock('next/headers', () => ({ headers: fx.headers })) +vi.mock('@/lib/rate-limit', () => ({ rateLimitOk: fx.rateLimit })) +vi.mock('@/lib/crm/lead-capture', async (importActual) => ({ + ...await importActual(), + captureEventLead: fx.capture, +})) +import { captureCheckIn } from '@/app/(capture)/checkin/actions' +import { makeLeadLinkPayload, signLeadLink } from '@/lib/crm/lead-links' + +const blank = { name: ' Jo ', email: ' JO@example.com ', phone: '', company: '' } +const token = () => signLeadLink(makeLeadLinkPayload({ s: 'space-1', d: 'event', w: 'Sunday Sit', tr: 'vip' })) + +beforeEach(() => { + process.env.OPTIN_CONFIRM_SECRET = 'test-guest-receipt-secret-0123456789' + vi.resetAllMocks() + fx.headers.mockResolvedValue(new Map([['x-forwarded-for', '192.0.2.1, 192.0.2.2']])) + fx.rateLimit.mockResolvedValue(true) + fx.capture.mockResolvedValue({ contactId: 'contact-1', firstTouch: true }) +}) + +describe('guest event contact receipt', () => { + it('describes the contact receipt without claiming verified attendance', async () => { + const result = await captureCheckIn({ ...blank, token: token() }) + expect(result).toEqual({ ok: true, heading: 'Thanks for coming.', message: 'The host has your contact information. Ask your host if you need attendance confirmation.' }) + expect(fx.capture).toHaveBeenCalledWith({ spaceId: 'space-1', email: 'jo@example.com', phone: null, displayName: 'Jo', eventTitle: 'Sunday Sit', tier: 'vip' }) + expect(fx.rateLimit).toHaveBeenCalledWith('lead_checkin', '192.0.2.1', 12, '10 m') + }) + it('gives existing and new contacts the same receipt', async () => { + const created = await captureCheckIn({ ...blank, token: token() }) + fx.capture.mockResolvedValue({ contactId: 'contact-2', firstTouch: false }) + expect(await captureCheckIn({ ...blank, token: token() })).toEqual(created) + }) + it('returns a visible generic retry error on a null save or capacity refusal', async () => { + fx.capture.mockResolvedValue(null) + expect(await captureCheckIn({ ...blank, token: token() })).toEqual({ ok: false, error: "We couldn't save your details. Please try again or ask the host for help." }) + }) + it('uses that same generic error for a rejected save without leaking provider details', async () => { + fx.capture.mockRejectedValue(new Error('private storage detail')) + expect(await captureCheckIn({ ...blank, token: token() })).toEqual({ ok: false, error: "We couldn't save your details. Please try again or ask the host for help." }) + }) + it('returns a retry result if request headers fail', async () => { + fx.headers.mockRejectedValue(new Error('request unavailable')) + expect((await captureCheckIn({ ...blank, token: token() })).ok).toBe(false) + expect(fx.capture).not.toHaveBeenCalled() + }) + it('returns a retry result if rate-limit storage rejects', async () => { + fx.rateLimit.mockRejectedValue(new Error('rate storage unavailable')) + expect((await captureCheckIn({ ...blank, token: token() })).ok).toBe(false) + expect(fx.capture).not.toHaveBeenCalled() + }) + it('keeps rate-limited attempts refused without a capture', async () => { + fx.rateLimit.mockResolvedValue(false) + expect(await captureCheckIn({ ...blank, token: token() })).toEqual({ ok: false, error: 'Too many tries. Give it a few minutes and try again.' }) + expect(fx.capture).not.toHaveBeenCalled() + }) + it('keeps the intentional honeypot success identical and skips token, headers and storage', async () => { + const success = await captureCheckIn({ ...blank, token: token() }) + vi.clearAllMocks() + expect(await captureCheckIn({ ...blank, token: 'forged', company: 'bot' })).toEqual(success) + expect(fx.capture).not.toHaveBeenCalled() + expect(fx.headers).not.toHaveBeenCalled() + expect(fx.rateLimit).not.toHaveBeenCalled() + }) + it('refuses forged tokens without capture', async () => { + expect((await captureCheckIn({ ...blank, token: 'forged' })).ok).toBe(false) + expect(fx.capture).not.toHaveBeenCalled() + }) + it('refuses other signed doors without capture', async () => { + const wrongDoor = signLeadLink(makeLeadLinkPayload({ s: 'space-1', d: 'share_back' })) + expect((await captureCheckIn({ ...blank, token: wrongDoor })).ok).toBe(false) + expect(fx.capture).not.toHaveBeenCalled() + }) + it('keeps invalid email validation before all storage calls', async () => { + expect((await captureCheckIn({ ...blank, email: 'invalid', token: token() })).ok).toBe(false) + expect(fx.capture).not.toHaveBeenCalled() + expect(fx.rateLimit).not.toHaveBeenCalled() + }) +}) diff --git a/docs/ledger/rows/SHARE-GUEST-RECEIPT.json b/docs/ledger/rows/SHARE-GUEST-RECEIPT.json new file mode 100644 index 0000000000..d9af87f437 --- /dev/null +++ b/docs/ledger/rows/SHARE-GUEST-RECEIPT.json @@ -0,0 +1,19 @@ +{ + "id": "SHARE-GUEST-RECEIPT", + "title": "Guest check-in shows a truthful contact receipt and recoverable save failures", + "lane": "live", + "priority": "P2", + "size": "S", + "status": "open", + "source": { + "file": "app/(capture)/checkin/actions.ts", + "ref": "Sharing-system audit QR-NFC-03, rechecked on main 21f1e340, 2026-11-04." + }, + "detail": "The public signed event contact door previously returned success after null or rejected captureEventLead. Its CRM engine can return null for capacity or storage failures and its non-null result proves a Space-scoped contact exists, not an event attendance receipt. Local candidate returns a generic retry result for null, rejected capture, header and rate-store exceptions, keeps the intentional honeypot uniform success before token/storage, and describes the host contact receipt without claiming attendance. Signed Space/event-label/tier binding and CRM consent/suppression behavior are unchanged. Acceptance: successful existing/new contacts share one truthful receipt; null/rejected failures never confirm saving; honeypot skips token/headers/storage; invalid and wrong-door tokens remain refused; no marketing opt-in, event attendance ledger or reward write is introduced. Daniel specifically approved the exact regression review patch on 2026-11-04; its 11 permanent action checks are applied. Still open pending required exact-head CI/preview, release integration and production verification.", + "verify": { + "kind": "manual", + "checked": "2026-11-04", + "evidence": "On isolated 21f1e340 candidate, all 85 tests across seven focused suites pass, including the exact 11-test regression patch Daniel approved. The temporary pristine-base negative control fails the five targeted defects while six controls pass. Focused lint/diff and static backlog checks pass. No real capture or production write was performed; no production proof is claimed." + }, + "wave": "W7" +}