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
## 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 inconst 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
company dashboard
Next.js · projects, money
- board, budget, invoices
- reports and exports
employee view
only what they're on
- my tasks, my sub-projects
- comments and attachments
operator console
companies · seats
- invite by signed link
- toggle company permissions
role gate
per-route, DB-backed
- role re-read from the row
- employees scoped by assignment
Express API
67 endpoints
- projects, invoices, employees
- activity written from the diff
realtime
Socket.IO
- board reorder, tasks, comments
- own actions never echo back
delivery queue
the 24-hour window
- undelivered → held, TTL 24h
- a reply flushes the backlog
scheduled work
hourly · 06:00 · 20:00
- incremental backup to S3
- task digest, then window nudge
MongoDB
projects · invoices · people
- three ordered sub-project buckets
- budget spent derived, not stored
media
AWS S3
- drawings, permits, proof of payment
Twilio
- status re-fetched, not assumed
- inbound webhook reopens the window
SendGrid
- the other half of each preference
PDF render
headless Chrome
- invoices and period reports
## 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
// want something like this built for you?
start-a-project