[FE-TYPE-001] fe-type: Fix all ID type mismatches

- Created ID normalization utility (idNormalization.ts) with:
  * normalizeId: Convert IDs to strings (handles number/string/null)
  * normalizeObjectIds: Recursively normalize IDs in objects
  * normalizeArrayIds: Normalize IDs in arrays of objects
  * Type guards for ID validation
- Updated stores/chat.ts to use normalization instead of manual String() conversions
- Fixed type definitions:
  * PlaylistAnalytics: playlistId number -> string
  * ImportPlaylistButton: playlistId number -> string
  * ExportPlaylistButton: playlistId number -> string
  * usePlaylistNotifications: lastNotificationId number -> string
- Removed unnecessary String() conversions in comparisons
- Comprehensive test suite (20 tests, all passing)
- Ensures all IDs are consistently strings (UUIDs) throughout the app
This commit is contained in:
senke 2025-12-25 14:27:28 +01:00
parent 5da916f3b3
commit b406d8a1a1
10 changed files with 371 additions and 24 deletions

View file

@ -9122,7 +9122,7 @@
"description": "Ensure all IDs are string (UUID) not number",
"owner": "frontend",
"estimated_hours": 3,
"status": "todo",
"status": "completed",
"files_involved": [],
"implementation_steps": [
{
@ -9143,7 +9143,8 @@
"Unit tests",
"Integration tests"
],
"notes": ""
"notes": "Fixed all ID type mismatches. Created idNormalization utility with functions to normalize IDs to strings consistently. Updated stores/chat.ts to use normalization. Fixed type definitions in PlaylistAnalytics, ImportPlaylistButton, ExportPlaylistButton (changed playlistId from number to string). Fixed usePlaylistNotifications to use string IDs. Removed unnecessary String() conversions. All tests passing (20/20).",
"completed_at": "2025-12-25T14:27:28.057714Z"
},
{
"id": "FE-TYPE-002",

View file

@ -11,7 +11,8 @@ export const ChatMessageComponent: React.FC<ChatMessageProps> = ({
message,
}) => {
const { user } = useAuthStore();
const isMe = String(user?.id) === String(message.sender_id);
// FE-TYPE-001: IDs are already strings, no conversion needed
const isMe = user?.id === message.sender_id;
return (
<div

View file

@ -12,7 +12,8 @@ import { useToast } from '@/hooks/useToast';
import { TokenStorage } from '@/services/tokenStorage';
interface ExportPlaylistButtonProps {
playlistId: number;
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
playlistId: string;
playlistTitle?: string;
}

View file

@ -10,7 +10,8 @@ import { TokenStorage } from '@/services/tokenStorage';
import { useNavigate } from 'react-router-dom';
interface ImportPlaylistButtonProps {
onImported?: (playlistId: number) => void;
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
onImported?: (playlistId: string) => void;
className?: string;
}

View file

@ -30,7 +30,8 @@ export interface PlaylistAnalyticsData {
}
interface PlaylistAnalyticsProps {
playlistId: number;
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
playlistId: string;
className?: string;
}

View file

@ -69,7 +69,8 @@ export function usePlaylistNotifications(
const queryClient = useQueryClient();
const { toast } = useToast();
const [lastNotificationId, setLastNotificationId] = useState<number | null>(
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
const [lastNotificationId, setLastNotificationId] = useState<string | null>(
null,
);
@ -111,9 +112,10 @@ export function usePlaylistNotifications(
const latestNotification = playlistNotifications[0];
// Si c'est une nouvelle notification (pas encore vue)
// FE-TYPE-001: Compare IDs as strings
if (
lastNotificationId === null ||
latestNotification.id > lastNotificationId
latestNotification.id !== lastNotificationId
) {
setLastNotificationId(latestNotification.id);

View file

@ -18,7 +18,8 @@ export function usePlaylistPermissions(playlist?: Playlist) {
};
}
const isOwner = String(playlist.user_id) === String(user.id);
// FE-TYPE-001: IDs are already strings, no conversion needed
const isOwner = playlist.user_id === user.id;
// Add logic for collaborators if/when implemented
return {

View file

@ -1,6 +1,7 @@
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { wsService } from '@/services/websocket';
import { normalizeObjectIds } from '@/utils/idNormalization';
import type { ChatMessage, Conversation, ChatWebSocketEvent } from '@/types';
interface ChatState {
@ -249,15 +250,17 @@ export const useChatStore = create<ChatState & ChatActions>()(
const response = await apiClient.get<{ conversations: Conversation[] }>('/conversations');
// apiClient unwrap déjà le format { success, data }
const data = response.data;
const conversations = (data.conversations || []).map((conv: any) => ({
...conv,
id: String(conv.id),
name: conv.name || `Conversation ${conv.id}`,
participants: (conv.participants || []).map((p: any) =>
String(typeof p === 'string' ? p : p.id || p),
),
created_by: conv.created_by ? String(conv.created_by) : undefined,
}));
// FE-TYPE-001: Normalize all IDs to strings
const conversations = (data.conversations || []).map((conv: any) => {
const normalized = normalizeObjectIds(conv);
return {
...normalized,
name: normalized.name || `Conversation ${normalized.id}`,
participants: (normalized.participants || []).map((p: any) =>
typeof p === 'string' ? p : normalizeObjectIds(p).id || String(p),
),
};
});
set({ conversations });
} catch (error) {
console.error('Error fetching conversations:', error);
@ -276,14 +279,14 @@ export const useChatStore = create<ChatState & ChatActions>()(
});
// apiClient unwrap déjà le format { success, data }
const conv = response.data;
// FE-TYPE-001: Normalize all IDs to strings
const normalized = normalizeObjectIds(conv);
const conversation: Conversation = {
...conv,
id: String(conv.id),
name: conv.name || `Conversation ${conv.id}`,
participants: (conv.participants || []).map((p: any) =>
String(typeof p === 'string' ? p : p.id || p),
...normalized,
name: normalized.name || `Conversation ${normalized.id}`,
participants: (normalized.participants || []).map((p: any) =>
typeof p === 'string' ? p : normalizeObjectIds(p).id || String(p),
),
created_by: conv.created_by ? String(conv.created_by) : undefined,
};
// Ajouter la nouvelle conversation à la liste

View file

@ -0,0 +1,177 @@
/**
* Tests for ID Normalization
* FE-TYPE-001: Test ID normalization utilities
*/
import { describe, it, expect } from 'vitest';
import {
normalizeId,
normalizeIdRequired,
normalizeIds,
normalizeObjectIds,
normalizeArrayIds,
isValidId,
isValidStringId,
} from './idNormalization';
describe('idNormalization', () => {
describe('normalizeId', () => {
it('should return string ID as-is', () => {
expect(normalizeId('abc-123')).toBe('abc-123');
expect(normalizeId('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567-e89b-12d3-a456-426614174000');
});
it('should convert number ID to string', () => {
expect(normalizeId(123)).toBe('123');
expect(normalizeId(0)).toBe('0');
});
it('should return undefined for null or undefined', () => {
expect(normalizeId(null)).toBeUndefined();
expect(normalizeId(undefined)).toBeUndefined();
});
});
describe('normalizeIdRequired', () => {
it('should return string ID as-is', () => {
expect(normalizeIdRequired('abc-123')).toBe('abc-123');
});
it('should convert number ID to string', () => {
expect(normalizeIdRequired(123)).toBe('123');
});
it('should throw error for null or undefined', () => {
expect(() => normalizeIdRequired(null)).toThrow('ID is required');
expect(() => normalizeIdRequired(undefined)).toThrow('ID is required');
});
});
describe('normalizeIds', () => {
it('should normalize array of IDs', () => {
expect(normalizeIds([1, 2, 'abc'])).toEqual(['1', '2', 'abc']);
});
it('should filter out null and undefined', () => {
expect(normalizeIds([1, null, 'abc', undefined])).toEqual(['1', 'abc']);
});
});
describe('normalizeObjectIds', () => {
it('should normalize ID fields in object', () => {
const obj = {
id: 123,
user_id: 456,
name: 'test',
};
const normalized = normalizeObjectIds(obj);
expect(normalized.id).toBe('123');
expect(normalized.user_id).toBe('456');
expect(normalized.name).toBe('test');
});
it('should normalize nested objects', () => {
const obj = {
id: 123,
user: {
id: 456,
name: 'John',
},
};
const normalized = normalizeObjectIds(obj);
expect(normalized.id).toBe('123');
expect(normalized.user.id).toBe('456');
expect(normalized.user.name).toBe('John');
});
it('should normalize arrays of objects', () => {
const obj = {
id: 123,
items: [
{ id: 1, name: 'item1' },
{ id: 2, name: 'item2' },
],
};
const normalized = normalizeObjectIds(obj);
expect(normalized.id).toBe('123');
expect(normalized.items[0].id).toBe('1');
expect(normalized.items[1].id).toBe('2');
});
it('should handle string IDs without conversion', () => {
const obj = {
id: 'abc-123',
user_id: 'def-456',
};
const normalized = normalizeObjectIds(obj);
expect(normalized.id).toBe('abc-123');
expect(normalized.user_id).toBe('def-456');
});
it('should normalize custom ID fields', () => {
const obj = {
track_id: 123,
playlist_id: 456,
};
const normalized = normalizeObjectIds(obj, ['track_id', 'playlist_id']);
expect(normalized.track_id).toBe('123');
expect(normalized.playlist_id).toBe('456');
});
});
describe('normalizeArrayIds', () => {
it('should normalize IDs in array of objects', () => {
const items = [
{ id: 1, name: 'item1' },
{ id: 2, name: 'item2' },
];
const normalized = normalizeArrayIds(items);
expect(normalized[0].id).toBe('1');
expect(normalized[1].id).toBe('2');
});
});
describe('isValidId', () => {
it('should return true for string IDs', () => {
expect(isValidId('abc-123')).toBe(true);
});
it('should return true for number IDs', () => {
expect(isValidId(123)).toBe(true);
});
it('should return false for invalid IDs', () => {
expect(isValidId(null)).toBe(false);
expect(isValidId(undefined)).toBe(false);
expect(isValidId({})).toBe(false);
});
});
describe('isValidStringId', () => {
it('should return true for non-empty string IDs', () => {
expect(isValidStringId('abc-123')).toBe(true);
});
it('should return false for empty strings', () => {
expect(isValidStringId('')).toBe(false);
});
it('should return false for non-string values', () => {
expect(isValidStringId(123)).toBe(false);
expect(isValidStringId(null)).toBe(false);
});
});
});

View file

@ -0,0 +1,159 @@
/**
* ID Normalization Utilities
* FE-TYPE-001: Ensure all IDs are string (UUID) not number
*
* Provides utilities to normalize IDs to strings (UUIDs) consistently
* across the application, preventing type mismatches.
*/
/**
* Normalize an ID to a string
* Handles both string and number IDs, converting them to strings
*
* @param id - ID value (string, number, or undefined/null)
* @returns Normalized string ID or undefined if input is null/undefined
*
* @example
* ```typescript
* normalizeId(123) // "123"
* normalizeId("abc-123") // "abc-123"
* normalizeId(null) // undefined
* ```
*/
export function normalizeId(id: string | number | null | undefined): string | undefined {
if (id === null || id === undefined) {
return undefined;
}
if (typeof id === 'string') {
return id;
}
if (typeof id === 'number') {
return String(id);
}
// Fallback for any other type
return String(id);
}
/**
* Normalize an ID to a string (required)
* Throws an error if ID is null or undefined
*
* @param id - ID value (string, number, or undefined/null)
* @returns Normalized string ID
* @throws Error if id is null or undefined
*
* @example
* ```typescript
* normalizeIdRequired(123) // "123"
* normalizeIdRequired("abc-123") // "abc-123"
* normalizeIdRequired(null) // throws Error
* ```
*/
export function normalizeIdRequired(id: string | number | null | undefined): string {
const normalized = normalizeId(id);
if (normalized === undefined) {
throw new Error('ID is required but was null or undefined');
}
return normalized;
}
/**
* Normalize an array of IDs to strings
*
* @param ids - Array of ID values
* @returns Array of normalized string IDs
*
* @example
* ```typescript
* normalizeIds([1, 2, "abc"]) // ["1", "2", "abc"]
* ```
*/
export function normalizeIds(ids: (string | number | null | undefined)[]): string[] {
return ids
.map((id) => normalizeId(id))
.filter((id): id is string => id !== undefined);
}
/**
* Normalize IDs in an object
* Recursively normalizes all fields that match common ID field names
*
* @param obj - Object to normalize
* @param idFields - Optional array of field names to normalize (default: common ID fields)
* @returns New object with normalized IDs
*
* @example
* ```typescript
* normalizeObjectIds({ id: 123, user_id: 456 })
* // { id: "123", user_id: "456" }
* ```
*/
export function normalizeObjectIds<T extends Record<string, any>>(
obj: T,
idFields: string[] = ['id', 'user_id', 'track_id', 'playlist_id', 'conversation_id', 'message_id', 'sender_id', 'creator_id', 'created_by', 'parent_id', 'parent_message_id'],
): T {
if (!obj || typeof obj !== 'object') {
return obj;
}
const normalized = { ...obj } as Record<string, any>;
for (const [key, value] of Object.entries(normalized)) {
// Normalize ID fields
if (idFields.includes(key)) {
normalized[key] = normalizeId(value);
}
// Recursively normalize nested objects
else if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
normalized[key] = normalizeObjectIds(value, idFields);
}
// Normalize arrays of objects
else if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'object') {
normalized[key] = value.map((item) =>
typeof item === 'object' && item !== null
? normalizeObjectIds(item, idFields)
: item
);
}
}
return normalized as T;
}
/**
* Normalize IDs in an array of objects
*
* @param items - Array of objects to normalize
* @param idFields - Optional array of field names to normalize
* @returns Array of objects with normalized IDs
*
* @example
* ```typescript
* normalizeArrayIds([{ id: 1 }, { id: 2 }])
* // [{ id: "1" }, { id: "2" }]
* ```
*/
export function normalizeArrayIds<T extends Record<string, any>>(
items: T[],
idFields?: string[],
): T[] {
return items.map((item) => normalizeObjectIds(item, idFields));
}
/**
* Type guard to check if a value is a valid ID (string or number)
*/
export function isValidId(id: unknown): id is string | number {
return typeof id === 'string' || typeof id === 'number';
}
/**
* Type guard to check if a value is a valid string ID
*/
export function isValidStringId(id: unknown): id is string {
return typeof id === 'string' && id.length > 0;
}