cd ../projects

Project 1 // _constrack

Constrack

The hard problem wasn't tracking the build. It was that you can't message a man on a roof unless he messaged you first.

client

private client — confirm before publishing · live at constrack.app

timeline

12 months · Aug 2023 → Aug 2024 · live

role

full-stack developer

scope

role model & scopingproject / sub-project trackinginvoicing & paymentsnotification deliveryreporting & exports

## the-problem

Construction software is written for the office and used on a site. The person who needs today's task list is on scaffolding with gloves on; they will not open a dashboard, and a push notification from an app they installed once is a notification they never see. The one thing they do read is WhatsApp.

Which is where the actual engineering problem lives, and it isn't a construction problem at all. The WhatsApp Business API won't let you send a free-form message to someone who hasn't messaged you in the last 24 hours. So the whole design — send the day's tasks at 6am — is illegal by default, and worse, it fails quietly: Twilio accepts the request, returns a message id, and the message is dropped downstream. Fire and forget means forget.

Under that sits the usual permissions question with an unusual shape. A company's employees can't be given the company's view, and they can't be handed a role flag either — an employee's access is a consequence of who's been assigned what, and it changes every time a task is reassigned.

## the-approach

  • stopped trusting the send: every WhatsApp message is re-fetched from Twilio ten seconds later, and only its *delivered* status counts as sent
  • gave failure somewhere to go — an undelivered message is pushed onto the employee's pending queue, TTL-indexed so it expires with the same 24 hours it's waiting on
  • made the reply the trigger: an inbound webhook matches the sender's number, flushes every queued message at once and stamps the window open for another day
  • and nudged the window open before it was needed — a 20:00 prompt asking for one tap, so the 06:00 task digest lands inside a live session rather than against a closed one
  • the digest itself is one aggregation, not a loop: unwind tasks, match today's due dates, group by assignee — one message per person, however many tasks they have
  • scoped employees by assignment rather than by flag — their project list is derived from the sub-projects they're on, and a direct hit on a project id is re-checked the same way before anything renders
  • derived instead of stored where I could: budget spent is the sum of payments on that project's invoices, and project status is a virtual over the three sub-project buckets — so nothing can drift out of sync with the invoices
  • wrote the audit trail from the diff: on save, the modified paths are compared against the pre-save document, so the log says what changed from what to what instead of "project updated"
  • a delivery failure is also a visible fact — it lands in the project activity feed as "failed to send to X", so the admin knows a person wasn't told

// the 24-hour window — accept the failure, then wait for the door to open

// Twilio returns a message id, not a delivery. The window may
// have closed hours ago and nothing here would know it, so the
// send is verified after the fact rather than assumed.
client.messages.create({ from: WA_FROM, to, body, mediaUrl })
.then((res) => setTimeout(() => {
client.messages(res.sid).fetch().then((message) => {
if (message.status === "failed" || message.status === "undelivered")
onFailed?.({ to, body, mediaUrl }); // queue it, don't drop it
});
}, 10_000));
// …the caller's onFailed parks it on the employee. The TTL is the
// same 24 hours the queue is waiting on: if the window never
// reopens, the backlog expires instead of arriving stale.
pendingNotifications: [{
to: String, body: String, mediaUrl: [String],
expiresAt: { type: Date, default: Date.now, expires: 86400 },
}]
// One reply — any reply — reopens the window. The webhook flushes
// everything that was held and marks the session live for a day.
export const whatsappReply = async (req, res) => {
const from = req.body.From.replace("whatsapp:", "");
// stored numbers vary by how much country code was typed in
const employee = await Employee.findOne({
phone: { $in: [from, from.slice(1), from.slice(2), from.slice(3)] },
});
if (!employee) return;
employee.notifications.pendingNotifications?.forEach(SendWAMesage);
employee.notifications.waAllowedTill = moment().add(1, "day").toDate();
employee.notifications.pendingNotifications = [];
await employee.save();
};

## the-architecture

// architecture — one project record, and a message that waits its turn

client

company dashboard

Next.js · projects, money

  • board, budget, invoices
  • reports and exports
client

employee view

only what they're on

  • my tasks, my sub-projects
  • comments and attachments
client

operator console

companies · seats

  • invite by signed link
  • toggle company permissions
backend

role gate

per-route, DB-backed

  • role re-read from the row
  • employees scoped by assignment
backend

Express API

67 endpoints

  • projects, invoices, employees
  • activity written from the diff
backend

realtime

Socket.IO

  • board reorder, tasks, comments
  • own actions never echo back
backend

delivery queue

the 24-hour window

  • undelivered → held, TTL 24h
  • a reply flushes the backlog
backend

scheduled work

hourly · 06:00 · 20:00

  • incremental backup to S3
  • task digest, then window nudge
data

MongoDB

projects · invoices · people

  • three ordered sub-project buckets
  • budget spent derived, not stored
data

media

AWS S3

  • drawings, permits, proof of payment
external

WhatsApp

Twilio

  • status re-fetched, not assumed
  • inbound webhook reopens the window
external

email

SendGrid

  • the other half of each preference
external

PDF render

headless Chrome

  • invoices and period reports
clientbackenddataexternal

## the-results

24h

delivery window the whole queue is built around

3

roles, one API, one project record

67

endpoints behind a single role gate

  • the day's tasks reach people who never open the app, and a message that couldn't be delivered is held rather than lost
  • an employee's access follows their assignments — nobody maintains a second list of who can see what
  • one record answers both questions a contractor asks: how far along is this, and how much of the budget has actually gone out
  • shipped and live at constrack.app
  • what I'd change: the pending queue is per-employee state mutated from a callback, which is fine on one process and a race on two — it belongs in a small outbox collection with a claim, and the 10-second status check belongs on Twilio's status webhook rather than a timer
nextreactexpressmongodbrealtimetypescriptawsscrapingdataviz

// want something like this built for you?

start-a-project