diff --git a/src/data/api/files.api.ts b/src/data/api/files.api.ts index 7341abf..af761bd 100644 --- a/src/data/api/files.api.ts +++ b/src/data/api/files.api.ts @@ -1,63 +1,37 @@ import { supabaseBrowser } from "../supabase/client"; -import { throwIfError, requireData, getUserIdOrThrow } from "./_helpers"; -import type { Archivo, UUID } from "../types/domain"; +import { invokeEdge } from "../supabase/invokeEdge"; +import { throwIfError } from "./_helpers"; +import type { AppFile } from "./openaiFiles.api"; -const DEFAULT_BUCKET = "archivos"; +const EDGE = { + signedUrl: "files_signed_url", // Edge: recibe archivoId o ruta_storage y devuelve URL +} as const; -export type UploadFileInput = { - file: File; - bucket?: string; - pathPrefix?: string; // ej: "planes/" o "materias/" +export async function files_list(params?: { temporal?: boolean; - notas?: string | null; -}; - -export async function files_upload(input: UploadFileInput): Promise { + search?: string; + limit?: number; +}): Promise { const supabase = supabaseBrowser(); - const userId = await getUserIdOrThrow(supabase); - const bucket = input.bucket ?? DEFAULT_BUCKET; - const safeName = input.file.name.replace(/[^\w.\-() ]+/g, "_"); - const pathPrefix = (input.pathPrefix ?? `usuarios/${userId}`).replace(/\/+$/g, ""); - - const storagePath = `${pathPrefix}/${crypto.randomUUID()}-${safeName}`; - - const { data: upData, error: upErr } = await supabase.storage - .from(bucket) - .upload(storagePath, input.file, { upsert: false }); - - throwIfError(upErr); - requireData(upData, "No se pudo subir archivo."); - - const { data: row, error: insErr } = await supabase + let q = supabase .from("archivos") - .insert({ - ruta_storage: `${bucket}/${storagePath}`, - nombre: input.file.name, - mime_type: input.file.type || null, - bytes: input.file.size, - subido_por: userId as UUID, - temporal: Boolean(input.temporal), - notas: input.notas ?? null, - }) - .select("id,ruta_storage,nombre,mime_type,bytes,subido_por,subido_en,temporal,openai_file_id,notas") - .single(); + .select("id,openai_file_id,nombre,mime_type,bytes,ruta_storage,temporal,notas,subido_en") + .order("subido_en", { ascending: false }); - throwIfError(insErr); - return requireData(row, "No se pudo registrar metadata del archivo."); -} + if (typeof params?.temporal === "boolean") q = q.eq("temporal", params.temporal); + if (params?.search?.trim()) q = q.ilike("nombre", `%${params.search.trim()}%`); + if (params?.limit) q = q.limit(params.limit); -export async function files_signed_url(params: { - ruta_storage: string; // "bucket/path/to/file" - expiresIn?: number; // segundos -}): Promise { - const supabase = supabaseBrowser(); - const expires = params.expiresIn ?? 60 * 10; - - const [bucket, ...rest] = params.ruta_storage.split("/"); - const path = rest.join("/"); - - const { data, error } = await supabase.storage.from(bucket).createSignedUrl(path, expires); + const { data, error } = await q; throwIfError(error); - return requireData(data?.signedUrl, "No se pudo generar URL firmada."); + return (data ?? []) as AppFile[]; +} + +/** Para preview/descarga desde espejo — SIN tocar storage directo en el cliente */ +export async function files_get_signed_url(payload: { + archivoId: string; // id interno (tabla archivos) + expiresIn?: number; // segundos +}): Promise<{ signedUrl: string }> { + return invokeEdge<{ signedUrl: string }>(EDGE.signedUrl, payload); } diff --git a/src/data/api/openaiFiles.api.ts b/src/data/api/openaiFiles.api.ts new file mode 100644 index 0000000..ad7b9e8 --- /dev/null +++ b/src/data/api/openaiFiles.api.ts @@ -0,0 +1,64 @@ +import { invokeEdge } from "../supabase/invokeEdge"; +import type { UUID } from "../types/domain"; + +/** + * Metadata “canónica” para UI (archivo OpenAI + espejo en Supabase) + * Se apoya en tu tabla `archivos`. + */ +export type AppFile = { + id: UUID; // id interno (tabla archivos) + openai_file_id: string; // id OpenAI + nombre: string; + mime_type: string | null; + bytes: number | null; + + // espejo Supabase para preview/descarga + ruta_storage: string | null; // "bucket/path" + signed_url?: string | null; + + // auditoría/evidencia + temporal: boolean; + notas?: string | null; + + subido_en: string; +}; + +const EDGE = { + upload: "openai_files_upload", + remove: "openai_files_delete", +} as const; + +/** + * Sube archivo a OpenAI y (opcional) crea espejo en Storage + * - El frontend NO toca Storage. + */ +export async function openai_files_upload(payload: { + /** + * Si tu Edge soporta multipart: manda File/Blob directo. + * Si no, manda base64/bytes (según tu implementación). + */ + file: File; + + /** “temporal” = evidencia usada para generar plan/materia */ + temporal?: boolean; + + /** contexto para auditoría */ + contexto?: { + planId?: UUID; + asignaturaId?: UUID; + motivo?: "WIZARD_PLAN" | "WIZARD_MATERIA" | "ADHOC"; + }; + + /** si quieres forzar espejo para preview siempre */ + mirrorToSupabase?: boolean; +}): Promise { + return invokeEdge(EDGE.upload, payload); +} + +export async function openai_files_delete(payload: { + openaiFileId: string; + /** si quieres borrar también espejo y registro */ + hardDelete?: boolean; +}): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.remove, payload); +} diff --git a/src/data/api/repositories.api.ts b/src/data/api/repositories.api.ts new file mode 100644 index 0000000..c7aa4a8 --- /dev/null +++ b/src/data/api/repositories.api.ts @@ -0,0 +1,44 @@ +import { invokeEdge } from "../supabase/invokeEdge"; + +export type Repository = { + id: string; // id del vector store (OpenAI) o tu id interno + nombre: string; + creado_en?: string; + meta?: Record; +}; + +const EDGE = { + create: "repos_create", + remove: "repos_delete", + add: "repos_add_files", + detach: "repos_remove_files", +} as const; + +export async function repos_create(payload: { + nombre: string; + descripcion?: string; + /** si tu implementación crea también registro DB */ + persist?: boolean; +}): Promise { + return invokeEdge(EDGE.create, payload); +} + +export async function repos_delete(payload: { repoId: string }): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.remove, payload); +} + +/** Agrega archivos (OpenAI file ids) a un repositorio */ +export async function repos_add_files(payload: { + repoId: string; + openaiFileIds: string[]; +}): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.add, payload); +} + +/** Quita archivos (OpenAI file ids) del repositorio */ +export async function repos_remove_files(payload: { + repoId: string; + openaiFileIds: string[]; +}): Promise<{ ok: true }> { + return invokeEdge<{ ok: true }>(EDGE.detach, payload); +} diff --git a/src/data/hooks/useFiles.ts b/src/data/hooks/useFiles.ts new file mode 100644 index 0000000..830ae2c --- /dev/null +++ b/src/data/hooks/useFiles.ts @@ -0,0 +1,43 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { files_get_signed_url, files_list } from "../api/files.api"; +import { openai_files_delete, openai_files_upload } from "../api/openaiFiles.api"; + +const qkFiles = { + list: (filters: any) => ["files", "list", filters] as const, +}; + +export function useFilesList(filters?: { temporal?: boolean; search?: string; limit?: number }) { + return useQuery({ + queryKey: qkFiles.list(filters ?? {}), + queryFn: () => files_list(filters), + staleTime: 15_000, + }); +} + +export function useUploadOpenAIFile() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: openai_files_upload, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["files"] }); + }, + }); +} + +export function useDeleteOpenAIFile() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: openai_files_delete, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["files"] }); + }, + }); +} + +export function useFileSignedUrl() { + return useMutation({ + mutationFn: files_get_signed_url, + }); +} diff --git a/src/data/hooks/useRepositories.ts b/src/data/hooks/useRepositories.ts new file mode 100644 index 0000000..aa99e8e --- /dev/null +++ b/src/data/hooks/useRepositories.ts @@ -0,0 +1,46 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { repos_add_files, repos_create, repos_delete, repos_remove_files } from "../api/repositories.api"; + +export function useCreateRepository() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: repos_create, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["repos"] }); + }, + }); +} + +export function useDeleteRepository() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: repos_delete, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["repos"] }); + }, + }); +} + +export function useRepoAddFiles() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: repos_add_files, + onSuccess: (_ok, vars) => { + qc.invalidateQueries({ queryKey: ["repos", vars.repoId] }); + }, + }); +} + +export function useRepoRemoveFiles() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: repos_remove_files, + onSuccess: (_ok, vars) => { + qc.invalidateQueries({ queryKey: ["repos", vars.repoId] }); + }, + }); +} diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 7f2a5e6..2cbd6a8 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -12,11 +12,9 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LoginRouteImport } from './routes/login' import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as IndexRouteImport } from './routes/index' -import { Route as MateriasIndexRouteImport } from './routes/materias/index' import { Route as DemoTanstackQueryRouteImport } from './routes/demo/tanstack-query' import { Route as PlanesListaRouteRouteImport } from './routes/planes/_lista/route' import { Route as PlanesListaNuevoRouteImport } from './routes/planes/_lista/nuevo' -import { Route as MateriasMateriaIdMateriaIdRouteImport } from './routes/materias/$materiaId/$materiaId' import { Route as PlanesPlanIdAsignaturasRouteRouteImport } from './routes/planes/$planId/asignaturas/route' import { Route as PlanesPlanIdDetalleRouteRouteImport } from './routes/planes/$planId/_detalle/route' import { Route as PlanesPlanIdAsignaturasIndexRouteImport } from './routes/planes/$planId/asignaturas/index' @@ -46,11 +44,6 @@ const IndexRoute = IndexRouteImport.update({ path: '/', getParentRoute: () => rootRouteImport, } as any) -const MateriasIndexRoute = MateriasIndexRouteImport.update({ - id: '/materias/', - path: '/materias/', - getParentRoute: () => rootRouteImport, -} as any) const DemoTanstackQueryRoute = DemoTanstackQueryRouteImport.update({ id: '/demo/tanstack-query', path: '/demo/tanstack-query', @@ -66,12 +59,6 @@ const PlanesListaNuevoRoute = PlanesListaNuevoRouteImport.update({ path: '/nuevo', getParentRoute: () => PlanesListaRouteRoute, } as any) -const MateriasMateriaIdMateriaIdRoute = - MateriasMateriaIdMateriaIdRouteImport.update({ - id: '/materias/$materiaId/$materiaId', - path: '/materias/$materiaId/$materiaId', - getParentRoute: () => rootRouteImport, - } as any) const PlanesPlanIdAsignaturasRouteRoute = PlanesPlanIdAsignaturasRouteRouteImport.update({ id: '/planes/$planId/asignaturas', @@ -155,10 +142,8 @@ export interface FileRoutesByFullPath { '/login': typeof LoginRoute '/planes': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute - '/materias': typeof MateriasIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren - '/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute '/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute '/planes/$planId/datos': typeof PlanesPlanIdDetalleDatosRoute @@ -177,9 +162,7 @@ export interface FileRoutesByTo { '/login': typeof LoginRoute '/planes': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute - '/materias': typeof MateriasIndexRoute '/planes/$planId': typeof PlanesPlanIdDetalleRouteRouteWithChildren - '/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute '/planes/nuevo': typeof PlanesListaNuevoRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasIndexRoute @@ -199,10 +182,8 @@ export interface FileRoutesById { '/login': typeof LoginRoute '/planes/_lista': typeof PlanesListaRouteRouteWithChildren '/demo/tanstack-query': typeof DemoTanstackQueryRoute - '/materias/': typeof MateriasIndexRoute '/planes/$planId/_detalle': typeof PlanesPlanIdDetalleRouteRouteWithChildren '/planes/$planId/asignaturas': typeof PlanesPlanIdAsignaturasRouteRouteWithChildren - '/materias/$materiaId/$materiaId': typeof MateriasMateriaIdMateriaIdRoute '/planes/_lista/nuevo': typeof PlanesListaNuevoRoute '/planes/$planId/asignaturas/$asignaturaId': typeof PlanesPlanIdAsignaturasAsignaturaIdRouteRoute '/planes/$planId/asignaturas/_lista': typeof PlanesPlanIdAsignaturasListaRouteRouteWithChildren @@ -224,10 +205,8 @@ export interface FileRouteTypes { | '/login' | '/planes' | '/demo/tanstack-query' - | '/materias' | '/planes/$planId' | '/planes/$planId/asignaturas' - | '/materias/$materiaId/$materiaId' | '/planes/nuevo' | '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/datos' @@ -246,9 +225,7 @@ export interface FileRouteTypes { | '/login' | '/planes' | '/demo/tanstack-query' - | '/materias' | '/planes/$planId' - | '/materias/$materiaId/$materiaId' | '/planes/nuevo' | '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas' @@ -267,10 +244,8 @@ export interface FileRouteTypes { | '/login' | '/planes/_lista' | '/demo/tanstack-query' - | '/materias/' | '/planes/$planId/_detalle' | '/planes/$planId/asignaturas' - | '/materias/$materiaId/$materiaId' | '/planes/_lista/nuevo' | '/planes/$planId/asignaturas/$asignaturaId' | '/planes/$planId/asignaturas/_lista' @@ -291,10 +266,8 @@ export interface RootRouteChildren { LoginRoute: typeof LoginRoute PlanesListaRouteRoute: typeof PlanesListaRouteRouteWithChildren DemoTanstackQueryRoute: typeof DemoTanstackQueryRoute - MateriasIndexRoute: typeof MateriasIndexRoute PlanesPlanIdDetalleRouteRoute: typeof PlanesPlanIdDetalleRouteRouteWithChildren PlanesPlanIdAsignaturasRouteRoute: typeof PlanesPlanIdAsignaturasRouteRouteWithChildren - MateriasMateriaIdMateriaIdRoute: typeof MateriasMateriaIdMateriaIdRoute } declare module '@tanstack/react-router' { @@ -320,13 +293,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } - '/materias/': { - id: '/materias/' - path: '/materias' - fullPath: '/materias' - preLoaderRoute: typeof MateriasIndexRouteImport - parentRoute: typeof rootRouteImport - } '/demo/tanstack-query': { id: '/demo/tanstack-query' path: '/demo/tanstack-query' @@ -348,13 +314,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PlanesListaNuevoRouteImport parentRoute: typeof PlanesListaRouteRoute } - '/materias/$materiaId/$materiaId': { - id: '/materias/$materiaId/$materiaId' - path: '/materias/$materiaId/$materiaId' - fullPath: '/materias/$materiaId/$materiaId' - preLoaderRoute: typeof MateriasMateriaIdMateriaIdRouteImport - parentRoute: typeof rootRouteImport - } '/planes/$planId/asignaturas': { id: '/planes/$planId/asignaturas' path: '/planes/$planId/asignaturas' @@ -527,11 +486,9 @@ const rootRouteChildren: RootRouteChildren = { LoginRoute: LoginRoute, PlanesListaRouteRoute: PlanesListaRouteRouteWithChildren, DemoTanstackQueryRoute: DemoTanstackQueryRoute, - MateriasIndexRoute: MateriasIndexRoute, PlanesPlanIdDetalleRouteRoute: PlanesPlanIdDetalleRouteRouteWithChildren, PlanesPlanIdAsignaturasRouteRoute: PlanesPlanIdAsignaturasRouteRouteWithChildren, - MateriasMateriaIdMateriaIdRoute: MateriasMateriaIdMateriaIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/planes/$planId/_detalle/datos.tsx b/src/routes/planes/$planId/_detalle/datos.tsx index 90e58e9..d72fdbd 100644 --- a/src/routes/planes/$planId/_detalle/datos.tsx +++ b/src/routes/planes/$planId/_detalle/datos.tsx @@ -1,3 +1,4 @@ +import { usePlan } from '@/data'; import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/planes/$planId/_detalle/datos')({ @@ -5,6 +6,10 @@ export const Route = createFileRoute('/planes/$planId/_detalle/datos')({ }) function DatosGenerales() { + const {data, isFetching} = usePlan('0e0aea4d-b8b4-4e75-8279-6224c3ac769f'); + if(!isFetching && !data) { + return
No se encontró el plan de estudios.
+ } return (