Apariencia
Ganchos y eventos de dominio
El núcleo (pos, crm, platform…) no importa plugins. Se comunica con ellos por dos mecanismos que comparten registro y alcance por petición (api/src/lib/domainEvents.ts).
Eventos de dominio
Se declaran con domainHandlers en el ZModule. Son síncronos y corren dentro de la transacción del emisor: si un manejador lanza, la transacción entera se deshace. Sé tolerante (el plugin lucky envuelve su trabajo en un SAVEPOINT).
ts
domainHandlers: {
"order.paid": async (ctx, payload) => {
// ctx.db es la transacción del emisor; ctx.req la petición (o null)
},
},Un manejador tiene la firma (ctx: { db: Conn; req: FastifyRequest | null }, payload: Record<string, unknown>) => Promise<unknown>.
| Evento | Payload | Nota |
|---|---|---|
order.created | { orderId } | |
order.updated | { orderId } | |
order.paid | { orderId } | pedido cobrado del todo |
order.completed | { orderId } | |
order.cancelled | { orderId } | |
payment.completed | { paymentId, orderId, amount } | |
payment.refunded | — | |
customer.created | { customerId } | |
customer.updated | { customerId } | |
employee.clocked_in | { employeeId, entryId } | |
employee.clocked_out | { employeeId, entryId } | |
stock.updated | { itemId, stock } | |
stock.low | { itemId, stock, minStock } | |
store_order.paid | { storeOrderId } | |
store_order.cancelled | { storeOrderId } | cancelado (ya estaba pagado si paidAt) |
checkout.completed | — | |
checkout.expired | — | |
newsletter.subscribed | { customerId, email } | |
pos.ordering_changed | { enabled } | se enciende/apaga el pedido por QR (pos/common.ts → orderingEnabled) |
Ganchos
Puntos de extensión con valor de vuelta. El núcleo llama y, sin nadie enganchado, sigue su camino por defecto. Se responden con hooks en el ZModule (Record<nombre, handler | handler[]>). Los nombres siguen área.punto.
Función (@host/lib/domainEvents) | Devuelve |
|---|---|
callHook(ctx, nombre, payload) | los resultados de todos los manejadores, en orden |
firstHook(ctx, nombre, payload) | el primer resultado distinto de undefined (o undefined) |
filterHook(ctx, nombre, valor, payload) | el valor, pasado por cada manejador (payload.value) |
ts
hooks: {
"survey.answered": async (ctx, payload) => {
if (!(await isModuleEnabled(ctx.db, "googleReviews"))) return undefined;
return { token: "…" };
},
},Los ganchos no respetan el interruptor del módulo: los manejadores se registran aunque el módulo esté apagado, así que cada uno mira por sí mismo isModuleEnabled donde haga falta y devuelve undefined (o [], null, un Map vacío, según el contrato del gancho) si está apagado.
Ganchos que ofrece hoy el núcleo
La tabla sale de docs/plugins.md, que es la fuente.
| Gancho | Lo llama | Lo aporta | Devuelve |
|---|---|---|---|
survey.answered | surveys (al responder) | googleReviews | invitación a reseñar { token } o null |
whatsapp.webhook | platform (webhook de Meta) | resultado del tratamiento | |
message.deliver.whatsapp | crm (mensajería) | resultado del envío | |
birthday.notice | crm (regalo de cumpleaños) | birthdays | { title, body } o null |
kitchen.takeover | pos (comanda a cocina) | printing | true si la cola de impresión se hace cargo |
order.delivery_ticket | pos (pedido a domicilio) | printing | nada |
product.assert_available | pos (precio de una línea) | setMenus | nada; lanza si el menú no se puede vender ahora |
menu.closed_products | catalog (carta pública) | setMenus | ids de menús no disponibles |
app.wallet.availability | crm (configuración de la app) | wallet | { apple, google } |
sale.vouchers_issued | posv3 (venta) | vouchers | cheques regalo vendidos |
sale.items_returned | posv3 (devolución) | vouchers | nada |
sale.redeem_payments · sale.link_redemptions · sale.undo_redemptions | posv3 (venta con cheque) | vouchers | canjes / respuesta / nada |
posv3.routes | posv3 (arranque) | vouchers | nada; registra rutas en el TPV v3 |
display.offers | display (diapositiva «Oferta activa») | happyHour | DisplayOffer[] (ofertas vigentes con franjas y precios); [] con el módulo apagado |
display.offer_cards | display (banda destacada) | happyHour | OfferCard[] (ofertas vigentes de cualquier canal); [] con el módulo apagado |
floor.reserved_tables | pos (GET /tables, plano del TPV) y waitlist (mesas libres) | reservations | Map<tableId, TableReservation> con la reserva próxima de cada mesa; Map vacío con el módulo apagado |
order.auto_accept | pos (checkout.completed de un pedido online: pos/handlers.ts, dentro de la transacción del webhook de pago) | kiosk | boolean: ¿el pedido pagado sale a cocina sin que el personal lo acepte? Solo responde para source: "kiosk" (ajuste autoAccept del quiosco); undefined para el resto |
integration.saved | platform (al guardar una integración: POST /integrations/:provider/connect) | display | nada; el plugin reacciona si provider === "instagram" y descarga las últimas publicaciones |
home.connections | insights (Inicio → Conexiones) | display | HomeService[] con las pantallas conectadas alguna vez (kind: "screen", ok o down); sin nadie enganchado, ninguna fila |
fulfillment.resolve | pos (POST /public/orders, vía lib/fulfillment.ts) y phoneOrders | delivery | ResolvedFulfillment (tipo, envío, dirección, «lo antes posible», franja, mínimo); nunca null; undefined con el módulo apagado |
fulfillment.reserve_slot | pos y phoneOrders, dentro de la transacción del pedido | delivery | nada; lanza CONFLICT slot_unavailable si la franja se llena |
fulfillment.quote | pos (POST /public/orders/quote, solo con «domicilio») | delivery | { deliveryFee, minOrder, freeDeliveryFrom }; undefined con el módulo apagado |
delivery.available | groupOrder (configuración pública y confirmación del grupo) | delivery | boolean: módulo encendido y al menos una zona activa |
groupOrder.delivery_allowed | delivery (GET/PUT /delivery/settings) | groupOrder | boolean (ajuste group_order.allowDelivery) |
groupOrder.set_delivery_allowed | delivery (PUT /delivery/settings, en su transacción) | groupOrder | nada; guarda el ajuste con { value, userId } |
pricing.offers_env | pos (offerEnvFor: presupuesto web/QR, alta de pedidos, quiosco, pedidos por teléfono) | happyHour | firstHook: entorno de ofertas del canal, o null (módulo apagado / sin reglas vigentes); undefined = nadie responde (se trata como null) |
pricing.quote_offers | pos (quoteOrder, etapa 1; solo si hay entorno de ofertas) | happyHour | filterHook: el presupuesto con las ofertas aplicadas (descuento por línea, offers, discount, total) |
pricing.quote_bundles | pos (quoteOrder, etapa 2; siempre, después de las ofertas) | combos | filterHook: el presupuesto con los packs aplicados (combos, descuento por línea y total) |
pricing.record_offers | pos (recordQuoteOffers: alta de pedido web/QR/quiosco/teléfono, en su transacción) | happyHour | nada; guarda un descuento por línea y regla y su historial |
pricing.sync_bundles | pos (syncBundles: alta de pedido con packs y syncPromotions del TPV) | combos | nada; rehace los descuentos de pack del pedido (idempotente) |
pricing.line_offers_sync | pos (TPV: línea añadida, fusionada o con otra cantidad/extras) | happyHour | nada; rehace las ofertas de esa línea (solo orígenes pos y waiter) |
pricing.line_offers_clear | pos (TPV: línea quitada o anulada; descuento manual en la línea) | happyHour | nada; quita las ofertas de esas líneas y su historial |
pricing.discounts_removed | pos (TPV: se quita a mano un descuento que era de una oferta) | happyHour | nada; esos usos dejan de contar en el historial |
Para ver el estado real en tu copia del código: grep -rn "firstHook\|callHook\|filterHook" api/src. Un gancho nuevo se añade en el punto del núcleo donde hace falta (llamada con valor por defecto) y se documenta ahí.
Reglas de diseño
- Un plugin no se engancha a otro plugin. Si dos plugins deben hablar y no pueden depender uno del otro, rompe el ciclo con un evento o un gancho que defina el núcleo.
- Transaccionalidad. Cuando el gancho indica que el
dbdel contexto es la transacción del emisor, úsalo; no abras otra conexión. - Errores. Si el contrato del gancho lo pide, envuelve tu lectura en
try/catchy devuelveundefinedante cualquier error, para que un fallo tuyo no deshaga la operación del núcleo. - Sin datos de más. Un gancho devuelve lo que su contrato describe; no filtres información de otros módulos en el valor de vuelta.