Skip to content
Merged
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
45 changes: 28 additions & 17 deletions app/(capture)/checkin/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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.
Expand All @@ -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
}
}
8 changes: 4 additions & 4 deletions app/(capture)/checkin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -37,15 +37,15 @@ export default async function CheckInPage({ searchParams }: { searchParams: Prom
<CaptureShell
eyebrow={spaceName}
title={eventTitle ? `Check in to ${eventTitle}.` : 'Check in.'}
description="Leave your name so the host knows you came. That's it."
description="Leave your contact information for the host. Ask them if you need attendance confirmation."
>
<CaptureForm
token={t as string}
submit={captureCheckIn}
submitLabel="Check me in"
showPhone
consentNote={
<>Checking in just tells the host you were here. We won&apos;t add you to any email list for it.</>
<>Your details go to the host. We won&apos;t add you to any email list for it.</>
}
/>
</CaptureShell>
Expand Down
83 changes: 83 additions & 0 deletions app/(capture)/checkin/receipt.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@/lib/crm/lead-capture')>(),
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()
})
})
19 changes: 19 additions & 0 deletions docs/ledger/rows/SHARE-GUEST-RECEIPT.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading