DinnerTwine: Building a Multi-App Social Dining Platform with Real-Time Features and Commission Payouts

April 5, 2026 (4mo ago)

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:

  1. Main Website (React 19) -- the consumer-facing app where users join clubs, attend events, and chat
  2. Ambassador Hub (Next.js 15) -- a dedicated portal for referral partners to track earnings and payouts
  3. 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

  1. 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.
  2. Any system that touches real money needs to think about race conditions, duplicate prevention, and fraud from day one. Adding those later is painful.
  3. 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.
  4. 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.
  5. 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.
  6. Segmenting email senders across 6 addresses was a small decision that paid off hugely in deliverability.