fix #76: se corrige el content-type de la response para que el cliente pueda recibir los documentos adecuadamente
Deploy Function / deploy (pull_request) Successful in 30s
Deploy Migrations to Production / deploy (pull_request) Successful in 10s

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
This commit is contained in:
2026-03-20 17:42:42 -06:00
parent d378c43821
commit 7a1ec8799b
2 changed files with 267 additions and 134 deletions
@@ -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<typeof DownloadReportSchema>;
type SupabaseClient = ReturnType<typeof createClient<Database>>;
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<string> {
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<string> {
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<Response> {
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<string, unknown>;
const result = await carbone.render(
templateId,
{ data, ...extraBody },
{ download: true },
);
return downloadToResponse(ensureCarboneDownload(result));
}
+15 -134
View File
@@ -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<typeof DownloadReportSchema>;
function getAuthHeader(req: Request): string | null {
return req.headers.get("Authorization") ?? req.headers.get("authorization");
}
async function loadPlanDatos(
supabase: ReturnType<typeof createClient<Database>>,
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<typeof createClient<Database>>,
estructuraId: string,
): Promise<string> {
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<Response> => {
const url = new URL(req.url);
const functionName = url.pathname.split("/").pop();
@@ -176,67 +98,20 @@ Deno.serve(async (req: Request): Promise<Response> => {
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<Response> => {
# 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 <JWT>' \
--header 'Content-Type: application/json' \
--data '{"action":"downloadReport","plan_estudio_id":"<uuid>"}'
--data '{"action":"downloadReport","plan_estudio_id":"<uuid>","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 <JWT>' \
--header 'Content-Type: application/json' \
--data '{"action":"downloadReport","asignatura_id":"<uuid>","body":{"data":{}}}'
*/