299 lines
8.2 KiB
TypeScript
299 lines
8.2 KiB
TypeScript
// 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),
|
|
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();
|
|
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<Database>(SUPABASE_URL, SERVICE_ROLE_KEY, {
|
|
auth: { persistSession: false },
|
|
});
|
|
|
|
switch (action) {
|
|
case "downloadReport": {
|
|
const input: DownloadReportInput = DownloadReportSchema.parse(
|
|
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",
|
|
});
|
|
|
|
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 (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
|
|
# - 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>"}'
|
|
|
|
*/
|