veza/apps/web/src/features/playlists/services/playlistService.ts

501 lines
13 KiB
TypeScript

import { apiClient } from '@/services/api/client';
import { requireFeature } from '@/config/features';
import type {
Playlist,
CreatePlaylistRequest,
UpdatePlaylistRequest,
} from '../types';
import type { PlaylistListResponse } from '../types';
// Re-export PlaylistListResponse for use in other modules
export type { PlaylistListResponse } from '../types';
/** PlaylistError - thrown when playlist API calls fail */
export class PlaylistError extends Error {
constructor(
message: string,
public readonly cause?: unknown,
) {
super(message);
this.name = 'PlaylistError';
}
}
function getErrorMessage(e: unknown): string {
if (e && typeof e === 'object' && 'response' in e) {
const res = (e as { response?: { data?: { error?: string; message?: string } } })
.response;
if (res?.data?.error) return res.data.error;
if (res?.data?.message) return res.data.message;
}
return e instanceof Error ? e.message : String(e);
}
async function wrapPlaylistError<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
throw new PlaylistError(getErrorMessage(e), e);
}
}
// Collaborator interfaces (assuming they might be in types/collaborator but defining here if needed or using any for now if not in types.ts)
// types.ts content I saw above did NOT include Collaborator types.
// I will define them here or assume they are returned as any/object for now, or add to types.ts later.
// usePlaylist.test.tsx used { id, playlist_id, user_id, permission, user: {...} }
export interface PlaylistCollaborator {
id: string; // or number? test used number. But if we move to string... I'll use string to be safe or number if DB uses number? Tracks used string.
playlist_id: string;
user_id: string;
permission: 'read' | 'write' | 'admin';
created_at: string;
updated_at: string;
user: {
id: string;
username: string;
email: string;
avatar_url?: string;
};
}
export interface AddCollaboratorRequest {
user_id: string;
permission: 'read' | 'write' | 'admin';
}
export interface UpdateCollaboratorPermissionRequest {
permission: 'read' | 'write' | 'admin';
}
/**
* Créer une nouvelle playlist
*/
export async function createPlaylist(
data: CreatePlaylistRequest,
): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.post<{ playlist: Playlist }>(
'/playlists',
data,
);
return response.data.playlist;
});
}
/**
* Récupérer une playlist par ID
*/
export async function getPlaylist(id: string): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.get<{ playlist: Playlist }>(
`/playlists/${id}`,
);
return response.data.playlist;
});
}
/**
* Mettre à jour une playlist
*/
export async function updatePlaylist(
id: string,
data: UpdatePlaylistRequest,
): Promise<Playlist> {
return wrapPlaylistError(async () => {
const response = await apiClient.put<{ playlist: Playlist }>(
`/playlists/${id}`,
data,
);
return response.data.playlist;
});
}
/**
* Supprimer une playlist
*/
export async function deletePlaylist(id: string): Promise<void> {
return wrapPlaylistError(async () => {
await apiClient.delete(`/playlists/${id}`);
});
}
/**
* Alias for listPlaylists - backward compatibility for tests
*/
export async function getPlaylists(
userId?: number | string,
page = 1,
limit = 20,
): Promise<PlaylistListResponse> {
return wrapPlaylistError(() =>
listPlaylists(page, limit, userId?.toString()),
);
}
/**
* Alias for addTrackToPlaylist
*/
export const addTrack = addTrackToPlaylist;
/**
* Alias for removeTrackFromPlaylist
*/
export const removeTrack = removeTrackFromPlaylist;
/**
* Reorder tracks by array of track IDs (converts to track_ids for API)
*/
export async function reorderTracks(
playlistId: string,
trackIds: (string | number)[],
): Promise<void> {
await reorderPlaylistTracks(playlistId, {
track_ids: trackIds.map((id) => String(id)),
});
}
/**
* Lister les playlists
*/
export async function listPlaylists(
page = 1,
limit = 20,
userId?: string,
sortBy?: 'created_at' | 'title' | 'track_count',
sortOrder?: 'asc' | 'desc',
): Promise<PlaylistListResponse> {
// S'assurer que limit n'est jamais 0 (corrige le bug GET /api/v1/playlists?page=20&limit=0: 401)
const safeLimit = Math.max(limit, 1);
const safePage = Math.max(page, 1);
const params: Record<string, string | number> = {
page: safePage,
limit: safeLimit,
};
if (userId) {
params.user_id = userId;
}
// CRITIQUE FIX #45: Ajouter les paramètres de tri pour préparer la migration vers le tri backend
// Le backend peut ignorer ces paramètres s'il ne les supporte pas encore
if (sortBy) {
params.sort_by = sortBy;
}
if (sortOrder) {
params.sort_order = sortOrder;
}
return wrapPlaylistError(async () => {
const response = await apiClient.get<PlaylistListResponse>('/playlists', {
params,
});
return response.data;
});
}
/**
* Ajouter un collaborateur à une playlist
*
* Backend endpoint: POST /playlists/:id/collaborators
*
* @see FEATURES.PLAYLIST_COLLABORATION
*/
export async function addCollaborator(
playlistId: string,
data: AddCollaboratorRequest,
): Promise<PlaylistCollaborator> {
return wrapPlaylistError(async () => {
const response = await apiClient.post<PlaylistCollaborator>(
`/playlists/${playlistId}/collaborators`,
data,
);
return response.data;
});
}
/**
* Retirer un collaborateur
*
* Backend endpoint: DELETE /playlists/:id/collaborators/:userId
*
* @see FEATURES.PLAYLIST_COLLABORATION
*/
export async function removeCollaborator(
playlistId: string,
userId: string,
): Promise<void> {
return wrapPlaylistError(() =>
apiClient.delete(`/playlists/${playlistId}/collaborators/${userId}`),
);
}
/**
* Mettre à jour les permissions d'un collaborateur
*
* Backend endpoint: PUT /playlists/:id/collaborators/:userId
*
* @see FEATURES.PLAYLIST_COLLABORATION
*/
export async function updateCollaboratorPermission(
playlistId: string,
userId: string,
data: UpdateCollaboratorPermissionRequest,
): Promise<void> {
return wrapPlaylistError(() =>
apiClient.put(`/playlists/${playlistId}/collaborators/${userId}`, data),
);
}
export interface SearchPlaylistsParams {
q?: string;
page?: number;
limit?: number;
user_id?: string;
is_public?: boolean;
sort_by?: 'created_at' | 'title' | 'track_count';
sort_order?: 'asc' | 'desc';
}
export interface PlaylistShareLink {
share_token: string;
expires_at: string;
}
export interface ReorderTracksRequest {
track_ids: string[];
}
export interface PlaylistRecommendation {
playlist: Playlist;
score: number;
reason?: string;
}
export interface GetRecommendationsParams {
limit?: number;
min_score?: number;
include_own?: boolean;
}
/**
* Rechercher des playlists
*
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
* TODO: Enable when backend implements GET /api/v1/playlists/search
*
* @see FEATURES.PLAYLIST_SEARCH
*/
export async function searchPlaylists(
params: SearchPlaylistsParams,
): Promise<PlaylistListResponse> {
requireFeature('PLAYLIST_SEARCH');
const response = await apiClient.get<PlaylistListResponse>(
'/playlists/search',
{ params },
);
return response.data;
}
/**
* Créer un lien de partage
*
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
* TODO: Enable when backend implements POST /api/v1/playlists/:id/share
*
* @see FEATURES.PLAYLIST_SHARE
*/
export async function createShareLink(id: string): Promise<PlaylistShareLink> {
requireFeature('PLAYLIST_SHARE');
const response = await apiClient.post<{ share_link: PlaylistShareLink }>(
`/playlists/${id}/share`,
);
return response.data.share_link;
}
/**
* Réorganiser les tracks d'une playlist
*/
export async function reorderPlaylistTracks(
id: string,
data: ReorderTracksRequest,
): Promise<void> {
return wrapPlaylistError(() =>
apiClient.put(`/playlists/${id}/tracks/reorder`, data),
);
}
/**
* Retirer un track d'une playlist
*/
export async function removeTrackFromPlaylist(
playlistId: string,
trackId: string,
): Promise<void> {
return wrapPlaylistError(() =>
apiClient.delete(`/playlists/${playlistId}/tracks/${trackId}`),
);
}
/**
* Obtenir des recommandations de playlists
*
* ⚠️ MVP: This feature is disabled. Backend endpoint is not implemented.
* TODO: Enable when backend implements GET /api/v1/playlists/recommendations
*
* @see FEATURES.PLAYLIST_RECOMMENDATIONS
*/
export async function getPlaylistRecommendations(
_params: GetRecommendationsParams,
): Promise<{ recommendations: PlaylistRecommendation[] }> {
requireFeature('PLAYLIST_RECOMMENDATIONS');
// TODO: Replace with actual API call when backend is ready
// const response = await apiClient.get<{ recommendations: PlaylistRecommendation[] }>('/playlists/recommendations', { params });
// return response.data;
// Mock response for now to satisfy type checker and frontend dev
return Promise.resolve({
recommendations: [],
});
}
/**
* Récupérer les collaborateurs
*/
/**
* Récupérer les collaborateurs d'une playlist
*
* Backend endpoint: GET /playlists/:id/collaborators
*/
export async function getCollaborators(
playlistId: string,
): Promise<PlaylistCollaborator[]> {
return wrapPlaylistError(async () => {
const response = await apiClient.get<{
collaborators: PlaylistCollaborator[];
}>(`/playlists/${playlistId}/collaborators`);
return response.data.collaborators || [];
});
}
/**
* Ajouter un track à une playlist
*/
export async function addTrackToPlaylist(
playlistId: string,
trackId: string,
): Promise<void> {
return wrapPlaylistError(() =>
apiClient.post(`/playlists/${playlistId}/tracks`, {
track_id: trackId,
}),
);
}
/**
* FE-COMP-017: Follow/Unfollow playlist functions
*/
export interface FollowPlaylistResponse {
message: string;
is_following: boolean;
}
/**
* Suivre une playlist
*/
export async function followPlaylist(
playlistId: string,
): Promise<FollowPlaylistResponse> {
const response = await apiClient.post(`/playlists/${playlistId}/follow`);
return {
message: response.data.message || 'Playlist followed',
is_following: true,
};
}
/**
* Ne plus suivre une playlist
*/
export async function unfollowPlaylist(
playlistId: string,
): Promise<FollowPlaylistResponse> {
const response = await apiClient.delete(`/playlists/${playlistId}/follow`);
return {
message: response.data.message || 'Playlist unfollowed',
is_following: false,
};
}
/**
* Vérifier si l'utilisateur suit une playlist
* Note: This uses the playlist data which may include is_following and follower_count
*/
export async function getPlaylistFollowStatus(
playlistId: string,
): Promise<{ is_following: boolean; follower_count: number }> {
// For now, we'll use the playlist data which may include follow status
// In the future, this could use a dedicated endpoint
const playlist = await getPlaylist(playlistId);
return {
is_following: (playlist as any).is_following ?? false,
follower_count: (playlist as any).follower_count ?? 0,
};
}
// ─── Backward-compatible object export ──────────────────────────────────────
// Allows consumers to use `playlistService.list(...)` syntax without changing call sites.
// Import: `import { playlistService } from '@/features/playlists/services/playlistService'`
export const playlistService = {
list: async (params?: {
page?: number;
limit?: number;
user_id?: string;
search?: string;
}) => {
const result = await listPlaylists(
params?.page ?? 1,
params?.limit ?? 20,
params?.user_id,
);
const items =
(result as any).list ||
(result as any).items ||
(result as any).playlists ||
[];
return {
playlists: items,
pagination: (result as any).pagination || {
total: (result as any).total || items.length,
page: params?.page || 1,
limit: params?.limit || 20,
total_pages: (result as any).total_pages || 1,
},
};
},
get: getPlaylist,
create: createPlaylist,
update: updatePlaylist,
delete: deletePlaylist,
addTrack: addTrackToPlaylist,
removeTrack: removeTrackFromPlaylist,
reorderTracks: async (playlistId: string, trackIds: string[]) => {
await reorderPlaylistTracks(playlistId, { track_ids: trackIds });
},
getRecommendations: async () => {
const result = await getPlaylistRecommendations({});
return { playlists: result.recommendations.map((r) => r.playlist) };
},
addCollaborator: async (
playlistId: string,
userId: string,
role: string = 'write',
) => {
return addCollaborator(playlistId, {
user_id: userId,
permission: role as 'read' | 'write' | 'admin',
});
},
getCollaborators,
removeCollaborator,
search: searchPlaylists,
createShareLink,
};