Creada edge function wrapper para carbone.io del lci con un método (descargar reporte) #80
@@ -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));
|
||||
}
|
||||
@@ -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":{}}}'
|
||||
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user