271 lines
8.7 KiB
TypeScript
271 lines
8.7 KiB
TypeScript
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<string> {
|
|
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<void> {
|
|
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<HubApp[]> {
|
|
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<string[]> {
|
|
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<string[]> {
|
|
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<HubData> {
|
|
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<HubApp> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<number> {
|
|
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);
|
|
};
|
|
}
|