How I built a system to upload restaurant menus and generate QR codes that never change
Tags: node, aws, heroku, vercel
Published: 2020-05-28 20:09:43

I built this prototype to help restaurant avoid handling physical menus to their clients during the COVID-19 pandemic situation which would need to be sanitised afterwards.
QR codes that redirects to the online PDF menu can be printed once as they never change and clients can scan them with their smartphones while sitting at the table.
NB: I was targeting the Italian market so the UI is all in Italian.

First steps

1. Bought the domain https://menu-qr.tech/ from [Vercel](https://vercel.com) (formerly Zeit) where I could easily manage DNS and connect the frontend repo for automatic deployments

1. Created a new project on Heroku where I got a node dyno and a Postgres database connection, all on the free tier.

1. Created a bucket on AWS S3 named `view.menu-qr.tech`, configured it to be public accessible as that's where I would upload the menus and put Cloudfront in front of it to have a CDN for faster loads. I've also added the relevant DNS CNAME records to Vercel in order to associate the subdomain with the Cloudfront distribution.

1. I initially thought about adding Stripe for paid subscriptions so I've registered, got my development key and verified myself.

1. Registered a new project with Auth0 to handle the passwordless authentication.

1. Registered and connected the domain with Mailgun in order to send transactional and authentication emails.

## How does it work?
The user once authenticated can upload a menu, at this point a few things happen:
- the PDF menu is uploaded on S3, I put a timestamp on it in order to avoid overwriting existing menus as I want them to be immutable but still keep track of the file name as it can be handy.
- a new `Upload` entry is created in the DB, generating a UUID and saving the S3 url and the path where the file is located plus other info.
- a QR code is generated on demand, pointing at the url `https://view.menu-qr.tech/?id={{UUID}}` that will never change for this menu

At that point a customer can scan that QR code which will point to the `view.menu-qr.tech/?id={{UUID}}` page that will show a loading spinner and make a GET request to the API to fetch the correct URL where the PDF menu can be viewed, using the Cloudfront CDN url rather than S3.

The restaurant owner can go and update the menu anytime on the dashboard, making a new upload that will update the S3 url reference on the DB, allowing the final customer to view the updated menu still using the old QR code (no need to print it again).

The project involved 3 repos:

## Web app (https://menu-qr.tech/)
{% github mtt87/menu-qr-web no-readme %}
It's a SPA built with `create-react-app`, using:
- [Auth0](https://github.com/auth0/auth0-spa-js) to handle passwordless authentication
- [Rebass](https://rebassjs.org/) for the UI primitives with a custom basic theme.
- [SWR](https://swr.now.sh/) for data fetching 
Once the user is logged in they can see their dashboard where they can create a restaurant and upload a menu.
I connected this repo to Vercel so every time I pushed the code to `master` it automatically built and deployed the latest version.
I used `react-icons` and https://undraw.co/illustrations to make it nicer.

## Server (https://api.menu-qr.tech/)
{% github mtt87/menu-qr-server no-readme %}
Built with node using express, where I defined all the routes for CRUD operations, persisting data on a Postgres database using Sequelize as ORM to be quicker.
The server is also handling all the image uploading to S3 using `multer`, here is a snippet of how it's done
```js
const fileSize = 1024 * 1024 * 5; // 5mb

const upload = multer({
  limits: {
    fileSize,
  },
  fileFilter: (req, file, callback) => {
    const ext = path.extname(file.originalname);
    if (ext !== '.png' && ext !== '.jpg' && ext !== '.pdf' && ext !== '.jpeg') {
      callback(new Error('Only PDF or images'));
      return;
    }
    callback(null, true);
  },
  storage: multerS3({
    s3,
    bucket: 'view.menu-qr.tech',
    acl: 'public-read',
    contentType: multerS3.AUTO_CONTENT_TYPE,
    key: (req, file, cb) => {
      // append timestamp to avoid overwriting
      cb(null, `${file.originalname}_${Date.now()}`);
    },
  }),
});
```

I like Sequelize as it can make your life easier in these small projects, here is where I defined the tables and associations
```js
const db = {
  Sequelize,
  sequelizeInstance,
  User: sequelizeInstance.import('./User.js'),
  Restaurant: sequelizeInstance.import('./Restaurant.js'),
  Upload: sequelizeInstance.import('./Upload.js'),
};

db.User.hasMany(db.Restaurant);
db.Restaurant.belongsTo(db.User);
db.Restaurant.hasMany(db.Upload);
db.Upload.belongsTo(db.Restaurant);

module.exports = db;
```

Then you can easily load a user restaurant's and the their uploads
```js
const data = await db.User.findByPk(userId, {
    include: [
      {
        model: db.Restaurant,
        include: db.Upload,
      },
    ],
});
```

I've used `qrcode` package to generate QR codes on demand which is nice because it supports streams, no need to save/read data on the disk.
```js
app.get('/view-qr/:uploadId', async (req, res) => {
  const { uploadId } = req.params;
  const url = `https://view.menu-qr.tech/?id=${uploadId}`;
  QRCode.toFileStream(res, url, {
    width: 512,
    margin: 0,
    color: {
      dark: '#000',
      light: '#fff',
    },
  });
});
```

There is already Stripe built in supporting subscriptions management and handling webhooks for client-side checkout events, and also the logic to give users a trial period and expire with cron jobs.

## Menu loader page (https://view.menu-qr.tech/)
{% github mtt87/menu-qr-view no-readme %}
This is a simple `index.html` page that is used to show a spinner and redirect the user to the menu or show an error message.
It's being deployed at `https://view.menu-qr.tech/?id=` automatically with Vercel, here is the simple configuration and the page code.
`vercel.json`
```json
{
  "version": 2,
  "routes": [{ "src": "/(.*)", "dest": "/index.html" }]
}
```
`index.html`
```html
<html lang="en">
  <title>Caricamento</title>
  <link
    rel="stylesheet"
    href="https://cdnjs.cloudflare.com/ajax/libs/css-spinning-spinners/1.1.1/load8.css"
  />
  <style>
    html,
    body {
      font-family: sans-serif;
    }
  </style>
  <body>
    <div id="root" style="padding: 24px; text-align: center;">
      <div class="loading" />
    </div>
    <script>
      const urlParams = new URLSearchParams(window.location.search);
      const id = urlParams.get('id');
      fetch(`https://api.menu-qr.tech/view/${id}`)
        .then((res) => {
          if (res.status === 403) {
            document.getElementById('root').innerHTML = 'Subscription expired';
            return;
          }
          if (res.ok) {
            res.json().then((json) => window.location.replace(json.url));
            return;
          }
          throw new Error('fail');
        })
        .catch(
          () =>
            (document.getElementById('root').innerHTML = 'Error loading'),
        );
    </script>
  </body>
</html>
```

Right after building this I realised there are already solutions that are more complete and supported by existing companies so I decided to stop the project and open-source it.

It was a good exercise and I hope it can be useful for others.

Thanks for reading 😀