Creada edge function wrapper para carbone.io del lci con un método (descargar reporte) #80
+13
-21
@@ -489,7 +489,7 @@
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 6,
|
||||
"id": "caf8bc20",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
@@ -500,13 +500,13 @@
|
||||
" success: \u001b[33mtrue\u001b[39m,\n",
|
||||
" data: {\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",
|
||||
" }\n",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
"execution_count": 19,
|
||||
"execution_count": 6,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
@@ -514,13 +514,13 @@
|
||||
"source": [
|
||||
"await carbone.patchTemplate(templateID, {\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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"execution_count": 7,
|
||||
"id": "929883b9",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
@@ -570,7 +570,7 @@
|
||||
" { download: true },\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"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4528a917",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"await carbone.list"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
@@ -661,7 +651,7 @@
|
||||
" type: \"docx\",\n",
|
||||
" name: \"Anexo 1\",\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",
|
||||
" origin: 1\n",
|
||||
" }\n",
|
||||
@@ -674,10 +664,10 @@
|
||||
"source": [
|
||||
"const versions = await carbone.listTemplates({\n",
|
||||
" id: \"1373945625988937236\",\n",
|
||||
" includeVersions: true\n",
|
||||
" includeVersions: true,\n",
|
||||
"});\n",
|
||||
"\n",
|
||||
"console.log(versions);"
|
||||
"console.log(versions);\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -746,8 +736,10 @@
|
||||
"console.log(result);\n",
|
||||
"\n",
|
||||
"// const file = await carbone.downloadRender(render.data.renderId);\n",
|
||||
"const file = await carbone.downloadRender(\"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\");\n",
|
||||
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);"
|
||||
"const file = await carbone.downloadRender(\n",
|
||||
" \"X1Bx6Stre95Sm54jHxIwpQYW5leG8xLnBkZg.pdf\",\n",
|
||||
");\n",
|
||||
"await Deno.writeFile(\"dist/anexo1.pdf\", file.buffer);\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -76,3 +76,14 @@ entrypoint = "./functions/buscar-bibliografia/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/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" ]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Json =
|
||||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
@@ -7,10 +7,30 @@ export type Json =
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
// Allows to automatically instantiate createClient with right options
|
||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: "14.1"
|
||||
graphql_public: {
|
||||
Tables: {
|
||||
[_ in never]: never
|
||||
}
|
||||
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: {
|
||||
Tables: {
|
||||
@@ -1548,6 +1568,9 @@ export type CompositeTypes<
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
graphql_public: {
|
||||
Enums: {},
|
||||
},
|
||||
public: {
|
||||
Enums: {
|
||||
estado_asignatura: ["borrador", "revisada", "aprobada", "generando"],
|
||||
@@ -1607,3 +1630,4 @@ export const Constants = {
|
||||
},
|
||||
},
|
||||
} 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,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));
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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 } from "../_shared/database.types.ts";
|
||||
import { HttpError, sendError } from "../_shared/utils.ts";
|
||||
import { handleDownloadReportAction } from "./download-report.ts";
|
||||
|
||||
const ActionSchema = z.object({
|
||||
action: z.string().min(1),
|
||||
});
|
||||
|
||||
function getAuthHeader(req: Request): string | null {
|
||||
return req.headers.get("Authorization") ?? req.headers.get("authorization");
|
||||
}
|
||||
|
||||
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 response = await handleDownloadReportAction({
|
||||
bodyUnknown,
|
||||
supabase,
|
||||
carboneBaseUrl: CARBONE_BASE_URL,
|
||||
carboneApiToken: CARBONE_API_TOKEN,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[${
|
||||
new Date().toISOString()
|
||||
}][${functionName}]: Request processed successfully`,
|
||||
);
|
||||
|
||||
return response;
|
||||
}
|
||||
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
|
||||
# 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>","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