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/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 new file mode 100644 index 0000000..175a2e8 --- /dev/null +++ b/supabase/functions/carbone-io-wrapper/index.ts @@ -0,0 +1,179 @@ +// 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 } from "../_shared/database.types.ts"; +import { HttpError, sendError } from "../_shared/utils.ts"; +import { handleDownloadReportAction } from "./download-report.ts"; + +const ActionSchema = z.object({ + action: z.string().min(1), +}); + +function getAuthHeader(req: Request): string | null { + return req.headers.get("Authorization") ?? req.headers.get("authorization"); +} + +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_BASE_URL = Deno.env.get("CARBONE_BASE_URL") || + "https://carbone.lci.ulsa.mx"; + if (!CARBONE_API_TOKEN) { + throw new HttpError( + 500, + "Configuración del servidor incompleta.", + "MISSING_ENV", + { + missing: [ + !CARBONE_API_TOKEN ? "CARBONE_API_TOKEN" : 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 response = await handleDownloadReportAction({ + bodyUnknown, + supabase, + carboneBaseUrl: CARBONE_BASE_URL, + carboneApiToken: CARBONE_API_TOKEN, + }); + + console.log( + `[${ + new Date().toISOString() + }][${functionName}]: Request processed successfully`, + ); + + return response; + } + default: + throw new HttpError( + 400, + "Acción no soportada.", + "UNSUPPORTED_ACTION", + { action }, + ); + } + } 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", + ); + } +}); + +/* 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 + # 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":"","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":{}}}' + +*/