Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
{
"allowArrowFunctions": false
}
]
],
"typescript/no-explicit-any": "error"
},
"env": {
"es2024": true
Expand Down
2 changes: 1 addition & 1 deletion src/components/router/EditionRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { ScheduleTab } from "@/pages/EditionView/tabs/ScheduleTab";

interface EditionRoutesProps {
basePath: string;
WrapperComponent?: React.ComponentType<any>;
WrapperComponent?: React.ComponentType<{ component: React.ComponentType }>;
}

export function createEditionRoutes({
Expand Down
6 changes: 4 additions & 2 deletions src/components/router/GlobalRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import PrivacyPolicy from "@/pages/legal/PrivacyPolicy";
import TermsOfService from "@/pages/legal/TermsOfService";
import NotFound from "@/pages/NotFound";
import { AdminRolesTable } from "@/pages/admin/Roles/AdminRolesTable";
import { ArtistsManagement } from "@/pages/admin/ArtistsManagement/ArtistsManagement";
import { DuplicateArtistsPage } from "@/pages/admin/ArtistsManagement/DuplicateArtistsPage";
import { ArtistBulkEditor } from "@/pages/admin/ArtistsManagement/ArtistBulkEditor";

export function GlobalRoutes() {
return (
Expand All @@ -26,7 +27,8 @@ export function GlobalRoutes() {
{/* Admin routes */}
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="artists" />} />
<Route path="artists" element={<ArtistsManagement />} />
<Route path="artists" element={<ArtistBulkEditor />} />
<Route path="artists/duplicates" element={<DuplicateArtistsPage />} />
<Route path="analytics" element={<AdminAnalytics />} />
<Route path="admins" element={<AdminRolesTable />} />
<Route path="festivals" element={<AdminFestivals />}>
Expand Down
44 changes: 44 additions & 0 deletions src/hooks/queries/artists/useBulkArchiveArtists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { supabase } from "@/integrations/supabase/client";
import { artistsKeys } from "./useArtists";

async function bulkArchiveArtists(artistIds: string[]) {
const { error } = await supabase
.from("artists")
.update({ archived: true })
.in("id", artistIds);

if (error) {
console.error("Error bulk archiving artists:", error);
throw new Error("Failed to archive artists");
}

return artistIds;
}

export function useBulkArchiveArtistsMutation() {
const queryClient = useQueryClient();
const { toast } = useToast();

return useMutation({
mutationFn: bulkArchiveArtists,
onSuccess: (results) => {
queryClient.invalidateQueries({
queryKey: artistsKeys.all,
});

toast({
title: "Bulk Archive Complete",
description: `Successfully archived ${results.length} artist(s).`,
});
},
onError: (error) => {
toast({
title: "Bulk Archive Error",
description: error?.message || "Failed to archive artists",
variant: "destructive",
});
},
});
}
272 changes: 272 additions & 0 deletions src/hooks/queries/artists/useBulkMergeArtists.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/integrations/supabase/client";
import { Artist, artistsKeys } from "./useArtists";
import {
duplicateArtistsKeys,
type DuplicateGroup,
} from "./useDuplicateArtists";

export interface BulkMergeParams {
groups: DuplicateGroup[];
strategy: "smart" | "first" | "newest" | "oldest";
}

interface MergeProgress {
total: number;
completed: number;
current?: string;
errors: Array<{ group: string; error: string }>;
}

async function bulkMergeArtists(
params: BulkMergeParams,
onProgress?: (progress: MergeProgress) => void,
) {
const { groups, strategy } = params;
const progress: MergeProgress = {
total: groups.length,
completed: 0,
errors: [],
};

for (const group of groups) {
try {
progress.current = group.name;
onProgress?.(progress);

const { primaryArtist, mergeData } = getSmartMergeData(group, strategy);
const duplicateIds = group.artists
.filter((a) => a.id !== primaryArtist.id)
.map((a) => a.id);

await performSingleMerge(primaryArtist.id, duplicateIds, mergeData);

progress.completed++;
onProgress?.(progress);
} catch (error) {
progress.errors.push({
group: group.name,
error: error instanceof Error ? error.message : "Unknown error",
});
progress.completed++;
onProgress?.(progress);
}
}

return progress;
}

function getSmartMergeData(
group: DuplicateGroup,
strategy: BulkMergeParams["strategy"],
) {
let primaryArtist = group.artists[0];

// Choose primary artist based on strategy
switch (strategy) {
case "newest":
primaryArtist = group.artists.reduce((newest, current) =>
new Date(current.created_at) > new Date(newest.created_at)
? current
: newest,
);
break;
case "oldest":
primaryArtist = group.artists.reduce((oldest, current) =>
new Date(current.created_at) < new Date(oldest.created_at)
? current
: oldest,
);
break;
case "smart":
// Choose the one with most complete data as the base
primaryArtist = group.artists.reduce((best, current) => {
const currentScore = getCompletenessScore(current);
const bestScore = getCompletenessScore(best);
return currentScore > bestScore ? current : best;
});
break;
case "first":
default:
primaryArtist = group.artists[0];
break;
}

// Smart merge: start with primary artist's data, then enhance with missing data from others
const mergeData = {
name: group.name,
description: smartMergeDescription(primaryArtist, group.artists),
spotify_url: smartMergeUrl(primaryArtist, group.artists, "spotify_url"),
soundcloud_url: smartMergeUrl(
primaryArtist,
group.artists,
"soundcloud_url",
),
genreIds: getAllGenres(group.artists),
};

return { primaryArtist, mergeData };
}

function getCompletenessScore(artist: Artist): number {
let score = 0;
if (artist.description) score += 3;
if (artist.spotify_url) score += 2;
if (artist.soundcloud_url) score += 2;
if (artist.artist_music_genres?.length) score += 1;
return score;
}

function smartMergeDescription(
primaryArtist: Artist,
allArtists: Artist[],
): string | null {
// If primary artist has description, use it (it was chosen for having most complete data)
if (primaryArtist.description) {
return primaryArtist.description;
}

// Otherwise, find the best description from any duplicate
return (
allArtists
.map((a) => a.description)
.filter((a): a is string => Boolean(a))
.sort((a, b) => b.length - a.length)[0] || null
);
}

function smartMergeUrl(
primaryArtist: Artist,
allArtists: Artist[],
field: keyof Pick<Artist, "spotify_url" | "soundcloud_url">,
): string | null {
// If primary artist has this URL, use it
if (primaryArtist[field]) {
return primaryArtist[field];
}

// Otherwise, find this URL from any duplicate
return allArtists.map((a) => a[field]).filter(Boolean)[0] || null;
}

function getAllGenres(artists: Artist[]): string[] {
const allGenres = new Set<string>();
artists.forEach((artist) => {
if (artist.artist_music_genres) {
artist.artist_music_genres.forEach((genre) => {
allGenres.add(genre.music_genre_id);
});
}
});
return Array.from(allGenres);
}

async function performSingleMerge(
primaryArtistId: string,
duplicateArtistIds: string[],
mergeData: {
name: string;
description: string | null;
spotify_url: string | null;
soundcloud_url: string | null;
genreIds: string[];
},
) {
// Update the primary artist with merged data
const { error: updateError } = await supabase
.from("artists")
.update({
name: mergeData.name,
description: mergeData.description,
spotify_url: mergeData.spotify_url,
soundcloud_url: mergeData.soundcloud_url,
updated_at: new Date().toISOString(),
})
.eq("id", primaryArtistId);

if (updateError) {
throw new Error(`Failed to update primary artist: ${updateError.message}`);
}

// Update genres
if (mergeData.genreIds.length > 0) {
const { error: deleteGenresError } = await supabase
.from("artist_music_genres")
.delete()
.eq("artist_id", primaryArtistId);

if (deleteGenresError) {
throw new Error(
`Failed to remove existing genres: ${deleteGenresError.message}`,
);
}

const genreInserts = mergeData.genreIds.map((genreId: string) => ({
artist_id: primaryArtistId,
music_genre_id: genreId,
}));

const { error: insertGenresError } = await supabase
.from("artist_music_genres")
.insert(genreInserts);

if (insertGenresError) {
throw new Error(`Failed to add new genres: ${insertGenresError.message}`);
}
}

// Transfer data for each duplicate
for (const duplicateId of duplicateArtistIds) {
// Transfer set associations
const { error: updateSetArtistsError } = await supabase
.from("set_artists")
.update({ artist_id: primaryArtistId })
.eq("artist_id", duplicateId);

if (updateSetArtistsError && updateSetArtistsError.code === "23505") {
// Conflict: delete duplicate associations
await supabase.from("set_artists").delete().eq("artist_id", duplicateId);
} else if (updateSetArtistsError) {
throw new Error(
`Failed to transfer sets: ${updateSetArtistsError.message}`,
);
}

// Transfer notes (ignore if table doesn't exist)
await supabase
.from("artist_notes")
.update({
artist_id: primaryArtistId,
updated_at: new Date().toISOString(),
})
.eq("artist_id", duplicateId);

// Delete duplicate artist
const { error: deleteError } = await supabase
.from("artists")
.delete()
.eq("id", duplicateId);

if (deleteError) {
throw new Error(`Failed to delete duplicate: ${deleteError.message}`);
}
}
}

export function useBulkMergeArtistsMutation() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: ({
params,
onProgress,
}: {
params: BulkMergeParams;
onProgress?: (progress: MergeProgress) => void;
}) => bulkMergeArtists(params, onProgress),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: artistsKeys.all });
queryClient.invalidateQueries({ queryKey: duplicateArtistsKeys.all });
},
});
}
Loading
Loading