What is DinnerTwine
DinnerTwine is a social dining platform where people create clubs, host dinner events, and invite friends. Think of it as a mashup between Meetup and a loyalty program, with real-time chat, referral commissions, and gamified rewards baked in.
I built the entire backend, all frontend functionality across three apps, and handled the DevOps. The only thing I didn't do was the design work.
Here's the scale of the project:
| What | Numbers |
|---|---|
| MongoDB Models | 47 |
| Controllers | 19 |
| Route Modules | 20 |
| Frontend Apps | 3 (Main Website, Ambassador Hub, Admin Panel) |
| Third-Party Services | 8+ (Stripe, PayPal, AWS SES, Firebase FCM, etc.) |
| Email Types | 20+ across 6 segmented sender addresses |
| Automated Badges | 60+ across 11 categories |
Why Three Separate Frontends
This was a deliberate architectural decision, not over-engineering.
DinnerTwine has three completely different user groups with different needs. Regular users browse events, chat with club members, and manage their profiles. Ambassadors track referrals, monitor commissions, and request payouts. Admins manage everything: users, subscriptions, commissions, badges, and platform configuration.
Cramming all of that into one app would have been a mess. Different auth flows, different layouts, different state management needs. So I split it:
- Main Website (React 19) -- the consumer-facing app where users join clubs, attend events, and chat
- Ambassador Hub (Next.js 15) -- a dedicated portal for referral partners to track earnings and payouts
- Admin Panel (React 19) -- full platform management with analytics, user controls, and configuration
All three hit the same Express.js backend. The API doesn't care which frontend is calling it. JWT tokens carry the user's role, and middleware gates access accordingly. One backend, three frontends, zero code duplication on the server side.
The Ambassador Hub uses Next.js 15 specifically because it needed SSR for SEO on public referral landing pages. The other two apps are pure React 19 SPAs because they're behind authentication walls where SSR doesn't add value.
The Commission Engine
This is the most complex piece of the system and honestly the one I'm proudest of. DinnerTwine has a multi-level referral program with two types of ambassadors, tiered commission rates that change by year, and a configurable hold period before earnings become available.
How the Tiers Work
There are two ambassador types with very different payout structures:
| Ambassador Type | Level 1 | Level 2 | Level 3 |
|---|---|---|---|
| Sales (Year 1+) | $15 flat | -- | -- |
| Standard (Yr 1) | $4.00 | $2.00 | $0.50 |
| Standard (Yr 2) | $3.00 | $1.50 | $0.25 |
Sales Ambassadors get a flat $15 per direct referral. Simple. Standard Ambassadors earn on a chain up to two levels deep, meaning if Alice refers Bob, and Bob refers Carol, and Carol refers Dave -- Alice earns on all three. The rates step down by year so the platform doesn't bleed money as it scales.
The Hold System
When a commission is earned, it doesn't hit the ambassador's available balance right away. There's a configurable hold period (0 to 5 days) that the admin can set. Every time a new commission comes in for an ambassador, the hold timer resets on their pending balance. This prevents a specific gaming scenario where someone could generate a burst of fraudulent referrals and cash out before anyone notices.
Fraud Prevention
This was the part that kept me up at night. When real money is involved, you need to think adversarially. Here's what I built:
Circular referral prevention. Before creating any referral chain, the system walks the entire chain to make sure nobody is referring themselves through intermediaries. If Alice refers Bob and Bob tries to refer Alice, the system catches it.
Self-referral prevention. Straightforward but necessary. You can't refer yourself.
Duplicate commission indexes. MongoDB unique compound indexes on the commission collection prevent the same commission from being created twice, even under race conditions.
Race condition handling. Commission calculations and balance updates use MongoDB transactions. If two referral events fire at the same time for the same ambassador, one will succeed and the other will retry or fail gracefully instead of double-crediting.
const session = await mongoose.startSession();
session.startTransaction();
try {
const commission = await Commission.create([{
ambassador: ambassadorId,
referral: referralId,
amount: calculatedAmount,
level: referralLevel,
status: 'held',
holdExpiresAt: calculateHoldExpiry(holdDays),
}], { session });
await Ambassador.findByIdAndUpdate(
ambassadorId,
{ $inc: { pendingBalance: calculatedAmount } },
{ session }
);
await session.commitTransaction();
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
The transaction wraps both the commission creation and the balance update. If either fails, neither happens.
Payment Processing
DinnerTwine handles money in three different directions: users pay for subscriptions (Stripe), ambassadors get paid commissions (PayPal), and ambassadors can convert commission earnings into subscription credits.
Stripe Subscriptions
Users pick from subscription plans that unlock different features. Stripe handles the billing cycle, and webhooks update the user's access level in real time. Plan changes, cancellations, and failed payments all flow through webhook handlers that update the user's subscription status and feature gates.
PayPal Payouts
When an ambassador's held commissions clear the hold period, they can request a payout. The system sends money through PayPal's Payouts API. I chose PayPal over Stripe payouts here because the ambassador user base skews toward people who already have PayPal accounts.
Commission-to-Credit Conversion
This was a fun feature. Ambassadors can take their earned commissions and apply them as credit toward their own DinnerTwine subscription instead of cashing out. The conversion uses a MongoDB transaction to atomically debit the ambassador's available balance and credit the user's subscription account. No money leaves the platform, so there are no PayPal fees.
Real-Time Chat with Socket.io
Every dining club has a group chat. Members can message each other in real time, see who's online, and get notified of new messages even when they're browsing other parts of the app.
How It Works
The Socket.io server runs alongside the Express API. When a user connects, the server validates their JWT before allowing the WebSocket handshake to complete. No valid token, no connection.
io.use((socket, next) => {
const token = socket.handshake.auth?.token;
if (!token) return next(new Error('Authentication required'));
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
socket.userId = decoded.userId;
socket.role = decoded.role;
next();
} catch (err) {
next(new Error('Invalid token'));
}
});
Once connected, users join rooms based on their club memberships. Messages are broadcast to the room, and the server tracks per-member unread counts. When a user opens a chat, their unread count for that club resets to zero. This sounds simple, but tracking unread counts per-member per-club means maintaining a matrix of read cursors that updates on every message and every chat open event.
Messages are persisted to MongoDB so users see full history when they rejoin. The unread count system uses a separate collection that maps userId + clubId to the last-read message timestamp. New messages with a timestamp after the cursor increment the unread count.
60+ Automated Badges
DinnerTwine has a full gamification system with over 60 badges spread across 11 categories. Things like "Hosted 10 events," "Joined 5 clubs," "Referred 3 friends," "Attended events in 3 different cities," and so on.
A daily cron job evaluates every active user against every badge criterion. If a user qualifies for a badge they don't already have, it's awarded automatically. The system also fires a Firebase Cloud Messaging push notification so the user knows they unlocked something.
I organized badges into 11 categories to keep the evaluation logic clean:
| Category | Examples |
|---|---|
| Hosting | First event hosted, 10 events, 50 events |
| Attending | First event attended, streak badges |
| Social | Referral milestones, club invites |
| Club Engagement | Active in multiple clubs, founding member |
| Loyalty | Account age milestones, subscription tenure |
| Explorer | Events in different cities/states |
Each badge has a criteriaType and threshold stored in the database, so admins can create new badges from the admin panel without touching code. The cron job reads the criteria dynamically and evaluates them against aggregated user stats.
Feature-Gated Subscription Plans
Not every user gets access to every feature. DinnerTwine uses subscription plans to gate functionality, and this runs deeper than just a boolean check.
Each plan has a features object that defines what's unlocked: number of clubs you can join, whether you can host events, chat access, badge visibility, and more. When a user makes a request that touches a gated feature, middleware checks their active plan's feature map before the controller logic runs.
The admin panel has a plan configuration screen where you can toggle features per plan. Change the config, and every user on that plan immediately sees the updated access. No deploys, no code changes.
Event Cards with Fabric.js and Puppeteer
When someone creates a dinner event, DinnerTwine generates a shareable card image for it automatically. This was a fun one to build.
The flow works like this: event data (title, date, location, host name) feeds into a Fabric.js canvas template on the server. Fabric.js handles the layout, text rendering, and image composition. Then Puppeteer takes a screenshot of the rendered canvas to produce a PNG. The image gets uploaded to storage and attached to the event.
This means every event gets a unique, branded card that users can share on social media or in messages. The admin panel lets you customize the card templates, so the design team can update the look without needing a developer.
Authentication Across Three Apps
Running three frontends with different frameworks against one backend meant I needed a flexible auth system.
Multi-Role JWT
The backend issues JWTs that carry the user's role (user, ambassador, admin). Middleware reads the role from the token and gates routes accordingly. An ambassador token can't hit admin endpoints, and a user token can't access ambassador dashboards. Simple role-based access control, but it has to work consistently across all three apps.
NextAuth SSR Proxy in the Ambassador Hub
The Ambassador Hub runs on Next.js 15 with server-side rendering. NextAuth.js handles the auth flow on the server side, and it proxies authentication to the Express backend. The SSR pages can check auth state before rendering, so there's no flash of unauthenticated content. The NextAuth session wraps the JWT from the backend, giving the Next.js server access to the user's role and permissions during SSR.
Encrypted Client-Side Storage
On the two React SPA frontends, tokens are stored in localStorage but encrypted with CryptoJS. It's not a replacement for HttpOnly cookies (which aren't practical with a separate API domain), but it means a casual XSS payload can't just read localStorage.getItem('token') and get a usable JWT. The encryption key rotates with the user's session.
Email System
DinnerTwine sends over 20 types of emails: welcome emails, event reminders, commission notifications, badge awards, payout confirmations, club invitations, and more.
I set up 6 segmented sender addresses through AWS SES. Transactional emails (password resets, payment receipts) come from one address. Marketing emails come from another. Commission notifications have their own sender. This segmentation keeps deliverability high because a spam complaint on a marketing email doesn't tank the reputation of the transactional sender.
Tech Stack
| Layer | What's Used |
|---|---|
| Main Website | React 19, Zustand, Tailwind CSS |
| Ambassador Hub | Next.js 15, NextAuth.js, Zustand |
| Admin Panel | React 19, Zustand, Tailwind CSS |
| Backend | Node.js, Express.js, Mongoose, Socket.io, Joi |
| Database | MongoDB (47 models) |
| Payments | Stripe (subscriptions), PayPal (payouts) |
| Notifications | AWS SES (email), Firebase FCM (push) |
| Real-Time | Socket.io (JWT-validated WebSocket connections) |
| Image Gen | Fabric.js (canvas), Puppeteer (screenshot) |
| Scheduling | node-cron (badge evaluation, commission hold release) |
| DevOps | Docker, Nginx, CI/CD |
What I Took Away
- Building three frontends against one backend is surprisingly clean if you design the API to be role-aware from the start. The backend never needs to know which app is calling it.
- Any system that touches real money needs to think about race conditions, duplicate prevention, and fraud from day one. Adding those later is painful.
- Multi-level commission math sounds simple on a whiteboard. In practice, walking referral chains, handling edge cases, and keeping balances consistent under concurrency is a real engineering challenge.
- Socket.io with JWT validation and per-member unread tracking was more state management work than I expected, but it made the chat experience feel polished.
- The gamification engine taught me the value of making systems data-driven. Sixty badges defined in a database, evaluated by one generic cron job, is much better than sixty hardcoded badge checks.
- Segmenting email senders across 6 addresses was a small decision that paid off hugely in deliverability.