chore: enable noUncheckedIndexedAccess, isolate ghost MSW handlers, document go-clamd tech debt

- Enable TypeScript noUncheckedIndexedAccess and fix 133 resulting errors
  across 46 files with proper null guards, optional chaining, and fallbacks
- Extract education/gamification ghost feature MSW handlers into handlers-ghost.ts
- Add Storybook test plugin documentation in vitest.config.ts
- Document abandoned go-clamd dependency (2017) as tech debt in upload_validator.go

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
senke 2026-02-12 23:12:35 +01:00
parent 1695606e24
commit 09bb663659
50 changed files with 252 additions and 187 deletions

View file

@ -98,6 +98,10 @@ export function Onboarding({
const isFirstStep = currentStep === 0;
const isLastStep = currentStep === steps.length - 1;
if (!step) {
return null;
}
return (
<Dialog
open={open}
@ -110,9 +114,9 @@ export function Onboarding({
<div className="flex items-center justify-between w-full">
{/* Progress indicator */}
<div className="flex items-center gap-2">
{steps.map((_, index) => (
{steps.map((step, index) => (
<div
key={index}
key={step.title}
className={cn(
'h-2 w-2 rounded-full transition-colors',
index === currentStep

View file

@ -129,7 +129,7 @@ export function BarChart({
className="cursor-pointer transition-opacity hover:opacity-80"
>
<title>
{bar.label}: {data[index].value}
{bar.label}: {data[index]?.value}
</title>
</rect>
{showValues && (
@ -140,7 +140,7 @@ export function BarChart({
className="fill-foreground text-[1.5px]"
fontSize="1.5"
>
{data[index].value}
{data[index]?.value}
</text>
)}
</g>

View file

@ -145,7 +145,7 @@ export function LineChart({
className="cursor-pointer transition-all hover:r-[6]"
>
<title>
{point.label}: {data[index].value}
{point.label}: {data[index]?.value}
</title>
</circle>
))}

View file

@ -51,10 +51,13 @@ export const StatCard: React.FC<StatCardProps> = ({
y: height - ((val - min) / range) * height,
}));
let pathStr = `M ${points[0].x},${points[0].y}`;
const first = points[0];
if (!first) return '';
let pathStr = `M ${first.x},${first.y}`;
for (let i = 0; i < points.length - 1; i++) {
const curr = points[i];
const next = points[i + 1];
if (!curr || !next) continue;
const mx = (curr.x + next.x) / 2;
pathStr += ` C ${mx},${curr.y} ${mx},${next.y} ${next.x},${next.y}`;
}

View file

@ -13,7 +13,7 @@ const ENDPOINTS = [
export const APIPlaygroundView: React.FC = () => {
const { addToast } = useToast();
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]);
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!);
const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
const [response, setResponse] = useState<string | null>(null);
const [loading, setLoading] = useState(false);

View file

@ -23,6 +23,10 @@ export const QuizModal: React.FC<QuizModalProps> = ({
const currentQuestion = quiz.questions[currentQuestionIndex];
const isLastQuestion = currentQuestionIndex === quiz.questions.length - 1;
if (!currentQuestion) {
return null;
}
const handleAnswerSelect = (index: number) => {
const newAnswers = [...selectedAnswers];
newAnswers[currentQuestionIndex] = index;

View file

@ -67,6 +67,7 @@ export const LeaderboardView: React.FC = () => {
{/* Order: Silver (index 1), Gold (index 0), Bronze (index 2) */}
{[leaderboard[1], leaderboard[0], leaderboard[2]].map(
(entry, i) => {
if (!entry) return null;
const podiumStyles = {
// Gold (center, 1st place)
1: {
@ -89,7 +90,8 @@ export const LeaderboardView: React.FC = () => {
badge: 'bg-orange-400 text-background',
label: 'text-orange-400',
},
}[i]!;
}[i as 0 | 1 | 2];
if (!podiumStyles) return null;
return (
<div

View file

@ -100,11 +100,12 @@ export function Tabs({
attempts++;
}
if (items[newIndex] && !items[newIndex].disabled) {
handleTabChange(items[newIndex].id);
const targetItem = items[newIndex];
if (targetItem && !targetItem.disabled) {
handleTabChange(targetItem.id);
// Focus sur le nouvel onglet après un court délai pour permettre la mise à jour
setTimeout(() => {
tabRefs.current[items[newIndex].id]?.focus();
tabRefs.current[targetItem.id]?.focus();
}, 0);
}
},

View file

@ -15,7 +15,7 @@ export const LyricsPanel: React.FC = () => {
return (
currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 ||
currentTime < currentTrack.lyrics![i + 1].time)
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity))
);
},
);
@ -62,7 +62,7 @@ export const LyricsPanel: React.FC = () => {
const isActive =
currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 ||
currentTime < currentTrack.lyrics![i + 1].time);
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity));
return (
<p
key={i}

View file

@ -59,16 +59,17 @@ export function GlobalSearchBar({
searchPromises.push(Promise.resolve({ playlists: [], total: 0 }));
}
const [tracksData, usersData, playlistsData] = await Promise.allSettled(
searchPromises,
);
const settled = await Promise.allSettled(searchPromises);
const tracksData = settled[0];
const usersData = settled[1];
const playlistsData = settled[2];
const duration = performance.now() - startTime;
logger.debug('Search requests completed', {
duration: `${duration.toFixed(2)}ms`,
tracksStatus: tracksData.status,
usersStatus: usersData.status,
playlistsStatus: playlistsData.status,
tracksStatus: tracksData?.status,
usersStatus: usersData?.status,
playlistsStatus: playlistsData?.status,
component: 'GlobalSearchBar',
});
@ -76,7 +77,7 @@ export function GlobalSearchBar({
// Add track suggestions
// FIX: searchTracks retourne PaginatedResponse<Track> avec propriété 'data'
if (tracksData.status === 'fulfilled' && tracksData.value?.data) {
if (tracksData?.status === 'fulfilled' && tracksData.value?.data) {
tracksData.value.data.forEach((track: { id: string; title?: string; name?: string; artist?: string; artist_name?: string; cover_url?: string; cover?: string; thumbnail_url?: string }) => {
results.push({
id: track.id,
@ -91,7 +92,7 @@ export function GlobalSearchBar({
// Add playlist suggestions (seulement si la feature est activée)
if (
isFeatureEnabled('PLAYLIST_SEARCH') &&
playlistsData.status === 'fulfilled' &&
playlistsData?.status === 'fulfilled' &&
playlistsData.value?.playlists
) {
playlistsData.value.playlists.forEach((playlist: { id: string; title?: string; is_public?: boolean; cover_url?: string; thumbnail_url?: string }) => {
@ -106,7 +107,7 @@ export function GlobalSearchBar({
}
// Add user suggestions
if (usersData.status === 'fulfilled' && usersData.value?.users) {
if (usersData?.status === 'fulfilled' && usersData.value?.users) {
usersData.value.users.forEach((user: { id: string; username: string; email?: string; avatar_url?: string }) => {
results.push({
id: user.id,

View file

@ -30,7 +30,7 @@ function formatRelativeTime(timestamp: string): string {
const units: Record<string, string> = {
s: 's', m: 'm', h: 'h', d: 'd', w: 'w', mo: 'mo', y: 'y',
};
return `${relMatch[1]}${units[relMatch[2].toLowerCase()]} ago`;
return `${relMatch[1]}${units[relMatch[2]?.toLowerCase() ?? ''] ?? ''} ago`;
}
// Try parsing as a date
const date = new Date(timestamp);

View file

@ -24,7 +24,7 @@ export function useAIToolsView() {
setIsProcessing(false);
addToast('Processing complete!', 'success');
setResult({
fileName: files[0].name,
fileName: files[0]?.name ?? 'unknown',
outputs:
tool === 'stem-splitter'
? ['Drums.wav', 'Bass.wav', 'Vocals.wav', 'Other.wav']

View file

@ -79,6 +79,7 @@ export function AstralBackground() {
// Draw connections
for (let j = i + 1; j < particles.length; j++) {
const p2 = particles[j];
if (!p2) continue;
const dx = p.x - p2.x;
const dy = p.y - p2.y;
const distance = Math.sqrt(dx * dx + dy * dy);

View file

@ -199,7 +199,7 @@ export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
if (!name) return '?';
const parts = name.trim().split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};

View file

@ -112,7 +112,7 @@ export function Dropdown({
focusedIndexRef.current >= 0 &&
elements[focusedIndexRef.current]
) {
elements[focusedIndexRef.current].click();
elements[focusedIndexRef.current]?.click();
}
break;

View file

@ -44,7 +44,7 @@ export function SelectDropdownContent({
const highlightedOptionId =
highlightedIndex >= 0 && highlightedIndex < allOptions.length
? `${listboxId}-option-${allOptions[highlightedIndex].value}`
? `${listboxId}-option-${allOptions[highlightedIndex]?.value}`
: undefined;
const handleKeyDown = (e: React.KeyboardEvent) => {
@ -65,7 +65,7 @@ export function SelectDropdownContent({
case ' ':
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
e.preventDefault();
onSelect(allOptions[highlightedIndex].value);
onSelect(allOptions[highlightedIndex]!.value);
}
break;
case 'Home':
@ -115,7 +115,7 @@ export function SelectDropdownContent({
isHighlighted={
highlightedIndex >= 0 &&
highlightedIndex < allOptions.length &&
allOptions[highlightedIndex].value === option.value
allOptions[highlightedIndex]?.value === option.value
}
multiple={multiple}
onSelect={onSelect}
@ -137,7 +137,7 @@ export function SelectDropdownContent({
isHighlighted={
highlightedIndex >= 0 &&
highlightedIndex < allOptions.length &&
allOptions[highlightedIndex].value === option.value
allOptions[highlightedIndex]?.value === option.value
}
multiple={multiple}
onSelect={onSelect}

View file

@ -18,7 +18,7 @@ export function useSelect({
options.forEach((option) => {
if (option.group) {
if (!groups[option.group]) groups[option.group] = [];
groups[option.group].push(option);
groups[option.group]!.push(option);
} else {
ungrouped.push(option);
}

View file

@ -120,7 +120,7 @@ const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
}
};
const percentage = ((value[0] - min) / (max - min)) * 100;
const percentage = (((value[0] ?? min) - min) / (max - min)) * 100;
return (
<div

View file

@ -31,9 +31,9 @@ export const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
}
e.preventDefault();
triggers[nextIndex].focus();
triggers[nextIndex]?.focus();
// Activate on arrow key navigation (follows WAI-ARIA tabs pattern)
const value = triggers[nextIndex].getAttribute('data-value');
const value = triggers[nextIndex]?.getAttribute('data-value');
if (value) onValueChange?.(value);
};

View file

@ -56,7 +56,7 @@ export const VirtualizedList = React.forwardRef<
0,
Math.floor(scrollTop / itemHeight) - overscan,
);
const endIndex = virtualItems[virtualItems.length - 1].index;
const endIndex = virtualItems[virtualItems.length - 1]!.index;
onItemsRendered(startIndex, endIndex);
}
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]);

View file

@ -27,7 +27,7 @@ export function ProfileViewOverview({
<div className="w-32 h-32 md:w-48 md:h-48 shrink-0 shadow-2xl rounded-lg overflow-hidden">
<img
src={
tracks[0].coverUrl || 'https://via.placeholder.com/400'
tracks[0]?.coverUrl || 'https://via.placeholder.com/400'
}
alt=""
className="w-full h-full object-cover"
@ -40,7 +40,7 @@ export function ProfileViewOverview({
className="mb-2"
/>
<h2 className="text-2xl md:text-4xl font-heading font-bold text-foreground mb-2 tracking-tight">
{tracks[0].title}
{tracks[0]?.title}
</h2>
<p className="text-muted-foreground mb-6">
Latest upload from {profile.username}.

View file

@ -38,6 +38,7 @@ export function useAudioContextValue() {
const next = shuffle
? queue[Math.floor(Math.random() * queue.length)]
: queue[0];
if (!next) return;
setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev));
if (repeatMode !== 'all') {
setQueue((prev) => prev.filter((t) => t.id !== next.id));
@ -50,6 +51,7 @@ export function useAudioContextValue() {
} else if (autoplay) {
const randomMock =
mockTracks[Math.floor(Math.random() * mockTracks.length)];
if (!randomMock) return;
setHistory((prev) => (currentTrack ? [...prev, currentTrack] : prev));
setCurrentTrack({
...randomMock,

View file

@ -48,7 +48,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo {
} else if (ua.includes('mac os x') || ua.includes('macintosh')) {
info.os = 'macOS';
const match = ua.match(/mac os x (\d+[._]\d+)/);
if (match) {
if (match?.[1]) {
info.osVersion = match[1].replace('_', '.');
}
} else if (ua.includes('linux')) {
@ -66,7 +66,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo {
) {
info.os = 'iOS';
const match = ua.match(/os (\d+[._]\d+)/);
if (match) {
if (match?.[1]) {
info.osVersion = match[1].replace('_', '.');
}
}
@ -115,7 +115,7 @@ export function parseUserAgent(userAgent: string): DeviceInfo {
} else if (ua.includes('android')) {
// Try to extract device model from Android user agent
const match = ua.match(/android.*?;\s*([^)]+)\)/);
if (match) {
if (match?.[1]) {
info.deviceModel = match[1].trim();
}
}

View file

@ -153,7 +153,7 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ conversationId }) => {
{currentMessages.map((msg, index) => {
const isMe = currentUserId ? msg.sender_id === currentUserId : false;
const isSequence =
index > 0 && currentMessages[index - 1].sender_id === msg.sender_id;
index > 0 && currentMessages[index - 1]?.sender_id === msg.sender_id;
return (
<div

View file

@ -46,8 +46,9 @@ export function VirtualizedChatMessages({
useEffect(() => {
if (!isFetching && messages.length > 0) {
const lastMessage = messages[messages.length - 1];
const isRecentMessage =
Date.now() - new Date(lastMessage.created_at).getTime() < 5000;
const isRecentMessage = lastMessage
? Date.now() - new Date(lastMessage.created_at).getTime() < 5000
: false;
if (isRecentMessage) {
scrollToBottom();

View file

@ -106,7 +106,7 @@ export const useChatStore = create<ChatState>()(
if (!state.messages[message.conversation_id]) {
state.messages[message.conversation_id] = [];
}
state.messages[message.conversation_id].push(message);
state.messages[message.conversation_id]!.push(message);
}),
loadMessages: (conversationId, newMessages) =>
set((state) => {
@ -142,10 +142,12 @@ export const useChatStore = create<ChatState>()(
if (!message.reactions) message.reactions = {};
// Remove existing reaction from this user if any
Object.keys(message.reactions).forEach((e) => {
message.reactions![e] = message.reactions![e].filter(
const users = message.reactions![e];
if (!users) return;
message.reactions![e] = users.filter(
(id) => id !== userId,
);
if (message.reactions![e].length === 0)
if (message.reactions![e]?.length === 0)
delete message.reactions![e];
});
// Add new reaction
@ -163,10 +165,12 @@ export const useChatStore = create<ChatState>()(
const message = messages.find((m) => m.id === messageId);
if (message && message.reactions) {
Object.keys(message.reactions).forEach((emoji) => {
message.reactions![emoji] = message.reactions![emoji].filter(
const users = message.reactions![emoji];
if (!users) return;
message.reactions![emoji] = users.filter(
(id) => id !== userId,
);
if (message.reactions![emoji].length === 0)
if (message.reactions![emoji]?.length === 0)
delete message.reactions![emoji];
});
}

View file

@ -194,7 +194,7 @@ export function Cart({ isOpen, onClose }: CartProps) {
<div className="flex justify-between text-lg font-semibold">
<span>Total</span>
<span>
{items.length > 0
{items.length > 0 && items[0]
? formatPrice(getTotal(), items[0].product.currency)
: '€0.00'}
</span>

View file

@ -49,9 +49,10 @@ export function PlaybackSpeedControl({
: DEFAULT_SPEEDS;
const currentSpeedOption =
speeds.find((s) => s.value === currentSpeed) ||
speeds.find((s) => s.value === 1) ||
speeds[0];
speeds.find((s) => s.value === currentSpeed) ??
speeds.find((s) => s.value === 1) ??
speeds[0] ??
{ value: currentSpeed, label: `${currentSpeed}x` };
// Fermer le dropdown quand on clique en dehors
useEffect(() => {

View file

@ -43,7 +43,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
const activeIndex = lyrics.findIndex(
(line, i) =>
currentTime >= line.time &&
(i === lyrics.length - 1 || currentTime < lyrics[i + 1].time)
(i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity))
);
if (activeIndex >= 0) {
const el = lyricsScrollRef.current.children[activeIndex] as HTMLElement;
@ -212,7 +212,7 @@ export function PlayerExpanded({ isOpen, onClose, currentTime, duration, onSeek,
{lyrics.map((line, i) => {
const isActive =
currentTime >= line.time &&
(i === lyrics.length - 1 || currentTime < lyrics[i + 1].time);
(i === lyrics.length - 1 || currentTime < (lyrics[i + 1]?.time ?? Infinity));
return (
<p
key={i}

View file

@ -47,7 +47,9 @@ export function QualitySelector({
: DEFAULT_QUALITIES;
const currentQualityOption =
qualities.find((q) => q.value === currentQuality) || qualities[0];
qualities.find((q) => q.value === currentQuality) ??
qualities[0] ??
{ value: currentQuality, label: currentQuality };
// Fermer le dropdown quand on clique en dehors
useEffect(() => {

View file

@ -75,7 +75,7 @@ export function useAudioAnalyser(
const step = Math.floor(data.length / BAR_COUNT);
const newLevels = Array.from(
{ length: BAR_COUNT },
(_, i) => data[Math.min(i * step, data.length - 1)] / 255,
(_, i) => (data[Math.min(i * step, data.length - 1)] ?? 0) / 255,
);
setLevels(newLevels);
rafRef.current = requestAnimationFrame(update);

View file

@ -112,6 +112,7 @@ export function usePlaylistNotifications(
// Trouver la notification la plus récente
const latestNotification = playlistNotifications[0];
if (!latestNotification) return;
// Si c'est une nouvelle notification (pas encore vue)
// FE-TYPE-001: Compare IDs as strings

View file

@ -42,6 +42,7 @@ export function useTouchGestures(handlers: TouchGestureHandlers = {}) {
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
const touch = e.touches[0];
if (!touch) return;
touchStartRef.current = {
x: touch.clientX,
y: touch.clientY,
@ -79,6 +80,7 @@ export function useTouchGestures(handlers: TouchGestureHandlers = {}) {
if (!touchStartRef.current) return;
const touch = e.changedTouches[0];
if (!touch) return;
const deltaX = touch.clientX - touchStartRef.current.x;
const deltaY = touch.clientY - touchStartRef.current.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);

View file

@ -41,11 +41,11 @@ function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null {
const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0);
const maxIntensitySegment = heatmap.segments.reduce(
(max, seg) => (seg.intensity > max.intensity ? seg : max),
heatmap.segments[0],
heatmap.segments[0]!,
);
const maxSkipSegment = heatmap.segments.reduce(
(max, seg) => (seg.skip_count > max.skip_count ? seg : max),
heatmap.segments[0],
heatmap.segments[0]!,
);
return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment };
}

View file

@ -274,6 +274,7 @@ export async function retryPendingAnalytics(): Promise<number> {
for (let i = pending.length - 1; i >= 0; i--) {
const pendingEvent = pending[i];
if (!pendingEvent) continue;
// Ne pas retry les événements trop anciens (> 7 jours)
const age = Date.now() - pendingEvent.timestamp;

View file

@ -72,7 +72,7 @@ export function TrackListPaginationNav({
{showPageNumbers && (
<div className="flex items-center gap-1">
{visiblePages[0] > 1 && (
{visiblePages[0] != null && visiblePages[0] > 1 && (
<>
<button
type="button"
@ -83,7 +83,7 @@ export function TrackListPaginationNav({
>
1
</button>
{visiblePages[0] > 2 && (
{visiblePages[0] != null && visiblePages[0] > 2 && (
<span className="px-2 text-muted-foreground/90 tabular-nums">...</span>
)}
</>
@ -106,9 +106,11 @@ export function TrackListPaginationNav({
{page}
</button>
))}
{visiblePages[visiblePages.length - 1] < totalPages && (
{(() => {
const lastVisible = visiblePages[visiblePages.length - 1];
return lastVisible != null && lastVisible < totalPages && (
<>
{visiblePages[visiblePages.length - 1] < totalPages - 1 && (
{lastVisible < totalPages - 1 && (
<span className="px-2 text-muted-foreground/90 tabular-nums">...</span>
)}
<button
@ -121,7 +123,8 @@ export function TrackListPaginationNav({
{totalPages}
</button>
</>
)}
);
})()}
</div>
)}

View file

@ -117,6 +117,7 @@ export function useInfiniteScroll({
observerRef.current = new IntersectionObserver(
(entries) => {
const [entry] = entries;
if (!entry) return;
// Si l'élément sentinelle est visible et qu'on n'est pas déjà en train de charger
if (entry.isIntersecting && !isLoadingRef.current && hasMore) {
handleLoadMore();

View file

@ -0,0 +1,132 @@
/**
* GHOST FEATURE MSW Handlers
*
* These handlers mock backend endpoints that do NOT have a real backend
* implementation. They exist solely for frontend development/storybook use.
*
* Ghost features:
* - Education (courses, enrollments)
* - Gamification (achievements, leaderboard, XP)
*
* Do NOT rely on these for production. When a backend endpoint is implemented,
* move the corresponding handler to handlers.ts with proper response envelope.
*/
import { http, HttpResponse } from 'msw';
export const ghostHandlers = [
// ─── Education ─────────────────────────────────────────────────────
http.get('*/api/v1/education/courses', () => {
return HttpResponse.json([
{
id: 'course-1',
title: 'Music Production Fundamentals',
level: 'Beginner',
duration: '5h 30m',
progress: 0,
instructor: 'John Doe',
thumbnailUrl: 'https://picsum.photos/400/250',
price: 49.99,
rating: 4.8,
studentCount: 1200,
tags: ['Production', 'Beginner'],
},
{
id: 'course-2',
title: 'Advanced Mixing Techniques',
level: 'Advanced',
duration: '8h 15m',
progress: 0,
instructor: 'Jane Smith',
thumbnailUrl: 'https://picsum.photos/401/250',
price: 79.99,
rating: 4.9,
studentCount: 850,
tags: ['Mixing', 'Advanced'],
},
]);
}),
http.get('*/api/v1/education/enrollments', () => {
return HttpResponse.json([
{
id: 'enroll-1',
course_id: 'course-1',
progress: 45,
lastAccessed: '2024-01-01T12:00:00Z',
course: {
id: 'course-1',
title: 'Music Production Fundamentals',
thumbnailUrl: 'https://picsum.photos/400/250',
},
},
]);
}),
// ─── Gamification ──────────────────────────────────────────────────
http.get('*/api/v1/gamification/achievements', () => {
return HttpResponse.json({
success: true,
data: [
{
id: 'ach-1',
name: 'First Upload',
description: 'Upload your first track',
icon: '🎵',
progress: 1,
maxProgress: 1,
xpReward: 50,
category: 'creation',
unlocked: true,
},
{
id: 'ach-2',
name: 'Social Butterfly',
description: 'Follow 10 users',
icon: '🦋',
progress: 5,
maxProgress: 10,
xpReward: 100,
category: 'social',
unlocked: false,
},
],
});
}),
http.get('*/api/v1/gamification/leaderboard', () => {
return HttpResponse.json([
{
rank: 1,
userId: 'user-1',
username: 'TopProducer',
avatar: 'https://i.pravatar.cc/100?u=top',
level: 50,
xp: 125000,
trend: 0,
},
{
rank: 2,
userId: 'user-2',
username: 'BeatMaster',
avatar: 'https://i.pravatar.cc/100?u=beat',
level: 45,
xp: 98000,
trend: 1,
},
]);
}),
http.get('*/api/v1/gamification/xp/:userId', () => {
return HttpResponse.json({
success: true,
data: {
current: 4250,
next: 5000,
level: 12,
rank: 420,
totalEarned: 15400,
},
});
}),
];

View file

@ -1,4 +1,5 @@
import { http, HttpResponse } from 'msw';
import { ghostHandlers } from './handlers-ghost';
/**
* FE-API-019: MSW Mock Handlers
@ -547,122 +548,8 @@ export const handlers = [
});
}),
// GHOST FEATURE — no backend implementation exists for education
// These handlers are kept for frontend MSW development mode only
http.get('*/api/v1/education/courses', () => {
return HttpResponse.json([
{
id: 'course-1',
title: 'Music Production Fundamentals',
level: 'Beginner',
duration: '5h 30m',
progress: 0,
instructor: 'John Doe',
thumbnailUrl: 'https://picsum.photos/400/250',
price: 49.99,
rating: 4.8,
studentCount: 1200,
tags: ['Production', 'Beginner']
},
{
id: 'course-2',
title: 'Advanced Mixing Techniques',
level: 'Advanced',
duration: '8h 15m',
progress: 0,
instructor: 'Jane Smith',
thumbnailUrl: 'https://picsum.photos/401/250',
price: 79.99,
rating: 4.9,
studentCount: 850,
tags: ['Mixing', 'Advanced']
}
]);
}),
http.get('*/api/v1/education/enrollments', () => {
return HttpResponse.json([
{
id: 'enroll-1',
course_id: 'course-1',
progress: 45,
lastAccessed: '2024-01-01T12:00:00Z',
course: {
id: 'course-1',
title: 'Music Production Fundamentals',
thumbnailUrl: 'https://picsum.photos/400/250'
}
}
]);
}),
// GHOST FEATURE — no backend implementation exists for gamification
// These handlers are kept for frontend MSW development mode only
http.get('*/api/v1/gamification/achievements', () => {
return HttpResponse.json({
success: true,
data: [
{
id: 'ach-1',
name: 'First Upload',
description: 'Upload your first track',
icon: '🎵',
progress: 1,
maxProgress: 1,
xpReward: 50,
category: 'creation',
unlocked: true
},
{
id: 'ach-2',
name: 'Social Butterfly',
description: 'Follow 10 users',
icon: '🦋',
progress: 5,
maxProgress: 10,
xpReward: 100,
category: 'social',
unlocked: false
}
]
});
}),
http.get('*/api/v1/gamification/leaderboard', () => {
return HttpResponse.json([
{
rank: 1,
userId: 'user-1',
username: 'TopProducer',
avatar: 'https://i.pravatar.cc/100?u=top',
level: 50,
xp: 125000,
trend: 0
},
{
rank: 2,
userId: 'user-2',
username: 'BeatMaster',
avatar: 'https://i.pravatar.cc/100?u=beat',
level: 45,
xp: 98000,
trend: 1
}
]);
}),
http.get('*/api/v1/gamification/xp/:userId', () => {
return HttpResponse.json({
success: true,
data: {
current: 4250,
next: 5000,
level: 12,
rank: 420,
totalEarned: 15400
}
});
}),
// Ghost features (Education, Gamification) are in handlers-ghost.ts
// They are spread into this array below via ...ghostHandlers
// Developer & Webhooks endpoints
http.get('*/api/v1/webhooks', () => {
@ -1708,6 +1595,9 @@ export const handlers = [
});
}),
// Ghost feature handlers (Education, Gamification — no backend)
...ghostHandlers,
// Catch-all for API to prevent network leaks (Phase 1: Stabilization)
http.all('*/api/v1/*', ({ request }) => {
console.warn('[MSW] Intercepted unhandled API request:', request.method, request.url);

View file

@ -62,6 +62,7 @@ export function getCookie(name: string): string | null {
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i];
if (cookie == null) continue;
while (cookie.charAt(0) === ' ') {
cookie = cookie.substring(1, cookie.length);
}

View file

@ -168,6 +168,7 @@ class OfflineQueueService {
// Process requests in order (high priority first)
while (this.queue.length > 0 && !this.isOffline()) {
const request = this.queue[0];
if (!request) break;
// #region agent log
// fetch('http://127.0.0.1:7242/ingest/09c5ea5e-2380-4cc3-92aa-d26f3b3d26f6',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({location:'offlineQueue.ts:149',message:'Processing request',data:{requestId:request.id,method:request.config.method,url:request.config.url,retryCount:request.retryCount},timestamp:Date.now(),sessionId:'debug-session',runId:'run1',hypothesisId:'C'})}).catch(()=>{});
// #endregion

View file

@ -100,7 +100,7 @@ class ResponseCacheService {
for (const part of parts) {
if (part.includes('=')) {
const [key, value] = part.split('=').map((p) => p.trim());
directives[key.toLowerCase()] = value;
if (key) directives[key.toLowerCase()] = value;
} else {
directives[part.toLowerCase()] = true;
}

View file

@ -81,7 +81,7 @@ function decodeJWT(token: string): JWTPayload | null {
return null;
}
// Décoder le payload (base64url)
const payload = parts[1];
const payload = parts[1]!;
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded) as JWTPayload;
} catch (error) {

View file

@ -21,7 +21,7 @@ export function getRelativeLuminance(r: number, g: number, b: number): number {
return normalized <= 0.03928
? normalized / 12.92
: Math.pow((normalized + 0.055) / 1.055, 2.4);
});
}) as [number, number, number];
// Calculate relative luminance using WCAG formula
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;

View file

@ -70,7 +70,7 @@ export function buildCSPHeader(nonce?: string): string {
} as unknown as Record<string, string[]>;
if (nonce) {
policy['script-src'] = policy['script-src'].map((src) =>
policy['script-src'] = policy['script-src']?.map((src) =>
src === "'nonce-__CSP_NONCE__'" ? `'nonce-${nonce}'` : src,
);
}

View file

@ -122,10 +122,10 @@ export function parseDuration(duration: string): number {
if (parts.length === 2) {
// MM:SS
return parts[0] * 60 + parts[1];
return (parts[0] ?? 0) * 60 + (parts[1] ?? 0);
} else if (parts.length === 3) {
// HH:MM:SS
return parts[0] * 3600 + parts[1] * 60 + parts[2];
return (parts[0] ?? 0) * 3600 + (parts[1] ?? 0) * 60 + (parts[2] ?? 0);
}
return 0;

View file

@ -85,8 +85,9 @@ export function formatUsername(username: string): string {
export function formatEmail(email: string): string {
const [localPart, domain] = email.split('@');
if (!localPart) return email;
if (localPart.length > 3) {
return `${localPart.substring(0, 3)}***@${domain}`;
return `${localPart.substring(0, 3)}***@${domain ?? ''}`;
}
return email;
}

View file

@ -28,7 +28,7 @@
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
// "noUncheckedIndexedAccess": true, // TODO: Enable progressively - requires fixing 200+ array/object access checks
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"erasableSyntaxOnly": true,
"noUncheckedSideEffectImports": true,

View file

@ -2,6 +2,8 @@ import { defineConfig, configDefaults } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'node:url';
// Storybook test plugin — uncomment storybookTest and the browser project below
// to run story-based visual tests (requires Playwright to be installed).
// import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
const dirname =
typeof __dirname !== 'undefined'

View file

@ -12,6 +12,10 @@ import (
"strings"
"time"
// TECH DEBT: github.com/dutchcoders/go-clamd is abandoned (last commit 2017).
// TODO: Replace with a maintained alternative (e.g., github.com/baruwa-enterprise/clamd)
// or implement a minimal HTTP/TCP ClamAV client. The current dependency works
// but receives no security patches.
"github.com/dutchcoders/go-clamd"
"go.uber.org/zap"
)