Owner notification webhook: Telegram re-point (#23)
Test backoffice (smb-crm) / test (push) Has been cancelled
Test backoffice (smb-crm) / test (push) Has been cancelled
Flask fires a fire-and-forget internal webhook (owner_notify.py, mirroring
mailer.py's background-thread queue) on booking create/cancel/reschedule,
carrying the same {booking, business_name, notify_channel} shape the old
EA-driven "Build booking row" node produced, plus an event field so the
Telegram message can say what actually happened. n8n/booking-sync.json gets
a new webhook + IF node feeding the existing Telegram node directly, so it
no longer needs EA's API to build the notification payload; channel routing
(only telegram sends for now) lives in that IF node.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,3 +15,8 @@ SMTP_PASSWORD=
|
|||||||
MAIL_FALLBACK_FROM=noreply@mivanchenko.de
|
MAIL_FALLBACK_FROM=noreply@mivanchenko.de
|
||||||
# Base URL the manage-booking link in the confirmation email is built from.
|
# Base URL the manage-booking link in the confirmation email is built from.
|
||||||
PUBLIC_BASE_URL=https://onboard.mivanchenko.de
|
PUBLIC_BASE_URL=https://onboard.mivanchenko.de
|
||||||
|
# Owner Telegram notification (#23). Points at n8n's "Owner notify webhook"
|
||||||
|
# node (n8n/booking-sync.json), e.g. https://n8n.mivanchenko.de/webhook/owner-notify.
|
||||||
|
# Left blank, sending is skipped (logged, not fatal) -- same convention as
|
||||||
|
# SMTP_HOST above.
|
||||||
|
OWNER_NOTIFY_WEBHOOK_URL=
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from flask import Blueprint, jsonify, request
|
|||||||
import availability
|
import availability
|
||||||
import booking_db as bdb
|
import booking_db as bdb
|
||||||
import booking_mail
|
import booking_mail
|
||||||
|
import owner_notify
|
||||||
|
|
||||||
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
|
bp = Blueprint("booking_api", __name__, url_prefix="/api/booking")
|
||||||
|
|
||||||
@@ -161,6 +162,7 @@ def create_booking_row(client_id, resource_id, service_id, start_time,
|
|||||||
# owner manual-entry (#20) included -- calling bdb.create_booking()
|
# owner manual-entry (#20) included -- calling bdb.create_booking()
|
||||||
# directly would bypass it.
|
# directly would bypass it.
|
||||||
booking_mail.send_booking_confirmation(client, booking, token)
|
booking_mail.send_booking_confirmation(client, booking, token)
|
||||||
|
owner_notify.notify(client, booking, "created") # #23
|
||||||
return booking, token
|
return booking, token
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +176,9 @@ def cancel_booking_row(client_id, booking_id):
|
|||||||
raise NotFound()
|
raise NotFound()
|
||||||
if existing["status"] == "cancelled":
|
if existing["status"] == "cancelled":
|
||||||
raise AlreadyCancelled()
|
raise AlreadyCancelled()
|
||||||
return bdb.update_booking(client_id, booking_id, status="cancelled")
|
updated = bdb.update_booking(client_id, booking_id, status="cancelled")
|
||||||
|
owner_notify.notify(bdb.get_client(client_id), updated, "cancelled") # #23
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
def reschedule_booking_row(client_id, booking_id, new_start):
|
def reschedule_booking_row(client_id, booking_id, new_start):
|
||||||
@@ -203,10 +207,12 @@ def reschedule_booking_row(client_id, booking_id, new_start):
|
|||||||
raise SlotUnavailable()
|
raise SlotUnavailable()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return bdb.update_booking(client_id, booking_id, start_time=new_start,
|
updated = bdb.update_booking(client_id, booking_id, start_time=new_start,
|
||||||
end_time=new_end)
|
end_time=new_end)
|
||||||
except bdb.BookingConflict:
|
except bdb.BookingConflict:
|
||||||
raise SlotTaken() from None
|
raise SlotTaken() from None
|
||||||
|
owner_notify.notify(client, updated, "rescheduled") # #23
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
class _BadDuration(ValueError):
|
class _BadDuration(ValueError):
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Owner Telegram notification webhook (#23): fires an internal webhook call
|
||||||
|
on booking create/cancel/reschedule, with the same {booking, business_name,
|
||||||
|
notify_channel} payload shape n8n/booking-sync.json's "Build booking row"
|
||||||
|
step used to hand the Telegram node -- plus an "event" field (that node
|
||||||
|
never needed, since it only ever fired for a new EA booking) so the message
|
||||||
|
text can say what actually happened instead of always "new booking".
|
||||||
|
|
||||||
|
Fire-and-forget over HTTP, mirroring mailer.py's background-thread queue: a
|
||||||
|
slow/unreachable webhook must never block or fail a booking mutation.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Left blank, sending is skipped (logged, not fatal) -- same convention as
|
||||||
|
# mailer.py's SMTP_HOST. Channel routing (only "telegram" does anything at
|
||||||
|
# launch) lives on the n8n side of this webhook, not here -- see #23.
|
||||||
|
NOTIFY_WEBHOOK_URL = os.environ.get("OWNER_NOTIFY_WEBHOOK_URL", "")
|
||||||
|
|
||||||
|
_queue = queue.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_booking(booking):
|
||||||
|
return {k: (v.isoformat() if hasattr(v, "isoformat") else v)
|
||||||
|
for k, v in booking.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_now(payload):
|
||||||
|
if not NOTIFY_WEBHOOK_URL:
|
||||||
|
print(f"[owner_notify] OWNER_NOTIFY_WEBHOOK_URL not configured, "
|
||||||
|
f"skipping notify for booking {payload['booking'].get('booking_id')}",
|
||||||
|
flush=True)
|
||||||
|
return
|
||||||
|
req = urllib.request.Request(
|
||||||
|
NOTIFY_WEBHOOK_URL,
|
||||||
|
data=json.dumps(payload).encode("utf-8"),
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=10):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
while True:
|
||||||
|
payload = _queue.get()
|
||||||
|
try:
|
||||||
|
_send_now(payload)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
print(f"[owner_notify] send for booking "
|
||||||
|
f"{payload['booking'].get('booking_id')} failed:", flush=True)
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
_queue.task_done()
|
||||||
|
|
||||||
|
|
||||||
|
threading.Thread(target=_worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def notify(client, booking, event):
|
||||||
|
"""Queue a best-effort owner notification for a booking create/cancel/
|
||||||
|
reschedule (#23). event is "created"/"cancelled"/"rescheduled" -- booking
|
||||||
|
status alone can't distinguish a fresh booking from a rescheduled one
|
||||||
|
(both land as "confirmed"), and the n8n message text needs to say which
|
||||||
|
happened. Fires for every notify_channel value, including one that isn't
|
||||||
|
"telegram" (or is unset) -- that's not an error, the n8n workflow this
|
||||||
|
webhook feeds is what decides whether a given channel actually sends
|
||||||
|
anything, which keeps this call site the same regardless of how many
|
||||||
|
channels exist in the future."""
|
||||||
|
payload = {
|
||||||
|
"event": event,
|
||||||
|
"booking": _serialize_booking(booking),
|
||||||
|
"business_name": (client or {}).get("business_name") or "",
|
||||||
|
"notify_channel": (client or {}).get("notify_channel"),
|
||||||
|
}
|
||||||
|
_queue.put(payload)
|
||||||
@@ -102,6 +102,37 @@ def test_create_booking_via_public_endpoint_sends_confirmation_email(client, mon
|
|||||||
assert "Haircut" in sent[0]["html"]
|
assert "Haircut" in sent[0]["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_booking_notifies_owner(client, monkeypatch):
|
||||||
|
"""#23's acceptance criterion: creating a booking via the public API
|
||||||
|
triggers the owner notification webhook, carrying the client's
|
||||||
|
notify_channel and business_name alongside the booking."""
|
||||||
|
notified = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"owner_notify.notify",
|
||||||
|
lambda client, booking, event: notified.append((client, booking, event)))
|
||||||
|
resource, service = _setup_resource_and_service(
|
||||||
|
auto_confirm=True, min_notice_minutes=0, max_advance_days=365)
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slots = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
|
||||||
|
|
||||||
|
resp = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slots[0],
|
||||||
|
"customer_name": "Nia", "customer_contact": "nia@example.com"})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
booking_id = resp.get_json()["booking_id"]
|
||||||
|
|
||||||
|
assert len(notified) == 1
|
||||||
|
notified_client, notified_booking, notified_event = notified[0]
|
||||||
|
assert notified_client["client_id"] == CLIENT_A
|
||||||
|
assert notified_booking["booking_id"] == booking_id
|
||||||
|
assert notified_booking["customer_name"] == "Nia"
|
||||||
|
assert notified_event == "created"
|
||||||
|
|
||||||
|
|
||||||
def test_create_booking_auto_confirm_false_yields_pending(client):
|
def test_create_booking_auto_confirm_false_yields_pending(client):
|
||||||
resource, service = _setup_resource_and_service(
|
resource, service = _setup_resource_and_service(
|
||||||
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
|
auto_confirm=False, min_notice_minutes=0, max_advance_days=365)
|
||||||
@@ -180,6 +211,64 @@ def test_cancel_with_valid_token_cancels_booking(client):
|
|||||||
assert resp3.status_code == 400
|
assert resp3.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_notifies_owner(client, monkeypatch):
|
||||||
|
notified = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"owner_notify.notify",
|
||||||
|
lambda client, booking, event: notified.append((client, booking, event)))
|
||||||
|
resource, service = _setup_resource_and_service(
|
||||||
|
min_notice_minutes=0, max_advance_days=365)
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slot = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"][0]
|
||||||
|
created = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slot,
|
||||||
|
"customer_name": "Omar", "customer_contact": "o@example.com"}).get_json()
|
||||||
|
notified.clear() # drop the create-time notification, isolate the cancel one
|
||||||
|
|
||||||
|
resp = client.post("/api/booking/cancel", json={"token": created["token"]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
assert len(notified) == 1
|
||||||
|
notified_client, notified_booking, notified_event = notified[0]
|
||||||
|
assert notified_client["client_id"] == CLIENT_A
|
||||||
|
assert notified_booking["booking_id"] == created["booking_id"]
|
||||||
|
assert notified_booking["status"] == "cancelled"
|
||||||
|
assert notified_event == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_reschedule_notifies_owner(client, monkeypatch):
|
||||||
|
notified = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"owner_notify.notify",
|
||||||
|
lambda client, booking, event: notified.append((client, booking, event)))
|
||||||
|
resource, service = _setup_resource_and_service(
|
||||||
|
min_notice_minutes=0, max_advance_days=365)
|
||||||
|
day = _next_monday(date.today())
|
||||||
|
slot_list = client.get("/api/booking/slots", query_string={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"],
|
||||||
|
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
|
||||||
|
created = client.post("/api/booking", json={
|
||||||
|
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
|
||||||
|
"service_id": service["service_id"], "start_time": slot_list[0],
|
||||||
|
"customer_name": "Priya", "customer_contact": "p@example.com"}).get_json()
|
||||||
|
notified.clear() # drop the create-time notification, isolate the reschedule one
|
||||||
|
|
||||||
|
resp = client.post("/api/booking/reschedule", json={
|
||||||
|
"token": created["token"], "start_time": slot_list[2]})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
assert len(notified) == 1
|
||||||
|
notified_client, notified_booking, notified_event = notified[0]
|
||||||
|
assert notified_client["client_id"] == CLIENT_A
|
||||||
|
assert notified_booking["start_time"].isoformat() == slot_list[2]
|
||||||
|
assert notified_event == "rescheduled"
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_with_expired_token_is_rejected(client):
|
def test_cancel_with_expired_token_is_rejected(client):
|
||||||
import jwt as pyjwt
|
import jwt as pyjwt
|
||||||
from booking_api import TOKEN_SECRET
|
from booking_api import TOKEN_SECRET
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Unit tests for owner_notify.py's webhook-call shape (#23). Calls
|
||||||
|
_send_now directly rather than going through the background-thread queue,
|
||||||
|
so assertions are synchronous -- the queue itself is just plumbing.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import owner_notify
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeResponse:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _booking():
|
||||||
|
return {
|
||||||
|
"booking_id": "BK-1",
|
||||||
|
"client_id": "C-1",
|
||||||
|
"customer_name": "Alice",
|
||||||
|
"customer_contact": "alice@example.com",
|
||||||
|
"service": "Haircut",
|
||||||
|
"start_time": datetime(2026, 8, 10, 9, 0, tzinfo=timezone.utc),
|
||||||
|
"end_time": datetime(2026, 8, 10, 10, 0, tzinfo=timezone.utc),
|
||||||
|
"source": "public",
|
||||||
|
"status": "confirmed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_now_skips_when_webhook_url_not_configured(monkeypatch):
|
||||||
|
monkeypatch.setattr(owner_notify, "NOTIFY_WEBHOOK_URL", "")
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(owner_notify.urllib.request, "urlopen",
|
||||||
|
lambda *a, **kw: calls.append((a, kw)) or _FakeResponse())
|
||||||
|
owner_notify._send_now({"booking": _booking(), "business_name": "Happy Nails",
|
||||||
|
"notify_channel": "telegram"})
|
||||||
|
assert calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_now_posts_json_when_configured(monkeypatch):
|
||||||
|
monkeypatch.setattr(owner_notify, "NOTIFY_WEBHOOK_URL", "https://n8n.example.com/webhook/owner-notify")
|
||||||
|
captured = []
|
||||||
|
|
||||||
|
def fake_urlopen(req, timeout=None):
|
||||||
|
captured.append(req)
|
||||||
|
return _FakeResponse()
|
||||||
|
|
||||||
|
monkeypatch.setattr(owner_notify.urllib.request, "urlopen", fake_urlopen)
|
||||||
|
payload = {"booking": owner_notify._serialize_booking(_booking()),
|
||||||
|
"business_name": "Happy Nails", "notify_channel": "telegram"}
|
||||||
|
owner_notify._send_now(payload)
|
||||||
|
assert len(captured) == 1
|
||||||
|
req = captured[0]
|
||||||
|
assert req.full_url == "https://n8n.example.com/webhook/owner-notify"
|
||||||
|
assert req.get_header("Content-type") == "application/json"
|
||||||
|
import json
|
||||||
|
body = json.loads(req.data)
|
||||||
|
assert body["booking"]["booking_id"] == "BK-1"
|
||||||
|
assert body["booking"]["start_time"] == "2026-08-10T09:00:00+00:00"
|
||||||
|
assert body["business_name"] == "Happy Nails"
|
||||||
|
assert body["notify_channel"] == "telegram"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_enqueues_regardless_of_channel(monkeypatch):
|
||||||
|
"""Channel routing (only "telegram" sends anything real) lives on the
|
||||||
|
n8n side of this webhook, not in notify() -- an unset/other channel is
|
||||||
|
still queued, not silently dropped here (#23)."""
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(owner_notify._queue, "put", lambda p: captured.append(p))
|
||||||
|
client = {"business_name": "Happy Nails", "notify_channel": "email"}
|
||||||
|
owner_notify.notify(client, _booking(), "created")
|
||||||
|
assert len(captured) == 1
|
||||||
|
assert captured[0]["notify_channel"] == "email"
|
||||||
|
assert captured[0]["business_name"] == "Happy Nails"
|
||||||
|
assert captured[0]["event"] == "created"
|
||||||
|
|
||||||
|
|
||||||
|
def test_notify_defaults_business_name_and_channel_when_client_missing(monkeypatch):
|
||||||
|
captured = []
|
||||||
|
monkeypatch.setattr(owner_notify._queue, "put", lambda p: captured.append(p))
|
||||||
|
owner_notify.notify(None, _booking(), "cancelled")
|
||||||
|
assert captured[0]["business_name"] == ""
|
||||||
|
assert captured[0]["notify_channel"] is None
|
||||||
|
assert captured[0]["event"] == "cancelled"
|
||||||
@@ -32,6 +32,7 @@ services:
|
|||||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||||
MAIL_FALLBACK_FROM: ${MAIL_FALLBACK_FROM}
|
MAIL_FALLBACK_FROM: ${MAIL_FALLBACK_FROM}
|
||||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL}
|
||||||
|
OWNER_NOTIFY_WEBHOOK_URL: ${OWNER_NOTIFY_WEBHOOK_URL}
|
||||||
depends_on:
|
depends_on:
|
||||||
smb-db:
|
smb-db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+65
-3
@@ -68,7 +68,7 @@
|
|||||||
"resource": "message",
|
"resource": "message",
|
||||||
"operation": "sendMessage",
|
"operation": "sendMessage",
|
||||||
"chatId": "5499280257",
|
"chatId": "5499280257",
|
||||||
"text": "=📅 Neue Buchung\nBetrieb: {{ $('Build booking row').item.json.business_name || $('Build booking row').item.json.booking.client_id }}\nKunde: {{ $('Build booking row').item.json.booking.customer_name || '—' }}\nLeistung: {{ $('Build booking row').item.json.booking.service || '—' }}\nWann: {{ $('Build booking row').item.json.booking.start_time || '—' }}\nKontakt: {{ $('Build booking row').item.json.booking.customer_contact || '—' }}\nQuelle: {{ $('Build booking row').item.json.booking.source }}",
|
"text": "=📅 {{ $json.body.event === 'cancelled' ? 'Buchung storniert' : $json.body.event === 'rescheduled' ? 'Buchung verschoben' : 'Neue Buchung' }}\nBetrieb: {{ $json.body.business_name || $json.body.booking.client_id }}\nKunde: {{ $json.body.booking.customer_name || '—' }}\nLeistung: {{ $json.body.booking.service || '—' }}\nWann: {{ $json.body.booking.start_time || '—' }}\nKontakt: {{ $json.body.booking.customer_contact || '—' }}\nQuelle: {{ $json.body.booking.source }}",
|
||||||
"additionalFields": {
|
"additionalFields": {
|
||||||
"appendAttribution": false
|
"appendAttribution": false
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"typeVersion": 1.2,
|
"typeVersion": 1.2,
|
||||||
"position": [
|
"position": [
|
||||||
900,
|
900,
|
||||||
300
|
500
|
||||||
],
|
],
|
||||||
"credentials": {
|
"credentials": {
|
||||||
"telegramApi": {
|
"telegramApi": {
|
||||||
@@ -87,6 +87,57 @@
|
|||||||
"name": "SMB Telegram (leads bot)"
|
"name": "SMB Telegram (leads bot)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"httpMethod": "POST",
|
||||||
|
"path": "owner-notify",
|
||||||
|
"responseMode": "onReceived",
|
||||||
|
"options": {
|
||||||
|
"allowedOrigins": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id": "b0000000-1111-2222-3333-888888888888",
|
||||||
|
"name": "Owner notify webhook",
|
||||||
|
"type": "n8n-nodes-base.webhook",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
240,
|
||||||
|
500
|
||||||
|
],
|
||||||
|
"webhookId": "smb-owner-notify-webhook-0001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"parameters": {
|
||||||
|
"conditions": {
|
||||||
|
"options": {
|
||||||
|
"caseSensitive": true,
|
||||||
|
"leftValue": "",
|
||||||
|
"typeValidation": "strict"
|
||||||
|
},
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"id": "b0000000-1111-2222-3333-aaaaaaaaaaaa",
|
||||||
|
"leftValue": "={{ $json.body.notify_channel }}",
|
||||||
|
"rightValue": "telegram",
|
||||||
|
"operator": {
|
||||||
|
"type": "string",
|
||||||
|
"operation": "equals"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"combinator": "and"
|
||||||
|
},
|
||||||
|
"options": {}
|
||||||
|
},
|
||||||
|
"id": "b0000000-1111-2222-3333-999999999999",
|
||||||
|
"name": "Telegram channel?",
|
||||||
|
"type": "n8n-nodes-base.if",
|
||||||
|
"typeVersion": 2,
|
||||||
|
"position": [
|
||||||
|
460,
|
||||||
|
500
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"connections": {
|
"connections": {
|
||||||
@@ -112,7 +163,18 @@
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Save to CRM (bookings)": {
|
"Owner notify webhook": {
|
||||||
|
"main": [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"node": "Telegram channel?",
|
||||||
|
"type": "main",
|
||||||
|
"index": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Telegram channel?": {
|
||||||
"main": [
|
"main": [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user