Skip to content

Commit e8e8d03

Browse files
authored
Add digital contract signing to Sponsor CRM (#335)
1 parent 533d456 commit e8e8d03

197 files changed

Lines changed: 20157 additions & 2548 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,5 @@ storybook-static/*
4848
public/mockServiceWorker.js
4949
# storybook local env
5050
.storybook/.env
51+
52+
certificates
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React from 'react'
2+
import { SessionProvider } from 'next-auth/react'
3+
import type { Decorator } from '@storybook/nextjs-vite'
4+
5+
const mockSession = {
6+
expires: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
7+
user: {
8+
name: 'Storybook User',
9+
email: 'storybook@example.com',
10+
},
11+
speaker: {
12+
_id: 'speaker-storybook',
13+
name: 'Storybook User',
14+
isOrganizer: true,
15+
},
16+
}
17+
18+
export const SessionDecorator: Decorator = (Story) => (
19+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
20+
<SessionProvider session={mockSession as any}>
21+
<Story />
22+
</SessionProvider>
23+
)

.storybook/main.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,33 @@ const config: StorybookConfig = {
1515
},
1616
},
1717
staticDirs: ['../public'],
18+
viteFinal: async (config) => {
19+
// CloudNativePattern imports static CNCF SVGs that Vite cannot resolve.
20+
// Replace with a lightweight stub so stories using BackgroundImage can build.
21+
config.plugins = config.plugins || []
22+
config.plugins.push({
23+
name: 'mock-cloud-native-pattern',
24+
enforce: 'pre',
25+
resolveId(id) {
26+
if (
27+
id === './CloudNativePattern' ||
28+
id.endsWith('/CloudNativePattern')
29+
) {
30+
return '\0mock:CloudNativePattern'
31+
}
32+
},
33+
load(id) {
34+
if (id === '\0mock:CloudNativePattern') {
35+
return `
36+
export function CloudNativePattern({ className }) {
37+
return null;
38+
}
39+
`
40+
}
41+
},
42+
})
43+
return config
44+
},
1845
typescript: {
1946
check: false,
2047
reactDocgen: 'react-docgen-typescript',

.storybook/preview.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Preview } from '@storybook/nextjs-vite'
22
import type { Decorator } from '@storybook/nextjs-vite'
33
import { initialize, mswLoader } from 'msw-storybook-addon'
44
import { TRPCDecorator } from './decorators/TRPCDecorator'
5+
import { SessionDecorator } from './decorators/SessionDecorator'
56
import '../src/styles/tailwind.css'
67

78
// Initialize MSW
@@ -123,8 +124,6 @@ const preview: Preview = {
123124
'SponsorCRMForm',
124125
'SponsorBulkActions',
125126
'MobileFilterSheet',
126-
'OnboardingLinkButton',
127-
'SendContractButton',
128127
'ContractReadinessIndicator',
129128
'ImportHistoricSponsorsButton',
130129
],
@@ -167,8 +166,8 @@ const preview: Preview = {
167166
],
168167
'Components',
169168
['SponsorLogo', 'Sponsors', 'SponsorThankYou'],
170-
'Onboarding',
171-
['SponsorOnboardingForm', 'SponsorOnboardingLogoUpload'],
169+
'Portal',
170+
['SponsorPortal', 'SponsorOnboardingLogoUpload'],
172171
'Email',
173172
['SponsorTemplatePicker', 'SponsorEmailTemplateEditor'],
174173
'Form',
@@ -198,6 +197,7 @@ const preview: Preview = {
198197
},
199198
decorators: [
200199
TRPCDecorator,
200+
SessionDecorator,
201201
((Story, context) => {
202202
const theme = context.globals.theme || 'light'
203203
return (
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
/**
2+
* @jest-environment node
3+
*/
4+
import {
5+
describe,
6+
it,
7+
expect,
8+
jest,
9+
beforeEach,
10+
afterEach,
11+
} from '@jest/globals'
12+
import { NextRequest } from 'next/server'
13+
14+
/* eslint-disable @typescript-eslint/no-explicit-any */
15+
const mockSanityFetch = jest.fn<(...args: any[]) => any>()
16+
const mockPatch = jest.fn<(...args: any[]) => any>()
17+
const mockSet = jest.fn<(...args: any[]) => any>()
18+
const mockCommit = jest.fn<(...args: any[]) => any>()
19+
const mockCreate = jest.fn<(...args: any[]) => any>()
20+
const mockResendSend = jest.fn<(...args: any[]) => any>()
21+
22+
jest.mock('@/lib/sanity/client', () => ({
23+
clientWrite: {
24+
fetch: (...args: unknown[]) => mockSanityFetch(...args),
25+
patch: (...args: unknown[]) => mockPatch(...args),
26+
create: (...args: unknown[]) => mockCreate(...args),
27+
},
28+
}))
29+
30+
jest.mock('@/lib/time', () => ({
31+
getCurrentDateTime: () => '2026-01-15T10:00:00Z',
32+
formatConferenceDateLong: (date: string) => date,
33+
}))
34+
35+
jest.mock('@/lib/email/config', () => ({
36+
resend: {
37+
emails: {
38+
send: (...args: unknown[]) => mockResendSend(...args),
39+
},
40+
},
41+
retryWithBackoff: async (fn: () => Promise<unknown>) => fn(),
42+
}))
43+
44+
jest.mock('@/lib/email/contract-email', () => ({
45+
renderContractEmail: jest.fn<(...args: any[]) => any>().mockResolvedValue({
46+
subject: 'Reminder: Sponsorship Agreement',
47+
react: null,
48+
}),
49+
CONTRACT_EMAIL_SLUGS: {
50+
SENT: 'contract-sent',
51+
REMINDER: 'contract-reminder',
52+
SIGNED: 'contract-signed',
53+
},
54+
}))
55+
56+
jest.mock('next/cache', () => ({
57+
unstable_noStore: jest.fn(),
58+
}))
59+
60+
describe('api/cron/contract-reminders', () => {
61+
beforeEach(() => {
62+
jest.clearAllMocks()
63+
process.env.CRON_SECRET = 'test-cron-secret'
64+
65+
mockPatch.mockReturnValue({ set: mockSet })
66+
mockSet.mockReturnValue({ commit: mockCommit })
67+
mockCommit.mockResolvedValue({})
68+
mockCreate.mockResolvedValue({})
69+
mockResendSend.mockResolvedValue({ data: { id: 'email-123' } })
70+
})
71+
72+
afterEach(() => {
73+
delete process.env.CRON_SECRET
74+
})
75+
76+
function cronRequest(authHeader?: string): NextRequest {
77+
return new NextRequest(
78+
'http://localhost:3000/api/cron/contract-reminders',
79+
{
80+
headers: authHeader ? { authorization: authHeader } : {},
81+
},
82+
)
83+
}
84+
85+
describe('Authentication', () => {
86+
it('returns 401 without authorization header', async () => {
87+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
88+
89+
const response = await GET(cronRequest())
90+
expect(response.status).toBe(401)
91+
})
92+
93+
it('returns 401 with wrong token', async () => {
94+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
95+
96+
const response = await GET(cronRequest('Bearer wrong-secret'))
97+
expect(response.status).toBe(401)
98+
})
99+
100+
it('returns 500 when CRON_SECRET is not set', async () => {
101+
delete process.env.CRON_SECRET
102+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
103+
104+
const response = await GET(cronRequest('Bearer test-cron-secret'))
105+
expect(response.status).toBe(500)
106+
})
107+
})
108+
109+
describe('Reminder Processing', () => {
110+
it('returns success with 0 sent when no pending contracts', async () => {
111+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
112+
113+
mockSanityFetch.mockResolvedValueOnce([])
114+
115+
const response = await GET(cronRequest('Bearer test-cron-secret'))
116+
expect(response.status).toBe(200)
117+
118+
const data = await response.json()
119+
expect(data.success).toBe(true)
120+
expect(data.sent).toBe(0)
121+
})
122+
123+
it('sends reminders and updates reminder count', async () => {
124+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
125+
126+
mockSanityFetch.mockResolvedValueOnce([
127+
{
128+
_id: 'sfc-1',
129+
signatureId: 'agr-001',
130+
signingUrl: 'https://sign.example.com/1',
131+
signerEmail: 'signer1@example.com',
132+
reminderCount: 0,
133+
sponsorName: 'Acme Corp',
134+
conferenceName: 'Cloud Native Day 2026',
135+
conferenceCity: 'Oslo',
136+
conferenceStartDate: '2026-06-15',
137+
conferenceSponsorEmail: 'sponsors@example.com',
138+
conferenceOrganizer: 'Cloud Native Days',
139+
},
140+
{
141+
_id: 'sfc-2',
142+
signatureId: 'agr-002',
143+
signingUrl: 'https://sign.example.com/2',
144+
signerEmail: 'signer2@example.com',
145+
reminderCount: 1,
146+
sponsorName: 'Beta Inc',
147+
conferenceName: 'Cloud Native Day 2026',
148+
conferenceCity: 'Oslo',
149+
conferenceStartDate: '2026-06-15',
150+
conferenceSponsorEmail: 'sponsors@example.com',
151+
conferenceOrganizer: 'Cloud Native Days',
152+
},
153+
])
154+
155+
const response = await GET(cronRequest('Bearer test-cron-secret'))
156+
expect(response.status).toBe(200)
157+
158+
const data = await response.json()
159+
expect(data.success).toBe(true)
160+
expect(data.total).toBe(2)
161+
expect(data.sent).toBe(2)
162+
expect(data.failed).toBe(0)
163+
164+
// Verify emails sent
165+
expect(mockResendSend).toHaveBeenCalledTimes(2)
166+
expect(mockResendSend).toHaveBeenCalledWith(
167+
expect.objectContaining({
168+
to: ['signer1@example.com'],
169+
subject: expect.stringContaining('Reminder'),
170+
}),
171+
)
172+
173+
// Verify reminder count was incremented
174+
expect(mockPatch).toHaveBeenCalledWith('sfc-1')
175+
expect(mockPatch).toHaveBeenCalledWith('sfc-2')
176+
expect(mockSet).toHaveBeenCalledWith({ reminderCount: 1 })
177+
expect(mockSet).toHaveBeenCalledWith({ reminderCount: 2 })
178+
179+
// Verify activity logs created
180+
expect(mockCreate).toHaveBeenCalledTimes(2)
181+
expect(mockCreate).toHaveBeenCalledWith(
182+
expect.objectContaining({
183+
_type: 'sponsorActivity',
184+
activityType: 'contract_reminder_sent',
185+
}),
186+
)
187+
})
188+
189+
it('handles partial failures gracefully', async () => {
190+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
191+
192+
mockSanityFetch.mockResolvedValueOnce([
193+
{
194+
_id: 'sfc-1',
195+
signatureId: 'agr-ok',
196+
signingUrl: 'https://sign.example.com/ok',
197+
signerEmail: 'ok@example.com',
198+
reminderCount: 0,
199+
sponsorName: 'Good Corp',
200+
conferenceName: 'Cloud Native Day',
201+
conferenceCity: 'Oslo',
202+
},
203+
{
204+
_id: 'sfc-2',
205+
signatureId: 'agr-bad',
206+
signingUrl: 'https://sign.example.com/bad',
207+
signerEmail: 'bad@example.com',
208+
reminderCount: 0,
209+
sponsorName: 'Bad Corp',
210+
conferenceName: 'Cloud Native Day',
211+
conferenceCity: 'Oslo',
212+
},
213+
])
214+
215+
// First email succeeds + commit succeeds, second email throws
216+
mockResendSend
217+
.mockResolvedValueOnce({ data: { id: 'ok' } })
218+
.mockRejectedValueOnce(new Error('Resend error'))
219+
220+
const response = await GET(cronRequest('Bearer test-cron-secret'))
221+
expect(response.status).toBe(200)
222+
223+
const data = await response.json()
224+
expect(data.sent).toBe(1)
225+
expect(data.failed).toBe(1)
226+
})
227+
228+
it('queries Sanity with correct threshold parameters', async () => {
229+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
230+
231+
mockSanityFetch.mockResolvedValueOnce([])
232+
233+
await GET(cronRequest('Bearer test-cron-secret'))
234+
235+
expect(mockSanityFetch).toHaveBeenCalledWith(
236+
expect.stringContaining('signatureStatus == "pending"'),
237+
expect.objectContaining({ maxReminders: 2 }),
238+
)
239+
})
240+
241+
it('skips contract entirely when signingUrl or signerEmail is missing', async () => {
242+
const { GET } = await import('@/app/api/cron/contract-reminders/route')
243+
244+
mockSanityFetch.mockResolvedValueOnce([
245+
{
246+
_id: 'sfc-no-url',
247+
signatureId: 'agr-no-url',
248+
signingUrl: null,
249+
signerEmail: 'signer@example.com',
250+
reminderCount: 0,
251+
sponsorName: 'No URL Corp',
252+
conferenceName: 'Cloud Native Day',
253+
},
254+
])
255+
256+
const response = await GET(cronRequest('Bearer test-cron-secret'))
257+
const data = await response.json()
258+
259+
// Should count as failed, not sent
260+
expect(data.sent).toBe(0)
261+
expect(data.failed).toBe(1)
262+
expect(mockResendSend).not.toHaveBeenCalled()
263+
// Should NOT increment reminder count
264+
expect(mockPatch).not.toHaveBeenCalled()
265+
expect(mockCreate).not.toHaveBeenCalled()
266+
})
267+
})
268+
})

0 commit comments

Comments
 (0)