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 (
- {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;
}