diff --git a/.vscode/settings.json b/.vscode/settings.json index 5309819..6e856bb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,10 +2,12 @@ "deno.enablePaths": ["supabase/functions"], "deno.lint": true, "[typescript]": { - "editor.defaultFormatter": "denoland.vscode-deno" + "editor.defaultFormatter": "denoland.vscode-deno", }, "notebook.defaultFormatter": "denoland.vscode-deno", "editor.defaultFormatter": "esbenp.prettier-vscode", "deno.enable": true, - "editor.formatOnSave": true + "editor.formatOnSave": true, + + "typescript.tsserver.experimental.enableProjectDiagnostics": false, } diff --git a/deno.lock b/deno.lock index 47882cb..55a3dca 100644 --- a/deno.lock +++ b/deno.lock @@ -5,25 +5,20 @@ "jsr:@david/dax@~0.44.2": "0.44.2", "jsr:@david/path@0.2": "0.2.0", "jsr:@david/which@~0.4.1": "0.4.1", - "jsr:@openai/openai@*": "6.9.0", "jsr:@openai/openai@^6.16.0": "6.16.0", - "jsr:@std/assert@1": "1.0.16", - "jsr:@std/bytes@^1.0.5": "1.0.6", - "jsr:@std/dotenv@*": "0.225.6", + "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/fmt@1": "1.0.9", "jsr:@std/fs@1": "1.0.22", "jsr:@std/fs@^1.0.20": "1.0.22", "jsr:@std/internal@^1.0.12": "1.0.12", - "jsr:@std/io@0.225": "0.225.2", + "jsr:@std/io@0.225": "0.225.3", "jsr:@std/path@1": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", - "jsr:@supabase/functions-js@*": "2.90.1", "jsr:@zod/zod@^4.3.5": "4.3.5", "npm:@supabase/supabase-js@^2.90.1": "2.90.1", "npm:@types/bun@^1.3.5": "1.3.5", "npm:deno@^2.6.4": "2.6.4", "npm:openai@^6.16.0": "6.16.0", - "npm:supabase-js@^1.0.4": "1.0.4", "npm:supabase@^2.72.6": "2.72.6", "npm:zod@3": "3.25.76" }, @@ -53,27 +48,15 @@ "@david/which@0.4.1": { "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e" }, - "@openai/openai@6.9.0": { - "integrity": "d1828101f5a782a75c25805c4e06c1316d9c19f422d84755fb908046184c3ef6" - }, "@openai/openai@6.16.0": { "integrity": "ccee548f61c382d715091fff0c2c3390a4487823644430b8235df543d6d6a78b", "dependencies": [ "npm:zod" ] }, - "@std/assert@1.0.16": { - "integrity": "6a7272ed1eaa77defe76e5ff63ca705d9c495077e2d5fd0126d2b53fc5bd6532", - "dependencies": [ - "jsr:@std/internal" - ] - }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, - "@std/dotenv@0.225.6": { - "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" - }, "@std/fmt@1.0.9": { "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" }, @@ -87,8 +70,8 @@ "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, - "@std/io@0.225.2": { - "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", + "@std/io@0.225.3": { + "integrity": "27b07b591384d12d7b568f39e61dff966b8230559122df1e9fd11cc068f7ddd1", "dependencies": [ "jsr:@std/bytes" ] @@ -99,9 +82,6 @@ "jsr:@std/internal" ] }, - "@supabase/functions-js@2.90.1": { - "integrity": "9a077ecf42aa84594ef3aac621d643e15ffaa9506a4c051e35c93f127ffdb035" - }, "@zod/zod@4.3.5": { "integrity": "3d14553f025d6e3d1a836b9c56e366bbce27d942301e8c7c1ac4c365df5f8085" } @@ -340,8 +320,7 @@ "minipass", "minizlib", "yallist" - ], - "deprecated": true + ] }, "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" @@ -371,9 +350,9 @@ }, "workspace": { "dependencies": [ + "jsr:@david/dax@~0.44.2", "jsr:@openai/openai@^6.16.0", "jsr:@zod/zod@^4.3.5" - "jsr:@david/dax@~0.44.2" ], "packageJson": { "dependencies": [ diff --git a/supabase/config.toml b/supabase/config.toml index 1d4bdc8..d81e000 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -15,3 +15,14 @@ entrypoint = "./functions/ai-generate-plan/index.ts" # enabled = false # verify_jwt = false # import_map = "./functions/ai-structured/deno.json" + +[functions.create-chat-conversation] +enabled = true +verify_jwt = true +import_map = "./functions/create-chat-conversation/deno.json" +# Uncomment to specify a custom file path to the entrypoint. +# Supported file extensions are: .ts, .js, .mjs, .jsx, .tsx +entrypoint = "./functions/create-chat-conversation/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/create-chat-conversation/*.html" ] diff --git a/supabase/functions/create-chat-conversation/.npmrc b/supabase/functions/create-chat-conversation/.npmrc new file mode 100644 index 0000000..48c6388 --- /dev/null +++ b/supabase/functions/create-chat-conversation/.npmrc @@ -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 diff --git a/supabase/functions/create-chat-conversation/deno.json b/supabase/functions/create-chat-conversation/deno.json new file mode 100644 index 0000000..758d070 --- /dev/null +++ b/supabase/functions/create-chat-conversation/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/functions-js": "jsr:@supabase/functions-js@^2" + } +} diff --git a/supabase/functions/create-chat-conversation/index.ts b/supabase/functions/create-chat-conversation/index.ts new file mode 100644 index 0000000..cadbed2 --- /dev/null +++ b/supabase/functions/create-chat-conversation/index.ts @@ -0,0 +1,134 @@ +// Setup type definitions for built-in Supabase Runtime APIs +import "@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +type WebhookPayload = { + type: "INSERT" | "UPDATE" | "DELETE"; + table: string; + schema: string; + record: Record | null; + old_record: Record | null; +}; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL") ?? ""; +const SUPABASE_SERVICE_ROLE_KEY = + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""; +const OPENAI_API_KEY = Deno.env.get("OPENAI_API_KEY") ?? ""; +const OPENAI_BASE_URL = + Deno.env.get("OPENAI_BASE_URL") ?? "https://api.openai.com/v1"; + +const ALLOWED_SCHEMA = "public"; +const ALLOWED_TABLES = new Set(["planes_estudio", "asignaturas"]); + +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, +}); + +function jsonResponse(status: number, body: Record) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +async function createConversationId(metadata: Record) { + const response = await fetch(`${OPENAI_BASE_URL}/conversations`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${OPENAI_API_KEY}`, + }, + body: JSON.stringify({ metadata }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI error: ${response.status} ${errorText}`); + } + + const data = await response.json(); + const conversationId = data?.id as string | undefined; + + if (!conversationId) { + throw new Error("OpenAI response missing conversation id"); + } + + return conversationId; +} + +Deno.serve(async (req) => { + if (req.method !== "POST") { + return jsonResponse(405, { error: "Method not allowed" }); + } + + if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) { + return jsonResponse(500, { error: "Supabase env vars missing" }); + } + + if (!OPENAI_API_KEY) { + return jsonResponse(500, { error: "OPENAI_API_KEY missing" }); + } + + let payload: WebhookPayload; + try { + payload = await req.json(); + } catch { + return jsonResponse(400, { error: "Invalid JSON body" }); + } + + const { type, table, schema, record } = payload; + + if (schema !== ALLOWED_SCHEMA || !ALLOWED_TABLES.has(table)) { + return jsonResponse(400, { error: "Table not allowed" }); + } + + if (type !== "INSERT") { + return jsonResponse(200, { status: "ignored", reason: "event_type" }); + } + + if (!record || typeof record !== "object") { + return jsonResponse(400, { error: "Missing record" }); + } + + const recordId = record["id"] as string | number | undefined; + if (!recordId) { + return jsonResponse(400, { error: "Record id missing" }); + } + + const existingConversationId = record["conversation_id"] as + | string + | null + | undefined; + if (existingConversationId) { + return jsonResponse(200, { + status: "skipped", + conversation_id: existingConversationId, + }); + } + + let conversationId: string; + try { + conversationId = await createConversationId({ + table, + record_id: String(recordId), + }); + } catch (error) { + return jsonResponse(502, { error: (error as Error).message }); + } + + const { error } = await supabase + .from(table) + .update({ conversation_id: conversationId }) + .eq("id", recordId); + + if (error) { + return jsonResponse(500, { error: error.message }); + } + + return jsonResponse(200, { + status: "updated", + table, + record_id: recordId, + conversation_id: conversationId, + }); +});