Skip to content

Commit 863b386

Browse files
committed
Refactor volunteer and workshop routers to improve structure and error handling
- Moved volunteer-related queries and mutations under an 'admin' router for better organization. - Simplified error handling in volunteer queries and mutations. - Updated workshop router to include pagination in signups retrieval and improved error messages. - Added batch confirmation and cancellation of workshop signups with detailed success/failure reporting. - Enhanced manual signup process with checks for existing signups and workshop capacity. - Updated registration time management with validation and revalidation of tags.
1 parent 9216108 commit 863b386

28 files changed

Lines changed: 1849 additions & 1836 deletions

__tests__/api/trpc/volunteer.test.ts

Lines changed: 6 additions & 6 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.admin.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.admin.list()).rejects.toMatchObject({
144144
code: 'FORBIDDEN',
145145
})
146146
})
@@ -151,7 +151,7 @@ describe('volunteer router', () => {
151151
const regularUser = speakers.find((s) => !s.isOrganizer)!
152152
const caller = createAuthenticatedCaller(regularUser._id)
153153
await expect(
154-
caller.volunteer.getById({ id: 'vol-1' }),
154+
caller.volunteer.admin.getById({ id: 'vol-1' }),
155155
).rejects.toMatchObject({ code: 'FORBIDDEN' })
156156
})
157157

@@ -163,7 +163,7 @@ describe('volunteer router', () => {
163163

164164
const caller = createAdminCaller()
165165
await expect(
166-
caller.volunteer.getById({ id: 'nonexistent' }),
166+
caller.volunteer.admin.getById({ id: 'nonexistent' }),
167167
).rejects.toMatchObject({ code: 'NOT_FOUND' })
168168
})
169169

@@ -178,7 +178,7 @@ describe('volunteer router', () => {
178178
})
179179

180180
const caller = createAdminCaller()
181-
const result = await caller.volunteer.getById({ id: 'vol-1' })
181+
const result = await caller.volunteer.admin.getById({ id: 'vol-1' })
182182
expect(result._id).toBe('vol-1')
183183
})
184184
})
@@ -196,7 +196,7 @@ describe('volunteer router', () => {
196196

197197
const caller = createAdminCaller()
198198
await expect(
199-
caller.volunteer.sendEmail({
199+
caller.volunteer.admin.sendEmail({
200200
volunteerId: 'vol-1',
201201
subject: 'Welcome',
202202
message: 'Congrats',

docs/TRPC_SERVER_ARCHITECTURE.md

Lines changed: 24 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,19 @@ src/
1313
├── server/
1414
│ ├── _app.ts # Root router composing all feature routers
1515
│ ├── routers/ # tRPC route definitions (13 routers)
16-
│ │ ├── badge.ts # OpenBadges management
17-
│ │ ├── featured.ts # Featured talks & speakers
18-
│ │ ├── gallery.ts # Photo gallery
19-
│ │ ├── proposal.ts # CFP proposals + admin sub-router
16+
│ │ ├── badge.ts # OpenBadges verification + admin sub-router
17+
│ │ ├── featured.ts # Featured talks & speakers (admin sub-router)
18+
│ │ ├── gallery.ts # Photo gallery + admin sub-router
19+
│ │ ├── proposal.ts # CFP proposals + admin & invitation sub-routers
2020
│ │ ├── registration.ts # Attendee registration
2121
│ │ ├── schedule.ts # Schedule management
2222
│ │ ├── signing.ts # Contract signing
23-
│ │ ├── speaker.ts # Speaker profiles, email, CLI auth + admin sub-router
24-
│ │ ├── sponsor.ts # Sponsors, tiers, CRM, email + admin sub-routers
25-
│ │ ├── tickets.ts # Ticket management
26-
│ │ ├── travelSupport.ts # Travel support applications
27-
│ │ ├── volunteer.ts # Volunteer signups
28-
│ │ └── workshop.ts # Workshop management
23+
│ │ ├── speaker.ts # Speaker profiles + admin sub-router
24+
│ │ ├── sponsor.ts # Sponsors + tiers, crm, emailTemplates, contractTemplates sub-routers
25+
│ │ ├── tickets.ts # Ticket management (admin sub-router)
26+
│ │ ├── travelSupport.ts # Travel support + admin sub-router
27+
│ │ ├── volunteer.ts # Volunteer signups + admin sub-router
28+
│ │ └── workshop.ts # Workshop management + admin sub-router
2929
│ ├── schemas/ # Zod validation schemas
3030
│ │ ├── common.ts # Shared schemas (IdParamSchema, etc.)
3131
│ │ ├── speaker.ts # Speaker input/update schemas
@@ -50,16 +50,13 @@ src/
5050

5151
### Router Organization
5252

53-
One router per domain, registered in `src/server/_app.ts`. Use sub-routers for scoped operations:
53+
One router per domain, registered in `src/server/_app.ts`. All routers with admin operations use a standardized `admin` sub-router to separate organizer-only procedures from user-facing ones:
5454

5555
```typescript
5656
export const speakerRouter = router({
57-
// Top-level: user-facing procedures (protectedProcedure)
57+
// Top-level: user-facing procedures (protectedProcedure / publicProcedure)
5858
getCurrent: protectedProcedure.query(...),
5959
update: protectedProcedure.input(SpeakerInputSchema).mutation(...),
60-
getEmails: protectedProcedure.query(...),
61-
updateEmail: protectedProcedure.input(EmailUpdateSchema).mutation(...),
62-
generateCliToken: protectedProcedure.mutation(...),
6360

6461
// admin sub-router: organizer-only operations (adminProcedure)
6562
admin: router({
@@ -70,26 +67,27 @@ export const speakerRouter = router({
7067
update: adminProcedure.input(...).mutation(...),
7168
delete: adminProcedure.input(IdParamSchema).mutation(...),
7269
sendEmail: adminProcedure.input(...).mutation(...),
73-
broadcastEmail: adminProcedure.input(...).mutation(...),
74-
syncAudience: adminProcedure.mutation(...),
7570
}),
7671
})
7772
```
7873

74+
**All 10 routers with admin procedures use this pattern:** badge, featured, gallery, proposal, speaker, tickets, travelSupport, volunteer, workshop. The sponsor router uses domain-specific sub-routers (tiers, crm, emailTemplates, contractTemplates) instead. Three routers stay flat: registration (2 admin ops), signing (0 admin), schedule (1 admin).
75+
7976
### Naming Conventions
8077

8178
**Routers** — singular noun, camelCase: `speaker`, `proposal`, `sponsor`, `travelSupport`.
8279

8380
**Sub-routers** — group related operations under a descriptive key:
8481

85-
| Pattern | Example | Purpose |
86-
| ---------------- | ----------------------------- | ---------------------------------- |
87-
| `admin` | `speaker.admin.list` | Organizer-only CRUD and management |
88-
| `crm` | `sponsor.crm.sendEmail` | CRM-specific operations |
89-
| `tiers` | `sponsor.tiers.create` | Domain sub-entity management |
90-
| `invitation` | `proposal.invitation.send` | Feature-specific workflows |
91-
| `activities` | `sponsor.crm.activities.list` | Nested sub-entities |
92-
| `emailTemplates` | `sponsor.emailTemplates.list` | Configuration management |
82+
| Pattern | Example | Purpose |
83+
| ------------------- | -------------------------------- | ---------------------------------- |
84+
| `admin` | `speaker.admin.list` | Organizer-only CRUD and management |
85+
| `crm` | `sponsor.crm.sendEmail` | CRM-specific operations |
86+
| `tiers` | `sponsor.tiers.create` | Domain sub-entity management |
87+
| `invitation` | `proposal.invitation.send` | Feature-specific workflows |
88+
| `activities` | `sponsor.crm.activities.list` | Nested sub-entities |
89+
| `emailTemplates` | `sponsor.emailTemplates.list` | Configuration management |
90+
| `contractTemplates` | `sponsor.contractTemplates.list` | Template management |
9391

9492
**Procedures** — use verb or verb+noun, camelCase:
9593

@@ -457,7 +455,7 @@ When migrating a REST route to tRPC:
457455

458456
1. **Read the route handler** — identify auth checks, input parsing, business logic, response shape
459457
2. **Pick the right router** — add to existing feature router, not a new one
460-
3. **Pick the right sub-router**`admin` for organizer ops, domain-specific (`crm`, `tiers`) for grouped features
458+
3. **Pick the right sub-router**`admin` for organizer ops (standardized across all routers), domain-specific (`crm`, `tiers`) only for sponsor
461459
4. **Choose procedure type**`adminProcedure` for organizer-only, `protectedProcedure` for authenticated users
462460
5. **Convert input validation**`req.body` → Zod schema in `.input()`. Do not use `.passthrough()` on schemas that feed typed interfaces
463461
6. **Handle service return types** — if the existing service returns `Response` objects (REST-oriented), check `.ok` and throw `TRPCError` on failure

src/app/(admin)/admin/marketing/featured/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default function AdminFeaturedPage() {
1414
data: summary,
1515
isLoading: summaryLoading,
1616
error: summaryError,
17-
} = api.featured.summary.useQuery()
17+
} = api.featured.admin.summary.useQuery()
1818

1919
if (summaryError) {
2020
return (

src/app/(admin)/admin/marketing/gallery/client.tsx

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ function GalleryPageContent() {
3535
data: images,
3636
isLoading,
3737
refetch: refetchImages,
38-
} = api.gallery.list.useQuery(
38+
} = api.gallery.admin.list.useQuery(
3939
{
4040
featured: filters.featured,
4141
speakerId: filters.speakerId,
@@ -53,7 +53,7 @@ function GalleryPageContent() {
5353
},
5454
)
5555

56-
const { data: filteredCount } = api.gallery.count.useQuery({
56+
const { data: filteredCount } = api.gallery.admin.count.useQuery({
5757
featured: filters.featured,
5858
speakerId: filters.speakerId,
5959
dateFrom: filters.dateFrom,
@@ -62,11 +62,11 @@ function GalleryPageContent() {
6262
locationSearch: filters.locationSearch,
6363
})
6464

65-
const deleteMutation = api.gallery.delete.useMutation({
65+
const deleteMutation = api.gallery.admin.delete.useMutation({
6666
onSuccess: () => {
6767
showNotification({ title: 'Image deleted successfully', type: 'success' })
68-
utils.gallery.list.invalidate()
69-
utils.gallery.count.invalidate()
68+
utils.gallery.admin.list.invalidate()
69+
utils.gallery.admin.count.invalidate()
7070
},
7171
onError: (error) => {
7272
showNotification({
@@ -76,11 +76,11 @@ function GalleryPageContent() {
7676
},
7777
})
7878

79-
const toggleFeaturedMutation = api.gallery.toggleFeatured.useMutation({
79+
const toggleFeaturedMutation = api.gallery.admin.toggleFeatured.useMutation({
8080
onSuccess: () => {
8181
showNotification({ title: 'Featured status updated', type: 'success' })
82-
utils.gallery.list.invalidate()
83-
utils.gallery.count.invalidate()
82+
utils.gallery.admin.list.invalidate()
83+
utils.gallery.admin.count.invalidate()
8484
},
8585
onError: (error) => {
8686
showNotification({
@@ -100,8 +100,8 @@ function GalleryPageContent() {
100100

101101
while (attempts < maxAttempts) {
102102
await new Promise((resolve) => setTimeout(resolve, 500))
103-
await utils.gallery.list.invalidate()
104-
await utils.gallery.count.invalidate()
103+
await utils.gallery.admin.list.invalidate()
104+
await utils.gallery.admin.count.invalidate()
105105
const result = await refetchImages()
106106

107107
const newCount = result.data?.length ?? 0
@@ -117,8 +117,8 @@ function GalleryPageContent() {
117117
)
118118

119119
const handleImageUpdate = useCallback(() => {
120-
utils.gallery.list.invalidate()
121-
utils.gallery.count.invalidate()
120+
utils.gallery.admin.list.invalidate()
121+
utils.gallery.admin.count.invalidate()
122122
setIsMetadataModalOpen(false)
123123
setSelectedImage(null)
124124
}, [utils])
@@ -148,8 +148,8 @@ function GalleryPageContent() {
148148
}, [selectedImages])
149149

150150
const handleBulkUpdate = useCallback(() => {
151-
utils.gallery.list.invalidate()
152-
utils.gallery.count.invalidate()
151+
utils.gallery.admin.list.invalidate()
152+
utils.gallery.admin.count.invalidate()
153153
setIsMetadataModalOpen(false)
154154
setSelectedImage(null)
155155
setSelectedImages([])

src/components/admin/BadgeManagementClient.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ export function BadgeManagementClient({
8989
}, [searchQuery])
9090

9191
const { data: existingBadges, refetch: refetchBadges } =
92-
api.badge.list.useQuery({}, { initialData: initialBadges })
92+
api.badge.admin.list.useQuery({}, { initialData: initialBadges })
9393

94-
const issueMutation = api.badge.issue.useMutation({
94+
const issueMutation = api.badge.admin.issue.useMutation({
9595
onSuccess: (data) => {
9696
refetchBadges()
9797
setSelectedSpeakers(new Set())
@@ -113,7 +113,7 @@ export function BadgeManagementClient({
113113
},
114114
})
115115

116-
const bulkIssueMutation = api.badge.bulkIssue.useMutation({
116+
const bulkIssueMutation = api.badge.admin.bulkIssue.useMutation({
117117
onSuccess: (data) => {
118118
refetchBadges()
119119
setSelectedSpeakers(new Set())
@@ -146,7 +146,7 @@ export function BadgeManagementClient({
146146
},
147147
})
148148

149-
const deleteMutation = api.badge.delete.useMutation({
149+
const deleteMutation = api.badge.admin.delete.useMutation({
150150
onSuccess: (data) => {
151151
refetchBadges()
152152
showNotification({

src/components/admin/BadgeValidator.stories.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ export const Default: Story = {
202202
parameters: {
203203
msw: {
204204
handlers: [
205-
http.post('/api/trpc/badge.validate', () => {
205+
http.post('/api/trpc/badge.admin.validate', () => {
206206
return HttpResponse.json({
207207
result: { data: mockSuccessResponse },
208208
})
@@ -216,7 +216,7 @@ export const ValidationSuccess: Story = {
216216
parameters: {
217217
msw: {
218218
handlers: [
219-
http.post('/api/trpc/badge.validate', () => {
219+
http.post('/api/trpc/badge.admin.validate', () => {
220220
return HttpResponse.json({
221221
result: { data: mockSuccessResponse },
222222
})
@@ -233,7 +233,7 @@ export const ValidationErrors: Story = {
233233
parameters: {
234234
msw: {
235235
handlers: [
236-
http.post('/api/trpc/badge.validate', () => {
236+
http.post('/api/trpc/badge.admin.validate', () => {
237237
return HttpResponse.json({
238238
result: { data: mockErrorResponse },
239239
})
@@ -250,7 +250,7 @@ export const ValidationWarnings: Story = {
250250
parameters: {
251251
msw: {
252252
handlers: [
253-
http.post('/api/trpc/badge.validate', () => {
253+
http.post('/api/trpc/badge.admin.validate', () => {
254254
return HttpResponse.json({
255255
result: { data: mockWarningResponse },
256256
})
@@ -267,7 +267,7 @@ export const ServerError: Story = {
267267
parameters: {
268268
msw: {
269269
handlers: [
270-
http.post('/api/trpc/badge.validate', () => {
270+
http.post('/api/trpc/badge.admin.validate', () => {
271271
return HttpResponse.json(
272272
{
273273
error: {

src/components/admin/BadgeValidator.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default function BadgeValidator() {
5252
const [credential, setCredential] = useState<BadgeCredential | null>(null)
5353
const [svgPreview, setSvgPreview] = useState<string | null>(null)
5454

55-
const validateMutation = api.badge.validate.useMutation({
55+
const validateMutation = api.badge.admin.validate.useMutation({
5656
onSuccess: (data) => {
5757
if (data.checks) {
5858
setChecks(data.checks)

0 commit comments

Comments
 (0)