import { supabase } from "./supabase"; import type { AppCategory, AppVisibility, HubApp, HubData, HubUser } from "./types"; const APPS_TABLE = "glm_hub_apps"; const FAVORITES_TABLE = "glm_hub_favorites"; const RECENTS_TABLE = "glm_hub_recent_apps"; const ICON_BUCKET = "glm-hub-icons"; const LOCAL_MIGRATION_MARKER = "glm-hub:supabase-catalog-migrated:v1"; interface HubAppRow { id: string; name: string; description: string; category: AppCategory; url: string | null; icon_url: string; visibility: AppVisibility; created_at: string; updated_at: string; } export interface SaveHubAppInput { name: string; description: string; category: AppCategory; url: string; iconDataUrl: string; visibility: AppVisibility; } function rowToApp(row: HubAppRow): HubApp { return { id: row.id, name: row.name, description: row.description, category: row.category, keywords: [], url: row.url ?? "", mark: "", accent: "#4F758B", iconDataUrl: row.icon_url, visibility: row.visibility, createdAt: row.created_at, updatedAt: row.updated_at, }; } function dataUrlToBlob(dataUrl: string): { blob: Blob; mimeType: string; extension: string } { const match = /^data:(image\/(?:png|jpeg|webp));base64,([a-zA-Z0-9+/=]+)$/.exec(dataUrl); if (!match) throw new Error("El ícono no tiene un formato válido."); const mimeType = match[1]; const binary = atob(match[2]); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); return { blob: new Blob([bytes], { type: mimeType }), mimeType, extension: mimeType === "image/jpeg" ? "jpg" : mimeType.split("/")[1], }; } function storagePathFromPublicUrl(url: string): string | null { try { const parsed = new URL(url); const marker = `/storage/v1/object/public/${ICON_BUCKET}/`; const markerIndex = parsed.pathname.indexOf(marker); if (markerIndex < 0) return null; return decodeURIComponent(parsed.pathname.slice(markerIndex + marker.length)); } catch { return null; } } async function uploadIcon(appId: string, iconDataUrl: string): Promise { if (!iconDataUrl.startsWith("data:image/")) return iconDataUrl; const { blob, mimeType, extension } = dataUrlToBlob(iconDataUrl); const path = `apps/${appId}/${Date.now()}-${crypto.randomUUID()}.${extension}`; const { error: uploadError } = await supabase.storage .from(ICON_BUCKET) .upload(path, blob, { cacheControl: "31536000", contentType: mimeType, upsert: false }); if (uploadError) throw new Error(`No se pudo subir el ícono a Supabase: ${uploadError.message}`); const { data } = supabase.storage.from(ICON_BUCKET).getPublicUrl(path); const publicUrl = data?.publicUrl as string | undefined; if (!publicUrl) throw new Error("Supabase no devolvió la URL pública del ícono."); return publicUrl; } async function removeStoredIcon(url: string): Promise { const path = storagePathFromPublicUrl(url); if (!path) return; const { error } = await supabase.storage.from(ICON_BUCKET).remove([path]); if (error) console.warn("No se pudo retirar el ícono anterior de Storage:", error); } export async function fetchHubApps(): Promise { const { data, error } = await supabase .from(APPS_TABLE) .select("id,name,description,category,url,icon_url,visibility,created_at,updated_at") .order("name", { ascending: true }); if (error) throw new Error(`No se pudo cargar el catálogo compartido: ${error.message}`); return ((data ?? []) as HubAppRow[]).map(rowToApp); } async function fetchFavoriteIds(userId: string): Promise { const { data, error } = await supabase .from(FAVORITES_TABLE) .select("app_id,created_at") .eq("user_id", userId) .order("created_at", { ascending: false }); if (error) throw new Error(`No se pudieron cargar los favoritos: ${error.message}`); return (data ?? []).map((row: { app_id: string }) => row.app_id); } async function fetchRecentIds(userId: string): Promise { const { data, error } = await supabase .from(RECENTS_TABLE) .select("app_id,last_opened_at") .eq("user_id", userId) .order("last_opened_at", { ascending: false }) .limit(30); if (error) throw new Error(`No se pudieron cargar los accesos recientes: ${error.message}`); return (data ?? []).map((row: { app_id: string }) => row.app_id); } export async function fetchHubData(userId: string): Promise { const [apps, favorites, recents] = await Promise.all([ fetchHubApps(), fetchFavoriteIds(userId), fetchRecentIds(userId), ]); return { schemaVersion: 1, apps, favoritesByUser: { [userId]: favorites }, recentByUser: { [userId]: recents }, updatedAt: new Date().toISOString(), }; } export async function saveHubApp( input: SaveHubAppInput, existing: HubApp | null, userId: string, ): Promise { const appId = existing?.id ?? crypto.randomUUID(); const iconUrl = await uploadIcon(appId, input.iconDataUrl); const payload = { name: input.name, description: input.description, category: input.category, url: input.url || null, icon_url: iconUrl, visibility: input.visibility, updated_by: userId, }; let result: { data: unknown; error: { message: string } | null }; if (existing) { result = await supabase .from(APPS_TABLE) .update(payload) .eq("id", appId) .select("id,name,description,category,url,icon_url,visibility,created_at,updated_at") .single(); } else { result = await supabase .from(APPS_TABLE) .insert({ id: appId, ...payload, created_by: userId }) .select("id,name,description,category,url,icon_url,visibility,created_at,updated_at") .single(); } if (result.error) { if (iconUrl !== input.iconDataUrl) await removeStoredIcon(iconUrl); throw new Error(`No se pudo guardar la aplicación: ${result.error.message}`); } if (existing && existing.iconDataUrl !== iconUrl) await removeStoredIcon(existing.iconDataUrl); return rowToApp(result.data as HubAppRow); } export async function deleteHubApp(app: HubApp): Promise { const { error } = await supabase.from(APPS_TABLE).delete().eq("id", app.id); if (error) throw new Error(`No se pudo eliminar la aplicación: ${error.message}`); await removeStoredIcon(app.iconDataUrl); } export async function setFavorite(userId: string, appId: string, favorite: boolean): Promise { if (favorite) { const { error } = await supabase .from(FAVORITES_TABLE) .upsert({ user_id: userId, app_id: appId }, { onConflict: "user_id,app_id" }); if (error) throw new Error(`No se pudo guardar el favorito: ${error.message}`); return; } const { error } = await supabase .from(FAVORITES_TABLE) .delete() .eq("user_id", userId) .eq("app_id", appId); if (error) throw new Error(`No se pudo quitar el favorito: ${error.message}`); } export async function recordRecent(userId: string, appId: string): Promise { const { error } = await supabase .from(RECENTS_TABLE) .upsert( { user_id: userId, app_id: appId, last_opened_at: new Date().toISOString() }, { onConflict: "user_id,app_id" }, ); if (error) throw new Error(`No se pudo registrar el acceso reciente: ${error.message}`); } export async function migrateLocalAppsIfNeeded(user: HubUser, localApps: HubApp[]): Promise { if (user.role !== "admin" || localApps.length === 0) return 0; if (localStorage.getItem(LOCAL_MIGRATION_MARKER) === "completed") return 0; const remoteApps = await fetchHubApps(); const remoteNames = new Set(remoteApps.map((app) => app.name.trim().toLocaleLowerCase("es-DO"))); const missingApps = localApps.filter((app) => { const normalizedName = app.name.trim().toLocaleLowerCase("es-DO"); return Boolean(app.iconDataUrl.trim()) && !remoteNames.has(normalizedName); }); let migratedCount = 0; for (const app of missingApps) { await saveHubApp( { name: app.name, description: app.description, category: app.category, url: app.url, iconDataUrl: app.iconDataUrl, visibility: app.visibility, }, null, user.id, ); remoteNames.add(app.name.trim().toLocaleLowerCase("es-DO")); migratedCount += 1; } localStorage.setItem(LOCAL_MIGRATION_MARKER, "completed"); return migratedCount; } export function subscribeToHubApps(onChange: () => void): () => void { const channel = supabase .channel("glm-hub-apps-shared") .on( "postgres_changes", { event: "*", schema: "public", table: APPS_TABLE }, () => onChange(), ) .subscribe(); return () => { void supabase.removeChannel(channel); }; }