From 7d76aa0cca48193dc8776c5b3dcd578cb9e91e20 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 28 May 2026 14:45:27 +0100 Subject: [PATCH 1/2] 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 & { 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) --- .../hackathons/[slug]/teams/[teamId]/page.tsx | 31 ++++++----- .../team-formation/ContactTeamModal.tsx | 25 +++++---- .../team-formation/CreateTeamPostModal.tsx | 50 +++++++++++------ .../GradeSubmissionModal/useScoreForm.ts | 2 +- hooks/hackathon/use-submission.ts | 55 ++++++++++++++++++- lib/api/auth.ts | 17 +++--- lib/api/hackathons.ts | 19 ++++++- lib/api/hackathons/judging.ts | 2 +- lib/api/hackathons/teams.ts | 31 +++++++++-- 9 files changed, 174 insertions(+), 58 deletions(-) diff --git a/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx b/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx index d5ee5b4f..3c48a0b0 100644 --- a/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx +++ b/app/(landing)/hackathons/[slug]/teams/[teamId]/page.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import { useHackathon, useTeam } from '@/hooks/hackathon/use-hackathon-queries'; import { Button } from '@/components/ui/button'; import BasicAvatar from '@/components/avatars/BasicAvatar'; +import { readTeamContact } from '@/lib/api/hackathons/teams'; import { ArrowLeft, Calendar, @@ -227,22 +228,24 @@ export default function TeamDetailsPage({ )} - {team.contactInfo && ( -
-

- Contact -

-
- - {team.contactInfo} -
- {team.contactMethod && ( + {(() => { + const contact = readTeamContact(team.contactInfo); + if (!contact) return null; + return ( +
+

+ Contact +

+
+ + {contact.value} +

- Via {team.contactMethod} + Via {contact.method}

- )} -
- )} +
+ ); + })()} diff --git a/components/hackathons/team-formation/ContactTeamModal.tsx b/components/hackathons/team-formation/ContactTeamModal.tsx index 13d3f197..a85a63aa 100644 --- a/components/hackathons/team-formation/ContactTeamModal.tsx +++ b/components/hackathons/team-formation/ContactTeamModal.tsx @@ -18,7 +18,10 @@ import { ExternalLink, Check, } from 'lucide-react'; -import { TeamRecruitmentPost } from '@/lib/api/hackathons/teams'; +import { + readTeamContact, + TeamRecruitmentPost, +} from '@/lib/api/hackathons/teams'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -39,10 +42,16 @@ export function ContactTeamModal({ if (!team) return null; - const { teamName, contactMethod, contactInfo, id } = team; + const { teamName, contactInfo, id } = team; + const contact = readTeamContact(contactInfo); + + // If the team has no contact info at all, render nothing: the parent + // should have already gated this modal, but defend against bad data. + if (!contact) return null; + const { method: contactMethod, value: contactValue } = contact; const handleCopy = () => { - navigator.clipboard.writeText(contactInfo); + navigator.clipboard.writeText(contactValue); setCopied(true); toast.success('Contact info copied to clipboard'); setTimeout(() => setCopied(false), 2000); @@ -56,8 +65,6 @@ export function ContactTeamModal({ case 'telegram': case 'discord': return ; - case 'github': - return ; default: return ; } @@ -71,15 +78,13 @@ export function ContactTeamModal({ return 'Telegram Username/Link'; case 'discord': return 'Discord Username'; - case 'github': - return 'GitHub Profile'; default: return 'Contact Info'; } }; const isLink = - contactInfo.startsWith('http') || contactInfo.startsWith('https'); + contactValue.startsWith('http') || contactValue.startsWith('https'); return ( @@ -104,7 +109,7 @@ export function ContactTeamModal({ {getLabel()}

- {contactInfo} + {contactValue}

@@ -132,7 +137,7 @@ export function ContactTeamModal({ variant='outline' className='h-12 flex-1 rounded-xl border-white/10 bg-white/5 font-bold hover:bg-white/10' onClick={() => { - window.open(contactInfo, '_blank', 'noopener,noreferrer'); + window.open(contactValue, '_blank', 'noopener,noreferrer'); onTrackContact?.(id); }} > diff --git a/components/hackathons/team-formation/CreateTeamPostModal.tsx b/components/hackathons/team-formation/CreateTeamPostModal.tsx index 70053800..5733c728 100644 --- a/components/hackathons/team-formation/CreateTeamPostModal.tsx +++ b/components/hackathons/team-formation/CreateTeamPostModal.tsx @@ -28,7 +28,11 @@ import { BoundlessButton } from '@/components/buttons/BoundlessButton'; import { useTeamPosts } from '@/hooks/hackathon/use-team-posts'; import { Loader2, Plus, X, Trash2 } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { type TeamRecruitmentPost } from '@/lib/api/hackathons/teams'; +import { + readTeamContact, + type TeamContactInfo, + type TeamRecruitmentPost, +} from '@/lib/api/hackathons/teams'; const roleSchema = z.object({ role: z.string().min(1, 'Role name is required'), @@ -86,13 +90,12 @@ export function CreateTeamPostModal({ maxRoles, `You can add at most ${maxRoles} open role${maxRoles === 1 ? '' : 's'} (the team is capped at ${effectiveTeamMax} member${effectiveTeamMax === 1 ? '' : 's'} including you)` ), - contactMethod: z.enum([ - 'email', - 'telegram', - 'discord', - 'github', - 'other', - ]), + // contactMethod stays on the form for UX (radio-style selector) + // but is NOT sent to the backend as a separate field. Backend stores + // contact as a sparse object keyed by channel (TeamContactInfo); + // onSubmit transforms { contactMethod, contactInfo } into that shape. + // github / other were dropped because the backend cannot store them. + contactMethod: z.enum(['email', 'telegram', 'discord']), contactInfo: z.string().min(1, 'Contact info is required'), }), [maxRoles, effectiveTeamMax] @@ -105,6 +108,11 @@ export function CreateTeamPostModal({ const isEditMode = !!initialData; + // Flatten the backend's sparse contactInfo object back into the form's + // single { method, value } UI representation. Used both as the initial + // form value and in the edit-mode reset below. + const initialContact = readTeamContact(initialData?.contactInfo); + const form = useForm({ resolver: zodResolver(teamPostSchema), defaultValues: { @@ -115,8 +123,8 @@ export function CreateTeamPostModal({ role: typeof roleObj === 'string' ? roleObj : roleObj.role, skills: typeof roleObj === 'string' ? [] : roleObj.skills || [], })) || [], - contactMethod: initialData?.contactMethod || 'email', - contactInfo: initialData?.contactInfo || '', + contactMethod: initialContact?.method ?? 'email', + contactInfo: initialContact?.value ?? '', }, }); @@ -125,6 +133,7 @@ export function CreateTeamPostModal({ useEffect(() => { if (open && initialData) { + const contact = readTeamContact(initialData.contactInfo); form.reset({ teamName: initialData.teamName, description: initialData.description, @@ -132,8 +141,8 @@ export function CreateTeamPostModal({ role: typeof roleObj === 'string' ? roleObj : roleObj.role, skills: typeof roleObj === 'string' ? [] : roleObj.skills || [], })), - contactMethod: initialData.contactMethod || 'email', - contactInfo: initialData.contactInfo, + contactMethod: contact?.method ?? 'email', + contactInfo: contact?.value ?? '', }); } else if (!open) { form.reset(); @@ -207,6 +216,11 @@ export function CreateTeamPostModal({ }; const onSubmit = async (data: TeamPostFormData) => { + // Project the form's single { method, value } shape into the backend's + // sparse TeamContactInfo object. The channel is implicit in the key. + const contactInfo: TeamContactInfo = { + [data.contactMethod]: data.contactInfo, + }; try { if (isEditMode && initialData) { await updatePost(initialData.id, { @@ -214,11 +228,15 @@ export function CreateTeamPostModal({ description: data.description, lookingFor: data.lookingFor, isOpen: data.lookingFor.length > 0, - contactMethod: data.contactMethod, - contactInfo: data.contactInfo, + contactInfo, }); } else { - await createPost(data); + await createPost({ + teamName: data.teamName, + description: data.description, + lookingFor: data.lookingFor, + contactInfo, + }); } onOpenChange(false); @@ -483,8 +501,6 @@ export function CreateTeamPostModal({ Email Telegram Discord - GitHub - Other diff --git a/components/organization/cards/GradeSubmissionModal/useScoreForm.ts b/components/organization/cards/GradeSubmissionModal/useScoreForm.ts index 50a20c13..58c7813c 100644 --- a/components/organization/cards/GradeSubmissionModal/useScoreForm.ts +++ b/components/organization/cards/GradeSubmissionModal/useScoreForm.ts @@ -175,7 +175,7 @@ export const useScoreForm = ({ : await submitJudgingScore({ submissionId, criteriaScores: scoreData, - comment: overallComment, + notes: overallComment, }); const isSuccess = response.success !== false; diff --git a/hooks/hackathon/use-submission.ts b/hooks/hackathon/use-submission.ts index c4f0f496..63e70e16 100644 --- a/hooks/hackathon/use-submission.ts +++ b/hooks/hackathon/use-submission.ts @@ -51,6 +51,55 @@ export type SubmissionFormData = Omit< }>; }; +/** + * Fields the backend `UpdateSubmissionDto` accepts. Backend rejects any + * other field after PR #187 (forbidNonWhitelisted: true). Keep this list + * in sync with src/modules/hackathons/dto/submission.dto.ts. + * + * Notably NOT on update: participationType, teamId, teamName, + * organizationId, hackathonId, participantId. Those are set on create + * only and cannot be changed via PATCH. + */ +const UPDATE_SUBMISSION_FIELDS: readonly (keyof SubmissionFormData)[] = [ + 'projectName', + 'category', + 'description', + 'logo', + 'banner', + 'videoUrl', + 'introduction', + 'links', + 'socialLinks', + 'teamMembers', + 'trackIds', + 'trackAnswers', + 'tagline', + 'builtWith', + 'screenshots', + 'license', + 'codeAttested', +] as const; + +function pickUpdateSubmissionFields( + data: Partial +): Partial { + const out: Partial = {}; + for (const key of UPDATE_SUBMISSION_FIELDS) { + if (key in data && data[key] !== undefined) { + // The index type below is awkward because SubmissionFormData has + // many optional fields with different shapes. The cast is safe + // because each `key` is a real key of SubmissionFormData. + (out as Record)[key] = data[key]; + } + } + // teamMembers entries must not carry `email` (UpdateSubmissionDto's + // TeamMemberDto has no such field). Strip per-entry. + if (Array.isArray(out.teamMembers)) { + out.teamMembers = out.teamMembers.map(({ email: _email, ...rest }) => rest); + } + return out; +} + interface UseSubmissionOptions { hackathonSlugOrId: string; organizationId?: string; @@ -170,7 +219,11 @@ export function useSubmission({ setError(null); try { - const response = await updateSubmission(submissionId, data); + // Strip create-only fields (participationType, teamId, teamName, + // organizationId, ...) and per-team-member email so the request + // matches UpdateSubmissionDto under forbidNonWhitelisted: true. + const payload = pickUpdateSubmissionFields(data); + const response = await updateSubmission(submissionId, payload); if (response?.success && response?.data) { setSubmission(response.data); diff --git a/lib/api/auth.ts b/lib/api/auth.ts index 4b85f601..bc67adc1 100644 --- a/lib/api/auth.ts +++ b/lib/api/auth.ts @@ -55,7 +55,15 @@ export const getAuthHeaders = (): Record => { }; /** - * Update user profile request interface - matches API payload specification + * Update user profile request interface. Mirrors the backend + * `UpdateProfileDto` (src/modules/users/dto/update-profile.dto.ts). + * + * Notably absent: a `preferences` block. The backend has dedicated + * endpoints for theme / language / timezone / notification toggles + * (see updateAppearanceSettings, updateNotificationsSettings, + * updateUserSettings in lib/api/user/settings.ts). Sending a + * `preferences` field here would be rejected as an unknown property + * once the backend enables forbidNonWhitelisted (PR #187). */ export interface UpdateUserProfileRequest { bio?: string; @@ -69,13 +77,6 @@ export interface UpdateUserProfileRequest { linkedin?: string; discord?: string; }; - preferences?: { - theme?: 'light' | 'dark' | 'auto'; - language?: string; - timezone?: string; - emailNotifications?: boolean; - pushNotifications?: boolean; - }; } /** diff --git a/lib/api/hackathons.ts b/lib/api/hackathons.ts index 73c46978..2e571a3d 100644 --- a/lib/api/hackathons.ts +++ b/lib/api/hackathons.ts @@ -502,9 +502,24 @@ export interface PublishHackathonRequest extends Hackathon { escrowDetails?: object; } -export type UpdateHackathonRequest = Partial & { +/** + * Narrow shape for the PUT /hackathons/:id endpoint. The previous wider + * `Partial` typing let callers leak Hackathon-shape fields + * (id, organizationId, creatorId, status, ...) into the request body, + * any of which would 400 once the backend enables + * forbidNonWhitelisted (PR #187). The only caller today is the rank + * override save in JudgingResultsTable, which sends only `rewards`. + * + * Note: the backend route this calls (PUT /hackathons/:id) does not + * currently exist as a separate endpoint; the section-specific PATCH + * routes (/content, /schedule, /financial) are where edits should go. + * That mismatch is a separate pre-existing bug, but the narrow type + * here at least prevents the request from carrying unknown fields if + * the route is wired up later. + */ +export interface UpdateHackathonRequest { rewards?: HackathonRewards; -}; +} // Response Types export interface CreateDraftResponse extends ApiResponse { diff --git a/lib/api/hackathons/judging.ts b/lib/api/hackathons/judging.ts index 0b65e407..ba4871e5 100644 --- a/lib/api/hackathons/judging.ts +++ b/lib/api/hackathons/judging.ts @@ -237,7 +237,7 @@ export interface CriterionScoreRequest { export interface SubmitJudgingScoreRequest { submissionId: string; criteriaScores: CriterionScoreRequest[]; - comment?: string; // Optional global feedback + notes?: string; // Optional global feedback (per-judge notes on this submission) } export interface OverrideSubmissionScoreRequest { diff --git a/lib/api/hackathons/teams.ts b/lib/api/hackathons/teams.ts index a031c2b8..49582d6f 100644 --- a/lib/api/hackathons/teams.ts +++ b/lib/api/hackathons/teams.ts @@ -24,6 +24,31 @@ export interface LookingForRole { skills?: string[]; } +/** + * Contact information for a team. Backend stores this as a sparse object + * keyed by channel rather than a string + separate method enum. The + * channel is implicit in whichever key is populated. + */ +export interface TeamContactInfo { + telegram?: string; + discord?: string; + email?: string; +} + +/** + * Surface the active channel + raw string from a TeamContactInfo for UI + * display. Returns null when no channel is populated. + */ +export function readTeamContact( + info: TeamContactInfo | undefined | null +): { method: 'email' | 'telegram' | 'discord'; value: string } | null { + if (!info) return null; + if (info.email) return { method: 'email', value: info.email }; + if (info.telegram) return { method: 'telegram', value: info.telegram }; + if (info.discord) return { method: 'discord', value: info.discord }; + return null; +} + // Team Interface (Updated from TeamRecruitmentPost) export interface Team { id: string; @@ -44,8 +69,7 @@ export interface Team { maxSize: number; lookingFor: LookingForRole[]; rolesStatus?: TeamRole[]; // Track hired status for each role - contactMethod?: 'email' | 'telegram' | 'discord' | 'github' | 'other'; - contactInfo: string; + contactInfo?: TeamContactInfo; isOpen: boolean; organizationId?: string; views?: number; @@ -64,8 +88,7 @@ export interface CreateTeamRequest { description: string; lookingFor: LookingForRole[]; maxSize?: number; - contactMethod?: 'email' | 'telegram' | 'discord' | 'github' | 'other'; - contactInfo: string; + contactInfo?: TeamContactInfo; } export interface UpdateTeamRequest extends Partial { From 293a29dada80c2a6379ae9345a3bebe96c1771b3 Mon Sep 17 00:00:00 2001 From: Collins Ikechukwu Date: Thu, 28 May 2026 14:53:06 +0100 Subject: [PATCH 2/2] fix(submissions): project teamMembers entries to backend TeamMemberDto shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hooks/hackathon/use-submission.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/hooks/hackathon/use-submission.ts b/hooks/hackathon/use-submission.ts index 63e70e16..ae8503be 100644 --- a/hooks/hackathon/use-submission.ts +++ b/hooks/hackathon/use-submission.ts @@ -92,10 +92,16 @@ function pickUpdateSubmissionFields( (out as Record)[key] = data[key]; } } - // teamMembers entries must not carry `email` (UpdateSubmissionDto's - // TeamMemberDto has no such field). Strip per-entry. + // teamMembers entries must match the backend TeamMemberDto exactly: + // { userId, name, username?, role } — no `email`, no `avatar`. Project + // each entry down to that shape so unknown fields never reach the wire. if (Array.isArray(out.teamMembers)) { - out.teamMembers = out.teamMembers.map(({ email: _email, ...rest }) => rest); + out.teamMembers = out.teamMembers.map(member => ({ + userId: member.userId, + name: member.name, + role: member.role, + ...(member.username ? { username: member.username } : {}), + })); } return out; }