260 lines
14 KiB
JavaScript
260 lines
14 KiB
JavaScript
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) });
|
|
}
|
|
})
|
|
);
|
|
}
|