Building PowerSell: How I Shipped a Gamified Real Estate Platform in 35 Days
Ahad Nawaz3 min read
Architecture, trade-offs, and the WebSocket layer that powers PowerSell's real-time leaderboards. Four portals, edge state sync, and the decisions that made a 35-day MVP possible.
PowerSell is a gamified real estate sales platform built for a US client. Closers compete on leaderboards, setters book demos, admins watch the funnel. We shipped the MVP in 35 days. Here is how the architecture made that timeline survive contact with reality.
The Constraint That Drove Every Decision
Four user roles, each with their own UI. Real-time leaderboard updates. Third-party integrations for calendar, calls, and CRM. And 35 days. The constraint forced a specific shape: one Next.js app with four route groups, one Postgres database with row-level scoping, and one WebSocket channel for everything that needed to update live.
App Shape: Route Groups, Not Microservices
I split the app by audience using Next.js route groups:
app/
(closer)/ // sales rep portal
(setter)/ // appointment setting portal
(admin)/ // ops dashboard
(public)/ // landing, sign-in, marketing
api/
Each group has its own layout, middleware, and auth gate. The bundle still ships as one app, but each role only loads its own routes. No microservices, no orchestration, no DevOps tax.
Database: Single Postgres, Tenant Scoping at the Query Layer
Every query carries an organization_id. The auth middleware injects it into a request-scoped Context, and every Prisma call uses it as a filter. No leaked rows across tenants.
// middleware sets context
const ctx = { userId, organizationId, role };
// every repo function takes ctx
async function listLeads(ctx: Ctx) {
return prisma.lead.findMany({
where: { organizationId: ctx.organizationId },
});
}
The trade-off: I trade Postgres RLS for application-layer enforcement. Faster to ship, easier to debug, but you must never query without going through the repo functions. A code-review rule and a lint check caught the one time I tried.
The WebSocket Layer: One Channel, Topic Routing
The leaderboard updates the second a closer logs a sale. The setter dashboard pings when an appointment is booked. Both ride on the same Socket.IO server with topic-based rooms:
// server
io.on("connection", (socket) => {
const { organizationId, role } = socket.handshake.auth;
socket.join(`org:${organizationId}`);
socket.join(`org:${organizationId}:${role}`);
});
// publishing a sale
io.to(`org:${orgId}`).emit("sale.created", payload);
The frontend subscribes per-portal:
useEffect(() => {
socket.on("sale.created", (sale) => {
setLeaderboard((rows) => updateRank(rows, sale));
});
return () => socket.off("sale.created");
}, []);
One channel, room-scoped, role-gated. No fancy event bus. No Kafka.
Webhooks: Idempotent, Logged, Replayable
Third-party integrations send webhooks on every event. Three rules I never break:
- Verify the signature first. Every payload includes an HMAC. Reject unsigned traffic at the edge.
- Hash the payload and dedupe. Webhooks retry. A SHA-256 of the body indexed in a
webhook_eventstable catches replays before they corrupt state. - Store the raw payload. When something goes wrong at 3am, you replay against the latest schema and find the bug.
What I Got Wrong
I underestimated how chatty the leaderboard update would get with 50+ active closers per org. The first version recomputed the full ranking server-side on every sale and broadcast the whole array. With 50 connected sockets and a sale every 30 seconds, that is 100 broadcasts per minute of a 50-row payload.
The fix was a two-line change: emit only the delta (the closer who moved + their new rank), and let the client patch its local list. Memory and bandwidth both flat-lined.
What the Client Got
A production-ready MVP in 35 days. Four portals, real-time leaderboards, a public landing, calendar and CRM integrations, role-based access. Followed by feature sprints adding referral tracking, lead routing, and analytics.
The architecture above is boring on purpose. Boring scales, ships, and survives the 3am page. Pick boring early and your timelines stop slipping.
Comments
Sign in to leave a comment.