Online pharmacy · Client project

From a manual order desk to an automated pharmacy network

Dear Kyra offers online pharmacist consultations and home delivery. Orders came in through their online store, then a person had to place each one with a pharmacy by hand.

Role
Solo developer
Client
Dear Kyra
Before
About 20 orders a day, by hand
After
100+ a day, automatic
Illustration of Dear Kyra with demo data
5x+order capacity in the first month
100+orders a day at peak
~20orders a day before, by hand
The problem

Growth was capped by how fast staff could type

I first sat down with the team to map the whole order journey, from checkout to the parcel arriving.

  • Every order placed by handStaff copied each store order into a pharmacy order, one at a time.
  • A hard ceilingAbout 20 orders a day was the most the team could handle.
  • Which pharmacy?Picking the right pharmacy for each customer was guesswork.
  • Customers in the darkTracking and updates were manual too.
What I did

From first conversation to running system

  1. Mapped the business flow

    Who does what, where time is lost, and which steps a computer can safely take over.

  2. Automated order intake

    New store orders are picked up automatically, no copying.

  3. Built pharmacy routing

    Nearby pharmacies are notified, and the best one is chosen when several accept.

  4. Automated tracking and messages

    Delivery status is tracked and customers are updated without anyone checking.

See it work

How each order finds its pharmacy

SimulationNearest and cheapest pharmacy wins
Under the hood

How it works

For technical readers: the moving parts, and the mechanism that makes it reliable.

Partner first, first to accept wins

A new order goes to partner pharmacies near the customer first, and wider only if none can take it. The first to accept gets the order, and the others are told at once.

Status as one source of truth

Each order moves through clear states (new, matched, out for delivery, delivered), so tracking and messages are driven by the same record.

Nobody in the loop by default

Staff only step in on exceptions, instead of touching every order.

Engineering deep dive

How one person runs 100+ orders a day

For engineers: the order lifecycle, how a pharmacy is found, and how the system stays safe when messages and deliveries fail.

Stack

API
Node.jsExpressJoi
Jobs
BullMQRedisnode-cron
Data
MongoDBMongoosegeo index
Integrations
WhatsApp APILalamoveGoogle Maps
Admin
ReactTypeScriptViteTailwind
Quality
JestDockerNginx

Four queues, one job each

  • orderQueueTakes new orders from the online store, checks the address with Google Maps and starts the flow. Retried safely.
  • pharmacyQueueFinds nearby pharmacies and sends each one a WhatsApp order request.
  • chatbotQueueHandles WhatsApp replies from customers and pharmacies, such as confirm, accept or decline.
  • deliveryQueueBooks the courier and processes Lalamove status webhooks.

One order, from checkout to doorstep

Every step is driven by the order's status, so the next action is always clear.

  1. StoreA new order arrives by webhook and is queued. The store order ID makes a repeat harmless.
  2. CustomerThe customer confirms the order on WhatsApp. No reply in time triggers a timeout path.
  3. MatchingNearby partner pharmacies are found with a MongoDB geo query and asked on WhatsApp.
  4. FallbackIf no partner can take it, the search widens to any pharmacy. Those are paid upfront instead of on credit.
  5. AcceptThe first pharmacy to accept locks the order. The others are told it is no longer available.
  6. DeliveryA Lalamove quote is fetched. Staff pick Lalamove or manual delivery, and the courier is booked.
  7. TrackingLalamove webhooks map to order states and send the customer WhatsApp updates.

Order states

Pending→Customer confirmed→Awaiting pharmacy→Pharmacy confirmed→Booking delivery→Out for delivery→Delivered/Manual handling

More than 20 states in total. Exceptions go to a manual queue instead of getting stuck.

Key decisions

WhatsApp as the pharmacy app

Problem
Pharmacies would not install a new app just for us, and phone calls did not scale.
Choice
Every request, reply and update goes through approved WhatsApp templates with buttons. A reply comes back as a webhook and moves the order forward.
Why
Zero onboarding for pharmacies, and every answer is recorded against the order.

Partner first, then widen

Problem
Partner pharmacies are cheaper and work on credit, but they cannot cover every area.
Choice
Ask nearby partners first. If none accepts, ask any nearby pharmacy, with an upfront payment step for non-partners.
Why
Most orders go to the cheaper partner, and no order is lost because an area has no partner.

Retry only what is safe to repeat

Problem
A short database or Google outage used to drop a new order forever.
Choice
New-order jobs retry 3 times with backoff, because they dedupe on the store order ID. WhatsApp and courier jobs do not retry, because a repeat would send twice or book twice.
Why
No lost orders, and no double messages or double bookings.

Accepting an order, simplified

pharmacy.orchestrator.jssimplified sketch
async handlePharmacyResponse(orderId, pharmacyId, isAccept) {
  const order = await OrderService.getOrderById(orderId);
  // only orders still waiting for a pharmacy can be taken
  if (!AWAITING_PHARMACY.includes(order.status)) throw new AppError('Order closed', 400);
  if (order.pharmacyId) throw new AppError('Taken by another pharmacy', 409);
  if (!isAccept) return recordDecline(order, pharmacyId);

  const pharmacy = await PharmacyService.getPharmacyById(pharmacyId);
  const quote = await fetchLalamoveQuotation(order, pharmacy); // never throws
  await OrderService.updateById(orderId, {
    pharmacyId,
    lalamoveQuotation: quote,
    // partners work on credit, others pay first
    status: pharmacy.creditAllowed ? AWAITING_DELIVERY_METHOD_CHOICE : NON_PARTNER_PHARMACY_CONFIRMED,
  });
  await notifyOtherPharmacies(orderId, '003_partner_store_order_expired');
}

Scale

100+orders a day, up from about 20
5x+capacity in the first month
4background queues
20+order states

Reliability and operations

  • Timeouts by cronScheduled jobs expire orders and handle customers who never confirm, with a guard so a job never runs twice at once.
  • Audit logEvery status change and error is written to an audit log tied to the order.
  • Tested coreJest with an in-memory MongoDB covers the order, pharmacy and delivery flows.
  • Strict layersRoute, validation, controller, orchestrator, service, model. Services never call each other directly.
Illustration for Dear Kyra
The hard part

The tricky part was finding the right pharmacy, fast.

Partner pharmacies are cheaper, but they do not cover every area. The system asks nearby partners first and widens only when needed, so the customer gets their medicine fast and the business keeps its costs down.

The result

What changed for the business

  • Order capacity grew 5x+ within the first month
  • From fully manual to fully automatic order handling
  • Customers get status updates without staff effort
  • The team spends time on customers, not data entry
Next project

EasyFound

Read the story →