DealPlate
A location-first marketplace for restaurant deals
Two-sided marketplace where restaurant owners publish promotions and nearby diners discover them by distance. Flutter app, NestJS API across 23 modules, Postgres/Prisma schema, and a Next.js admin console.
23
Backend modules
20
Prisma models
30+
Flutter screens
10+
Migrations
Overview
DealPlate is a two-sided marketplace built around a single question: what's discounted near me, right now? Restaurant owners onboard their business, publish promotions with real expiry dates, and push them to diners in the surrounding area. Diners browse by proximity, save what they like, claim coupons, and redeem them in person via QR.
It runs as three clients against one API — a Flutter app that serves both the consumer and owner roles, a Next.js admin console for platform operations, and a NestJS backend over PostgreSQL. Authentication is Firebase across all of them, so identity is consistent from mobile to console.
Problem
Local restaurant promotions live in the worst possible places: paper flyers taped to a window, an Instagram story that expires in a day, a WhatsApp forward three groups deep. None of it is searchable, none of it is location-aware, and all of it goes stale silently.
That hurts both sides. An owner running a genuinely good Tuesday offer has no channel that reaches people who are actually nearby and actually hungry. A diner standing on a street has no way to find out that the place two doors down is doing buy-one-get-one right now.
The generic delivery apps don't solve this either — they optimise for ordering in, take a cut of every order, and bury promotions under their own merchandising. There was no lightweight place for a restaurant to say 'here is my offer, here is when it ends' and have nearby people see it.
Solution
DealPlate models the promotion itself as the primary object, not the order. Owners create a promotion with a category, an offer badge, a description and an expiry; it becomes immediately discoverable to diners within range, and it disappears on its own when it lapses.
Discovery is geographic first. Users' last known coordinates are stored on the user record and restaurants carry latitude/longitude with a composite index, so 'what's near me' is a query the database can answer efficiently rather than something filtered in application code.
Around that core sit the pieces that make it a working business rather than a listings page: coupons that are issued digitally and redeemed by QR scan in the restaurant, a loyalty event ledger, referral tracking, catering packages and requests, staff accounts with scoped roles, subscription plans that gate owner capabilities, and geo-targeted push campaigns over FCM.
For onboarding, an owner can photograph an existing paper flyer and have Claude's vision model read it into structured promotion fields — title, category, offer badge and description — against a constrained JSON schema. It turns the most tedious part of listing a deal into a single photo.
Features
Role-aware onboarding
One app, two products. On first sign-in a user picks consumer or owner; the root gate routes to a different shell for each, and owners are pushed through restaurant setup before they reach their dashboard.
Proximity discovery
Browse and search rank by distance from the device's GPS position, with a manual location picker for planning ahead. Restaurants are indexed on lat/long, zip and city.
Promotions with real expiry
Categorised, badge-able promotions that can be featured and that lapse on their own — scheduled jobs keep listings honest rather than relying on owners to clean up.
AI flyer scanning
Claude vision reads an uploaded flyer image and returns structured promotion fields against a fixed JSON schema, with categories kept in sync with the app's own category list.
Coupons and QR redemption
Diners claim coupons in-app and present a QR code; owner-side staff scan it from the redemption screen to mark it used, which closes the loop between a digital offer and an in-person visit.
Loyalty and referrals
A loyalty event ledger records earning activity, and app-generated referral codes tie a new user back to whoever invited them via a dedicated referral record.
Geo-targeted push campaigns
Owners compose push campaigns delivered through Firebase Cloud Messaging against registered device tokens, targeted using users' last known coordinates.
Staff, plans and subscriptions
Restaurants can add staff with scoped roles, and subscription plans gate which owner capabilities are available — the commercial layer of the product.
Catering pipeline
Restaurants publish catering packages; diners submit catering requests against them, giving owners a lead channel beyond walk-in offers.
Reviews, favourites and menus
Ratings roll up onto the restaurant record as a denormalised average and count, alongside per-user favourites and owner-managed menu items and photos.
Admin console
A separate Next.js dashboard for platform operations: restaurants, users, admins, promotions, reviews, subscriptions and an audit log of privileged actions. Owners can be suspended, which hides them from consumers without deleting data.
Tech Stack
Mobile
- Flutter
- Dart
- Provider
- google_fonts
- cached_network_image
Device
- geolocator
- geocoding
- flutter_map
- mobile_scanner
- qr_flutter
- image_picker
- file_picker
- share_plus
API
- NestJS
- TypeScript
- class-validator
- class-transformer
- @nestjs/schedule
Data
- PostgreSQL 18
- Prisma ORM
Identity & messaging
- Firebase Auth
- Firebase Admin
- Firebase Cloud Messaging
- Google Sign-In
Admin
- Next.js (App Router)
- React
- TypeScript
AI
- Anthropic Claude (vision, structured extraction)
Architecture
Three clients, one API, one database. The Flutter app carries both consumer and owner experiences behind a role gate; the Next.js console handles platform administration; both authenticate with Firebase and call the same NestJS service, which owns all business rules and is the only thing that touches Postgres.
Flutter app
Consumer and owner shells behind a root gate that reads the authenticated user's role. Provider for state, a thin api_service over HTTP, and dedicated services for location, push registration, auth and map launching.
FlutterProviderFirebase AuthFCMAdmin console
Next.js App Router dashboard grouped under a (dash) route group — restaurants, users, admins, promotions, reviews, subscriptions and audit — with a shared layout and its own login route.
Next.jsReactTypeScriptNestJS API
Feature modules per domain, a global ValidationPipe with whitelist and transform, CORS enabled for the app, and static hosting of uploaded flyer images at /uploads. A FirebaseAuthGuard verifies bearer ID tokens and a parameter decorator injects the resolved user into handlers.
NestJSFirebase Adminclass-validatorPostgreSQL + Prisma
A single relational schema of 20 models and 6 enums, evolved through incremental migrations. Geographic and lookup indexes live on the restaurant table; Prisma is the only data access path.
PostgreSQL 18PrismaClaude vision
An isolated AI module that reads a flyer image from the uploads directory and extracts promotion fields against a constrained JSON schema. It fails closed with a clear 503 when no API key is configured, so the rest of the product works without it.
Anthropic SDK
Folder Structure
- dealplatebackend/src/
- NestJS API — one directory per domain module
- ├── main.ts
- Bootstrap: CORS, global ValidationPipe, static /uploads
- ├── app.module.ts
- Wires every feature module together
- ├── prisma/
- Global PrismaModule + PrismaService
- ├── auth/
- FirebaseAuthGuard + @FirebaseUser() decorator
- ├── firebase/
- Firebase Admin initialisation
- ├── restaurants/ menu/ promotions/
- Core marketplace listing domains
- ├── coupons/ loyalty/ referral/
- Retention and redemption
- ├── catering/ staff/ subscriptions/ plans/
- Owner-side commercial features
- ├── campaigns/ devices/ notifications/
- Push targeting and delivery
- ├── reviews/ favorites/ users/
- Consumer-side social and identity
- ├── ai/
- Claude flyer extraction
- └── admin/ uploads/
- Platform administration and file intake
- dealplatebackend/prisma/
- schema.prisma, incremental migrations, seed
- dealplateflutter/lib/
- Flutter client
- ├── screens/auth/
- Sign-in and role choice
- ├── screens/consumer/
- Browse, search, coupons, loyalty, referrals, catering
- ├── screens/owner/
- Dashboard, promotions, staff, campaigns, redemption, analytics
- ├── services/
- api, auth, location, push, map_launcher
- ├── models/ state/ widgets/
- Typed models, Provider state, shared UI
- └── theme.dart, config.dart
- Design tokens and environment config
- dealplateweb/app/
- Next.js admin console
- └── (dash)/
- dashboard, restaurants, users, admins, promotions, reviews, subscriptions, audit
Database Design
20 models and 6 enums in one PostgreSQL schema, evolved through more than ten incremental migrations rather than rewrites. The design leans on a strict one-to-one between an owner and their restaurant, a join model for staff, and denormalised rating fields where read performance matters more than perfect normalisation.
User
Identity for every role, keyed to a Firebase UID. Carries last known coordinates for geo-targeting and a lastSeenAt stamp for active-user metrics.
firebaseUid (unique)email (unique)rolelatitude / longitudereferralCodelastSeenAt
Restaurant
The owned business. One per owner, enforced by a unique ownerId. Indexed on lat/long, zip and city for proximity and lookup queries.
ownerId (unique)cuisinelatitude / longituderatingreviewCountisOpensuspendedphotos[]
Promotion
The core marketplace object — a categorised, badge-able offer with an expiry and an optional featured flag.
restaurantIdtypestatuscategoryflyerPdfUrlexpiry
Coupon
A claimed offer tied to both the issuing restaurant and the claiming user, redeemed in person by QR scan.
userIdrestaurantIdredeemed state
StaffMember
Join between a user and a restaurant with a scoped StaffRole — how a person works at a business they don't own.
userIdrestaurantIdrole (StaffRole)
Subscription / Plan
The commercial layer. A plan defines capabilities; a subscription binds one to a restaurant with a status.
restaurantId (unique)plan (SubscriptionPlan)status (SubscriptionStatus)
PushCampaign / DeviceToken
Campaign records on the restaurant side, FCM tokens on the user side — together they make geo-targeted push possible.
restaurantIduserIdtoken
LoyaltyEvent / Referral
Append-only retention records: a loyalty ledger per user, and a referral linking the inviter to the invited.
userIdreferrals_madereferral_received
CateringPackage / CateringRequest
Owner-published catering offerings and the diner-submitted requests against them.
restaurantIduserId
Review / Favorite / MenuItem
Consumer-side signals and owner-managed menu content. Review counts roll up onto the restaurant record.
userIdrestaurantIdrating
AuditLog
Record of privileged admin actions taken from the console — who suspended what, and when.
actoractiontarget
Notification
Per-user in-app notification records, with geo and expiry handling added in a later migration.
userIdgeoexpiry
API Flow
Every authenticated request follows the same path, so the auth boundary is one thing to reason about rather than one per controller.
Client obtains a Firebase ID token
The Flutter app signs in with email/password or Google Sign-In and holds a Firebase ID token; the admin console does the same through its own login route.
Request carries the token as a bearer credential
api_service attaches the token to outgoing HTTP calls from the app.
FirebaseAuthGuard verifies it server-side
Firebase Admin verifies the token signature and expiry on the API. Invalid or missing tokens are rejected before any handler runs.
@FirebaseUser() injects the resolved identity
A parameter decorator hands the verified user to the handler, so controllers never parse headers or trust client-supplied user IDs.
ValidationPipe enforces the DTO contract
A global pipe with whitelist and transform strips unknown properties and casts query and route params to their declared types before the service sees them.
Service applies business rules, Prisma executes
Domain logic lives in the service layer; Prisma is the only path to Postgres, keeping queries typed and the schema authoritative.
Challenges
Modelling one person as three different things
A user can be a diner, an owner of exactly one restaurant, and staff at several others — sometimes simultaneously. Collapsing that into a single role enum would have been wrong. The schema uses an optional one-to-one for ownership (unique ownerId), a StaffMember join model with its own role enum for employment, and treats the consumer surface as available to everyone. Getting this wrong early would have meant a painful migration later.
Making 'near me' a database question
Proximity search is easy to get wrong by pulling rows and filtering in application code, which stops scaling almost immediately. Restaurants carry latitude and longitude with a composite index, plus separate indexes on zip and city for the non-geographic lookups, so the common queries are answered by the database.
Keeping listings honest
A marketplace full of expired offers is worse than an empty one. Promotions carry real expiry semantics and scheduled jobs handle lapsing, rather than trusting owners to tidy up after themselves. Notifications gained geo and expiry handling in a later migration for the same reason.
Turning a paper flyer into structured data
Owners' existing marketing is a photograph, not a form. Free-text extraction from a vision model produces output that's plausible but inconsistent, so the AI module constrains Claude to a fixed JSON schema with an enum of categories kept in sync with the app's own list. It also fails closed with an explicit 503 when unconfigured, so a missing API key degrades one feature instead of breaking promotion creation.
Evolving the schema without rewriting it
The schema grew from an initial migration through marketplace restructuring, featured promotions, menus and campaigns, flyer PDFs, notification geo/expiry, restaurant suspension, last-seen tracking and coupons — each as its own migration. Slower than editing schema.prisma and resetting, but it meant the database was never thrown away and the change history is legible.
A runtime that didn't have what a dependency assumed
@nestjs/schedule expects a global crypto, which Node 18 doesn't expose. The bootstrap file polyfills it from node:crypto before the app is created — a small fix, but the kind that's invisible until scheduled jobs silently fail to register.
Learnings
Design the relational model before writing endpoints. Nearly every awkward moment later traced back to a relationship modelled loosely at the start.
Incremental migrations are a discipline worth the friction. Ten small migrations tell a story; one rewritten schema tells you nothing.
A guard plus a parameter decorator is a better auth boundary than a helper called in every controller — it's enforced by structure rather than by remembering.
Constrain model output with a schema rather than parsing prose. The value of an LLM here was structured extraction, not generation.
Features that touch money or trust — subscriptions, redemption, suspension, audit logs — need to exist earlier than they feel necessary.
Sharing one Flutter codebase across two genuinely different products works, but only with a hard routing gate at the root and separate shells beneath it.
Future Improvements
Move proximity search to PostGIS so distance ranking and radius filtering happen properly in the database rather than approximately.
Add an automated test suite around the promotion lifecycle and coupon redemption — the paths where correctness actually matters.
Introduce background job processing for push campaign fan-out so large sends don't run inside a request.
Cache the hot browse queries; the same nearby-restaurant results are recomputed far more often than they change.
Add rate limiting and abuse controls around coupon claiming and review submission.
Ship analytics beyond the current owner dashboard — redemption rates per promotion would tell owners which offers are actually working.