Files
glm-hub/src/repository.ts
T

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);
};
}