RizTech Academy logo
RizTech Academy
Kirana Store: Orders and AdminLesson 5 of 525 min

Telling the customer what is happening

The shop knows something changed. The customer does not. Closing that gap is most of what "good service" means for a grocery order — and it is the feature most likely to be built badly, because sending a message feels trivial.

Where the call goes matters more than which vendor makes it

@Injectable()
export class NotificationsService {
  async orderStatusChanged(notification: OrderNotification): Promise<void> {
    const text = this.messageFor(notification);
    if (!text) return;

    try {
      this.logger.log(`SMS to ${this.maskPhone(notification.phone)}: ${text}`);
    } catch (error) {
      this.logger.error(`Could not notify about ${notification.orderNumber}`, error);
    }
  }
}

It logs. That is the honest state of it, and it is deliberate.

An SMS gateway in India is an account, a sender ID, a DLT registration with the telecom regulator, a template approved in advance, and a per-message cost. None of that teaches you anything about building applications. The shape around the call is what matters, and the shape is complete: swap the logger.log for a provider's SDK and nothing else in this codebase changes.

That is the test of a good seam. If adding the real thing means editing five files, the seam was in the wrong place.

Two rules, and both are about failure

1. Never inside the transaction

Module 12 said it and this is where it bites:

const placed = await this.prisma.$transaction(async (tx) => {
  // …claim stock, claim the slot, create the order, empty the cart…
  return order;
});

// After the commit, never inside it.
await this.notifications.orderStatusChanged({ … });

A transaction can be rolled back. It can be retried. An SMS cannot be unsent.

Put the send inside and a checkout that fails on its last step — a slot that filled up a moment ago — has already told the customer their order is confirmed. They now have a text message and no order, and no amount of apologising makes that look like anything other than incompetence.

The same applies to a payment capture, an email, a webhook to a courier, a push notification. Nothing inside a transaction may do anything the database cannot roll back.

2. Never let it fail the operation

try {
  // …send…
} catch (error) {
  this.logger.error(`Could not notify about ${notification.orderNumber}`, error);
}

The order is placed. The stock is taken. The money is committed. If the SMS gateway is down, that is a message problem, not an order problem.

An unhandled rejection here would surface to the customer as a failed checkout for an order that actually exists — so they would try again, and now the shop has two.

Swallowing an error is usually wrong. This is one of the few places it is right, and the log line is what makes it defensible rather than negligent.

Not every status is worth a message

private messageFor(n: OrderNotification): string | null {
  switch (n.status) {
    case "PLACED":
      return `Kirana Store: order ${n.orderNumber} confirmed. Delivery ${n.slotLabel ?? "soon"}…`;
    case "OUT_FOR_DELIVERY":
      return `Kirana Store: order ${n.orderNumber} is on its way.`;
    case "DELIVERED":
      return `Kirana Store: order ${n.orderNumber} delivered. Thank you.`;
    case "CANCELLED":
      return `Kirana Store: order ${n.orderNumber} has been cancelled.`;
    default:
      return null;
  }
}

Four of six. PACKED and PENDING_PAYMENT return null.

This is the whole design of the feature and it is a judgement call, not a technical one. A shop that texts four times about one order of dal gets muted — and then the one message that mattered is muted too.

PACKED is the shop's internal milestone. The customer does not care that a bag has been filled; they care when it leaves. PENDING_PAYMENT is not news either — they are looking at the payment screen.

Returning null and checking for it at the top is better than a switch with empty cases, because the intent is explicit: some statuses have no message, by design.

Do not put the phone number in the log

private maskPhone(phone: string): string {
  return phone.length <= 4 ? "****" : `******${phone.slice(-4)}`;
}

Logs are read by people, tailed in terminals over shoulders, and shipped to whatever aggregator you use. A full phone number in a log line is personal data sitting somewhere it was never meant to be, with a retention policy nobody chose.

The last four digits are enough to match a log entry to a support call, which is the only reason to have it there at all.

The same reasoning applies to email addresses, order contents and addresses. Log identifiers, not identities. orderNumber is in every line here, and from that anybody with database access can find the rest — with an audit trail.

One caller, not five

Every status change goes through OrdersService.setStatus, so the notification is sent from exactly one place:

await this.notifications.orderStatusChanged({
  orderNumber: updated.orderNumber,
  status: updated.status,
  customerName: updated.deliveryName,
  phone: updated.deliveryPhone,
  slotLabel: updated.slotLabel,
  slotDate: updated.slotDate ? utcIsoDate(updated.slotDate) : null,
});

The customer cancelling, the shopkeeper advancing an order, and module 14's payment webhook all arrive here. Nobody has to remember to send anything.

Note it reads the snapshotted delivery phone on the order, not the phone on the user account. The order was placed for delivery to that number; the customer changing their account phone next month must not redirect messages about an old order.

What this does not do, and what it would take

Worth being honest about, because the gap between this and production is where people get stuck.

Retries. A gateway timing out is common. Production wants a queue — the notification becomes a row, a worker picks it up, failures are retried with backoff and eventually land in a dead-letter table somebody looks at.

Idempotency. With retries, "did we already send this?" becomes a real question. The answer is a key per (order, status) and a unique index.

Preferences. Customers will ask to stop receiving some of these. That is a column, and it has to be honoured, and in several countries it is not optional.

Quiet hours. An SMS at 2am about an order placed at 2am is still an SMS at 2am.

All four are the same shape: the notification stops being a function call and becomes a record. That is a sensible thing to build when the shop has enough volume to need it, and premature before then — but you should know it is coming, because the queue is much easier to introduce while there is exactly one caller.

Check your work

Why the service only logs: the shape around the call is what generalises; a gateway is an account and a regulatory registration, and swapping it in changes one line.

Why notifications are sent after the commit: a transaction can roll back and an SMS cannot be unsent, so a failed checkout would have already confirmed an order that does not exist.

Why the send swallows its own errors: the order succeeded, and a message failure must not present itself as a failed checkout — which would produce a duplicate order.

Why only four statuses send: a shop that texts four times per order gets muted, and then the message that mattered is muted too.

Why PACKED sends nothing: it is the shop's milestone, not the customer's.

Why phone numbers are masked in logs: logs are read by people and shipped elsewhere; the last four digits are all that a support call needs.

Why there is one caller: every status change goes through setStatus, so nobody has to remember to notify.

Why the order's phone and not the account's: changing an account phone must not redirect messages about an old order.

What production adds: a queue with retries, an idempotency key per order and status, customer preferences, and quiet hours.

Practice

  1. Place an order and find the log line. Confirm the number is masked and the order number is not.
  2. Advance the order to PACKED. Confirm no message is logged, and find the line of code that decided that.
  3. Advance to OUT_FOR_DELIVERY and then DELIVERED. Confirm two messages.
  4. Make orderStatusChanged throw unconditionally. Place an order and confirm it still succeeds and still appears in the database.
  5. Move the notification call inside the checkout transaction, then force the slot claim to fail. Confirm the customer is told about an order that does not exist.
  6. Cancel an order and confirm the cancellation message is sent.
  7. Change the customer's account phone number after placing an order, then advance it. Confirm the message goes to the number on the order.
  8. Add a RETURNED status and confirm messageFor compiles without handling it. Decide whether that default is right, and what you would change if not.
  9. Write down what a notifications queue table would need: which columns, which unique index, and what a worker does with a row that has failed five times.
  10. Read your own log output as if you were a support agent with a customer on the phone. Note anything missing, and anything that should not be there.

Next: payments — taking money without trusting anything the browser says.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship