PS
All projects
In Progress2026

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.

FlutterNestJSPostgreSQLPrismaFirebaseNext.jsClaude API

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.

  1. 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 AuthFCM
  2. Admin 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.jsReactTypeScript
  3. NestJS 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-validator
  4. PostgreSQL + 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 18Prisma
  5. Claude 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)
  • role
  • latitude / longitude
  • referralCode
  • lastSeenAt

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)
  • cuisine
  • latitude / longitude
  • rating
  • reviewCount
  • isOpen
  • suspended
  • photos[]

Promotion

The core marketplace object — a categorised, badge-able offer with an expiry and an optional featured flag.

  • restaurantId
  • type
  • status
  • category
  • flyerPdfUrl
  • expiry

Coupon

A claimed offer tied to both the issuing restaurant and the claiming user, redeemed in person by QR scan.

  • userId
  • restaurantId
  • redeemed state

StaffMember

Join between a user and a restaurant with a scoped StaffRole — how a person works at a business they don't own.

  • userId
  • restaurantId
  • role (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.

  • restaurantId
  • userId
  • token

LoyaltyEvent / Referral

Append-only retention records: a loyalty ledger per user, and a referral linking the inviter to the invited.

  • userId
  • referrals_made
  • referral_received

CateringPackage / CateringRequest

Owner-published catering offerings and the diner-submitted requests against them.

  • restaurantId
  • userId

Review / Favorite / MenuItem

Consumer-side signals and owner-managed menu content. Review counts roll up onto the restaurant record.

  • userId
  • restaurantId
  • rating

AuditLog

Record of privileged admin actions taken from the console — who suspended what, and when.

  • actor
  • action
  • target

Notification

Per-user in-app notification records, with geo and expiry handling added in a later migration.

  • userId
  • geo
  • expiry

API Flow

Every authenticated request follows the same path, so the auth boundary is one thing to reason about rather than one per controller.

  1. 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.

  2. Request carries the token as a bearer credential

    api_service attaches the token to outgoing HTTP calls from the app.

  3. 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.

  4. @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.

  5. 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.

  6. 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.