From f5c84e87cff8799df590e703c3aef32ed09d2e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:43:51 +0000 Subject: [PATCH] Publicar: la persona elige el nombre de su app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hasta ahora la app publicada se llamaba como el id de la sala (vibe-fiesta-45.pages.dev), que no le dice nada a nadie. Ahora el panel de publicar trae un campo "Nombre de tu app" que arranca con el nombre de la sala y enseña en vivo cómo queda la dirección (mi-tienda-de-cafe.pages.dev). - Se elige solo antes de la primera publicación: Cloudflare no deja renombrar un proyecto, y cambiarlo después dejaría el link viejo congelado o roto. Las salas ya publicadas siguen con su nombre (su id) y republicar no lo cambia. - nombreDeApp() pasa lo escrito a algo que acepta un subdominio (sin acentos, guiones, 58 caracteres). El server lo vuelve a filtrar siempre. - Dos salas no pueden usar el mismo nombre (se pisarían el proyecto): se revisa contra la base, contra el id de las salas viejas publicadas, y contra las que se están publicando en ese momento. - El nombre se guarda solo si la publicación sale (columna nombre_publicado). - sacarUrl acepta el sufijo que pone Cloudflare cuando el subdominio ya lo tiene otra cuenta (nombre-abc.pages.dev). - En celular el panel crecía hacia afuera de la pantalla y cortaba el campo: ahora va a lo ancho, debajo del botón. - demo:publicar suma 12 checks y entra al CI. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01HJ5oT2Xm3VZMvbPAcgKbz4 --- .github/workflows/ci.yml | 1 + server/src/demos/publicar.ts | 47 +- server/src/engine/publicar.ts | 61 +- server/src/index.ts | 62 +- server/src/rooms.ts | 8 + server/src/storage/sqlite.ts | 1280 +++++++++++++++++---------------- server/src/storage/types.ts | 558 +++++++------- web/src/App.tsx | 21 +- web/src/PublicarPanel.tsx | 60 +- web/src/i18n.tsx | 6 + web/src/styles.css | 64 ++ 11 files changed, 1235 insertions(+), 933 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d243bf6..52f7b5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: - run: npx playwright-core install --with-deps chromium - run: npm run demo:ver-preview - run: npm run demo:internet + - run: npm run demo:publicar - run: npm run demo:documento - run: npm run demo:doc-sync - run: npm run demo:comentarios diff --git a/server/src/demos/publicar.ts b/server/src/demos/publicar.ts index 8da2913..9bdba1d 100644 --- a/server/src/demos/publicar.ts +++ b/server/src/demos/publicar.ts @@ -1,8 +1,10 @@ -import { writeFile, mkdir } from "node:fs/promises"; +import { writeFile, mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { createWorkspace } from "../engine/workspace.js"; import { detectBuild, buscarSalida } from "../engine/preview.js"; -import { credencialDeDeploy, motivoDeFallo, sacarUrl } from "../engine/publicar.js"; +import { credencialDeDeploy, motivoDeFallo, sacarUrl, nombreDeApp } from "../engine/publicar.js"; +import { SqliteStorage } from "../storage/sqlite.js"; /** * Demo: qué se compila y qué se sube al publicar la app de una sala. @@ -127,6 +129,47 @@ async function main() { check("sin URL en la salida, no se inventa una", sacarUrl("todo mal", "una-sala") === null); } + console.log("\n9b. El nombre lo elige quien publica"); + { + check("Página tipo Uber!! → pagina-tipo-uber", nombreDeApp("Página tipo Uber!!") === "pagina-tipo-uber", String(nombreDeApp("Página tipo Uber!!"))); + check("ñ y espacios de sobra", nombreDeApp(" Año Ñandú ") === "ano-nandu", String(nombreDeApp(" Año Ñandú "))); + check("solo símbolos no da nombre", nombreDeApp("!!! ???") === null); + const largo = nombreDeApp("a".repeat(57) + "-bcd") ?? ""; + check("se corta a 58 y sin guion al final", largo.length <= 58 && !largo.endsWith("-"), largo); + check("un id de sala se queda igual", nombreDeApp("vibe-fiesta-45") === "vibe-fiesta-45"); + + // Los subdominios de pages.dev son de todo Cloudflare: si otra cuenta ya + // tiene el nombre, el proyecto queda con un sufijo. + const conSufijo = "✨ Deployment complete! Take a peek over at https://9f1c2a3b.mi-banco-3xk.pages.dev"; + check( + "con sufijo de Cloudflare devuelve la estable con sufijo", + sacarUrl(conSufijo, "mi-banco") === "https://mi-banco-3xk.pages.dev", + String(sacarUrl(conSufijo, "mi-banco")), + ); + check("sin hash ni sufijo", sacarUrl("https://mi-banco.pages.dev/", "mi-banco") === "https://mi-banco.pages.dev"); + + const dir = await mkdtemp(join(tmpdir(), "multi-nombres-")); + try { + const db = new SqliteStorage(join(dir, "multi.db")); + await db.init(); + const sala = (id: string) => ({ id, workspaceDir: join(dir, id), createdAt: 1, lastActiveAt: 1 }); + await db.createRoom(sala("vieja-sala-1")); + await db.createRoom(sala("nueva-sala-2")); + await db.createRoom(sala("otra-sala-3")); + // Una que publicó antes de que se pudiera elegir: su proyecto se llama como su id. + await db.setPublicacion("vieja-sala-1", "https://vieja-sala-1.pages.dev", "vieja-sala-1"); + await db.setPublicacion("nueva-sala-2", "https://mi-banco.pages.dev", "mi-banco"); + check("un nombre que usa otra sala está tomado", await db.nombreEnUso("mi-banco", "otra-sala-3")); + check("pero no para la sala que lo tiene", !(await db.nombreEnUso("mi-banco", "nueva-sala-2"))); + check("el id de una sala vieja publicada está tomado", await db.nombreEnUso("vieja-sala-1", "otra-sala-3")); + check("un nombre libre no", !(await db.nombreEnUso("mi-tienda", "otra-sala-3"))); + const leida = await db.getRoom("nueva-sala-2"); + check("se guarda y se lee el nombre", leida?.nombrePublicado === "mi-banco", JSON.stringify(leida)); + } finally { + await rm(dir, { recursive: true, force: true }); + } + } + console.log("\n10. Una app con servidor propio no cabe, y se dice antes de subir"); { /** diff --git a/server/src/engine/publicar.ts b/server/src/engine/publicar.ts index 536f8e6..d1647c5 100644 --- a/server/src/engine/publicar.ts +++ b/server/src/engine/publicar.ts @@ -52,8 +52,20 @@ export async function publicarSala( workspace: Workspace, runner: Runner, cred: Credencial, + /** + * El nombre del proyecto en Cloudflare, que es la dirección de la app. + * + * Siempre el mismo para la misma sala: así republicar pisa lo anterior y el + * link que alguien ya compartió sigue sirviendo, en vez de acumular una URL + * nueva cada vez. Lo decide quien llama (ver /rooms/:id/publicar). + */ + nombre: string, onEtapa?: (etapa: EtapaDeploy) => void, ): Promise { + // Se filtra aunque ya venga limpio: acaba en un dominio y en un comando. + const proyecto = nombreDeApp(nombre); + if (!proyecto) throw new NoSePudoPublicar("ese nombre no sirve para una dirección web"); + const build = await detectBuild(workspace.dir); if (!build) { throw new NoSePudoPublicar( @@ -90,17 +102,6 @@ export async function publicarSala( ); } - /** - * El nombre del proyecto en Cloudflare es el id de la sala. - * - * Así republicar pisa lo anterior y el link que alguien ya compartió sigue - * sirviendo, en vez de acumular una URL nueva por cada vez que se publica. - * - * Se filtra aunque los ids ya vengan limpios: acaba en un dominio público y en - * un argumento de línea de comandos. - */ - const proyecto = workspace.roomId.replace(/[^a-z0-9-]/gi, "").toLowerCase().slice(0, 58); - onEtapa?.("subiendo"); /** @@ -163,11 +164,39 @@ export async function publicarSala( * lo esperado y es mejor no inventar una URL que quizá no carga. */ export function sacarUrl(salida: string, proyecto: string): string | null { - const conPrefijo = salida.match(/https:\/\/[a-z0-9.-]*\.pages\.dev[^\s]*/i)?.[0]; - if (!conPrefijo) return null; - return conPrefijo.includes(`${proyecto}.pages.dev`) - ? `https://${proyecto}.pages.dev` - : conPrefijo; + const urls = salida.match(/https:\/\/[a-z0-9.-]*\.pages\.dev[^\s]*/gi); + if (!urls) return null; + for (const url of urls) { + const etiquetas = new URL(url).hostname.split("."); + // `hash.sub.pages.dev` o `sub.pages.dev`. + const sub = etiquetas.length === 4 ? etiquetas[1]! : etiquetas[0]!; + // Los subdominios de pages.dev son de todo Cloudflare, no de esta cuenta: + // si el nombre ya lo tiene otra cuenta, el proyecto queda en + // `nombre-abc.pages.dev`. Esa es la estable, aunque no sea la que se pidió. + if (sub === proyecto || sub.startsWith(`${proyecto}-`)) return `https://${sub}.pages.dev`; + } + return urls[0]!; +} + +/** + * El nombre de una app para su dirección: `nombre.pages.dev`. + * + * Lo escribe la persona ("Página tipo Uber!!"), así que se pasa a lo que acepta + * un subdominio: minúsculas sin acentos, guiones en lugar de lo demás, y el tope + * de 58 que pone Cloudflare. Acaba en un dominio público y en un argumento de + * línea de comandos, así que se filtra siempre, venga de donde venga. + * `null` si no quedó nada. + */ +export function nombreDeApp(texto: string): string | null { + const limpio = texto + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 58) + .replace(/-+$/g, ""); + return limpio || null; } /** diff --git a/server/src/index.ts b/server/src/index.ts index 07c16cd..585d73d 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -131,6 +131,7 @@ import { } from "./supabase.js"; import { publicarSala, + nombreDeApp, credencialDeDeploy, NoSePudoPublicar, type Credencial, @@ -1456,7 +1457,7 @@ fastify.delete<{ Params: { id: string } }>("/rooms/:id/supabase", async (req) => * petición colgada ese rato la mataría por timeout. Lo que pasa después viaja * por socket, que además llega a TODA la sala y no solo a quien lo pidió. */ -fastify.post<{ Params: { id: string } }>("/rooms/:id/publicar", async (req, reply) => { +fastify.post<{ Params: { id: string }; Body: { nombre?: unknown } | undefined }>("/rooms/:id/publicar", async (req, reply) => { const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id)); if (!room) return reply.code(404).send({ error: "sala no encontrada" }); @@ -1470,11 +1471,55 @@ fastify.post<{ Params: { id: string } }>("/rooms/:id/publicar", async (req, repl return reply.code(409).send({ error: "ya se está publicando", etapa: room.publicando }); } - void publicarEnSegundoPlano(room, cred); + const nombre = await nombreParaPublicar(room, req.body?.nombre); + if ("error" in nombre) return reply.code(nombre.codigo).send({ error: nombre.error }); + + void publicarEnSegundoPlano(room, cred, nombre.nombre); return { ok: true }; }); -async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise { +/** + * Con qué nombre se publica: la dirección `nombre.pages.dev`. + * + * Se elige una vez, antes de la primera publicación. Cloudflare no deja + * renombrar un proyecto: cambiarlo después dejaría el link que ya se compartió + * apuntando a una versión vieja, o roto. Así que una sala que ya publicó sigue + * con el suyo y lo que llegue en el pedido se ignora; una que publicó antes de + * que se pudiera elegir se llama como su id, que era la regla de entonces. + * + * Para la primera vez: lo que escribió la persona, o el nombre de la sala, o el + * id, en ese orden. + */ +async function nombreParaPublicar( + room: Room, + pedido: unknown, +): Promise<{ nombre: string } | { error: string; codigo: 400 | 409 }> { + if (room.nombrePublicado) return { nombre: room.nombrePublicado }; + if (room.urlPublicada) return { nombre: nombreDeApp(room.id) ?? room.id }; + + const escrito = typeof pedido === "string" && pedido.trim() ? pedido : null; + const nombre = escrito + ? nombreDeApp(escrito) + : (room.nombre ? nombreDeApp(room.nombre) : null) ?? nombreDeApp(room.id); + if (!nombre) return { error: "ese nombre no sirve: usa letras o números", codigo: 400 }; + const otraLoEstaPublicando = [...nombresPublicandose].some(([sala, n]) => n === nombre && sala !== room.id); + if (otraLoEstaPublicando || (await (await getStorage()).nombreEnUso(nombre, room.id))) { + return { error: "ese nombre ya lo usa otra app de Multti", codigo: 409 }; + } + return { nombre }; +} + +/** + * Los nombres que se están publicando ahorita, por sala. + * + * El nombre se guarda en la base hasta que la publicación sale, y un build tarda + * minutos: sin esto, dos salas que publican al mismo tiempo con el mismo nombre + * pasarían las dos la revisión y la segunda pisaría a la primera. + */ +const nombresPublicandose = new Map(); + +async function publicarEnSegundoPlano(room: Room, cred: Credencial, nombre: string): Promise { + nombresPublicandose.set(room.id, nombre); const avisar = (etapa: EtapaDeploy) => { room.publicando = etapa; io.to(room.id).emit("deploy:progreso", { etapa }); @@ -1482,10 +1527,13 @@ async function publicarEnSegundoPlano(room: Room, cred: Credencial): Promise { // Dónde está publicada, si lo está: es un dato de la sala, no una noticia // de hace rato. Quien entra mañana tiene que poder abrirla igual. urlPublicada: room.urlPublicada ?? null, + nombrePublicado: room.nombrePublicado ?? null, agents: room.agents.list(), actividad: Object.fromEntries(ultimaAccion.get(room.id) ?? []), orphanTurns: room.orphanTurns ?? [], diff --git a/server/src/rooms.ts b/server/src/rooms.ts index dd80ef9..a7c2ee4 100644 --- a/server/src/rooms.ts +++ b/server/src/rooms.ts @@ -86,6 +86,13 @@ export interface Room { * estaba enterrado entre los demás. */ urlPublicada?: string | null; + /** + * Con qué nombre se publicó: `nombre.pages.dev`. Se elige una sola vez, antes + * de la primera publicación, porque Cloudflare no deja renombrar un proyecto. + * Null en una sala sin publicar, o en una que publicó antes de que se pudiera + * elegir (esa se llama como su id). + */ + nombrePublicado?: string | null; /** * Si en esta sala hay que mencionar al agente para despertarlo. * @@ -277,6 +284,7 @@ async function despertarSala(id: string): Promise { id, nombre: stored.nombre ?? null, urlPublicada: stored.urlPublicada ?? null, + nombrePublicado: stored.nombrePublicado ?? null, // Las salas anteriores a esta columna vuelven en multi, que es como se // comportaron siempre: nada cambia debajo de quien ya las usaba. modo: stored.modo ?? "multi", diff --git a/server/src/storage/sqlite.ts b/server/src/storage/sqlite.ts index c6f59a3..ff9ca63 100644 --- a/server/src/storage/sqlite.ts +++ b/server/src/storage/sqlite.ts @@ -1,629 +1,651 @@ -import { DatabaseSync } from "node:sqlite"; -import { mkdirSync } from "node:fs"; -import { dirname } from "node:path"; -import type { Message } from "../agent/providers/types.js"; -import { cifrar, descifrar } from "../cripto.js"; -import type { - ConexionSupabase, - ModoDeSala, - SalaDeUsuario, - Storage, - StoredMessage, - StoredRoom, - StoredUsuario, - StoredComentario, -} from "./types.js"; - -/** - * Persistencia con SQLite (node:sqlite, nativo en Node 22 — cero dependencias). - * - * Es el default a propósito: alguien clona el repo, corre npm install, y - * funciona. Sin crear cuentas ni configurar credenciales. Ese quickstart de - * "clonar y correr" es lo que hace que un proyecto open source se pruebe. - */ -export class SqliteStorage implements Storage { - private db: DatabaseSync; - - constructor(path: string) { - mkdirSync(dirname(path), { recursive: true }); - this.db = new DatabaseSync(path); - // WAL: mejor concurrencia lectura/escritura (varias salas a la vez). - this.db.exec("PRAGMA journal_mode = WAL"); - this.db.exec("PRAGMA foreign_keys = ON"); - } - - async init(): Promise { - this.db.exec(` - CREATE TABLE IF NOT EXISTS rooms ( - id TEXT PRIMARY KEY, - workspace_dir TEXT NOT NULL, - created_at INTEGER NOT NULL, - last_active_at INTEGER NOT NULL, - -- Cómo le dice la gente a esta sala. El id no cambia nunca (es la URL y - -- lo que se dicta por teléfono); esto es solo la etiqueta que se ve. - nombre TEXT, - -- Dónde quedó publicada la app, si es que se publicó. - url_publicada TEXT, - -- Si hay que mencionar al agente. Null es "multi": las salas de antes - -- de esta columna siguen comportándose igual que siempre. - modo TEXT - ); - - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - author TEXT NOT NULL, - color TEXT NOT NULL, - role TEXT NOT NULL, - text TEXT NOT NULL, - anchored_to TEXT, - adjuntos TEXT, - created_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS messages_room_idx ON messages(room_id, id); - - CREATE TABLE IF NOT EXISTS agent_histories ( - room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - agent_id TEXT NOT NULL, - messages TEXT NOT NULL, - updated_at INTEGER NOT NULL, - PRIMARY KEY (room_id, agent_id) - ); - - -- Quien quiso tener cuenta. La mayoría de la gente no va a estar aquí, y - -- eso está bien: entrar a una sala nunca la pide. - CREATE TABLE IF NOT EXISTS usuarios ( - id TEXT PRIMARY KEY, - -- El identificador de Google. Es lo que no cambia aunque la persona - -- cambie de correo, así que la cuenta se ata a esto y no al correo. - google_sub TEXT NOT NULL UNIQUE, - nombre TEXT NOT NULL, - correo TEXT NOT NULL, - -- La URL de la foto en los servidores de Google. Se guarda la URL y no - -- la imagen: es pública y estable, y bajarla sería hospedar fotos de - -- perfil para no ganar nada. - foto TEXT, - creado_en INTEGER NOT NULL - ); - - CREATE TABLE IF NOT EXISTS sesiones ( - token TEXT PRIMARY KEY, - usuario_id TEXT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - creada_en INTEGER NOT NULL, - expira_en INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS sesiones_usuario_idx ON sesiones(usuario_id); - - -- Las salas de quien tiene cuenta: el mismo historial que sin cuenta vive - -- en el navegador, pero que aquí le sigue al celular y sobrevive a un - -- borrado de datos del navegador. - CREATE TABLE IF NOT EXISTS salas_de_usuario ( - usuario_id TEXT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, - room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - visitada_en INTEGER NOT NULL, - PRIMARY KEY (usuario_id, room_id) - ); - - -- La base de datos que una sala conectó en Supabase. - -- - -- Los tres campos de secreto van CIFRADOS (ver cripto.ts): la llave vive - -- en el .env del server, así que una copia de este archivo no los revela. - -- - -- El CASCADE importa: borrar una sala se lleva su conexión, y sin él - -- quedarían tokens de acceso a cuentas ajenas apuntando a nada. - CREATE TABLE IF NOT EXISTS supabase_salas ( - room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE, - acceso TEXT NOT NULL, - refresco TEXT NOT NULL, - expira_en INTEGER NOT NULL, - -- Null mientras el proyecto se está creando: autorizar y tener base - -- lista son dos momentos distintos, y el segundo tarda minutos. - proyecto TEXT, - -- Supabase no la devuelve nunca, así que si no se guarda aquí se pierde. - password TEXT, - conectado_en INTEGER NOT NULL - ); - `); - - // Las salas que ya existían tienen la tabla sin la columna de adjuntos: el - // CREATE de arriba no las toca porque lleva IF NOT EXISTS. Agregarla a mano - // y tragarse el error si ya está es la forma barata de migrar sin llevar - // versiones de esquema, que para una columna nueva sería desproporcionado. - try { - this.db.exec(`ALTER TABLE messages ADD COLUMN adjuntos TEXT`); - } catch { - // ya la tiene - } - - // Dónde quedó publicada la app de esta sala, si es que se publicó. Nullable: - // sin esto no hay forma de saber, al volver mañana, si la sala está en vivo. - try { - this.db.exec(`ALTER TABLE rooms ADD COLUMN url_publicada TEXT`); - } catch { - // ya la tiene - } - - // El nombre que la gente le pone a la sala. Nullable a propósito: sin él la - // interfaz cae al id, que es lo que se ha visto siempre. - try { - this.db.exec(`ALTER TABLE rooms ADD COLUMN nombre TEXT`); - } catch { - // ya la tiene - } - - // Si en la sala hay que mencionar al agente. Nullable a propósito: las 64 - // salas que ya existían quedan en null, que se lee como "multi", y así - // ninguna cambia de comportamiento por debajo de quien la estaba usando. - try { - this.db.exec(`ALTER TABLE rooms ADD COLUMN modo TEXT`); - } catch { - // ya la tiene - } - - // Quién escribió el mensaje, si tenía cuenta. Sirve para pintar su foto. - // - // Lo de antes se queda en NULL y NO se rellena hacia atrás: atarlo por - // coincidencia de nombre sería adivinar, y dos personas distintas pueden - // haberse llamado igual en salas distintas. Atribuirle a alguien mensajes - // que no escribió es peor que mostrar una inicial. - // - // Sin FK a propósito: con ON DELETE CASCADE, borrar una cuenta borraría los - // mensajes de esa persona, y lo que se dijo en la sala es de la sala. Así, - // la columna queda apuntando a nadie y el mensaje cae a su inicial. - try { - this.db.exec(`ALTER TABLE messages ADD COLUMN usuario_id TEXT`); - } catch { - // ya la tiene - } - - // Los comentarios del documento. Aparte de los mensajes: viven anclados a - // un texto y en hilos, no en la línea del chat. - this.db.exec(` - CREATE TABLE IF NOT EXISTS comentarios ( - id TEXT PRIMARY KEY, - room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, - hilo_id TEXT NOT NULL, - ancla TEXT, - cita TEXT, - autor TEXT NOT NULL, - color TEXT NOT NULL, - rol TEXT NOT NULL, - usuario_id TEXT, - texto TEXT NOT NULL, - creado INTEGER NOT NULL, - resuelto INTEGER NOT NULL DEFAULT 0 - ); - CREATE INDEX IF NOT EXISTS comentarios_sala_idx ON comentarios(room_id, creado); - `); - } - - /** - * El `modo` se escribe al nacer, no se deja para despues. - * - * Antes esta columna se quedaba en NULL, porque la sala solo la guardaba - * cuando alguien la cambiaba, o sea cuando entraba una segunda persona. Para - * quien se quedaba solo no se escribia nunca, y al leerla de vuelta el NULL - * cae a "multi" (ver `toRoom`): la sala nacia en solo, funcionaba en solo - * mientras viviera en memoria, y al reiniciar el server o al despertar de - * dormida volvia pidiendo la arroba sin que nadie hubiera tocado el boton. - * - * Le pegaba justo a quien el modo de una persona venia a servir, y ahora mas, - * porque desde que escribir crea la sala todas nacen en solo. - * - * Ojo con el `INSERT OR REPLACE`: si algun dia se llama con un id que ya - * existe, REPLACE borra la fila entera y la reescribe solo con estas - * columnas, tirando el nombre y la url publicada. Hoy no pasa porque solo se - * llama al crear, con un id que se acaba de comprobar que es nuevo. - */ - async createRoom(room: StoredRoom): Promise { - this.db - .prepare( - `INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo) - VALUES (?, ?, ?, ?, ?)`, - ) - .run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null); - } - - async getRoom(id: string): Promise { - const row = this.db.prepare(`SELECT * FROM rooms WHERE id = ?`).get(id) as - | Record - | undefined; - return row ? toRoom(row) : null; - } - - async listRooms(): Promise { - const rows = this.db - .prepare(`SELECT * FROM rooms ORDER BY last_active_at DESC`) - .all() as Record[]; - return rows.map(toRoom); - } - - async touchRoom(id: string): Promise { - this.db.prepare(`UPDATE rooms SET last_active_at = ? WHERE id = ?`).run(Date.now(), id); - } - - async renameRoom(id: string, nombre: string | null): Promise { - this.db.prepare(`UPDATE rooms SET nombre = ? WHERE id = ?`).run(nombre, id); - } - - async setModo(id: string, modo: ModoDeSala): Promise { - this.db.prepare(`UPDATE rooms SET modo = ? WHERE id = ?`).run(modo, id); - } - - async setUrlPublicada(id: string, url: string): Promise { - this.db.prepare(`UPDATE rooms SET url_publicada = ? WHERE id = ?`).run(url, id); - } - - /** - * Los mensajes y los historiales se van solos: sus tablas declaran - * `ON DELETE CASCADE` contra `rooms(id)` y el PRAGMA de claves foráneas está - * activo desde el constructor. Aun así se borran a mano primero, porque si - * ese PRAGMA se cayera algún día esto dejaría filas huérfanas en silencio y - * la BD crecería sin que nadie lo notara. - */ - async deleteRoom(id: string): Promise { - this.db.prepare(`DELETE FROM agent_histories WHERE room_id = ?`).run(id); - this.db.prepare(`DELETE FROM messages WHERE room_id = ?`).run(id); - this.db.prepare(`DELETE FROM rooms WHERE id = ?`).run(id); - } - - async appendMessage(m: StoredMessage): Promise { - this.db - .prepare( - `INSERT INTO messages (room_id, author, color, role, text, anchored_to, adjuntos, created_at, usuario_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - m.roomId, - m.author, - m.color, - m.role, - m.text, - m.anchoredTo ?? null, - m.adjuntos?.length ? JSON.stringify(m.adjuntos) : null, - m.createdAt, - m.usuarioId ?? null, - ); - } - - /** - * Los últimos mensajes de la sala, con la foto de quien los escribió. - * - * La foto sale de un JOIN y no de la fila: si alguien cambia su foto, sus - * mensajes viejos muestran la nueva. Es lo que hace todo el mundo y lo que la - * gente espera. El LEFT es lo que importa: la enorme mayoría de los mensajes - * no tiene cuenta detrás y tienen que salir igual. - */ - async getMessages(roomId: string, limit = 200): Promise { - const rows = this.db - .prepare( - `SELECT * FROM ( - SELECT m.*, u.foto AS foto_usuario - FROM messages m - LEFT JOIN usuarios u ON u.id = m.usuario_id - WHERE m.room_id = ? ORDER BY m.id DESC LIMIT ? - ) ORDER BY id ASC`, - ) - .all(roomId, limit) as Record[]; - return rows.map((r) => ({ - roomId: String(r.room_id), - author: String(r.author), - color: String(r.color), - role: String(r.role) as StoredMessage["role"], - text: String(r.text), - anchoredTo: r.anchored_to ? String(r.anchored_to) : undefined, - adjuntos: parseAdjuntos(r.adjuntos), - createdAt: Number(r.created_at), - usuarioId: r.usuario_id ? String(r.usuario_id) : null, - foto: r.foto_usuario ? String(r.foto_usuario) : null, - })); - } - - async saveAgentHistory(roomId: string, agentId: string, messages: Message[]): Promise { - this.db - .prepare( - `INSERT INTO agent_histories (room_id, agent_id, messages, updated_at) - VALUES (?, ?, ?, ?) - ON CONFLICT(room_id, agent_id) DO UPDATE SET messages = excluded.messages, - updated_at = excluded.updated_at`, - ) - .run(roomId, agentId, JSON.stringify(messages), Date.now()); - } - - async listAgentIds(roomId: string): Promise { - const filas = this.db - .prepare(`SELECT agent_id FROM agent_histories WHERE room_id = ? ORDER BY agent_id`) - .all(roomId) as Array<{ agent_id: string }>; - return filas.map((f) => f.agent_id); - } - - async getAgentHistory(roomId: string, agentId: string): Promise { - const row = this.db - .prepare(`SELECT messages FROM agent_histories WHERE room_id = ? AND agent_id = ?`) - .get(roomId, agentId) as { messages?: string } | undefined; - if (!row?.messages) return []; - try { - return JSON.parse(row.messages) as Message[]; - } catch { - return []; - } - } - - // ── Cuentas ───────────────────────────────────────────────────────────── - - async usuarioPorGoogleSub(googleSub: string): Promise { - const row = this.db - .prepare(`SELECT * FROM usuarios WHERE google_sub = ?`) - .get(googleSub) as Record | undefined; - return row ? toUsuario(row) : null; - } - - async crearUsuario(u: StoredUsuario): Promise { - this.db - .prepare( - `INSERT INTO usuarios (id, google_sub, nombre, correo, foto, creado_en) - VALUES (?, ?, ?, ?, ?, ?)`, - ) - .run(u.id, u.googleSub, u.nombre, u.correo, u.foto ?? null, u.creadoEn); - } - - async actualizarUsuario( - id: string, - datos: { nombre: string; correo: string; foto: string | null }, - ): Promise { - this.db - .prepare(`UPDATE usuarios SET nombre = ?, correo = ?, foto = ? WHERE id = ?`) - .run(datos.nombre, datos.correo, datos.foto, id); - } - - /** - * El dueño de una sesión, comprobando de paso que no haya vencido. - * - * La expiración se filtra en el SELECT y no después: así una sesión vencida - * es indistinguible de una que no existe, y no hay forma de olvidarse de - * comprobarla en algún camino. - */ - async usuarioPorSesion(token: string): Promise { - const row = this.db - .prepare( - `SELECT u.* FROM sesiones s - JOIN usuarios u ON u.id = s.usuario_id - WHERE s.token = ? AND s.expira_en > ?`, - ) - .get(token, Date.now()) as Record | undefined; - return row ? toUsuario(row) : null; - } - - async crearSesion(token: string, usuarioId: string, expiraEn: number): Promise { - this.db - .prepare(`INSERT INTO sesiones (token, usuario_id, creada_en, expira_en) VALUES (?, ?, ?, ?)`) - .run(token, usuarioId, Date.now(), expiraEn); - } - - async borrarSesion(token: string): Promise { - this.db.prepare(`DELETE FROM sesiones WHERE token = ?`).run(token); - } - - async borrarSesionesVencidas(): Promise { - this.db.prepare(`DELETE FROM sesiones WHERE expira_en <= ?`).run(Date.now()); - } - - /** - * Las salas de alguien, de la visita más reciente hacia atrás. - * - * El JOIN contra `rooms` no es decorativo: filtra las que ya se borraron. Sin - * él la cuenta acumularía para siempre salas que no existen. - */ - async salasDeUsuario(usuarioId: string): Promise { - const rows = this.db - .prepare( - `SELECT s.room_id, s.visitada_en, r.nombre FROM salas_de_usuario s - JOIN rooms r ON r.id = s.room_id - WHERE s.usuario_id = ? ORDER BY s.visitada_en DESC`, - ) - .all(usuarioId) as Record[]; - return rows.map((r) => ({ - roomId: String(r.room_id), - visitadaEn: Number(r.visitada_en), - // El nombre sale del JOIN que ya estaba ahi. Sin el, la lista de quien - // entra con cuenta pisaba la del navegador (que si los tenia) y todas las - // salas volvian a verse por su id. - nombre: r.nombre == null ? null : String(r.nombre), - })); - } - - async recordarSalaDeUsuario(usuarioId: string, roomId: string): Promise { - this.db - .prepare( - `INSERT INTO salas_de_usuario (usuario_id, room_id, visitada_en) VALUES (?, ?, ?) - ON CONFLICT(usuario_id, room_id) DO UPDATE SET visitada_en = excluded.visitada_en`, - ) - .run(usuarioId, roomId, Date.now()); - } - - async olvidarSalaDeUsuario(usuarioId: string, roomId: string): Promise { - this.db - .prepare(`DELETE FROM salas_de_usuario WHERE usuario_id = ? AND room_id = ?`) - .run(usuarioId, roomId); - } - - // ── Supabase ────────────────────────────────────────────────────────────── - - async conexionSupabase(roomId: string): Promise { - const row = this.db.prepare(`SELECT * FROM supabase_salas WHERE room_id = ?`).get(roomId) as - | Record - | undefined; - if (!row) return null; - - const acceso = descifrar(String(row.acceso)); - const refresco = descifrar(String(row.refresco)); - // Si no se pueden leer, alguien cambió MULTI_LLAVE. La sala se trata como no - // conectada: sin los tokens no hay nada que hacer con esta fila, y pedir la - // autorización otra vez es la única salida. - if (acceso === null || refresco === null) { - console.error(`[supabase] la conexión de ${roomId} no se pudo descifrar`); - return null; - } - - return { - roomId, - acceso, - refresco, - expiraEn: Number(row.expira_en), - proyecto: row.proyecto == null ? null : String(row.proyecto), - // La contraseña sí puede venir en null sin que sea un problema: se guarda - // solo cuando Multi creó el proyecto. - password: row.password == null ? null : descifrar(String(row.password)), - conectadoEn: Number(row.conectado_en), - }; - } - - async guardarConexionSupabase(c: ConexionSupabase): Promise { - this.db - .prepare( - `INSERT OR REPLACE INTO supabase_salas - (room_id, acceso, refresco, expira_en, proyecto, password, conectado_en) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - c.roomId, - cifrar(c.acceso), - cifrar(c.refresco), - c.expiraEn, - c.proyecto ?? null, - c.password ? cifrar(c.password) : null, - c.conectadoEn, - ); - } - - /** - * Quita la conexión de la sala. - * - * Solo lo de aquí: el proyecto en Supabase sigue existiendo, porque es de la - * persona. Desconectar en Multi no puede borrarle una base de datos. - */ - async salasConSupabase(): Promise { - const rows = this.db.prepare(`SELECT room_id FROM supabase_salas`).all() as { room_id: string }[]; - return rows.map((r) => r.room_id); - } - - async actualizarConexionSupabase( - roomId: string, - campos: Partial>, - ): Promise { - const columnas: string[] = []; - const valores: (string | number | null)[] = []; - if (campos.acceso !== undefined) { - columnas.push("acceso = ?"); - valores.push(cifrar(campos.acceso)); - } - if (campos.refresco !== undefined) { - columnas.push("refresco = ?"); - valores.push(cifrar(campos.refresco)); - } - if (campos.expiraEn !== undefined) { - columnas.push("expira_en = ?"); - valores.push(campos.expiraEn); - } - if (campos.proyecto !== undefined) { - columnas.push("proyecto = ?"); - valores.push(campos.proyecto ?? null); - } - if (campos.password !== undefined) { - columnas.push("password = ?"); - valores.push(campos.password ? cifrar(campos.password) : null); - } - if (columnas.length === 0) return; - this.db - .prepare(`UPDATE supabase_salas SET ${columnas.join(", ")} WHERE room_id = ?`) - .run(...valores, roomId); - } - - async borrarConexionSupabase(roomId: string): Promise { - this.db.prepare(`DELETE FROM supabase_salas WHERE room_id = ?`).run(roomId); - } - - async getComentarios(roomId: string): Promise { - const filas = this.db - .prepare(`SELECT * FROM comentarios WHERE room_id = ? ORDER BY creado ASC, rowid ASC`) - .all(roomId) as Record[]; - return filas.map((r) => ({ - id: String(r.id), - roomId: String(r.room_id), - hiloId: String(r.hilo_id), - ancla: r.ancla ? String(r.ancla) : null, - cita: r.cita ? String(r.cita) : null, - autor: String(r.autor), - color: String(r.color), - rol: String(r.rol) as StoredComentario["rol"], - usuarioId: r.usuario_id ? String(r.usuario_id) : null, - texto: String(r.texto), - creado: Number(r.creado), - resuelto: Number(r.resuelto) === 1, - })); - } - - async addComentario(c: StoredComentario): Promise { - this.db - .prepare( - `INSERT INTO comentarios (id, room_id, hilo_id, ancla, cita, autor, color, rol, usuario_id, texto, creado, resuelto) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run(c.id, c.roomId, c.hiloId, c.ancla, c.cita, c.autor, c.color, c.rol, c.usuarioId, c.texto, c.creado, c.resuelto ? 1 : 0); - } - - async resolverHilo(roomId: string, hiloId: string, resuelto: boolean): Promise { - this.db - .prepare(`UPDATE comentarios SET resuelto = ? WHERE room_id = ? AND id = ?`) - .run(resuelto ? 1 : 0, roomId, hiloId); - } - - async close(): Promise { - this.db.close(); - } -} - -function toUsuario(r: Record): StoredUsuario { - return { - id: String(r.id), - googleSub: String(r.google_sub), - nombre: String(r.nombre), - correo: String(r.correo), - foto: r.foto == null ? null : String(r.foto), - creadoEn: Number(r.creado_en), - }; -} - -/** - * Los adjuntos de un mensaje, desde la columna. - * - * Llega null en todo lo anterior a esta columna, que son las 44 salas que ya - * existían. Un JSON corrupto tampoco debe tumbar la carga del chat: si no se - * puede leer, el mensaje se muestra sin sus imágenes, que es mejor que no - * mostrar el mensaje. - */ -function parseAdjuntos(v: unknown): StoredMessage["adjuntos"] { - if (typeof v !== "string" || !v) return undefined; - try { - const parsed = JSON.parse(v); - return Array.isArray(parsed) && parsed.length ? parsed : undefined; - } catch { - return undefined; - } -} - -function toRoom(r: Record): StoredRoom { - return { - id: String(r.id), - workspaceDir: String(r.workspace_dir), - createdAt: Number(r.created_at), - lastActiveAt: Number(r.last_active_at), - // Las salas de antes de esta columna la traen en null, y ahí la interfaz - // cae al id. - nombre: r.nombre == null ? null : String(r.nombre), - urlPublicada: r.url_publicada == null ? null : String(r.url_publicada), - // Solo "solo" cuenta como tal: null y cualquier cosa rara caen a multi, que - // es el comportamiento que ya tenían todas las salas. - modo: r.modo === "solo" ? "solo" : "multi", - }; -} +import { DatabaseSync } from "node:sqlite"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import type { Message } from "../agent/providers/types.js"; +import { cifrar, descifrar } from "../cripto.js"; +import type { + ConexionSupabase, + ModoDeSala, + SalaDeUsuario, + Storage, + StoredMessage, + StoredRoom, + StoredUsuario, + StoredComentario, +} from "./types.js"; + +/** + * Persistencia con SQLite (node:sqlite, nativo en Node 22 — cero dependencias). + * + * Es el default a propósito: alguien clona el repo, corre npm install, y + * funciona. Sin crear cuentas ni configurar credenciales. Ese quickstart de + * "clonar y correr" es lo que hace que un proyecto open source se pruebe. + */ +export class SqliteStorage implements Storage { + private db: DatabaseSync; + + constructor(path: string) { + mkdirSync(dirname(path), { recursive: true }); + this.db = new DatabaseSync(path); + // WAL: mejor concurrencia lectura/escritura (varias salas a la vez). + this.db.exec("PRAGMA journal_mode = WAL"); + this.db.exec("PRAGMA foreign_keys = ON"); + } + + async init(): Promise { + this.db.exec(` + CREATE TABLE IF NOT EXISTS rooms ( + id TEXT PRIMARY KEY, + workspace_dir TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_active_at INTEGER NOT NULL, + -- Cómo le dice la gente a esta sala. El id no cambia nunca (es la URL y + -- lo que se dicta por teléfono); esto es solo la etiqueta que se ve. + nombre TEXT, + -- Dónde quedó publicada la app, si es que se publicó. + url_publicada TEXT, + -- Con qué nombre se publicó (nombre.pages.dev). Null en las salas que + -- publicaron antes de esta columna: su proyecto se llama como su id. + nombre_publicado TEXT, + -- Si hay que mencionar al agente. Null es "multi": las salas de antes + -- de esta columna siguen comportándose igual que siempre. + modo TEXT + ); + + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + author TEXT NOT NULL, + color TEXT NOT NULL, + role TEXT NOT NULL, + text TEXT NOT NULL, + anchored_to TEXT, + adjuntos TEXT, + created_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS messages_room_idx ON messages(room_id, id); + + CREATE TABLE IF NOT EXISTS agent_histories ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL, + messages TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (room_id, agent_id) + ); + + -- Quien quiso tener cuenta. La mayoría de la gente no va a estar aquí, y + -- eso está bien: entrar a una sala nunca la pide. + CREATE TABLE IF NOT EXISTS usuarios ( + id TEXT PRIMARY KEY, + -- El identificador de Google. Es lo que no cambia aunque la persona + -- cambie de correo, así que la cuenta se ata a esto y no al correo. + google_sub TEXT NOT NULL UNIQUE, + nombre TEXT NOT NULL, + correo TEXT NOT NULL, + -- La URL de la foto en los servidores de Google. Se guarda la URL y no + -- la imagen: es pública y estable, y bajarla sería hospedar fotos de + -- perfil para no ganar nada. + foto TEXT, + creado_en INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sesiones ( + token TEXT PRIMARY KEY, + usuario_id TEXT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, + creada_en INTEGER NOT NULL, + expira_en INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS sesiones_usuario_idx ON sesiones(usuario_id); + + -- Las salas de quien tiene cuenta: el mismo historial que sin cuenta vive + -- en el navegador, pero que aquí le sigue al celular y sobrevive a un + -- borrado de datos del navegador. + CREATE TABLE IF NOT EXISTS salas_de_usuario ( + usuario_id TEXT NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + visitada_en INTEGER NOT NULL, + PRIMARY KEY (usuario_id, room_id) + ); + + -- La base de datos que una sala conectó en Supabase. + -- + -- Los tres campos de secreto van CIFRADOS (ver cripto.ts): la llave vive + -- en el .env del server, así que una copia de este archivo no los revela. + -- + -- El CASCADE importa: borrar una sala se lleva su conexión, y sin él + -- quedarían tokens de acceso a cuentas ajenas apuntando a nada. + CREATE TABLE IF NOT EXISTS supabase_salas ( + room_id TEXT PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE, + acceso TEXT NOT NULL, + refresco TEXT NOT NULL, + expira_en INTEGER NOT NULL, + -- Null mientras el proyecto se está creando: autorizar y tener base + -- lista son dos momentos distintos, y el segundo tarda minutos. + proyecto TEXT, + -- Supabase no la devuelve nunca, así que si no se guarda aquí se pierde. + password TEXT, + conectado_en INTEGER NOT NULL + ); + `); + + // Las salas que ya existían tienen la tabla sin la columna de adjuntos: el + // CREATE de arriba no las toca porque lleva IF NOT EXISTS. Agregarla a mano + // y tragarse el error si ya está es la forma barata de migrar sin llevar + // versiones de esquema, que para una columna nueva sería desproporcionado. + try { + this.db.exec(`ALTER TABLE messages ADD COLUMN adjuntos TEXT`); + } catch { + // ya la tiene + } + + // Dónde quedó publicada la app de esta sala, si es que se publicó. Nullable: + // sin esto no hay forma de saber, al volver mañana, si la sala está en vivo. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN url_publicada TEXT`); + } catch { + // ya la tiene + } + + // Con qué nombre se publicó, que elige quien publica la primera vez. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN nombre_publicado TEXT`); + } catch { + // ya la tiene + } + + // El nombre que la gente le pone a la sala. Nullable a propósito: sin él la + // interfaz cae al id, que es lo que se ha visto siempre. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN nombre TEXT`); + } catch { + // ya la tiene + } + + // Si en la sala hay que mencionar al agente. Nullable a propósito: las 64 + // salas que ya existían quedan en null, que se lee como "multi", y así + // ninguna cambia de comportamiento por debajo de quien la estaba usando. + try { + this.db.exec(`ALTER TABLE rooms ADD COLUMN modo TEXT`); + } catch { + // ya la tiene + } + + // Quién escribió el mensaje, si tenía cuenta. Sirve para pintar su foto. + // + // Lo de antes se queda en NULL y NO se rellena hacia atrás: atarlo por + // coincidencia de nombre sería adivinar, y dos personas distintas pueden + // haberse llamado igual en salas distintas. Atribuirle a alguien mensajes + // que no escribió es peor que mostrar una inicial. + // + // Sin FK a propósito: con ON DELETE CASCADE, borrar una cuenta borraría los + // mensajes de esa persona, y lo que se dijo en la sala es de la sala. Así, + // la columna queda apuntando a nadie y el mensaje cae a su inicial. + try { + this.db.exec(`ALTER TABLE messages ADD COLUMN usuario_id TEXT`); + } catch { + // ya la tiene + } + + // Los comentarios del documento. Aparte de los mensajes: viven anclados a + // un texto y en hilos, no en la línea del chat. + this.db.exec(` + CREATE TABLE IF NOT EXISTS comentarios ( + id TEXT PRIMARY KEY, + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + hilo_id TEXT NOT NULL, + ancla TEXT, + cita TEXT, + autor TEXT NOT NULL, + color TEXT NOT NULL, + rol TEXT NOT NULL, + usuario_id TEXT, + texto TEXT NOT NULL, + creado INTEGER NOT NULL, + resuelto INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS comentarios_sala_idx ON comentarios(room_id, creado); + `); + } + + /** + * El `modo` se escribe al nacer, no se deja para despues. + * + * Antes esta columna se quedaba en NULL, porque la sala solo la guardaba + * cuando alguien la cambiaba, o sea cuando entraba una segunda persona. Para + * quien se quedaba solo no se escribia nunca, y al leerla de vuelta el NULL + * cae a "multi" (ver `toRoom`): la sala nacia en solo, funcionaba en solo + * mientras viviera en memoria, y al reiniciar el server o al despertar de + * dormida volvia pidiendo la arroba sin que nadie hubiera tocado el boton. + * + * Le pegaba justo a quien el modo de una persona venia a servir, y ahora mas, + * porque desde que escribir crea la sala todas nacen en solo. + * + * Ojo con el `INSERT OR REPLACE`: si algun dia se llama con un id que ya + * existe, REPLACE borra la fila entera y la reescribe solo con estas + * columnas, tirando el nombre y la url publicada. Hoy no pasa porque solo se + * llama al crear, con un id que se acaba de comprobar que es nuevo. + */ + async createRoom(room: StoredRoom): Promise { + this.db + .prepare( + `INSERT OR REPLACE INTO rooms (id, workspace_dir, created_at, last_active_at, modo) + VALUES (?, ?, ?, ?, ?)`, + ) + .run(room.id, room.workspaceDir, room.createdAt, room.lastActiveAt, room.modo ?? null); + } + + async getRoom(id: string): Promise { + const row = this.db.prepare(`SELECT * FROM rooms WHERE id = ?`).get(id) as + | Record + | undefined; + return row ? toRoom(row) : null; + } + + async listRooms(): Promise { + const rows = this.db + .prepare(`SELECT * FROM rooms ORDER BY last_active_at DESC`) + .all() as Record[]; + return rows.map(toRoom); + } + + async touchRoom(id: string): Promise { + this.db.prepare(`UPDATE rooms SET last_active_at = ? WHERE id = ?`).run(Date.now(), id); + } + + async renameRoom(id: string, nombre: string | null): Promise { + this.db.prepare(`UPDATE rooms SET nombre = ? WHERE id = ?`).run(nombre, id); + } + + async setModo(id: string, modo: ModoDeSala): Promise { + this.db.prepare(`UPDATE rooms SET modo = ? WHERE id = ?`).run(modo, id); + } + + async setPublicacion(id: string, url: string, nombre: string): Promise { + this.db.prepare(`UPDATE rooms SET url_publicada = ?, nombre_publicado = ? WHERE id = ?`).run(url, nombre, id); + } + + async nombreEnUso(nombre: string, salaId: string): Promise { + // Una sala que publicó antes de que se pudiera elegir el nombre tiene su + // proyecto con el nombre de su id: ese también está tomado. + const fila = this.db + .prepare( + `SELECT 1 FROM rooms WHERE id != ? AND (nombre_publicado = ? OR (nombre_publicado IS NULL AND url_publicada IS NOT NULL AND id = ?)) LIMIT 1`, + ) + .get(salaId, nombre, nombre); + return fila !== undefined; + } + + /** + * Los mensajes y los historiales se van solos: sus tablas declaran + * `ON DELETE CASCADE` contra `rooms(id)` y el PRAGMA de claves foráneas está + * activo desde el constructor. Aun así se borran a mano primero, porque si + * ese PRAGMA se cayera algún día esto dejaría filas huérfanas en silencio y + * la BD crecería sin que nadie lo notara. + */ + async deleteRoom(id: string): Promise { + this.db.prepare(`DELETE FROM agent_histories WHERE room_id = ?`).run(id); + this.db.prepare(`DELETE FROM messages WHERE room_id = ?`).run(id); + this.db.prepare(`DELETE FROM rooms WHERE id = ?`).run(id); + } + + async appendMessage(m: StoredMessage): Promise { + this.db + .prepare( + `INSERT INTO messages (room_id, author, color, role, text, anchored_to, adjuntos, created_at, usuario_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + m.roomId, + m.author, + m.color, + m.role, + m.text, + m.anchoredTo ?? null, + m.adjuntos?.length ? JSON.stringify(m.adjuntos) : null, + m.createdAt, + m.usuarioId ?? null, + ); + } + + /** + * Los últimos mensajes de la sala, con la foto de quien los escribió. + * + * La foto sale de un JOIN y no de la fila: si alguien cambia su foto, sus + * mensajes viejos muestran la nueva. Es lo que hace todo el mundo y lo que la + * gente espera. El LEFT es lo que importa: la enorme mayoría de los mensajes + * no tiene cuenta detrás y tienen que salir igual. + */ + async getMessages(roomId: string, limit = 200): Promise { + const rows = this.db + .prepare( + `SELECT * FROM ( + SELECT m.*, u.foto AS foto_usuario + FROM messages m + LEFT JOIN usuarios u ON u.id = m.usuario_id + WHERE m.room_id = ? ORDER BY m.id DESC LIMIT ? + ) ORDER BY id ASC`, + ) + .all(roomId, limit) as Record[]; + return rows.map((r) => ({ + roomId: String(r.room_id), + author: String(r.author), + color: String(r.color), + role: String(r.role) as StoredMessage["role"], + text: String(r.text), + anchoredTo: r.anchored_to ? String(r.anchored_to) : undefined, + adjuntos: parseAdjuntos(r.adjuntos), + createdAt: Number(r.created_at), + usuarioId: r.usuario_id ? String(r.usuario_id) : null, + foto: r.foto_usuario ? String(r.foto_usuario) : null, + })); + } + + async saveAgentHistory(roomId: string, agentId: string, messages: Message[]): Promise { + this.db + .prepare( + `INSERT INTO agent_histories (room_id, agent_id, messages, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(room_id, agent_id) DO UPDATE SET messages = excluded.messages, + updated_at = excluded.updated_at`, + ) + .run(roomId, agentId, JSON.stringify(messages), Date.now()); + } + + async listAgentIds(roomId: string): Promise { + const filas = this.db + .prepare(`SELECT agent_id FROM agent_histories WHERE room_id = ? ORDER BY agent_id`) + .all(roomId) as Array<{ agent_id: string }>; + return filas.map((f) => f.agent_id); + } + + async getAgentHistory(roomId: string, agentId: string): Promise { + const row = this.db + .prepare(`SELECT messages FROM agent_histories WHERE room_id = ? AND agent_id = ?`) + .get(roomId, agentId) as { messages?: string } | undefined; + if (!row?.messages) return []; + try { + return JSON.parse(row.messages) as Message[]; + } catch { + return []; + } + } + + // ── Cuentas ───────────────────────────────────────────────────────────── + + async usuarioPorGoogleSub(googleSub: string): Promise { + const row = this.db + .prepare(`SELECT * FROM usuarios WHERE google_sub = ?`) + .get(googleSub) as Record | undefined; + return row ? toUsuario(row) : null; + } + + async crearUsuario(u: StoredUsuario): Promise { + this.db + .prepare( + `INSERT INTO usuarios (id, google_sub, nombre, correo, foto, creado_en) + VALUES (?, ?, ?, ?, ?, ?)`, + ) + .run(u.id, u.googleSub, u.nombre, u.correo, u.foto ?? null, u.creadoEn); + } + + async actualizarUsuario( + id: string, + datos: { nombre: string; correo: string; foto: string | null }, + ): Promise { + this.db + .prepare(`UPDATE usuarios SET nombre = ?, correo = ?, foto = ? WHERE id = ?`) + .run(datos.nombre, datos.correo, datos.foto, id); + } + + /** + * El dueño de una sesión, comprobando de paso que no haya vencido. + * + * La expiración se filtra en el SELECT y no después: así una sesión vencida + * es indistinguible de una que no existe, y no hay forma de olvidarse de + * comprobarla en algún camino. + */ + async usuarioPorSesion(token: string): Promise { + const row = this.db + .prepare( + `SELECT u.* FROM sesiones s + JOIN usuarios u ON u.id = s.usuario_id + WHERE s.token = ? AND s.expira_en > ?`, + ) + .get(token, Date.now()) as Record | undefined; + return row ? toUsuario(row) : null; + } + + async crearSesion(token: string, usuarioId: string, expiraEn: number): Promise { + this.db + .prepare(`INSERT INTO sesiones (token, usuario_id, creada_en, expira_en) VALUES (?, ?, ?, ?)`) + .run(token, usuarioId, Date.now(), expiraEn); + } + + async borrarSesion(token: string): Promise { + this.db.prepare(`DELETE FROM sesiones WHERE token = ?`).run(token); + } + + async borrarSesionesVencidas(): Promise { + this.db.prepare(`DELETE FROM sesiones WHERE expira_en <= ?`).run(Date.now()); + } + + /** + * Las salas de alguien, de la visita más reciente hacia atrás. + * + * El JOIN contra `rooms` no es decorativo: filtra las que ya se borraron. Sin + * él la cuenta acumularía para siempre salas que no existen. + */ + async salasDeUsuario(usuarioId: string): Promise { + const rows = this.db + .prepare( + `SELECT s.room_id, s.visitada_en, r.nombre FROM salas_de_usuario s + JOIN rooms r ON r.id = s.room_id + WHERE s.usuario_id = ? ORDER BY s.visitada_en DESC`, + ) + .all(usuarioId) as Record[]; + return rows.map((r) => ({ + roomId: String(r.room_id), + visitadaEn: Number(r.visitada_en), + // El nombre sale del JOIN que ya estaba ahi. Sin el, la lista de quien + // entra con cuenta pisaba la del navegador (que si los tenia) y todas las + // salas volvian a verse por su id. + nombre: r.nombre == null ? null : String(r.nombre), + })); + } + + async recordarSalaDeUsuario(usuarioId: string, roomId: string): Promise { + this.db + .prepare( + `INSERT INTO salas_de_usuario (usuario_id, room_id, visitada_en) VALUES (?, ?, ?) + ON CONFLICT(usuario_id, room_id) DO UPDATE SET visitada_en = excluded.visitada_en`, + ) + .run(usuarioId, roomId, Date.now()); + } + + async olvidarSalaDeUsuario(usuarioId: string, roomId: string): Promise { + this.db + .prepare(`DELETE FROM salas_de_usuario WHERE usuario_id = ? AND room_id = ?`) + .run(usuarioId, roomId); + } + + // ── Supabase ────────────────────────────────────────────────────────────── + + async conexionSupabase(roomId: string): Promise { + const row = this.db.prepare(`SELECT * FROM supabase_salas WHERE room_id = ?`).get(roomId) as + | Record + | undefined; + if (!row) return null; + + const acceso = descifrar(String(row.acceso)); + const refresco = descifrar(String(row.refresco)); + // Si no se pueden leer, alguien cambió MULTI_LLAVE. La sala se trata como no + // conectada: sin los tokens no hay nada que hacer con esta fila, y pedir la + // autorización otra vez es la única salida. + if (acceso === null || refresco === null) { + console.error(`[supabase] la conexión de ${roomId} no se pudo descifrar`); + return null; + } + + return { + roomId, + acceso, + refresco, + expiraEn: Number(row.expira_en), + proyecto: row.proyecto == null ? null : String(row.proyecto), + // La contraseña sí puede venir en null sin que sea un problema: se guarda + // solo cuando Multi creó el proyecto. + password: row.password == null ? null : descifrar(String(row.password)), + conectadoEn: Number(row.conectado_en), + }; + } + + async guardarConexionSupabase(c: ConexionSupabase): Promise { + this.db + .prepare( + `INSERT OR REPLACE INTO supabase_salas + (room_id, acceso, refresco, expira_en, proyecto, password, conectado_en) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + c.roomId, + cifrar(c.acceso), + cifrar(c.refresco), + c.expiraEn, + c.proyecto ?? null, + c.password ? cifrar(c.password) : null, + c.conectadoEn, + ); + } + + /** + * Quita la conexión de la sala. + * + * Solo lo de aquí: el proyecto en Supabase sigue existiendo, porque es de la + * persona. Desconectar en Multi no puede borrarle una base de datos. + */ + async salasConSupabase(): Promise { + const rows = this.db.prepare(`SELECT room_id FROM supabase_salas`).all() as { room_id: string }[]; + return rows.map((r) => r.room_id); + } + + async actualizarConexionSupabase( + roomId: string, + campos: Partial>, + ): Promise { + const columnas: string[] = []; + const valores: (string | number | null)[] = []; + if (campos.acceso !== undefined) { + columnas.push("acceso = ?"); + valores.push(cifrar(campos.acceso)); + } + if (campos.refresco !== undefined) { + columnas.push("refresco = ?"); + valores.push(cifrar(campos.refresco)); + } + if (campos.expiraEn !== undefined) { + columnas.push("expira_en = ?"); + valores.push(campos.expiraEn); + } + if (campos.proyecto !== undefined) { + columnas.push("proyecto = ?"); + valores.push(campos.proyecto ?? null); + } + if (campos.password !== undefined) { + columnas.push("password = ?"); + valores.push(campos.password ? cifrar(campos.password) : null); + } + if (columnas.length === 0) return; + this.db + .prepare(`UPDATE supabase_salas SET ${columnas.join(", ")} WHERE room_id = ?`) + .run(...valores, roomId); + } + + async borrarConexionSupabase(roomId: string): Promise { + this.db.prepare(`DELETE FROM supabase_salas WHERE room_id = ?`).run(roomId); + } + + async getComentarios(roomId: string): Promise { + const filas = this.db + .prepare(`SELECT * FROM comentarios WHERE room_id = ? ORDER BY creado ASC, rowid ASC`) + .all(roomId) as Record[]; + return filas.map((r) => ({ + id: String(r.id), + roomId: String(r.room_id), + hiloId: String(r.hilo_id), + ancla: r.ancla ? String(r.ancla) : null, + cita: r.cita ? String(r.cita) : null, + autor: String(r.autor), + color: String(r.color), + rol: String(r.rol) as StoredComentario["rol"], + usuarioId: r.usuario_id ? String(r.usuario_id) : null, + texto: String(r.texto), + creado: Number(r.creado), + resuelto: Number(r.resuelto) === 1, + })); + } + + async addComentario(c: StoredComentario): Promise { + this.db + .prepare( + `INSERT INTO comentarios (id, room_id, hilo_id, ancla, cita, autor, color, rol, usuario_id, texto, creado, resuelto) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run(c.id, c.roomId, c.hiloId, c.ancla, c.cita, c.autor, c.color, c.rol, c.usuarioId, c.texto, c.creado, c.resuelto ? 1 : 0); + } + + async resolverHilo(roomId: string, hiloId: string, resuelto: boolean): Promise { + this.db + .prepare(`UPDATE comentarios SET resuelto = ? WHERE room_id = ? AND id = ?`) + .run(resuelto ? 1 : 0, roomId, hiloId); + } + + async close(): Promise { + this.db.close(); + } +} + +function toUsuario(r: Record): StoredUsuario { + return { + id: String(r.id), + googleSub: String(r.google_sub), + nombre: String(r.nombre), + correo: String(r.correo), + foto: r.foto == null ? null : String(r.foto), + creadoEn: Number(r.creado_en), + }; +} + +/** + * Los adjuntos de un mensaje, desde la columna. + * + * Llega null en todo lo anterior a esta columna, que son las 44 salas que ya + * existían. Un JSON corrupto tampoco debe tumbar la carga del chat: si no se + * puede leer, el mensaje se muestra sin sus imágenes, que es mejor que no + * mostrar el mensaje. + */ +function parseAdjuntos(v: unknown): StoredMessage["adjuntos"] { + if (typeof v !== "string" || !v) return undefined; + try { + const parsed = JSON.parse(v); + return Array.isArray(parsed) && parsed.length ? parsed : undefined; + } catch { + return undefined; + } +} + +function toRoom(r: Record): StoredRoom { + return { + id: String(r.id), + workspaceDir: String(r.workspace_dir), + createdAt: Number(r.created_at), + lastActiveAt: Number(r.last_active_at), + // Las salas de antes de esta columna la traen en null, y ahí la interfaz + // cae al id. + nombre: r.nombre == null ? null : String(r.nombre), + urlPublicada: r.url_publicada == null ? null : String(r.url_publicada), + nombrePublicado: r.nombre_publicado == null ? null : String(r.nombre_publicado), + // Solo "solo" cuenta como tal: null y cualquier cosa rara caen a multi, que + // es el comportamiento que ya tenían todas las salas. + modo: r.modo === "solo" ? "solo" : "multi", + }; +} diff --git a/server/src/storage/types.ts b/server/src/storage/types.ts index ab7fc73..acf2f65 100644 --- a/server/src/storage/types.ts +++ b/server/src/storage/types.ts @@ -1,273 +1,285 @@ -import type { Message } from "../agent/providers/types.js"; - -/** - * Capa de persistencia de Multi. - * - * Abstracta a propósito: SQLite es el default (clonas el repo y corre, cero - * setup — importante para open source), pero la interfaz deja la puerta abierta - * a Postgres/Supabase cuando haya varios servidores. - */ - -export interface StoredRoom { - id: string; - workspaceDir: string; - createdAt: number; - lastActiveAt: number; - /** - * Cómo le dice la gente a esta sala. Null hasta que alguien la nombre, y - * entonces la interfaz cae al id. - * - * Es de la SALA, no de quien la nombró: la ven todos los que entren. El id - * sigue siendo lo que va en la URL y lo que se dicta por teléfono, así que - * renombrar no rompe ningún link ya compartido. - */ - nombre?: string | null; - /** Dónde quedó publicada la app, o null si la sala nunca se publicó. */ - urlPublicada?: string | null; - /** - * Si en esta sala hay que mencionar al agente para despertarlo. - * - * En "multi" sí, que es la regla de siempre: el chat sirve para hablar entre - * personas sin gastar tokens, y `@agente` es lo que permite lanzar varios en - * paralelo. En "solo" no hace falta, porque no hay con quién platicar. - * - * Null es "multi", y eso importa: las salas que ya existían se comportan - * exactamente igual que antes de que esto existiera. - */ - modo?: ModoDeSala | null; -} - -/** - * Cuánta gente asume una sala. - * - * Nace en "solo" porque así llega todo el mundo, y porque la mención fue lo - * primero con lo que se toparon las cuatro personas que llegaron por su cuenta: - * las cuatro escribieron sin `@agente` y las cuatro recibieron silencio. - */ -export type ModoDeSala = "solo" | "multi"; - -export interface StoredMessage { - roomId: string; - author: string; - color: string; - role: "human" | "agent" | "system"; - text: string; - anchoredTo?: string; - /** Las imágenes que venían con el mensaje. Solo los datos para mostrarlas. */ - adjuntos?: { id: string; nombre: string; mediaType: string }[]; - createdAt: number; - /** - * Quién lo escribió, si tenía cuenta. Null para todo lo anterior a las - * cuentas y para quien entra sin una, que es un caso normal y para siempre. - * - * Sirve para pintar su foto: el `author` es texto libre y no alcanza, porque - * dos personas distintas pueden haberse llamado igual en salas distintas. - */ - usuarioId?: string | null; - /** - * La foto de quien lo escribió, resuelta al leer (no se guarda en la fila). - * - * Se resuelve en la consulta y no se congela a propósito: si cambias tu foto, - * tus mensajes viejos muestran la nueva, que es lo que la gente espera. El - * `color` sí va congelado, y esa incoherencia es vieja y no vale una migración. - */ - foto?: string | null; -} - -/** - * Una persona con cuenta. - * - * Tener cuenta es OPCIONAL y lo seguirá siendo: se entra a cualquier sala con - * el link y nada más, como siempre. Esto es solo para quien quiere que sus - * salas le sigan entre dispositivos y que su cara y su nombre sean los mismos - * en todas. - */ -export interface StoredUsuario { - id: string; - /** El identificador de Google. Único y estable aunque cambie de correo. */ - googleSub: string; - nombre: string; - correo: string; - /** La URL de la foto que da Google. Null si su cuenta no tiene. */ - foto?: string | null; - creadoEn: number; -} - -/** - * La conexión de una sala con Supabase, para que su app tenga base de datos. - * - * Es DE LA SALA y no de quien la conectó: quien entra con el link la hereda y - * puede construir sobre esa base sin volver a autorizar nada. Es la misma - * decisión que el resto de la sala (lo que hay dentro es de quien tenga el - * link), y tiene la misma consecuencia: hoy los ids se pueden adivinar, así que - * acertar uno da acceso a la cuenta de Supabase de quien conectó. Está en el - * ROADMAP junto a los permisos de sala. - * - * Los tokens y la contraseña se guardan CIFRADOS (ver `cripto.ts`). Aquí viajan - * ya en claro: cifrar y descifrar es trabajo de la implementación, para que - * quien use esto no pueda olvidarse. - */ -export interface ConexionSupabase { - roomId: string; - acceso: string; - refresco: string; - /** Cuándo caduca el de acceso, en milisegundos. */ - expiraEn: number; - /** El identificador corto del proyecto. Null mientras se está creando. */ - proyecto?: string | null; - /** - * La contraseña de Postgres que Multi generó. - * - * Se guarda porque Supabase NO la devuelve nunca: su documentación dice que - * no se puede recuperar por la API. Si Multi no la guardara, se perdería para - * siempre y la única salida sería resetearla desde su panel. - */ - password?: string | null; - conectadoEn: number; -} - -/** Una sala que alguien con cuenta visitó, para que le siga entre dispositivos. */ -export interface SalaDeUsuario { - roomId: string; - visitadaEn: number; - /** Como le dice la gente. Null si nadie la ha nombrado: ahi se ve el id. */ - nombre?: string | null; -} - -/** - * Un comentario del documento. La raíz de un hilo tiene `hiloId === id` y - * lleva el ancla; las respuestas solo apuntan a su hilo. - */ -export interface StoredComentario { - id: string; - roomId: string; - hiloId: string; - /** - * Dónde está, como JSON: `{ bloque, inicio, fin }` con posiciones relativas - * de Yjs (siguen a su texto aunque editen alrededor), o `{ objeto }` para - * una gráfica o un diagrama. null en las respuestas. - */ - ancla: string | null; - /** El texto comentado, tal como estaba: se muestra si el ancla se pierde. */ - cita: string | null; - autor: string; - color: string; - rol: "human" | "agent" | "system"; - usuarioId: string | null; - texto: string; - creado: number; - /** Solo en la raíz. */ - resuelto: boolean; -} - -export interface Storage { - /** Crea el esquema si hace falta. */ - init(): Promise; - - createRoom(room: StoredRoom): Promise; - getRoom(id: string): Promise; - listRooms(): Promise; - /** Marca actividad reciente (para saber qué salas siguen vivas). */ - touchRoom(id: string): Promise; - /** - * Anota dónde quedó publicada la app. - * - * Se guarda para que al volver mañana la sala siga sabiendo que está en vivo: - * sin esto, la única señal de que se publicó era un mensaje en el chat que se - * pierde entre los demás. - */ - setUrlPublicada(id: string, url: string): Promise; - /** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */ - renameRoom(id: string, nombre: string | null): Promise; - /** Si en esta sala hay que mencionar al agente para despertarlo. */ - setModo(id: string, modo: ModoDeSala): Promise; - /** - * Borra la sala y todo lo suyo: mensajes e historiales de agentes. - * - * Solo la parte de base de datos. El workspace en disco y el contenedor los - * quita quien llama, porque no viven aquí. - */ - deleteRoom(id: string): Promise; - - appendMessage(msg: StoredMessage): Promise; - /** Los mensajes de una sala, en orden. */ - getMessages(roomId: string, limit?: number): Promise; - - /** Historial de conversación de un agente (para que continúe donde iba). */ - saveAgentHistory(roomId: string, agentId: string, messages: Message[]): Promise; - getAgentHistory(roomId: string, agentId: string): Promise; - /** - * Los agentes que han existido en una sala. - * - * Sin esto, al reiniciar el server la sala despierta con el registro vacío y - * el contador en cero: el siguiente agente vuelve a llamarse "agente-1", el - * menú de menciones sale vacío, y se pierde con quién venías conversando. - */ - listAgentIds(roomId: string): Promise; - - // ── Cuentas ─────────────────────────────────────────────────────────────── - // - // Todo lo de aquí es opcional en el sentido literal: si nadie se registra, - // estas tablas quedan vacías y Multi funciona igual que siempre. - - /** El usuario de esa cuenta de Google, o null si nunca ha entrado. */ - usuarioPorGoogleSub(googleSub: string): Promise; - crearUsuario(usuario: StoredUsuario): Promise; - /** - * Refresca lo que Google nos dice de alguien que ya tenía cuenta. - * - * Se llama en cada entrada: si cambió su nombre o su foto allá, aquí se ve. - * Es más barato que un botón de "actualizar perfil" que nadie apretaría. - */ - actualizarUsuario(id: string, datos: { nombre: string; correo: string; foto: string | null }): Promise; - - /** Quién es el dueño de esta sesión, o null si no vale o ya expiró. */ - usuarioPorSesion(token: string): Promise; - crearSesion(token: string, usuarioId: string, expiraEn: number): Promise; - borrarSesion(token: string): Promise; - /** Limpia las que ya vencieron. Se llama al arrancar, no con un timer. */ - borrarSesionesVencidas(): Promise; - - /** Las salas de alguien con cuenta, de la más reciente a la más vieja. */ - salasDeUsuario(usuarioId: string): Promise; - recordarSalaDeUsuario(usuarioId: string, roomId: string): Promise; - olvidarSalaDeUsuario(usuarioId: string, roomId: string): Promise; - - // ── Supabase ────────────────────────────────────────────────────────────── - // - // Opcional igual que las cuentas: si nadie conecta una base, esta tabla queda - // vacía y Multi funciona como siempre. - - /** - * La conexión de esta sala, o null si no tiene. - * - * También devuelve null si lo guardado no se puede descifrar, que pasa cuando - * alguien cambió `MULTI_LLAVE`. Ahí la respuesta correcta es tratar la sala - * como no conectada y pedir la autorización otra vez. - */ - conexionSupabase(roomId: string): Promise; - guardarConexionSupabase(conexion: ConexionSupabase): Promise; - /** - * Cambia SOLO los campos que llegan, sin tocar los demás. - * - * Es lo que usan la renovación del token, la preparación y una segunda - * autorización. Guardar la fila completa desde una copia leída antes pisaba lo - * que otro camino había escrito mientras tanto: el proyecto volvía a null, o - * los tokens a unos ya rotados. No hace nada si la sala no tiene conexión. - */ - actualizarConexionSupabase( - roomId: string, - campos: Partial>, - ): Promise; - borrarConexionSupabase(roomId: string): Promise; - /** Las salas que tienen una conexión guardada, para retomar las que quedaron a medias. */ - salasConSupabase(): Promise; - - /** Los comentarios del documento de una sala, en orden. */ - getComentarios(roomId: string): Promise; - addComentario(c: StoredComentario): Promise; - /** Resuelve o reabre un hilo (su raíz). */ - resolverHilo(roomId: string, hiloId: string, resuelto: boolean): Promise; - - close(): Promise; -} +import type { Message } from "../agent/providers/types.js"; + +/** + * Capa de persistencia de Multi. + * + * Abstracta a propósito: SQLite es el default (clonas el repo y corre, cero + * setup — importante para open source), pero la interfaz deja la puerta abierta + * a Postgres/Supabase cuando haya varios servidores. + */ + +export interface StoredRoom { + id: string; + workspaceDir: string; + createdAt: number; + lastActiveAt: number; + /** + * Cómo le dice la gente a esta sala. Null hasta que alguien la nombre, y + * entonces la interfaz cae al id. + * + * Es de la SALA, no de quien la nombró: la ven todos los que entren. El id + * sigue siendo lo que va en la URL y lo que se dicta por teléfono, así que + * renombrar no rompe ningún link ya compartido. + */ + nombre?: string | null; + /** Dónde quedó publicada la app, o null si la sala nunca se publicó. */ + urlPublicada?: string | null; + /** + * Con qué nombre se publicó (`nombre.pages.dev`). Null en las que publicaron + * antes de que se pudiera elegir: su proyecto se llama como su id. + */ + nombrePublicado?: string | null; + /** + * Si en esta sala hay que mencionar al agente para despertarlo. + * + * En "multi" sí, que es la regla de siempre: el chat sirve para hablar entre + * personas sin gastar tokens, y `@agente` es lo que permite lanzar varios en + * paralelo. En "solo" no hace falta, porque no hay con quién platicar. + * + * Null es "multi", y eso importa: las salas que ya existían se comportan + * exactamente igual que antes de que esto existiera. + */ + modo?: ModoDeSala | null; +} + +/** + * Cuánta gente asume una sala. + * + * Nace en "solo" porque así llega todo el mundo, y porque la mención fue lo + * primero con lo que se toparon las cuatro personas que llegaron por su cuenta: + * las cuatro escribieron sin `@agente` y las cuatro recibieron silencio. + */ +export type ModoDeSala = "solo" | "multi"; + +export interface StoredMessage { + roomId: string; + author: string; + color: string; + role: "human" | "agent" | "system"; + text: string; + anchoredTo?: string; + /** Las imágenes que venían con el mensaje. Solo los datos para mostrarlas. */ + adjuntos?: { id: string; nombre: string; mediaType: string }[]; + createdAt: number; + /** + * Quién lo escribió, si tenía cuenta. Null para todo lo anterior a las + * cuentas y para quien entra sin una, que es un caso normal y para siempre. + * + * Sirve para pintar su foto: el `author` es texto libre y no alcanza, porque + * dos personas distintas pueden haberse llamado igual en salas distintas. + */ + usuarioId?: string | null; + /** + * La foto de quien lo escribió, resuelta al leer (no se guarda en la fila). + * + * Se resuelve en la consulta y no se congela a propósito: si cambias tu foto, + * tus mensajes viejos muestran la nueva, que es lo que la gente espera. El + * `color` sí va congelado, y esa incoherencia es vieja y no vale una migración. + */ + foto?: string | null; +} + +/** + * Una persona con cuenta. + * + * Tener cuenta es OPCIONAL y lo seguirá siendo: se entra a cualquier sala con + * el link y nada más, como siempre. Esto es solo para quien quiere que sus + * salas le sigan entre dispositivos y que su cara y su nombre sean los mismos + * en todas. + */ +export interface StoredUsuario { + id: string; + /** El identificador de Google. Único y estable aunque cambie de correo. */ + googleSub: string; + nombre: string; + correo: string; + /** La URL de la foto que da Google. Null si su cuenta no tiene. */ + foto?: string | null; + creadoEn: number; +} + +/** + * La conexión de una sala con Supabase, para que su app tenga base de datos. + * + * Es DE LA SALA y no de quien la conectó: quien entra con el link la hereda y + * puede construir sobre esa base sin volver a autorizar nada. Es la misma + * decisión que el resto de la sala (lo que hay dentro es de quien tenga el + * link), y tiene la misma consecuencia: hoy los ids se pueden adivinar, así que + * acertar uno da acceso a la cuenta de Supabase de quien conectó. Está en el + * ROADMAP junto a los permisos de sala. + * + * Los tokens y la contraseña se guardan CIFRADOS (ver `cripto.ts`). Aquí viajan + * ya en claro: cifrar y descifrar es trabajo de la implementación, para que + * quien use esto no pueda olvidarse. + */ +export interface ConexionSupabase { + roomId: string; + acceso: string; + refresco: string; + /** Cuándo caduca el de acceso, en milisegundos. */ + expiraEn: number; + /** El identificador corto del proyecto. Null mientras se está creando. */ + proyecto?: string | null; + /** + * La contraseña de Postgres que Multi generó. + * + * Se guarda porque Supabase NO la devuelve nunca: su documentación dice que + * no se puede recuperar por la API. Si Multi no la guardara, se perdería para + * siempre y la única salida sería resetearla desde su panel. + */ + password?: string | null; + conectadoEn: number; +} + +/** Una sala que alguien con cuenta visitó, para que le siga entre dispositivos. */ +export interface SalaDeUsuario { + roomId: string; + visitadaEn: number; + /** Como le dice la gente. Null si nadie la ha nombrado: ahi se ve el id. */ + nombre?: string | null; +} + +/** + * Un comentario del documento. La raíz de un hilo tiene `hiloId === id` y + * lleva el ancla; las respuestas solo apuntan a su hilo. + */ +export interface StoredComentario { + id: string; + roomId: string; + hiloId: string; + /** + * Dónde está, como JSON: `{ bloque, inicio, fin }` con posiciones relativas + * de Yjs (siguen a su texto aunque editen alrededor), o `{ objeto }` para + * una gráfica o un diagrama. null en las respuestas. + */ + ancla: string | null; + /** El texto comentado, tal como estaba: se muestra si el ancla se pierde. */ + cita: string | null; + autor: string; + color: string; + rol: "human" | "agent" | "system"; + usuarioId: string | null; + texto: string; + creado: number; + /** Solo en la raíz. */ + resuelto: boolean; +} + +export interface Storage { + /** Crea el esquema si hace falta. */ + init(): Promise; + + createRoom(room: StoredRoom): Promise; + getRoom(id: string): Promise; + listRooms(): Promise; + /** Marca actividad reciente (para saber qué salas siguen vivas). */ + touchRoom(id: string): Promise; + /** + * Anota dónde quedó publicada la app y con qué nombre. + * + * Se guarda para que al volver mañana la sala siga sabiendo que está en vivo: + * sin esto, la única señal de que se publicó era un mensaje en el chat que se + * pierde entre los demás. + */ + setPublicacion(id: string, url: string, nombre: string): Promise; + /** + * Si otra sala ya publicó con ese nombre. + * + * Dos salas con el mismo nombre serían el mismo proyecto en Cloudflare: la + * segunda en publicar le borraría la app a la primera. + */ + nombreEnUso(nombre: string, salaId: string): Promise; + /** Le pone nombre a la sala. `null` lo quita y se vuelve a ver el id. */ + renameRoom(id: string, nombre: string | null): Promise; + /** Si en esta sala hay que mencionar al agente para despertarlo. */ + setModo(id: string, modo: ModoDeSala): Promise; + /** + * Borra la sala y todo lo suyo: mensajes e historiales de agentes. + * + * Solo la parte de base de datos. El workspace en disco y el contenedor los + * quita quien llama, porque no viven aquí. + */ + deleteRoom(id: string): Promise; + + appendMessage(msg: StoredMessage): Promise; + /** Los mensajes de una sala, en orden. */ + getMessages(roomId: string, limit?: number): Promise; + + /** Historial de conversación de un agente (para que continúe donde iba). */ + saveAgentHistory(roomId: string, agentId: string, messages: Message[]): Promise; + getAgentHistory(roomId: string, agentId: string): Promise; + /** + * Los agentes que han existido en una sala. + * + * Sin esto, al reiniciar el server la sala despierta con el registro vacío y + * el contador en cero: el siguiente agente vuelve a llamarse "agente-1", el + * menú de menciones sale vacío, y se pierde con quién venías conversando. + */ + listAgentIds(roomId: string): Promise; + + // ── Cuentas ─────────────────────────────────────────────────────────────── + // + // Todo lo de aquí es opcional en el sentido literal: si nadie se registra, + // estas tablas quedan vacías y Multi funciona igual que siempre. + + /** El usuario de esa cuenta de Google, o null si nunca ha entrado. */ + usuarioPorGoogleSub(googleSub: string): Promise; + crearUsuario(usuario: StoredUsuario): Promise; + /** + * Refresca lo que Google nos dice de alguien que ya tenía cuenta. + * + * Se llama en cada entrada: si cambió su nombre o su foto allá, aquí se ve. + * Es más barato que un botón de "actualizar perfil" que nadie apretaría. + */ + actualizarUsuario(id: string, datos: { nombre: string; correo: string; foto: string | null }): Promise; + + /** Quién es el dueño de esta sesión, o null si no vale o ya expiró. */ + usuarioPorSesion(token: string): Promise; + crearSesion(token: string, usuarioId: string, expiraEn: number): Promise; + borrarSesion(token: string): Promise; + /** Limpia las que ya vencieron. Se llama al arrancar, no con un timer. */ + borrarSesionesVencidas(): Promise; + + /** Las salas de alguien con cuenta, de la más reciente a la más vieja. */ + salasDeUsuario(usuarioId: string): Promise; + recordarSalaDeUsuario(usuarioId: string, roomId: string): Promise; + olvidarSalaDeUsuario(usuarioId: string, roomId: string): Promise; + + // ── Supabase ────────────────────────────────────────────────────────────── + // + // Opcional igual que las cuentas: si nadie conecta una base, esta tabla queda + // vacía y Multi funciona como siempre. + + /** + * La conexión de esta sala, o null si no tiene. + * + * También devuelve null si lo guardado no se puede descifrar, que pasa cuando + * alguien cambió `MULTI_LLAVE`. Ahí la respuesta correcta es tratar la sala + * como no conectada y pedir la autorización otra vez. + */ + conexionSupabase(roomId: string): Promise; + guardarConexionSupabase(conexion: ConexionSupabase): Promise; + /** + * Cambia SOLO los campos que llegan, sin tocar los demás. + * + * Es lo que usan la renovación del token, la preparación y una segunda + * autorización. Guardar la fila completa desde una copia leída antes pisaba lo + * que otro camino había escrito mientras tanto: el proyecto volvía a null, o + * los tokens a unos ya rotados. No hace nada si la sala no tiene conexión. + */ + actualizarConexionSupabase( + roomId: string, + campos: Partial>, + ): Promise; + borrarConexionSupabase(roomId: string): Promise; + /** Las salas que tienen una conexión guardada, para retomar las que quedaron a medias. */ + salasConSupabase(): Promise; + + /** Los comentarios del documento de una sala, en orden. */ + getComentarios(roomId: string): Promise; + addComentario(c: StoredComentario): Promise; + /** Resuelve o reabre un hilo (su raíz). */ + resolverHilo(roomId: string, hiloId: string, resuelto: boolean): Promise; + + close(): Promise; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index ce0e086..2f90791 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -646,6 +646,8 @@ function Sala({ * de la sala, no un mensaje de hace rato. */ const [urlPublicada, setUrlPublicada] = useState(null); + /** Por qué no se aceptó el nombre que se escribió para publicar. */ + const [errorDeNombre, setErrorDeNombre] = useState(null); /** * La base de datos de la sala. * @@ -1699,15 +1701,26 @@ function Sala({ * llega por socket. Un build tarda minutos, y una petición colgada ese rato se * moriría por timeout antes de contar nada. */ - const publicar = async () => { + const publicar = async (nombreElegido?: string) => { if (!roomId) return; setPublicando("compilando"); // respuesta inmediata; el server confirma en seguida + setErrorDeNombre(null); try { - const r = await fetch(`${SERVER_URL}/rooms/${roomId}/publicar`, { method: "POST" }); + const r = await fetch(`${SERVER_URL}/rooms/${roomId}/publicar`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(nombreElegido ? { nombre: nombreElegido } : {}), + }); if (!r.ok) { const d = (await r.json().catch(() => null)) as { error?: string } | null; setPublicando(null); - alert(d?.error ?? t.publicarFallo); + // Un nombre que no sirve o que ya está tomado se dice junto al campo, + // donde se arregla; lo demás, como siempre. + if (r.status === 400 || (r.status === 409 && d?.error?.includes("nombre"))) { + setErrorDeNombre(d?.error ?? t.publicarFallo); + } else { + alert(d?.error ?? t.publicarFallo); + } } } catch { setPublicando(null); @@ -2396,9 +2409,11 @@ function Sala({ )} {/* Las variables son del proyecto de la sala, así que sin sala no diff --git a/web/src/PublicarPanel.tsx b/web/src/PublicarPanel.tsx index 9627226..2636676 100644 --- a/web/src/PublicarPanel.tsx +++ b/web/src/PublicarPanel.tsx @@ -13,24 +13,57 @@ import { useTextos } from "./i18n.js"; * dónde, y el botón para volver a subir lo último. */ +/** + * El nombre tal como va a quedar en la dirección: `nombre.pages.dev`. + * + * Lo mismo que hace `nombreDeApp` en el server (engine/publicar.ts), para que lo + * que se ve mientras se escribe sea lo que va a salir. El server lo vuelve a + * filtrar: esto es solo para enseñarlo. + */ +export function nombreDeApp(texto: string): string { + return texto + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 58) + .replace(/-+$/g, ""); +} + export function PublicarPanel({ roomId, + nombreDeSala, urlPublicada, publicando, puedePublicar, + errorDeNombre, onPublicar, }: { roomId: string | null; + /** Con lo que arranca el campo del nombre: el nombre de la sala, o su id. */ + nombreDeSala: string | null; urlPublicada: string | null; publicando: "compilando" | "subiendo" | null; /** Si la sala tiene algo guardado que valga la pena subir. */ puedePublicar: boolean; - onPublicar: () => void; + /** Por qué el server no aceptó el nombre, para decirlo junto al campo. */ + errorDeNombre: string | null; + onPublicar: (nombre?: string) => void; }) { const { t } = useTextos(); const [abierto, setAbierto] = useState(false); const [copiado, setCopiado] = useState(false); const cajaRef = useRef(null); + /** + * El nombre que se escribe, solo antes de la primera publicación: Cloudflare + * no deja renombrar un proyecto, así que después el link se queda como está. + * Null mientras nadie lo toca, y ahí se usa el de la sala. + */ + const [escrito, setEscrito] = useState(null); + const sugerido = nombreDeApp(nombreDeSala ?? roomId ?? ""); + const nombre = escrito ?? sugerido; + const direccion = nombreDeApp(nombre); // Cerrar al hacer click fuera o con Escape, como los demás paneles. useEffect(() => { @@ -94,13 +127,32 @@ export function PublicarPanel({

{t.publicarNotaViva}

) : ( -

{t.publicarNota}

+ <> + +

+ {direccion ? `${direccion}.pages.dev` : t.publicarNombreVacio} +

+ {errorDeNombre &&

{errorDeNombre}

} +

{t.publicarNombreNota}

+

{t.publicarNota}

+ )}