PLATFORM

Notifications

Notifications

How a customer hears about their own order. The console has its own live feed over Socket.IO; this is the other audience, and the two never share a path.

Order event
  ├── Socket.IO ──▶ the shop's console
  └── Channels ──▶ the customer

Telegram is the first channel. Email, SMS, web push and WhatsApp are the same shape, and adding one does not touch the order code.


The architecture

One event, published once, delivered by whoever is listening.

Checkout / console
        │
        ▼
NotificationsService.publish(OrderEvent)
        │
        ├── TelegramNotificationService   ← today
        ├── EmailNotificationService      ← later
        └── SmsNotificationService        ← later

OrderEvent (order-event.ts) is the whole contract: the store's slug and name, the customer's id, name, email and phone, and the order's number, status, step, total and item count. It is deliberately not an entity — a channel should not know how an order is stored, and the order code should not know anybody is listening.

NotificationChannel is three members: a key, enabledFor(event) and send(event). A channel must never throw; the service logs and carries on, so one broken channel cannot take a checkout down with it.

Nothing is awaited. publish returns immediately. An order that saved and then failed to notify is an order; an order that failed to save because a chat app timed out is a lost sale.

To add a channel: write the class, add it to the array in NotificationsModule. That is the whole change.


Telegram

One bot for the whole platform

A merchant never creates a bot, never holds a token and never configures a webhook. My Store runs a single bot, and a shop turns order updates on under Settings → Notifications → Telegram.

              My Store Telegram bot
                       │
        ┌──────────────┼──────────────┐
      Store A        Store B        Store C
     customers      customers      customers

How a customer connects

Customer opens their order
        ↓
"Track on Telegram"                 (only when the shop has it on)
        ↓
API mints a one-time token          (POST /orders/:number/telegram-link)
        ↓
https://t.me/<bot>?start=<token>
        ↓
Customer presses Start
        ↓
Telegram webhook                    (POST /telegram/webhook)
        ↓
Token verified, store + customer + order resolved
        ↓
Chat id saved, first message sent

The token is 32 random bytes. Only its hash is stored, so a copy of the table cannot be used to follow anybody's orders. It is single use and expires after 24 hours.

What gets sent

Stage in the console What the customer reads
Placed Order confirmed
Paid Payment received
Packed Preparing your order
Shipped Out for delivery
Delivered Delivered
Cancelled Order cancelled, with the shop's reason

What a customer can say back

/status — where their latest order is, per shop they follow. /stop — stop the messages. The connection is muted rather than deleted, so pressing Track on Telegram again is what turns it back on.


Isolation

The rule is that nothing is trusted from the client or from Telegram. Not the store id, not the customer id, not the order id.

Telegram chat
    → connection      (store_id + telegram_user_id, in the control database)
    → customer        (looked up in THAT store's own database)
    → order           (must belong to that customer, in that store)

Every hop is checked against the store's own database rather than against anything the caller said:

  • Issuing a link reads the order from the shop's database and requires it to belong to the customer in the session. Another customer's order number is a 404 — not a hint that it exists elsewhere.
  • Redeeming a token re-checks all of it. The token names a store, a customer and an order, but naming is not proof: the customer is loaded from that store's database, and the order must still be theirs.
  • Sending looks chats up by (store_id, customer_id). A chat belonging to another shop is not reachable from that query even with the same Telegram account.

One person, several shops. Connections are unique on (store_id, telegram_user_id), so the same Telegram account can follow orders from any number of shops. Each is its own row and says nothing about the others: a status change at one shop messages that shop's chat only.

Why the control database

A Telegram update carries a chat id and nothing else — no subdomain, no path, no header. Every other request to this API names its store, which is what lets each shop live in its own ms_<slug> database. A chat cannot, so the lookup from chat to store has to happen somewhere that knows every store. That is the only reason these two tables carry a store_id at all.

telegram_connections     store_id · customer_id · telegram_user_id
                         telegram_chat_id · display_name · muted_at

telegram_link_tokens     store_id · customer_id · order_id · order_number
                         token_hash · expires_at · used_at

Setting it up

Three variables, on the API only:

TELEGRAM_BOT_TOKEN=          # from @BotFather
TELEGRAM_BOT_USERNAME=       # the bot's @name, without the @
TELEGRAM_WEBHOOK_SECRET=     # openssl rand -hex 32

Leave them blank and the feature reports itself as off: no button on an order, no messages, nothing for a merchant to see.

Then point the bot at the deployment, once, from the platform console:

POST /platform/admin/telegram/webhook
{ "url": "https://api.my-store.shop/telegram/webhook" }

GET /platform/admin/telegram says whether the bot is configured and whether Telegram can be reached.

A bot has exactly one webhook. That is why registering it is a decision somebody makes rather than something a deploy does — a staging machine that registered itself on boot would quietly take every shop's updates away from production.

Every update is checked against the secret, which Telegram echoes in the X-Telegram-Bot-Api-Secret-Token header. An update without it is refused with a 403 before anything is read.

Notifications — Docs