Skip to content

Commit bb9deca

Browse files
committed
Refactor conference ID handling across components and routers
- Removed conferenceId prop from SponsorActivityTimeline, SponsorDashboardMetrics, WorkshopsClientPage, VolunteerAdminPage, VolunteerForm, and WorkshopList components. - Updated API calls in various routers (badge, proposal, sponsor, tickets, volunteer, workshop) to resolve conference ID using a new utility function `resolveConferenceId`. - Simplified schemas by removing conferenceId requirements where applicable. - Ensured consistent handling of conference ID resolution across the application.
1 parent 12902d1 commit bb9deca

54 files changed

Lines changed: 389 additions & 497 deletions

Some content is hidden

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

.prettierignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ coverage
77
.turbo
88
node_modules
99
.vercel
10+
cli

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ This applies to any component using `formatDistanceToNow`, `getDaysPending`, or
303303
- Complete CRUD operations for sponsors and sponsor tiers
304304
- Conference-sponsor assignment management (add/remove sponsors)
305305
- Proper query invalidation and optimistic updates
306+
- **Conference Resolution:** Never accept `conferenceId` as client input. Use `resolveConferenceId()` from `/src/server/trpc.ts` to derive it server-side from the request's Host header via `getConferenceForCurrentDomain()`. This ensures multi-tenant isolation and prevents clients from accessing data across conferences.
306307
- **Input Validation:** Zod schemas in `/src/server/schemas/` for type-safe input validation
307308
- **Error Handling:** Consistent TRPCError usage with proper HTTP status codes and user-friendly messages
308309
- **Performance:** React Query integration provides automatic caching, background updates, and optimistic UI updates
@@ -329,6 +330,19 @@ This applies to any component using `formatDistanceToNow`, `getDaysPending`, or
329330
- **Qodo Review:** `pnpm qodo:review` - Review PR suggestions from Qodo Merge (auto-detects current branch PR, or pass PR number like `pnpm qodo:review 332`).
330331
- Run sanity commands with `pnpm sanity {command}` (e.g., `pnpm sanity deploy`) - do not use `npx sanity` directly.
331332

333+
#### CLI (`cli/` — Rust)
334+
335+
The `cnctl` CLI lives in `cli/` as a standalone Cargo project. It uses mise for toolchain management.
336+
337+
- **Full Check:** `cd cli && mise run check` - Runs clippy, format check, and tests in parallel.
338+
- **Clippy:** `cd cli && mise run clippy` - Runs clippy with pedantic lints, warnings as errors.
339+
- **Format:** `cd cli && mise run fmt` - Formats code with rustfmt.
340+
- **Format Check:** `cd cli && mise run fmt-check` - Checks formatting without modifying files.
341+
- **Tests:** `cd cli && mise run test` - Runs all tests.
342+
- **Tests (Verbose):** `cd cli && mise run test-verbose` - Runs tests with output.
343+
- **Build:** `cd cli && mise run build` - Builds release binary.
344+
- **Clean:** `cd cli && mise run clean` - Removes build artifacts.
345+
332346
## Code Organization & Refactoring
333347

334348
### Component Structure

__tests__/api/trpc/middleware.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,8 @@ describe('tRPC middleware', () => {
4040
it('should reject unauthenticated requests', async () => {
4141
const caller = createAnonymousCaller()
4242

43-
await expect(caller.speakers.list({})).rejects.toThrow(TRPCError)
44-
await expect(caller.speakers.list({})).rejects.toMatchObject({
43+
await expect(caller.speakers.list()).rejects.toThrow(TRPCError)
44+
await expect(caller.speakers.list()).rejects.toMatchObject({
4545
code: 'UNAUTHORIZED',
4646
})
4747
})
@@ -50,8 +50,8 @@ describe('tRPC middleware', () => {
5050
const regularUser = speakers.find((s) => !s.isOrganizer)!
5151
const caller = createAuthenticatedCaller(regularUser._id)
5252

53-
await expect(caller.speakers.list({})).rejects.toThrow(TRPCError)
54-
await expect(caller.speakers.list({})).rejects.toMatchObject({
53+
await expect(caller.speakers.list()).rejects.toThrow(TRPCError)
54+
await expect(caller.speakers.list()).rejects.toMatchObject({
5555
code: 'FORBIDDEN',
5656
})
5757
})
@@ -60,7 +60,7 @@ describe('tRPC middleware', () => {
6060
const caller = createAdminCaller()
6161
// Should not throw UNAUTHORIZED or FORBIDDEN — may throw due to missing mock data
6262
try {
63-
await caller.speakers.list({})
63+
await caller.speakers.list()
6464
} catch (error) {
6565
expect(error).toBeInstanceOf(TRPCError)
6666
expect((error as TRPCError).code).not.toBe('UNAUTHORIZED')

__tests__/api/trpc/volunteer.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,15 +132,15 @@ describe('volunteer router', () => {
132132
describe('list', () => {
133133
it('should reject unauthenticated requests', async () => {
134134
const caller = createAnonymousCaller()
135-
await expect(caller.volunteer.list({})).rejects.toMatchObject({
135+
await expect(caller.volunteer.list()).rejects.toMatchObject({
136136
code: 'UNAUTHORIZED',
137137
})
138138
})
139139

140140
it('should reject non-admin users', async () => {
141141
const regularUser = speakers.find((s) => !s.isOrganizer)!
142142
const caller = createAuthenticatedCaller(regularUser._id)
143-
await expect(caller.volunteer.list({})).rejects.toMatchObject({
143+
await expect(caller.volunteer.list()).rejects.toMatchObject({
144144
code: 'FORBIDDEN',
145145
})
146146
})

__tests__/app/cli/login/cli-login-client.test.tsx

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,25 @@ import CLILoginClient, {
99

1010
describe('buildCallbackUrl', () => {
1111
it('should build a valid localhost callback URL', () => {
12-
const url = buildCallbackUrl('8080', 'my-token', 'my-state', 'Alice')
12+
const url = buildCallbackUrl(
13+
'8080',
14+
'my-token',
15+
'my-state',
16+
'Alice',
17+
'conf-123',
18+
)
1319
expect(url.hostname).toBe('localhost')
1420
expect(url.port).toBe('8080')
1521
expect(url.pathname).toBe('/callback')
1622
expect(url.searchParams.get('token')).toBe('my-token')
1723
expect(url.searchParams.get('state')).toBe('my-state')
1824
expect(url.searchParams.get('name')).toBe('Alice')
25+
expect(url.searchParams.get('conference_id')).toBe('conf-123')
1926
})
2027

2128
it('should reject non-localhost hosts', () => {
2229
// buildCallbackUrl hardcodes localhost, so this tests the safety check
23-
expect(() => buildCallbackUrl('8080', 't', 's', 'n')).not.toThrow()
30+
expect(() => buildCallbackUrl('8080', 't', 's', 'n', 'c')).not.toThrow()
2431
})
2532
})
2633

@@ -37,7 +44,13 @@ describe('CLILoginClient', () => {
3744
})
3845

3946
it('should show confirm screen initially', () => {
40-
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
47+
render(
48+
<CLILoginClient
49+
userName="Test User"
50+
userEmail="test@example.com"
51+
conferenceId="conf-123"
52+
/>,
53+
)
4154
expect(screen.getByText(/requesting access/i)).toBeInTheDocument()
4255
expect(
4356
screen.getByRole('button', { name: /authorize cli/i }),
@@ -54,7 +67,13 @@ describe('CLILoginClient', () => {
5467
json: () => Promise.resolve({ token: 'jwt-token-value' }),
5568
})
5669

57-
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
70+
render(
71+
<CLILoginClient
72+
userName="Test User"
73+
userEmail="test@example.com"
74+
conferenceId="conf-123"
75+
/>,
76+
)
5877
fireEvent.click(screen.getByRole('button', { name: /authorize cli/i }))
5978

6079
await waitFor(() => {
@@ -92,6 +111,7 @@ describe('CLILoginClient', () => {
92111
state="random-state"
93112
userName="Test User"
94113
userEmail="test@example.com"
114+
conferenceId="conf-123"
95115
/>,
96116
)
97117

@@ -118,6 +138,7 @@ describe('CLILoginClient', () => {
118138
state="s"
119139
userName="Test User"
120140
userEmail="test@example.com"
141+
conferenceId="conf-123"
121142
/>,
122143
)
123144

@@ -136,6 +157,7 @@ describe('CLILoginClient', () => {
136157
state="s"
137158
userName="Test User"
138159
userEmail="test@example.com"
160+
conferenceId="conf-123"
139161
/>,
140162
)
141163

@@ -153,6 +175,7 @@ describe('CLILoginClient', () => {
153175
port="8080"
154176
userName="Test User"
155177
userEmail="test@example.com"
178+
conferenceId="conf-123"
156179
/>,
157180
)
158181

@@ -171,7 +194,13 @@ describe('CLILoginClient', () => {
171194
json: () => Promise.resolve({ error: 'Unauthorized' }),
172195
})
173196

174-
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
197+
render(
198+
<CLILoginClient
199+
userName="Test User"
200+
userEmail="test@example.com"
201+
conferenceId="conf-123"
202+
/>,
203+
)
175204
fireEvent.click(screen.getByRole('button', { name: /authorize cli/i }))
176205

177206
await waitFor(() => {
@@ -182,7 +211,13 @@ describe('CLILoginClient', () => {
182211
it('should show error when fetch throws', async () => {
183212
fetchMock.mockRejectedValue(new Error('Network error'))
184213

185-
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
214+
render(
215+
<CLILoginClient
216+
userName="Test User"
217+
userEmail="test@example.com"
218+
conferenceId="conf-123"
219+
/>,
220+
)
186221
fireEvent.click(screen.getByRole('button', { name: /authorize cli/i }))
187222

188223
await waitFor(() => {
@@ -199,7 +234,13 @@ describe('CLILoginClient', () => {
199234
const writeText = vi.fn().mockResolvedValue(undefined)
200235
Object.assign(navigator, { clipboard: { writeText } })
201236

202-
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
237+
render(
238+
<CLILoginClient
239+
userName="Test User"
240+
userEmail="test@example.com"
241+
conferenceId="conf-123"
242+
/>,
243+
)
203244
fireEvent.click(screen.getByRole('button', { name: /authorize cli/i }))
204245

205246
await waitFor(() => {
@@ -226,7 +267,13 @@ describe('CLILoginClient', () => {
226267
json: () => Promise.resolve({ token: 'retry-token' }),
227268
})
228269

229-
render(<CLILoginClient userName="Test User" userEmail="test@example.com" />)
270+
render(
271+
<CLILoginClient
272+
userName="Test User"
273+
userEmail="test@example.com"
274+
conferenceId="conf-123"
275+
/>,
276+
)
230277
fireEvent.click(screen.getByRole('button', { name: /authorize cli/i }))
231278

232279
await waitFor(() => {

__tests__/lib/proposal/schemas.test.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@ describe('ProposalAdminCreateSchema', () => {
266266
const result = ProposalAdminCreateSchema.safeParse({
267267
...fullProposal,
268268
speakers: ['speaker-1'],
269-
conferenceId: 'conf-1',
270269
})
271270
expect(result.success).toBe(true)
272271
})
@@ -275,16 +274,6 @@ describe('ProposalAdminCreateSchema', () => {
275274
const result = ProposalAdminCreateSchema.safeParse({
276275
...fullProposal,
277276
speakers: [],
278-
conferenceId: 'conf-1',
279-
})
280-
expect(result.success).toBe(false)
281-
})
282-
283-
it('requires conferenceId', () => {
284-
const result = ProposalAdminCreateSchema.safeParse({
285-
...fullProposal,
286-
speakers: ['s1'],
287-
conferenceId: '',
288277
})
289278
expect(result.success).toBe(false)
290279
})
@@ -294,7 +283,6 @@ describe('ProposalAdminCreateSchema', () => {
294283
...fullProposal,
295284
format: Format.workshop_240,
296285
speakers: ['s1'],
297-
conferenceId: 'conf-1',
298286
})
299287
expect(result.success).toBe(false)
300288
})

__tests__/lib/sponsor-crm/contract-schemas.test.ts

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -233,21 +233,9 @@ describe('ContractTemplateIdSchema', () => {
233233
})
234234

235235
describe('ContractTemplateListSchema', () => {
236-
it('passes with valid conferenceId', () => {
237-
const result = ContractTemplateListSchema.safeParse({
238-
conferenceId: 'conf-1',
239-
})
240-
expect(result.success).toBe(true)
241-
})
242-
243-
it('fails with empty conferenceId', () => {
244-
const result = ContractTemplateListSchema.safeParse({ conferenceId: '' })
245-
expect(result.success).toBe(false)
246-
})
247-
248-
it('fails without conferenceId', () => {
236+
it('passes with empty input', () => {
249237
const result = ContractTemplateListSchema.safeParse({})
250-
expect(result.success).toBe(false)
238+
expect(result.success).toBe(true)
251239
})
252240
})
253241

@@ -290,16 +278,13 @@ describe('GenerateContractPdfSchema', () => {
290278
})
291279

292280
describe('FindBestContractTemplateSchema', () => {
293-
it('passes with conferenceId only', () => {
294-
const result = FindBestContractTemplateSchema.safeParse({
295-
conferenceId: 'conf-1',
296-
})
281+
it('passes with no fields', () => {
282+
const result = FindBestContractTemplateSchema.safeParse({})
297283
expect(result.success).toBe(true)
298284
})
299285

300-
it('passes with all fields', () => {
286+
it('passes with all optional fields', () => {
301287
const result = FindBestContractTemplateSchema.safeParse({
302-
conferenceId: 'conf-1',
303288
tierId: 'tier-gold',
304289
language: 'nb',
305290
})
@@ -308,26 +293,17 @@ describe('FindBestContractTemplateSchema', () => {
308293

309294
it('passes without optional tierId', () => {
310295
const result = FindBestContractTemplateSchema.safeParse({
311-
conferenceId: 'conf-1',
312296
language: 'en',
313297
})
314298
expect(result.success).toBe(true)
315299
})
316300

317301
it('fails with invalid language', () => {
318302
const result = FindBestContractTemplateSchema.safeParse({
319-
conferenceId: 'conf-1',
320303
language: 'fr',
321304
})
322305
expect(result.success).toBe(false)
323306
})
324-
325-
it('fails with empty conferenceId', () => {
326-
const result = FindBestContractTemplateSchema.safeParse({
327-
conferenceId: '',
328-
})
329-
expect(result.success).toBe(false)
330-
})
331307
})
332308

333309
describe('SendContractSchema', () => {

docs/TRPC_SERVER_ARCHITECTURE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,33 @@ export const sponsorRouter = router({
5959
})
6060
```
6161

62+
### Conference Resolution
63+
64+
This site is multi-tenant — each conference runs on its own subdomain. The server must determine which conference the request is for, and **this must always happen server-side**.
65+
66+
**Rule: Never accept `conferenceId` as client input in tRPC procedures.** Instead, use `resolveConferenceId()` from `/src/server/trpc.ts`, which derives the conference from the request's `Host` header via `getConferenceForCurrentDomain()`.
67+
68+
```typescript
69+
// ✅ Correct — resolve server-side
70+
import { resolveConferenceId } from '../trpc'
71+
72+
list: adminProcedure.query(async () => {
73+
const conferenceId = await resolveConferenceId()
74+
// use conferenceId for queries...
75+
})
76+
77+
// ❌ Wrong — never accept conferenceId from client
78+
list: adminProcedure
79+
.input(z.object({ conferenceId: z.string() }))
80+
.query(async ({ input }) => {
81+
// DO NOT DO THIS — clients could access other conferences
82+
})
83+
```
84+
85+
**Why:** Accepting `conferenceId` from the client breaks multi-tenant isolation. A malicious or misconfigured client could pass a different conference's ID and access data it shouldn't. Server-side resolution guarantees each request only accesses data belonging to the conference identified by the domain.
86+
87+
**When you need the full conference object** (not just the ID), import and call `getConferenceForCurrentDomain()` directly.
88+
6289
### Input Validation
6390

6491
```typescript

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const eslintConfig = [
3030
'__tests__/**/testdata/**',
3131
'__tests__/**/mocks/**',
3232
'storybook-static/**', // Built Storybook output
33+
'cli/**',
3334
],
3435
},
3536

src/app/(admin)/admin/settings/page.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ export default async function AdminSettings() {
339339
</InfoCard>
340340

341341
<WorkshopRegistrationSettings
342-
conferenceId={conference._id}
343342
workshopRegistrationStart={conference.workshopRegistrationStart}
344343
workshopRegistrationEnd={conference.workshopRegistrationEnd}
345344
/>

0 commit comments

Comments
 (0)