Saltar al contenido

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>.

EventoPayloadNota
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.

GanchoLo llamaLo aportaDevuelve
survey.answeredsurveys (al responder)googleReviewsinvitación a reseñar { token } o null
whatsapp.webhookplatform (webhook de Meta)whatsappresultado del tratamiento
message.deliver.whatsappcrm (mensajería)whatsappresultado del envío
birthday.noticecrm (regalo de cumpleaños)birthdays{ title, body } o null
kitchen.takeoverpos (comanda a cocina)printingtrue si la cola de impresión se hace cargo
order.delivery_ticketpos (pedido a domicilio)printingnada
product.assert_availablepos (precio de una línea)setMenusnada; lanza si el menú no se puede vender ahora
menu.closed_productscatalog (carta pública)setMenusids de menús no disponibles
app.wallet.availabilitycrm (configuración de la app)wallet{ apple, google }
sale.vouchers_issuedposv3 (venta)voucherscheques regalo vendidos
sale.items_returnedposv3 (devolución)vouchersnada
sale.redeem_payments · sale.link_redemptions · sale.undo_redemptionsposv3 (venta con cheque)voucherscanjes / respuesta / nada
posv3.routesposv3 (arranque)vouchersnada; registra rutas en el TPV v3
display.offersdisplay (diapositiva «Oferta activa»)happyHourDisplayOffer[] (ofertas vigentes con franjas y precios); [] con el módulo apagado
display.offer_cardsdisplay (banda destacada)happyHourOfferCard[] (ofertas vigentes de cualquier canal); [] con el módulo apagado
floor.reserved_tablespos (GET /tables, plano del TPV) y waitlist (mesas libres)reservationsMap<tableId, TableReservation> con la reserva próxima de cada mesa; Map vacío con el módulo apagado
order.auto_acceptpos (checkout.completed de un pedido online: pos/handlers.ts, dentro de la transacción del webhook de pago)kioskboolean: ¿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.savedplatform (al guardar una integración: POST /integrations/:provider/connect)displaynada; el plugin reacciona si provider === "instagram" y descarga las últimas publicaciones
home.connectionsinsights (Inicio → Conexiones)displayHomeService[] con las pantallas conectadas alguna vez (kind: "screen", ok o down); sin nadie enganchado, ninguna fila
fulfillment.resolvepos (POST /public/orders, vía lib/fulfillment.ts) y phoneOrdersdeliveryResolvedFulfillment (tipo, envío, dirección, «lo antes posible», franja, mínimo); nunca null; undefined con el módulo apagado
fulfillment.reserve_slotpos y phoneOrders, dentro de la transacción del pedidodeliverynada; lanza CONFLICT slot_unavailable si la franja se llena
fulfillment.quotepos (POST /public/orders/quote, solo con «domicilio»)delivery{ deliveryFee, minOrder, freeDeliveryFrom }; undefined con el módulo apagado
delivery.availablegroupOrder (configuración pública y confirmación del grupo)deliveryboolean: módulo encendido y al menos una zona activa
groupOrder.delivery_alloweddelivery (GET/PUT /delivery/settings)groupOrderboolean (ajuste group_order.allowDelivery)
groupOrder.set_delivery_alloweddelivery (PUT /delivery/settings, en su transacción)groupOrdernada; guarda el ajuste con { value, userId }
pricing.offers_envpos (offerEnvFor: presupuesto web/QR, alta de pedidos, quiosco, pedidos por teléfono)happyHourfirstHook: entorno de ofertas del canal, o null (módulo apagado / sin reglas vigentes); undefined = nadie responde (se trata como null)
pricing.quote_offerspos (quoteOrder, etapa 1; solo si hay entorno de ofertas)happyHourfilterHook: el presupuesto con las ofertas aplicadas (descuento por línea, offers, discount, total)
pricing.quote_bundlespos (quoteOrder, etapa 2; siempre, después de las ofertas)combosfilterHook: el presupuesto con los packs aplicados (combos, descuento por línea y total)
pricing.record_offerspos (recordQuoteOffers: alta de pedido web/QR/quiosco/teléfono, en su transacción)happyHournada; guarda un descuento por línea y regla y su historial
pricing.sync_bundlespos (syncBundles: alta de pedido con packs y syncPromotions del TPV)combosnada; rehace los descuentos de pack del pedido (idempotente)
pricing.line_offers_syncpos (TPV: línea añadida, fusionada o con otra cantidad/extras)happyHournada; rehace las ofertas de esa línea (solo orígenes pos y waiter)
pricing.line_offers_clearpos (TPV: línea quitada o anulada; descuento manual en la línea)happyHournada; quita las ofertas de esas líneas y su historial
pricing.discounts_removedpos (TPV: se quita a mano un descuento que era de una oferta)happyHournada; 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 db del 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/catch y devuelve undefined ante 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.

Contrato con el host: apiVersion 2. Sitio estático: no recoge datos ni usa servicios de terceros.