Files
acai-scaffold/docs/hooks-and-api.md
Dmielgo ed35d4e313 Refine hooks distinction, add assets/, rename twig-filters
- Clarify module hook vs file hook: when to use each, different call URLs
- Add schema path (cms/data/schema/) to reglas importantes
- Add assets/ and hook.php to module file structure
- Fix variable assignment example: use string syntax for get()
- Rename twig-filters.md → twig-reference.md (covers more than filters)
- Move Global Variables from modular-system.md to twig-reference.md
- Update all references in CLAUDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:42:15 +00:00

17 KiB

Hooks & Server-Side API

Hooks

Hooks son archivos PHP en hooks/ que ejecutan lógica server-side. También pueden estar dentro de un módulo en template/estandar/modulos/<module-id>/hook.php.

Hooks de módulo vs hooks de archivo

IMPORTANTE: Los hooks de módulo (modulos/*/hook.php) y los hooks de archivo (hooks/*.php) reciben parámetros de forma DIFERENTE.

Hooks de módulo (modulos/MODULE_ID/hook.php)

Los parámetros se inyectan como variables directamente:

<?php
// Si llamas hook con {param1: 100}, tendrás $param1 = 100
$resultado = $param1 * 2;

return [
    "success" => true,
    "message" => "Valor procesado: " . $resultado,
    "value" => $resultado
];
?>

Hooks de archivo (hooks/*.php)

Se ejecutan dentro de function returnData() {} via eval(). NINGUNA variable del request se inyecta. Hay que leer manualmente:

<?php
// SIEMPRE hacer esto al inicio de cada hook de archivo:
$params = json_decode(file_get_contents('php://input'), true) ?: [];
$nombre = trim(@$params['nombre']);
$email = trim(@$params['email']);

return [
    "success" => true,
    "nombre" => $nombre
];
?>

Testing Hooks

El Docker debe estar corriendo. Hacer curl al endpoint del hook:

curl http://localhost:8080/hooks/example_hook/

No usar X-Hooks-Token en desarrollo local.

Gotchas de Hooks

tipo es variable reservada

El .htaccess de Acai añade tipo=barra a cada request. Si envías tipo como parámetro en un hook, se sobrescribe → 404 o comportamiento inesperado.

Usar nombres alternativos: account_type, user_tipo, record_tipo, etc.

Cuándo usar hook de módulo vs hook general

  • Hook de módulo (modulos/MODULE_ID/hook.php): cuando el hook solo lo usa ese módulo. Ej: un módulo calculadora con su propio hook.php.
  • Hook general (hooks/hooks.{nombre}.php): cuando la lógica se usa desde varias partes del sitio (login, carrito, etc.).

Nunca crear un directorio en modulos/ solo para tener un hook sin módulo visual. Si save_module crea un módulo fantasma (ej: modulos/auth_signup/), este intercepta /hooks/auth_signup/ porque addModulesHooksToLayout() se ejecuta ANTES que addFilesHooksToLayout(). Si aparecen fantasmas, borrar el directorio.

Cómo Llamar Hooks

La URL del endpoint depende del tipo de hook:

  • Hook de módulo: /modulos/MODULE_ID/hook.php (se llama con el path del módulo)
  • Hook general: /hooks/nombre/

Desde HTML (recomendado para módulos)

<!-- Hook de módulo -->
<hook result="myVar" endpoint="/modulos/calculadora/hook.php" :param1="value1"></hook>

<!-- Hook general -->
<hook result="myVar" endpoint="/hooks/auth_login/" :param1="value1"></hook>
<p>{{ myVar.message }}</p>

Desde Twig

{# Hook de módulo #}
{% set resultado = 'modulos/calculadora/hook.php' | hook({param1: 100}) %}

{# Hook general #}
{% set resultado = 'hooks/auth_login/' | hook({param1: 100}) %}

Desde JavaScript

(ver CmsApi JS para callback + Promise)

// Hook de módulo
CmsApi.hook('/modulos/calculadora/hook.php', {param1: 100}).then(res => console.log(res));

// Hook general
CmsApi.hook('/hooks/auth_login/', {param1: 100}).then(res => console.log(res));

Desde otro Hook PHP

<?php
$result = hook("/hooks/auth_login/", ["param1" => 100]);
$mensaje = $result["message"];
?>

Desde c-form: Los hooks se ejecutan automáticamente al enviar el formulario si están configurados.


CmsApi (PHP)

API server-side para operaciones de base de datos. Disponible en todos los hooks.

Read — CmsApi::get()

// Todos los registros
$products = CmsApi::get('productos');

// Con condición WHERE
$active = CmsApi::get('productos', ['active' => 1]);

// Con orden y límite
$latest = CmsApi::get('noticias', [], 'fecha DESC', 5);

// Con condición string
$activos = CmsApi::get('productos', 'activo=1');

// Condición compleja como array
$caros = CmsApi::get('productos', [
    ["column" => "precio", "operator" => ">", "value" => 100]
]);

// Múltiples condiciones (AND)
$resultados = CmsApi::get('productos', [
    ["column" => "activo", "operator" => "=", "value" => 1],
    ["column" => "stock", "operator" => ">", "value" => 0]
]);

// Con operadores
$expensive = CmsApi::get('productos', ['precio' => ['>=' => 100]]);
$search = CmsApi::get('productos', ['nombre' => ['LIKE' => '%keyword%']]);
$inList = CmsApi::get('productos', ['categoria_num' => ['IN' => [1, 2, 3]]]);

// Con opciones
$datos = CmsApi::get('productos', '', '', '', [
    'translates' => true,
    'uploads' => true,
    'relations' => true,
    'relationsDepth' => 2
]);

Insert — CmsApi::insert()

// Un registro
CmsApi::insert('contacto', [
    ["nombre" => "John", "email" => "john@example.com", "mensaje" => "Hello"]
]);

// Múltiples registros
CmsApi::insert('productos', [
    ["nombre" => "Producto A", "precio" => 100],
    ["nombre" => "Producto B", "precio" => 200]
]);

// Con retorno del último ID
CmsApi::insert('productos',
    [["nombre" => "Nuevo", "precio" => 150]],
    [],
    ['return_last_id' => true]
);

Update — CmsApi::update()

// Con condición string
CmsApi::update('productos', ["precio" => 150], "num=1");

// Con condición array
CmsApi::update('productos',
    ["activo" => 1],
    [["column" => "num", "operator" => "=", "value" => 1]]
);

// Múltiples registros
CmsApi::update('productos', ["activo" => 0], "precio < 50");

Delete — CmsApi::delete()

CmsApi::delete('productos', "num=5");

CmsApi::delete('productos',
    [["column" => "activo", "operator" => "=", "value" => 0]]
);

Reglas importantes

  • Nombres de tabla sin prefijo cms_
  • Primary key siempre es num, nunca id
  • Foreign keys: consultar siempre el schema en cms/data/schema/ (ver Gotchas de BD)
  • Upload fields: no se manejan via insert/update
  • Operadores: =, !=, >, >=, <, <=, LIKE, IN
  • Traducción en PHP: Usar t_var() para todo texto visible al usuario:
    return ['error' => t_var('Debes iniciar sesión')];
    return ['success' => t_var('Datos guardados correctamente')];
    

CmsApi (JavaScript — Client-Side)

// Llamar hook (callback)
CmsApi.hook('/hooks/module_id/', { param: 'value' }, function(response) {
  // response es la salida del hook
});

// Llamar hook (Promise)
CmsApi.hook('/hooks/module_id/', { param: 'value' }).then(function(res) {
  // res = lo que devuelve el PHP del hook
}).catch(function(err) {
  // error de red
});

// Leer registros (si está expuesto via hooks)
CmsApi.get('tableName', { where: conditions }, function(records) {
  // records array
});

CocoDB

Capa de abstracción de BD de bajo nivel usada internamente por CmsApi. Usar directamente desde hooks cuando necesites más control.

CocoDB::get($table, $where, $order, $limit, $options)

// Básico
$records = CocoDB::get('productos', ['activo' => 1], 'orden ASC', 10);

// Where con operadores avanzados
$records = CocoDB::get('productos', [
    ['column' => 'precio', 'value' => 100, 'operator' => '>='],
    ['column' => 'categoria_num', 'value' => [1, 2, 3], 'operator' => 'IN'],
]);

// Condiciones OR
$records = CocoDB::get('productos', [
    ['column' => 'nombre', 'value' => '%keyword%', 'operator' => 'LIKE'],
    ['column' => 'descripcion', 'value' => '%keyword%', 'operator' => 'LIKE', 'or' => true],
]);

// NOT
$records = CocoDB::get('productos', [
    ['column' => 'estado', 'value' => 'borrador', 'operator' => '=', 'not' => true],
]);

// IS NULL
$records = CocoDB::get('productos', [
    ['column' => 'fecha_baja', 'value' => '', 'operator' => 'IS NULL'],
]);

// Limit con offset
$records = CocoDB::get('productos', [], 'num DESC', ['limit' => 10, 'offset' => 20]);

Opciones de get()

Option Type Default Description
uploads bool true Incluir datos de upload fields
relations bool/array true Resolver foreign keys. Array para limitar: ['category']
relationsDepth int 2 Profundidad de relaciones anidadas
translates string current lang Código de idioma
groupBy string null GROUP BY clause
aggregates array [] Funciones de agregación
onlyFields array null Seleccionar solo campos específicos
debug bool false Mostrar SQL query
redis bool null Forzar cache Redis
redis_expire int 60 TTL de cache Redis (segundos)

CocoDB::insertRecords($table, $records, $functions, $options)

// Un registro
$count = CocoDB::insertRecords('contacto', [
    'nombre' => 'John',
    'email' => 'john@example.com',
]);
// Usar mysql_insert_id() para obtener el nuevo num

// Múltiples
$count = CocoDB::insertRecords('productos', [
    ['nombre' => 'Product A', 'precio' => 10],
    ['nombre' => 'Product B', 'precio' => 20],
]);

Opciones de insert/update

Option Description
forceNum Permite setear el campo num manualmente
ignoreSchema Saltar validación de schema
ignoreFields Array de campos a ignorar

CocoDB::updateRecords($table, $records, $where, $functions, $options)

CocoDB::updateRecords('productos',
    ['precio' => 29.99, 'activo' => 1],
    ['num' => 42]
);

// Con operador en where
CocoDB::updateRecords('productos',
    ['activo' => 0],
    [['column' => 'stock', 'value' => 0, 'operator' => '<=']]
);

CocoDB::deleteRecords($table, $where, $options)

CocoDB::deleteRecords('productos', ['num' => 42]);

CocoDB::deleteRecords('logs', [
    ['column' => 'fecha', 'value' => '2024-01-01', 'operator' => '<']
]);

Parámetro $functions

Permite aplicar funciones MySQL a valores durante insert/update:

CocoDB::insertRecords('logs', [
    'mensaje' => 'Login exitoso',
    'fecha' => '',
], [
    'fecha' => 'NOW()',
]);

Where Clause — Formatos

Simple (key-value):

['campo' => 'valor']  // campo = 'valor'

Avanzado (array de condiciones):

[
    'column'   => 'field_name',
    'value'    => 'match_value',
    'operator' => '=',       // =, !=, <, >, <=, >=, LIKE, IN, IS NULL
    'or'       => false,     // OR en vez de AND
    'not'      => false,     // Negar la condición
    'raw_key'  => false,     // Saltar check de existencia de columna
]

Creación y Actualización de Registros

Flujo correcto

  1. Consultar el esquema de la tabla (leer cms/data/schema/{tabla}.ini.php)
  2. Revisar los tipos de campo
  3. Rellenar según el tipo de dato
  4. Enviar con la estructura correcta

Tipos de campo y formato

Tipo Formato Ejemplo
Text field String "Texto"
Text box String multilínea "Línea 1\nLínea 2"
Date/time YYYY-MM-DD HH:mm:ss "2025-12-03 10:30:00"
Wysiwyg String HTML "<p class=\"font-bold\">Texto</p>"
List String o número "activo" o "1" (num si es foreign key)
Checkbox Número 1/0 1 o 0
Multivalores String JSON "[{\"producto\":\"1\"}]"
Upload NO enviar — usar upload_record_image después de crear el registro

Table Schemas

Los schemas están en cms/data/schema/ como archivos .ini.php. Definen:

  • Nombres y tipos de campo
  • Reglas de validación
  • Relaciones (foreign keys)
  • Configuración de display

Ejemplos Prácticos

Hook de Cálculo de Precio

<?php
// hook.php del módulo "calcular_precio"
$precioUnitario = 50;

if ($tipo === 'mayoreo' && $cantidad > 10) {
    $precioUnitario *= 0.85; // 15% descuento
}

return [
    "success" => true,
    "precioUnitario" => round($precioUnitario, 2),
    "total" => round($precioUnitario * $cantidad, 2),
    "descuento" => $tipo === 'mayoreo' ? 15 : 0
];
?>
<hook result="precio" endpoint="/hooks/calcular_precio/" :cantidad="10" :tipo="'mayoreo'"></hook>
<p>Total: ${{ precio.total }}</p>

Hook con Operaciones de BD

<?php
// hook.php del módulo "procesar_compra"
$producto = CmsApi::get('productos', "num=$producto_id");

if (empty($producto)) {
    return ["success" => false, "message" => "Producto no encontrado"];
}

$total = $producto[0]['precio'] * $cantidad;

// Crear venta
CmsApi::insert('ventas', [[
    "usuario_num" => $usuario_id,
    "producto_num" => $producto_id,
    "cantidad" => $cantidad,
    "total" => $total,
    "fecha" => date('Y-m-d H:i:s')
]], [], ['return_last_id' => true]);

// Actualizar stock
$stock = CmsApi::get('stocks', "producto_num=$producto_id");
if (!empty($stock)) {
    CmsApi::update('stocks',
        ["cantidad" => $stock[0]['cantidad'] - $cantidad],
        "producto_num=$producto_id"
    );
}

return ["success" => true, "total" => $total];
?>

Gotchas de Base de Datos

Nombres de columnas = nombres en el schema

Los campos foreign key NO añaden _num automáticamente. El nombre es exactamente el definido en el .ini.php. Siempre consultar DESCRIBE cms_<tabla> o el schema antes de escribir INSERT/UPDATE.

Campos name vs title según menuType

El campo principal depende del menuType de la tabla:

menuType Campo principal Ejemplos
category name apartados, categorias_productos, productos
multi title users, localizaciones, correos, favoritos
single no aplica configuracion, configuracion_tienda

Las tablas category generan name automáticamente como campo de navegación. Las multi generan title como campo scaffold por defecto.

Orden de tablas y campos

Al crear tablas o campos, usar menuOrder / order alto (99+) para que aparezcan al final del listado admin sin alterar el orden existente.

cms_uploads tiene esquema diferente

La tabla usa createdTime (no createdDate). No tiene createdByUserNum, updatedDate, ni updatedByUserNum. Ver Uploads desde Hooks para el ejemplo completo de INSERT.

Upload fields en PHP

Incluso con una sola imagen, los uploads son arrays:

$image = @$p['foto'][0]['urlPath'] ?: '';

CocoDB Cache en Memoria

CocoDB::localCache() se llama en cada request. Todos los CmsApi::get() se cachean en memoria por hash de SQL+options. Si insertas un registro y luego haces get con la misma query exacta, devuelve el resultado cacheado (sin el nuevo registro).

Solución: Usar opciones diferentes en la segunda query (ej: ignoreSchema: true en una, sin en la otra) para que el hash sea distinto.


Generación de Slug (enlace)

CmsApi::insert() desde hooks NO genera el slug automáticamente (el panel admin sí lo hace). Generar manualmente:

$slug = strtolower(trim($name));
$slug = preg_replace('/[áàâäã]/u', 'a', $slug);
$slug = preg_replace('/[éèêë]/u', 'e', $slug);
$slug = preg_replace('/[íìîï]/u', 'i', $slug);
$slug = preg_replace('/[óòôöõ]/u', 'o', $slug);
$slug = preg_replace('/[úùûü]/u', 'u', $slug);
$slug = preg_replace('/[ñ]/u', 'n', $slug);
$slug = preg_replace('/[^a-z0-9\-]/', '-', $slug);
$slug = preg_replace('/-+/', '-', $slug);
$slug = trim($slug, '-');
$enlace = '/' . $slug . '-' . substr(uniqid(), -6) . '/';

Uploads desde Hooks

CmsApi no gestiona uploads directamente. Para subir archivos desde un hook:

  1. Recibir base64 via php://input
  2. Decodificar y guardar en $_SERVER['DOCUMENT_ROOT'] . '/cms/uploads/'
  3. Insertar manualmente en cms_uploads
$params = json_decode(file_get_contents('php://input'), true) ?: [];
$data = base64_decode($params['file_b64']);
$filename = $num . '_' . time() . '.' . $extension;
$uploadsDir = $_SERVER['DOCUMENT_ROOT'] . '/cms/uploads';
file_put_contents($uploadsDir . '/' . $filename, $data);

global $TABLE_PREFIX;
$sql = "INSERT INTO ".$TABLE_PREFIX."uploads (createdTime, tableName, recordNum, fieldName, urlPath, `order`)
        VALUES (NOW(), 'productos', $num, 'foto', '/cms/uploads/$filename', 1)";
mysql_query($sql);

Email (CocoEmail)

Requiere template en BD

CocoEmail::send('IDENTIFICADOR', $params, $to) busca el template en cms_correos por identificador. Si no existe → excepción "Correo no encontrado". Si $to tiene emails inválidos/vacíos → "No hay destinatarios válidos".

Siempre envolver en try/catch:

try {
    hook("/hooks/customEmailHeader/", ["remote" => @$_SESSION["REMOTE_URL"]]);
    CocoEmail::send("ACTIVAR_CUENTA", $user, [$user["email"]]);
} catch (Exception $e) {
    // Email falló pero la operación principal sigue
}

Variables en templates de email

Las variables se reemplazan con {nombre_campo}:

Hola {nombre}, tu código es {codigo}

El segundo parámetro de send() es el array de datos con las variables.