Skip to content

Commit fd2b990

Browse files
0xdevcollinsclaude
andauthored
fix: align request payloads with backend DTOs ahead of forbidNonWhitelisted (#594)
* fix: align request payloads with backend DTOs ahead of forbidNonWhitelisted The boundless-nestjs backend (PR #187) is flipping its global ValidationPipe from forbidNonWhitelisted: false to true. Today any unknown body field is silently stripped; after the flip, sending such a field produces a 400. This commit fixes every front-end -> back-end payload the audit flagged. 1. Team posts (CreateTeamPostModal, ContactTeamModal, team-detail page). - lib/api/hackathons/teams.ts: replaced `contactInfo: string` and `contactMethod` with `contactInfo?: TeamContactInfo` matching the backend shape `{ telegram?, discord?, email? }`. Added a readTeamContact() helper that flattens the sparse object back to `{ method, value }` for UI display. - CreateTeamPostModal: form schema keeps the `{ method, value }` UX, but onSubmit projects to `{ contactInfo: { [method]: value } }` before calling the api. github and other were removed from the method enum because the backend cannot store them. Edit-mode initial-data hydrates from readTeamContact(). - ContactTeamModal: replaced direct string access with readTeamContact() and used contact.method/value throughout. The github icon case was dropped since github is no longer a valid method. - team-detail page: same readTeamContact() flattening for the Contact card. 2. Judging score (lib/api/hackathons/judging.ts, useScoreForm). The submitJudgingScore request used `comment` for the global per-judge note, but the backend ScoreSubmissionDto names the field `notes`. Renamed the interface field and the call site. Per-criterion `comment` stays because CriterionScoreDto does accept it. 3. Submission update (hooks/hackathon/use-submission.ts). Added a pickUpdateSubmissionFields() whitelist mapping to the backend UpdateSubmissionDto. The update path now strips: - participationType, teamId, teamName (Create-only) - organizationId, hackathonId, participantId (server-derived) - per-entry email on teamMembers (TeamMemberDto has no email) ...before PATCH /hackathons/submissions/:id. The form continues to send the full shape; the hook filters at the API boundary so future callers are also protected. 4. User profile (lib/api/auth.ts). Removed the `preferences` block from UpdateUserProfileRequest. The backend UpdateProfileDto has no such field; users use the dedicated /users/settings/* endpoints for appearance / language / timezone / notification toggles. The wide type was the same kind of unknown- field door the audit is closing. 5. Hackathon update (lib/api/hackathons.ts). UpdateHackathonRequest was `Partial<Hackathon> & { rewards? }`, which let any Hackathon-shape field (id, organizationId, status, creatorId, ...) leak into PUT /hackathons/:id. Narrowed to just `{ rewards?: HackathonRewards }` since that is the only field actually sent today (JudgingResultsTable's rank-override save). Out of scope (separate pre-existing bugs to file): - PUT /hackathons/:id has no matching backend route; the rank- override save in JudgingResultsTable is broken regardless of #187. - POST /users/earnings/claim does not match backend /users/earnings/ withdraw. - POST /organizations/:id/invite does not match backend /invitations. Backend #188 (global ThrottlerGuard + 429 handling): no frontend changes needed. lib/api/api.ts already honours Retry-After and exponential backoff (1s, 2s, 4s) for three retries before surfacing a RATE_LIMIT_EXCEEDED error code. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(submissions): project teamMembers entries to backend TeamMemberDto shape Found during code review of the previous commit on this branch. The strip on teamMembers only removed `email`, but the frontend SubmissionFormData also lets each entry carry `avatar` — and the backend TeamMemberDto only has { userId, name, username?, role }. Sending `avatar` would 400 once forbidNonWhitelisted lands. Switched from a deny-list strip (`{ email: _email, ...rest }`) to an explicit project-down to the four backend fields. Same defense-in- depth pattern as the outer pickUpdateSubmissionFields whitelist. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e083d17 commit fd2b990

9 files changed

Lines changed: 180 additions & 58 deletions

File tree

app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Link from 'next/link';
55
import { useHackathon, useTeam } from '@/hooks/hackathon/use-hackathon-queries';
66
import { Button } from '@/components/ui/button';
77
import BasicAvatar from '@/components/avatars/BasicAvatar';
8+
import { readTeamContact } from '@/lib/api/hackathons/teams';
89
import {
910
ArrowLeft,
1011
Calendar,
@@ -227,22 +228,24 @@ export default function TeamDetailsPage({
227228
</div>
228229
)}
229230

230-
{team.contactInfo && (
231-
<div className='rounded-2xl border border-white/5 bg-[#0A0B0D] p-6'>
232-
<h2 className='mb-4 text-[10px] font-black tracking-[0.2em] text-[#555555] uppercase'>
233-
Contact
234-
</h2>
235-
<div className='flex items-start gap-3 text-sm text-gray-300'>
236-
<Mail className='mt-0.5 h-4 w-4 shrink-0 text-gray-500' />
237-
<span className='break-all'>{team.contactInfo}</span>
238-
</div>
239-
{team.contactMethod && (
231+
{(() => {
232+
const contact = readTeamContact(team.contactInfo);
233+
if (!contact) return null;
234+
return (
235+
<div className='rounded-2xl border border-white/5 bg-[#0A0B0D] p-6'>
236+
<h2 className='mb-4 text-[10px] font-black tracking-[0.2em] text-[#555555] uppercase'>
237+
Contact
238+
</h2>
239+
<div className='flex items-start gap-3 text-sm text-gray-300'>
240+
<Mail className='mt-0.5 h-4 w-4 shrink-0 text-gray-500' />
241+
<span className='break-all'>{contact.value}</span>
242+
</div>
240243
<p className='mt-2 text-[10px] font-bold tracking-wider text-gray-500 uppercase'>
241-
Via {team.contactMethod}
244+
Via {contact.method}
242245
</p>
243-
)}
244-
</div>
245-
)}
246+
</div>
247+
);
248+
})()}
246249
</div>
247250
</div>
248251
</div>

components/hackathons/team-formation/ContactTeamModal.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import {
1818
ExternalLink,
1919
Check,
2020
} from 'lucide-react';
21-
import { TeamRecruitmentPost } from '@/lib/api/hackathons/teams';
21+
import {
22+
readTeamContact,
23+
TeamRecruitmentPost,
24+
} from '@/lib/api/hackathons/teams';
2225
import { useState } from 'react';
2326
import { toast } from 'sonner';
2427

@@ -39,10 +42,16 @@ export function ContactTeamModal({
3942

4043
if (!team) return null;
4144

42-
const { teamName, contactMethod, contactInfo, id } = team;
45+
const { teamName, contactInfo, id } = team;
46+
const contact = readTeamContact(contactInfo);
47+
48+
// If the team has no contact info at all, render nothing: the parent
49+
// should have already gated this modal, but defend against bad data.
50+
if (!contact) return null;
51+
const { method: contactMethod, value: contactValue } = contact;
4352

4453
const handleCopy = () => {
45-
navigator.clipboard.writeText(contactInfo);
54+
navigator.clipboard.writeText(contactValue);
4655
setCopied(true);
4756
toast.success('Contact info copied to clipboard');
4857
setTimeout(() => setCopied(false), 2000);
@@ -56,8 +65,6 @@ export function ContactTeamModal({
5665
case 'telegram':
5766
case 'discord':
5867
return <MessageCircle className='h-5 w-5' />;
59-
case 'github':
60-
return <Github className='h-5 w-5' />;
6168
default:
6269
return <Globe className='h-5 w-5' />;
6370
}
@@ -71,15 +78,13 @@ export function ContactTeamModal({
7178
return 'Telegram Username/Link';
7279
case 'discord':
7380
return 'Discord Username';
74-
case 'github':
75-
return 'GitHub Profile';
7681
default:
7782
return 'Contact Info';
7883
}
7984
};
8085

8186
const isLink =
82-
contactInfo.startsWith('http') || contactInfo.startsWith('https');
87+
contactValue.startsWith('http') || contactValue.startsWith('https');
8388

8489
return (
8590
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -104,7 +109,7 @@ export function ContactTeamModal({
104109
{getLabel()}
105110
</p>
106111
<p className='truncate text-lg font-bold text-white'>
107-
{contactInfo}
112+
{contactValue}
108113
</p>
109114
</div>
110115
</div>
@@ -132,7 +137,7 @@ export function ContactTeamModal({
132137
variant='outline'
133138
className='h-12 flex-1 rounded-xl border-white/10 bg-white/5 font-bold hover:bg-white/10'
134139
onClick={() => {
135-
window.open(contactInfo, '_blank', 'noopener,noreferrer');
140+
window.open(contactValue, '_blank', 'noopener,noreferrer');
136141
onTrackContact?.(id);
137142
}}
138143
>

components/hackathons/team-formation/CreateTeamPostModal.tsx

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import { BoundlessButton } from '@/components/buttons/BoundlessButton';
2828
import { useTeamPosts } from '@/hooks/hackathon/use-team-posts';
2929
import { Loader2, Plus, X, Trash2 } from 'lucide-react';
3030
import { cn } from '@/lib/utils';
31-
import { type TeamRecruitmentPost } from '@/lib/api/hackathons/teams';
31+
import {
32+
readTeamContact,
33+
type TeamContactInfo,
34+
type TeamRecruitmentPost,
35+
} from '@/lib/api/hackathons/teams';
3236

3337
const roleSchema = z.object({
3438
role: z.string().min(1, 'Role name is required'),
@@ -86,13 +90,12 @@ export function CreateTeamPostModal({
8690
maxRoles,
8791
`You can add at most ${maxRoles} open role${maxRoles === 1 ? '' : 's'} (the team is capped at ${effectiveTeamMax} member${effectiveTeamMax === 1 ? '' : 's'} including you)`
8892
),
89-
contactMethod: z.enum([
90-
'email',
91-
'telegram',
92-
'discord',
93-
'github',
94-
'other',
95-
]),
93+
// contactMethod stays on the form for UX (radio-style selector)
94+
// but is NOT sent to the backend as a separate field. Backend stores
95+
// contact as a sparse object keyed by channel (TeamContactInfo);
96+
// onSubmit transforms { contactMethod, contactInfo } into that shape.
97+
// github / other were dropped because the backend cannot store them.
98+
contactMethod: z.enum(['email', 'telegram', 'discord']),
9699
contactInfo: z.string().min(1, 'Contact info is required'),
97100
}),
98101
[maxRoles, effectiveTeamMax]
@@ -105,6 +108,11 @@ export function CreateTeamPostModal({
105108

106109
const isEditMode = !!initialData;
107110

111+
// Flatten the backend's sparse contactInfo object back into the form's
112+
// single { method, value } UI representation. Used both as the initial
113+
// form value and in the edit-mode reset below.
114+
const initialContact = readTeamContact(initialData?.contactInfo);
115+
108116
const form = useForm<TeamPostFormData>({
109117
resolver: zodResolver(teamPostSchema),
110118
defaultValues: {
@@ -115,8 +123,8 @@ export function CreateTeamPostModal({
115123
role: typeof roleObj === 'string' ? roleObj : roleObj.role,
116124
skills: typeof roleObj === 'string' ? [] : roleObj.skills || [],
117125
})) || [],
118-
contactMethod: initialData?.contactMethod || 'email',
119-
contactInfo: initialData?.contactInfo || '',
126+
contactMethod: initialContact?.method ?? 'email',
127+
contactInfo: initialContact?.value ?? '',
120128
},
121129
});
122130

@@ -125,15 +133,16 @@ export function CreateTeamPostModal({
125133

126134
useEffect(() => {
127135
if (open && initialData) {
136+
const contact = readTeamContact(initialData.contactInfo);
128137
form.reset({
129138
teamName: initialData.teamName,
130139
description: initialData.description,
131140
lookingFor: initialData.lookingFor.map(roleObj => ({
132141
role: typeof roleObj === 'string' ? roleObj : roleObj.role,
133142
skills: typeof roleObj === 'string' ? [] : roleObj.skills || [],
134143
})),
135-
contactMethod: initialData.contactMethod || 'email',
136-
contactInfo: initialData.contactInfo,
144+
contactMethod: contact?.method ?? 'email',
145+
contactInfo: contact?.value ?? '',
137146
});
138147
} else if (!open) {
139148
form.reset();
@@ -207,18 +216,27 @@ export function CreateTeamPostModal({
207216
};
208217

209218
const onSubmit = async (data: TeamPostFormData) => {
219+
// Project the form's single { method, value } shape into the backend's
220+
// sparse TeamContactInfo object. The channel is implicit in the key.
221+
const contactInfo: TeamContactInfo = {
222+
[data.contactMethod]: data.contactInfo,
223+
};
210224
try {
211225
if (isEditMode && initialData) {
212226
await updatePost(initialData.id, {
213227
teamName: data.teamName,
214228
description: data.description,
215229
lookingFor: data.lookingFor,
216230
isOpen: data.lookingFor.length > 0,
217-
contactMethod: data.contactMethod,
218-
contactInfo: data.contactInfo,
231+
contactInfo,
219232
});
220233
} else {
221-
await createPost(data);
234+
await createPost({
235+
teamName: data.teamName,
236+
description: data.description,
237+
lookingFor: data.lookingFor,
238+
contactInfo,
239+
});
222240
}
223241

224242
onOpenChange(false);
@@ -483,8 +501,6 @@ export function CreateTeamPostModal({
483501
<SelectItem value='email'>Email</SelectItem>
484502
<SelectItem value='telegram'>Telegram</SelectItem>
485503
<SelectItem value='discord'>Discord</SelectItem>
486-
<SelectItem value='github'>GitHub</SelectItem>
487-
<SelectItem value='other'>Other</SelectItem>
488504
</SelectContent>
489505
</Select>
490506
<FormMessage />

components/organization/cards/GradeSubmissionModal/useScoreForm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ export const useScoreForm = ({
175175
: await submitJudgingScore({
176176
submissionId,
177177
criteriaScores: scoreData,
178-
comment: overallComment,
178+
notes: overallComment,
179179
});
180180

181181
const isSuccess = response.success !== false;

hooks/hackathon/use-submission.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,61 @@ export type SubmissionFormData = Omit<
5151
}>;
5252
};
5353

54+
/**
55+
* Fields the backend `UpdateSubmissionDto` accepts. Backend rejects any
56+
* other field after PR #187 (forbidNonWhitelisted: true). Keep this list
57+
* in sync with src/modules/hackathons/dto/submission.dto.ts.
58+
*
59+
* Notably NOT on update: participationType, teamId, teamName,
60+
* organizationId, hackathonId, participantId. Those are set on create
61+
* only and cannot be changed via PATCH.
62+
*/
63+
const UPDATE_SUBMISSION_FIELDS: readonly (keyof SubmissionFormData)[] = [
64+
'projectName',
65+
'category',
66+
'description',
67+
'logo',
68+
'banner',
69+
'videoUrl',
70+
'introduction',
71+
'links',
72+
'socialLinks',
73+
'teamMembers',
74+
'trackIds',
75+
'trackAnswers',
76+
'tagline',
77+
'builtWith',
78+
'screenshots',
79+
'license',
80+
'codeAttested',
81+
] as const;
82+
83+
function pickUpdateSubmissionFields(
84+
data: Partial<SubmissionFormData>
85+
): Partial<SubmissionFormData> {
86+
const out: Partial<SubmissionFormData> = {};
87+
for (const key of UPDATE_SUBMISSION_FIELDS) {
88+
if (key in data && data[key] !== undefined) {
89+
// The index type below is awkward because SubmissionFormData has
90+
// many optional fields with different shapes. The cast is safe
91+
// because each `key` is a real key of SubmissionFormData.
92+
(out as Record<string, unknown>)[key] = data[key];
93+
}
94+
}
95+
// teamMembers entries must match the backend TeamMemberDto exactly:
96+
// { userId, name, username?, role } — no `email`, no `avatar`. Project
97+
// each entry down to that shape so unknown fields never reach the wire.
98+
if (Array.isArray(out.teamMembers)) {
99+
out.teamMembers = out.teamMembers.map(member => ({
100+
userId: member.userId,
101+
name: member.name,
102+
role: member.role,
103+
...(member.username ? { username: member.username } : {}),
104+
}));
105+
}
106+
return out;
107+
}
108+
54109
interface UseSubmissionOptions {
55110
hackathonSlugOrId: string;
56111
organizationId?: string;
@@ -170,7 +225,11 @@ export function useSubmission({
170225
setError(null);
171226

172227
try {
173-
const response = await updateSubmission(submissionId, data);
228+
// Strip create-only fields (participationType, teamId, teamName,
229+
// organizationId, ...) and per-team-member email so the request
230+
// matches UpdateSubmissionDto under forbidNonWhitelisted: true.
231+
const payload = pickUpdateSubmissionFields(data);
232+
const response = await updateSubmission(submissionId, payload);
174233

175234
if (response?.success && response?.data) {
176235
setSubmission(response.data);

lib/api/auth.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,15 @@ export const getAuthHeaders = (): Record<string, string> => {
5555
};
5656

5757
/**
58-
* Update user profile request interface - matches API payload specification
58+
* Update user profile request interface. Mirrors the backend
59+
* `UpdateProfileDto` (src/modules/users/dto/update-profile.dto.ts).
60+
*
61+
* Notably absent: a `preferences` block. The backend has dedicated
62+
* endpoints for theme / language / timezone / notification toggles
63+
* (see updateAppearanceSettings, updateNotificationsSettings,
64+
* updateUserSettings in lib/api/user/settings.ts). Sending a
65+
* `preferences` field here would be rejected as an unknown property
66+
* once the backend enables forbidNonWhitelisted (PR #187).
5967
*/
6068
export interface UpdateUserProfileRequest {
6169
bio?: string;
@@ -69,13 +77,6 @@ export interface UpdateUserProfileRequest {
6977
linkedin?: string;
7078
discord?: string;
7179
};
72-
preferences?: {
73-
theme?: 'light' | 'dark' | 'auto';
74-
language?: string;
75-
timezone?: string;
76-
emailNotifications?: boolean;
77-
pushNotifications?: boolean;
78-
};
7980
}
8081

8182
/**

lib/api/hackathons.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -502,9 +502,24 @@ export interface PublishHackathonRequest extends Hackathon {
502502
escrowDetails?: object;
503503
}
504504

505-
export type UpdateHackathonRequest = Partial<Hackathon> & {
505+
/**
506+
* Narrow shape for the PUT /hackathons/:id endpoint. The previous wider
507+
* `Partial<Hackathon>` typing let callers leak Hackathon-shape fields
508+
* (id, organizationId, creatorId, status, ...) into the request body,
509+
* any of which would 400 once the backend enables
510+
* forbidNonWhitelisted (PR #187). The only caller today is the rank
511+
* override save in JudgingResultsTable, which sends only `rewards`.
512+
*
513+
* Note: the backend route this calls (PUT /hackathons/:id) does not
514+
* currently exist as a separate endpoint; the section-specific PATCH
515+
* routes (/content, /schedule, /financial) are where edits should go.
516+
* That mismatch is a separate pre-existing bug, but the narrow type
517+
* here at least prevents the request from carrying unknown fields if
518+
* the route is wired up later.
519+
*/
520+
export interface UpdateHackathonRequest {
506521
rewards?: HackathonRewards;
507-
};
522+
}
508523

509524
// Response Types
510525
export interface CreateDraftResponse extends ApiResponse<HackathonDraft> {

lib/api/hackathons/judging.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export interface CriterionScoreRequest {
237237
export interface SubmitJudgingScoreRequest {
238238
submissionId: string;
239239
criteriaScores: CriterionScoreRequest[];
240-
comment?: string; // Optional global feedback
240+
notes?: string; // Optional global feedback (per-judge notes on this submission)
241241
}
242242

243243
export interface OverrideSubmissionScoreRequest {

0 commit comments

Comments
 (0)