From 93c4e46e62e7a267767c609ed45ccde45d048c5e Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 12:29:24 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Se=20cre=C3=B3=20la=20edge=20function=20que?= =?UTF-8?q?=20sirve=20de=20wrapper=20a=20carbone=20y=20ya=20permite=20desc?= =?UTF-8?q?argar=20el=20pdf=20del=20plan=20de=20estudios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Por hacer: - permitir descargar los otros dos anexos tanto en pdf como en word/excel segun el caso - quitar el hardcode de convertir a pdf de downloadReport - Extraer el template id no de una variable de entorno, sino de la columna correspondiente tanto en la tabla de la estructura del plan como en la tabla de la estructura de la asignatura. En alguna parte de la BDD se tendrá que poner el template id del anexo del mapa curricular - Permitir otros métodos --- notebooks/carbone.ipynb | 34 +- supabase/config.toml | 11 + supabase/functions/_shared/database.types.ts | 34 +- supabase/functions/carbone-io-wrapper/.npmrc | 3 + .../functions/carbone-io-wrapper/carbone.ts | 331 ++++++++++++++++++ .../functions/carbone-io-wrapper/deno.json | 8 + .../functions/carbone-io-wrapper/index.ts | 230 ++++++++++++ 7 files changed, 625 insertions(+), 26 deletions(-) create mode 100644 supabase/functions/carbone-io-wrapper/.npmrc create mode 100644 supabase/functions/carbone-io-wrapper/carbone.ts create mode 100644 supabase/functions/carbone-io-wrapper/deno.json create mode 100644 supabase/functions/carbone-io-wrapper/index.ts diff --git a/notebooks/carbone.ipynb b/notebooks/carbone.ipynb index 1d2bf4a..455fc23 100644 --- a/notebooks/carbone.ipynb +++ b/notebooks/carbone.ipynb @@ -489,7 +489,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "caf8bc20", "metadata": {}, "outputs": [ @@ -500,13 +500,13 @@ " success: \u001b[33mtrue\u001b[39m,\n", " data: {\n", " category: \u001b[32m\"Planes de estudio\"\u001b[39m,\n", - " comment: \u001b[32m\"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\"\u001b[39m,\n", + " comment: \u001b[32m\"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\"\u001b[39m,\n", " versionId: \u001b[32m\"1990f3351b4313779ae3ef3caeaea14dea51c3a98a691fb90a347dec53f875e6\"\u001b[39m\n", " }\n", "}" ] }, - "execution_count": 19, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -514,13 +514,13 @@ "source": [ "await carbone.patchTemplate(templateID, {\n", " category: \"Planes de estudio\",\n", - " comment: \"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n", + " comment: \"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n", "});\n" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "929883b9", "metadata": {}, "outputs": [], @@ -570,7 +570,7 @@ " { download: true },\n", ");\n", "\n", - "await Deno.writeFile(\"../dist/anexo1.docx\", result.buffer);\n" + "await Deno.writeFile(\"../anexo1.docx\", result.buffer);\n" ] }, { @@ -628,16 +628,6 @@ "await Deno.writeFile(\"../dist/anexo1.pdf\", result.buffer);\n" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "4528a917", - "metadata": {}, - "outputs": [], - "source": [ - "await carbone.list" - ] - }, { "cell_type": "code", "execution_count": null, @@ -661,7 +651,7 @@ " type: \"docx\",\n", " name: \"Anexo 1\",\n", " category: \"Planes de estudio\",\n", - " comment: \"Plantilla de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n", + " comment: \"Plantillaaa de anexo 1 SEP RVOE Acuerdo 17-11-17\",\n", " tags: [],\n", " origin: 1\n", " }\n", @@ -674,10 +664,10 @@ "source": [ "const versions = await carbone.listTemplates({\n", " id: \"1373945625988937236\",\n", - " includeVersions: true\n", + " includeVersions: true,\n", "});\n", "\n", - "console.log(versions);" + "console.log(versions);\n" ] }, { @@ -746,8 +736,10 @@ "console.log(result);\n", "\n", "// const file = await carbone.downloadRender(render.data.renderId);\n", - "const file = await carbone.downloadRender(\"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\");\n", - "await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);" + "const file = await carbone.downloadRender(\n", + " \"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\",\n", + ");\n", + "await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);\n" ] }, { diff --git a/supabase/config.toml b/supabase/config.toml index 87d7933..d3f5f19 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -76,3 +76,14 @@ entrypoint = "./functions/buscar-bibliografia/index.ts" # Specifies static files to be bundled with the function. Supports glob patterns. # For example, if you want to serve static HTML pages in your function: # static_files = [ "./functions/buscar-bibliografia/*.html" ] + +[functions.carbone-io-wrapper] +enabled = true +verify_jwt = true +import_map = "./functions/carbone-io-wrapper/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/carbone-io-wrapper/index.ts" +# Specifies static files to be bundled with the function. Supports glob patterns. +# For example, if you want to serve static HTML pages in your function: +# static_files = [ "./functions/carbone-io-wrapper/*.html" ] diff --git a/supabase/functions/_shared/database.types.ts b/supabase/functions/_shared/database.types.ts index 35f2364..e166d6f 100644 --- a/supabase/functions/_shared/database.types.ts +++ b/supabase/functions/_shared/database.types.ts @@ -1,4 +1,4 @@ -export type Json = +export type Json = | string | number | boolean @@ -7,10 +7,30 @@ export type Json = | Json[] export type Database = { - // Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: "14.1" + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } } public: { Tables: { @@ -1548,6 +1568,9 @@ export type CompositeTypes< : never export const Constants = { + graphql_public: { + Enums: {}, + }, public: { Enums: { estado_asignatura: ["borrador", "revisada", "aprobada", "generando"], @@ -1607,3 +1630,4 @@ export const Constants = { }, }, } as const + diff --git a/supabase/functions/carbone-io-wrapper/.npmrc b/supabase/functions/carbone-io-wrapper/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/carbone-io-wrapper/.npmrc @@ -0,0 +1,3 @@ +# Configuration for private npm package dependencies +# For more information on using private registries with Edge Functions, see: +# https://supabase.com/docs/guides/functions/import-maps#importing-from-private-registries diff --git a/supabase/functions/carbone-io-wrapper/carbone.ts b/supabase/functions/carbone-io-wrapper/carbone.ts new file mode 100644 index 0000000..72a122d --- /dev/null +++ b/supabase/functions/carbone-io-wrapper/carbone.ts @@ -0,0 +1,331 @@ +const CARBONE_VERSION = "5"; + +export type CarboneSuccess = { + success: true; + data: T; + hasMore?: boolean; + nextCursor?: string; +}; + +export type CarboneError = { + success: false; + error: string; +}; + +export type CarboneResponse = CarboneSuccess | CarboneError; + +export type CarboneDownloadResult = { + buffer: Uint8Array; + contentType: string | null; + contentDisposition: string | null; +}; + +export class CarboneClient { + constructor( + private baseUrl: string, + private apiToken: string, + ) {} + + private buildHeaders(extra?: Record) { + return { + Authorization: `Bearer ${this.apiToken}`, + "carbone-version": CARBONE_VERSION, + ...extra, + }; + } + + private async parseResponse(res: Response): Promise { + const contentType = res.headers.get("content-type") || ""; + + if (!res.ok) { + let message = `Carbone request failed: ${res.status}`; + try { + const err = await res.json(); + message = err?.error || err?.message || message; + } catch { + // ignore json parse errors + } + throw new Error(message); + } + + if (contentType.includes("application/json")) { + return res.json() as Promise; + } + + return res as unknown as T; + } + + private toQuery(params?: Record) { + const qs = new URLSearchParams(); + if (!params) return ""; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null) continue; + qs.set(key, String(value)); + } + const s = qs.toString(); + return s ? `?${s}` : ""; + } + + async listTemplates(params?: { + id?: string; + versionId?: string; + category?: string; + origin?: 0 | 1; + includeVersions?: boolean; + search?: string; + limit?: number; + cursor?: string; + }) { + const res = await fetch( + `${this.baseUrl}/templates${this.toQuery(params)}`, + { + method: "GET", + headers: this.buildHeaders(), + }, + ); + return this.parseResponse< + CarboneResponse< + Array<{ + id: string; + versionId: string; + deployedAt: number; + createdAt: number; + expireAt?: number; + size: number; + type: string; + name?: string; + category?: string; + comment?: string; + tags?: string[]; + origin?: number; + }> + > + >(res); + } + + async listCategories() { + const res = await fetch(`${this.baseUrl}/templates/categories`, { + method: "GET", + headers: this.buildHeaders(), + }); + return this.parseResponse>>(res); + } + + async listTags() { + const res = await fetch(`${this.baseUrl}/templates/tags`, { + method: "GET", + headers: this.buildHeaders(), + }); + return this.parseResponse>>(res); + } + + async uploadTemplateJson(body: { + template: string; + versioning?: boolean; + id?: string; + name?: string; + comment?: string; + tags?: string[]; + category?: string; + sample?: unknown[]; + deployedAt?: number; + expireAt?: number; + }) { + const res = await fetch(`${this.baseUrl}/template`, { + method: "POST", + headers: this.buildHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(body), + }); + return this.parseResponse< + CarboneResponse<{ + id?: string; + versionId?: string; + templateId?: string; + type?: string; + size?: number; + createdAt?: number; + }> + >(res); + } + + async uploadTemplateFile(input: { + file: Blob; + filename: string; + versioning?: boolean; + id?: string; + name?: string; + comment?: string; + tags?: string[]; + category?: string; + sample?: unknown[]; + deployedAt?: number; + expireAt?: number; + }) { + const form = new FormData(); + + if (input.versioning !== undefined) { + form.append("versioning", String(input.versioning)); + } + if (input.id) form.append("id", input.id); + if (input.name) form.append("name", input.name); + if (input.comment) form.append("comment", input.comment); + if (input.category) form.append("category", input.category); + if (input.tags) { + for (const tag of input.tags) form.append("tags[]", tag); + } + if (input.sample) form.append("sample", JSON.stringify(input.sample)); + if (input.deployedAt !== undefined) { + form.append("deployedAt", String(input.deployedAt)); + } + if (input.expireAt !== undefined) { + form.append("expireAt", String(input.expireAt)); + } + + // template should be appended last + form.append("template", input.file, input.filename); + + const res = await fetch(`${this.baseUrl}/template`, { + method: "POST", + headers: this.buildHeaders(), + body: form, + }); + return this.parseResponse< + CarboneResponse<{ + id?: string; + versionId?: string; + templateId?: string; + type?: string; + size?: number; + createdAt?: number; + }> + >(res); + } + + async patchTemplate( + id: string, + body: { + name?: string; + comment?: string; + tags?: string[]; + category?: string; + deployedAt?: number; + expireAt?: number; + }, + ) { + const res = await fetch(`${this.baseUrl}/template/${id}`, { + method: "PATCH", + headers: this.buildHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(body), + }); + return this.parseResponse>(res); + } + + async deleteTemplate(id: string) { + const res = await fetch(`${this.baseUrl}/template/${id}`, { + method: "DELETE", + headers: this.buildHeaders(), + }); + return this.parseResponse>(res); + } + + async downloadTemplate(id: string): Promise { + const res = await fetch(`${this.baseUrl}/template/${id}`, { + method: "GET", + headers: this.buildHeaders(), + }); + + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error( + err?.error || `Failed to download template: ${res.status}`, + ); + } + + return { + buffer: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get("content-type"), + contentDisposition: res.headers.get("content-disposition"), + }; + } + + async render( + templateIdOrVersionId: string, + body: Record, + opts?: { download?: boolean }, + ) { + const query = opts?.download ? "?download=true" : ""; + const res = await fetch( + `${this.baseUrl}/render/${templateIdOrVersionId}${query}`, + { + method: "POST", + headers: this.buildHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(body), + }, + ); + + if (opts?.download) { + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || `Render failed: ${res.status}`); + } + return { + buffer: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get("content-type"), + contentDisposition: res.headers.get("content-disposition"), + } satisfies CarboneDownloadResult; + } + + return this.parseResponse>(res); + } + + async renderFromTemplate( + body: Record, + opts?: { download?: boolean }, + ) { + const query = opts?.download ? "?download=true" : ""; + const res = await fetch(`${this.baseUrl}/render/template${query}`, { + method: "POST", + headers: this.buildHeaders({ + "Content-Type": "application/json", + }), + body: JSON.stringify(body), + }); + + if (opts?.download) { + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || `Render failed: ${res.status}`); + } + return { + buffer: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get("content-type"), + contentDisposition: res.headers.get("content-disposition"), + } satisfies CarboneDownloadResult; + } + + return this.parseResponse>(res); + } + + async downloadRender(renderId: string): Promise { + const res = await fetch(`${this.baseUrl}/render/${renderId}`, { + method: "GET", + }); + + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.error || `Failed to download render: ${res.status}`); + } + + return { + buffer: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get("content-type"), + contentDisposition: res.headers.get("content-disposition"), + }; + } +} diff --git a/supabase/functions/carbone-io-wrapper/deno.json b/supabase/functions/carbone-io-wrapper/deno.json new file mode 100644 index 0000000..7c46eff --- /dev/null +++ b/supabase/functions/carbone-io-wrapper/deno.json @@ -0,0 +1,8 @@ +{ + "imports": { + "zod": "https://deno.land/x/zod@v3.22.4/mod.ts", + "@supabase/supabase-js": "npm:@supabase/supabase-js@2", + "@supabase/functions-js/edge-runtime.d.ts": "jsr:@supabase/functions-js@^2/edge-runtime.d.ts", + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/carbone-io-wrapper/index.ts b/supabase/functions/carbone-io-wrapper/index.ts new file mode 100644 index 0000000..62cf1bd --- /dev/null +++ b/supabase/functions/carbone-io-wrapper/index.ts @@ -0,0 +1,230 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "@supabase/supabase-js"; +import { z } from "zod"; +import { corsHeaders } from "../_shared/cors.ts"; +import type { Database, Json } from "../_shared/database.types.ts"; +import { HttpError, sendError } from "../_shared/utils.ts"; +import { CarboneClient } from "./carbone.ts"; + +const ActionSchema = z.object({ + action: z.string().min(1), +}); + +const DownloadReportSchema = z.object({ + action: z.literal("downloadReport"), + plan_estudio_id: z.string().min(1), +}); + +type DownloadReportInput = z.infer; + +function getAuthHeader(req: Request): string | null { + return req.headers.get("Authorization") ?? req.headers.get("authorization"); +} + +async function loadPlanDatos( + supabase: ReturnType>, + planEstudioId: string, +): Promise { + const { data, error } = await supabase + .from("planes_estudio") + .select("datos") + .eq("id", planEstudioId) + .maybeSingle(); + + if (error) { + throw new HttpError( + 500, + "Error consultando el plan de estudios.", + "DB_ERROR", + { message: error.message, details: error.details, hint: error.hint }, + ); + } + if (!data) { + throw new HttpError( + 404, + "Plan de estudios no encontrado.", + "NOT_FOUND", + { plan_estudio_id: planEstudioId }, + ); + } + return data.datos; +} + +Deno.serve(async (req: Request): Promise => { + const url = new URL(req.url); + const functionName = url.pathname.split("/").pop(); + console.log( + `[${new Date().toISOString()}][${functionName}]: Request received`, + ); + + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: corsHeaders }); + } + + try { + if (req.method !== "POST") { + throw new HttpError( + 405, + "Método no permitido.", + "METHOD_NOT_ALLOWED", + { method: req.method }, + ); + } + + const authHeader = getAuthHeader(req); + if (!authHeader) { + throw new HttpError(401, "No autorizado.", "UNAUTHORIZED", { + reason: "missing_authorization_header", + }); + } + + const contentType = (req.headers.get("content-type") || "").toLowerCase(); + if (!contentType.startsWith("application/json")) { + throw new HttpError( + 415, + "Content-Type no soportado.", + "UNSUPPORTED_MEDIA_TYPE", + { contentType }, + ); + } + + const SUPABASE_URL = Deno.env.get("SUPABASE_URL"); + const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); + if (!SUPABASE_URL || !SERVICE_ROLE_KEY) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "MISSING_ENV", + { + missing: [ + !SUPABASE_URL ? "SUPABASE_URL" : null, + !SERVICE_ROLE_KEY ? "SUPABASE_SERVICE_ROLE_KEY" : null, + ].filter(Boolean), + }, + ); + } + + const CARBONE_API_TOKEN = Deno.env.get("CARBONE_API_TOKEN"); + const CARBONE_TEMPLATE_ID = Deno.env.get("CARBONE_TEMPLATE_ID"); + const CARBONE_BASE_URL = Deno.env.get("CARBONE_BASE_URL") || + "https://carbone.lci.ulsa.mx"; + if (!CARBONE_API_TOKEN || !CARBONE_TEMPLATE_ID) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "MISSING_ENV", + { + missing: [ + !CARBONE_API_TOKEN ? "CARBONE_API_TOKEN" : null, + !CARBONE_TEMPLATE_ID ? "CARBONE_TEMPLATE_ID" : null, + ].filter(Boolean), + }, + ); + } + + const bodyUnknown: unknown = await req.json(); + const { action } = ActionSchema.parse(bodyUnknown); + + const supabase = createClient(SUPABASE_URL, SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + switch (action) { + case "downloadReport": { + const input: DownloadReportInput = DownloadReportSchema.parse( + bodyUnknown, + ); + + const datos = await loadPlanDatos(supabase, input.plan_estudio_id); + const carbone = new CarboneClient(CARBONE_BASE_URL, CARBONE_API_TOKEN); + + const result = await carbone.render( + CARBONE_TEMPLATE_ID, + { data: datos, convertTo: "pdf" }, + { download: true }, + ); + + if (!(result && typeof result === "object" && "buffer" in result)) { + throw new HttpError( + 502, + "Respuesta inválida de Carbone.", + "UPSTREAM_INVALID_RESPONSE", + ); + } + + const download = result as { + buffer: Uint8Array; + contentType: string | null; + contentDisposition: string | null; + }; + + const bytes = download.buffer; + const arrayBuffer = bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ) as ArrayBuffer; + const blob = new Blob([arrayBuffer], { + type: download.contentType ?? "application/octet-stream", + }); + + const headers = new Headers({ + ...corsHeaders, + "Content-Type": download.contentType ?? "application/octet-stream", + Connection: "keep-alive", + }); + if (download.contentDisposition) { + headers.set("Content-Disposition", download.contentDisposition); + } + + console.log( + `[${ + new Date().toISOString() + }][${functionName}]: Request processed successfully`, + ); + return new Response(blob, { status: 200, headers }); + } + default: + throw new HttpError( + 400, + "Acción no soportada.", + "UNSUPPORTED_ACTION", + { action }, + ); + } + } catch (err) { + const error = err instanceof HttpError ? err : new HttpError( + 500, + "Error interno del servidor.", + "INTERNAL_SERVER_ERROR", + { + original: err instanceof Error + ? { message: err.message, stack: err.stack } + : err, + }, + ); + return sendError(error.status, error.message, error.code); + } +}); + +/* To invoke locally: + + 1. Run `supabase start` (see: https://supabase.com/docs/reference/cli/supabase-start) + 2. Make an HTTP request: + + # Requires secrets: + # - CARBONE_API_TOKEN + # - CARBONE_TEMPLATE_ID + # Optional: + # - CARBONE_BASE_URL (defaults to https://carbone.lci.ulsa.mx) + + curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/carbone-io-wrapper' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{"action":"downloadReport","plan_estudio_id":""}' + +*/ -- 2.52.0 From d378c43821945ca345f30cb77fb73f857b03b910 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 13:08:20 -0600 Subject: [PATCH 2/3] =?UTF-8?q?Se=20corrigi=C3=B3=20hardcode=20de=20id=20d?= =?UTF-8?q?e=20template=20y=20se=20permite=20recibir=20body=20para=20el=20?= =?UTF-8?q?downloadReport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../functions/carbone-io-wrapper/index.ts | 108 ++++++++++++++---- 1 file changed, 88 insertions(+), 20 deletions(-) diff --git a/supabase/functions/carbone-io-wrapper/index.ts b/supabase/functions/carbone-io-wrapper/index.ts index 62cf1bd..f4d625e 100644 --- a/supabase/functions/carbone-io-wrapper/index.ts +++ b/supabase/functions/carbone-io-wrapper/index.ts @@ -18,6 +18,7 @@ const ActionSchema = z.object({ const DownloadReportSchema = z.object({ action: z.literal("downloadReport"), plan_estudio_id: z.string().min(1), + body: z.record(z.unknown()), }); type DownloadReportInput = z.infer; @@ -29,10 +30,10 @@ function getAuthHeader(req: Request): string | null { async function loadPlanDatos( supabase: ReturnType>, planEstudioId: string, -): Promise { +): Promise<{ datos: Json; estructura_id: string }> { const { data, error } = await supabase .from("planes_estudio") - .select("datos") + .select("datos, estructura_id") .eq("id", planEstudioId) .maybeSingle(); @@ -52,7 +53,48 @@ async function loadPlanDatos( { plan_estudio_id: planEstudioId }, ); } - return data.datos; + + return { + datos: data.datos, + estructura_id: data.estructura_id, + }; +} + +async function loadTemplateIdForEstructura( + supabase: ReturnType>, + estructuraId: string, +): Promise { + const { data, error } = await supabase + .from("estructuras_plan") + .select("template_id") + .eq("id", estructuraId) + .maybeSingle(); + + if (error) { + throw new HttpError( + 500, + "Error consultando la estructura del plan.", + "DB_ERROR", + { message: error.message, details: error.details, hint: error.hint }, + ); + } + if (!data) { + throw new HttpError( + 404, + "Estructura del plan no encontrada.", + "NOT_FOUND", + { estructura_id: estructuraId }, + ); + } + if (!data.template_id) { + throw new HttpError( + 409, + "La estructura del plan no tiene template_id configurado.", + "MISSING_TEMPLATE_ID", + { estructura_id: estructuraId }, + ); + } + return data.template_id; } Deno.serve(async (req: Request): Promise => { @@ -110,10 +152,9 @@ Deno.serve(async (req: Request): Promise => { } const CARBONE_API_TOKEN = Deno.env.get("CARBONE_API_TOKEN"); - const CARBONE_TEMPLATE_ID = Deno.env.get("CARBONE_TEMPLATE_ID"); const CARBONE_BASE_URL = Deno.env.get("CARBONE_BASE_URL") || "https://carbone.lci.ulsa.mx"; - if (!CARBONE_API_TOKEN || !CARBONE_TEMPLATE_ID) { + if (!CARBONE_API_TOKEN) { throw new HttpError( 500, "Configuración del servidor incompleta.", @@ -121,7 +162,6 @@ Deno.serve(async (req: Request): Promise => { { missing: [ !CARBONE_API_TOKEN ? "CARBONE_API_TOKEN" : null, - !CARBONE_TEMPLATE_ID ? "CARBONE_TEMPLATE_ID" : null, ].filter(Boolean), }, ); @@ -140,12 +180,22 @@ Deno.serve(async (req: Request): Promise => { bodyUnknown, ); - const datos = await loadPlanDatos(supabase, input.plan_estudio_id); + const { datos, estructura_id } = await loadPlanDatos( + supabase, + input.plan_estudio_id, + ); + const templateId = await loadTemplateIdForEstructura( + supabase, + estructura_id, + ); const carbone = new CarboneClient(CARBONE_BASE_URL, CARBONE_API_TOKEN); + const { data: _ignoredData, ...extraBody } = input.body; + + console.log("body:", extraBody); const result = await carbone.render( - CARBONE_TEMPLATE_ID, - { data: datos, convertTo: "pdf" }, + templateId, + { data: datos, ...extraBody }, { download: true }, ); @@ -196,18 +246,36 @@ Deno.serve(async (req: Request): Promise => { { action }, ); } - } catch (err) { - const error = err instanceof HttpError ? err : new HttpError( - 500, - "Error interno del servidor.", - "INTERNAL_SERVER_ERROR", - { - original: err instanceof Error - ? { message: err.message, stack: err.stack } - : err, - }, + } catch (error) { + if (error instanceof HttpError) { + console.error( + `[${new Date().toISOString()}][${functionName}] ⚠️ Handled Error:`, + { + message: error.message, + code: error.code, + internalDetails: error.internalDetails || "N/A", + }, + ); + + return sendError(error.status, error.message, error.code); + } + + const unexpectedError = error instanceof Error + ? error + : new Error(String(error)); + + console.error( + `[${ + new Date().toISOString() + }][${functionName}] 💥 CRITICAL UNHANDLED ERROR:`, + unexpectedError.stack || unexpectedError.message, + ); + + return sendError( + 500, + "Ocurrió un error inesperado en el servidor.", + "INTERNAL_SERVER_ERROR", ); - return sendError(error.status, error.message, error.code); } }); -- 2.52.0 From 7a1ec8799bed14616f1042ee33c28db9d0a434c5 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 17:42:42 -0600 Subject: [PATCH 3/3] fix #76: se corrige el content-type de la response para que el cliente pueda recibir los documentos adecuadamente si el content-type es de tipo application/pdf, el cliente de supabase lo recibe adecuadamente como pdf, pero si es de otro tipo se debe mandar como application/octet-stream para que el cliente de supabase lo reciba como blob --- .../carbone-io-wrapper/download-report.ts | 252 ++++++++++++++++++ .../functions/carbone-io-wrapper/index.ts | 149 ++--------- 2 files changed, 267 insertions(+), 134 deletions(-) create mode 100644 supabase/functions/carbone-io-wrapper/download-report.ts diff --git a/supabase/functions/carbone-io-wrapper/download-report.ts b/supabase/functions/carbone-io-wrapper/download-report.ts new file mode 100644 index 0000000..a3a70a5 --- /dev/null +++ b/supabase/functions/carbone-io-wrapper/download-report.ts @@ -0,0 +1,252 @@ +import { createClient } from "@supabase/supabase-js"; +import { z } from "zod"; +import { corsHeaders } from "../_shared/cors.ts"; +import type { Database, Json } from "../_shared/database.types.ts"; +import { HttpError } from "../_shared/utils.ts"; +import { CarboneClient } from "./carbone.ts"; + +const DownloadReportBodySchema = z.record(z.unknown()).optional().default({}); + +const DownloadReportPlanSchema = z + .object({ + action: z.literal("downloadReport"), + plan_estudio_id: z.string().min(1), + body: DownloadReportBodySchema, + }) + .strict(); + +const DownloadReportAsignaturaSchema = z + .object({ + action: z.literal("downloadReport"), + asignatura_id: z.string().min(1), + body: DownloadReportBodySchema, + }) + .strict(); + +export const DownloadReportSchema = z.union([ + DownloadReportPlanSchema, + DownloadReportAsignaturaSchema, +]); + +export type DownloadReportInput = z.infer; + +type SupabaseClient = ReturnType>; + +type CarboneDownload = { + buffer: Uint8Array; + contentType: string | null; + contentDisposition: string | null; +}; + +async function loadPlanDatos( + supabase: SupabaseClient, + planEstudioId: string, +): Promise<{ datos: Json; estructura_id: string }> { + const { data, error } = await supabase + .from("planes_estudio") + .select("datos, estructura_id") + .eq("id", planEstudioId) + .maybeSingle(); + + if (error) { + throw new HttpError( + 500, + "Error consultando el plan de estudios.", + "DB_ERROR", + { + message: error.message, + details: error.details, + hint: error.hint, + }, + ); + } + if (!data) { + throw new HttpError(404, "Plan de estudios no encontrado.", "NOT_FOUND", { + plan_estudio_id: planEstudioId, + }); + } + + return { datos: data.datos, estructura_id: data.estructura_id }; +} + +async function loadTemplateIdForEstructura( + supabase: SupabaseClient, + estructuraId: string, +): Promise { + const { data, error } = await supabase + .from("estructuras_plan") + .select("template_id") + .eq("id", estructuraId) + .maybeSingle(); + + if (error) { + throw new HttpError( + 500, + "Error consultando la estructura del plan.", + "DB_ERROR", + { + message: error.message, + details: error.details, + hint: error.hint, + }, + ); + } + if (!data) { + throw new HttpError( + 404, + "Estructura del plan no encontrada.", + "NOT_FOUND", + { + estructura_id: estructuraId, + }, + ); + } + if (!data.template_id) { + throw new HttpError( + 409, + "La estructura del plan no tiene template_id configurado.", + "MISSING_TEMPLATE_ID", + { estructura_id: estructuraId }, + ); + } + + return data.template_id; +} + +async function loadTemplateIdForAsignatura( + supabase: SupabaseClient, + asignaturaId: string, +): Promise { + const { data, error } = await supabase + .from("plantilla_asignatura") + .select("template_id") + .eq("asignatura_id", asignaturaId) + .maybeSingle(); + + if (error) { + throw new HttpError( + 500, + "Error consultando la plantilla de la asignatura.", + "DB_ERROR", + { + message: error.message, + details: error.details, + hint: error.hint, + }, + ); + } + if (!data) { + throw new HttpError(404, "Asignatura no encontrada.", "NOT_FOUND", { + asignatura_id: asignaturaId, + }); + } + if (!data.template_id) { + throw new HttpError( + 409, + "La asignatura no tiene template_id configurado.", + "MISSING_TEMPLATE_ID", + { asignatura_id: asignaturaId }, + ); + } + + return data.template_id; +} + +function ensureCarboneDownload(result: unknown): CarboneDownload { + if (!(result && typeof result === "object" && "buffer" in result)) { + throw new HttpError( + 502, + "Respuesta inválida de Carbone.", + "UPSTREAM_INVALID_RESPONSE", + ); + } + + const download = result as CarboneDownload; + if (!(download.buffer instanceof Uint8Array)) { + throw new HttpError( + 502, + "Respuesta inválida de Carbone.", + "UPSTREAM_INVALID_RESPONSE", + ); + } + + return download; +} + +function downloadToResponse(download: CarboneDownload): Response { + const headers = new Headers({ + ...corsHeaders, + "Content-Type": download.contentType === "application/pdf" + ? "application/pdf" + : "application/octet-stream", + Connection: "keep-alive", + }); + + if (download.contentDisposition) { + headers.set("Content-Disposition", download.contentDisposition); + } + + const body = new Uint8Array( + download.buffer.buffer as ArrayBuffer, + download.buffer.byteOffset, + download.buffer.byteLength, + ); + return new Response(body, { status: 200, headers }); +} + +export async function handleDownloadReportAction(args: { + bodyUnknown: unknown; + supabase: SupabaseClient; + carboneBaseUrl: string; + carboneApiToken: string; +}): Promise { + const input: DownloadReportInput = DownloadReportSchema.parse( + args.bodyUnknown, + ); + const carbone = new CarboneClient(args.carboneBaseUrl, args.carboneApiToken); + + if ("plan_estudio_id" in input) { + const { datos, estructura_id } = await loadPlanDatos( + args.supabase, + input.plan_estudio_id, + ); + const templateId = await loadTemplateIdForEstructura( + args.supabase, + estructura_id, + ); + + const { data: _ignoredData, ...extraBody } = input.body; + const result = await carbone.render( + templateId, + { data: datos, ...extraBody }, + { download: true }, + ); + + return downloadToResponse(ensureCarboneDownload(result)); + } + + const templateId = await loadTemplateIdForAsignatura( + args.supabase, + input.asignatura_id, + ); + + const hasData = Object.prototype.hasOwnProperty.call(input.body, "data"); + if (!hasData) { + throw new HttpError( + 400, + "Para asignatura_id se requiere body.data.", + "MISSING_DATA", + { asignatura_id: input.asignatura_id }, + ); + } + + // console.log("body:", input.body); + const { data, ...extraBody } = input.body as Record; + const result = await carbone.render( + templateId, + { data, ...extraBody }, + { download: true }, + ); + + return downloadToResponse(ensureCarboneDownload(result)); +} diff --git a/supabase/functions/carbone-io-wrapper/index.ts b/supabase/functions/carbone-io-wrapper/index.ts index f4d625e..175a2e8 100644 --- a/supabase/functions/carbone-io-wrapper/index.ts +++ b/supabase/functions/carbone-io-wrapper/index.ts @@ -7,96 +7,18 @@ import "@supabase/functions-js/edge-runtime.d.ts"; import { createClient } from "@supabase/supabase-js"; import { z } from "zod"; import { corsHeaders } from "../_shared/cors.ts"; -import type { Database, Json } from "../_shared/database.types.ts"; +import type { Database } from "../_shared/database.types.ts"; import { HttpError, sendError } from "../_shared/utils.ts"; -import { CarboneClient } from "./carbone.ts"; +import { handleDownloadReportAction } from "./download-report.ts"; const ActionSchema = z.object({ action: z.string().min(1), }); -const DownloadReportSchema = z.object({ - action: z.literal("downloadReport"), - plan_estudio_id: z.string().min(1), - body: z.record(z.unknown()), -}); - -type DownloadReportInput = z.infer; - function getAuthHeader(req: Request): string | null { return req.headers.get("Authorization") ?? req.headers.get("authorization"); } -async function loadPlanDatos( - supabase: ReturnType>, - planEstudioId: string, -): Promise<{ datos: Json; estructura_id: string }> { - const { data, error } = await supabase - .from("planes_estudio") - .select("datos, estructura_id") - .eq("id", planEstudioId) - .maybeSingle(); - - if (error) { - throw new HttpError( - 500, - "Error consultando el plan de estudios.", - "DB_ERROR", - { message: error.message, details: error.details, hint: error.hint }, - ); - } - if (!data) { - throw new HttpError( - 404, - "Plan de estudios no encontrado.", - "NOT_FOUND", - { plan_estudio_id: planEstudioId }, - ); - } - - return { - datos: data.datos, - estructura_id: data.estructura_id, - }; -} - -async function loadTemplateIdForEstructura( - supabase: ReturnType>, - estructuraId: string, -): Promise { - const { data, error } = await supabase - .from("estructuras_plan") - .select("template_id") - .eq("id", estructuraId) - .maybeSingle(); - - if (error) { - throw new HttpError( - 500, - "Error consultando la estructura del plan.", - "DB_ERROR", - { message: error.message, details: error.details, hint: error.hint }, - ); - } - if (!data) { - throw new HttpError( - 404, - "Estructura del plan no encontrada.", - "NOT_FOUND", - { estructura_id: estructuraId }, - ); - } - if (!data.template_id) { - throw new HttpError( - 409, - "La estructura del plan no tiene template_id configurado.", - "MISSING_TEMPLATE_ID", - { estructura_id: estructuraId }, - ); - } - return data.template_id; -} - Deno.serve(async (req: Request): Promise => { const url = new URL(req.url); const functionName = url.pathname.split("/").pop(); @@ -176,67 +98,20 @@ Deno.serve(async (req: Request): Promise => { switch (action) { case "downloadReport": { - const input: DownloadReportInput = DownloadReportSchema.parse( + const response = await handleDownloadReportAction({ bodyUnknown, - ); - - const { datos, estructura_id } = await loadPlanDatos( supabase, - input.plan_estudio_id, - ); - const templateId = await loadTemplateIdForEstructura( - supabase, - estructura_id, - ); - const carbone = new CarboneClient(CARBONE_BASE_URL, CARBONE_API_TOKEN); - - const { data: _ignoredData, ...extraBody } = input.body; - - console.log("body:", extraBody); - const result = await carbone.render( - templateId, - { data: datos, ...extraBody }, - { download: true }, - ); - - if (!(result && typeof result === "object" && "buffer" in result)) { - throw new HttpError( - 502, - "Respuesta inválida de Carbone.", - "UPSTREAM_INVALID_RESPONSE", - ); - } - - const download = result as { - buffer: Uint8Array; - contentType: string | null; - contentDisposition: string | null; - }; - - const bytes = download.buffer; - const arrayBuffer = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; - const blob = new Blob([arrayBuffer], { - type: download.contentType ?? "application/octet-stream", + carboneBaseUrl: CARBONE_BASE_URL, + carboneApiToken: CARBONE_API_TOKEN, }); - const headers = new Headers({ - ...corsHeaders, - "Content-Type": download.contentType ?? "application/octet-stream", - Connection: "keep-alive", - }); - if (download.contentDisposition) { - headers.set("Content-Disposition", download.contentDisposition); - } - console.log( `[${ new Date().toISOString() }][${functionName}]: Request processed successfully`, ); - return new Response(blob, { status: 200, headers }); + + return response; } default: throw new HttpError( @@ -286,13 +161,19 @@ Deno.serve(async (req: Request): Promise => { # Requires secrets: # - CARBONE_API_TOKEN - # - CARBONE_TEMPLATE_ID # Optional: # - CARBONE_BASE_URL (defaults to https://carbone.lci.ulsa.mx) curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/carbone-io-wrapper' \ --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ - --data '{"action":"downloadReport","plan_estudio_id":""}' + --data '{"action":"downloadReport","plan_estudio_id":"","body":{}}' + + # Or for asignaturas (must include body.data): + + curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/carbone-io-wrapper' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data '{"action":"downloadReport","asignatura_id":"","body":{"data":{}}}' */ -- 2.52.0