[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:
parent
5da916f3b3
commit
b406d8a1a1
10 changed files with 371 additions and 24 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ export interface PlaylistAnalyticsData {
|
|||
}
|
||||
|
||||
interface PlaylistAnalyticsProps {
|
||||
playlistId: number;
|
||||
// FE-TYPE-001: IDs are strings (UUIDs), not numbers
|
||||
playlistId: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
177
apps/web/src/utils/idNormalization.test.ts
Normal file
177
apps/web/src/utils/idNormalization.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
159
apps/web/src/utils/idNormalization.ts
Normal file
159
apps/web/src/utils/idNormalization.ts
Normal 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;
|
||||
}
|
||||
|
||||
Loading…
Reference in a new issue