import { z } from "zod"; import { withAuth } from "../../auth/index.js"; import { handleToolError, validateRequired } from "../helpers/errorHandler.js"; import { withAuthParams } from "../helpers/authSchema.js"; import { canAccessTable } from "../helpers/accessControl.js"; import { pythonPost } from "../helpers/pythonServerClient.js"; import { getCurrentProjectInfo } from "../files/helpers.js"; // Tool: create_or_update_record // // TRANSPORTE: escribe SIEMPRE a traves del server Python // (/api/cms/create-record y /api/cms/update-record), nunca contra el cmsApi de // la web. Esos endpoints son el mismo camino que usa el dashboard, asi que la // tool hereda gratis toda la logica de escritura que ya vive en Python: // // * auto-relleno y normalizacion de `enlace` (slug derivado de title/name). // * hasheo sha1 de los campos `editor_password` (el cmsApi no ejecuta hooks // de plugin; la regla canonica vive en server/password_fields.py). // * metadatos de tablas `category`: regeneracion del arbol solo cuando hace // falta (jerarquia real) y derivados calculados para las tablas planas. // * defaults del schema en INSERT (fill_schema_defaults) y filtrado de // campos `adminOnly` para usuarios no admin. // // Duplicar todo eso en JS era inviable: un solo camino de escritura. // // LOTES: el endpoint Python de creacion acepta UN registro, asi que un `fields` // array se resuelve con N llamadas secuenciales. Ver BATCH_POLICY. // El endpoint de creacion escribe de uno en uno y NO hay transaccion que // envuelva el lote: si la llamada k falla, las k-1 anteriores ya estan en BD. // Politica: ABORTAR en el primer fallo y devolver los `num` ya creados, el // indice que fallo y cuantos quedaron sin intentar. Preferimos un lote a medias // EXPLICITO (el agente puede continuar o borrar) a seguir insertando a ciegas o // a callarnoslo con un success:true enganoso. const BATCH_POLICY = "abort-on-first-error"; // Campos que nunca deben cambiar en un registro existente. El server Python NO // los filtra (su `autofill_enlace` en update solo normaliza el `enlace` que le // llegue), asi que el strip se mantiene aqui: es lo que la descripcion de la // tool le promete al agente. const PROTECTED_UPDATE_FIELDS = ["enlace", "controlador", "precontrolador"]; /** * POST al server Python normalizando el error. * Los handlers responden {success:false, error, errorCode} con status 4xx/5xx, y * las validaciones tempranas responden {error: "..."} con 400 — axios lanza en * ambos casos, asi que aqui se aplanan a { ok, data, error, errorCode, status }. */ async function postToPython(path, body) { try { const data = await pythonPost(path, body); if (data && data.success === true) return { ok: true, data }; return { ok: false, error: (data && (data.error || data.message)) || "El server Python no confirmo la escritura", errorCode: data?.errorCode, status: 200, }; } catch (error) { const payload = error?.response?.data; const message = (payload && typeof payload === "object" && (payload.error || payload.message)) || error?.message || "Error desconocido escribiendo en el server Python"; return { ok: false, error: typeof message === "string" ? message : JSON.stringify(message), errorCode: payload?.errorCode, status: error?.response?.status, }; } } export function registerCreateOrUpdateRecordTool(server) { server.tool( "create_or_update_record", `Crea o actualiza registros en una tabla. Antes de usar: consulta el schema con 'get_table_schema' (sin 'cms_'); si dudas del formato lee 'read_doc({ name: "11-quick-reference" })' o '06-hooks-and-cmsapi'. Reglas clave: tablas sin prefijo 'cms_'; PK es 'num' (nunca 'id'); foreign keys con sufijo '_num'; uploads son arrays — NO los envíes en 'fields', sube después con 'upload_record_image'; fechas en formato YYYY-MM-DD HH:mm:ss; checkboxes como 1/0 (números). Para tablas builder (e.g. 'apartados') al crear nuevo registro: incluye num:null, builder:"[]", controlador, precontrolador, breadcrumb. NUNCA modifiques 'enlace' ni 'controlador' de un registro existente — los stripeo automáticamente en updates. Enlace: NO hace falta que lo inventes al crear. Si la tabla tiene campo 'enlace' y no lo envías, se genera un slug legible a partir de 'title' o 'name' (y si no hay ninguno, uno aleatorio); si lo envías, se normaliza a la forma /.../. El valor final lo decide el servidor, así que si necesitas la URL del registro léela después con 'get_record'. Contraseñas: los campos de tipo 'editor_password' (e.g. 'usuarios.clave') se hashean automáticamente con SHA1 en el servidor antes de guardarse — envía la contraseña en texto plano y NO la hashees tú. Su valor no se puede leer/descifrar después (solo verás el hash), así que no intentes recuperar contraseñas existentes ni reenviarlas. Si envías el campo vacío ('' o null) se omite del guardado y la contraseña actual se mantiene. Alta múltiple ('fields' como array): los registros se crean UNO A UNO y no hay transacción. Si uno falla, se aborta ahí: la respuesta te dice qué 'num' se llegaron a crear (createdIds), en qué índice falló y cuántos quedaron sin intentar. Los ya creados NO se revierten — decide tú si reintentas el resto o los borras. Campos restringidos: los campos marcados como 'adminOnly' en el schema se descartan silenciosamente si el usuario del proyecto no es admin (solo aplica en producción).`, withAuthParams({ tableName: z.string().describe("Nombre de la tabla sin prefijo 'cms_' (e.g. 'productos', 'apartados')"), recordId: z.any().optional().describe("'num' del registro a actualizar. Omitir para crear nuevo. NO se usa cuando 'fields' es array."), fields: z.any().describe("Objeto único o array de objetos para inserción batch. Ejemplo: { nombre: 'Producto 1' } o [{ nombre: 'A' }, { nombre: 'B' }]. Antes consulta el schema y, si dudas, lee 'read_doc({ name: \"11-quick-reference\" })'."), tableSchema: z.any().describe("Schema de la tabla para validar tipos antes de enviar (opcional)."), }), { readOnlyHint: false, destructiveHint: false }, withAuth(async ({ tableName, recordId, fields }, _extra) => { try { // Validate required parameters const validationError = validateRequired({ tableName, fields }, ['tableName', 'fields'], 'create_or_update_record'); if (validationError) return validationError; // Check table access const accessCheck = canAccessTable(tableName); if (!accessCheck.allowed) { return { content: [{ type: "text", text: JSON.stringify({ success: false, error: accessCheck.error }) }], isError: true }; } // if fields is string, try to parse as JSON if (typeof fields === 'string') { try { fields = JSON.parse(fields); } catch (e) { return { content: [{ type: "text", text: "Error: 'fields' parameter is a string but not valid JSON." }], isError: true, }; } } // Determine if fields is array or single object const isArray = Array.isArray(fields); const recordsArray = isArray ? fields : [fields]; // Check if trying to update with array (not supported) if (isArray && recordId) { return { content: [{ type: "text", text: "Error: Cannot use recordId when fields is an array. Use fields as array for batch insert only." }], isError: true, }; } // Un array vacio no es un alta de 0 registros: es una llamada sin // sentido. Antes acababa en un insert vacio; ahora se corta aqui // para no devolver un success enganoso. if (recordsArray.length === 0) { return handleToolError( "Error: 'fields' is an empty array — there is nothing to create.", 'create_or_update_record', { tableName } ); } const { projectSlug } = getCurrentProjectInfo(); const isNewRecord = !recordId; // ---------- UPDATE: un registro, una llamada ---------- if (!isNewRecord) { // Protege los campos criticos: se eliminan en silencio (contrato // publico de la tool). Python no hace este strip. const record = { ...recordsArray[0] }; const stripped = PROTECTED_UPDATE_FIELDS.filter(f => f in record); stripped.forEach(f => { delete record[f]; }); // El endpoint exige `fields` no vacio; si el strip lo dejo seco // devolvemos un error accionable en vez del generico de Python. if (Object.keys(record).length === 0) { return handleToolError( `Nothing to update: after stripping protected fields (${PROTECTED_UPDATE_FIELDS.join(', ')}) there are no fields left. ` + `Those fields cannot be modified on an existing record.`, 'create_or_update_record', { tableName, recordId, strippedFields: stripped } ); } const res = await postToPython("/api/cms/update-record", { project: projectSlug, table: tableName, num: recordId, fields: record, }); if (!res.ok) { return handleToolError(res.error, 'create_or_update_record', { tableName, recordId, errorCode: res.errorCode, httpStatus: res.status, }); } return { content: [{ type: "text", text: JSON.stringify({ success: true, message: `Record ${recordId} updated successfully`, tableName, recordIds: recordId, recordsCount: 1, strippedFields: stripped.length > 0 ? stripped : undefined, // El server responde skipped cuando el filtrado // (adminOnly / password vacia) dejo el UPDATE sin columnas. skipped: res.data?.skipped === true ? true : undefined, skippedReason: res.data?.skipped === true ? "El servidor descartó todos los campos enviados (adminOnly o contraseña vacía): no se escribió nada." : undefined, }, null, 2) }], }; } // ---------- INSERT: N registros, N llamadas ---------- const createdIds = []; for (let i = 0; i < recordsArray.length; i++) { const res = await postToPython("/api/cms/create-record", { project: projectSlug, table: tableName, fields: recordsArray[i], }); if (!res.ok) { // BATCH_POLICY: abortar y reportar el estado real del lote. return { content: [{ type: "text", text: JSON.stringify({ success: false, error: res.error, errorCode: res.errorCode, httpStatus: res.status, tableName, batchPolicy: BATCH_POLICY, failedIndex: i, createdIds, createdCount: createdIds.length, notAttemptedCount: recordsArray.length - i - 1, hint: createdIds.length > 0 ? `Los ${createdIds.length} registro(s) anteriores YA se crearon (num: ${createdIds.join(', ')}) y NO se han revertido. Corrige el registro del índice ${i} y reintenta solo los que faltan, o bórralos con delete_record.` : `No se creó ningún registro. Corrige el registro del índice ${i} y reintenta.`, }, null, 2) }], isError: true, }; } createdIds.push(res.data.num); } return { content: [{ type: "text", text: JSON.stringify({ success: true, message: `${recordsArray.length} record(s) created successfully`, tableName, recordIds: isArray ? createdIds : createdIds[0], recordsCount: recordsArray.length, createdIds, suggestion: !isArray ? `Puedes verificar el registro con get_record({ tableName: "${tableName}", recordId: ${JSON.stringify(createdIds[0])} }) — ahí verás el 'enlace' definitivo que generó el servidor.` : undefined, }, null, 2) }], }; } catch (error) { return handleToolError(error, 'create_or_update_record', { tableName, recordId, isArray: Array.isArray(fields) }); } }) ); }