4 · Action Hook (before-hook sözleşmesi)

Before-hook, bir çekirdek işlem (ör. masa kapatma) gerçekleşmeden ÖNCE çalışan senkron bir kapıdır. Restomenum actionUrl'inize imzalı POST atar; siz allow/deny kararı dönersiniz ve işlem buna göre devam eder veya iptal olur.

Kavram: gate, bildirim değil

Normal event'ler (webhook) işlem olduktan sonra gelir (async, yalnız haber alırsınız). Before-hook ise işlem olmadan önce çalışan senkron bir kapıdır — allow/deny kararınız işlemin devam edip etmeyeceğini belirler. Örn: "masa kapanmadan önce e-faturayı kes; kesmeden kapanma."

after-event (table.closed)before-hook (table.close)
Ne zamanKapandıktan sonraKapanıştan önce
DoğaAsync bildirimSenkron kapı (akışı durdurur)
Sizin cevabınız(yok){ decision:"allow"|"deny" }

Çalışma mantığı (adım adım)

  1. Kullanıcı panelde "Masa Kapat"a basar.
  2. Restomenum, manifest'te tanımladığınız table.close hook'unu bulur.
  3. Bildirdiğiniz FORMU kullanıcıya gösterir (siz HTML yazmazsınız — şemayı bildirirsiniz, native render).
  4. Kullanıcı formu doldurur.
  5. Restomenum → actionUrl'inize İMZALI SENKRON POST atar (aşağıdaki istek).
  6. SİZ: imzayı doğrula → (gerekirse target.id ile veriyi çek) → karar ver → timeoutMs içinde { decision } dön.
  7. allow → masa kapanır (kapanış gate’inde receiptExtras verdiyseniz fişe işlenir) · deny → kapanma İPTAL, mesajınız kullanıcıya.
Formu siz render etmezsiniz; manifest'te şemayı bildirirsiniz, Restomenum native gösterir. Token/DOM erişiminiz yoktur — yalnız kararınızı dönersiniz.

1) Aldığınız istek (CANLI)

POST {actionUrl}
Content-Type: application/json
X-Restomenum-Signature: t=<unixSec>,v1=<HMAC_SHA256(webhookSecret,"<t>.<rawBody>")>
X-Restomenum-Event: hook

{
  "type": "hook",                 // event/action'dan ayırt edin
  "event": "table.close",
  "stage": "before",
  "environment": "sandbox",       // "sandbox" | "production" — imzalı gövdede (header kopyası imzasız)
  "tenantId": "kcK88DtUafc…",     // hangi restoran (tenant)
  "target": { "type": "table", "id": "bah%C3%A7e1" },  // bağlam REFERANSI (doküman id)
  "data": {                       // YALNIZ manifest includeData:true ise — kapanış-anı snapshot'ı
    "tableId": "masa-1", "tableName": "Masa 1", "docNo": 4, "desing": "Bahçe",
    "location": "Masa 1", "personCount": 0,
    "orders":   [ { "id": "masa-1-5be4", "title": "Frozen", "quantity": 1, "lineTotal": 14 } ],
    "payments": [ { "methodId": "29-cash", "title": "nakit", "amount": 14, "isDiscount": false } ],
    "total": 14, "paid": 14, "totalDiscount": 0
  },
  "formData": { "courierCalled": true },               // kullanıcının formda girdiği değerler
  "actor": { "userId": "<uid>", "role": "manager" },   // işlemi yapan kullanıcı; role ∈ manager|staff (imzalı → güvenilir)
  "timeoutMs": 10000,             // cevap bütçeniz (1–10 sn); aşılırsa failMode
  "occurredAt": 1780713277601,
  "hookId": "hk_0380ad69-…"       // idempotency / izleme
}
AlanTipZorunluAçıklama
type:"hook"literalWebhook event'lerinden ayırt edin (aynı uca düşebilir).
eventstringHangi gate (table.close).
stage"before"Akış öncesi gate.
environment"sandbox" | "production"Teslimin ortamı (test/dev mağazası = "sandbox") — imzalı gövdede. Header kopyası imzasızdır; gövdedekini kullan.
tenantIdstringHangi restoran (tenant).
target{ type, id }Değer-torbası değil — yalnız referans. Veriye ihtiyacınız varsa id ile Data API'den çekin (↓) — ya da includeData:true ile data gövdede gelir.
dataobjectYalnız includeData:true ise. Kapanış-öncesi server-türevli kanonik veri (masa → tables/get şekli). customer yalnız customers:read+consent; data yalnız orders:read. Kaynak kapanışta silineceği için karar-anı veriyi tek seferde alır.
formDataobjectKullanıcının doldurduğu form çıktısı.
actor{ userId, role }İşlemi yapan kullanıcı; role ∈ manager|staff. Per-user yetki için (imzalı → güvenilir). Ad/PII için users/get.
timeoutMsnumberCevap bütçeniz (1–10 sn) — aşarsanız failMode devreye girer.
occurredAtnumberUnix ms.
hookIdstringÇift-işlem koruması (idempotency) / log.
Neden context (total/tableName) yok? Güvenlik: client değerlerine güvenmeyiz. Gereken veriyi otoriter kaynaktan kendiniz çekersiniz (↓) — Stripe/Shopify deseni.

2) Veriyi çekin (target → Data API)

Kısayol — includeData:true: manifest'te açarsan kapanış-öncesi kanonik veri data alanında gövdeyle gelir → bu adımı (ayrı fetch) atlarsın. Gate allow dedikten sonra masa kapanır ve tables/{id} silinir; o yüzden karar anında veri lazımsa includeData en güvenli yoldur (kaybolan-kaynak yarışı yok).

includeData kullanmıyorsan: target sadece referans verir; masanın içeriğini (ürünler/tutar) çekmek için Masa Detayı (tables/get):

GET {RESTOMENUM_BASE}/plugin-api/tables/get?id=<encodeURIComponent(target.id)>
Authorization: Bearer <apiKey>        // kurulumdaki install API key  ·  scope: orders:read
target.id URL-encoded olabilir (bah%C3%A7e1) → query'de encodeURIComponent kullanın.

Yanıt (kanonik order — packets/get ile aynı şekil):

{ "success": true, "data": {
  "tableId": "bah%C3%A7e1", "tableName": "Bahçe1", "docNo": …, "desing": "Bahçe",
  "total": 285, "paid": 285, "totalDiscount": 0,
  "orders": [ { "title": "HYPATİA KAHVALTI", "quantity": 1, "options": [], "lineTotal": 160 }, … ],
  "customer": { … }   // varsa (customers:read + consent ile)
} }

3) Döndüğünüz yanıt (zorunlu)

// HTTP 200 + JSON
{ "decision": "allow" | "deny",
  "message": "Kullanıcıya gösterilecek metin",
  "receiptExtras": [                        // ⏳ yakında — YALNIZ kapanış gate'lerinde (table.close/packet.close)
    { "key": "tse.qr", "type": "qr", "value": "V0;…" },
    { "key": "tse.txNumber", "type": "text", "label": "Beleg-Nr", "value": "366" }
  ] }
  • allow → işlem devam eder.
  • deny → işlem iptal; message kullanıcıya (düz metin).
  • receiptExtras (⏳ yakında) → yalnız kapanış gate'lerinde (table.close / packet.close) ve yalnız allow'da: öğeler kapanış yazılmadan önce adisyona işlenir ve fişe basılabilir hale gelir (mali blok / belge no / sadakat satırı) — ikinci bir API çağrısı gerekmez. Şema, sahiplik (tse.*) ve sınırlar: Fişe Ek Alanlar.

4) timeoutMs · failMode · enforce

  • timeoutMs içinde yanıt verin. Aşarsanız → failMode (manifest): closed=deny (durdur) / open=allow (geç).
  • enforce:true (manifest) → sert garanti: gate çalışmadan işlem gerçekleşmez (backend doğrular, atlanamaz). Erişilemezseniz işlem bloklanabilir — yalnız zorunlu gate'lerde kullanın. Token'ı Restomenum üretir; sizi ilgilendirmez. Detay: Hook'lar → Sert Garanti.

5) İmza doğrulama (webhook ile aynı)

X-Restomenum-Signatureham gövde üzerinden webhookSecret ile HMAC-SHA256 (±5 dk). Doğrulanmazsa 401. Algoritma/kod: İmza Doğrulama.

Tam örnek

// /hooks/table-close — Action Hook (akışı DURDURAN). Restomenum HMAC imzalı senkron POST eder.
// Restomenum → sana (✅ teyitli — canlı örnek):
//   { type:"hook", event:"table.close", stage:"before", environment, tenantId,
//     target:{ type:"table", id:"Masa 5" },        // environment = "sandbox" | "production" (imzalı gövdede)
//     actor:{ userId, role },        // işlemi yapan kullanıcı; role ∈ manager|staff (imzalı → güvenilir)
//     data,                          // YALNIZ manifest includeData:true ise — kapanış-öncesi kanonik veri
//                                    //   (tables/get ile aynı şekil; customer → customers:read+consent, data → orders:read)
//     formData, hookId:"hk_…", occurredAt }
// Sen → Restomenum:  { decision, message?, receiptExtras? }   // receiptExtras ⏳ yakında (yalnız kapanış gate'lerinde)
import express from 'express';
const app = express();

app.post('/action', express.raw({ type: '*/*' }), async (req, res) => {
  const rawBody = req.body.toString('utf8');
  const sigHeader = req.get('X-Restomenum-Signature');
  const body = JSON.parse(rawBody);
  if (!(body.type === 'hook' && body.event === 'table.close')) return res.json({});

  // 1) imzayı doğrula (webhook ile AYNI: HMAC_SHA256(webhookSecret, "<t>.<rawBody>"))
  if (!verifySignature(webhookSecret, rawBody, sigHeader)) return res.sendStatus(401);

  // 2) per-user yetki: actor.role'e göre karar ver (imzalı gövde → güvenilir)
  if (body.actor?.role !== 'manager')
    return res.json({ decision: 'deny', message: 'Bu işlemi yalnız yönetici yapabilir.' });

  // 3) kapanış-öncesi veri: includeData:true ise body.data hazır; değilse target.id ile çek
  //    (kapanıştan SONRA kaynak kaybolur → karar anında includeData ile tek seferde al)
  const table = body.data ?? (await fetch(`${BASE}/plugin-api/tables/get?id=${encodeURIComponent(body.target.id)}`,
                            { headers: { authorization: 'Bearer ' + apiKey } }).then((r) => r.json())).data;

  // 4) iş + karar (formData kullanıcı girdisi, table otoriter veri)
  if (!body.formData?.courierCalled)
    return res.json({ decision: 'deny', message: 'Önce kuryeyi çağırın.' });
  const invoiceNo = await issueInvoice(table);
  // Belgeyi fişe bağlamak için İKİNCİ bir API çağrısı YOK — karar ile veri aynı yanıtta (⏳ yakında):
  return res.json({ decision: 'allow', message: 'Onaylandı.',
    receiptExtras: [{ key: 'tse.txNumber', type: 'text', label: 'Beleg-Nr', value: String(invoiceNo) }] });
});

// allow → işlem devam eder. Adisyon belgesine YAZILAN tek alan receiptExtras'tır (kapanış gate'leri;
//         tse.* için capability:fiscal.de:provide şart). message/level protokol alanıdır — belgeye yazılmaz.
// deny  → işlem iptal, message kullanıcıya gösterilir (düz metin).
// timeout/hata → manifest.failMode:  closed (varsayılan) = deny · open = allow.
//
// SENİN SORUMLULUĞUN yalnız bu { decision, message?, receiptExtras? } cevabını dönmek.
// Consent ekranı, onay-fişi ve enforcement RESTOMENUM tarafındadır — sen yapmazsın.

Manifest

"hooks": [{
  "action": "table.close", "blocking": true,
  "failMode": "closed", "enforce": true, "timeoutMs": 5000,
  "includeData": true,            // gate gövdesine kapanış-öncesi kanonik data göm (ayrı fetch gerekmez)
  "ui": { "kind": "form", "form": {
    "fields": [ { "key": "courierCalled", "type": "checkbox", "label": { "tr": "Kurye çağrıldı" } } ],
    "submitLabel": { "tr": "Onayla" }
  } }
}]

Editörde ui.formId ile bir form seçersiniz; portal yayında formu inline gömer (yukarıdaki ui.form). Manifest şeması: Hook'lar (Akış Kontrolü).

Belgeye yazılan tek alan receiptExtras'tır. decision, message, level ve display protokol alanlarıdır: akışı yönetir / kasiyere bildirim gösterir, adisyon dokümanına yazılmaz. Fişe veri işlemek için kapanış gate'lerinde receiptExtras; tutar/kalem için update-orders · update-payments. Rastgele alan enjekte edemezsiniz.