Se creó la edge function que sirve de wrapper a carbone y ya permite descargar el pdf del plan de estudios

Por hacer:
- permitir descargar los otros dos anexos tanto en pdf como en word/excel segun el caso
- quitar el hardcode de convertir a pdf de downloadReport
- Extraer el template id no de una variable de entorno, sino de la columna correspondiente tanto en la tabla de la estructura del plan como en la tabla de la estructura de la asignatura. En alguna parte de la BDD se tendrá que poner el template id del anexo del mapa curricular
- Permitir otros métodos
This commit is contained in:
2026-03-20 12:29:24 -06:00
parent 28c4732f44
commit 93c4e46e62
7 changed files with 625 additions and 26 deletions
+13 -21
View File
@@ -489,7 +489,7 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 6,
"id": "caf8bc20", "id": "caf8bc20",
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [
@@ -500,13 +500,13 @@
" success: \u001b[33mtrue\u001b[39m,\n", " success: \u001b[33mtrue\u001b[39m,\n",
" data: {\n", " data: {\n",
" category: \u001b[32m\"Planes de estudio\"\u001b[39m,\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", " versionId: \u001b[32m\"1990f3351b4313779ae3ef3caeaea14dea51c3a98a691fb90a347dec53f875e6\"\u001b[39m\n",
" }\n", " }\n",
"}" "}"
] ]
}, },
"execution_count": 19, "execution_count": 6,
"metadata": {}, "metadata": {},
"output_type": "execute_result" "output_type": "execute_result"
} }
@@ -514,13 +514,13 @@
"source": [ "source": [
"await carbone.patchTemplate(templateID, {\n", "await carbone.patchTemplate(templateID, {\n",
" category: \"Planes de estudio\",\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" "});\n"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": null, "execution_count": 7,
"id": "929883b9", "id": "929883b9",
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
@@ -570,7 +570,7 @@
" { download: true },\n", " { download: true },\n",
");\n", ");\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" "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", "cell_type": "code",
"execution_count": null, "execution_count": null,
@@ -661,7 +651,7 @@
" type: \"docx\",\n", " type: \"docx\",\n",
" name: \"Anexo 1\",\n", " name: \"Anexo 1\",\n",
" category: \"Planes de estudio\",\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", " tags: [],\n",
" origin: 1\n", " origin: 1\n",
" }\n", " }\n",
@@ -674,10 +664,10 @@
"source": [ "source": [
"const versions = await carbone.listTemplates({\n", "const versions = await carbone.listTemplates({\n",
" id: \"1373945625988937236\",\n", " id: \"1373945625988937236\",\n",
" includeVersions: true\n", " includeVersions: true,\n",
"});\n", "});\n",
"\n", "\n",
"console.log(versions);" "console.log(versions);\n"
] ]
}, },
{ {
@@ -746,8 +736,10 @@
"console.log(result);\n", "console.log(result);\n",
"\n", "\n",
"// const file = await carbone.downloadRender(render.data.renderId);\n", "// const file = await carbone.downloadRender(render.data.renderId);\n",
"const file = await carbone.downloadRender(\"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\");\n", "const file = await carbone.downloadRender(\n",
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);" " \"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\",\n",
");\n",
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);\n"
] ]
}, },
{ {
+11
View File
@@ -76,3 +76,14 @@ entrypoint = "./functions/buscar-bibliografia/index.ts"
# Specifies static files to be bundled with the function. Supports glob patterns. # 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: # For example, if you want to serve static HTML pages in your function:
# static_files = [ "./functions/buscar-bibliografia/*.html" ] # 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" ]
+29 -5
View File
@@ -1,4 +1,4 @@
export type Json = export type Json =
| string | string
| number | number
| boolean | boolean
@@ -7,10 +7,30 @@ export type Json =
| Json[] | Json[]
export type Database = { export type Database = {
// Allows to automatically instantiate createClient with right options graphql_public: {
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY) Tables: {
__InternalSupabase: { [_ in never]: never
PostgrestVersion: "14.1" }
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: { public: {
Tables: { Tables: {
@@ -1548,6 +1568,9 @@ export type CompositeTypes<
: never : never
export const Constants = { export const Constants = {
graphql_public: {
Enums: {},
},
public: { public: {
Enums: { Enums: {
estado_asignatura: ["borrador", "revisada", "aprobada", "generando"], estado_asignatura: ["borrador", "revisada", "aprobada", "generando"],
@@ -1607,3 +1630,4 @@ export const Constants = {
}, },
}, },
} as const } as const
@@ -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
@@ -0,0 +1,331 @@
const CARBONE_VERSION = "5";
export type CarboneSuccess<T> = {
success: true;
data: T;
hasMore?: boolean;
nextCursor?: string;
};
export type CarboneError = {
success: false;
error: string;
};
export type CarboneResponse<T> = CarboneSuccess<T> | 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<string, string>) {
return {
Authorization: `Bearer ${this.apiToken}`,
"carbone-version": CARBONE_VERSION,
...extra,
};
}
private async parseResponse<T>(res: Response): Promise<T> {
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<T>;
}
return res as unknown as T;
}
private toQuery(params?: Record<string, unknown>) {
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<CarboneResponse<Array<{ name: string }>>>(res);
}
async listTags() {
const res = await fetch(`${this.baseUrl}/templates/tags`, {
method: "GET",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<Array<{ name: string }>>>(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<CarboneResponse<true>>(res);
}
async deleteTemplate(id: string) {
const res = await fetch(`${this.baseUrl}/template/${id}`, {
method: "DELETE",
headers: this.buildHeaders(),
});
return this.parseResponse<CarboneResponse<true>>(res);
}
async downloadTemplate(id: string): Promise<CarboneDownloadResult> {
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<string, unknown>,
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<CarboneResponse<{ renderId: string }>>(res);
}
async renderFromTemplate(
body: Record<string, unknown>,
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<CarboneResponse<{ renderId: string }>>(res);
}
async downloadRender(renderId: string): Promise<CarboneDownloadResult> {
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"),
};
}
}
@@ -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"
}
}
@@ -0,0 +1,230 @@
// 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),
});
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<Json> {
const { data, error } = await supabase
.from("planes_estudio")
.select("datos")
.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 data.datos;
}
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_TEMPLATE_ID = Deno.env.get("CARBONE_TEMPLATE_ID");
const CARBONE_BASE_URL = Deno.env.get("CARBONE_BASE_URL") ||
"https://carbone.lci.ulsa.mx";
if (!CARBONE_API_TOKEN || !CARBONE_TEMPLATE_ID) {
throw new HttpError(
500,
"Configuración del servidor incompleta.",
"MISSING_ENV",
{
missing: [
!CARBONE_API_TOKEN ? "CARBONE_API_TOKEN" : null,
!CARBONE_TEMPLATE_ID ? "CARBONE_TEMPLATE_ID" : 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 = await loadPlanDatos(supabase, input.plan_estudio_id);
const carbone = new CarboneClient(CARBONE_BASE_URL, CARBONE_API_TOKEN);
const result = await carbone.render(
CARBONE_TEMPLATE_ID,
{ data: datos, convertTo: "pdf" },
{ 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 (err) {
const error = err instanceof HttpError ? err : new HttpError(
500,
"Error interno del servidor.",
"INTERNAL_SERVER_ERROR",
{
original: err instanceof Error
? { message: err.message, stack: err.stack }
: err,
},
);
return sendError(error.status, error.message, error.code);
}
});
/* 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>"}'
*/