Merge pull request 'Creada edge function wrapper para carbone.io del lci con un método (descargar reporte)' (#80) from issue/76-crear-edge-function-para-plantillas-de-carbone into main
Reviewed-on: AlexRG/genesis-2#80
This commit was merged in pull request #80.
This commit is contained in:
+13
-21
@@ -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"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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" ]
|
||||||
|
|||||||
@@ -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,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