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":""}' + +*/