From 7a1ec8799bed14616f1042ee33c28db9d0a434c5 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 17:42:42 -0600 Subject: [PATCH] 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":{}}}' */