From 93c4e46e62e7a267767c609ed45ccde45d048c5e Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 12:29:24 -0600 Subject: [PATCH] =?UTF-8?q?Se=20cre=C3=B3=20la=20edge=20function=20que=20s?= =?UTF-8?q?irve=20de=20wrapper=20a=20carbone=20y=20ya=20permite=20descarga?= =?UTF-8?q?r=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":""}' + +*/